The Loop That Measured the Language

Before designing the memory fix, the discipline demanded numbers, so the probe was the simplest program that never stops allocating: count lines from a stream. It could not even be written — read_line existed only in the interpreter. Once it could, the measurements refused to match anyone's story: the string version held thirty permanent bytes per line, and the supposedly frugal byte-buffer version was ten times worse. Chasing that anomaly uncovered the most consequential performance bug in the compiler's history — and fixing it made a counting loop leaner than wc.

meremeasurementuncurryingperformancelanguage-design

The server’s memory problem was tangled in threads, channels, and a store — too many moving parts to reason about a fix. The method said: isolate. The minimal program with the same disease is a single-threaded loop that reads a line, looks at it, and forgets it — wc -l in the language itself, fed millions of lines, with resident memory as the only output that matters. If per-item reclamation is ever going to work, it has to work here first. And the probe earned its keep before the first measurement, in a familiar way: the language had a read-a-line builtin, but only in the interpreter — the sixth member of the interpreter-only family that native programs keep discovering. read_stdin slurps the whole input by design; a native streaming line reader could not be written at all until the C backend learned read_line. One small release later, the measuring could begin.

Two dialects, two wrong numbers

The string-dialect counter — idiomatic code, read_line and str_len — showed exactly the expected disease: resident memory tracking input size, about thirty permanent bytes per line, a quarter-gigabyte to count eight million lines that wc counts in two and a half megabytes. Every line string lands in the program-lifetime region and stays. That number was the baseline the eventual fix would be judged against. The control experiment was supposed to flatter the language: the byte dialect — tcp_read into one reused buffer, counting newlines with mem_get_u8, no strings at all — should have run in constant memory. It ran in two gigabytes. The frugal version was ten times worse than the wasteful one, and no story anyone believed about the language could explain it. This is what measurement is for: a number that contradicts everyone’s model is a bug’s home address.

Every call was paying rent

Bisecting the anomaly led somewhere no one had looked, because it had been hiding inside the most ordinary code shape in the language. A multi-parameter function in an ML is a chain of single-argument functions, and the C backend compiled every call level by level: count buf i n acc became “call count with buf, get a closure back, feed it i, get a closure back…” — and each step allocated a closure environment in the program-lifetime region, through the region’s thread lock, on every call. A four-argument function in a loop over three hundred million bytes allocated three environments per byte, forever. Every hot loop in every dogfood had been paying this rent invisibly; the byte-dialect probe just paid it three hundred million times in a row. The fix is the classic one — for each top-level curried function, also emit a direct N-ary C function, and compile exactly-saturated calls straight to it, pinning the interpreter’s left-to-right argument order with temporaries because C’s is unspecified. Partial applications keep the closure chain; nothing observable changes. What changes is the meter: no environments, no lock traffic, and self-recursion becomes a genuine C tail call.

Leaner than wc, and an honest split

With uncurrying in place the byte-dialect counter processed three hundred megabytes in a tenth of a second at one and a half megabytes of memory, constant across input size — below wc itself. The number is pleasing; the diagnosis mattered more. The string dialect re-measured at essentially the same quarter-gigabyte as before, which was the point: the two problems had been cleanly separated. One was an accident — a compilation strategy silently taxing every saturated call, now abolished for good. The other was the real design debt: idiomatic string code still had no way to give per-line memory back, because the values themselves, not the calling convention, were permanent. The probe had done what probes do — turned “the server leaks somehow” into two precise statements, one already fixed and one now unavoidable. The next episodes take on the unavoidable one, but not before a detour: while the memory work was being staged, a generic data structure quietly demonstrated that the interpreter and the compiler didn’t always agree on what a program means.

← Back to Mere: Building a Language