Skip to content

On Metal, the shared-node half of a take is a call, not code inlined into the taken match - #1978

Open
HamedShams wants to merge 2 commits into
bendlang:mainfrom
HamedShams:fix/metal-span-fade-call
Open

HamedShams wants to merge 2 commits into
bendlang:mainfrom
HamedShams:fix/metal-span-fade-call

Conversation

@HamedShams

@HamedShams HamedShams commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1954.

What fails. On an M1 Max (macOS 27.2, Xcode 27.1) the reproducer from the issue fails every run on Metal, on current main and back to v2.0.37: mostly a function the device does not hold, sometimes frontier drained without a result, once a hang. --gpu off and the interpreter print 57798656. The same program passed 466/466 on raramos9's M1 under macOS 15.6.1 and fails 400/400 on that same M1 after its update to 27.2; on Lorenzo's M4 Pro under macOS 26.5.2 it passes 100/100. So the trigger is the machine code the macOS 27.2 Metal compiler produces, seen so far on the M1 and the M1 Max; whether an M4 on 27.2 is affected is unknown.

Where. Bisected on the failing machine (every step rebuilt from main and run 10 times):

  • the reporter's variant with a heap-built Seg instead of the constant passes, and its shader is 208 KB instead of 1011 KB; the only device-side difference in the emitted C is one ctr_take call on the static Seg
  • ctr_take not inlined (static, or __attribute__((noinline))): passes, shader 214 KB / 294 KB
  • only the shared-node branch of ctr_take (acquire, last-holder free, or fade) as a real call: passes, shader 304 KB
  • span_fade alone as a real call, the copy loop not unrolled, every OUTLINE function as a real call on Metal, maxTotalThreadsPerThreadgroup = 128, the language version pinned to 3.2 or 4.0, 16 or 64 threadgroups per pass instead of 128: all still fail

The branch that moves never runs in this program. The static Seg is a plain constructor word (term_ctr sets no RFC_BIT), so at that site ctr_take always returns from the owned path. A counter in the CPU build confirms it: the program makes 16 ctr_take calls in all and none enters the shared-node branch, at this site or at the two four-field sites. Deleting that branch for two-field takes only, on top of main, therefore changes nothing the program executes, and the GPU result goes from wrong 10/10 to right 10/10 (shader 1011 KB to 297 KB).

So the Metal compiler at its default level inlines the shared-node branch of ctr_take into the taken match, the shader grows about five times, and on the M1s under macOS 27.2 the live code around that dead branch runs wrong. Code that never runs changing the result of code that does is a compiler defect, unless the live code has undefined behaviour the newer compiler exploits differently; nothing found points at the second, and the same shader is right under the older compiler, on the M4 Pro, on the CPU and in the interpreter. The corruption is there before dispatch, as raramos9 saw on the M1: captured at the failing dispatch here, the entry word is 0x72f3baae7b12e2fa and the like, function id 0xf3ba in a program with 40 functions, the same pattern within a few bits across six runs. Shader validation (MTL_SHADER_VALIDATION=1) rewrites the shader and fails every variant, main included, so it is not a usable check here.

The change. The shared-node branch moves from ctr_take into span_fade, which already is the shared path's out-of-line function, and span_fade is a real call on Metal (FAR) rather than a static the compiler inlines anyway. Host and CUDA keep OUTLINE as before, with the last-holder free now inside it. ctr_take is the copy and one branch. 18 lines in, 15 out; bun gates/repo.ts passes (comp.ts 65990 of 66000 ttok).

Evidence on the M1 Max (32-core GPU, 64 GB, macOS 27.2 build 26B123, Xcode 27.1, bun 1.4.2):

  • reproducer: 50/50 at the default level, 10/10 with +RTS --gpu 1GB, 5/5 compiled from source with no cached .gpu, clean under MTL_DEBUG_LAYER=1; --gpu off, --threads 1 and the interpreter print 57798656; the heap-built variant still passes 10/10; the shader archive goes from 1011 KB to 304 KB
  • the 17 bench/runtime programs: same checksums as main on GPU and on CPU, times below, medians of 3 runs, or of 5 alternating runs on an idle machine where a first pass had moved; CPU is +RTS --gpu off on all cores
  • the 45 tests that use a ! call, built natively (clang, Metal): 43 pass; the other 2 (printer/offload_bang_g, _h) have mains the compiler refuses to print, so they are check-only, as on main
  • tests/run and tests/rfc, built natively: 124 files: 113 pass, 7 are error tests whose expected output is the build error and exit 1, 4 are check-only; all 11 give byte-identical build logs on main
