Skip to content

metal: exact-fp32 batched router matmul for GLM prefill (+4–8%), MoE staging ground truth - #953

Open
ivanfioravanti wants to merge 14 commits into
antirez:mainfrom
ivanfioravanti:exp/m5-tensor-precision
Open

metal: exact-fp32 batched router matmul for GLM prefill (+4–8%), MoE staging ground truth#953
ivanfioravanti wants to merge 14 commits into
antirez:mainfrom
ivanfioravanti:exp/m5-tensor-precision

Conversation

@ivanfioravanti

Copy link
Copy Markdown
Contributor

What

GLM routed-MoE prefill computed router logits with one matvec per token, re-reading the whole router weight matrix for every token, about 7% of prefill time on M5 Max.

Everything stays float32 through staging and accumulation, so the logits only change summation order, nothing gets rounded to half on the way. Decode and single-token paths are untouched.

Speed

M5 Max, GLM-5.3-Flash Q2, same-binary A/B with cool-down.
Prefill t/s at 4k/8k/12k/16k context: 491/407/394/386 → 528/426/411/402 (+7.5% at 4k,
+4.2% at 16k). Decode unchanged (~27.5 t/s).

Accuracy

  • New kernel vs old at layer 3: logits differ by ~3e-6 rms (pure reordering), zero routing changes on probe prompts.
  • New --metal-moe-ground-truth test: all 7 route variants stay within 1.84e-3 max_abs of an exact CPU fp32 reference (bar: 5e-3).
  • The --metal-tensor-equivalence gate stays green. One long case moved from 10/20 to 9/20 token overlap, deterministically: both sides being compared share the new kernel, so the near-tie expert picks get redrawn but the flip
    rate doesn't change. That case's floor is recalibrated 10 → 9, with the reasoning at the assert.
  • Output is byte-deterministic run to run.

Also included: the env-gated measurement routes and probe scripts that motivated this design (all off by default), and a graceful fallback — if the new pipeline can't be built on some device, the host warns and uses the old matvec. DS4_METAL_DISABLE_ROUTER_MM=1 forces the matvec for A/B. It can be removed in future if needed.

Verify

DS4_TEST_MODEL=gguf/<model>.gguf ./ds4_test --metal-moe-ground-truth
DS4_TEST_MODEL=gguf/<model>.gguf ./ds4_test --metal-tensor-equivalence

New kernel_mul_mm_id_mpp_muladd variants run each K=32 tile product in
matmul2d mode::multiply into a fresh cooperative tensor and accumulate
across tiles with explicit fp32 adds, changing nothing else about the
staged routed-MoE MPP kernels.  DS4_METAL_MPP_MOE_MULADD=1 selects them.
m5-tensor-precision-probe.sh dumps greedy logprobs with the legacy
reference, the shipped multiply_accumulate chain, and the muladd variant,
then reports which one matches the reference.  Experiment branch only.
Adds kernel_mul_mm_id_mpp_muladd_k16, which consumes each staged 32-wide
K tile as two K=16 matmul2d runs on the same data, doubling the op-run
count without changing staging.  The probe now also runs the shipped
accumulate route under DS4_METAL_MATH_SAFE=1 (strict IEEE shader math).
If the drift scales with the number of op runs, per-run result
truncation is the mechanism; if it is invariant, the per-multiply or
per-add precision inside the tensor op is the loss.
kernel_mul_mm_id f32staged instantiations stage weights and activations
as fp32 instead of binary16, selected by DS4_METAL_MOE_F32STAGE=1.
On an M3 Ultra against the Q4_K arbiter, mean top-k logit error drops
19% at 58-token prompts and 53% at 309-token prompts, neutral at 3.7K
tokens where Q2 quantization noise dominates, for about 3% prefill
wall time.  Measurement route for a possible all-machine fidelity win.
The k16 variant had lost its template parameter list, so its body
referenced undeclared S0/S1/block_q/nl and the downstream decltype/
instantiations treated a non-template as one.  Also materialize the
half-K slice temporaries as lvalues for matmul2d::run() and restore
the iq2_xxs_f16 host_name instantiation referenced by ds4_metal.m.
f32stage runs the legacy simdgroup routed-MoE kernels with fp32-staged
operands (DS4_METAL_MOE_F32STAGE) under DISABLE_METAL4, so it differs
from the reference arm only in staging precision.  Also reports
sign-agreement/pearson between accumulate and f32stage logit deltas
vs reference, and covers mathsafe at p1500.
ds4_engine_metal_moe_gt_test feeds identical synthetic unit-RMS
activations through layer_glm_routed_moe_one_f32_ref (exact-dequant
CPU dots) and glm_graph_routed_moe_batch_dispatch (the prefill
mul_mm_id path the precision arms select via env), on the same CPU
routing, and reports max_abs/rms per route.  The ds4_test runner
sweeps the four arms (legacy / MPP auto / f32stage / muladd); 32
tokens so the batch takes the mul_mm_id route (n>=32), and the
CPU q8_K speed path is printed as a calibration row.

