This page runs from a web server, not from a file: in the repository run python3 -m http.server 8000 and open http://localhost:8000/tutorial/.

Part VII · Speed · Chapter 27

Faster compiles

Before a scene renders, its kernels have to be compiled: turned from text into the graphics card's own instructions, by the browser and the card's driver. For a big scene that took over a minute, and every change to the scene's shape starts it again. Why the scene is in a kernel's text at all: ShadyTracer writes each scene's shapes as code, the distance function the rays march by (chapter 24), into the kernels that need it; a new shape is new text, and new text is a new compile, while moving a shape only changes a number the kernel reads. Chapter 24 also showed why a kernel's size, with every call written out, sets its time. This chapter is what ShadyTracer does around that: compiling without freezing the page, all at once, only what changed, and a bar that says honestly how long is left.

1A compile that freezes the page

WebGPU, the browser's way for a page to use the graphics card, has two ways to make a kernel ready (a pipeline, in its words). The plain one hands the text to the driver, the card maker's software that compiles it, and waits, on the page's own thread, the one that draws the page and answers clicks, until the compile is done: "seconds to a minute in which the page cannot paint", as ShadyTracer's own WebGPU library says. Its first builds were made that way. Here are the Noise library's kernels (one of the scenes that come with ShadyTracer) as the user's card compiled them, each timed. (The user, on these pages, is ShadyTracer's developer, who runs its tests on their own cards and pastes the numbers back: here an NVIDIA GTX 1080 Ti, of the generation NVIDIA calls Pascal; elsewhere a newer card, an Ampere. ShadyTracer's versions are numbered like 0.9.2 and 0.10.0, and the middle number counts up: 0.10 comes after 0.9.) That version, 0.9.2, had 13 kernels; today there are 17. 13.4 seconds in all, one after another, and the page frozen for every one of them. The Menger cubes showed what that looks like: raising their iteration count froze the tab with no error, and the "recompiling…" line meant to say so never reached the screen, because the compile began before the page could draw it.

The other way, createComputePipelineAsync, does the same work on another thread and hands back a promise of the kernel: a value that arrives later, which the program can wait for. The page keeps drawing and answering. The kernels here still compile one after another, as ShadyTracer's build first did with them: "nine sequential awaits", its comment says, "and every one of them awaited nothing" while they were the plain kind (nine is the comment's count; it does not say when it was written, and figure 1's version built 13). Still 13.4 seconds.

So ask for all of them at once, and wait for the last: the build is then about as long as its slowest kernel, here the one that shades, which was 80% of the work by itself. They can run side by side because the browser and the driver compile on several of the processor's cores at once (inferred: the measurements say only that the total shrinks). Measured: 11.2 seconds all at once against 13.4 one at a time, 16% less, and within half a second of shade alone. Not the slowest kernel's time exactly, but half a second more: the small kernels, 12 to 126 ms each alone, were ready only at 177 to 402 ms, queued behind the big ones, which suggests they share the processor with shade while it compiles (inferred).

The same in every scene measured: all at once 16 to 21% under one at a time, and the build as long as shade, give or take half a second. So the slow kernel is what to make smaller.

And 0.10.0 did: shade's compile fell by 38 to 48% in every scene, the Noise library's from 11.4 to 5.9 seconds (the user's GTX 1080 Ti; figure 1's 10.7 is another run, of an earlier version). Mostly by calling the materials' functions from fewer places: the compiler writes a function's body in wherever it is called, so every call is a copy (chapter 24), and one of them went from nine places to three.

Since 0.20.8 shade holds no scene at all. A new kernel, surface, works out from the scene what each ray hit (its normal, its material), and shade only uses that.

