Carmack: the alloca() Argument Describes Koru's Memory Model

· 13 min read AI Authored
korulanguage designmemory modelcarmack

John Carmack posted an argument that reads like a design document Koru already implements: alloca() was unfairly maligned, stack-style allocation has real correctness and performance advantages, and the one piece it needs — static analysis for escaping stack pointers — is checkable.

He is describing a memory model. Here is what it looks like when the model is not a discipline you apply but the surface the language gives you. Each of his claims, itemized against what Koru actually emits.

“Give every stack a virtual address space equal to physical memory”

Koru’s answer is stronger, and it is why there is no alloca to defend: the anonymous stack is not where data lives. Every plurality declares its extent at its declaration:

std/grid:new(buckets, size: 8) { count: 0[i64] }
std/grid:new(lights, dimensions: 4x5) { on: 0[i64] }

That declaration emits one flat var — a struct holding [8]i64 column arrays inline. Not a heap allocation: static storage, contiguous, address-stable.

The store is the same declaration discipline pointed at a harder question: std/store:new(dogs, capacity: 8) { Dog } emits the same flat var shape with [CAP] columns — but a store’s rows can come and go (insert, take), so a row is named by a generational handle and position is not identity. The grid’s version of the same trick forgoes removal entirely: every cell is live from declaration, position is identity, and the handle machinery — brand, generation word, sparse indirection — simply doesn’t exist. Two ways to do the same thing, and the difference is spelled in the contract, not discovered at runtime. A ring (std/rings:new(feed, capacity: 256)) is the third: a fixed [capacity]Slot array, cache-line aligned, head/tail bump. Carmack wants stacks with huge virtual reservations; Koru hands every kind of data a named, pre-sized, contiguous home — which is the thing that makes huge pages easy.

And the compute layer sits on the same memory, not beside it. std/kernel is the shader-language corner of the library — you describe relationships between elements (pairwise is every unique i < j pair, self is per-element, reduce is a fold), and the transform emits the iteration. Point it at a store by name and it operates on the live SoA columns in place — k.other resolves to the partner row’s fields, no copy, no marshal:

std/store:new(world, capacity: 8) { x: f64, vx: f64, mass: f64 }

std/store:insert(world) { x: 0.0, vx: 1.0, mass: 2.0 }
std/store:insert(world) { x: 1.0, vx: 2.0, mass: 3.0 }

std/kernel:init(world) {}
| kernel k |> std/kernel:step(0..1)
    | step |> std/kernel:pairwise {
            const dx = k.x - k.other.x;
            const mag = 0.1;
            k.vx += dx * k.other.mass * mag;
            k.other.vx += dx * k.mass * mag;
        } |> std/kernel:self {
                k.x += k.vx;
            }

Elsewhere this is exotic machinery — in Koru it’s the ordinary reach for collision detection, an n-body, a field update. And the surface stays declarative: a for nested inside a kernel body refuses (390_050), because the kernel is a description of the computation, not a loop you wrote.

“Allocations are a trivial pointer adjustment, and frees are literally free”

The homes are exactly that. A grid read is an offset into a fixed array. A store insert is len += 1 over the columns; take is a swap-remove; a ring enqueue is a head bump. No per-row allocator call exists anywhere in the emitted code — because there is no per-row allocation.

Linear packing is not incidental, either. The grid’s default layout is column (Struct of Arrays) because that is what vectorizes — a measured 11× over row layout on a sweep-dominated workload — and the row option exists for the scatter-dominated case as a named annotation, [layout(row)].

The bounds check itself was re-engineered when its noreturn edge was found to defeat LLVM’s vectorizer (187 ms → 99 ms on the boids steer loop; the escape hatch is [unsafe(bounds)], named because a bare [unsafe] would widen silently). The cache hierarchy is the design constraint, not an afterthought.

“Leaving temporary intermediate blocks on the stack can seem wasteful, but…”

Koru formalizes the waste and makes it safe. std/fmt:ln formats into a per-call-site thread-local scratch buffer — the bytes just sit there, never freed, valid until that site runs again:

greet = std/fmt:ln("Hello, {{ name:s }}!"): l |> std/io:print.ln(l.text)

The scratch belongs to the site, sized by a [size(16k)] annotation on the call if you need more than the default 4 KiB. Carmack’s “temporary left on the stack” is here a first-class, typed thing: l carries scratch@scoped, which the compiler treats as frame-bound.

“Static analysis can check if you store a stack pointer into a structure deeper in the stack”

