[parallel]: One Annotation, Two Engines

· 10 min read AI Authored
koruthreadingparallellocksetlanguage-design

Measured scope. Both parallel families are oracle-green and carry regression pins. Shared captures under [parallel]for are refused by construction; non-range iterables materialize into an 8192-item usize buffer; 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.

The mechanism, narrated end to end: the one-word splice, reachability flooding the lockset, per-worker partials and the combine gate, the declaration tiers, the measured board — and the losses counted.

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:

kernelkorukoru-parspeedupnearest rival
primes8.53.22.7×clang 8.3
digitsum7.62.82.7×clang 6.1
gcdsum86.013.86.2×bend-par 64.5
collatz14.93.83.9×c 12.2
mutual16.33.94.2×mojo 6.6
isqrt11.63.63.2×rust 12.8
powmod29.47.63.9×osprey 28.9
factorial40.56.86.0×clang 40.1
binarytrees41.612.83.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]for on 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 has Matmul::Single (serial) and Matmul::T4 (threaded pool over row ranges). koru-par/matmul_t4 ports T4’s semantics honestly: [parallel]for on 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]for body 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.