metal: fix NaN in mul_mm_id when activations exceed f16 range - #26223
metal: fix NaN in mul_mm_id when activations exceed f16 range#26223mdegans wants to merge 6 commits into
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
Noted for next time. I reviewed the post and was satisfied so I said "go for it". |
| // 16 bytes for the factor pair (inverse scale applied to src1 on | ||
| // load, scale applied to the f32 accumulator on store), then one | ||
| // float per stage-1 partial. |
There was a problem hiding this comment.
Shorten to:
| // 16 bytes for the factor pair (inverse scale applied to src1 on | |
| // load, scale applied to the f32 accumulator on store), then one | |
| // float per stage-1 partial. | |
| // 2 scaling factors (16 bytes) + N_MM_NPART_AMAX per-threadgroup scales for stage-1 |
Btw, why do we reserve 16 bytes for the 2 floats, instead of just 8 bytes?
| // src1 rescale factors, computed before the matmul so the | ||
| // narrowing to the half MMA operands cannot overflow. See | ||
| // kernel_mul_mm_id_amax_f32. |
There was a problem hiding this comment.
| // src1 rescale factors, computed before the matmul so the | |
| // narrowing to the half MMA operands cannot overflow. See | |
| // kernel_mul_mm_id_amax_f32. | |
| // src1 rescale factors, computed before the matmul | |
| // ref: https://github.com/ggml-org/llama.cpp/pull/26223 |
| // threadgroups into partials, stage 2 folds the partials and writes the | ||
| // factor pair. | ||
|
|
||
| #define GGML_METAL_AMAX_NPART 256 |
There was a problem hiding this comment.
Rename this constant to N_MM_NPART_AMAX and move to ggml-metal-impl.h
| // Compute max(|x|) over src1 and derive a power-of-two scale that | ||
| // brings the activations inside f16 range. | ||
| // | ||
| // kernel_mul_mm_id narrows src1 to `half` for the simdgroup MMA | ||
| // operands. f16 saturates at 65504, so a model whose activations exceed | ||
| // that yields inf, and simdgroup_multiply_accumulate then poisons the | ||
| // whole accumulator tile with NaN. Scaling src1 down by a power of two | ||
| // on load and scaling the f32 accumulator back up on store is exact — | ||
| // powers of two are exact in binary floating point and the dot product | ||
| // is linear, so a single tensor-wide factor introduces no error at all. | ||
| // | ||
| // When amax fits already (the overwhelmingly common case) the factor is | ||
| // 1.0 and the result is bit-identical to not doing this. | ||
| // | ||
| // Two stages so the pass stays bandwidth-bound rather than serialized | ||
| // on one threadgroup: stage 1 reduces rows across GGML_METAL_AMAX_NPART | ||
| // threadgroups into partials, stage 2 folds the partials and writes the | ||
| // factor pair. |
There was a problem hiding this comment.
Delete this comment - we have a reference to this PR at the kernel launch site.
| // operand itself has to fit. | ||
| float scale = 1.0f; | ||
|
|
||
| if (isfinite(amax) && amax > 32768.0f) { |
There was a problem hiding this comment.
Is this isfinite check needed?
| // Power-of-two rescale so activations outside f16 range survive the | ||
| // narrowing to the `half` MMA operands. Both factors are exactly 1.0 | ||
| // unless src1 needed it, in which case they are exact powers of two. |
There was a problem hiding this comment.
| // Power-of-two rescale so activations outside f16 range survive the | |
| // narrowing to the `half` MMA operands. Both factors are exactly 1.0 | |
| // unless src1 needed it, in which case they are exact powers of two. | |
| // power-of-two rescaling |
| // Activations outside f16 range. Backends that narrow src1 to a | ||
| // half-precision type for a matrix-multiply path (Metal's | ||
| // mul_mm_id feeds simdgroup_half8x8) saturate at 65504 and produce | ||
| // inf, then NaN — while the vector path on the same backend, and | ||
| // every CPU path, are correct. Real models hit this: Mistral | ||
| // Small 4 (arch mistral4, 128 experts / 4 active) has a layer whose | ||
| // ffn activations reach ~1e5, so on Metal every prefill of >= 32 | ||
| // tokens returns an all-NaN vocabulary, and fewer than 32 tokens is | ||
| // correct (ne21_mm_id_min switches mul_mv_id -> mul_mm_id at 32). | ||
| // n = 32 and 64 sit above that switch; n = 16 below it is the | ||
| // control that must stay green. |
There was a problem hiding this comment.
| // Activations outside f16 range. Backends that narrow src1 to a | |
| // half-precision type for a matrix-multiply path (Metal's | |
| // mul_mm_id feeds simdgroup_half8x8) saturate at 65504 and produce | |
| // inf, then NaN — while the vector path on the same backend, and | |
| // every CPU path, are correct. Real models hit this: Mistral | |
| // Small 4 (arch mistral4, 128 experts / 4 active) has a layer whose | |
| // ffn activations reach ~1e5, so on Metal every prefill of >= 32 | |
| // tokens returns an all-NaN vocabulary, and fewer than 32 tokens is | |
| // correct (ne21_mm_id_min switches mul_mv_id -> mul_mm_id at 32). | |
| // n = 32 and 64 sit above that switch; n = 16 below it is the | |
| // control that must stay green. | |
| // test src1 f16 overflow |
| // Magnitude of the src1 activations. Default 1.0f reproduces the | ||
| // historical uniform [-1, 1] init. Larger values exercise backends | ||
| // that narrow the activations to a lower-range type internally: | ||
| // Metal's mul_mm_id feeds simdgroup_half8x8, and f16 saturates at | ||
| // 65504, so real models whose activations exceed that produce inf | ||
| // and then NaN on that path while the mul_mv_id path is correct. | ||
| const float amax; |
There was a problem hiding this comment.
| // Magnitude of the src1 activations. Default 1.0f reproduces the | |
| // historical uniform [-1, 1] init. Larger values exercise backends | |
| // that narrow the activations to a lower-range type internally: | |
| // Metal's mul_mm_id feeds simdgroup_half8x8, and f16 saturates at | |
| // 65504, so real models whose activations exceed that produce inf | |
| // and then NaN on that path while the mul_mv_id path is correct. | |
| const float amax; | |
| const float amax; // magnitude of src1 |
| // src1 (activations) only — the weights stay in their normal | ||
| // range so this isolates activation magnitude. |
| // Leave a comfortable margin below the f16 max of 65504: the | ||
| // products feeding the accumulator stay in f32, so only the | ||
| // operand itself has to fit. |
There was a problem hiding this comment.
| // Leave a comfortable margin below the f16 max of 65504: the | |
| // products feeding the accumulator stay in f32, so only the | |
| // operand itself has to fit. | |
| // leave a comfortable margin below the f16 max of 65504 |
|
Would you like me to trim down the PR description as well? My justification for leaving it was/is that it explains the rationale of the agent who made the edits, however if you'd prefer one or two sentences I can make that happen. |
a26851f to
fe448b6
Compare
|
|
||
| ggml_metal_op_concurrency_reset(ctx); | ||
|
|
There was a problem hiding this comment.
In terms of concurrency, currently we run the kernels like this:
amax_part
amax + map0 (these 2 run in parallel)
main
I wonder if it would be more optimal to stack the map0 together with the amax_part like this:
amax_part + map0 (these 2 run in parallel)
amax
main
There was a problem hiding this comment.
Ran ABBA BAAB tests on a hot M2 MacBook. B (amax_part + map0) is slightly faster (-1.8% mean, up to 3.5%) when the batch size is 512. At 32 it's just noise. If you'd like I can submit B as a separate PR. I'm assuming you don't want it rolled into this one.
There was a problem hiding this comment.
It's ok to push the change in this PR. Btw, it's worth benchmarking up to -ub 2048 since many models benefit from larger than 512 microbatch size.
There was a problem hiding this comment.
My mac's GPU is going to be tied up until Saturday. I will test up to 2048, after are reboot, then and add the commit here unless there is a performance regression.
There was a problem hiding this comment.
So. As you suspected, win scales with batch size. The commit is added. The 1024 and 2048 cases are left out of the commit since everywhere else seems to only go to 512.
The description in the PR is OK. The goal is the comments in the code to be short to make it easier to read/edit. Longer descriptions should be referenced with links. |
The Metal mul_mm_id path narrows src1 to `half` for the simdgroup MMA
(`S1 = half` in every instantiation; ggml-metal.metal:10582 and :10595,
mirrored at :10643/:10654 in the tensor-ops path). f16 saturates at
65504, so a model whose activations exceed that produces inf, and
`simdgroup_multiply_accumulate` then turns the whole 8x8 accumulator
tile into NaN. The mul_mv_id path used below `ne21_mm_id_min` (32)
carries the same values in f32 and is correct, as is every CPU path.
This was untestable before: `init_mul_mat_id_tensors` initializes
uniform [-1, 1], so no existing case can drive an operand out of f16
range. `test_mul_mat_id` gains an `amax` parameter (default 1.0f,
preserving the historical init exactly) that scales only the f32
activations, leaving the quantized weights in their normal range.
Six cases: n=16 sits below the mul_mv_id -> mul_mm_id switch and is the
control that must stay green; n=32 and n=64 are above it and fail on
Metal today. Two shapes, because this is not model- or size-specific —
q4_K at 128 experts / 4 active / 4096x2048 mirrors a real model, and
q8_0 at 8 experts / 2 active / 512x256 shows the same failure at
minimal size.
Observed on Apple M2 Max, macOS, llama.cpp b10156:
MUL_MAT_ID(type_a=q8_0,...,n=32,k=256,amax=100000.000000):
[MUL_MAT_ID] NaN at index 0 (MTL0=nan CPU=583442.375000) FAIL
The real model behind this is Mistral Small 4 (arch mistral4, 128
experts / 4 active), one of whose layers reaches ~1e5 activations: on
Metal every prefill of >=32 tokens returns an entirely NaN vocabulary,
while <32 tokens is correct.
Note kernel_mul_mm (dense) has the identical conversion at :10273 and
:10286 and is expected to fail the same way; it is not covered here.
Found and written by Claude Opus 5 (via Claude Code).
kernel_mul_mm_id narrows src1 to `half` for the simdgroup MMA operands
(`S1 = half` in every instantiation). f16 saturates at 65504, so a model
whose activations exceed that produces inf on load, and
simdgroup_multiply_accumulate then propagates NaN across the whole 8x8
accumulator tile. The result is an entirely NaN output — not a precision
loss, a total loss. The mul_mv_id path taken below ne21_mm_id_min (32)
keeps the same values in f32 and is correct, as is every CPU path, so
the same model produces correct logits for short inputs and NaN for
long ones.
Fix: rescale src1 by a power of two so it fits, and undo the scale on
the f32 accumulator at the store. A two-stage reduction computes
max(|src1|) and writes the pair (1/scale, scale) into scratch chained
off the destination buffer, in the same style as the existing tpe/ids
id-mapping scratch. The matmul multiplies on load and on store.
This is exact, not approximate, for two reasons: the dot product is
linear, so one tensor-wide factor commutes through the accumulation;
and the factor is a power of two, so both multiplications are exact in
binary floating point. When max(|src1|) already fits — every model that
works today — the factor is exactly 1.0 and the output is bit-identical
to before. Accumulation was already f32 and is unchanged; only the
operand narrowing was ever the problem.
The reduction is two-stage (256 threadgroups into partials, then one
threadgroup folding them) specifically so it stays bandwidth-bound. A
single-threadgroup version was measured first and cost up to +451%
median on prefill — the scan serialized against an otherwise idle GPU.
It is also dispatched only on the mm path, so decode never pays for it.
Measured on Apple M2 Max, `test-backend-ops perf -o MUL_MAT_ID -b MTL0`,
99 cases, versus the same build without this change:
n=1/4/8 (mul_mv_id, decode) : -0.8% / -0.8% / -0.4% median (noise)
n=32 (mul_mm_id, prefill) : +1.73% median
n=64 : +1.30% median
n=128 : +1.80% median
n=256 : +3.98% median
n=512 : +3.74% median, +7.20% worst
overall : +1.14% median
Correctness, same machine:
- the six new test-backend-ops cases go from 4 FAIL / 2 OK to all OK,
with the n=16 controls (mul_mv_id path) unchanged;
- `test-backend-ops -b MTL0` full run: 0 failures, no regression;
- Mistral-Small-4-119B (arch mistral4, 128 experts / 4 active) now
generates correctly at the default n_ubatch of 512, in both
UD-IQ3_S and UD-Q4_K_XL quantizations. Before this, every prefill of
>= 32 tokens returned an all-NaN vocabulary and only n_ubatch <= 31
(forcing the mul_mv_id path) worked.
Likely fixes ggml-org#25722 (mistral4 empty output on Metal above ~300 tokens,
FA on and off, generation degenerating to a single control token — the
signature of argmax over an all-NaN distribution). ggml-org#20668 may be the
same defect attributed to a bad GGUF.
Note kernel_mul_mm (dense) has the identical narrowing at the
corresponding load sites and is expected to fail the same way; it is
left alone here to keep this change reviewable. Also possible, and left
for later: scaling per output column rather than per tensor, which
would preserve more precision when a single token is the hot one.
Found, diagnosed and fixed by Claude Opus 5 (via Claude Code).
- remove verbose comments - explain rationale as requested Generative AI disclosure: Claude made the edits as requested.
fe448b6 to
976221d
Compare
|
|
||
| ggml_metal_encoder_dispatch_threadgroups(enc, 1, 1, 1, 32, 1, 1); | ||
| } | ||
|
|
||
| // this barrier is always needed because the next kernel has to wait for the id maps to be computed |
There was a problem hiding this comment.
This comment should become:
// the next kernel has to wait for the amax dataThere was a problem hiding this comment.
It's done. Let me know when/if you want me to rebase on top of the latest master.
Implement @ggerganov suggestion to stack amax_part + map0. Mean 2.6% faster (worst -0.7%, best -4.1%). Win grows with batch size. Benchmarked on a hot M2 Max after reboot. Generative AI disclosure: Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
976221d to
2f7259a
Compare
|
Verified this fixes the failure on a second machine and a different chip. Setup: Apple M4 Max / 128 GB, macOS, Metal. Built from Red/green with this PR's own test cases, toggling only
The
Independent corroboration at the model level. Before finding this PR I hit #25722 with Mistral Small 4 119B (Q4_K_M) and bisected it from the other direction — via
Same integer, reached without touching the kernel. That's consistent with your analysis that One note for anyone else trying to reproduce the test: the |
|
@mdegans Could you rebase on latest @forforever73 I think this change is good. Could you have a second look when you get the chance? One improvement we can do in the future is to gate it with the new |
Confirmed via a CPU-only diagnostic (-ngl 0) that the original empty-content/hallucination failure is a Metal-backend f16 overflow in mul_mm_id's MoE down-projection (large Laguna activations overflow f16's 65504 ceiling -> NaN), not a reasoning-mode or harness-probe issue. Upstream fix (ggml-org/llama.cpp#26223) is still unmerged. 9/9 clean results with GPU offload disabled entirely.
I have been vacation but I can certainly take care of that in a few days when I am back home. |
Fixes an all-NaN output from
mul_mm_idon Metal when a model's activations exceed f16 range. Likely fixes #25722; #20668 may be the same defect attributed to a bad GGUF.The bug
kernel_mul_mm_idnarrows src1 tohalffor the simdgroup MMA operands —S1 = halfin every instantiation. f16 saturates at 65504, so activations above that becomeinfon load, andsimdgroup_multiply_accumulatethen propagates NaN across the entire 8x8 accumulator tile. The output is not degraded, it is entirely NaN.The conversion sites are
ggml-metal.metalinkernel_mul_mm_id:(plus the two mirrors in the
GGML_METAL_HAS_TENSORpath)Because the
mul_mv_idpath taken belowne21_mm_id_min(32) keeps the same values in f32, the same model produces correct logits for short prompts and NaN for long ones, with the switch at exactly 32 tokens. Accumulation was already f32 and was never the problem — only the operand narrowing.Reproducer
The first commit adds one. This class of bug was previously untestable:
init_mul_mat_id_tensorsinitializes uniform[-1, 1], so no existing case can drive an operand out of f16 range.test_mul_mat_idgains anamaxparameter (default1.0f, preserving the historical init exactly) that scales only the f32 activations, leaving quantized weights in their normal range.Six cases, two shapes —
n=16is the control on themul_mv_idpath and must stay green;n=32/n=64are above the switch:It reproduces at minimal size (8 experts, 2 active, 512x256), so this is not specific to any model or geometry.
The fix
Rescale src1 by a power of two so it fits, and undo the scale on the f32 accumulator at the store. A two-stage reduction computes
max(|src1|)and writes(1/scale, scale)into scratch chained off the destination buffer, in the same style as the existingtpe/idsid-mapping scratch.This is exact, not approximate, for two reasons: the dot product is linear, so one tensor-wide factor commutes through the accumulation; and the factor is a power of two, so both multiplications are exact in binary floating point. When
max(|src1|)already fits — every model that works today — the factor is exactly1.0and the output is bit-identical to before.The reduction is two-stage (256 threadgroups into partials, then one threadgroup folding them) specifically so it stays bandwidth-bound; a single-threadgroup version was measured first and cost up to +451% on prefill. It is dispatched only on the mm path, so decode never pays for it.
Performance
Apple M2 Max,
test-backend-ops perf -o MUL_MAT_ID -b MTL0, 99 cases, against the same build without this change:mul_mv_id(decode)mul_mm_id(prefill)Validation
n=16controls unchangedtest-backend-ops -b MTL0full run: 0 failures, no regressionmistral4, 128 experts / 4 active) generates correctly at the defaultn_ubatchof 512 in bothUD-IQ3_SandUD-Q4_K_XL. Before this, every prefill of >=32 tokens returned an all-NaN vocabulary, and onlyn_ubatch <= 31— forcing themul_mv_idpath — worked.Not covered
kernel_mul_mm(dense) has the identical narrowing at the corresponding load sites and is expected to fail the same way. Left alone here to keep this reviewable; happy to extend if you'd prefer one change.Requirements
This bug was found, diagnosed, reproduced and fixed by Claude Opus 5 (via Claude Code), working from a real-model failure. I reviewed and understand the code.
🤖 Generated with Claude Code