The 64-Megabyte Cliff

With the ray tracer assembling, the question became what the boxing costs. Measured: a 96-by-54 render allocates twenty-six megabytes from a bump allocator that never frees — about five kilobytes per pixel, for an image whose text is fifteen kilobytes. Scale the same program to 320 by 180 and the tax exceeds the backend's fixed 64 megabytes of linear memory: the render traps a third of the way in, out of bounds, exactly at the last page. Along the way the checksum itself had to change — a CRC-32 needs 0xFFFFFFFF and a logical shift, neither of which a 32-bit signed int can express, so the backend's integer width reached up and chose the algorithm. The fix for the cliff is known, scoped, and deferred: allocation is inlined at seventy-nine sites, centralizing it is real work, and no real program needs it yet. Writing down the number the cliff sits at is worth more than pretending it isn't there.

merewasmmemoryperformancelanguage-design

The declarations were fixed and the ray tracer ran on all three backends with one checksum and one image. That answered correctness. The probe’s original question was still open: what does boxing every float actually cost? A small harness that runs the module and reads the allocator’s bump pointer afterward gave the number. The 96-by-54 render — five thousand pixels, fifteen kilobytes of output text — allocated twenty-six megabytes. Five kilobytes of heap per pixel, none of it ever freed, because the bump allocator only moves forward. Every multiply, every dot product, every component of every intermediate vector is eight bytes plus alignment, carved out and abandoned. The C backend renders the same image in a few megabytes of process; on Wasm the arithmetic itself is the allocation.

The cliff has an address

Then the same program at 320 by 180. The tax scales with pixels: eleven times the area wants roughly two hundred and ninety megabytes, and the backend’s linear memory is declared at a fixed 1024 pages — sixty-four megabytes, with nothing anywhere that calls memory.grow. The render traps a third of the way through the frame, out of bounds, with the bump pointer parked at exactly 67,108,864. The deferred item called “Wasm memory growth” had been an abstract worry for months; now it has an address. A 96-by-54 float program works. A 320-by-180 one dies. The cliff edge is a number you can write down.

When the integer width picks your algorithm

One more finding arrived sideways. The plan was to checksum the pixels with CRC-32, the same checksum every binary probe had used — and the Wasm backend rejected the constant 0xFFFFFFFF at compile time, with the pointed error built two months earlier for exactly this: the literal doesn’t fit a 32-bit signed int. And it isn’t just the constant. CRC needs a logical right shift, and the backend’s shift on a signed int is arithmetic. The honest response wasn’t to contort the CRC but to change algorithms: an Adler-style checksum, two running sums modulo 65521, every intermediate value comfortably inside 31 bits, identical on a 63-bit interpreter, a 64-bit C backend, and a 32-bit Wasm backend. It is a small, concrete lesson about portability: the backend’s integer width doesn’t just bound your values — it reaches up into your design and picks your algorithm.

Deferring with a number attached

The obvious next move would be to fix the cliff: teach the bump allocator to grow the memory. Scoping that revealed why it has been deferred twice already — allocation is not a function but an idiom, inlined at seventy-nine sites across the emitter and the hand-written runtime templates. The robust fix is to centralize all of it into one allocation routine that checks, grows, and bumps; the same shape as the namespacing change two episodes ago, where a uniform mechanism replaces scattered repetition and the test suite verifies the migration. That is real, bounded work — and no real program needs it yet. The honest posture, recorded in the deferral file with the measurements attached: on Wasm, keep float-heavy computation small; the cliff is at sixty-four megabytes; growing past it is a known refactor awaiting a program that demands it. And the tax itself — five kilobytes a pixel — survives any amount of memory growth. The real fix there is unboxing floats into typed locals, and that is a different, larger episode for a different day. A probe that ends with two numbers and a scoped plan is a probe that did its job.

← Back to Mere: Building a Language