workshop private

← all viz

Survivors

viz · created 2026-10-10

A garbage collector's work is proportional to what lives, not to what died — so buying memory drives a copying collector's cost to zero and cannot do the same for a sweeping one. Three collectors run one program and every word they touch is billed. Copying costs 2s/(1−s) per word reclaimed; sweeping costs that plus one visit per corpse, a floor of 0.190 words it never gets below. They cross at s = 16.2%, which is why the nursery is copied and the old generation is swept.

algorithmssimulationcanvas

A collector that reclaims a megabyte of garbage has not done a megabyte of work. It has done work proportional to the objects that survived — it never touches the dead ones at all, it just stops regarding their space as taken. That one fact decides nearly every argument in garbage collection, and it is easier to measure than to argue about.

So: three collectors, one program. Same seed, same objects, same order, same address space. Every word any of them touches is charged to a line item, and the pause work is kept apart from the tax the program pays while it runs.

pause workmutator tax
tracereading an object’s header and ref slots
copymoving a survivor’s words
sweepScanreading the mark bitmap, 1 bit per word
sweepFreeone visit per dead object, to free it
promoteScanfinding a survivor a home in the old space
allocScanone free-list block examined — a bump charges 1
barrierone write-barrier check on a pointer store

The two costs, and where they cross

Divide each collector’s pause work by the words it reclaimed. For a heap of H words with survival rate s and mean object size a:

copying      2sH / (1−s)H                     = 2s / (1−s)
mark–sweep   (sH + H/64 + (1−s)H/a) / (1−s)H  = (s + 1/64 + (1−s)/a) / (1−s)

Copying traces the survivors and moves them: two touches per live word, none per dead one. Mark–sweep traces the survivors, reads a mark bitmap over the whole heap, and then visits every corpse individually to put it on the free list. That last term is the whole difference, and it does not go away:

mean objectcrossing s*sweep’s floor
small (cons cells)3.38 words24.0%0.311
mixed (typical)5.74 words16.2%0.190
chunky (buffers)16.32 words7.2%0.077

As s → 0, copying’s cost goes to zero. Mark–sweep’s goes to 1/a + 1/64 and stops there. With cons cells, a sweeping collector can never get below 0.311 words of work per word reclaimed, no matter how much memory you buy. That is the argument for a copying nursery, stated as a number.

Below the crossing copying is doing less work; above it, sweeping is. And smaller objects widen the band where copying wins, because small objects mean more corpses per byte, and sweeping is billed per corpse.

Nobody tunes the survival rate. They tune the heap.

s is not a property of your program. It is the live set divided by the space you were willing to leave empty, and it only moves when the heap does. Same program, same live set, eleven heap sizes:

heap, wordscopying scopying costmark–sweep smark–sweep cost
2,04878.4%7.23958.7%1.642
4,09646.5%1.74023.5%0.503
8,19220.7%0.52110.1%0.305
16,3849.6%0.2134.5%0.239
24,5766.0%0.1282.9%0.220
49,1522.9%0.059——
the limit, s → 00000.190

Copying falls by more than two orders of magnitude across that range and is still falling when the table runs out. Mark–sweep falls by 7× and then flattens against its floor, with nothing left to buy. Throughput in a collected runtime is a purchase, and what you are buying is emptiness. (The two largest heaps have no mark–sweep row because it collects fewer than three times in this allocation budget — too few to average.)

Note the other half of that table: at the same total memory, copying’s survival rate runs about double mark–sweep’s, because only half the address space is ever available to fill. A copying collector does not get the heap you gave it. It gets half, and pays for the other half in a higher s.

And it still wins on work at the right-hand end — 0.213 against 0.239 at 16k words, despite sitting at twice the survival rate — because the floor is worth more than the half-space costs once memory is generous. What actually argues against copying at that size is not the work. It is the address space, and the hole in the next section.

Which is the whole argument for the generational arrangement, and it is not a compromise — it is putting each collector on the side of the crossing where it wins. Copy the nursery, where survival is around 10% and the floor would hurt. Sweep the old generation, where survival is high and copying would be absurd.

What the nursery actually buys

The same run, three ways, at 16,384 words — and total work is close to a wash:

collectionsp50 pausep99 pausework / word reclaimed
mark–sweep313,7103,8020.242
copying651,7321,9920.231
generational1229652,9550.264

Generational does 9% more total work than mark–sweep and cuts the typical pause by 3.8×. It cuts the p99 by 1.3×. The red bars in the pauses view are the majors, and a major is still a full collection over the whole old space — so the tail is more or less exactly where it was. The nursery buys the median. It does not buy the tail, and nothing in this design could.

The price is on the mutator, continuously: 9,687 write-barrier checks over the run, one on every pointer store into a heap object. What they buy is the remembered set — the old→young pointers a minor collection must treat as roots. It peaked at 91 entries, standing in for scanning all 12,288 words of old space, 135× bigger. That ratio is the whole bet, and it is the reason the barrier is worth its instructions.

The hole you own and cannot use

Mark–sweep never moves anything, so its free space is wherever the dead happened to lie. At 16,384 words with the live set holding about half of it, after 70–80 collections:

sizesfreein blockslargest blockblocks ≥ 64 words
small9,7021,252470
mixed9,554778806
chunky9,72926416850

9.5k words free — 58% of the heap — and with cons-cell-sized objects not one of the 1,252 pieces could hold a 512-byte array. Worst point in the run: 99.6% of free space out of reach of any single object. The run does not crash; it just quietly stops being able to allocate anything larger than its own rubble, which is the failure mode that looks like a memory leak and is not one.

A copying collector cannot reach this state at all. Compaction is not a feature it has; it is a consequence of moving every survivor into a fresh space, and free memory is therefore always exactly one block. It pays for that with the half-space standing empty — which, per the table above, is a higher survival rate and so a higher cost per word. There is no free move anywhere in this subject.

Reading it

Four views, 1 2 3 4. space plays and pauses, r resets, [ and ] step the heap size.

tenure moves how much of the allocation survives; sizes moves the mean object, and so the crossing; nursery moves where the generational split sits.

Reuse

src/survivors.js is a framework-free ES module. No DOM, no timers, no rendering.

Liveness is computed, not scheduled: objects hold real references, a root set and one long-lived spine hold the graph, and marking is an actual traversal. The three policies consume the same number of random draws per step in the same order, so a seed names one program and all three collectors run it.

Gotchas