bench shader GPU main GPU fix CPU main CPU fix
bfs 116 KB 0.57s 0.58s 0.63s 0.59s
editdist 112 KB 0.85s 0.85s 0.85s 0.91s
gameoflife 103 KB 0.16s 0.16s 1.70s 1.69s
hashmap 125904 B -> 126192 B 1.40s 1.41s 0.50s 0.52s
histogram 286 KB 1.84s 1.86s 0.63s 0.64s
kmeans 222 KB 0.84s 0.83s 0.71s 0.64s
lexer 133824 B -> 133808 B 3.06s 2.91s 0.68s 0.64s
mandelbrot 145 KB 0.17s 0.17s 0.93s 0.79s
merkle 130 KB 0.15s 0.16s 0.86s 0.87s
nbody 132 KB 0.14s 0.15s 0.89s 0.89s
queens 133 KB 3.82s 3.89s 0.95s 0.94s
raytrace 142 KB 0.47s 0.46s 0.91s 0.91s
symreg 198 KB 1.50s 1.50s 0.66s 0.66s
terrain 117 KB 0.99s 0.99s 0.39s 0.39s
tree-bitonic 159 KB 3.47s 3.43s 1.41s 1.37s
tree-matmul 329 KB 0.76s 0.77s 0.62s 0.62s
tree-radix 164 KB 1.51s 1.49s 0.80s 0.86s

Rejected alternatives, both pass the reproducer:

  • compile the whole device program at MTLLibraryOptimizationLevelSize on the M1 family (supportsFamily:MTLGPUFamilyApple7 without Apple8), the shape I announced in the issue: raytrace 0.47 s to 0.55 s here, lexer unchanged, and it gates on the GPU family when raramos9's update shows the compiler version is the variable
  • ctr_take as a whole not inlined: lexer 3.03 s to 4.00 s, one call per character of the static string it walks

Still open in the thread: the reporter's macOS version and chip. A run of this branch on raramos9's M1 would be the second-machine test I cannot do here.

…into the taken match

On an M1 and an M1 Max under macOS 27.2, a program whose taken match
sits on a static constructor failed every run on Metal with "a function
the device does not hold", sometimes "frontier drained without a
result", once a hang; --gpu off and the interpreter agreed with each
other. The same source runs right on the same M1 under macOS 15.6.1 and
on an M4 Pro under 26.5.2, so the trigger is the code the newer Metal
compiler emits. At its default level it inlines ctr_take, and with it
the shared-node branch (acquire, free or fade), into the taken match;
the shader came out five times bigger and wrong.

The shared-node branch moves into span_fade, which already is the
shared path's out-of-line function, and span_fade is a real call on
Metal (FAR) instead of a static the compiler inlines anyway. On the host
and on CUDA the code is unchanged in meaning: OUTLINE as before, with
the last-holder free now inside it. The reproducer passes 50/50 at the
default level, 10/10 under --gpu 1GB, cleanly under MTL_DEBUG_LAYER=1,
and its shader shrinks from 1011 KB to 304 KB. The 17 runtime benches
give their checksums on GPU and CPU; lexer, the one bench that walks a
static string through ctr_take, runs 5 % faster on the M1 Max (3.06 s
to 2.91 s).

Rejected: compiling the whole device program at MTLLibraryOptimization-
LevelSize passes too but costs raytrace 17 % and gates on the GPU family
when the data points at the compiler version; a noinline ctr_take passes
but costs lexer 32 %, one call per character.
@raramos9

Copy link
Copy Markdown
Contributor

