fix: evaluate list literals / deep call chains left-to-right - #49
Merged
Conversation
shen-lua evaluates the elements of a list literal of 16 or more elements right-to-left, where shen-go, shen-cl and shen-rust all evaluate left-to-right. Reproduction, all four ports, same source: (define demo X -> [(output "~A " 0) ... (output "~A " 19)]) (demo 0) shen-go 0 1 2 3 ... 19 shen-cl 0 1 2 3 ... 19 shen-rust 0 1 2 3 ... 19 shen-lua 19 18 17 ... 0 <-- diverges The threshold is exactly the 16-frame trigger of compiler.lua's try_flatten_call_chain, which lowers a deep right-spine call chain into `local` bindings (to dodge Lua's ~200-level expression-nesting parser limit) but emits them innermost-first. This commit only adds the failing spec; the fix follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
try_flatten_call_chain lowers a >=16-deep right-spine call chain into a sequence of `local` bindings so the chain does not hit Lua's ~200-level expression-nesting parser limit. It built the chain inside out and, at each call site, compiled that frame's non-last arguments right there -- so those arguments ran in reverse source order. For a list literal, whose KL form is exactly such a chain of `cons` calls, that meant [(output "a") (output "b") (output "c") ... ] (16+ elements) printed c b a. shen-go, shen-cl and shen-rust all print a b c. Non-last arguments are now evaluated up front, outermost frame first, before the innermost value, and the call sites reference the results. Only arguments that can be observed are hoisted: `arg_pure` operands (atoms, unbound symbols, bound Lua locals) have no effects and cannot be disturbed by a later one, so they stay inline. The Prolog compiler's shen.gc(A, shen.gc(A, ...)) CPS spines -- the chains this lowering exists for -- are entirely of that kind, and their generated Lua is byte-for-byte unchanged (verified by diffing codegen before/after). Hoisted values go into a single scratch table, not into `local`s: Lua allows 200 locals per function and the chain already spends one per frame, so hoisting into locals would halve the depth this lowering can handle. The table is emitted only when something actually needs hoisting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The divergence
shen-lua evaluated the elements of a list literal right to left once the
literal reached 16 elements. shen-go, shen-cl and shen-rust all evaluate
left to right, so any side-effecting expression inside a list literal was
port-dependent. It broke a urdr test suite whose own output became
port-dependent (urdr
d426237).Same source, all four ports (
(define demo X -> [(output "~A " 0) ... (output "~A " 19)])):0 1 2 3 ... 190 1 2 3 ... 190 1 2 3 ... 1919 18 17 ... 00 1 2 3 ... 19Cause
compiler.lua'stry_flatten_call_chainlowers a >=16-deep right-spine callchain into a sequence of
localbindings so the chain does not hit Lua's~200-level expression-nesting parser limit. It built the chain inside out
and compiled each frame's non-last arguments at the call site it emitted for
that frame — so those arguments ran in reverse source order. A list literal's
KL form is exactly such a chain of
conscalls, which is why the divergenceshowed up there, and why the threshold was exactly 16.
Nothing else reorders: the
MKLISTpath (value position, >=16 elements) andthe
MKTREEpath (>=60 cons nodes) both already emit left to right, andtry_let_floatalready guards onarg_pure. Below 16 frames the loweringdoes not fire at all, which is why short lists always looked correct.
Fix
Non-last arguments are evaluated up front, outermost frame first, before the
innermost value; the call sites then only reference the results.
Two properties preserved deliberately:
arg_pureoperands (atoms,unbound symbols, bound Lua locals) have no effects and cannot be disturbed
by a later one, so they stay inline. The Prolog compiler's
shen.gc(A, shen.gc(A, ...))CPS spines — the chains this lowering existsfor — are entirely of that kind, and their generated Lua is byte-for-byte
unchanged (verified by diffing codegen before/after).
locals. Lua allows200 locals per function and the chain already spends one per frame, so
hoisting into locals would halve the depth this lowering can handle. The
table is emitted only when something actually needs hoisting.
Tests
New
test/eval_order_spec.lua(40 checks), registered inscripts/run-tests.lua.It pins left-to-right order for list literals in tail / argument / value
position at 3, 15, 16, 20 and 70 elements; deep user call chains with effectful
non-last arguments; and the collateral constructs that must not have
changed — application arguments, nested applications,
let,do, arithmetic,@p,@v,@s,ifbranch selection,freeze/thawdeferral. It alsopins that the flattening optimisation still fires (a 150-deep spine still
compiles to loadable Lua, and the pure chain still allocates no scratch table),
so a future "fix" that just disabled the lowering would fail here.
The first commit adds the spec failing; the second makes it pass.
All twelve collateral constructs were also run through all four ports directly
and produce identical traces.
🤖 Generated with Claude Code