为什么浮点数在几何确定性中失效:整数算术的必要性
在计算几何的世界里,一个比特的差异可能意味着操作成功与灾难性失败之间的区别。对于许多开发者来说,一个假设是:只要输入相同,无论在什么机器上运行,相同的代码都会产生相同的结果。然而,正如 exact-poly 的作者所发现的那样,当涉及浮点数时,情况并不总是如此。
在调试多边形重叠测试时,作者发现一个判断顶点是凸顶点还是凹顶点的函数在本地运行正常,但在服务器上却失败了。罪魁祸首不是逻辑错误,而是不同架构处理浮点运算的方式。在 x86 上,编译器使用 Fused Multiply-Add (FMA) 来减少舍入步骤;而在 WASM 上则不然。这种舍入上的微小差异导致靠近零的顶点符号发生翻转,从而引发多米诺效应,彻底改变了多边形的分解结果。
IEEE 754 可复现性的幻象
许多开发者认为 IEEE 754 兼容性可以确保行为的一致性。实际上,该标准主要定义了浮点数如何存储,而不是它们在不同执行环境下的行为。几种机制可能会破坏可复现性:
- 中间寄存器: x87 FPU 通常以 80 位精度保留数值,仅在溢出到内存时才进行舍入,而 ARM 或 WASM 可能不会。
- Fused Multiply-Add (FMA):
fma(a, b, c)通过单个舍入步骤执行乘法和加法,产生的比单独操作更精确但结果不同的结果。 - 重结合律 (Reassociation): 使用
-ffast-math等标志的编译器可能会将(a + b) + c重写为a + (b + c),从而改变舍入顺序。 - Denormals: “清零” (flush-to-zero) 标志可能会根据进程静默翻转,从而改变次正规数 (subnormal numbers) 的处理方式。
正如一位评论者所指出的,虽然像 JVM (通过 strictfp 或 StrictMath) 这样的环境试图保证严格的可复现性,但浮点算术的本质仍然是一种近似。
对于需要绝对确定性的应用——例如锁步 (lockstep) 游戏模拟或 ZK 证明生成 (ZK witness generation)——这些近似是不够的。
凸分解中的“一位比特”问题
在几何学中,cross_sign(A, B, C) 函数是决策的主要信号。它决定了一个转向是左转、右转还是共线。当三个点几乎共线时,一台机器可能会返回 +1e-12,而另一台则返回 -2e-13。
这种符号翻转至关重要,因为凸分解是一个离散过程。一个顶点要么是凹的,要么不是。如果符号翻转,就会识别出不同的凹顶点,从而导致不同的起始切割点,并产生完全不同的分解图。使用 epsilon (例如,“在比较前舍入到微米级”) 并不能解决这个问题,因为任何 epsilon 区域最终都会捕获真实的几何形状并导致行为分叉。
解决方案:精确整数算术
为了在 x86、ARM 和 WASM 之间实现比特级的一致性,exact-poly 完全放弃了浮点数,转而使用整数。通过将坐标映射到固定比例 (例如,在测地学中,1 单位 = 1 微米),该库确保了叉积的符号是一个位逻辑问题,而不是近似问题。
管理整数预算
整数算术需要仔细选择比例。利用 i64::MAX (大约 $9.2 \times 10^{18}$),开发者必须在测量单位与最大坐标大小之间取得平衡。对于测地学,使用 $10^6$ 的比例可以实现微米级精度,同时在覆盖整个地球周长时仍有充足的余量。
使用 i128 防止溢出
叉积公式——$(bx - ax)(cy - ay) - (by - ay)(cx - ax)$——很容易超过 i64 的限制。为了防止这种情况,exact-poly 采用了一条严格的规则:坐标以 i64 存储,但任何乘法都在 i128 中进行。
至关重要的是,该库在减法之前扩大数值:(bx as i128) - (ax as i128)。如果先在 i64 中进行减法,可能会导致溢出,即使存储在 i128 容器中也会产生错误数据。
实现鲁棒的分解级联机制
由于没有任何一种凸分解算法对所有输入都完美,exact-poly 使用了算法的“级联”模式。如果第一个失败,下一个就会接管:
- ExactPartition: 一个优先考虑凹顶点的贪婪划分器。
- Bayazit: 一种可以插入 Steiner points (中点顶点) 的递归方法。
- EarClip + Hertel-Mehlhorn: 先进行三角剖分,然后通过合并步骤来减少部件数量。
如果这三种方法都失败,该库会旋转环 (改变起始顶点) 并重试。这种启发式方法之所以有效,是因为病态案例通常与特定的顶点遍历顺序有关。
处理离散数学的边缘情况
转向整数会引入连续浮点数学中不存在的新挑战:
- 中点精度: 简单的整数除法
(a + b) / 2会丢弃低位比特。该库使用round_div2来确保点不会发生漂移。 - Steiner Point Snapping: 如果中点距离现有顶点太近,必须对其进行微调,以防止意外的共线。
- Bookkeeping: 合成顶点必须进行标记,以确保最终输出的顶点数与输入一致,从而防止下游验证失败。
结论:共识重于精度
浮点数是为精度设计的,但整数算术是为共识设计的。当两个不同的进程必须得出完全相同的答案时,如果舍入方向不同,浮点的精度就变得无关紧要了。通过利用 i128 进行中间计算并完全避免浮点数,exact-poly 为几何操作提供了一个确定性的基础,这在任何支持 64 位整数的架构上都能保证是一致的的。