I independently confirmed both the optimization-level finding and the fix in #1978 on my Apple M1 (7-core GPU, macOS 27.2, build 26B123).

Using isolated builds of the same generated C:

  • Default optimization: 0/113 correct
  • MTLLibraryOptimizationLevelSize: 72/72 correct

With GPU task-dispatch instrumentation, the default build produced invalid task words containing floating-point-like patterns, while the size-optimized build produced no invalid task captures across 31 runs.

I then tested the PR base (9e8d7b79) against the head (7311a63c), keeping Metal's default optimization level:

  • Base: 0/80 correct (50 regular + 30 instrumented)
  • PR head: 90/90 correct (50 regular + 30 instrumented + 10 compiled-from-source)
  • Invalid task captures: 21 on the base, 0 on the PR head
  • GPU archive size: 1,000,512 bytes → 302,800 bytes

The builds were isolated, with separate shader archives. The original reproducer was checksum-verified, and the PR head also passed when compiling the shader from source on every run.

15 of 17 benchmarks produced matching GPU results between base and head, consistent with CPU output, with execution times within observed noise.

The remaining two (lexer and queens) intermittently hit macOS's GPU watchdog on both revisions, so they don't appear to be regressions introduced by this PR.

This independently confirms that #1978 resolves the reproducer's failure on a second M1-family machine without changing Metal's optimization level. Thanks again for tracking this down!

@HamedShams

Copy link
Copy Markdown
Contributor Author

@raramos9 thanks for running it, that's the second machine the PR needed.

The lexer/queens watchdog hits look like their own issue: those two take 3 to 4 s on my 32-core GPU M1 Max here, so a 7-core M1 running past the GPU time limit on main as well makes sense. If you open it with your numbers I'll add mine.

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 31, 2026
Merged onto main 1cecb12 the PR took comp.ts to 66,032 of 66,000 ttok.
FAR on the host and CUDA too, instead of the Metal-only #ifdef: CUDA's
OUTLINE already is FAR, and the host loses only cold and preserve_most
on a path that frees or fades a shared node. comp.ts 66,000; gates test
1799/1799, perf 124/124, ping 81/81 on an M4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

@HamedShams merged onto main 1cecb12 the PR put bend2/comp.ts at 66,032 of its 66,000 ttok cap, so I pushed fd8fafb on your branch: span_fade is plain FAR on every target instead of the Metal-only #ifdef (CUDA's OUTLINE already is FAR; the host only loses cold/preserve_most on the shared-node path), with a shorter comment. comp.ts is now exactly 66,000; on an M4 test 1799/1799, perf 124/124, ping 81/81, safe unchanged.

On Metal it is the same FAR as your version, so it should behave the same, but I can't check that without an M1 on 27.2. Could you (or @raramos9) rerun the #1954 reproducer on this head? If it fails, please just revert fd8fafb and tell us, and we'll find the tokens elsewhere.

@HamedShams

Copy link
Copy Markdown
Contributor Author

@Lorenzobattistela fd8fafb passes here, same M1 Max as before (32-core GPU, macOS 27.2 build 26B123, Xcode 27.1).

  • Metal: standalone renderer fails with “a function the device does not hold”; CPU and interpreter agree #1954 reproducer: 50/50 at the default level, 10/10 with +RTS --gpu 1GB, 5/5 compiled from source with no cached .gpu, clean under MTL_DEBUG_LAYER=1; --gpu off and --threads 1 print 57798656; the heap-built variant still passes 10/10
  • shader archive 303,664 B, the same size as on 7311a63 (main: 1,011 KB); the emitted C differs from 7311a63 only in the two attribute lines on span_fade, so under __METAL_VERSION__ the source is the same
  • the 17 bench/runtime programs: same checksums as main on GPU and on CPU; 7311a63 and fd8fafb alternated, medians of 5, every pair within 4% either way, so the host path losing cold/preserve_most does not show
  • bun gates/repo.ts 54/54

Nothing to revert.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metal: standalone renderer fails with “a function the device does not hold”; CPU and interpreter agree

3 participants