One scene's 13 kernels compiled three ways, from the user's measurements (the user's Pascal card, ShadyTracer 0.9.2, docs/PERFORMANCE_REVIEW.md): shade, occlusion and the march each timed alone, the ten small kernels together (what one at a time leaves after the three, 0.28 to 0.39 s). Top row: the page's own thread, painting or frozen. The dashed line: the build ready. Choose the way and the scene.

2Compile only what changed

A session in the builder, ShadyTracer's scene editor, step by step. Opening a scene compiles every kernel, today seventeen; the table below says what each does, and pointing at a row says it too. Each build's time is predicted the engine's way (section 3), as its slowest kernel's, from kernel sizes that are a model's, not measured. Moving a shape compiles nothing (chapter 24: a value is written into the uniform buffer, the memory of numbers the kernels read).

kernelits job
genraysthe camera's rays, one a pixel (chapter 1)
march, occludestep each ray to the surface it hits, and the shadow rays to see whether they are blocked: they hold the scene (chapters 2, 14)
surfacewhat was hit: its normal, its material, the medium beyond: holds the scene
occlude_emitthe glow a shadow ray aimed at a glowing surface met, where it was blocked (the occlusion kernel asks only distances, chapter 26): holds the scene
shadethe bounce and the light at each hit (chapters 13, 14)
flip, shadow_flip, prep_march, prep_shade, prep_occludethe wavefront's bookkeeping: swap the queues of rays for the next iteration, and size the next round of work (chapter 22)
resolveadds the sample into the picture (chapter 23)
genphotons, lt_buildthe light tracer's photons from the lights, and its map of where to aim them (chapter 18)
emit_collect, emit_publishfind the glowing surfaces and list them, to aim at (chapter 16)
sppmthe photon map: stores the photons and gathers them (chapter 19)

Adding a shape changes the scene's text. Only four kernels hold the scene, the march, the surface kernel, occlusion and its emitter rays' resolve (and the step cache's fill, chapter 26, when that is on), so only they compile again; the other thirteen are asked for with text the device has compiled before, and come from the pipeline cache. The build still takes as long as its slowest kernel, the surface one, which holds the scene: what the cache saves here is the other kernels' work alongside it, not the wait.

Another scene, then back to the first: everything it needs is in the cache, keyed by the kernels' whole text, and nothing compiles. The same for an undo that puts the text back as it was. Measured on the user's card when the cache came in (0.10.0), in the Noise library: a part of the scene (a node of its tree, chapter 24) hidden and shown once, then shown again and hidden again, so texts already compiled once. Showing it again took 4.2 seconds before the cache and 2.0 with it; hiding it again, 3.0 and 0.56. Those times are the builder's whole update of the scene, not the compile alone, so what is left with the cache is the rest of its work.

Without the cache, as before 0.10.0, a revisit is a compile like any other: every step that changes a text compiles every kernel, and going back compiles them all again.

And before 0.10.0 four of the kernels that hold no scene changed with it anyway: ray generation, resolve, the light tracer's photons and the emitter list's collect pass. A kernel declares, at its top, the uniforms it is handed, and tinywebgpu, ShadyTracer's WebGPU library, refuses a value no declaration names. The renderer handed every kernel the scene's whole list, so these four declared it, and another scene, or an edit that changed the list, was new text for them, compiled again for values none of them reads. Here, cache on: opening scene B compiles eight kernels, not four (the build no longer, since the surface kernel is still its slowest: the four cost work beside it, not the wait). Since 0.10.0 those four declare only the renderer's own uniforms and are handed only those (shade and the photon map's kernel are made the same way), so their text is the same for every scene.

Six steps of a session (columns) and today's seventeen kernels (rows): a kernel red: compiled, and it holds the scene; orange: compiled; blue: from the cache; faint: nothing to do, or a step not reached yet. Below each step, the build's time as the engine's compileprogress.js predicts it from the kernels' sizes (a model's sizes, chosen near the documents': shade 113,000 characters inlined, the surface kernel 160,000 in scene A). Press Next step, point at a row for its job, and choose whether the cache is on and how the four kernels of the last step declare their uniforms ("scene list": the scene's too).

3A bar that does not stick

While a build runs, the page shows a bar. It used to count kernels ready: "12 of 13". But the kernels compile side by side, the small ones are ready in a few hundred milliseconds, and the build waits on the biggest. So the bar jumped to nearly full at once and sat there for the rest: "often gets stuck on the last part", the user wrote. Here a build predicted at 10 seconds that really takes 13.

Since 0.10.0 the bar moves by the clock. When the build starts, its time is predicted from the kernels that will compile (not those from the cache): the slowest one's, by chapter 24's model from its size (the box below). The bar runs straight to 90% at the predicted time, then eases towards 99% and never reaches the end until the build does, and the words beside it change from "about 3 s left" to "taking longer than usual". A build that takes far longer than predicted, two and a half times here, keeps moving, slowly.

That rate was measured on one card. Another machine learns its own: after each build, the slowest kernel's time and size give the rate it showed, which is blended into the kept one, half old and half new, and kept in the browser. Here a machine twice as slow, whose real rate is 30.4: its first build is predicted at half its time, the next at three quarters, and after five builds that taught it, the sixth is within 2% (the fold-out works it through).

Some builds teach nothing. One whose slowest kernel took under 300 ms. One four times faster than the kept rate: that is the browser's own cache answering after a reload. The browser keeps compiled kernels of its own across a reload, while ShadyTracer's pipeline cache lives in the page's memory and is gone, and the Noise library's 6.3-second build then took 0.2 seconds. The reload in the figure is such a build. And a build slowed by something else on the card can at most double the rate.

The price of ignoring the cache's answers: a machine more than four times faster than the starting rate is never believed, and keeps being told too long a time (slide below 0.25). Its bar ends early, which is better than stuck.

The mathsLearning a machine twice as slow, build by build

The kept rate starts at 15.2 ms a thousand characters; the machine's real one is 30.4. Each build that teaches, the kept rate becomes half itself plus half what the build showed:

buildkept rate beforepredicted against takenkept after
115.2−50%(15.2 + 30.4) ÷ 2 = 22.8
222.8−25%26.6
326.6−12.5%28.5
428.5−6.3%29.45
the reload29.45it took 1/40 of the time: a rate of 0.76, under a quarter of 29.4529.45: ignored
5, 6, 729.45, 29.93, 30.16−3.1%, −1.6%, −0.8%29.93, 30.16, 30.28

The gap to 30.4 halves with each build, so the prediction's error does too. The reload took 0.51 s, over the 300 ms floor: it is the quarter rule that throws it out. A stalled build shows a rate held to three times the kept one before the blend, so the kept rate can at most double: (1 + 3) ÷ 2 = 2.

Left view: a build's progress against time, the old bar (kernels ready, in red) and the bar by the clock (blue, compileFraction), for a build predicted at 10 s that really takes what you set; point at it. Right view: seven builds and a reload on a machine slower or faster than the rate it starts from, each predicted at the rate kept so far (grey) and taken (blue; orange where the browser's own cache answered), the rate learned by learnRate.

4How ShadyTracer does it

The two ways, as ShadyTracer's WebGPU library explains them:


    

Building all at once, in the renderer:


    

The kernels that hold no scene:


    

The cache, keyed by the whole text:


    

And the bar by the clock:


  

5Tricks and trade-offs

Each is stamped with how it is known; point at a stamp to see what it means.

Loops that do not unroll. A loop with a fixed count around a scene call is unrolled by the compiler into that many copies of the scene (chapter 24); the march's 32 steps were written so on purpose, for speed. The Noise library then held about a hundred copies of itself and took over a minute to compile. Its loops now take their count from a uniform that is always 0 added to it, which the compiler cannot see through (noUnroll): about 20 seconds (the user's Ampere card, 2026-09-29, docs/PERFORMANCE.md). Whether the unrolled march rendered faster was never measured.

