Generators in Koru: yield Ergonomics, Hand-Written-Iterator Speed

· 7 min read

Coming from Python, a generator is the most natural thing in the world: def, a loop, yield, done. It reads like a list and costs like a stream. The headline here is that the same generator, written in Koru, compiles to a flat loop with no generator object and no per-step allocation — and on a one-billion-iteration fold it ran neck-and-neck with a hand-written Mojo iterator. You write yield-shaped code; you get hand-written-state-machine speed.

Coming from Python

Here is the generator you already know — yield the naturals, fold each one into a running accumulator:

def nats(n):
    for i in range(n):
        yield i

acc = 0
for i in nats(n):
    acc = (acc + i) * 1000003
print(acc)

nats is a named, reusable generator. The consumer drives it lazily, one value at a time, and folds the stream. Nothing is materialized.

The same, in Koru

Koru’s thesis is control flow into the type system, so a generator isn’t a special construct — it’s an ordinary effect-bearing event. It yields through an effect arm (! each), and that one arm is the whole contract — a pure generator needs no terminal branch, it simply ends when its loop does:

pub event nats { n: usize }   // the generator's contract:
! each usize                  //   yields a usize each step.

nats = for(0..n)              // the generator's body:
! each i |> each(i)           //   for each i, yield it.

And the consumer folds the yielded stream into an accumulator with capture:

capture { acc: 0[u64] }
! as st |> nats(n: n)
    ! each i |> captured { acc: (st.acc +% i) *% 1000003 }
| captured r |> std/io:print.ln("{{ r.acc:d }}")

It is more lines than Python, and that is worth owning plainly rather than hiding: the extra lines are the two things Python leaves implicit. First, the generator’s type contract! each usize says exactly what this thing yields, checked at compile time. Second, the fold is explicit: capture names the accumulator, and the stream flows into it. Python folds the same way with a hidden loop variable; Koru asks you to name the shape.

That is the trade the rest of this post is about: you name the shape, and in return the abstraction is free. A yield in Python allocates a generator object and suspends a frame per step. The Koru version has neither — the effect stream and its fold monomorphize into one flat loop. The naming is what buys the fusion.

Both halves run green in the regression suite — the squares generator (Python’s canonical yield i*i) and the consumer-side fold, where the mutable accumulator lands in flow scope with no handler-struct boundary between the generator and its fold:

The performance

The claim above is a performance claim, so here is exactly what was measured, and exactly how narrow it is.

The workload: fold a one-billion-element counting generator into a u64 with the recurrence acc = (acc + i) * 1000003, wrapping. The recurrence is deliberate — it is data-dependent, so no compiler can constant-fold the loop away or vectorize it into a closed form; every one of the billion steps has to run. All three programs print the same accumulator, which is what proves they do the identical work.

On this machine, both the Koru program (compiled by koruc, which defaults its output to ReleaseFast) and a hand-written Mojo iterator (mojo build, optimized native) landed in the same place:

written as1e9-iteration foldper iteration
Koru — effect-branch generator + capture fold~1.25 s~1.25 ns
Mojo — hand-written Iterator struct + loop~1.26 s~1.26 ns
Pythonyield generator + loop~130 s*~130 ns
*Python measured at 1e8 (~13 s) and scaled; it is the doorway, not a contest — interpreted versus compiled.

Scope, stated once and meant: this is a wall-clock measurement of one workload on one machine, three trials each (dead stable), not a formal benchmark harness. The identical output across all three makes the fairness airtight — same arithmetic, same sequence, same count. What is narrow is the breadth: the honest claim is ”Koru’s generator matched a hand-written Mojo iterator on this workload,” not “Koru is as fast as Mojo in general.” The Python number is the interpreted-versus-compiled gap and is included only as the doorway.

The point that survives the narrowness: the Koru version is written at the generator level and lowered to the iterator level. There is no per-step generator object to pay for.

What Mojo makes you write

