C# Backend: Koru Compiles to .NET — Dispatch Without Delegates

· 7 min read

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:

armns/callB/callwhat it is
generated0.30the emitted handler<H> + ops struct
gen-subflow0.30same, with a pure-Koru subflow impl (pulse)
direct0.30a static call to a static body — the atom
struct-method0.30the handwritten where H : struct analogue
virtual2.00interface call on a class
typed-func2.20Func<int> properties — the delegate floor
gen-dynread10.056the typed Output read through dynamic
anon-func21.380Func<dynamic> properties + anonymous objects — the previous emitted shape
one `! tick` fire per call, 30M calls, min of 3 — the generated handler sits on the direct-call atom; everything above the green rows is a spelling of dispatch the emitter does not use

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_chaining runs 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 the DOTNET_JitDisasm receipt shows why that isn’t noise: the emitted chain’s hot loop is instruction-identical to direct, five arm64 instructions, all eight hops inlined and folded to i + 8, the Input/Output envelope promoted to registers so the tag store doesn’t exist in the machine code. Interface dispatch is 19ms, a composed Func<long,long> chain 24ms, LINQ Select×8+Sum 647ms — 72x.
  • 010_numeric_loop runs the language’s own for with an effect hop per element, 100M elements: generated 121ms against handwritten direct at 119ms — and the claim is again the disassembly: the emitted hot loop is instruction-identical to direct — madd; add; add; cmp; blt, accumulator register-resident, module cell written back once after the loop. (The func arm 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 - dragged hslot_free_len negative, and the next take indexed below zero — a real corruption on any host without -|, invisible on the one that had it. The cs arm emits Math.Max(0, …); the zig text is unchanged and still correct.
  • A reserved-name collision nobody declared. A user column literally named tag met the generated Output struct’s public string tag discriminator — legal on Zig’s union, a CS0102 on C#‘s flattened record. The payload member now emits as __ktag while the discriminator convention stays intact.
  • A cold path that had never compiled. The [tree] store’s cycle-guard proc emitted zig-shaped const locals and an anonymous new { … } argument into a typed Input — refused the first time any test asked, because no tree test was cs-marked. 695_002 probes it now: | cycle fires, 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 | item declares a single __type_ref while the body hands back the whole row record — a ValueTuple boxed into a dynamic member erases its element names, and six tests died to RuntimeBinderException before 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.