Repository navigation
Metal: a full lane stack spills to the heap, so non-tail recursion under ! no longer faults at 1023 frames (#1393, #1394) - #1439
Conversation
…k under a FID_EXIT frame, which moves them back on return, so non-tail recursion under ! no longer faults at 1023 frames (bendlang#1393, bendlang#1394)
Lorenzobattistela
left a comment
There was a problem hiding this comment.
Thanks @totaland. I reproduced both issues on an M4 Pro at the same boundaries as the reports: List.append passes at 1022 and faults at 1023, and the fork tree passes at 173+3 and faults at 174+3 (d 417 passes, 418 faults). With this PR merged on main 8578b7c, every case prints the same result as --gpu off, up to 1,000,000 frames. The new test fails on main as it should, and ping (48/48) and test (1598/1598) pass.
What blocks the merge:
-
bend2/comp.ts is 64,275 ttok, over its 64,000 cap, so the repo gate fails (53/54). The caps are permanent, so the change has to fit under 64,000.
-
On Metal, lane_spill gets inlined at every WL_ROOM site. symreg's .gpu grows from 220 KB to 283 KB, its build takes 13% longer, and the perf gate fails on the COMPILER column (symreg 1.24x, merkle 1.17x).
-
The test never spills at a call where the def still holds words on top of the stack (D > 0). A build that skips copying those D words back still passes it.
Smaller things: lane_spill takes its heap block before the ERR_DEEP check. Also, a loop of calls at exactly the depth where the stack fills spills and restores the whole stack on every call: 100,000 calls there take 24.7 s, against 0.27 s one frame shallower. Main faults at that depth, so this isn't a regression, but it's worth knowing about. We have no CUDA machine, so the CUDA lane is untested here.
|
I have CUDA I will test again and let you know. Also will have a look again at the comp.ts |
|
If |
|
That will be great. Also when I put the kernel code out of comp.ts and into packages it reduce decoding time and reduce tok/s about 1.6%. Can we have a discussion in team about that too? I am building inference engine with bend. |
|
Thanks @totaland, and good to hear you're building an inference engine on Bend. On moving the kernels: Base and the compiler are only for really basic things, the primitives every Bend program needs. Inference kernels (rmsnorm, quantized matmul, attention and so on) are too specific for that. The right place for them is your own package. If you need hand-written C there, a foreign effect can import it ( About the 1.6%: if you share a repro (the program, the machine and before/after numbers over several runs), we're happy to look at why the package version is slower. If it comes from something general in the compiler, we'd rather fix that for everyone than move model-specific code into it. |
…'s own to a heap block under an exit frame, whose return moves them back, so non-tail recursion under ! no longer faults at 1023 frames (bendlang#1393, bendlang#1394) Takes over bendlang#1439 (totaland): lane_spill is FAR, so Metal emits one copy instead of inlining it at every WL_ROOM; it refuses a frame that cannot fit before it takes a block; FID_EXIT's restore needs no TERM_HOLE test. The test adds a spill at a fork call where the def still holds two words.
# Conflicts: # bend2/comp.ts
|
The same limit holds on CUDA (Bend 2.0.37, and main 881cb2e today; RTX 4050 Laptop, driver 610.57.04, CUDA 12.9): a non-tail recursion inside a import Base
def deep(n: Nat) -> U32:
match n:
case 0n:
0
case 1n+p:
(deep(p) + 1 : U32)
def main() -> IO(Unit):
IO.print(U32.show(deep!(5000n)))GPU: the fault, exit 1.
Every run was deterministic, and I built this PR (6764c33) and its base (3f9c40e) from source and ran the programs above on CUDA. The base faults at every depth past the limit; the PR runs all of them with the CPU's results, my matrix product included (9031405), and a depth of 100,000 ( |
…eturns skip it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mo bodies and locals inline, and five comments say less Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 17% slower #1439 moved a full lane stack to the heap, so non-tail recursion under `!` no longer faulted at 1023 frames on Metal (#1393, #1394). Its lane_spill joins the device code every `!` program compiles, and made the Metal GPU program build (`./main +RTS --gpu-build`, part of `bend -o`) 17% slower, 0.257s -> 0.300s on an M4 mini; the perf gate's COMPILER column went past 1.15x (symreg 1.16x, merkle 1.17x, two gate runs out of two). Bisected over v2.0.37..main on one mini (median of 7 `bend main.bend -o main`): symreg 0.747s at 10256a7, 0.793s at 40402b4. This removes only the spill: WL_ROOM loses its depth argument and its lane_spill call, lane_spill goes, the work loop no longer unspills at FID_EXIT, and the spill test goes. #1439's other changes (the token cuts in comp.ts) stay. #1393 and #1394 are open again until the spill returns in a form whose cost only a program that needs it pays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 17% slower (#1809) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Heads-up: the lane spill from this PR is removed in #1809 (the token cuts in |
…on-tail recursion under ! no longer faults at 1023 frames (bendlang#1393, bendlang#1394) Today on main (de26a91, after bendlang#1809 reverted bendlang#1439), emit_fork emits WL_ROOM(N) before pushing each sequential continuation frame, and WL_ROOM(N) posts ERR_DEEP and returns 0 on DEVICE once a lane's 2048-word stack fills, faulting at 1023 frames of List.append (bendlang#1393) or on an unbalanced fork tree (bendlang#1394). bendlang#1439 (40402b4) replaced WL_ROOM(N) with WL_ROOM(N, D) calling 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 added a separate unspill block in WL_CASE(FID_EXIT). That grew the device CFG at every WL_ROOM site (27 device sites in symreg, 8 in merkle) and slowed bend -o's --gpu-build step by 17% on symreg and merkle (bendlang#1809). This change moves the stack-room check to emit_jump, after the sequential frame push and after r0..rn are loaded (where all caller segment local variables are dead and the entire continuation frame is already on the stack below sp), and makes WL_ROOM(F) on DEVICE jump to one cold sp_spill: label at the bottom of switch (fid) in work_loop: - emit_fork removes WL_ROOM(fr.length) before STK(j) = w and passes push = true to emit_jump(sc, cargs, c.k!, !fork && c.b, true). No other emit_jump caller passes push = true, so tail jumps and self-tail loops without a frame push emit no WL_ROOM. - emit_jump with push = true emits WL_ROOM(seg_ref(sc, fid)) right after r0..rn are assigned, before WL_JMP or WL_AGAIN. Self-recursive calls in emit_fork still set sc.seg.spin = true and take WL_AGAIN on the normal path when the stack is not full. - Because WL_ROOM runs after STK(j) = w; WL_PUSHN(fr.length) and after r0..rn are loaded, all caller segment locals are dead and the caller's complete continuation frame sits below sp. In bendlang#1439, WL_ROOM ran before WL_PUSHN, so spilling at a second fork call (such as walk in tests/compile/bang_lane_spill.bend) split the earlier call's stack words from the new frame's words across the exit frame unless lane_spill took D and copied D words back above the exit frame. Emitting WL_ROOM after WL_PUSHN places the exit frame above the complete continuation frame, so lane_spill(e, sp) needs neither N nor D. - On !DEVICE, WL_ROOM(F) expands to nothing (host SEQ-CPU and PAR-CPU machine code is byte-for-byte identical). - On DEVICE, WL_ROOM(F) checks if (sp >= e.mem + STAT_OFF - 247 * CUBE) { fid = (F); goto sp_spill; }. Because comp.ts caps each continuation segment's parameter count at WIDE (247), a single frame push adds at most WIDE + 1 (248) words, so STAK_LEN - WIDE (1801 words) is a static watermark: starting below 1801 words, pushing a frame reaches at most 2048 words (STAK_LEN), and every word below sp belongs to a complete pushed frame. - At the bottom of switch (fid) in work_loop on DEVICE, inside default: after return 0;, sp_spill: sp = lane_spill(e, sp); calls FAR DEV Term* lane_spill(Env e, DEV Term* sp) at a single call site where only e, sp, fid, and r0..rn are live, then exits switch (fid) to loop back to WL_SPIN -> switch (fid) without placing a label before switch (fid). - In WL_CASE(FID_EXIT), unspilling shares the existing WL_ARGS + heap_free + WL_TAKE block with continuation dispatch under cont != TERM_HOLE && ((DEVICE && wf == FID_EXIT) || fid_resw(wf)), ending with if (DEVICE && wf == FID_EXIT) { WL_RETN(n); }. comp.ts goes from 66,000 to 66,268 ttok (+268 ttok, vs +283 in bendlang#1439; main is at the 66,000 cap after bendlang#1809 kept bendlang#1439's token cuts and bendlang#1542 used the 283 tokens).
…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).
Fixes #1393. Fixes #1394.
Why
On the GPU, a
!job dies withbend: memory fault (machine stack overflow?)once a lane's stack fills. The same binary with--gpu offfinishes. Two shapes hit it:List.appendover a list) faults at 1023 elements.!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: a fork tree with one long spine beside a short one (365 + 3) faults, while each spine alone passes.The limit is the same on CUDA (see the comment by @nuxyel): it hits at 3 + w × depth > 2048 words, where w is the number of words a level keeps.
Both programs from the issues, built from upstream
60fa05dcand from this branch, on an M1 Pro:--gpu off--gpu offdeep 1022deep 1023deep 2048deep 100000deep 1000000lsum 2048lsum 1000000forks 64 800 365forks 64 800 365 365forks 64 800 365 3forks 64 800 174 3forks 64 418 365 3forks 64 800 365 3 3 3 3lsumisdeepwith a hand-written non-tail sum (h + LSum(t)) in place ofList.append. Its source is at the end.How it failed
A bang's root and a fork kid run on the lane's own
STAK_LEN-word (2048) stack on the device, astests/compile/fork_leaf_budget.bendsays. A non-tail call pushes its continuation frame throughWL_ROOM. When the next push would not fit,WL_ROOMpostedERR_DEEPand the lane returned. Nothing moved the frames anywhere else, so the depth limit was about 1022 frames ofList.append. In #1394's unbalanced tree, a leaf job runs above a deeper fork spine on the same lane, so it gets less of the stack.Change
WL_ROOM(N, D)now takesD, the number of words that the calling def holds on the stack (emit_forkpassesdepth - ws.length). When a push would overflow onDEVICE, it callslane_spillinstead of failing:Dwords, the frame and a 3-word exit frame would not fit even on an empty stack (d + n + 3 > STAK_LEN), it returns 0 andWL_ROOMpostsERR_DEEPas before. This check comes before any heap block is taken.cls_fit(STAK_LEN)words.term_tsk(FID_EXIT, b)points at the block, the next word holdsk, the number of words below the def's own, and the third word isFID_EXIT.Dwords back above that frame withWL_ARGSand returns the newsp. The push then goes ahead on an almost empty stack.When a return reaches that frame, the
FID_EXITcase inwork_loopsees a continuation whose function id isFID_EXIT. It copies thekwords back withWL_ARGS, frees the block and returns into the restored top frame. Spills nest, because each spilled block keeps the exit frame below it, so the depth is bounded by the heap. When the heap runs out, the run stops with the usual out-of-memory message (--gpu 512MB, 80,000,000 levels). The host lane is unchanged, becauseWL_ROOMonly checks onDEVICE.lane_spillisFAR, so Metal emits one copy of it instead of inlining it at everyWL_ROOMsite. An earlier version inlined it: symreg's.gpugrew from 220 KB to 283 KB, and the COMPILER column of the perf gate failed (symreg 1.24x, merkle 1.17x).tests/compile/bang_lane_spill.bendcovers:deep!(5000n): one bang's root, 5001List.appendframes (Metal: non-tail recursion under a bang faults at 1023 list elements; CPU completes through 100000 #1393).two!(365n): a 365-fork spine beside a 3-fork spine (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).pair!(600n): the first spill happens at a fork call where the def still holds two words on the stack (D > 0), so a build that drops those words fails it.On main, its compiled run faults on the GPU.
Verification
Merged onto main
cece1cbf, Apple M4 Pro and the mini cluster:repoPASS: 54 / 54.bend2/comp.tsis 65,723 ttok, under the 66,000 cap (main is 65,424, so +299).testPASS: 1693 / 1693.pingPASS: 63 / 63.safe: 1098 agree, 0 false rejects, 0 timeouts.perfPASS: 124 / 124, the same as main. No bench fills a lane, so no spill runs, and every runtime cell is within noise of main. In the COMPILER column, symreg is 1.14x on this branch against 1.09x on main, and merkle 1.15x against 1.11x.deep!(1000000n)(deep(p) + 1) prints 1000000 on Metal, the same as--gpu off.CUDA: @nuxyel built 6764c33 on an RTX 4050 (CUDA 12.9). It runs every depth past the old limit with the CPU's results, up to
deep!(100000n), and the two loop kernels they time run at the same speed.Earlier, by @totaland, on an M1 Pro against upstream
60fa05dc: every test intests/gave byte-identical output on both trees (interpreter, compiled and JS), except the new test's compiled lane, which faults upstream. The Metal benches (mandelbrot, tree-matmul, tree-bitonic, nbody) were within noise.Limits
STAK_LENwords to the heap and back. A loop of calls at exactly the depth where the stack fills spills and restores the whole stack on every call: 100,000 calls there take 24.7 s, against 0.27 s one frame shallower. Main faults at that depth, so this is not a regression. Avoiding it would need a partial restore.lsum.bend