Skip to content

feat(lang): add a while special form - #590

Merged
singaraiona merged 2 commits into
devfrom
feat/while-form
Sep 19, 2026
Merged

singaraiona merged 2 commits into
devfrom
feat/while-form

Conversation

@singaraiona

Copy link
Copy Markdown
Collaborator

Closes #588.

Rayfall had no way to stop iterating before the end of a sequence. Every iteration primitive consumes its whole input, and there was no loop construct, so "repeat until done" had to be a fold over a fixed range whose full length was paid on every call however early the work finished. return did not help — it exits the lambda, not the iteration. Recursion was not an alternative either: no TCE, and the stack tops out around 1–2k frames.

Surface

(while cond body...) evaluates cond, and while it is truthy evaluates each body expression in order, then tests again. Always returns null — a statement form run for effect, and a never-taken loop has no last value to report. Zero body expressions is legal, so a side-effecting condition can be the whole loop; that is the shape a drain wants, where there is no sequence to iterate and the range was only ever scaffolding.

(set n 5)
(while (> n 0) (set n (- n 1)))     ; => null

(while (drain-one-batch))            ; body-less: condition drives the loop

Implementation

Both evaluators, which must agree:

  • ray_while_fn in src/lang/eval.c, registered beside if and do.
  • an sf_while case in src/lang/compile.c emitting JMPF over a backward JMP — the first backward branch the compiler produces. The VM's op_jmp already checked for a pending interrupt when the displacement is negative (eval.c:2369), dormant until now, so a runaway loop is Ctrl-C-able for free.

Compiling the body inline, rather than letting it fall through to the generic special-form path, is what keeps return working inside a loop: it reaches the sf_return case and unwinds the lambda instead of degrading to the tree walker's identity return.

Scoping

Neither path pushes a scope around the body. let binds only in the top frame (env_bind_local), so a per-iteration frame would discard loop-carried let state on the tree-walking path while the compiled path — whose let writes a function-level slot — kept it, and the two evaluators would disagree on the same source. A caller wanting a fresh frame per pass writes (while cond (do ...)), which composes.

patch_jump and emit_jump_back now share write_jump_offset; an out-of-range displacement still sets c->error, dropping the lambda to the interpreter.

Performance

The drain shape from the issue — a 4-step drain under a 4096 safety bound — on a release build, driver baseline subtracted:

drain form µs/batch vs fold-left
fold-left over (til 4096) 1420
nested 64×64 workaround 45.8 31×
while 3.8 374×

The middle row independently reproduces the reporter's "32x improvement" figure. Note fold also routes its collection through unbox_vec_argto_boxed_list, so the original form allocated 4096 boxed atoms plus a LIST per call on top of the range — the reason the gap is this wide.

Testing

test/rfl/lang/while.rfl, 33 assertions, auto-discovered by the harness. Each behaviour is asserted on both paths where it can be: basic iteration, null result, zero-trip, body-less form, arity, condition-driven termination counted exactly, let as loop-carried state (compiled and interpreted), lambda params read from the condition, no global leakage, return exiting the enclosing lambda from inside a body, error propagation from body and condition with the iteration count pinned, nesting, and do composition for per-iteration isolation.

Full suite: 3901/3901 pass, ASan+UBSan output clean. The patch_jump refactor touches every compiled if and return, so the full run is the relevant check there.

Not in scope

do[n; ...], fold-while, converge/adverb forms, and TCE — as discussed on the issue, those stay separate.

Rayfall had no way to stop iterating before the end of a sequence.  Every
iteration primitive — map, pmap, fold, fold-left/right, scan, scan-left/right,
prior — consumes its whole input, and there was no loop construct, so a
"repeat until done" loop had to be written as a fold over a fixed range whose
full length was paid on every call however early the work finished.  `return`
did not help: it exits the lambda, not the iteration, so the fold kept calling
the lambda for every remaining element.  Recursion was not an alternative
either, since there is no TCE and the stack tops out around 1-2k frames.

(while cond body...) evaluates cond, and while it is truthy evaluates each
body expression in order, then tests again.  It always returns null — a
statement form run for effect, and a never-taken loop has no last value to
report.  Zero body expressions is legal, so a condition with side effects can
be the whole loop; that is the shape a drain wants, where there is no sequence
to iterate over and the range was only ever scaffolding.

Implemented on both evaluators, which must agree:

  - ray_while_fn (eval.c), registered beside `if` and `do`.
  - an sf_while case in the bytecode compiler emitting JMPF over a backward
    JMP — the first backward branch the compiler produces.  The VM's op_jmp
    already checked for a pending interrupt when the displacement is negative
    (dormant until now), so a runaway loop is Ctrl-C-able for free.

Compiling the body inline, rather than letting it fall through to the generic
special-form path, is what keeps `return` working inside a loop: it reaches
the sf_return case and unwinds the lambda instead of degrading to the tree
walker's identity `return`.

Neither path pushes a scope around the body.  `let` binds only in the top
frame (env_bind_local), so a per-iteration frame would discard loop-carried
`let` state on the tree-walking path while the compiled path — whose `let`
writes a function-level slot — kept it, and the two evaluators would disagree
on the same source.  A caller wanting a fresh frame per pass writes
(while cond (do ...)), which composes.

patch_jump and emit_jump_back now share write_jump_offset; an out-of-range
displacement still sets c->error, dropping the lambda to the interpreter.

Measured on the drain shape from the issue (4-step drain under a 4096 safety
bound, release build, driver baseline subtracted): fold-left over (til 4096)
1420 us/batch, the nested 64x64 workaround 45.8 us/batch, `while` 3.8
us/batch — 374x over the original and 12x over the workaround.

Closes #588
@singaraiona
singaraiona merged commit 4e122fc into dev Sep 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: an early-terminating iteration form (while / conditional fold)

1 participant