-
Notifications
You must be signed in to change notification settings - Fork 0
BitExact FMA Bug Hunt
A write-up of a real cross-platform determinism bug hunt in poncelet, a C++ exterior/terminal-ballistics library.
poncelet has a mode called Determinism::BitExact. The promise is simple to
state and unusually hard to deliver: run the same simulation on Windows,
Linux, and macOS, in Debug or Release, with MSVC, GCC, or Clang, and get
back the exact same bits — not "close enough," not "matches to six
decimal places," the same 64-bit hash of the entire simulation state, every
time. That's what you want for rollback netcode and server-authoritative
hit detection: if two machines can silently disagree about where a bullet
is, they can silently disagree about who died.
Building that mode meant swapping the physics core from double to a
custom Q32.32 fixed-point type for everything that mattered, replacing
sin/cos/acos/exp/pow with hand-built lookup tables, and writing a
bit-for-bit deterministic integer square root. That part went fine. It was
covered by 138 unit tests, cross-checked against the double path,
and looked done.
Then poncelet got pushed to GitHub for the first time, and its 3-OS CI matrix ran for real. That's when the actual test started.
The golden test (poncelet_bitexact_golden) runs five shots — different
tiers, different precision flags — for 400 frames each under BitExact,
folds every frame's state hash into one running digest, and compares it
against a committed constant. Windows and Linux matched it immediately.
macOS did not:
bitexact-golden: digest = 0f68a05d18074da9 golden = d22e42a57778d225 MISMATCH
Same code, same compiler flags file, same everything — except the OS. And
critically: GitHub's macos-latest runners are Apple Silicon. ARM64. Unlike
x86-64, ARM64 has fused multiply-add (FMA) as part of its baseline
instruction set, not an optional extension you can just turn off with a
target flag. That's a real, well-known source of cross-platform floating-
point divergence: a*b + c*d computed as two separate rounding steps gives
a (very slightly) different last bit than computed as one fused instruction
with a single rounding step at the end. Every C/C++ compiler has a flag for
this — -ffp-contract=off — specifically because the ambient default
("fast," i.e. fuse when it's profitable) is allowed to silently make this
choice for you.
One obviously-suspicious line jumped out: the drag-table interpolation code
did a + (b - a) * f — a textbook a*b + c*d shape — in a file that had
never been told -ffp-contract=off. Looked like the answer. Fixed it,
verified locally (all tests green, digest unchanged on Windows as
expected, since x86-64 wasn't the platform at risk), pushed it as a fix
with an honest caveat in the commit message that it wasn't yet re-
confirmed on real CI.
CI ran again. macOS produced:
bitexact-golden: digest = 0f68a05d18074da9 golden = d22e42a57778d225 MISMATCH
Same digest. Byte for byte. Not "still mismatched" — the exact same wrong number, down to the last bit, as before the fix.
That's not a maybe. If the fix had touched the actual divergent computation — even if it hadn't fully closed the gap — the resulting digest would have changed, because the whole point of a 64-bit hash over 400 frames of physics is that literally any different bit anywhere cascades into a completely different final number. An unchanged digest after a real code change is proof, not a hint, that the change never executed on the path that actually produces the divergence.
This became the one rule that mattered for the rest of the hunt: never trust a fix until a re-run's digest either changes (if it's still wrong) or matches (if it's right). A repeated wrong digest means you fixed the wrong thing.
The golden digest folds five shots into one number. That's great for a
gate, useless for a diagnosis — it tells you that something's wrong, not
which of the five shots, let alone which frame or which field. So before
guessing again, the next commit was pure tooling: Sim::stateHash(id)
already existed for a single shot; a new --bitexact-per-shot bench mode
folded and printed all five independently.
shot0 Integrated/None digest = 64fec260b058a6ff
shot1 AnalyticDrag/None digest = 20994cd3bf1ba7db
shot2 Integrated/SixDOF digest = 8988746533071099
shot3 Integrated/SpinDrift+Coriolis digest = 40ba282758ff58dc
shot4 Integrated/None(arrow) digest = 3b6f6d19f87f3191
Windows and Linux agreed on all five. macOS disagreed on exactly two: shot2 (6-DOF rigid body) and shot3 (spin drift + Coriolis force) — the only two of the five that use a cross product or a quaternion multiply with genuinely non-trivial operands. That's the shape of a real signal: not "everything's broken," a precise, explainable subset.
Tracing shot2 specifically found it: the 6-DOF roll angle integrates
exactly in fixed-point the whole flight, but the code that turned that
angle into a display quaternion called plain std::cos/std::sin
unconditionally — no fixed-point branch at all. Different platforms'
libm aren't required to agree on the last bit of sin/cos for a
general input (unlike + - * / sqrt, which IEEE-754 does mandate
correctly-rounded, identical-everywhere results for). That's a second,
independent bug, unrelated to FMA.
Fixed it — added periodic-range-reduction fixed-point sin/cos built on
the existing [0, π] lookup table — and pushed. This time CI came back
with:
bitexact-golden: digest = 1480dbd5c41c8890 golden = 21525237f2e246b2 MISMATCH
A different wrong number. Still failing, but a different failure — which is exactly the signal that says "this fix did something real," as opposed to the silent no-op from before. Genuine progress, not yet done.
Per-shot digests still only say that shot2 and shot3 diverge over 400 frames, not when. Next tool: print the raw, un-folded per-frame hash for the first several dozen frames, diffed directly against a reference run.
For shot3, the result was oddly clean: frames 0 through 5 matched Windows
bit-for-bit. Frame 6 didn't. That's not "everything's slightly wrong" —
that's a single specific computation, at a single specific point, finally
tipping something over a rounding boundary. Dumping every individual field
of the projectile's state at that frame (position, velocity, spin,
orientation, flags, all of it) showed position and velocity matching
exactly — and distanceTravelled_m off by exactly one bit in the last
place. That field accumulates via length(a - b), i.e. a dot product of
three squared terms — real a*a + b*b + c*c fusion bait, on real nonzero
numbers this time (not the earlier drag-table false start).
The fix that had already been applied — -ffp-contract=off on the file —
should have prevented exactly this. It didn't, and only in the Debug build
config, only on macOS. Release was fine. That's a genuinely surprising,
narrow toolchain gap: Apple Clang's unoptimized (-O0) code generation
path apparently doesn't honor that flag as reliably as its optimized path
does. And ARM64, unlike x86, has no -mno-fma-style escape hatch — FMA
isn't an optional extension there, it's baseline.
The actual, portable fix had to happen at the source level: route the
critical multiply through a volatile local variable.
Real dist_no_fma(Vec3 a, Vec3 b) {
const volatile Real dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
const volatile Real xx = dx * dx, yy = dy * dy, zz = dz * dz;
const Real sum = xx + yy + zz;
return std::sqrt(sum);
}A volatile write can never be optimized away or folded into an adjacent
operation — it forces the value to actually round and materialize before
the next step runs. It's a compiler-flag-proof, optimization-level-proof,
architecture-proof way to say "these two operations are not allowed to
fuse," and it's the one guarantee that held up when a command-line flag
didn't.
Shot2's remaining gap turned out to be the same class of bug — a quaternion
multiply, quat_from_to(...) * Quat{rollCos, rollSin, 0, 0}, feeding the
orientation stored in the hashed state. The obvious fix was to wrap that
multiply the same volatile way. Pushed it, waited for CI:
shot2 digest = 687466ca61461706 (identical to before the fix)
Same lesson, same tell, a second time in this same hunt: unchanged digest,
wrong fix. The quaternion multiply wasn't the culprit — its OWN internal
normalization step was: quat_from_to() calls normalized() on its inputs
and its output, and that function's w*w + x*x + y*y + z*z sum is the
actual a*a+b*b+c*c+d*d fusion-bait pattern, sitting one call frame
"inside" the multiply that got fixed first. Routing that through the same
volatile barrier finally closed it. Every one of the 6 CI jobs — three
operating systems, Debug and Release each — came back green.
The wrong guess is left in the codebase's comments on purpose, not deleted. Future-me (or anyone else touching this code) benefits far more from "here's what looked right and wasn't" than from a history that pretends every fix landed on the first try.
Cross-platform floating-point determinism bugs share an uncomfortable property: they're real, they're reproducible, and they are invisible without the exact right test. A test suite that runs on one machine can pass forever while quietly promising something it can't deliver everywhere else. The only thing that surfaces this class of bug is a real matrix of real machines actually running the code — which is a strong argument, if you needed one, for why "it works on my machine" and CI matrices covering every platform you claim to support are not the same statement.
The concrete techniques that came out of this, for anyone chasing a similar bug:
- A digest that changes after a fix is progress. A digest that doesn't change is proof the fix didn't touch the real bug — not a maybe, a logical certainty, given how a folding hash works.
- Isolate before you diagnose. One folded number over many frames/objects tells you almost nothing; per-object, then per-frame, then per-field digests turn "somewhere in 400 frames of five shots" into "this exact field, this exact frame."
-
Not every
cross()/dot()call is FMA-risk. A cross product of{0, 1, 0}against anything only ever multiplies by an exact 0 or 1 — mathematically incapable of a fusion-caused rounding difference, fused or not. The actual risk is specifically two independently nonzero products being summed. Knowing the difference saves you from "fixing" (and re-breaking your trust in your own tooling on) code that was never actually broken. -
A compile flag is a request, not a guarantee, at every optimization
level, on every backend. When the stakes are real portability, a
volatilebarrier is the one technique that doesn't depend on a compiler's discretion.
The result, concretely: Determinism::BitExact now genuinely holds across
the full 3-OS × 2-config matrix, verified by CI on every push, not claimed
from a local run. That distinction was the entire point.