Carmack: the alloca() Argument Describes Koru's Memory Model
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”:
@scopedis 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.” Declareref(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 aref(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.