Emulating f64 in shaders · Part 2 of 10
Testing emulated doubles
The verification stack behind shader-dsl's fp64: a correctly-rounding f32 machine built from Math.fround, a metamorphic oracle gate, byte goldens, and discriminative real-GPU known answers.
By the X-GIS team 5 min read
The fp64 feature poses an awkward testing problem. The thing under test is f32 arithmetic arranged to behave like f64 — but CI has no GPU, JavaScript has no native f32, and the algorithms fail in ways a “run it once, looks right” check cannot catch. The gates below layer so that each catches a failure class the others cannot.
Layer 1: a correctly-rounding f32 machine
The df64 kernels (twoSum, twoProd, …) only work if every intermediate rounds
to f32 exactly once. JS numbers are IEEE doubles, but
— round-to-f32 after a double-precision op — is
bit-exact f32 arithmetic for + − × ÷ and sqrt. That is not an accident of
testing luck: for binary32 operands, computing in binary64 and then rounding
to binary32 provably equals the single rounding, because binary64 carries
more than significand bits — Figueroa’s classic innocuous double
rounding result [1]. It is the same theorem that lets JS engines implement
Math.fround-typed code with native float32 instructions [2].
So the test harness takes the actual lowered IR (the same modules the
GPU receives), wraps every f32-typed operation in a synthetic __fround
call, and evaluates it on the CPU oracle:
const froundWrap = (e: Expr): Expr => (e.op === 'binop' || e.op === 'unop' || e.op === 'call') && isF32ish(e.type) ? { op: 'call', type: e.type, fn: '__fround', args: [e] } : econst cpu = compileModule(mapModuleExprs(fp64Lower(m), froundWrap))cpu.fns['__fround'] = (x) => Math.fround(x)No hand-written mirror of the formulas exists — a mirror would drift from the shader and start testing itself. The known-answer vectors are chosen so plain f32 provably fails them:
| Case | exact | plain f32 |
|---|---|---|
| full double | 24-bit product | |
lo-word tie-break in min | picks by | cannot order |
Each test asserts both halves: the df64 result lands within tolerance, and the f32 twin misses by orders of magnitude more. A test that a broken implementation could also pass proves nothing.
Layer 2: the metamorphic gate
The CPU oracle evaluates authored f64 natively — a JS number is an IEEE double, so the authored module is its own specification. Lowering is meant to preserve semantics, giving one metamorphic relation [3] over arbitrary inputs:
In exact-enough arithmetic the EFT error terms are genuinely near zero, so the pass reproduces the native-f64 oracle to roughly – relative (df64 keeps ~48 bits, dropping only the lo·lo cross terms); the gate itself asserts a looser so honest rounding never trips it. One property, every rewrite rule in the pass covered — no per-rule expectations to maintain.
Layer 3: byte goldens and optimizer invariants
The guard threading (* one through every error term) is a textual
property of the emitted shader, so it is pinned as text: WGSL and GLSL
goldens are byte-compared per commit, and a re-bake is a reviewed diff. Two
invariants are asserted on the optimized output directly: df64_* helpers
are never inlined, and the guard reference survives the O2 fixpoint in every
EFT-bearing helper body. A module using no f64 must come out of the pass as
the same object — existing goldens changed by zero bytes when the feature
landed.
Layer 4: discriminative real-GPU known answers
Everything above runs the IR under our semantics. The failure mode fp64 exists to survive — a downstream shader compiler folding the EFTs — is only visible on a real compiler stack. A Playwright gate executes the emitted shaders on both backends:
- WGSL: a WebGPU compute pass writes each case’s hi/lo pair to a storage buffer (runs under SwiftShader in CI).
- GLSL ES 3.00: a WebGL2 fragment pass renders one pass/fail pixel per case (exercises ANGLE), plus one pixel that runs the whole-plane vec64 arithmetic and componentwise builtins.
The assertions reuse layer 1’s discriminative structure: the result must beat the plain-f32 twin by ≥8×, so a fast-math collapse turns the gate red rather than passing vacuously. The 8 is not a precision target — the CPU gate demands ; the real-GPU threshold is deliberately loosened to leave headroom for driver rounding on the collapsed path (and the total-cancellation cases, where the f32 twin is exactly 0, are gated on the absolute tolerance instead).
What each layer caught
The stack is not hypothetical, and each layer is discriminative about a different shape of failure:
- Layer 1 pins exact answers with a paired f32-fails half: must land on (a 41-bit tail plain f32 flushes to ), the product must keep its cross term (f32 keeps ). Because every case also asserts the f32 twin missing by orders of magnitude, a too-loose tolerance or a wrong expected value cannot slip through as a vacuous pass.
- Layer 2 is where the vector milestone’s lowering shapes get caught:
vecN<f64>lowers to hi/lo planes (struct DF64VecN { hi, lo }), and a swizzle or adotthat gathers the wrong plane or lane still type-checks. The metamorphic relation diverges the instant a lane is mis-indexed — no per-shape golden to hand-maintain. - Layer 3 asserts on the optimized output that
df64_sqrt/df64_divare still called (nothing inlined the EFT bodies) and that each ofdf64_twoSum/quickTwoSum/splitstill references_fp64after the O2 fixpoint — so a new algebraic pass that folds* oneaway turns the assertion red instead of silently collapsing precision. - Layer 4 is the only reason the
driver-specialization incident
could be fixed with confidence — when the guard’s representation changed
from a uniform to a texel fetch, the intrinsic kept a CPU spelling of
exactly
1, and every layer re-ran unchanged over the new lowered IR.
References
- S. A. Figueroa, “When is Double Rounding Innocuous?” ACM SIGNUM Newsletter 30(3) (1995), 21–26 — why binary64-then-binary32 rounding equals a single binary32 rounding for the basic operations.
- Mozilla, “Efficient float32 arithmetic in
JavaScript”
— the
Math.frounddesign and the same double-rounding argument in engine practice. - T. Y. Chen, S. C. Cheung, S. M. Yiu, “Metamorphic Testing: A New Approach for Generating Next Test Cases,” Technical Report HKUST-CS98-01 (1998) — testing without an output oracle via relations between runs.
- W3C, WebGPU Shading Language — Reassociation and fusion — the license the real-GPU layer exists to catch.
Read next
precision
5 min read
20.7 km and 0.7 m: a difference forgives the bias its terms share
I spec'd a drift guard for the globe's camera-relative ECEF offset: force the ellipsoid constant to a sphere, assert the result shifts >10 km. The implementer measured it: 0.7 m. Turning the WHOLE computation spherical barely moves a relative offset, because both endpoints carry the same 21 km bias and a subtraction cancels it. The regression that actually bites is the mixed frame — ellipsoid tile, spherical camera — and only a guard that breaks the symmetry the same way sees it.
precision
6 min read
The precision fix that opened a seam
Making the line outline's projection precise, but not its polygon fill, turned a shared invisible jitter into a ~3px fill≠outline seam at deep zoom. The fix isn't more f64 — it's the same camera-relative reframe, which the linear term needs no emulation for.
shader-dsl
9 min read
An opaque guard defeats the compiler, not the hardware
After a texel-fetched guard fixed the fast-math collapse, isolated df64 ops passed on the reporter's phone but composed ones still broke. There are two failure classes, not one — and the second lives in the ALU, where no value you thread into the shader can reach it. df64 is per-vendor, and our CI's SwiftShader is structurally blind to half of it.