feat(lang): add a while special form - #590
Merged
Merged
Conversation
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
This was referenced Sep 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
returndid 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...)evaluatescond, 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.Implementation
Both evaluators, which must agree:
ray_while_fninsrc/lang/eval.c, registered besideifanddo.sf_whilecase insrc/lang/compile.cemittingJMPFover a backwardJMP— the first backward branch the compiler produces. The VM'sop_jmpalready 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
returnworking inside a loop: it reaches thesf_returncase and unwinds the lambda instead of degrading to the tree walker's identityreturn.Scoping
Neither path pushes a scope around the body.
letbinds only in the top frame (env_bind_local), so a per-iteration frame would discard loop-carriedletstate on the tree-walking path while the compiled path — whoseletwrites 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_jumpandemit_jump_backnow sharewrite_jump_offset; an out-of-range displacement still setsc->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:
fold-leftfold-leftover(til 4096)whileThe middle row independently reproduces the reporter's "32x improvement" figure. Note
foldalso routes its collection throughunbox_vec_arg→to_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,letas loop-carried state (compiled and interpreted), lambda params read from the condition, no global leakage,returnexiting the enclosing lambda from inside a body, error propagation from body and condition with the iteration count pinned, nesting, anddocomposition for per-iteration isolation.Full suite: 3901/3901 pass, ASan+UBSan output clean. The
patch_jumprefactor touches every compiledifandreturn, 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.