Skip to content

metal: OUT_PROD's transposed F32 src0 is viewed in src0's own buffer, not op's - #41

Merged
joelteply merged 2 commits into
feat/props-weight-residencyfrom
fix/metal-out-prod-f32-transpose-buffer
Oct 6, 2026
Merged

joelteply merged 2 commits into
feat/props-weight-residencyfrom
fix/metal-out-prod-f32-transpose-buffer

Conversation

@joelteply

Copy link
Copy Markdown

ggml_metal_op_out_prod built the transposed view of an F32 src0 as out_prod_view(op, ...,
s_view.data): op's buffer with src0's address. When src0 lives in another buffer, as a LoRA
weight does (blk.N.*.lora_a/b, in the adapter's buffer), ggml_metal_buffer_get_id found no
buffer holding that address ("tensor '' buffer is nil"), the S^T copy into scratch never ran,
and the mul_mm read stale scratch. Every OUT_PROD with a LoRA weight as src0 (the backward
through A/B into the activation gradient) was wrong on Metal since 973357e, deterministically
but differently per graph layout: it is why recompute ON and OFF disagreed on Metal (#37) and
why recomputing only some layers gave NaN.

The view is now based on S, which carries the buffer that holds its data.

Measured on Qwen3.5-0.8B Q8_0 (M5, Metal), window 1024, seed 7, 5165 tokens, 1 epoch:

  • "buffer is nil": 70 per run before (10 per window, every run, recompute off or on); 0 after
  • eval loss, recompute off: 1.9291604 before, 1.9292085 after (the old value was computed
    from stale scratch)

Pending in this PR or follow-ups (from review in the room): a CPU reference for the same config (running); a lookup that finds no buffer fails the training job instead of computing on stale scratch; a test-backend-ops case with src0 in its own buffer (OUT_PROD, MUL_MAT), against CPU, on every backend.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

… not op's

ggml_metal_op_out_prod built the transposed view of an F32 src0 as out_prod_view(op, ...,
s_view.data): op's buffer with src0's address. When src0 lives in another buffer, as a LoRA
weight does (blk.N.*.lora_a/b, in the adapter's buffer), ggml_metal_buffer_get_id found no
buffer holding that address ("tensor '' buffer is nil"), the S^T copy into scratch never ran,
and the mul_mm read stale scratch. Every OUT_PROD with a LoRA weight as src0 (the backward
through A/B into the activation gradient) was wrong on Metal since 973357e, deterministically
but differently per graph layout: it is why recompute ON and OFF disagreed on Metal (#37) and
why recomputing only some layers gave NaN.

The view is now based on S, which carries the buffer that holds its data.

Measured on Qwen3.5-0.8B Q8_0 (M5, Metal), window 1024, seed 7, 5165 tokens, 1 epoch:
- "buffer is nil": 70 per run before (10 per window, every run, recompute off or on); 0 after
- eval loss, recompute off: 1.9291604 before, 1.9292085 after (the old value was computed
  from stale scratch)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… on #41)

Every test-backend-ops case allocated a, b and out in ONE buffer, so a kernel that resolved
src0's address against op's buffer passed them all: Metal's F32 transposed path did exactly
that and read stale scratch in every LoRA backward. test_out_prod gains a_own_buffer, which
puts a in the weight context (its own buffer, as a LoRA weight sits in its adapter's buffer
during training), at LoRA's shape (rank 8 against a 1024-wide activation), F32 and q8_0, both
gradient layouts. It runs on every backend against the CPU.

Known-positive on the M5: with #41's fix reverted, both F32 cases FAIL (ERR = inf, "buffer is
nil"); with it, 33/33 OUT_PROD cases pass on MTL0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Author

Reviewed, approve (as a comment: GitHub won't let our shared account approve). The fix bases the view on s_view, which is right: an F32 src0 is s_view's memory, and op's buffer never holds it. The new a_own_buffer case is the test that was missing. CUDA's ggml_cuda_out_prod reads src0->data with no buffer lookup, so the 5090 has no equivalent bug.

@joelteply

Copy link
Copy Markdown
Author

The fix is right. The view now takes its buffer from s_view, which carries src0's buffer for F32 and op's scratch for the dequantized case, so both paths resolve. The a_own_buffer cases are exactly the coverage that was missing.

One receipt before I approve, please: the new F32 a_own_buffer cases FAILING on Metal with the one-line fix reverted, and passing with it. That proves the test catches this class, rather than passing for some other reason. A test-backend-ops -o OUT_PROD -b Metal line for each is enough.

joelteply added a commit that referenced this pull request Oct 6, 2026
… never process-wide (Cormac on #42)

A process-wide counter let a training graph's nil lookup fail a decode graph encoding beside it,
which costs her a turn for a fault that was not hers. Each Metal context now owns its count:
every encode block (the main thread's and the dispatched ones) points a thread-local sink at
its context's counter while it encodes and clears it after, a nil lookup increments the
current sink, and ggml_metal_graph_compute zeroes its own count on entry and fails only its
own graph.

Measured on the M5 (Qwen3.5-0.8B, Metal, 2 slots decoding):
- serving only, fix in: 29 decode requests ok, 0 failed, 0 "buffer is nil"
- #41's fix reverted, training beside decoding: the training job fails on its first graph
  ("found no buffer while encoding this graph"), the decode requests in flight with it all
  succeed (2 ok, 0 failed: a thin sample, one failing graph)
- fix in, training beside decoding: training done, 216 decode requests ok, 0 failed,
  0 "buffer is nil"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply

Copy link
Copy Markdown
Author

Receipt asked for: with this PR's one-line fix reverted, on the M5 (MTL0) test-backend-ops -o OUT_PROD -b MTL0: OUT_PROD(type_a=f32,...,m=1024,n=1024,k=8,trans_b=0,a_own_buffer=1) and trans_b=1 both FAIL with [OUT_PROD] ERR = inf > 0.0005 and buffer is nil, 31/33 passed. With the fix: 33/33 passed, no nil line. (The q8_0 own-buffer cases pass either way: the quantized path dequantizes into scratch in op's buffer, so it never borrowed src0's address.)

@joelteply

Copy link
Copy Markdown
Author

APPROVED at 40163d7.

The mutation receipt settles it: with the fix reverted, both F32 own-buffer cases fail on MTL0 (ERR=inf, buffer is nil, 31/33); with it, 33/33. The test now guards this class on every backend.

@joelteply
joelteply merged commit f14c24b into feat/props-weight-residency Oct 6, 2026
17 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant