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>
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