It Wasn't the Bytes

Inflating one megabyte cost 484. The obvious suspect was the byte representation — a vector of ints spends eight bytes per byte, and this whole arc had been circling whether the language needs a real bytes type. Measurement said no. A million plain pushes cost 17 megabytes; a four-argument curried inner recursive function called a million times cost 769; the same work with one tuple argument cost 1.4. The tax wasn't the bytes. It was that curried inner functions had never been uncurried the way top-level ones were — every partial application allocated a closure that the region never freed.

mereperformanceclosuresmemorylanguage-design

The decompressor, once it compiled, printed the right bytes — and one wrong number. Inflating a one-megabyte file used 484 megabytes of memory. The whole bytes arc had been building toward a design question: does the language need a real bytes type, since it was representing binary data as a vector of ints at eight bytes per stored byte? The 484 looked like the answer arriving as a bill. It wasn’t. This episode is about following a number to its actual cause instead of its plausible one — and finding the plausible cause completely innocent.

Three measurements

The discipline that mattered was refusing to fix the suspect before confirming it. Measurement one: a loop that pushes a million integers into a vector, nothing else — 17 megabytes. That is the eight-bytes-per-int representation plus the doubling buffer’s leftovers, and it is the entire cost of the byte representation at this scale. Whatever was eating 484 megabytes, it was not the vector of ints. Measurement two: the smallest program shaped like the decompressor’s hot path — a four-argument curried inner recursive function, called a million times — 769 megabytes. There it was. Measurement three, the confirmation: the identical computation with the inner function taking a single tuple argument instead of four curried ones — 1.4 megabytes. A five-hundred-fold difference, and the only variable was currying. The bytes were exonerated; the culprit was the shape of a function.

The uncurrying that only reached half the language

The mechanism, once named, was clear. A curried function fn a -> fn b -> fn c -> fn d -> ... applied to all four arguments is, underneath, four applications: each supplies one argument and returns a new function closing over it. For a top-level function the compiler had, since an earlier episode, emitted an uncurried twin — a single function taking all the arguments at once, used whenever a call site supplies them all at once, skipping the intermediate closures. Inner (nested) functions had never gotten that treatment. So the decompressor’s Huffman decoder, a four-argument inner recursive function called once per symbol and recursing several times within each call, allocated a fresh closure environment from the region on every partial application and every recursive step — and the region, a bump allocator, never frees. A megabyte of output is millions of symbols; the closures piled up into hundreds of megabytes.

The fix extends the uncurrying to inner functions: a curried inner function with concrete parameter types now gets the same direct twin, and saturated calls — crucially including the recursive self-call inside the function’s own body — use it, with no closure allocation. The single-argument closure form stays for the rare partial application, so the change is additive. Measured: the million-iteration microbenchmark fell from 769 megabytes to 1.46 — the tuple-rewrite’s number, reached without rewriting anything — and the decompressor went from 484 megabytes to 34, still producing byte-identical output with a verified checksum. The arc that set out to weigh a bytes type closed by finding the memory had nothing to do with bytes. The design question it was really asking had been answered two features early, for top-level functions only; the dogfood’s contribution was to show the other half of the language needed the same answer. What the next part measures is, as ever, undecided — a real program will point at it.

← Back to Mere: Building a Language