Generators in Koru: yield Ergonomics, Hand-Written-Iterator Speed
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 as | 1e9-iteration fold | per 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 |
Python — yield generator + loop | ~130 s* | ~130 ns |
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.