Mojo is the interesting comparison because it is the fast-Python — Python’s expressiveness, native speed — so it is where you would expect yield to also be free. It isn’t there. As of the release measured here, Mojo has no yield: the keyword is rejected outright. To express a generator at all you hand-write an iterator that conforms to the Iterator trait — the state machine yield would have written for you:

@fieldwise_init
struct Nats(Iterator, Copyable, Movable):
    comptime Element = UInt64
    var i: UInt64
    var n: UInt64

    def __iter__(self) -> Self:
        return self.copy()
    def __has_next__(self) -> Bool:
        return self.i < self.n
    def __next__(mut self) -> UInt64:
        var v = self.i
        self.i += 1
        return v

def main():
    var acc: UInt64 = 0
    for i in Nats(0, n):
        acc = (acc + i) * 1000003
    print(acc)

Three dunder methods, an explicit cursor, explicit copy/move conformance — the generator’s body turned inside out into a struct. That is the version that hits ~1.25 ns/iter. It is a fine iterator; it is just not a generator, and it is not the five lines you started with in Python.

So the shape of the result is: same speed, opposite ergonomics. Mojo makes you hand-lower the state machine to get the number. Koru keeps the yield-shaped surface — nats = for(0..n) ! each i — and lowers it for you, because a generator is just an effect stream and effect streams are Koru’s core primitive, not a bolted-on feature.

The rung yield can’t reach

Everything so far has been “the same idiom as Python, at the speed of a hand-written iterator.” This part is neither — it is a rung above both.

A pure generator ends silently: its Output is void, it yields, the loop finishes, control returns. That is the yield generator from the top of this post, and the Mojo iterator we timed — a StopIteration the for loop swallows, a __has_next__ that returns false so the loop simply stops. Nothing is asked of the caller at the end.

A Koru generator can instead declare its completion — and the moment it does, the compiler requires the consumer to handle it:

pub event nats { n: usize }
! each usize
| done                          // completion is part of the contract now

nats(n: 3)
! each i |> std/io:print.ln("{{ i:d }}")
// no `| done` arm →
// error[KORU022]: branch 'done' must be handled but no continuation found

There is no binary until the end is handled. Completion has become a compile-time obligation instead of a convention.

The completion can also carry a value — a summary handed back at the end — and that value cannot be dropped, because the branch delivering it must be handled:

pub event nats { n: usize }
! each usize
| done usize                    // completion returns a value

nats = for(0..n)
! each i |> each(i)
| done => done n                // hand back how many were yielded

nats(n: 3)
! each i |> std/io:print.ln("{{ i:d }}")
| done total |> std/io:print.ln("yielded {{ total:d }} values")
// → 0 / 1 / 2 / yielded 3 values

The yield generator at the top of this post can return a value too — it becomes StopIteration.value — but the for loop discards it; nothing reads it. The Mojo iterator we hand-wrote has no completion value at all. Here | done total is a branch that must be handled, so the summary reaches the consumer or the program does not build.

So the same five-line shape spans a ladder: a pure producer (void, exactly yield), a producer whose completion is a required continuation, and one whose completion returns a value you are made to consume. The bottom rung is the generator you already knew; the top two are contracts the yield and the hand-written iterator we tested here have no way to state.

The one honest gap

There is a boundary, pinned red on purpose. The fold above works because the mutable accumulator lands in flow scope. When the generator also declares an optional effect arm, that event currently keeps an older lowering path where the accumulator can’t reach the fold — so folding an optional-arm generator does not yet compile. That intersection is pinned red on the board — a failing test, not a comment:

Optional-arm presence itself is intact and green; it is only the intersection with consumer-side folding that is open, and it is on the board as a failing test rather than a comment, so it can’t quietly become the way things are.

Name the shape

Generators are where “expressive but slow” is supposed to be a law. In Koru it isn’t, because the generator is not an object with a hidden cost — it is a typed effect stream that the compiler fuses into a loop. The price is that you name the stream’s contract instead of leaving it implicit. On the fold measured here, that price bought the performance of a hand-written iterator, out of code that still reads like yield.