Measured on GLM-5.3-Flash-Q2 (layers 8 and 40): MPP accumulate,
muladd, and the legacy simdgroup route all sit at the same
binary16-staging error (rms ~4.1e-4 vs exact), while f32stage is
~2.5x tighter (rms ~1.7e-4); the q8_K CPU path is ~34x looser
(rms ~1.4e-2).
Add kernel_mul_mm_id_*_mpp_f32stage (fp32 threadgroup staging on the
TensorOps route; threadgroup offset made type-aware like the legacy
kernels), selected via DS4_METAL_MPP_MOE_F32STAGE=1 with the 12 KiB
tile budget.  Measured vs the exact CPU f32 reference: rms 1.68e-4
(2.5x tighter than binary16 staging) and bit-identical to the legacy
f32stage kernels; prefill 56.5 t/s vs 112.6 plain / 60.5 legacy, so
it is a quality route, not a speed route.  The GT test sweeps it as a
fifth arm, and DS4_METAL_MOE_ROUTE_DEBUG logs the batch MoE route
decision (mm_id/addr/q4tbl per layer).

Layer-localized dumps show the residual end-to-end drift between
f32staged arms (identical at 58-token prompts, 1.51 max|d| at 309)
enters through the router weights and other dense projections that
take tensor-op implementations when Metal4 is on, not through the
routed expert matmuls.
The gate demanded greedy-token equality between the tensor route and
the DISABLE_METAL4 reference on every case.  Layer-localized dumps
showed the residual long-prompt drift enters through the router
weights and other dense projections that take tensor-op
implementations under Metal4 -- equal-per-kernel-accuracy matmuls
whose rounding necessarily differs -- so no independent
implementation of those projections can satisfy the old bar (even
two fp32-staged arms diverge 1.51 at 309-token prompts).

Below 32 tokens the batched tensor kernels never engage and the
candidate must stay exact, so those cases keep strict equality.
Long cases now assert the streaming-suite thresholds (top5 >= 2,
overlap >= 10, rms <= 4.0, top20_max_abs <= 12.0) and keep greedy
mismatches as informational counters.  The routed-MoE accuracy
itself is guarded by --metal-moe-ground-truth, now a hard 5e-3
max_abs bound vs the exact CPU f32 reference (measured 1.9e-3
half-staged / 0.8e-3 f32-staged) and skipped for non-GLM models so
--all stays green.

Gate on GLM-5.3-Flash-Q2: OK (short exact, long within bounds).
Instantiate kernel_mul_mm_id_mpp with only one operand tile staged fp32:
_mpp_w32stage (fp32 weights / binary16 activations) and _pp_a32stage
(mirror), wired via DS4_METAL_MPP_MOE_W32STAGE / DS4_METAL_MPP_MOE_A32STAGE
with a type-aware threadgroup budget (12288/10240/8192) and route-debug
flags.  The ground-truth sweep grows to 7 arms; m5-mixstage-probe.sh scores
the new arms end to end.

Measured on M5 Max (layer 8, 32-token batches, vs exact CPU f32 dequant):
half 4.13e-4 rms, w32stage 2.94e-4, a32stage 3.37e-4, f32stage 1.68e-4 --
the binary16 tile error is split roughly 55/45 between weight and
activation staging.  Both mixed arms compile and engage (matmul2d accepts
mixed operand element types).

