C# Backend: Koru Compiles to .NET — Dispatch Without Delegates
Koru now compiles to three hosts. koruc app.k --lang=cs writes output_emitted.cs, dotnet build produces a real binary, and the regression suite runs the same expected.txt against the emitted C# that it runs against Zig — one source, three targets, one verdict file.
The question this backend started with was not how fast the dispatch would be. It was whether an effect branch can be pushed onto .NET at all. A Koru ! tick is not a callable looking for a host spelling — it is a continuation edge: the arm is attached at the call site, the compiler can see both ends of it, and nothing is stored in a runtime list anywhere. If that structure survives the trip, cs is a backend. If the branch has to dissolve into a delegate to exist on the runtime, cs is a demo that happens to print the right output.
C#‘s answer to “name this behavior and pass it” is Func<T>: an object holding a method pointer, allocated when the lambda captures, invoked through a call instruction the JIT can’t see through. async/await is the same idea with a bigger cloak — the state machine is real, it just lives where the source can’t see it. Koru’s arms are the opposite shape: a ! tick pulse and the arm that answers it are continuations spelled in the grammar, visible to the compiler end to end. Nothing is stored in a runtime list and nothing captures a variable nobody named.
So when the C# emitter needed a surface to push the branch onto, the delegate was available and wrong — it would have turned the edge into data. What it emits instead is C#‘s one spelling of what Zig writes as comptime __H: type:
pub tor beat {}
! tick
| done i64
pub tor pulse {}
! tick
| done i64
pulse = tick() => done 0 public static class beat_event {
public struct Input {
}
public struct Output {
public string tag;
public long done;
}
public interface IOps {
void tick();
}
public static Output handler<H>(Input __koru_input, H ops) where H : struct, IOps {
void tick() => ops.tick();
tick();
return new Output { tag = "done", done = 0 };
}
} Input/Output are structs — Zig’s tagged-union envelope flattened into the host’s record. IOps declares one member per arm — a void tick() method, not a Func property. The handler’s body is beat’s ~proc beat|cs host text, transplanted from dispatch.kcs. And at each call site the emitter writes a private struct __koru_handlers_N : beat_event.IOps whose methods hold the arm bodies, then calls handler generic on it:
public static void flow0() {
var __koru_handlers_0 = new __koru_handlers_0 { };
var result_2 = main_module.beat_event.handler(new beat_event.Input { }, __koru_handlers_0);
var r = result_2.done;
}
struct __koru_handlers_0 : beat_event.IOps {
public void tick() { }
} new __koru_handlers_0 { } is a struct on the stack — no capture object, nothing for the GC — and result_2.done is a field read off a struct Output. The where H : struct constraint is the whole move: the JIT sees the concrete ops type at the call site, specializes handler<H> per site the way Zig instantiates per Handlers_N, devirtualizes ops.tick(), and inlines the arm body. The arm is not a value passed to a dispatch machine. It is a name the compiler resolves — on .NET, resolved all the way into the caller’s instruction stream.
A lambda capture that can’t be proven safe falls back to a stored Func — the old shape is still there for the arm that needs it. But the interface stays statically callable; the fallback is per-arm, not per-dispatch.
The numbers, measured
tests/benchmarks/008_effect_dispatch compiles the real emitted beat_event.handler<BenchOps> into the benchmark assembly — if the emission shape changes, the harness stops compiling — and fires one ! tick per call, 30M calls per rep, min of 3, .NET 9 on arm64:
| arm | ns/call | B/call | what it is |
|---|---|---|---|
| generated | 0.3 | 0 | the emitted handler<H> + ops struct |
| gen-subflow | 0.3 | 0 | same, with a pure-Koru subflow impl (pulse) |
| direct | 0.3 | 0 | a static call to a static body — the atom |
| struct-method | 0.3 | 0 | the handwritten where H : struct analogue |
| virtual | 2.0 | 0 | interface call on a class |
| typed-func | 2.2 | 0 | Func<int> properties — the delegate floor |
| gen-dynread | 10.0 | 56 | the typed Output read through dynamic |
| anon-func | 21.3 | 80 | Func<dynamic> properties + anonymous objects — the previous emitted shape |
Generated is the atom. The dispatch surface pays literally nothing over a static call — the DOTNET_JitDisasm receipt shows handler<H> specialized to 20 bytes per ops struct, tick inlined to nothing. The delegate floor it isn’t paying is 2.2ns; the Func<dynamic> shape it used to emit is 71x that. And gen-dynread is the honest line-item for what remains: read the same typed Output through a dynamic reference and boxing plus the call-site binder cost 10ns and 56 bytes, every call.
Two further measurements answer “but what about composition”:
009_effect_chainingruns a value through 8 resuming arm hops per call (chain = step(x): a |> step(a): b |> … |> step(g): h => done h), 30M calls: generated and the fused handwritten loop both measure 9ms — and theDOTNET_JitDisasmreceipt shows why that isn’t noise: the emitted chain’s hot loop is instruction-identical todirect, five arm64 instructions, all eight hops inlined and folded toi + 8, theInput/Outputenvelope promoted to registers so thetagstore doesn’t exist in the machine code. Interface dispatch is 19ms, a composedFunc<long,long>chain 24ms, LINQSelect×8+Sum647ms — 72x.010_numeric_loopruns the language’s ownforwith an effect hop per element, 100M elements: generated 121ms against handwrittendirectat 119ms — and the claim is again the disassembly: the emitted hot loop is instruction-identical todirect—madd; add; add; cmp; blt, accumulator register-resident, module cell written back once after the loop. (Thefuncarm reads 39ms on this machine; the README files the spread on these near-identical streams as microarchitectural, and pins the verdict to the disassembly, which is the instrument that can’t lie about it.)
Neither number started there. 009 first measured 15ms against direct’s 9, and the README called the residual the Input/Output envelope — plausible, and wrong: the disassembly showed the only extra instruction was one sxtw, and it belonged to the harness, not the emission (an int counter feeding a long field). Symmetric counters, identical loop, identical time. 010’s 1ms residual was the emitter’s — for|cs emitted var i = 0, var binds the literal’s width, and the usize counter came out int, paying two sxtw per element against a long bound. The template now emits long, and the loop is the same five instructions as the handwritten atom. That is the standard these numbers are held to: a residual is not a finding to explain away — it is an instruction to find, and on this branch the instrument that finds it is DOTNET_JitDisasm, not the stopwatch.
The same question at program scale got its own benches on this runtime. 011_linq_task races the emitted run_event.handler against LINQ’s headline Where().Select().Sum() on .NET itself — generated sits on the handwritten foreach/direct floor, 2x LINQ’s best case and 4.4x the lazy source-equivalent. 012_threat_scanner runs the canonical LINQ post’s Where/Select/ToList verbatim — the emitted program beats LINQ’s array fast path 1.03x, the lazy IEnumerable the posted signature takes 1.69x, and is indistinguishable from handwritten C#. That race is its own post.
What the third backend was actually for
A second emitter isn’t a port — it’s a falsifier for the shared pipeline, and the store sweep paid that thesis out in full. Porting std/store to cs surfaced defects the Zig emitter had been silently absorbing for months:
- A saturating subtraction assumption. Zig’s
-|clamps at zero, so the bulk-insert freelist accounting never underflowed. C# has no saturating subtract, the emitted plain-draggedhslot_free_lennegative, and the nexttakeindexed below zero — a real corruption on any host without-|, invisible on the one that had it. The cs arm emitsMath.Max(0, …); the zig text is unchanged and still correct. - A reserved-name collision nobody declared. A user column literally named
tagmet the generatedOutputstruct’spublic string tagdiscriminator — legal on Zig’s union, a CS0102 on C#‘s flattened record. The payload member now emits as__ktagwhile the discriminator convention stays intact. - A cold path that had never compiled. The
[tree]store’s cycle-guard proc emitted zig-shapedconstlocals and an anonymousnew { … }argument into a typedInput— refused the first time any test asked, because no tree test was cs-marked.695_002probes it now:| cyclefires, preorder stays intact. - The sweep caught the sweep’s own change. Multi-field record payloads moved from anonymous
new { … }objects to named tuples — the only record shape a C# signature can spell. The full cs sweep then caught what a 12-test control set hadn’t: on a store with no owned columns,take’s| itemdeclares a single__type_refwhile the body hands back the whole row record — aValueTupleboxed into adynamicmember erases its element names, and six tests died toRuntimeBinderExceptionbefore the spelling learned to key off the declared arity, not the value’s.
That’s the honest accounting: the bugs were in shared lowering all along, and each fix landed once, in the shape layer both emitters share. The sweep didn’t stop at the store — cs is a declared target across the suite now, 866 tests carrying the mark.
What is still dynamic, honestly
The dispatch path is clean; the coverage boundary is not. dynamic remains where the decl cannot name a type at emission: wildcard * inputs (transform-only machinery), resume-sum arm results, Func<dynamic,dynamic> on optional resuming arms (presence is observable — ask != null has no method spelling), and __cap_* capture fields, which box value types because flow locals carry no declared types. Every one of those is a gen-dynread waiting to happen the day that shape lands in a hot loop.
Record payloads came off that list this week. A Koru { a: 1, b: 2 } arm payload used to emit new { … } — js_emitter’s anonymous-object shape, compilable but unnameable, so every signature spelled dynamic. It now spells the C# record that can cross a signature: the named tuple. ! token { kind: i64, val: i64 } arrives at the arm as (long kind, long val) — a struct, member reads resolved at compile time, no boxing under the generic-struct dispatch. Where the decl genuinely carries no record — a single field, a wildcard, a synthesized __type_ref — the payload still spells new { … } into an honest dynamic, because C# has no 1-tuple and tuple element names are compile-time metadata that erase through dynamic.
The pump (tick-wait, tasks-wait) is zig-only on every host — the largest unported island, now cs-visible. std/list and std/grid cs facets are unwritten. And csType still maps only scalar spellings — *T pointees (host-owned, the emitter cannot name the pointee), slices, and qualified proto names widen to dynamic, and a tuple element spells dynamic the same way when its field type does. That is the remaining real-programs gap between the bench’s scalar tors and a codebase’s.
The boundary itself has teeth: a proc with no |cs body refuses the build by name (NoCsProcBody) rather than silently emitting a stub, and the current sweep’s only red cs rows are named refusals — UnsupportedConstruct, NoCsProcBody — not wrong output. A mark declares coverage; a refusal marks the edge of it.
Where the claims are pinned
The closing question is the one this backend opened with — can an effect branch be pushed onto .NET? The answer is that it never had to become data on the way. Func<T> is what you pay when dispatch is an object with a method pointer, opaque to the compiler at the exact boundary where the call happens; Koru’s arm was never data, so the push kept it as a name the compiler resolves — on .NET, resolved all the way into the caller’s instruction stream. The receipt is a generated call that costs what a static call costs — because by the time it runs, it is one.