The bar's model. 15.2 ms per thousand inlined characters, fitted over 24 kernels on the user's GTX 1080 Ti (typical error a third), and growing as the size to the power 1.37, fitted over 16 builds of 2 to 46 seconds on the user's Pascal card (typical error 11%). Past 46 seconds it is a guess: the lens assembly copied 32 times, predicted at 6.5 minutes, was still compiling after ten (compileprogress.js).

A prediction without a compile. The same prediction can be made from the kernels' text alone, without a graphics card: a stand-in device keeps the text it is handed instead of compiling it. The benchmark, ShadyTracer's page of timed test scenes (chapter 25), uses it to skip a scene predicted to compile for over a minute, rather than wait (raytrace_main.js predictCompile, tests.js).

No frame during a build. A kernel still compiling cannot run yet, and a frame that asked it to (dispatched it, chapter 21) was refused whole by the browser. So the renderer counts the builds under way and traces no frame while one runs (raytrace_main.js, _building).

Instead of compiling, data. Chapter 24's third way writes a scene not as code but as data: each shape a record of numbers, which one fixed loop in the kernel walks. The lens assembly copied 32 times, as code, was still compiling after ten minutes. As data it compiled in 1.8 seconds in 0.22.6, against 52.2 as data in 0.21.6 (the user's Pascal card, roadmap/ARCHITECTURE.md): its copies are more records, not more text, so its kernels' text is the same as the optics bench's, the one lens assembly, as data.

Whether the prediction costs the build anything. Measuring the kernels' sizes takes 30 to 40 ms for the Noise library's, timed in Node.js (JavaScript run outside a browser); whether it runs while the kernels compile or before they start is not known (raytrace_main.js, the prediction's comment, which names the test that would tell).

6What went wrong before

  • A frozen tab with no error. Section 1: the Menger cubes' compile on the page's own thread ().
  • Awaits of nothing. The build awaited each kernel in turn, and each await waited for nothing: the calls underneath had already blocked until done ().
  • The stuck bar. Section 3: a count of kernels, most of them tiny ().
  • A profile that read the cache. An early profile of each kernel's compile kept its own list of kernels; five came back in 0 to 3 ms, the live text byte for byte, most likely from a cache. The profile now builds from the renderer's own table, and makes every text one no cache has seen ().
  • A cache that could hand back the wrong kernel. The toolkit before tinywebgpu keyed its cache by a hash of the text; two texts with one hash would share a pipeline. The key is now the text itself (section 4).
  • A frame traced during the first build. A redraw asked for while the page first loaded dispatched kernels still compiling, and the frame was refused; it had been fixed for a scene change alone before, and the state now lives where every build passes ().