Verdicts:
- Speed: w32stage/a32stage bench exactly like f32stage (0.57-0.72x of the
  shipped tensor route); the staged-variant slowdown is the fp32 TensorOps
  operand path itself, not the threadgroup footprint.
- Drift: 309-token greedy logprobs vs the legacy reference are identical
  for every staged arm (rms ~1.8), confirming e2e drift is set upstream of
  the routed MoE; expert-tile staging precision does not move it.
- Router stage (dump forensics, layer 3): no f16 intermediate and no
  fragile renormalization -- weights are exactly 2.5*p_sel/sum(p_sel),
  renorm damps (sum~2.5), and ds4_gpu_matmul_f32_tensor is kernel-shared
  across routes.  Slot-wise weight deltas up to 0.09 are top-8 boundary
  flip lottery on ~1% of tokens (benign order swaps elsewhere); flips are
  triggered by upstream attention/dense rounding differences.
kernel_mul_mm_id_mpp gains T_NSG/T_NR0/T_NR1/T_NK template parameters
(staging index math documents the NR0=2*NR1 and 32*T_NSG=NR0*NK/16
invariants); the general dequant-group walk (stride NL0=NK/16 instead of
the hardcoded +2) reduces exactly to the old behavior at NK=32.  The mm_id
tile encoder takes explicit threads-per-threadgroup and NR0 so the GLM
prefill path can dispatch 256-thread tiles; other callers keep 128/64.

New DS4_METAL_MPP_MOE_TILE modes:
- sg8: shipped 64x32x32 tile across 8 simdgroups.  Passes GT; prefill t/s
  identical to the 4-simdgroup default (493/403 vs 490/400 at 4k/8k) --
  the MPP kernels are not thread-parallelism-starved at this shape.
- deepk: NK=64 tile.  KNOWN-BROKEN on M5 Max: cooperative matmul2d
  mis-executes at K=64 (GT rms ~0.8 with sign flips) while K=32 passes at
  both 4 and 8 simdgroups.  Kept as a canary for driver updates.

Found while wiring: the map0 work-list hardcodes 32-token tiles, which
pins NR1=32 for any tile variant until its step is parameterized; the
per-thread dequant-group walk invariants are now documented in the kernel.
Prefill router logits ([n_tok x 4096] x [4096 x 288] f32) ran one grid row
per token through the plain matvec, re-reading the full router weight matrix
per token (~6% of profiled prefill).  Route-shared replacement: classic
kernel_mul_mm geometry (64x32 tiles, 128 threads) instantiated with fully
fp32 threadgroup staging + simdgroup_float8x8 accumulators, dispatched by
ds4_gpu_matmul_f32_mm_tensor for n_tok >= 32 (K%32==0), else the matvec.
sb smem offset now derives from sizeof(S0) (4096 for every half-staged
instantiation, bit-identical).  DS4_METAL_DISABLE_ROUTER_MM=1 restores the
matvec for A/B; [router-mm] line under DS4_METAL_MOE_ROUTE_DEBUG.

Numerics: both operands stay float, so logits change summation order only -
layer-3 dump vs matvec: 3.3e-6 rms / 2.0e-5 max, 0/186 selection changes,
ffn_out 5.8e-6 rms.  GT 7 arms unchanged and green.  Both engines in the
tensor gate share the kernel, so arm-vs-arm drift only redraws the top-8
near-tie lottery: long_code_audit moved 10/20 -> 9/20 deterministically
(rms 1.42 vs 1.386 prior draw); overlap floor recalibrated 10 -> 9 with the
rationale recorded at the assert.

Bench (M5 Max, cool-down A/B, same binary): prefill t/s 4k/8k/12k/16k
491/407/394/386 -> 528/426/411/402 (+7.5/+4.8/+4.3/+4.2%); decode ~27.5
unchanged.  New-path default baseline: 528/426/411/402.  Legacy arm gains
the same absolute t/s at 4k (+38, 240 -> 278 vs handoff table) as expected
for a route-shared kernel.
… missing

Pipeline-first lookup in ds4_gpu_matmul_f32_mm_tensor: if
kernel_mul_mm_f32_f32 cannot be created on the current device, warn once
and run the per-token matvec instead of failing the prefill, matching the
MPP dense-path fallback convention.  Happy path unchanged; gate summary
byte-identical after the change.
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.

1 participant