Repository navigation
A device lane short of stack moves it to the heap and back, so non-tail recursion under ! no longer faults at 1023 frames (#1393, #1394) - #1819
Open
wakamex wants to merge 1 commit into
Conversation
wakamex
marked this pull request as ready for review
October 22, 2026 02:06
Contributor
Author
|
my clanker opened this without me telling them to to be fair to them, they seem confident about it: |
wakamex
marked this pull request as draft
October 22, 2026 03:11
Contributor
Author
|
needs more work, gonna rent a Mac to test |
…il recursion under ! no longer faults at 1023 frames (bendlang#1393, bendlang#1394) A lane's stack on the device holds 2,048 words (STAK_LEN). On main a call that finds it full posts ERR_DEEP: a non-tail List.append under ! stops at 1,023 frames (bendlang#1393), and so does an unbalanced fork tree such as two!(365n) (bendlang#1394). Now a call that finds too little room moves the lane's whole stack to a heap block under a 3-word exit frame, and FID_EXIT moves it back when the lane returns to that frame. bendlang#1439 did this with a 4-argument lane_spill call inlined at every call site, which made the Metal GPU build of symreg and merkle 17% slower (bendlang#1809). Here a call site costs one compare and a jump: - emit_jump checks after the callee's arguments are in r0..rn, where the caller's locals are dead and its whole frame is on the stack. - On the device, WL_ROOM(F) takes WL_JMP to F with bit 16 set (FIDs stay under 65,536); work_loop's default case clears the bit, calls lane_spill and dispatches F. Metal rejects goto and labels, so the jump goes through the switch. - FID_EXIT unspills through its existing WL_ARGS, heap_free and WL_TAKE block, testing for a spilt frame with WL_SPILT(F), which is 0 on the host. A restored stack resumes near the top, and the continuation pushes the next call's frame before any check, so one frame of room is not enough. emit_fork records in FL.room the most words one fork chain pushes (its held words plus a fid per call), and WL_ROOM spills when less than that remains. Every def is entered with that much room, so its chain fits after a spill and a spilled stack never exceeds STAK_LEN words. Host machine code is the same as main for symreg, merkle, queens, raytrace and tree-matmul (objdump -d, addresses aside). The device no longer posts ERR_DEEP: runaway recursion under ! spills until the heap runs out and fails with ERR_HEAP. tests/io/stack_fault_trap.bend's comment says so. tests/compile/bang_lane_spill.bend covers bendlang#1393, bendlang#1394, a spill at a fork's second call, and tall, whose fork chain pushes 449 words; with one frame of room the second tall! faulted on CUDA. It passes on Metal (Mac mini M4), on CUDA (RTX 3090), natively, interpreted and as JS, and every runnable ! test passes on both GPUs. On the M4, symreg's --gpu-build takes 0.267 s on main and 0.252 s here, merkle's 0.163 s and 0.156 s, and none of the 17 bench/runtime programs runs slower. comp.ts goes from 66,000 to 66,377 ttok (+377).
Contributor
Author
|
okay I rented a Mac ($15 minimum, can't rent for less than a day, thanks Apple) and updated the PR |
wakamex
marked this pull request as ready for review
October 22, 2026 17:45
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.
Fixes #1393. Fixes #1394. Follow-up to #1439, which #1809 reverted.
Today on main, a lane on the GPU has a 2,048-word stack (
STAK_LEN), and once it fills, the next call postsERR_DEEPand the run stops. A non-tailList.appendunder!fails at 1,023 frames (#1393), an unbalanced fork tree such astwo!(365n)fails (#1394), and Unbalanced Tree Search (UTS) (uts-compact) exits withERR_DEEPon the GPU. The host has no such limit.This PR lets a full lane keep going. When a call finds too little room left,
lane_spillcopies the lane's whole stack to a heap block and leaves a 3-word exit frame at the stack's base. When the lane returns down to that frame,FID_EXITcopies the stack back and returns into the continuation on top of it.What each program pays
#1809 reverted #1439 because
lane_spilljoined the device code of every!program and made the Metal GPU build 17% slower on symreg and merkle. #1439 inlined the check and a 4-argumentlane_spillcall at every call site, and added a second unspill loop toFID_EXIT. Here a call site costs one compare and a jump, and the spill itself runs in one cold place:emit_jump, after the callee's arguments are inr0..rn, so the caller's locals are dead and its whole frame is already on the stack.WL_ROOM(F)isif (sp >= <stack end - room>) WL_JMP((F) | 1u << 16). FIDs stay under 65,536, so bit 16 never names a segment.work_loop'sdefault:case clears the bit, callslane_spilland dispatchesF. This avoidsgoto, which the Metal Shading Language does not support (section 1.5.4 of the 4.1 specification): this PR's first version jumped to ansp_spill:label, and Metal refused to build it ("labeled statements are not supported in Metal").FID_EXITunspills through its existingWL_ARGS+heap_free+WL_TAKEblock, testing for a spilt frame withWL_SPILT(wf).Every
!program with a non-tail call still carrieslane_spilland thatdefault:branch in its device code, symreg and merkle included. On Metal both now build faster than on main, and no program runs slower (tables below).Why a lane never writes past its stack
A spilled stack comes back whole, so a lane can resume near the top of its 2,048 words, and the continuation then pushes the next call's frame before any check runs. One frame of room is therefore not enough.
emit_forkrecordsroom, the most words one fork chain in the program pushes (its held words plus one fid per call), andWL_ROOMspills when less thanroomremains. Every def is entered with at leastroomwords free, so its whole chain fits even when it resumes from a spill, and a spilled stack never holds more thanSTAK_LENwords.roomis 8 in merkle and 449 in the new test.Effects on shared code
WL_ROOMexpands to nothing andWL_SPILTto 0.objdump -dshows the same host machine code as main for symreg, merkle, queens, raytrace and tree-matmul, apart from addresses.FID_EXITnow testswf == FID_EXIT, in every!program.ERR_DEEPany more. Runaway recursion under!now spills 16 KB blocks until the heap runs out, then fails withERR_HEAP. The comment intests/io/stack_fault_trap.bendthat described the device postingERR_DEEPis updated; that test exercises the host stack and its output is unchanged.comp.ts goes from 66,000 to 66,377 ttok (+377). Main is at the 66,000 cap.
Tests
tests/compile/bang_lane_spill.bendcovers four cases:List.append(Metal: non-tail recursion under a bang faults at 1023 list elements; CPU completes through 100000 #1393);two!(365n)(Metal: one!over a two-leaf fork tree (a 365-fork spine beside a 3-fork spine) faults; each leaf alone, a balanced 365+365, and--gpu offall pass #1394);tall, a fork that holds six 40-word records across its first call and hands one to each later call, so its frames add up to 449 words. Built with one frame of room (WIDE), the secondtall!faults withbend: device faulton CUDA.All five lines match on Metal, on CUDA, natively on the CPU, interpreted, and as JS under Bun. All 40 runnable tests that use
!pass on an M4 mini (Metal) and on an RTX 3090 (CUDA); main fails onlybang_lane_spill.Metal
Measured on a rented Mac mini M4 (10-core GPU, macOS 26.7.1), main (de26a91) against this PR, alternating main and PR in every round.
Build time, median of 7 runs per side, the same commands as #1809 (lower is better):
bend main.bend -o main./main +RTS --gpu-buildbend main.bend -o main./main +RTS --gpu-buildIn every row but merkle's
bend -othe slowest PR run is faster than the fastest main run; there the two ranges overlap by 1 ms.Runtime of the 17 programs under
bench/runtime, in 4 blocks of 4 runs (A B B A, then B A A B). A block's ratio is main's time over this PR's, so above 1x means the PR is faster; the table gives the median block ratio and its 95% bootstrap interval. All 17 print the same output as main.No program gets slower. I have not found why symreg (11.1%) and lexer (5.3%) get faster.
CUDA build time and registers
On an RTX 3090 (CUDA 13.1, sm_86),
./main +RTS --gpu-buildwithCUDA_CACHE_DISABLE=1for the 17 programs underbench/runtime, alternating main and PR in 4 blocks, totals 23.7 s on main and 22.9 s with this PR. Single runs vary by up to 15% (main against itself), and only tree-matmul builds shorter in every block (3,183 to 2,699 ms, medians). Registers per thread ofbend_dev(cuobjdump --dump-resource-usage) go up for symreg (64 to 72), tree-bitonic (72 to 80), editdist (56 to 64), gameoflife (70 to 77), terrain (60 to 66) and nbody (113 to 118), down for merkle, mandelbrot and raytrace (by 2) and tree-matmul (92 to 86), and stay the same for the other 7.CUDA runtime
bend-bench
paired(0f6d45f) checks each output, warms up, then runs 8 blocks of 4 runs (A B B A, then B A A B) on the same RTX 3090 with a 4 GB heap, against main (de26a91). A block's ratio is main's time over this PR's, so above 1x means the PR is faster; the table gives the median block ratio and its 95% bootstrap interval.kmeans gets 3.2% slower, merkle 2.1% and queens 0.8%. editdist (10.0%), gameoflife (4.6%) and tree-matmul (15.9%) get faster, and I have not found why.
uts-compact passes with this PR (
293367 0); main exits withERR_DEEP, so it has no main time to compare against.Original description
Fixes #1393. Fixes #1394. Follow-up to #1439 and #1809.
Today on main (de26a91, after #1809 reverted #1439), emit_fork emits WL_ROOM(fr.length) before pushing each sequential continuation frame, and WL_ROOM(N) posts ERR_DEEP and returns 0 on DEVICE once a lane's 2,048-word (STAK_LEN) stack fills. A non-tail List.append under ! faults at 1,023 frames (#1393), an unbalanced fork tree such as two!(365n) faults (#1394), and Unbalanced Tree Search (UTS) (uts-compact) exits with ERR_DEEP on the GPU.
In #1439 (40402b4), WL_ROOM(N, D) called a 4-argument lane_spill(e, sp, N, D) at every sequential call site while the caller segment's local variables were still live, followed by a second branch if (!(sp = ...)) { err_post(e.mem, ERR_DEEP); return 0; }, and WL_CASE(FID_EXIT) added a second WL_ARGS + heap_free + WL_TAKE block alongside continuation dispatch. Even with FAR on lane_spill, expanding a 4-argument call with live caller locals and two branches at every WL_ROOM site (27 device sites in symreg, 8 in merkle, 47 in tree-matmul) plus a second unspill loop in WL_CASE(FID_EXIT) grew the device control-flow graph, slowed bend -o's GPU build step (./main +RTS --gpu-build) by 17% on symreg and merkle on Metal (#1809), and slowed CUDA execution on queens (6.7% slower), symreg (7.4% slower), tree-matmul (20.6% slower), and tree-bitonic (50.3% slower).
This PR moves the stack-room check into emit_jump, after the sequential frame is pushed and after the callee arguments are loaded into r0..rn, and routes every WL_ROOM(F) overflow on DEVICE to a single cold sp_spill: label at the bottom of switch (fid) in work_loop:
comp.ts goes from 66,000 to 66,268 ttok (+268 ttok, vs +283 in #1439; main is at the 66,000 cap after #1809 kept #1439's token cuts and #1542 used the 283 tokens).
GPU build time (--gpu-build) and register usage
Measured under NVIDIA Runtime Compilation (NVRTC) on an RTX 3090 (CUDA 13.1, sm_86) across 4 interleaved ABBA/BAAB blocks against base 8409268 (identical to de26a91 outside tools/bend-fmt-lsp) and #1439 (40402b4), both with a warm ~/.nv/ComputeCache (which times the NVRTC C++ -> PTX front-end) and with CUDA_CACHE_DISABLE=1 (which times the full uncached C++ -> PTX -> SASS ptxas compile). Register counts come from cuobjdump --dump-resource-usage on bend_dev; lower build time and lower register count are better:
Across all 75 build steps in bend-bench gpu fast (including the three compile-stress programs stress-flatmany, stress-flatchain, and stress-loopchain), total build time is 74.7 s on main vs 75.9 s on this PR, and no step slows by more than 10% and 1 s.
GPU runtime
Measured with bend-bench paired (0f6d45f, interleaved ABBA/BAAB blocks of 4 runs each after correctness check and warmup on RTX 3090, CUDA 13.1, 4 GB heap) against base 8409268; a median block ratio above 1x means the candidate is faster than main:
Across the remaining 12 workloads in paired (4 ABBA/BAAB blocks each), 5 are faster (kmeans 1.141x, summation 1.135x, editdist 1.086x, gameoflife 1.058x, mandelbrot 1.021x) and 7 show no detected change (bfs 0.999x, hashmap 1.031x, nbody 1.000x, terrain 1.010x, tree-radix 0.990x, pricing 0.999x, game-search 0.984x).
tests/compile/bang_lane_spill.bend passes on both GPU (+RTS --gpu on) and CPU (+RTS --gpu off), printing 1543298507, 2063974529, and 1878402805.
Other approaches tried
Before settling on goto sp_spill into default: after r0..rn, I tested six alternatives against base 8409268: