X-GIS
Blog

Emulating f64 in shaders · Part 3 of 10

The flickering Mandelbrot: a driver defeats the fast-math guard

Incident report: the fp64 demo alternated between correct and f32-collapsed frames under byte-identical inputs. Inferred cause: driver pipeline re-optimization specializing on uniform values. Fixes: render-on-demand and a texel-fetched guard.

By the X-GIS team 4 min read

Report (Windows 11, RTX 5060 Ti, Chrome, production): the f64 half of the fp64 Mandelbrot alternated between correct rendering and an f32-collapsed one, frame to frame, with no interaction. The demo has no animation — every frame binds the same program with the same uniforms, so with deterministic GPU execution the output cannot change. Either the inputs or the code differed between draws.

Live · WebGL2 Pointer-interactive
Full example
The classic double-float demo: a Mandelbrot needle-spike filament zoomed to a ~1e-7 span — narrower than one f32 ulp, so the plain-f32 left half collapses flat while the emulated-double f64 right half keeps the structure. Drag to pan and wheel to zoom, map-style — the camera accumulates in full double precision and lands in the vec2<f64> center uniform, so the f64 half stays sharp all the way to the df64 floor (~1e-13) while the f32 half died six orders of magnitude earlier. Raise the iterations slider past the old 96 to keep deep-zoom filaments resolved, or flip the fp64 toggle to collapse the right half in place.

The fp64 emulation toggle above is the whole experiment in miniature: flip it and the right half of the screen re-routes through the plain-f32 branch. On the reporter’s machine that one control turned an unreproducible flicker into a controlled A/B — off was stable, on alternated — which is what localized the bug to the df64 path.

Localizing without a repro

The df64 emulation depends on error-free transformations whose error terms are algebraically zero; WGSL explicitly licenses implementations to reassociate them away [1], so they survive only because a runtime-opaque one (then a uniform, value 1.0) is threaded through the intermediates — luma.gl’s long-standing code-elimination workaround [2], and fp64-emulation breakage on specific GPU/driver stacks has independent field reports [3]. On our CI GPU (SwiftShader) the deployed page was frame-stable: ten consecutive frames, zero differing pixels. The bug reproduced only on the reporter’s machine.

The demo ships an fp64 emulation toggle whose off state routes the whole screen through the plain-f32 branch of the same shader — same canvas, same loop, same uniform upload. One observation from the reporter settled it:

  • toggle off → stable
  • toggle on → alternating

Everything shared by both branches was exonerated. The instability lived in the df64 code path, and since the uniform bytes were identical every frame, the remaining variable was the compiled code itself: drivers ship a fast pipeline build, re-optimize in the background, and hot-swap variants — and some specialize pipelines on observed uniform values. The guard uniform’s value is always exactly 1.0; specialized, (sonea)one(s \cdot \mathrm{one} - a) \cdot \mathrm{one} becomes sas - a and the EFTs legally cancel. Variant swapping mid-session produces exactly the observed alternation.

This last step is an inference, not a captured fact: no WebGL or WebGPU API exposes the driver’s specialized pipeline variant, so we never read back the collapsed shader. What we have is that the collapse is confined to the df64 branch, is invariant to the (byte-identical) uniform bytes, and disappears under both fixes below — which the specialization theory predicts and no other hypothesis we tried does. We infer the cause from the fix; we did not observe it directly.

The lesson the two fixes below share: “runtime-opaque” has to hold across the pipeline’s whole lifecycle, not just its first compile. The uniform guard was opaque to ahead-of-time folding and still lost to a background re-optimizer that specializes on the value it observes at runtime. One fix removes the frames that can show the swap; the other removes the observability the swap depends on.

Fix 1: render-on-demand

The playground loop redrew every rAF tick with unchanged inputs. Every live input (clock, sliders, toggles, pointer, camera, canvas size) flows through one packed uniform buffer, so byte-equal uniforms imply an identical frame — skip the draw. A frame that is never redrawn cannot alternate, regardless of what the driver swaps in between draws.

Measured with an instrumented drawArrays counter: 0 draws/s at rest (was ~60), one draw per pointer step while dragging, animated examples unaffected. Result on the reporter’s machine: at rest, fixed; during drag/zoom — where redrawing is unavoidable — the collapse still appeared intermittently. The specialization theory held.

Fix 2: a texel-fetched guard

If the driver can observe the uniform, store the guard where observation doesn’t help. No WebGL2 or WebGPU compiler in current driver stacks treats texture contents as a compile-time constant it can fold the cancellation against. The guard is now a 1×1 texture whose texel reads exactly 1.0:

// GLSL ES 3.00
float one = texelFetch(_fp64, ivec2(0, 0), 0).x;
// WGSL
let one = textureLoad(_fp64, vec2<i32>(0, 0), 0).x;

At the IR level the guard is an intrinsic, f64Guard(), with three spellings: the WGSL fetch, the GLSL fetch, and — on the CPU oracle — the number 1. The entire verification stack re-ran unchanged across the representation change.

Two supporting changes: the lowering injects the texture binding automatically (as it did the uniform), and the render loop schedules one extra draw ~300 ms after inputs stop changing, so the frozen at-rest frame cannot be one that landed on a bad in-flight variant.

Deployed; the reporter’s machine is clean at rest and during interaction.

What generalizes

Render-on-demand is a correctness feature, not just a battery optimization: skipping a redundant, byte-identical draw structurally removes any nondeterminism the driver can introduce between draws, just as giving a GPU-opaque value a CPU-exact twin let this guard move from uniform to texel without the verification stack registering the change.

References

  1. W3C, WebGPU Shading Language — Reassociation and fusion; see also gpuweb/gpuweb #2402 “reassociation is always allowed”.
  2. luma.gl, fp64 shader module — the ONE-uniform LUMA_FP64_CODE_ELIMINATION_WORKAROUND this guard descends from.
  3. D. McCurdy, fp64-arithmetic: testing FP64 arithmetic in GLSL on Apple GPUs — independent field evidence of df64 emulation breaking per GPU/driver stack.

Read next

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.

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.

precision

8 min read

The error term you compute in integers

Fast-math deleted our emulated-double multiply on Apple GPUs, and every float-side guard — including hardware FMA — died with it. So we rebuilt twoSum and twoProd in u32 bit arithmetic: same pair format, same compositions, exact by construction. The device that zeroed every float variant returned the golden answer.