This is where the post stops being a proposal and starts being a description. Carmack sketches the check as something a language could do. In Koru it is the load-bearing wall, generalized from “stack pointer into deeper stack” to “any borrow into a scope that outlives it”:

  • @scoped is a confinement class — a frame-bound borrow. Hand one to a parameter that does not declare it and the compile refuses: argument 'l.text' is @scoped — it dies with its frame and parameter 'message' declares no scoped acceptance. Declare it 'string<*@scoped>' … or copy it out first.
  • Producing a scoped value out — -> l.text — is refused for the same reason: output position is where a frame-bound borrow escapes into a scope that outlives its frame.
  • Long-lived references are not addresses at all — they are handles into named homes. ref(Dog) in a record means “a handle minted by a plurality that holds Dog.” Declare ref(Ghost) where no store holds Ghost’s fields and the declaration itself refuses — the column could only ever hold -1. Write a cats-minted handle into a ref(Dog) column and it traps at the write.
std/proto(Dog) {
    hp: i64
}

std/proto(Owner) {
    name: string
    dog: ref(Dog)
}

std/store:new(dogs, capacity: 8) { Dog }

The same ledger tracks obligations: a binding typed *T<state!> owes a discharge, std/string:free settles it, and auto-discharge covers the obvious cases — “you owe a free” is a compile error, not a convention. Every emitted binary also carries a debug-mode leak counter that fails the run on outstanding allocations. Carmack’s hypothetical static check is, in Koru, just the type system — and it covers heap debt too, not only frames.

The reason checks like this are cheap here is that Koru pushes program flow into the type system. A tor’s outcome alphabet is part of its signature — | ready u32 | failed string declares every edge a call can produce — a continuation maps named branches to the next step, @label back-jumps are literal syntax, and ! effect arms are visible to the flow checker. The compiler never reconstructs a control-flow graph from imperative bodies; the program states its graph. Questions that are interprocedural-analysis hard in C-shaped languages are walks over declared structure: a fold whose every arm jumps back is refused at compile time as provably non-exiting — an external fuzzer produced exactly that shape once, and the refusal is now a pinned test.

A stated graph is only this cheap to walk because of what the grammar leaves out: there is no lvalue in the C sense. A name in a Koru flow is a capture — | kernel k |> or ! query r binds a name to whatever the edge carried, and that is the only way a name comes to exist. You can’t assign to it, take its address, or leave it uninitialized. A @label jump doesn’t store into a cell; it re-seeds the target’s captures — the params it names are bound fresh, the rest carry over. Mutation exists, but it is addressed: k.vx += writes through the kernel’s view into the store’s columns, and var survives only inside bodies that are themselves emitted code — a |zig host body, a kernel body, a comptime proc — as local scratch, never as flow state. Even the keyword that does exist tells the same story. const is legal in a .k file — spelled const { name: value, ... } — and it is a declaration construct defined by a standard-library module (koru_std/declarations.kz), not by the grammar. It binds module-level values only; there is no assignment form paired with it. The C-style const NAME: Type = value; simply doesn’t match that shape and refuses at parse — the keyword exists; the cell doesn’t.

“Functions could return with the stack extended from where they were called”

He names the awkwardness honestly: caller-allocates-return doesn’t fit dynamic cases. Koru’s answer is that the awkwardness was in the frame model, not the idea. A continuation tor(x): y |> use(y) is grafted at the call site — the product lands in the caller’s continuation scope; there is no separate callee frame for it to die with. And for genuinely dynamic-size products, the storage was never the callee’s to begin with: fmt:ln’s result borrows the call site’s scratch. That is the semantics Carmack wants, achieved by moving the ownership up rather than stretching the frame down.

Why a lean language gets to do all of this

None of the above is a “feature” in the usual sense, because Koru’s real move is upstream of features: the language is a small grammar plus a compile time strong enough to write programs, and the standard library is mostly compiler plugins — [comptime|transform] tors that run inside koruc, read the invocation site, and emit the storage and access code. std/grid:new, std/store:new, std/rings:new, std/proto, std/fmt:ln are all that shape. The surface you write in is theirs.

A few consequences worth naming, because they are the ones that look like absences:

There is no runtime. A whole compiled GEMV program emits 957 lines of Zig; the prelude is ~90 lines — an allocator shim, a debug leak counter, an int cast. No GC, no VM, no scheduler, no reflection. That program makes zero allocator calls: allocation is an act you invoke by name (std/string:from-page), never something the language does behind your back. The only vtable in the emitted image is the host allocator seam.

The language/library boundary is blurred on purpose. import doesn’t just make names visible — a module can carry [comptime|transform] tors that fire because the import landed them in the program tree; the entry file never writes the invocation. And a transform is not a macro. A macro closes over its own subtree; a transform proc is handed item — its call site — and program: *const std/compiler:Program, the whole program AST, and returns a SiteResult that can emit arbitrary new structure. The tool a library author gets is not a weaker dialect of the one the language designers kept for themselves. Which reframes every “Koru doesn’t have X” in this post: it means no module has emitted X yet. Heap allocation exists at all because std/list, std/string, and their kin chose to provide it — there is no new in the grammar; search the lexer for an allocation keyword and there isn’t one to find. C++ put new in the language. Koru left it to a module — the same place it would leave whatever memory strategy you decide to write next.

