[parallel]: One Annotation, Two Engines
Measured scope. Both parallel families are oracle-green and carry regression pins. Shared captures under
[parallel]forare refused by construction; non-range iterables materialize into an 8192-itemusizebuffer; sparse fork sites remain a ruling, not a spelling. The arm64 board is hyperfine on this machine; the quiesced columns are two dedicated x86_64 passes (a DO c-2 and a Hetzner CCX43), kept separate and never merged with the arm64 numbers.
What it looks like
The whole sell is that the parallel program is the serial program. This is the complete primes kernel — capture a cell, fold the range, combine. One word makes it parallel:
capture { acc: 0[i64] }
! as a |> [parallel]for(2..200000)
! each i |> is-prime(n: @as(i64, @intCast(i))): p
|> captured { acc: a.acc + p }
| captured r |> std/io:print.ln("{{ r.acc:d }}") Delete [parallel] and the same source is serial. The annotation is
checked, not trusted: acc: a.acc + p is a provably additive write, so
the fold partitions and combines. Write acc: @max(a.acc, p) and the
compiler refuses — naming the field and the additive spelling.
Fork/join is the same idea one slot earlier — mark the sibling calls, the bindings are the partials:
fib = if(n < 2)
| then -> n
| else |> [parallel]fib(n: n - 1): a |> [parallel]fib(n: n - 2): b -> a + b No spawn, no join, no atomic, no mutex in the source. The compiler proves the shape; the runtime does the rest.
What exists
[parallel] is an invocation annotation, and it now means two different
things in two different spots — both legal, both real.
[parallel]for — the range-split fold
[parallel]for(1..N) marks the loop body’s continuation subtree threaded
(threaded_checker.zig floods dirty forward over the call graph —
including name = <flow> impls, which was the gap fork/join needed
closed). control.for.kz then lowers the loop to a chunk driver: a bare lo..hi range splits arithmetically (no materialization — this is
why the 2M–20M-item kernels fit at all), other iterables materialize into
an 8192-item usize buffer and refuse anything else at comptime. Width
tracks std.Thread.getCpuCount() capped at 64. Chunks dispatch through a
shared lazily-initialized std.Thread.Pool (spawnWg), drained by waitAndWork — the same pool fork/join rides.
Captured cells partition. capture/captured dissolve to .assignment nodes before template render; under [parallel] the
transform retargets those writes to worker-local names
(__koru_parcell_<cell> — Zig’s no-shadow rule makes the rename
load-bearing). Each worker folds a private copy: written fields start at
the combine’s identity, unwritten fields inherit the fork’s value, the
partial writes back through a slot, and the join combines into the real
cell.
The combine is proved, not assumed. A captured write is legal only
as <cell>.<f> + <expr>, <cell>.<f> * <expr> (or mirrored), optionally
wrapped in @rem(<inner>, <literal>) — the modulated forms replay the
wrap at the join. Identities: +→0, *→1. Everything else — @max, last-wins, an item-bound modulus like @rem(a.acc + 7, i) (the
join runs after i is gone) — is a teaching @compileError, not a wrong
answer.
[parallel] mid-chain — fork/join
On a sibling invocation, [parallel] marks a fork point:
| else |> [parallel]fib(n: n - 1): a |> [parallel]fib(n: n - 2): b -> a + b Each marked call lowers to an input slot, an output slot, and a spawnWg task; the join is waitAndWork — cooperative, because a pool
worker blocked in plain wg.wait() can starve the queued descendants
it’s waiting on (measured deadlock class). Bindings are the partials; no
combine law is needed. Recursion is bounded by fork-depth (threadlocal __koru_par_depth, cap 7 ≈ 2⁷ coarse tasks), not live-task
count — measured: a live-task cap bounds concurrency but not churn, and
fib(40) ran 4.9s on queue-op overhead before the depth bound.
Measured
Osprey compute-kernel board, this machine (arm64, hyperfine -N,
warmup 3, min-runs 10, every port checksum-identical to expected.txt). koru-p is the [parallel] column; full board had c/gcc/clang/rust/
haskell/osprey/c3/odin/mojo/bend/bend-par present:
| kernel | koru | koru-par | speedup | nearest rival |
|---|---|---|---|---|
| primes | 8.5 | 3.2 | 2.7× | clang 8.3 |
| digitsum | 7.6 | 2.8 | 2.7× | clang 6.1 |
| gcdsum | 86.0 | 13.8 | 6.2× | bend-par 64.5 |
| collatz | 14.9 | 3.8 | 3.9× | c 12.2 |
| mutual | 16.3 | 3.9 | 4.2× | mojo 6.6 |
| isqrt | 11.6 | 3.6 | 3.2× | rust 12.8 |
| powmod | 29.4 | 7.6 | 3.9× | osprey 28.9 |
| factorial | 40.5 | 6.8 | 6.0× | clang 40.1 |
| binarytrees | 41.6 | 12.8 | 3.3× | haskell 17.1 |
koru-par is the fastest column on every fold row — past cc -O2, clang -O3, rustc -O3, and bend-par. Where bend-par overlaps: powmod
7.6 vs 817.9 (108×), factorial 6.8 vs 481.7 (71×), binarytrees
12.8 vs 577.9 (45×). Bend’s fork machinery doesn’t pay at these
sizes; arithmetic range-splitting over a pool does.
Fork/join, honestly: suite fib is fib(35) ≈ 29ms of work — koru-par
42.0, slower than koru 29.1, because every one of ~30M calls pays the
fork-site gate and the depth cap lets only ~128 of them spawn. tak is a
wash (37.3 vs 37.9). The crossover evidence: fib(45) measured 2.89s
parallel vs 3.25s serial — real win, ~4× work amplification, still
room. Serial fib itself is C-parity: 27.5ms head-to-head vs cc -O2 27.5 (the board’s 29.1-vs-27.9 was load jitter).
On dedicated hardware
Two quiesced x86_64 passes ran 2026-10-25 — separate boards, never merged with the arm64 numbers above:
- Hetzner CCX43 (16 dedicated vCPU, quiet box): 8/9 fold rows —
gcdsum 10.2× (106.3→10.4), factorial 9.6× (43.2→4.5),
collatz/isqrt ~5.3×, powmod 5.1×, primes 3.8× — past gcc -O3, clang,
and rustc -O3 on every one. binarytrees 1.2× — the row Haskell takes
by laziness, not throughput: the port’s
check (make 13)doesn’t mention the loop variable, so GHC’s full-laziness pass floats it out and builds the tree once; its 1.8ms is 1200 additions of a constant, while every other lane builds all 1200 trees. Different measured work — not a row parallelism can win. fib −1.7×, tak even — the dense fork-site crossover, honestly unchanged by more workers. - DO c-2 (2 vCPU): 6/9 fold rows, up to 1.88×.
Geomeans, counted with the losses in. On the CCX43 board: fold rows geomean 4.61× over serial Koru (nine rows); the full parallel set — including the two fork/join rows that lose — is 3.33× over eleven. Against the fastest non-Koru lane per row, koru-par wins 8 of the 11 rows it ran: every fold row except binarytrees, and neither fork/join row.
The other parallel lane. bend-par is the board’s only other
parallel class — absent on both dedicated x86 rigs (toolchain not
installed there), so that comparison stays on arm64 where it ran: 4 of
17 rows won against the best serial lane, a 3.4× geomean slower than the serial best. Automatic parallelism without a legality gate
pays for the rows it can’t prove; the annotation’s refusals are what
the wins are made of.
And the engine isn’t suite-shaped. LangArena’s 47-task board was
43/47 ahead serially — so the interesting question was the five
deficits. These live in a separate koru-par/ lane, honestly labelled:
the arena’s own references are serial classes, so a parallel port is a
different-class number, never cherry-picked into the serial board.
clbg_spectralnorm(1.202× behind):[parallel]foron the four independent row loops, no other source change — 820.7ms → 156.6ms, 5.22×, checksum-identical.matmul_single’s deficit (1.203×) is a class question — the arena hasMatmul::Single(serial) andMatmul::T4(threaded pool over row ranges).koru-par/matmul_t4ports T4’s semantics honestly:[parallel]foron the 900-row loop — 6.97s → 0.815s, 8.5×, checksum-identical.binarytrees_arena— legal additive fold, checksum-identical, ~1.0× gain: a 2M-element fold is a pure memory stream already at bandwidth. Legal ≠ profitable — the counter-row that keeps the lane honest.etc_logparser,compress_bwt_decode— sequential streaming / per-symbol prefix chains: honestly serial, the refusal class.
What does NOT exist yet
- Sparse fork sites.
[parallel]marks sites statically; every marked call pays the gate. A size/depth-conditional fork ([parallel]fib(...) when n > 25-shaped) is a ruling, not a spelling. - Shared captures. Partition-per-chunk is the only semantics. Reads
of other outer cells inside a
[parallel]forbody hit the emitted worker’s namespace boundary — loud, but not yet teaching-formatted. - Lockset declaration tiers.
[lock(name)],[lock(param)],[threadsafe],[serial]— ruled in the frag, unimplemented.
The design it came out of
The ruling (frag-locksets-are-the-unit-of-synchronization): locks are a
protocol between accessors, not a call-site property — the unit is the lockset, and any threaded-reachable member dirties the whole set.
Parallel folds sidestep locking entirely: partition means nothing is
shared, so there is nothing to lock. Deferred: obligation guards,
declaration tiers, shared-capture semantics.
The pins
Honest distance from the claim
“Koru has parallel loops and parallel recursion” — both halves are now real, oracle-verified, and measured. The fold engine is past every compiled-language column on nine rows on this machine. The fork/join engine is correct, deadlock-free, and wins once the workload clears the crossover — what it lacks is a way to place fork sites sparsely, which is a language decision, not an implementation one. The automatic-locking half of the lockset story remains analysis plus declaration-tier plumbing.