10.1 浮点确定性与跨端一致性
9.x 把变换、四元数、曲线都补齐了,helio 的数学层看起来已经能用了。但有一个问题一直悬着没碰:同一份浮点代码,在不同平台上跑出来的结果可能不一样。
对单机游戏,这无所谓——玩家自己机器上的画面和物理是自洽的。可一旦涉及回放、联机同步、服务器校验,它就是致命的:两台机器各算各的,位置只要差一点点,几百帧后就天各一方,服务器和客户端对不上账。
这一节不写引擎功能,只做一件事:把「浮点为什么不可复现」从一句人云亦云的话,变成一组你在本机就能复跑、能断言的数字。定点数怎么用它换确定性,放在 10.2。
本章与站内既有内容的分工
站内已经有文章讲过游戏数学与引擎组织,先把边界划清楚,避免重复:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-math-foundations/ | 向量、矩阵、四元数、投影的数学推导与公式 | 假定公式都对,只追问「同样的公式在不同平台上算出同样的数吗」 |
/posts/game/engine/ | 引擎选型与入门,Unity / Unreal / Defold 的取舍 | 不比较引擎,只实现 helio 自己的确定性工具 |
/defold-ecs-architecture/ | 引擎子系统的组织方式与 ECS 视角 | 不碰 ECS,只处理单次浮点运算的结果 |
一句话:别人讲数学「是什么」,本章讲浮点结果「为什么对不上、怎么测出来」。 确定性模拟与定点数在站内是空白话题,所以这一章承担的是「从零补一块地基」。
浮点不可复现的三条来源
浮点数不是「近似实数」,它是一套有明确规则的离散表示。问题出在规则本身有几处「实现自由」。
来源一:加法不满足结合律
小学就学过的 a + b + c,在浮点里换个括号就换个结果。因为每一次加法都要把结果舍入到能表示的最近值,而舍入会丢掉低位信息。三次加法的舍入顺序不同,丢掉的信息就不同。
本机(Node 26.5.0)跑出来的真实数字:
| 表达式 | 结果 | 与右式之差 |
|---|---|---|
(0.1 + 0.2) + 0.3 | 0.6000000000000001 | 1.1102230246251565e-16 |
0.1 + (0.2 + 0.3) | 0.6 | — |
(1e16 + -1e16) + 1 | 1 | 1 |
1e16 + (-1e16 + 1) | 0 | — |
第一组差了 1.1e-16,看着微不足道;第二组直接差了整整 1——因为 1e16 大到「加上 1 也还是 1e16」,那个 1 被整个吃掉了,而先加 1 再抵消就留下了它。这就是「大数吃小数」。
对引擎的意义:编译器、向量化、并行归约都可能改变加法的分组顺序。sum(xs) 在不同平台上按不同顺序累加,就会得到不同的和。
来源二:FMA 改变了舍入时机
现代 CPU 有 FMA(fused multiply-add,融合乘加)指令,a * b + c 只舍入一次。没有 FMA 的平台则要先把 a * b 舍入一次,再加 c 舍入一次——两次舍入。同一条源码,两种指令序列,结果可能不同。
本机实测:取 a = 1.0000000000000002、b = 1.0000000000000002、c = -1,
- 没有 FMA(先舍乘积再相加):
Math.fround(Math.fround(a * b) + c)得到0; - 有 FMA(乘积保持精度、加完再舍):
Math.fround(a * b + c)得到4.440892098500626e-16。
同一个公式,一个平台算出 0,另一个算出 4.4e-16。这在「判断两个量是否相等」的地方会直接改变分支走向。
来源三:单精度与双精度的位宽差异
float(32 位)只有约 7 位有效十进制数字,double(64 位)有约 15 位。同一段代码,一边用 32 位、一边用 64 位,误差会在迭代中被放大。
JavaScript 的 number 永远是 64 位双精度,但 Math.fround 能把任意数舍入到最近的 32 位浮点值,用它就能在双精度环境里模拟「只有单精度」的平台。本机实测:
- 双精度的
0.1是0.10000000000000000555; Math.fround(0.1)是0.10000000149011612;- 两者不相等——
0.1这个数在单精度里的最近值,和双精度里的最近值,压根不是同一个。
把 v += a * dt 这样的积分跑 10 万步,单精度与双精度的最终值差到 0.2(见下节实测表)。位宽差异不是「精度少一点点」,而是「跑得越久差得越多」。
顺带一提:x87 的扩展精度
老一代 x86 的 x87 浮点单元内部用 80 位扩展精度做中间运算,只在写回内存时才舍到 64 位。于是「中间结果留在寄存器里」和「中间结果存进内存再读回」会得到不同答案——取决于编译器有没有溢出寄存器。现代 64 位平台普遍改用 SSE/AVX 的 64 位运算,这条来源已经基本退出历史舞台,但它是「浮点结果依赖编译器和平台」这一事实最早的注脚。
可运行产物:把「不可复现」变成断言
光看现象不够,我们要把它固化成能复跑、能断言的代码。下面是 helio 内核里 src/math/float-determinism.ts 的完整实现——它不做业务,只把上面三条来源变成可调用的函数。层级 L1,Node 直跑。
// file: src/math/float-determinism.ts
// tier: L1
// verify: npx vitest run src/math/float-determinism.test.ts
//
// helio 内核:浮点确定性的实验工具。
// 这些函数不做业务,只把「浮点结果依赖顺序 / 依赖位宽」这件事
// 变成可断言的事实。
/** 从左到右累加——浮点加法的默认顺序。 */
export function sumLeftToRight(xs: readonly number[]): number {
let s = 0;
for (let i = 0; i < xs.length; i++) s += xs[i];
return s;
}
/** 从右到左累加:只换了遍历方向,公式一个字没改。 */
export function sumRightToLeft(xs: readonly number[]): number {
let s = 0;
for (let i = xs.length - 1; i >= 0; i--) s += xs[i];
return s;
}
/** Kahan 补偿求和:把被大数吃掉的低位误差单独攒起来。 */
export function kahanSum(xs: readonly number[]): number {
let sum = 0;
let c = 0;
for (let i = 0; i < xs.length; i++) {
const y = xs[i] - c;
const t = sum + y;
c = t - sum - y;
sum = t;
}
return sum;
}
/** 每步都舍入到单精度,模拟「浮点只有 32 位」的平台。 */
export function integrateF32(v0: number, a: number, dt: number, steps: number): number {
let v = Math.fround(v0);
const step = Math.fround(a * dt);
for (let i = 0; i < steps; i++) {
v = Math.fround(v + step);
}
return v;
}
/** 同样的积分,全程双精度。 */
export function integrateF64(v0: number, a: number, dt: number, steps: number): number {
let v = v0;
for (let i = 0; i < steps; i++) v += a * dt;
return v;
}
/** 乘积先舍到 f32 再相加——没有 FMA 的平台。 */
export function mulAddNoFma(a: number, b: number, c: number): number {
return Math.fround(Math.fround(a * b) + c);
}
/** 乘积保持精度、加完再舍——有 FMA 的平台。 */
export function mulAddFused(a: number, b: number, c: number): number {
return Math.fround(a * b + c);
}
配套的单测 src/math/float-determinism.test.ts 把三条来源都钉成断言。注意它断言的是具体数值——一旦某个平台的浮点行为变了,测试立刻红,这正是我们要的效果。
// file: src/math/float-determinism.test.ts
// tier: L1
// verify: npx vitest run src/math/float-determinism.test.ts
import { describe, expect, it } from 'vitest';
import {
integrateF32,
integrateF64,
kahanSum,
mulAddFused,
mulAddNoFma,
sumLeftToRight,
sumRightToLeft,
} from './float-determinism.ts';
describe('浮点加法不满足结合律', () => {
it('(0.1+0.2)+0.3 与 0.1+(0.2+0.3) 不相等', () => {
const l = 0.1 + 0.2 + 0.3;
const r = 0.1 + (0.2 + 0.3);
expect(l).not.toBe(r);
expect(l).toBe(0.6000000000000001);
expect(r).toBe(0.6);
});
it('换遍历方向就能改结果', () => {
const xs = [1e16, -1e16, 1];
expect(sumLeftToRight(xs)).toBe(1);
expect(sumRightToLeft(xs)).toBe(0);
});
it('Kahan 求和与方向无关', () => {
expect(kahanSum([1e16, -1e16, 1])).toBe(1);
});
});
describe('单精度与双精度会分道扬镳', () => {
it('100000 步积分后 f32 与 f64 差了 0.2', () => {
const f64 = integrateF64(0, 0.1, 1 / 60, 100_000);
const f32 = integrateF32(0, 0.1, 1 / 60, 100_000);
expect(f64).toBe(166.66666666653134);
expect(f32).toBe(166.46539306640625);
expect(Math.abs(f64 - f32)).toBeGreaterThan(0.2);
});
});
describe('FMA 改变舍入时机', () => {
it('同输入下有无 FMA 结果不同', () => {
const a = 1.0000000000000002;
const b = 1.0000000000000002;
expect(mulAddNoFma(a, b, -1)).toBe(0);
expect(mulAddFused(a, b, -1)).toBe(4.440892098500626e-16);
});
});
本机实测:npx vitest run src/math/float-determinism.test.ts,5 个用例全部通过。Kahan 求和那一组尤其值得看——[1e16, -1e16, 1] 这个朴素求和会因为顺序得到 1 或 0,而 Kahan 补偿求和稳定给出 1,因为它把「被吃掉的低位」单独记在一个补偿项 c 里,最后再补回来。
实测:同一段积分的三条岔路
把上面这些现象放到一段更接近游戏的积分里,差距会变得直观。基准脚本 bench/float-order.mjs(B 级)跑 v = v + a * dt,分别在双精度、单精度下迭代,并单独打印非结合性与 FMA 的数字。
// file: bench/float-order.mjs
// tier: B
// verify: node bench/float-order.mjs
//
// 打印三组真实数字:
// 1) 加法的非结合性(同三个数,换括号就换结果)
// 2) 同一段积分,f32 与 f64 的最终值差多少
// 3) 同输入下有无 FMA 的结果差
const f = Math.fround;
// 1) 非结合性
const triples = [
[0.1, 0.2, 0.3],
[1e16, -1e16, 1],
[1, 1e16, -1e16],
];
for (const [a, b, c] of triples) {
const l = (a + b) + c;
const r = a + (b + c);
console.log(`(${a}+${b})+${c} = ${l} ${a}+(${b}+${c}) = ${r} equal=${l === r}`);
}
// 2) 积分漂移:v = v + a*dt,跑 100000 步
function integrate(round, v0, a, dt, steps) {
let v = round(v0);
const step = round(a * dt);
for (let i = 0; i < steps; i++) v = round(v + step);
return v;
}
const id = (x) => x;
for (const steps of [10, 1000, 100000]) {
const d = integrate(id, 0, 0.1, 1 / 60, steps);
const s = integrate(f, 0, 0.1, 1 / 60, steps);
console.log(`steps=${steps} f64=${d} f32=${s} delta=${d - s}`);
}
// 3) FMA
const a = 1.0000000000000002;
const b = 1.0000000000000002;
console.log(`无 FMA: f(f(a*b)+c) = ${f(f(a * b) + -1)}`);
console.log(`有 FMA: f(a*b+c) = ${f(a * b + -1)}`);
node bench/float-order.mjs 本机输出如下。这些是单机实测值,绝对数值不可移植,请关注量级与趋势,不要记小数点。
(0.1+0.2)+0.3 = 0.6000000000000001 0.1+(0.2+0.3) = 0.6 equal=false
(10000000000000000+-10000000000000000)+1 = 1 10000000000000000+(-10000000000000000+1) = 0 equal=false
(1+10000000000000000)+-10000000000000000 = 0 1+(10000000000000000+-10000000000000000) = 1 equal=false
steps=10 f64=0.016666666666666666 f32=0.01666666753590107 delta=-8.692344032057431e-10
steps=1000 f64=1.6666666666666916 f32=1.6666651964187622 delta=0.000001470247929402646
steps=100000 f64=166.66666666653134 f32=166.46539306640625 delta=0.20127360012509143
无 FMA: f(f(a*b)+c) = 0
有 FMA: f(a*b+c) = 4.440892098500626e-16
把积分那三行单独拎出来看:
| 步数 | 双精度结果 | 单精度结果 | 差值 |
|---|---|---|---|
| 10 | 0.016666666666666666 | 0.01666666753590107 | -8.7e-10 |
| 1000 | 1.6666666666666916 | 1.6666651964187622 | 1.47e-6 |
| 100000 | 166.66666666653134 | 166.46539306640625 | 0.2013 |
误差随步数放大。 10 步时差 8.7e-10,几乎可忽略;1000 步时差 1.5e-6;到 10 万步已经差了 0.2。原因很朴素:每一步的单精度舍入都留下一点点残差,而积分是累加——残差不会抵消,只会一层层叠上去。这就是为什么「只在最后比较两个浮点位置」的联机方案一定会炸:误差是随时间线性甚至更快增长的。
这对引擎意味着什么
把三条来源合起来,helio 得出三条工程结论:
- 需要跨端一致的量,绝不能用浮点累加。 物理位置、速度、能量累积,只要参与跨端比对,就必须换成整数运算(10.2 的定点数)或定长步的整数累加。
- 需要跨端一致的比较,绝不能用
===。 浮点相等判断要用 epsilon(10.3),而且 epsilon 的尺度要随量级缩放,不能用固定的绝对值。 - 归约顺序要固定。 求和一个数组时,谁先加谁后加、是否并行归约,都要写死。
sumLeftToRight和sumRightToLeft在同一台机器上都不同,更别说跨平台了。
一句话:确定性的敌人不是「浮点不精确」,而是「浮点不精确的方式因平台而异」。 精度损失本身可以容忍,不可复现才致命。
常见坑
- 以为「同一份源码 = 同样的结果」。 编译器能否用 FMA、会不会重排加法、向量宽度是多少,都可能不同。源码相同不代表机器码相同。
- 用
a === b比较浮点。 即使两条路径在数学上等价,浮点里也常常不相等。0.1 + 0.2 === 0.3就是false。 - 用固定绝对 epsilon。 比较
1e8和1e8 + 1时,绝对 epsilon 要么太松要么太紧;要用随量级缩放的相对 epsilon(10.3)。 - 只在最后比较一次结果。 误差是累积的,10 步看不出来、10 万步就翻天。要么每步都比对(暴露得早),要么从一开始就用确定性表示。
- 以为
Math.fround是「降低精度」。 它做的是「舍入到最近的 32 位浮点值」,用来模拟单精度平台,不是随手丢精度。 - 把 Kahan 求和当成万能药。 它让求和与顺序无关,但代价是每步多几次运算,而且它只解决「累加」,解决不了乘法与 FMA 的差异。
浮点这条路走到这里,结论很清楚:要确定性,就得离开浮点。 下一节把整数运算请回来——定点数用「把小数点钉死」的办法,让同一份代码在任何平台上逐位一致。
阅读导航:上一节:目录 · 下一节:10.2 定点数:为确定性模拟准备 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。