workshop private

← all creations

High Water

viz · created 2026-10-07

A growable array over a real allocator, so the growth factor can be charged for both of the things it costs. Doubling copies each element 1.3 times and never — at any n, for an arithmetic reason — takes back one element of the 131,071 it has freed. ×1.25 takes them back 23 times and copies each element 4.8. The threshold between the two is the golden ratio, close enough that rounding the new capacity up instead of down is what decides which side you are on.

algorithmscanvas

Everyone picks the growth factor by counting copies. Geometric growth makes push amortized O(1), arithmetic growth makes it O(n), and the argument is over before anyone has looked at the heap underneath. So this puts one there — a first-fit free list with coalescing, over a bump pointer — and reallocates through it, which makes the second bill arrive.

The waterfall is one row per allocation generation: the whole address space, drawn at the scale it has reached by that step. Teal is elements that exist, dark teal is capacity bought and not yet used, grey is freed space, and the pale tick on the right is the high-water mark — the furthest address the process has ever touched, which is the number the operating system actually charges for.

At ×2 the rows march to the right and never come back.

Short by one, forever

Step through it and watch the two bars under the fall. They are the only comparison the allocator makes: the longest run of freed space against the block being asked for.

cap 64 → 128:   free run 63      asked for 128     short by 65
cap 128 → 256:  free run 127     asked for 256     short by 129
cap 256 → 512:  free run 255     asked for 512     short by 257

The free run is 1 + 2 + 4 + … + 2^k, which is 2^(k+1) − 1, and the block being abandoned is 2^(k+1). So everything the array has ever freed, added together and perfectly coalesced into one run, is one element short of the block it is about to walk away from — and a hair under half of the one it is asking for. Not usually. Not at these sizes. Every time, at every n: the header counts the reuses and it reads 0 after eighteen reallocations and 131,071 elements of freed space, and it reads 0 for every n from 1 to 20,000.

Which means the free list is dead code. Switch freed space to never reuse — bump only and the run does not change: same generations, same addresses, same high water of 262,143, to the element. An entire allocator mechanism, sitting under the most-used data structure in the language, that never fires once.

The high water settles at exactly 2× the capacity — and capacity peaks at twice the live elements, so the worst moment of a ×2 array is 3.9997 addresses touched per element you are actually storing, which the fall reaches at n = 16,385.

The threshold is the golden ratio, and rounding decides it

Wind the factor down and at some point the arithmetic flips. Reuse needs the freed run to cover the next block:

1 + r + r² + … + r^(k−1)  ≥  r^(k+1)
      (r^k − 1)/(r − 1)   ≥  r^(k+1)

which in the limit is r² − r − 1 ≤ 0, so r ≤ φ. That is where the folklore number comes from, and the simulation lands on it hard. Over 100,000 pushes:

factorreuses
×20
×1.750
×1.650
×1.620
×1.61803398870
×1.6171
×1.612
×1.56
×1.2523

The flip sits between 1.617 and 1.618. It is a knife edge in the literal sense: at exactly φ the inequality is an equality, so the rounding mode in newCap = ceil(cap × φ) is the whole decision. Round up and you get 0 reuses; round down — floor instead of ceil, one word — and the same factor reuses twice. There is no tolerance either side of it to hide in.

The first reuse under ×1.5 is worth watching frame by frame, because the numbers are small enough to check by hand. Capacities go 1, 2, 3, 5, 8, 12, 18; at the seventh generation the freed run is 19 and the request is 18, the block lands back at address 0, and the high-water tick stops at 31 and stays there while the array doubles again underneath it.

What reuse is worth, and what it costs

Running the free list against the same stream with it switched off, 100,000 elements each:

factorhigh water, reusingnot reusingreuse is worthcopies per element
×2262,143262,1431.00×1.31
×1.75243,771243,7711.00×1.39
×φ317,784317,7841.00×1.96
×1.5338,182414,7461.23×2.76
×1.25267,742602,4202.25×4.82
+8361,552625,062,5011,728.83×6,249.63

Three exact ties and then a column that starts doing work, which is the clearest statement of the threshold the demo has. And the last column is the bill you pay for it: there is no factor that both takes its memory back and copies each element about once. +8 reuses 12,469 times and is still the worst row in the table on footprint — reuse does not make arithmetic growth frugal, it only stops it being catastrophic, and it charges 6,249 copies per element for the favour.

The move that makes all of this go away

Tick grow in place. Copies per element goes to 0.000 and the high water goes to 131,072, which is exactly the capacity — no waste at all, because nothing was ever abandoned. This is realloc’s cheap path: if the space directly above the block is free, extend it there and copy nothing.

It is also the path a C++ std::vector structurally cannot take. An Allocator offers allocate and deallocate and nothing in between, so there is no way to ask “can this block get bigger where it is?” — and even with one, the elements would have to be trivially relocatable for the answer to be usable. The entire table above is a consequence of an interface, not of arithmetic.

And it is conditional on an empty heap, which is why there is a second client. Set heap to a second client allocating and its small long-lived blocks start landing directly above the array: in-place growth now succeeds 13 times out of 18 instead of 17, and copies per element goes from 0.000 to 0.088. The purple blocks in the fall are the ones in the way.

Reading it

←/→ scrub, space plays, clicking a row jumps to that generation. The gutter on the far left marks what each generation did — teal for a reuse, purple for an in-place grow — so the events stay findable when there are 249 rows and no room for labels. The two bars at the bottom are the bills, on a log axis because +8 is four orders of magnitude away from the rest; the faint ticks on them are where the other five factors land on the same stream.

Reuse

src/high-water.js is a framework-free ES module:

No rendering and no timers in the module; the canvas demo is reference code.

Gotchas