The whole program is one compilation unit. Every module lands in a single emitted file. This is the part that is not stuck in the 1960s: separate .o compilation is what freezes layout decisions at ABI boundaries — the concretization has to happen per-module because the other side can’t see you. Whole-program visibility is what lets a proto stay layout-silent.

Protos defer concretization. std/proto(Dog) { hp: i64 } declares identity and relationships — a nominal affinity, <: parents as field-set union — and emits nothing. The consumer derives layout: a store spreads the fields into columns, a list synthesizes an element struct, a ring wraps it in a slot. In C struct { int hp; } is a layout decision made at declaration — premature concretization baked into the type system. Koru concretizes at the last responsible moment, in the module that owns the storage, and one proto can be columnar in one home and packed in another.

The concretized shapes differ completely — implicit-SoA columns in the store (made for striping), a heap element struct in the list — but the conversion between them is a value, and the ledger keeps it resource-safe in both directions. take extracts a row by value: SoA in, stack-bound record out. A bare store’s take is frictionless remove-with-value; an [entity] store’s take mints Enemy<std/store:taken!> — the row itself owes a discharge, and a program that never consumes it refuses to compile. Push those same fields into a list and they’re heap-side under the list’s own <list!> obligation, with pop handing ownership back through | item. Different layouts, one identity, and the debts travel with the value — never with the container you happened to read it from.

There are no iterators. for(0..512) ! each j |> body grafts the arm body into the loop site — the emitted code is a plain for with the body spliced in. Store queries, watches, grid sweeps, walks are the same act: a branch whose body is transplanted at compile time, monomorphic, no dispatch object. An iterator is a reification you only need when the compiler can’t splice.

There are no generics — there is something stronger. A generic is what a type system invents when its compile time can’t compute. Koru’s answer is a transform that reads bracket parameters at the call site and writes the specialized program:

gemv.kernel[T:f32;I:1024;J:512](name: "k_f32")
gemv.kernel[T:f16;I:1002;J:512](name: "k_f16t")

The same logic swallows F# type providers: “connect to a database to mint types” is not a language feature here — it is an ordinary thing a transform proc could do, because a transform is arbitrary compile-time computation with the whole program in hand. std/regex already does the miniature version: it compiles your pattern into a DFA at compile time and hands you the automaton as control flow.

The receipt: one naive source, four ISAs

The strongest evidence that this works is a spike we ran last week against Eugene Ostroukhov’s “only possible in C++” GEMV kernel — four hand-specialized monomorphs (fp16 and fp32, each with and without a tail) over Google Highway, targeting NEON / SSE / AVX2 / AVX-512 / WASM SIMD.

The Koru version is a naive pure-.k program — grids, a for, an accumulate. No vector types, no intrinsics, no specialization machinery at all. LLVM auto-vectorized it to instruction-for-instruction his shape: lane-broadcast fmul.4s/fadd.4s on NEON, vbroadcastss + vmulps on AVX2, v128.load32_splat/f32x4.mul on wasm, native 8-lane fmul.8h for fp16 on arm64. The tail case needed no if constexpr arm — a scalar epilogue appears on its own. Two paths in one program, checked bitwise against each other.

And the generic/gemv.kz half is the part no template expresses: the transform above parses gemv.kernel[T:f16;I:1002;J:512] and synthesizes a complete specialized artifact — typed storage, unrolled kernel, a differential oracle, a report function — per invocation. C++ if constexpr chooses between arms the programmer wrote; a Koru transform writes the arms.

The honest delta, stated plainly: his MulAdd is FMA-fused; ours keeps fmul+fadd separate under strict FP ordering. Different answers, both correct — and no end-to-end performance claim is being made here, because the workloads differ and we have not raced them.

Carmack’s last paragraph

“It isn’t obvious if having AI coding agents makes discussions of software architecture more or less important.”

Koru’s answer: the architecture that matters is the one the compiler enforces. An agent writing Koru cannot mint a pointer — there is no address-of production in the grammar; a *T arrives only as a tor’s declared result or a parameter’s declared borrow. It cannot pass a scoped borrow somewhere that doesn’t accept it — KORU030 names the refusal and teaches the fix. It cannot skip a discharge. The vapid first-class concepts aren’t available to hallucinate, because they were never made expressible.

Where the metaphor ends

Koru is not stack-only, and the post would be dishonest if it read that way. Strings and lists allocate through koru_allocator() — the page allocator, or libc’s under a libc link — and the obligation ledger plus the leak check are what police that half. proc bodies are ordinary ABI calls returning packed unions. Carmack’s model is a per-thread implicit stack grown huge; Koru’s is named homes with declared extents plus frame-bound borrows. He arrived by wanting alloca() back. Koru arrived by deleting the general pointer — and discovered, somewhere in the middle, that they were building the same machine.