Our Mandelbrot deep zoom explorer renders views down to 10-1000 in the browser with WebGPU. This article records what made it fast, what did not, and the results that surprised us. It is written for someone building a similar tool who already knows GPUs and floating point.

The headline: at the explorer's Fast size, the 10-318 test view went from 40.6 s to about 1.4 s, with the image on screen after about 0.7 s. The hardest view went from 49 s to about 3 s, and the 10-113 view at 800×600 from 117 s to under 4 s. Almost none of that came from clever arithmetic. It came from finding where the time actually went: host round trips per dispatch, a few slow pixels holding up whole frames, and an error-bounded number format that was more careful than the picture needed.

Scope. Every number comes from one Apple M4 (Metal) in Chrome, mostly headless, with other software running. Phones, Windows machines, other GPUs and long interactive sessions have not been measured. Treat every figure as an observation from this setup, not a benchmark of a technique.

1. The renderer in brief

The renderer uses perturbation, the approach K. I. Martin's SuperFractalThing popularised (see Claude Heiland-Allen's perturbation notes). A worker computes one reference orbit Z at high precision with GNU MPFR, compiled to WebAssembly through gmp-wasm. Every pixel then iterates only its small difference from that orbit:

dn+1 = 2·Zn·dn + dn2 + dc

(derivations: Ultra Fractal, Phil Thompson, Matt Fisher). At a span of 10-1000, dc is far below what a native float can hold, and WGSL has no f64 at all. So values need a separate wide exponent, often called floatexp, or rescaling as in "Deep zoom theory and practice".

The engine has two GPU arithmetic tiers:

  • integer72 (the original, now "Exact (audit)"): a 72-bit integer significand in 12-bit limbs plus an exponent, with a conservative error envelope per pixel. A pixel whose envelope grows too wide to decide escape is flagged.
  • fast32 (the default since 21 October): native f32 with a shared exponent per complex value and a heuristic glitch test. Flagged pixels restart in integer72.

