The viewer's thread pool was sized by one expression: Math.min(navigator.hardwareConcurrency, 32). On a 24-core, 32-thread desktop that spawned thirty-two web workers before the first frame was drawn. Nobody decided thirty-two was right — it is what the machine reported. That is worth examining, because a default nobody chose is a default nobody has justified. This post walks the trade-offs and ends where the code now stands: a cap of sixteen, a hard ceiling of thirty-two, and a ?threads=N override.
What the workers actually do
They are not general-purpose. The pool exists for one thing: rayon's par_chunks over lane collection and rasterization in orbit-live-render. Everything else — the egui pass, wgpu submission, the network fetches — stays on the main thread. So the pool's usefulness is bounded by one parallel section inside a frame, not by how much of the app could in principle be parallel.
That framing matters. A frame at 60 fps has 16.7 ms in total. The collect/raster section is a slice of that — single-digit milliseconds for a typical capture. Handing a 3 ms job to thirty-two workers means each does roughly 90 µs of work, which is the region where chunking, scheduling and cache traffic start to eat the gain.
The case for turning it down
- Workers are not free at rest. Each is a full wasm instance sharing memory through a
SharedArrayBuffer, with its own stack reservation and JS-side bookkeeping. Thirty-two of them is tens of megabytes of memory for threads that are idle most of the time, in a browser tab that also holds the capture ring. - They cost startup latency. The pool is initialized before the first frame —
initThreadPoolis awaited during boot. Every worker must be created and joined before anything is drawn, so the count is paid directly in time-to-first-pixel. - The hardware thread count is the wrong unit. On a hybrid part, 32 threads means 8 performance cores, 16 efficiency cores, and SMT siblings. Rasterization is memory-bandwidth-bound; SMT siblings share the very resource that is saturated, and E-cores finish later, so the frame waits on the slowest chunk. Wider is not faster once bandwidth is the limit.
- It competes with the work that matters. The main thread and the compositor need a core. Thirty-two runnable workers on a machine also running the profiled application — which is the whole point — take cycles from the thing being measured. A profiler perturbing its subject is a real cost, not a theoretical one.
The case for leaving it alone
- Large captures do scale. With thousands of lanes, the raster job is genuinely big and more workers genuinely help. A cap chosen for a small capture penalises the large one, and large captures are where a profiler earns its keep.
- Idle threads are cheap in CPU terms. Rayon workers park when there is no work; the steady-state cost is memory and scheduler presence, not burn.
- It is not obviously broken. Nobody reported jank traceable to the pool. Changing a default on reasoning alone can trade a theoretical problem for a real regression.
The verdict, and the honest caveat
Turn it down — but not to the performance-core count, and with the caveat stated plainly: this is reasoned, not measured. The shipped default is min(hardwareConcurrency, 16) rather than min(hardwareConcurrency, 32), clamped to a hard ceiling of 32, with ?threads=N overriding either way.
The tempting answer was eight — the performance cores of a typical desktop, and the choice that cuts idle memory and startup cost hardest. It is not the one that shipped. The collect/raster job scales with the lane count, and on a many-core box the lane count tracks the core count: a 72-thread machine has 72 scheduler lanes to walk. Capping at eight would hand that machine a pool narrower than its work, penalising exactly the large captures where a profiler earns its keep. Sixteen keeps the pool wide enough for a big box to get real parallelism while still bounding worker spin-up — a 72-thread box gets sixteen workers, not seventy-two. The ceiling of thirty-two backstops the override: even ?threads=64 is clamped, so no load spins up an unbounded pool. Either way it is a better guess than "whatever the CPU reports", which was the previous policy.
More useful than the new number is that the number is now reachable. The pool width can be overridden per load:
http://127.0.0.1:44766/?threads=1 # sequential baseline
http://127.0.0.1:44766/?threads=16 # the default
http://127.0.0.1:44766/?threads=32 # the ceiling, and the old behaviour
That turns an argument into an experiment. The measurement worth running: load a large capture, hold the view still, and compare frame time across ?threads=1,2,4,8,16,32. If the curve flattens by four, the default is too wide. If it keeps improving to thirty-two on big captures, the right answer is not a constant at all but a width chosen from the lane count — small captures sequential, large ones wide.
The general lesson is the one this log keeps relearning: hardwareConcurrency answers "how many threads does this machine have", which is never the question. The question is "how much parallel work is there, and how wide before the overhead wins", and only measurement answers that. Until someone runs it, sixteen is an honest guess replacing an unexamined one — headroom chosen over the safer eight, on the bet that big captures are where the pool has to earn its keep.