Around them sit BLA (bivariate linear approximation, which skips blocks of iterations; we followed Heiland-Allen's "Deep Zoom" notes and BLA note), rebasing to the start of the reference when the pixel orbit nears zero, a GPU strong retry without BLA, and CPU repair that recomputes whatever is still uncertain in MPFR. The GPU runs in rounds: each dispatch advances every live pixel by a fixed number of iterations (the "work units"), then a summary pass counts what is left and the host reads it back.

2. How we measured

Four fixed camera views, each at its original iteration budget. Magnification is 4 / span.

FixtureMagnificationIteration budgetCharacter
e1135×1011335,494Dense ordinary work, useful BLA skipping.
e3184×10318212,138Heavy skipping, plus a few slow pixels that fail the fast tier.
e10001×10100032,000The deepest view, and the cheapest.
glitch_test_13.3×1046275,250Almost no BLA skipping: 2.7 billion ordinary iterations at 256×256.

Early screens used 256×256 and 32×32 frames. Later work measured at the explorer's own sizes: 256×192 (Fast), 512×384, 800×600 (Fine) and 1920×1440 (Ultra). Method:

  • Paired, alternating runs. Baseline and candidate ran in the same page session, warm, in alternating order (AB, BA, AB), at least three pairs, reporting medians. Comparing a candidate with a baseline from another session was useless: the same engine rendered e113 in 11.5 s in one session and 20.7 s in another.
  • A stated timing boundary. Headless Chrome suspended animation-frame callbacks, so frames end on an explicit final GPU queue fence. "Settled" means the image is complete apart from CPU repair still being patched in.
  • GPU timestamps for compute time (quantised to 100 μs, per Chrome's WebGPU developer notes), and a per-round phase trace showing how many pixels were still live in each round.
  • One GPU job at a time. Parallel agents shared a lock, because one benchmark distorts another's timings.
  • Identical output. Every scheduling change had to leave the status, escape-count and colour planes unchanged against its baseline.

Even so, the host was noisy. Backup and antivirus software ran at 60–160% CPU during the later batches, and the development server opened an animated fractal tab in the user's own browser each time it started, which competed for the same GPU. Absolute times in the later batches run 1.3–2× higher than the same code on a quiet afternoon. Only ratios from the same batch are comparable, and the tables below say which is which.

3. What made it fast

Roughly in order of impact.

Fewer, longer dispatch rounds

Each round costs a host round trip regardless of how much work it carries. Raising the work units per dispatch from 32 to 512 was worth 3.7× to 8.5× on its own (32 still applies during gestures, so cancelling stays quick):

fast32, warm seconds, by work units per dispatch
View322565122048
e113 256×1923.850.910.610.53
glitch_test_1 256×19255.510.96.5–
e113 800×60014.63.363.192.96

Above 512 the dense frames gained little, but the end of the frame was a different story; see the tail below.

Native floats with a heuristic glitch test

The fast32 hot loop runs in native f32 with one signed exponent per complex value, renormalised each step, so even the 10-1000 view does not underflow. Reference orbit, BLA coefficients and offsets are read from the existing 72-bit buffers by rounding their top bits. There is no error envelope. Instead a pixel is flagged by Pauldelbrot's criterion, |Zm + d|² < 10-4·|Zm|², and restarts in integer72 inside the same shader.

integer72 against fast32, warm, 256×192 unless stated
Viewinteger72fast32Speedup
e10000.28 s0.06 s4.7×
e1139.7 s1.3 s7.6×
e31840.6 s3.6 s11.1×
glitch_test_149.5 s11.3 s4.3×
e113 800×600117 s4.0 s29.5×

What it costs. fast32 changes the integer iteration count on 0.004% of e1000 pixels, 5.9% of e113, 8.0% of e318 and 43% of glitch_test_1, with no inside/outside changes. That sounds alarming until you see where: every difference lies in regions that already change at pixel scale. Moving the exact renderer's camera by a ten-thousandth of a pixel changes a comparable number of pixels, and where a pixel agrees with its four neighbours only 3 of about 92,000 differ. Tightening the tolerance to 10-3 removed only about 1% of the differences at twice the time. This is the trade mainstream deep zoomers make; the exact tier stays one click away.

Cheaper repair, and showing the image before repair finishes

A tiny fraction of pixels can dominate a frame. On the original exact engine, 48 of 65,536 e318 pixels needed CPU recomputation, and the frame spent 46 s waiting for them. Three changes fixed that:

  • Parallel repair. Splitting the batch across two workers cut e318 from 64.8 s to 49.4 s, five wins in five fresh pairs. In the cautious early phase this was the only optimisation whose gain repeated cleanly. The explorer now uses up to four workers.
  • A cheaper direct orbit. The repair loop reuses x² and y² for both the escape test and the next iterate (22–27% faster). The confirmation run at higher precision now runs only near a decision boundary; across 96 replayed pixels the two precisions never disagreed. Replayed e318 repair fell from about 12.5 s to 3.6 s.
  • Settle before repair. Once every GPU pixel is done, the frame is shown and repair pixels are drawn provisionally (from their own escape count, or their nearest finished neighbour), then patched. Pixels that exhaust the iteration budget are drawn as interior rather than grey.

The tail: rounds, not arithmetic

After the changes above, GPU compute was no longer the main cost on the hard views. glitch_test_1 at 256×192 needed 535 rounds (its 275,250-iteration budget divided by 512 work units, with almost no BLA skipping) and spent about 4.4 s of 7.5 s outside GPU compute. Most of those rounds carried a handful of pixels. A per-round breakdown showed where the host time went:

glitch_test_1 256×192, per round, before and after one submission per round
PartFence, then copyOne submission
Whole round12.7 ms6.7 ms
GPU compute7.7 ms4.9 ms
Summary pass (GPU)0.16 ms0.09 ms
Command encoding0.06 ms0.01 ms
Progress event and present request0.09 ms0.04 ms

The summary pass, encoding and progress events were negligible, so skipping the summary, the obvious first idea, would have saved nothing. The cost was waiting on the GPU twice per round, and the idle gap between rounds let the GPU clock down: back-to-back work cut GPU compute itself by about 35%. Three changes followed, all on by default:

  • One submission per round: compute, summary and readback copy together, then map, instead of waiting on a queue fence first.
  • Round batching: while no fresh summary is needed (no repair pending, no gesture), encode up to eight compute passes, about 48 ms of work, before one readback.
  • Long tail rounds: once the live pixels fall below max(4,096, 2% of the frame), rounds grow from 512 to as many as 4,096 work units, each pass capped at about 100 ms of predicted wall time so a zoom click is never stuck behind a long round.

glitch_test_1 went from 1,070 submissions to 77 and from 12.5 s to 4.6 s in the same session; e318 at 800×600 from 484 to 62 and from 11.5 s to 6.8 s. Output planes were identical in every pair.

A preview that does not wait for the slowest pixels

Before the full frame, the explorer renders one sample per 4×4 block so something appears quickly: about 0.35 s at 800×600 and 0.8–1.0 s at 1920×1440, where the screen used to stay grey for 1.5 s and 6.8 s. On glitch_test_1, the preview was effectively complete by about 260 ms, then spent another 1.2 s waiting for 16–20 coarse samples on the minibrot to reach the 275,250-iteration budget. The preview now stops 150 ms after half its samples are drawn and fills unfinished ones from their nearest finished neighbour. That view's final image arrives 27% sooner (4.1 s → 3.0 s); the other views' previews already finish inside the 150 ms window.

4. The combined result

"Exact tier" is the integer72 GPU arithmetic that was the default before 21 October. "21 Oct, quiet" is the first merged fast32 engine, measured on a quiet afternoon. "21 Oct, busy" and "Now, busy" compare that engine with the current one in the same busy session, which is why the same engine appears twice with different times.

Warm seconds to the final image, CPU repair included, explorer defaults, Apple M4
ViewExact tier21 Oct, quiet21 Oct, busyNow, busyRatio
glitch_test_1 256×19249.07.4712.5≈3.4*≈0.27
e318 256×19240.61.201.581.370.87
e318 512×384––9.054.62*≈0.51
e318 800×600–6.0011.56.790.59
e113 256×1929.70.710.980.65*≈0.66
e113 800×6001172.774.173.66*≈0.88
e113 1920×1440–9.6917.516.1*≈0.92

* Chained from two paired batches rather than one direct pair. e1000 was not re-measured in the later work; it took 0.07 s at 256×192 and 0.57 s at 800×600 on the 21 October engine. For e318 at 256×192 most of the remaining time is CPU repair after the image is shown: it settles in 0.66 s, against 0.93 s for the 21 October engine in the same session.

The gains are largest where rounds dominated (glitch_test_1, e318) and smallest at Ultra, where about 13 s of the 15–16 s is dense GPU compute that none of the round changes touch.

5. What did not work, and what surprised us

Single-pair wins that did not repeat

Two kernel changes produced striking first full-size pairs on e318: loading BLA coefficients only after a block passes its radius check (−22.3%), and a range-safe FP32 pre-check for obvious escape decisions (−20.7%). Five fresh alternating pairs each brought them down to about 0.3% and 1%. Session drift alone was larger than most effects we were chasing. A single pair is now only a reason to repeat the measurement.

Less shader work did not mean a faster frame

A conservative global BLA gate cut table queries from 9.5 million to 64,000 on glitch_test_1, yet e318 got slower in the same pair. A narrower operation-magnitude computation cut summed GPU compute 1.8% and raised summed elapsed time 0.4%. Shader time, query counts and frame time are different quantities.

Three multiplications instead of four was slower

The classic three-product complex multiply (Bruce Dawson's algebraic tricks) lost in all five e318 pairs (+4.1% elapsed, +5.6% GPU compute). In 72-bit software arithmetic built from 12-bit limbs, the extra additions and normalisation cost more than the saved multiplication.

Compaction lost, twice

Packing the surviving pixels so sparse rounds stop wasting lanes was a top suggestion in our research notes. Our implementation pays a full-frame scan per round, so it was slower on every fixture that actually compacted. Enabled only for the sparse tail, on top of long rounds, it was still slower on glitch_test_1 (3.71 → 3.91 s) and neutral elsewhere. The late rounds are not wasting lanes so much as waiting on a few slow pixels.

Rigorous error bounds made native floats useless

Before fast32, we built a rescaled FP32 recurrence with directed error bounds covering projection, rounding, cancellation and discarded terms, and it passed every numerical check against MPFR. Every one of twelve real pixels asked to restart in integer72 long before its budget (e318 at iteration 2,692–2,701), so no speedup was possible. The enclosure was too loose, not the arithmetic. Dropping the enclosure for Pauldelbrot's heuristic gave the 4–30× of fast32. A double-float variant was not an option either: Dawn on Metal folded the two-sum and two-product error terms to zero, which the WGSL evaluation rules permit (see gpuweb #2076).

Glitched pixels are lonely

Two CPU repair shortcuts failed completely:

  • Restarting with the CPU's fast kernel against the primary reference resolved 0 of 96 residue pixels.
  • New local references resolved 0 of 4,242 attempts. A reference placed exactly on a failed pixel fixed that pixel and no other, even in the same 32-pixel cell.

Glitched pixels were scattered across 36 cells with no clusters to share a reference, so the only path that worked was making direct MPFR cheaper and parallel.

Handing the last pixels to the CPU did not pay

Late in a frame, a few dozen pixels keep the GPU busy for many rounds, so passing them to the CPU workers looked attractive. Built with a cost model, it won on e113 256×192 (0.69×) until the long tail rounds arrived, then either never fired or lost. At e318 depth, direct MPFR cost 2.3–2.5 μs per iteration on an idle processor, but up to 6.8 μs while the GPU was busy, probably because the workers landed on efficiency cores. The decision rule correctly declined (estimated 3.4–3.7 s on the processor against 2.6 s on the GPU).

Optimisations that collided

Long tail rounds and round batching each won on their own. Together they made e318 at 256×192 settle about three times slower (0.98 → 3.07 s). The strong-retry rule sent pixels back to the GPU when a summary showed at least eight first failures, a threshold sized for one 512-step pass per summary. With batched 4,096-step passes all eight residual failures landed in one summary, and the rule sent them to a 26-round GPU retry without BLA instead of to the CPU. Failures are now counted per base pass. The same combination stretched Ultra tail rounds to 260–460 ms, which made a mid-frame zoom click about 14% slower until each pass was capped at about 100 ms. Merging two winners is a new experiment.

Metrics that measured the wrong thing

  • "First present" was an all-grey frame. The preview's first-present time marked the first coarse round, which on glitch_test_1 and at Ultra had drawn nothing yet. "Half of the samples drawn" turned out to be the useful first-image measure.
  • "Over 99.9% of pixels stayed on the GPU" and "the frame spent most of its time waiting on the CPU" were both true of the same e318 frame.

Depth does not predict cost

The deepest view was the cheapest. BLA skipping and the number of slow pixels decided the cost:

Warm GPU work counters, 256×256, exact tier
FixtureOrdinary iterationsSkipped by BLA
e113489 million1.06 billion
e318823 million6.68 billion
e100018.5 million1.07 billion
glitch_test_12.69 billion1.9 million

6. Keeping the pictures right

The exact tier was audited before any of the speed work: full-plane comparison of GPU status, escape counts and smooth colouring values (Linas Vepstas's renormalised escape) against the CPU workers and an independent native renderer, with direct MPFR at two precisions for every disagreement. Across 262,144 pixels the CPU and GPU agreed everywhere; the native renderer disagreed on 56, and MPFR sided with our output on all of them.

After that, two rules held:

  • Scheduling changes must not change pixels. Rounds, batching, submission, preview and repair placement were each validated plane by plane against their baseline in every timed pair. The only accepted differences are a handful of repaired pixels whose fractional colour value moves by at most 5×10-7.
  • Arithmetic trade-offs are measured, not hidden. fast32's differences were mapped against camera-jitter sensitivity before it became the default, and the benchmark's MPFR spot check reports them rather than failing.

Lifecycle bugs mattered as much as arithmetic. A repaired pixel could be overwritten by a later GPU retry, a second navigation could recycle a reference buffer before the GPU had finished with it, and a terminated worker's late error could kill its replacement. Each is now guarded by a test.

7. Lessons

  • Profile the round, not just the kernel. Once compute is fast, host round trips and GPU idle gaps dominate. Count rounds and measure each phase of one.
  • Watch the critical path, not the pixel count. A few dozen slow pixels, whether in CPU repair, a preview or the tail, set the frame time.
  • Pick the precision the picture needs. A certified bound that restarts every pixel is slower than a heuristic that is right where it matters, as long as an exact mode remains for checking.
  • Repeat before you believe. Pair runs in one session, alternate the order, repeat on fresh sessions, and keep the machine quiet. Our two most exciting single results vanished on repeat.
  • Re-measure after merging. Two winners combined produced a 3× regression that neither showed alone.

8. What is still open

  • Ultra is compute-bound. About 13 s of a 15–16 s Ultra frame is dense GPU compute. Only faster per-iteration arithmetic or better BLA skipping will move it.
  • The slowest pixels restart from scratch. Pixels that fail the fast32 test restart from the first iteration in integer72, one GPU thread each. At e318 800×600 about 40 of them occupy the last 2 s of the frame. An intermediate floatexp tier, or resuming from where the pixel failed, could recover 20–30% on very deep views.
  • Small gains are below our noise floor. Two rounds in flight would hide about 2.5 ms per round, roughly 2–4% now that round counts are low. That is less than the run-to-run spread on a busy machine.
  • Other hardware. Everything here is from one Apple M4. Windows, other GPUs, phones and long interactive sessions are unmeasured.

The raw reports, paired timings, plane validations and driver scripts are in the repository under test/fixtures/mandelbrot-deep-zoom/.

Sources and further reading