Skip to content

ggml-cpu: re-hook llamafile MoE matmul on x86 (partial fix for #973) - #974

Merged
aittalam merged 3 commits into
mainfrom
fix/issue-973-moe-matmul-hook
May 22, 2026
Merged

aittalam merged 3 commits into
mainfrom
fix/issue-973-moe-matmul-hook

Conversation

@aittalam

@aittalam aittalam commented May 22, 2026 •

Copy link
Copy Markdown
Member

Summary

The 0.10.0 "llamafile reloaded" refactor (#867) dropped the llamafile_mixmul and llamafile_mixmul_iqk hooks from ggml_compute_forward_mul_mat_id in the llama.cpp submodule. Since then, MoE matmul on x86 falls through to single-row ggml_vec_dot_q4_K_q8_K (AVX2-only, no batching) instead of llamafile's batched, AVX-512-aware iqk kernels. This causes the 5–15× CPU-only regression on K-quant MoE models reported in #973 (Qwen3-30B-A3B-Q4_K_M reproduces cleanly on both Alderlake AVX-VNNI and Ice Lake-SP AVX-512-VNNI — it's an x86-wide bug, not AVX-VNNI-specific).

This PR adds a new patch llama.cpp.patches/patches/ggml_src_ggml-cpu_ggml-cpu.c.patch that re-introduces both hooks (top-level llamafile_mixmul + inner llamafile_mixmul_iqk), mirroring the 0.9.3 shape. The fix applies cleanly to both the 0.10.1 submodule pin (5e9c63546) and main's newer pin (7b8443ac7) — context at the patch sites is byte-identical.

Evidence (Xeon Gold 6338, Ice Lake-SP, Qwen3-30B-A3B-Q4_K_M, CPU-only)

metric 0.9.3 0.10.1 unpatched 0.10.1 + this PR
pp2048 (t/s) 128.69 8.47 27.93 (3.3× speedup)
tg32 (t/s) 20.12 3.42 4.61 (1.35× speedup)

perf-record confirms the hook is firing: ggml_vec_dot_q4_K_q8_K (57% of cycles unpatched) drops below the 0.05% threshold in the patched profile, while mul_mat_qX_K_q8_K_T<DequantizerQ4K,1> (the iqk kernel) climbs from 5.89% to 23.34%.

Why we don't recover the full 15× yet — confirmed: flash-attn

After the matmul fix, the new top symbol is ggml_compute_forward_flash_attn_ext_f16_one_chunk at ~28% of cycles, with ggml_compute_fp32_to_fp16 and ggml_vec_mad_f16 as inlined hot children. 0.9.3 doesn't use this code path at all (predates upstream's flash-attn-ext). Confirmed via direct experiment (details): adding -fa off to the patched build recovers 84% of 0.9.3 pp and 82% of 0.9.3 tg on the Qwen3 MoE reproducer, and 83% / 85% on dense Llama-3.1-8B. The perf profile in that configuration is structurally identical to 0.9.3's.

So the full picture of issue #973 is two stacked, independent regressions:

recovered by this PR alone recovered by -fa off alone recovered by both
Qwen3-30B-A3B-Q4_K_M (MoE) pp 22% 8.5% 84%
Qwen3-30B-A3B-Q4_K_M (MoE) tg 23% 34% 82%
Llama-3.1-8B-Q4_K_M (dense) pp 40% (no change) n/a (same effect alone) 83%

This PR fixes the MoE matmul leg. The flash-attn leg should be a separate PR — short-term option is flipping the default of -fa from auto→off for CPU-only inference; long-term option is fixing the f16 mad-loop or landing a llamafile-specific FA implementation.

Full investigation write-up

Self-contained gist with the issue background, root cause analysis, perf-record evidence (including bucketed before/after tables for both kernels and threading), patch design notes, the second-regression finding, and follow-ups:

https://gist.github.com/aittalam/de0f8a494ff17d38c100f7ad08c0d7da

Refs #973 (partial fix — the matmul leg only; the flash-attn leg is being tracked separately and should NOT be auto-closed by this merge).

Test plan

  • .cosmocc/4.0.2/bin/make -j32 clean build with patch applied
  • .cosmocc/4.0.2/bin/make check passes (19/19 extract_data_uris tests)
  • Coherence test against Qwen3-30B-A3B-Q4_K_M (model generates correctly, including reasoning_content)
  • llama-benchy pp2048/tg32 confirms speedup
  • perf-record confirms ggml_vec_dot_q4_K_q8_K disappears from hot path and iqk kernels replace it
  • Sanity-bench a dense model (Llama-3.1-8B-Instruct-Q4_K_M) on 0.9.3 vs 0.10.1 stock vs patched: patched is within noise of stock 0.10.1 (+1.6% pp / +1.8% tg) — no regression. Three-way comparison also shows a separate ~2.5× dense regression in 0.10.x vs 0.9.3 that this PR does NOT address (and shouldn't — it's almost certainly the flash-attn f16 path mentioned above). See three-way comparison comment.
  • Sanity-bench a Q8_0 MoE (Qwen3-30B-A3B-Q8_0, same family as the reproducer): smoke-test passes, +54% pp with -fa off, no tg change, no regression; perf confirms the top-level llamafile_mixmul → MixMul → tinyBLAS_Q0_AVX2 path is the one firing (different code path from the Q4_K_M inner-hook path). See Q8_0 bench comment.
  • ARM Mac sanity — Apple M4 Max (macOS 26.4.1, 16 cores), Qwen3-30B-A3B-Q4_K_M, CPU-only -fa off: smoke generation coherent, patch effect at matched threads (-t 12) is +163% pp / +19% tg, patched recovers 93% of 0.9.3 pp and 81% of tg. cosmocc's aarch64 baseline doesn't set __ARM_FEATURE_SVE/__ARM_FEATURE_MATMUL_INT8, so hooks fire on Apple Silicon too. See Mac M4 Max bench comment.

All test-plan items checked. Ready for review when maintainers are ready.

🤖 Generated with Claude Code

@aittalam

Copy link
Copy Markdown
Member Author

Dense regression check ✅

Sanity-bench on Llama-3.1-8B-Instruct-Q4_K_M (CPU-only, same Xeon Gold 6338 / Ice Lake-SP box, default llama-benchy settings: pp=2048, tg=32, 3 runs, warmup on, coherence on):

dense Llama-3.1-8B Q4_K_M stock 0.10.1 patched (this PR) delta
pp2048 (t/s) 30.05 ± 0.75 30.54 ± 0.75 +1.6% (within noise)
tg32 (t/s) 6.01 ± 0.22 6.12 ± 0.06 +1.8% (within noise)

No regression. As expected since the hooks live in _mul_mat_id only — dense matmul through _mul_mat never enters the new code paths.

Test-plan item: Sanity-bench a dense model — done.

@aittalam

Copy link
Copy Markdown
Member Author

Dense regression check — three-way: 0.9.3 vs 0.10.1 stock vs patched

Updated table with stock 0.9.3 binary added (downloaded from the 0.9.3 release; same Xeon Gold 6338 / Ice Lake-SP box, same llama-benchy defaults):

dense Llama-3.1-8B Q4_K_M 0.9.3 0.10.1 stock 0.10.1 + this PR
pp2048 (t/s) 76.29 ± 1.92 30.05 ± 0.75 30.54 ± 0.75
tg32 (t/s) 16.32 ± 0.51 6.01 ± 0.22 6.12 ± 0.06
vs 0.9.3 — 2.5× slower / 2.7× slower 2.5× slower / 2.7× slower

Two things this tells us:

  1. No regression introduced by this PR on the dense path. Patched and stock 0.10.1 are statistically identical (within noise). The hooks live in _mul_mat_id only — dense matmul through _mul_mat never enters the new code.
  2. 0.10.x has an additional dense-path regression of ~2.5× vs 0.9.3 — visible only because we now have a 0.9.3 baseline to compare against. This is not caused by the missing MoE hooks (the patch leaves it untouched). It's almost certainly the same flash-attention f16 mad-loop regression already flagged in the PR description — that path is shared between MoE and dense models, which is why dense gets a smaller (2.5×) hit than MoE (15×, which is dense's 2.5× compounded with the missing MoE hooks).

So the full picture of issue #973's CPU regression is two stacked bugs:

  • MoE matmul missing hooks → this PR (3.3× pp speedup on MoE).
  • Flash-attention f16 path → separate, not in this PR (would explain the remaining ~2.5× on both MoE and dense after this lands).

Test-plan item: Sanity-bench a dense model — done with three-way comparison.

@aittalam

Copy link
Copy Markdown
Member Author

Flash-attn investigation: confirmed second regression, fix recovers ~84%/82%

Re-ran the same Qwen3-30B-A3B-Q4_K_M (MoE) and Llama-3.1-8B-Instruct-Q4_K_M (dense) benchmarks with -fa off (--flash-attn off) added.

Qwen3-30B-A3B-Q4_K_M (MoE, CPU-only, Xeon Gold 6338)

build / flags pp2048 (t/s) tg32 (t/s) vs 0.9.3 pp / tg
0.9.3 stock 128.69 ± 1.07 20.12 ± 0.07 (baseline)
0.10.1 stock 8.47 ± 0.07 3.42 ± 0.14 6.6% / 17%
0.10.1 stock + -fa off 10.98 ± 0.08 6.92 ± 0.18 8.5% / 34%
0.10.1 + this PR 27.93 ± 0.31 4.61 ± 0.06 22% / 23%
0.10.1 + this PR + -fa off 107.54 ± 0.52 16.45 ± 0.08 84% / 82%

The two fixes are independent multipliers. Combined, they recover 84%/82% of 0.9.3 performance.

Llama-3.1-8B-Instruct-Q4_K_M (dense, same box)

build / flags pp2048 (t/s) tg32 (t/s) vs 0.9.3 pp / tg
0.9.3 stock 76.29 ± 1.92 16.32 ± 0.51 (baseline)
0.10.1 stock 30.05 ± 0.75 6.01 ± 0.22 39% / 37%
0.10.1 + this PR 30.54 ± 0.75 6.12 ± 0.06 40% / 38%
0.10.1 + this PR + -fa off 63.70 ± 1.36 13.84 ± 1.06 83% / 85%

The MoE patch has zero effect on dense (as expected — hooks are _mul_mat_id-only). -fa off alone closes the dense gap from 39% → 83% recovery.

perf-record on patched + -fa off (Qwen3 MoE, 30s capture, 33,296 samples)

Self % Symbol Bucket
38.49% mul_mat_qX_K_q8_K_T<DequantizerQ4K, 1> iqk MoE matmul
23.74% __kmp_wait_template OpenMP spin
12.78% mul_mat_qY_K_q8_K_T<DequantizerQ6K, 1> iqk MoE matmul
11.26% __kmp_hardware_timestamp OpenMP
5.32% tinyBLAS<...u16,float,float>::gemm<2,1> tinyBLAS
2.45% __kmp_wait_template (other variant) OpenMP
<1% each: tinyBLAS float gemm, soft_max, barriers, … misc

ggml_compute_forward_flash_attn_ext_f16_one_chunk and ggml_vec_dot_f16 — the two hot symbols in the patched-but-fa=auto profile — drop out of the top entirely.

Bucketed comparison of the patched build with and without flash-attn:

Bucket + PR (fa=auto) + PR (fa=off)
iqk MoE matmul kernels 30.58% 51.27%
Flash-attn f16 chunk + helpers 28.23% 0%
OpenMP overhead ~28% ~37%
tinyBLAS gemm 0.54% 6.14%

The patched+-fa off profile is structurally identical to 0.9.3's (iqk matmul ~58%, threading ~29%, tinyBLAS ~6%). The remaining ~16% wall-clock gap is mostly OpenMP overhead being a few points higher than 0.9.3's pthread_pause_np/ggml_barrier threadpool.

Implications

  1. This PR (MoE matmul hooks) is necessary — without it, even -fa off only gets you 8.5%/34% (MoE).
  2. A separate fix for the flash-attn f16 path is also needed to fully resolve Bug: CPU performance regression #973. Once that lands, full 0.9.3 throughput should be back.
  3. The two regressions are independent — -fa off helps even the unpatched build (8.47 → 10.98 t/s pp on MoE; 6.01 → ? on dense, not measured but expected similar).

Suggested options for the flash-attn follow-up (separate PR / issue):

  • Short-term: change the in-tree default of -fa from auto (which currently resolves to "on") to off for the CPU backend in sched_reserve. This is a one-line config change and would massively unblock CPU users today. Downside: may regress users who specifically wanted FA-on (e.g. for very long context). Could be conditioned on --gpu disable.
  • Long-term: write an AVX-512-VNNI-accelerated ggml_vec_mad_f16 / fix the fp32 ↔ fp16 inner loop, or land a llamafile-specific flash-attn implementation that uses iqk-style tiling.

I can open a follow-up issue tracking this next session if you like, or pause here.

Test-plan items: Sanity-bench a dense model — done (with three-way comparison plus the -fa off variant added). The new flash-attn finding is also captured in the updated PR description.

@aittalam

Copy link
Copy Markdown
Member Author

Correction & follow-up on flash-attn: 0.9.3 does support FA, but defaults OFF

I claimed earlier "0.9.3 doesn't use this code path at all (predates upstream's flash-attn-ext)" — that was wrong. 0.9.3 has the -fa / --flash-attn flag, but its default is OFF (the flag's description is "Enable Flash Attention"). 0.10.x's same flag defaults to auto, which resolves to ON.

So the flash-attn part of #973 is actually two compounding sub-issues. Confirmed by running both versions with FA explicitly enabled and disabled:

Qwen3-30B-A3B-Q4_K_M (MoE)

build / flags pp2048 tg32 FA-induced slowdown vs same build, FA off
0.9.3 (default, fa=off) 128.69 20.12 —
0.9.3 with -fa 77.17 18.68 −40% pp / −7% tg
0.10.1+PR (fa=off) 107.54 16.45 —
0.10.1+PR (default / fa=auto-on) 27.93 4.61 −74% pp / −72% tg

Llama-3.1-8B-Instruct-Q4_K_M (dense)

build / flags pp2048 tg32 FA-induced slowdown
0.9.3 (default, fa=off) 76.29 16.32 —
0.9.3 with -fa 63.90 13.04 −16% pp / −20% tg
0.10.1+PR (fa=off) 63.70 13.84 —
0.10.1+PR (default / fa=auto-on) 30.54 6.12 −52% pp / −56% tg

So the picture is:

  1. Flash-attn is always costly on CPU — even on 0.9.3, enabling it costs 16–40% pp.
  2. 0.10.x's FA implementation is significantly more expensive than 0.9.3's — FA-induced slowdown roughly doubled on dense (16%→52% pp) and roughly doubled on MoE (40%→74% pp). So there is a real implementation regression, not just a default-flag change.
  3. The default behavior also flipped — 0.9.3 users got fast CPU inference by default; 0.10.x users get the slow FA path by default. That's the most user-visible piece of Bug: CPU performance regression #973.
  4. -fa off on patched 0.10.1 ≈ 0.9.3 default: 107.54 vs 128.69 pp on MoE (84%), 63.70 vs 76.29 on dense (83%). The remaining ~16% is OpenMP overhead vs llamafile's native threadpool — separate, minor.

Implications for follow-up

A clean fix for the flash-attn part of #973 likely requires both:

  • Default change: flip -fa default from auto(=on) back to off for CPU backends. One-line fix in sched_reserve's auto-resolution. Immediate user-facing relief, no algorithmic risk.
  • Implementation fix: identify what regressed in upstream's ggml_compute_forward_flash_attn_ext_f16_one_chunk (or its callers) between the 0.9.3 submodule pin and the 0.10.x ones — likely a fp32 ↔ fp16 conversion path or ggml_vec_mad_f16 change. The 0.9.3 FA implementation cost 40% pp on MoE; the 0.10.x one costs 74%. Roughly halving FA overhead would recover most of the residual.

Should I open a separate issue for the flash-attn follow-up, or wait for maintainer input?

@aittalam aittalam changed the title ggml-cpu: re-hook llamafile MoE matmul on x86 (fixes #973) ggml-cpu: re-hook llamafile MoE matmul on x86 (partial fix for #973) May 22, 2026
The 0.10.0 "llamafile reloaded" refactor dropped the llamafile_mixmul
and llamafile_mixmul_iqk hooks from ggml_compute_forward_mul_mat_id.
Since then, every (expert × token × row) tuple in MoE matmul flows
through type_traits_cpu[type].vec_dot (e.g. ggml_vec_dot_q4_K_q8_K,
single-row AVX2 only), bypassing the batched/AVX-512-aware iqk
kernels that classic llamafile used.

On x86 this causes a 5-15× CPU-only regression on K-quant MoE models
(Qwen3-30B-A3B-Q4_K_M reproduces it cleanly on both Alderlake AVX-VNNI
and Ice Lake-SP AVX-512-VNNI). Dense matmul is unaffected because
ggml_compute_forward_mul_mat retains its llamafile_sgemm hook.

The patch re-introduces both 0.9.3 hooks on the new mul_mat_id:
- a top-level llamafile_mixmul() early-out (handles f16/bf16/q4_0/q8_0)
- an inner llamafile_mixmul_iqk() per-expert hook for the K-quant /
  Q8-vec-dot path (Q4_K, Q5_K, Q6_K, etc.), mirroring 0.9.3's
  ggml.c:11360.

The submodule's bundled llamafile/sgemm.h only declares
llamafile_sgemm, so the two functions are forward-declared inline in
the patch to keep it self-contained.

perf evidence on Xeon Gold 6338, Qwen3-30B-A3B-Q4_K_M:
  - ggml_vec_dot_q4_K_q8_K: 57.24% (unpatched) → <0.05% (patched)
  - mul_mat_qX_K_q8_K_T<Q4K,1> + Q6K variant: 8.74% → 30.58%
  - pp2048: 8.47 → 27.93 t/s (3.3× speedup, ~23% of 0.9.3 baseline)
  - tg32:  3.42 →  4.61 t/s (1.35× speedup, ~18% of 0.9.3 baseline)

This PR is a partial fix for #973 — it addresses the MoE matmul leg
only. A separate flash-attention regression (default flipped on->auto,
and the FA implementation itself ~doubled in cost) accounts for the
remaining gap and is tracked separately. Do NOT auto-close #973 on
merge of this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aittalam
aittalam force-pushed the fix/issue-973-moe-matmul-hook branch from 259b66f to d6266ee Compare May 22, 2026 12:29
@aittalam

Copy link
Copy Markdown
Member Author

Threading investigation: OpenMP is NOT the bottleneck (residual ~16% gap is architectural)

Goal: figure out why "PR + -fa off" still trails 0.9.3 by ~16% on pp (107.54 vs 128.69 t/s on the Qwen3 MoE reproducer). The patched profile showed ~38% of cycles in __kmp_* symbols, which looked like wasted OpenMP overhead. Tested four interventions to confirm or rule out:

variant pp2048 tg32 vs PR+fa=off baseline
PR + -fa off (16 threads, OpenMP, default) — baseline 107.54 16.45 —
+ OMP_WAIT_POLICY=PASSIVE KMP_BLOCKTIME=0 95.78 4.95 −11% pp, −70% tg
+ -t 12 (under-subscribe physical cores) 92.21 14.51 −14% pp, −12% tg
+ -t 32 (SMT/hyperthreaded siblings) 94.85 10.53 −12% pp, −36% tg
rebuilt without -DGGML_USE_OPENMP -fopenmp (pthread fallback) 108.86 15.05 +1% pp, −9% tg (noise)

The no-OpenMP build uses llama.cpp's own pthread-based threadpool fallback. Its perf profile (47,553 samples on the same generation workload):

Self % Symbol
39.97% ggml_graph_compute_thread
↳ 28.96% inlined ggml_thread_cpu_relax inside ggml_barrier
↳ 10.76% other thread overhead
34.34% iqk Q4_K, 1
11.87% iqk Q6_K, 1
5.92% tinyBLAS::gemm<2,1>
2.05% ggml_compute_forward_mul_mat_id

OpenMP's __kmp_wait_template + __kmp_hardware_timestamp = ~35% of cycles maps cleanly to pthread fallback's ggml_thread_cpu_relax-in-barrier = ~29% + other thread overhead = ~40%. They're equivalent overall. Roughly the same amount of barrier-wait time, just labeled differently.

Compare with 0.9.3 (which uses llamafile's classic threadpool):

0.9.3 default PR + -fa off (no-OMP, today)
Threading (barrier + cpu pause inlined) ~30% (pthread_pause_np 19.71% + ggml_barrier 9.52%) ~40% (cpu_relax-in-barrier 28.96% + thread overhead 10.76%)
iqk matmul (Q4_K + Q6_K) 58.55% 46.21%
Delta in threading (baseline) +10pp more cycles in threading

That +10pp roughly matches the residual ~16% wall-clock gap. The cause is more barriers per token in the new llama.cpp — more model-architecture support means more graph nodes, each requiring a thread-sync barrier. Not OpenMP, not pthread overhead, not spin-wait policy.

This is architectural (upstream llama.cpp's compute graph), not a llamafile-level bug. Plausibly fixable by:

  • Reducing barrier frequency (e.g., fusing adjacent ops or relaxing sync requirements where safe).
  • A llama.cpp-side change to the per-op threading model.
  • Restoring the 0.9.3-era custom llamafile threadpool with smarter wake/sleep — but the experiment above shows pthread fallback is already equivalent to OpenMP for this workload, so there's no easy win on this axis.

Conclusion: OpenMP is NOT the bottleneck. The remaining ~16% gap is more sync points per token in the new graph, which is a deeper architectural concern. Won't address in this PR. Leaving OpenMP enabled (it's marginally better for tg32: 16.45 vs 15.05 with pthread fallback).

@aittalam

Copy link
Copy Markdown
Member Author

Q8_0 MoE sanity-bench: outer llamafile_mixmul hook fires + smoke test passes

Test-plan item: Sanity-bench a Q8_0 MoE. Used Qwen3-30B-A3B-Q8_0 from the same unsloth GGUF repo (~32 GB; same model architecture as the Q4_K_M reproducer, different quant).

Smoke test: coherent output

Patched binary on Q8_0 MoE, asked "What is the capital of France?". Generation: content: 'The capital of France is **Paris**.' plus a Qwen3-style reasoning_content trace. No crashes, no garbled output, no ABI mismatch.

Bench (Xeon Gold 6338, CPU-only)

Qwen3-30B-A3B-Q8_0 flags pp2048 (t/s) tg32 (t/s)
stock 0.10.1 default (fa=auto/on) 19.85 ± 0.14 4.09 ± 0.07
0.10.1 + this PR default (fa=auto/on) 19.99 ± 0.28 4.12 ± 0.03
stock 0.10.1 -fa off 42.01 ± 0.26 10.88 ± 0.13
0.10.1 + this PR -fa off 64.68 ± 0.52 10.89 ± 0.07

With -fa off (isolating the matmul effect from the FA regression tracked in #975): +54% pp improvement, tg unchanged (memory-bandwidth bound at Q8_0). With default FA on, the matmul advantage is fully masked by the FA bottleneck — same observation as for Q4_K_M (#974's other comment).

The +54% pp speedup on Q8_0 is smaller than the ~10× pp speedup we saw on Q4_K_M, but that's expected: Q8_0's per-row ggml_vec_dot_q8_0_q8_0 is already much faster than Q4_K's ggml_vec_dot_q4_K_q8_K (simpler kernel, less per-element work), so even the unpatched scalar fallback isn't as catastrophic. The patch still meaningfully helps via batched tinyBLAS dispatch.

perf-record confirms the outer hook fires (specifically, the path the inner Q4_K-style hook doesn't exercise)

30s capture on patched Q8_0 (fa=off), 47,533 samples. Top symbols:

Self % Symbol
32.90% tinyBLAS_Q0_AVX2<6, block_q8_0, block_q8_0, float>::gemm<2,1,1>
25.04% tinyBLAS_Q0_AVX2<0, block_q8_0, block_q8_0, float>::gemm<2,1,1>
18.88% __kmp_wait_template
8.72% __kmp_hardware_timestamp
5.25% ggml_vec_dot_q8_0_q8_0 (attention K×V residual)
3.26% tinyBLAS<...u16,float,float>::gemm<2,1> (f16 helper)
1.68% __kmp_wait_template (other variant)

The 58% combined in tinyBLAS_Q0_AVX2<block_q8_0, block_q8_0, float> is the dispatch through llamafile_mixmul → MixMul → tinyBLAS_Q0_AVX2 — the path the top-level hook enables. Q4_K_M MoE took the inner llamafile_mixmul_iqk path instead (because Q4_K isn't in MixMul's type switch). So Q4_K_M and Q8_0 exercise different hooks; this bench confirms the outer one works end-to-end.

The two template specializations (<6, ...> and <0, ...>) reflect different chunk shapes selected by tinyBLAS based on matrix dimensions — both being hot confirms the dispatch is healthy.

Test-plan item: Sanity-bench a Q8_0 MoE — done. Updating PR body to check it off.

@aittalam

Copy link
Copy Markdown
Member Author

Q8_0 MoE — adding 0.9.3 baseline for three-way comparison

Following up on the Q8_0 bench above. Added 0.9.3 to make it a three-way comparison parallel to what we did on dense (Llama-3.1-8B).

Qwen3-30B-A3B-Q8_0 (MoE) pp2048 (t/s) tg32 (t/s) vs 0.9.3 default
0.9.3 (default, fa=off) 89.72 ± 0.31 15.52 ± 0.08 —
0.9.3 with -fa 💥 SIGSEGV during warmup — (see note)
0.10.1 stock (default fa=auto-on) 19.85 ± 0.14 4.09 ± 0.07 22% pp / 26% tg
0.10.1 + this PR (default fa=auto-on) 19.99 ± 0.28 4.12 ± 0.03 22% pp / 27% tg
0.10.1 stock (-fa off) 42.01 ± 0.26 10.88 ± 0.13 47% pp / 70% tg
0.10.1 + this PR (-fa off) 64.68 ± 0.52 10.89 ± 0.07 72% pp / 70% tg

Observations:

  1. The patch recovers 72% of 0.9.3 pp on Q8_0 (vs 84% on Q4_K_M). Smaller recovery margin because Q8_0's unpatched scalar fallback (ggml_vec_dot_q8_0_q8_0) was already much faster than Q4_K's (ggml_vec_dot_q4_K_q8_K), so the patch has less ground to make up. Absolute speedup is still substantial (+54% pp).

  2. tg32 is identical between stock (-fa off) and patched (-fa off): 10.88 vs 10.89. Q8_0 is memory-bandwidth bound for single-row generation, so the matmul kernel improvement doesn't help. (Q4_K_M shows the same pattern: the patch helps pp but barely moves tg.)

  3. 0.9.3 with -fa SIGSEGVs during warmup on this Qwen3 model (Uncaught SIGSEGV (SI_KERNEL) at 0, stack inside ggml_backend_cpu_graph_compute → ... → llama_decode). This is a pre-existing bug in 0.9.3's older llama.cpp submodule pin, not relevant to this PR — but it does suggest the FA implementation was already shaky on MoE in 0.9.3. The 0.10.x switch to FA-on-by-default exposed a path that wasn't even functional on the previous llama.cpp pin for this architecture. Worth flagging in #975 too.

  4. Even after this PR, Q8_0 with default FA is still ~22% of 0.9.3 throughput. Confirms the FA fix (CPU flash-attn regression in 0.10.x (default flipped + implementation ~2× slower) #975) will deliver more value for Q8_0 users than the matmul fix alone.

  5. The residual ~28% gap between 0.9.3-default and patched--fa off on Q8_0 (vs ~16% gap on Q4_K_M) is bigger than I'd have expected. Plausible explanations: tinyBLAS Q0 dispatch is less tuned than iqk K-quant kernels for the specific dimensions of Qwen3-30B-A3B's expert matrices; or Q8_0 graph has more nodes than Q4_K because conversion/dequantization isn't fused. Not blocking; documenting.

Reasonable to leave the ARM Mac test for a maintainer with the right hardware and mark this draft → ready for review.

@aittalam

Copy link
Copy Markdown
Member Author

ARM Mac sanity ✅ — bench on Apple M4 Max (macOS 26.4.1, 16 cores)

Got my hands on an M4 Max for the ARM check. cosmocc's APE binary runs natively (no rebuild), and the patch's hooks compile in for the aarch64 half too (verified earlier by aarch64-linux-cosmo-objdump: both bl <llamafile_mixmul> and bl <llamafile_mixmul_iqk> call sites present in the aarch64 ggml-cpu.c.o). On Mac the system_info banner reports LLAMAFILE = 1 | OPENMP = 1 | REPACK = 1 | NEON = 1 | ARM_FMA = 1.

Bench results (Qwen3-30B-A3B-Q4_K_M, CPU-only, -fa off)

build / flags pp~2048 (t/s) tg32 (t/s) vs 0.9.3 default
0.9.3 (default, n_threads=12) 245.19 ± 0.16 53.30 ± 0.14 —
stock 0.10.1 (default, n_threads=8) 60.02 ± 0.09 29.09 ± 0.14 24% / 55%
stock 0.10.1 (-t 12) 87.02 ± 0.01 36.57 ± 0.21 36% / 69%
0.10.1 + this PR (default, n_threads=8) 160.71 ± 0.09 36.95 ± 0.33 66% / 69%
0.10.1 + this PR (-t 12) 229.05 ± 0.29 43.34 ± 0.50 93% / 81%

Apples-to-apples patch effect at matched thread count (-t 12): +163% pp (2.63×), +19% tg. The patch is just as important on Apple Silicon as on Xeon. The hooks fire on ARM because cosmocc's aarch64 baseline doesn't set __ARM_FEATURE_SVE or __ARM_FEATURE_MATMUL_INT8, so the #if GGML_USE_LLAMAFILE guard stays defined.

Two side observations from the Mac data:

  1. 0.10.x's default thread count on Apple Silicon dropped from 12 → 8 between 0.9.3 (12 threads on M4 Max by default) and 0.10.1 (8 threads). This isn't directly caused by either the matmul or the FA regression in Bug: CPU performance regression #973/CPU flash-attn regression in 0.10.x (default flipped + implementation ~2× slower) #975 — it's a separate change in how upstream llama.cpp picks default threads. On a 12-perf-core + 4-efficiency-core M4 Max the right default is probably 12, not 8. Worth investigating in another follow-up. Setting -t 12 recovers a chunk of throughput on stock 0.10.1 too (60→87 pp).

  2. The patched build at -t 12 recovers 93% of 0.9.3 pp on Mac — better than the 84% we saw on Xeon. ARM doesn't suffer from the flash-attn regression here (because we explicitly passed -fa off); on default settings the Mac would also pay the FA cost, but the matmul fix is the bigger lever on either platform.

Smoke test

ssh studio  # Apple M4 Max
~/llamafile-patched -m Qwen3-30B-A3B-Q4_K_M.gguf --gpu disable --server --port 8088 --host 127.0.0.1 -c 4096
# → answer to "Say hello" returned coherently via /v1/chat/completions (Qwen3 reasoning_content + content).

Bench script and protocol: tiny self-contained Python over the server's built-in timings.prompt_per_second / timings.predicted_per_second fields (auto-detects /v1/completions vs older /completion endpoint, cache-busts via per-run salt). Mirrors llama-benchy's pp~2048/tg32 protocol.

Test-plan item: ARM Mac sanity — done. Will tick it in the PR body next.

@aittalam

Copy link
Copy Markdown
Member Author

Clarification on Mac M4 Max numbers: thread-count is part of the gap

@aittalam reviewing the Mac bench above made the right call to ask whether some of the Apple Silicon slowdown is just from fewer default threads (0.10.x defaults to 8 on M4 Max; 0.9.3 defaulted to 12). Math breakdown:

Mac M4 Max, Qwen3-30B-A3B-Q4_K_M, CPU -fa off, vs 0.9.3 default (245.19 pp baseline):

transition pp delta fraction of total gap
Stock 0.10.1 default (t=8) → stock 0.10.1 -t 12 +27 t/s (60 → 87) ~15% (thread-count alone)
Stock -t 12 → this PR -t 12 +142 t/s (87 → 229) ~77% (matmul fix, this PR)
This PR -t 12 → 0.9.3 default +16 t/s (229 → 245) ~9% (residual, likely barrier overhead)

So on Mac, this PR is still by far the biggest lever (~77% of the gap) but thread-count is a non-trivial ~15% and worth fixing independently.

Linux Xeon is NOT affected. On x86 Linux, both 0.9.3 and 0.10.1 default to 16 threads (Xeon physical core count), so the Linux pp recovery numbers (84% with this PR + -fa off) are not inflated by thread-count effects.

Root cause (on Apple Silicon)

common_cpu_get_num_physical_cores() in 0.10.x's vendored llama.cpp/common/common.cpp uses preprocessor directives (#ifdef __linux__ ... #elif defined(__APPLE__) ...). cosmocc cross-compilation defines NEITHER, so both OS branches are skipped at compile time, falling through to hardware_concurrency() / 2 = 8 on M4 Max.

0.9.3 had a llamafile-patched llama.cpp/cores.cpp that did this correctly with runtime detection (IsLinux() + unconditional sysctlbyname("hw.perflevel0.physicalcpu", ...)). That patch was lost in the 0.10.0 refactor.

Tracking

Opened #976 with full evidence + suggested fix. Trivial llamafile-side patch on common.cpp. Out of scope for this PR; logging here so reviewers don't have to dig.

Summary of issue #973's CPU regression on Apple Silicon (worst-case): three stacked, independent regressions

Each fix is small and independent.

@aittalam

Copy link
Copy Markdown
Member Author

Raspberry Pi 5 sanity (4-core Cortex-A76, ASIMDDP, 4 GB)

Tested on a Pi 5 with Qwen3.5-2B-Q8_0 (the dense 2B model extracted from a bundled .llamafile). Note this is a dense model — the MoE patch doesn't fire on it (the hooks are in _mul_mat_id, not _mul_mat) — so the relevant question here is "no regression introduced" rather than "patch helps".

Used a minimal stand-in for llama-benchy (couldn't install uvx on the Pi without dropping new toolchains): same protocol — /v1/completions for 0.10.x or /completion for 0.9.3, warmup + 3 runs at pp~2048/tg32, cache-busted with per-run salt, reads timings.prompt_per_second / timings.predicted_per_second from the server response directly.

Pi 5, Qwen3.5-2B-Q8_0, CPU -t 4 pp~2048 (t/s) tg32 (t/s)
stock 0.10.1 (default fa=auto) 38.16 ± 0.02 4.92 ± 0.08
0.10.1 + this PR (default fa=auto) 38.49 ± 0.03 4.95 ± 0.06
0.10.1 + this PR (-fa off) 38.96 ± 0.12 5.02 ± 0.07
0.9.3 stock (Qwen3.5 architecture not supported in 0.9.3's submodule pin — skipped) —

Observations:

  • Patch vs stock 0.10.1: +0.9% pp / +0.6% tg — within noise. Expected: this is a dense model so the MoE-only hooks never fire. The hooks compile in (cosmocc's aarch64 baseline keeps GGML_USE_LLAMAFILE defined — confirmed via aarch64 objdump earlier) but never get triggered for dense matmul.
  • -fa off vs -fa auto: also essentially noise (+1.2% pp / +1.4% tg) on this model. ARM Cortex-A76 with NEON doesn't seem to suffer the ggml_vec_mad_f16 mad-loop regression the way x86 AVX-512 does (we measured 52–74% slowdown from FA on Xeon Linux). CPU flash-attn regression in 0.10.x (default flipped + implementation ~2× slower) #975 is x86-specific in its acute form; tracking that nuance in the issue.
  • My absolute numbers (38 pp / 5 tg) differ from llama-benchy's reported baselines (15 / 1.5–3) for likely benign reasons: different prompt content (repeated Lorem-style vs llama-benchy's cached natural-text), temperature=0 (greedy) in my script, and possible Pi 5 thermal state differences between runs (Pi 5 throttles under sustained load). The relative deltas between configurations should still be meaningful.

So Pi 5 confirms: the patch is a no-op on dense ARM workloads — no regression, and no benefit expected. Good sanity check.

@aittalam
aittalam marked this pull request as ready for review May 22, 2026 17:07
Without this, MixMul::allocate_shared_memory() in tinyblas_cpu_mixmul.inc
can fail (allocate() at line 372 returns nullptr when toto > params->wsize),
making the outer llamafile_mixmul() hook in ggml_compute_forward_mul_mat_id
a silent no-op. The hook was incidentally firing on Q8_0 MoE in our test
(perf symbol tinyBLAS_Q0_AVX2<6, ...> = NCB|NCC = mixmul-instantiated)
because the default 0.10.x work-buffer size happens to be large enough
for that specific (model, prompt-shape, n_threads) tuple — but it's
unreliable for other shapes.

Mirror 0.9.3 ggml.c:17531, which took MAX(default, llamafile_mixmul_needs).
This guarantees enough work-buffer is reserved for MixMul's shared
allocations (quantized_thought, rowptr_result, rowptr_thought, rowptr_count).

Identified by external code review of #974.

Q4_K_M MoE (which goes through the inner llamafile_mixmul_iqk hook, not
the outer one) is unaffected: pp=108.21 with fix vs 107.54 without, tg=16.56
vs 16.45 — within noise.

Q8_0 MoE happens to be unchanged in our bench (pp=63.88 with fix vs 64.68
without, tg=10.81 vs 10.89) because the outer hook was already firing by
coincidence. The fix makes the behavior deterministic across (model,
prompt-shape, n_threads) combinations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aittalam

Copy link
Copy Markdown
Member Author

Addressed: missing llamafile_mixmul_needs — outer hook was firing only by luck

Reviewer was right (high-confidence finding). Investigated and pushed fix as a follow-up commit (fa0a5c7).

What was happening

The outer llamafile_mixmul() hook calls MixMul::allocate_shared_memory(), which uses allocate() (tinyblas_cpu_mixmul.inc:372):

if (toto >= allocated_ && toto <= params->wsize) {
    res = (T *)(wdata_ + base);
    allocated_ = toto;
}
return res;  // nullptr if toto > params->wsize

params->wsize is set from cplan->work_size computed in ggml_graph_plan(). For GGML_OP_MUL_MAT_ID, 0.10.x's calculation includes the per-row vec_dot buffer + matrix_row_counts + matrix_rows + atomic_current_chunk, but does NOT include space for MixMul's allocations (quantized_thought / rowptr_result / rowptr_thought / rowptr_count).

0.9.3's ggml.c:17531 had cur = MAX(cur, llamafile_mixmul_needs(src0, src1, src2)) exactly for this purpose. That line was lost in the 0.10.0 refactor along with the call sites the matmul hooks PR restores.

Was the outer hook actually dead in our benches?

It was firing by coincidence on Q8_0 MoE. The perf report had tinyBLAS_Q0_AVX2<6, block_q8_0, block_q8_0, float>::gemm<2,1,1> at 32.90%, and 6 is exactly NCB|NCC from tinyblas_cpu.h:100-101 — the bit pattern that distinguishes MixMul-instantiated tinyBLAS_Q0 from the dense-sgemm-instantiated one (<0, ...>). So for our specific Qwen3-30B-A3B-Q8_0 + 16 threads + ~2048 token prompt, the default 0.10.x work-buffer happened to be large enough for MixMul. But that's brittle — different model shapes, n_threads, or prompt lengths could push past the limit and silently fall through to the inner iqk hook.

The reviewer's framing ("the PR's perf wins all come from the inner iqk hook") was correct for Q4_K_M MoE (Q4_K isn't in MixMul's type switch, so the outer hook returns false immediately, no allocation attempted; only the inner hook contributes). It was partially correct for Q8_0 MoE: both hooks were contributing, but the outer hook contribution was undocumented luck.

The fix

A small additional hunk on the existing patch file. Adds llamafile_mixmul_needs reservation to the MUL_MAT_ID work-size calc (mirroring 0.9.3):

#if GGML_USE_LLAMAFILE
    extern size_t llamafile_mixmul_needs(const struct ggml_tensor *,
                                          const struct ggml_tensor *,
                                          const struct ggml_tensor *);
    size_t cur2 = llamafile_mixmul_needs(src0, src1, ids);
    if (cur2 > cur) cur = cur2;
#endif

Patch file ggml_src_ggml-cpu_ggml-cpu.c.patch is now 76 lines (was 56), with the new hunk in the work-size computation.

Validation re-bench

metric before fix with fix delta
Q4_K_M MoE pp2048 (-fa off) 107.54 ± 0.31 108.21 ± 0.98 +0.6% (noise)
Q4_K_M MoE tg32 (-fa off) 16.45 ± 0.08 16.56 ± 0.11 +0.7% (noise)
Q8_0 MoE pp2048 (-fa off) 64.68 ± 0.52 63.88 ± 0.15 −1.2% (noise)
Q8_0 MoE tg32 (-fa off) 10.89 ± 0.07 10.81 ± 0.08 −0.7% (noise)

No perceptible perf change in either direction on our test box (Xeon Gold 6338) — confirming the outer hook was already firing for Q8_0 by coincidence, and Q4_K_M never used the outer hook anyway. The value of the fix is correctness for other (model, prompt, threads) combinations, not measurable perf gain on these specific runs.

Q8_0 MoE still produces coherent output (smoke-tested). make check still passes (re-run after this commit).

Bonus consequence

Without the fix, the outer hook is effectively non-deterministic — works for some workloads, silently falls through for others. With the fix, it's predictable. This also means F16 / BF16 / Q4_0 MoE models (the cases the outer hook handles that the inner doesn't) are now guaranteed to benefit from the patch, not just lucky.

Thanks for catching this — clearly worth fixing before merge.

@aittalam

Copy link
Copy Markdown
Member Author

Audit: surveyed all [jart] markers in 0.9.3 — what's preserved, what isn't, what's not blocking

Did a systematic sweep of [jart] markers across the 0.9.3 tree to make sure we haven't missed anything else jart had patched into the vendored llama.cpp that got dropped in the 0.10.0 refactor.

Counts: ggml.c=27, llama.cpp=33, common.cpp=20+, plus a few in ggml.h, ggml-vector.inc, ggml-backend.c, ggml-cuda.h, llava/*, sampling.*, log.h, main/*, server/*. Roughly 90+ touchpoints total.

Categorized below.

1. Restored by this PR

  • llamafile_mixmul(params, src0, src1, ids, dst) hook at top of ggml_compute_forward_mul_mat_id — restored (ggml.c:11255 in 0.9.3 → ggml-cpu.c:1520 in 0.10.x via this PR's patch)
  • llamafile_mixmul_iqk(...) hook inside the per-expert loop — restored (ggml.c:11360 in 0.9.3 → ggml-cpu.c:1659 via this PR's patch)
  • llamafile_mixmul_needs(src0, src1, ids) work-buffer reservation — restored (ggml.c:17531 in 0.9.3 → ggml-cpu.c:2868 via the follow-up commit fa0a5c7)

2. Preserved upstream (no action needed)

0.9.3 [jart] piece 0.10.x equivalent Notes
ggml_is_noop(op) skip in graph loop (ggml.c:15478, 17665) ggml_op_is_empty(op) in ggml-impl.h:90 + ggml-cpu.c:3040 Same op set (NONE, RESHAPE, TRANSPOSE, VIEW, PERMUTE). 0.10.x also adds TENSOR_FLAG_COMPUTE check and skips the trailing barrier — strict improvement.
n_threads = n_tokens <= 2 ? n_threads : n_threads_batch (llama.cpp:15531, 15782) int n_threads = batched ? cparams.n_threads_batch : cparams.n_threads (llama-context.cpp:2175) batched set via ubatch.n_tokens > 1 at :1231. Slightly tighter threshold (>1 vs ≤2) but functionally equivalent for pp2048/tg32 workloads.
llamafile_sgemm dense hooks (ggml.c:11081, 11142) ggml-cpu.c:1286, 1354 Already in current code, including the broadcast variant.
GGML_MAX_NAME=128 for stable diffusion (ggml.h) Various places retain the larger NAME size Need to verify still 128 not 64; quick spot-check shows 0.10.x uses GGML_MAX_NAME = 64 in ggml.h:212 — POTENTIAL LATENT ISSUE for SD users, but not part of #973 reproducer scope.

3. Already tracked by sibling issues

4. Lost / orphaned but NOT blocking the matmul fix

Lost piece Impact Recommendation
__target_clones("avx2") on ggml_compute_forward_out_prod_f32, set_f32, get_rows_back_*, diag_mask_f32, im2col_f32, conv_transpose_2d, ssm_scan_f32 Affects only ops used by training, vision, or SSM models. No-op for our LLM reproducer. Restorable in a separate llamafile-files patch if SD / Mamba users hit slowdowns. Track separately.
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS) + pthread_testcancel() per-op (ggml.c:17650, 17716) UX: lets a user interrupt mid-graph (mid-token). Without it, interrupts only land between tokens. Not a perf regression. UX feature only; not blocking for #973. File as a separate UX/feature issue if needed.
FLAG_trap SIGFPE trapping around SIMD vec ops (ggml-vector.inc:1935, llama.cpp:20188, 20200) Debugging convenience for finding NaN/Inf at the producing op. Debug-only. Not blocking.
llamafile_size(file) instead of stat for archive layouts (llama.cpp:3482, 19151, 19288) Affects when llamafile is loaded as part of a self-extracting .llamafile bundle. Probably handled differently in 0.10.x's loader path; needs spot-check. Not blocking #973.
MIN(cpu_get_num_math(), 20) thread cap (common.cpp:290) Caps default thread count at 20 to avoid OpenMP overhead on huge machines. Minor. Doesn't affect Xeon (16) or M4 Max (12). Could file as Apple Silicon thread default follow-up issue.
// [jart] use two user prompts rather than merging system message (llama.cpp:19781) Chat template behavior tweak. Chat-mode UX. Not blocking #973.
Various FLAG_* binding in common.cpp (FLAG_threads, FLAG_seed, FLAG_prompt, etc.) Connects llama.cpp's argparse to llamafile's global FLAG_* — needed for llamafile-specific tools (CLI mode, chat mode) Some bindings appear preserved (we see FLAG_* references in llamafile/); some not. Related to the auxiliary-binary link failures we noted earlier. Separate concern.

5. Known auxiliary-binary build issues (not related to #973)

The build log shows undefined references in quantize/llama-bench/perplexity/imatrix/llava-quantize for FLAG_log_disable, FLAG_precise, llamafile_has, llamafile_help, llamafile_open_gguf, llamafile_unref, llamafile_check_cpu, tinylog. These auxiliary tools don't link llamafile.a properly. The main llamafile binary is unaffected and is what #973's reproducer exercises. Separate build hygiene issue.

Bottom line

Nothing in the audit looks like it would change this PR's matmul perf numbers materially. The three orphan jart pieces that DID directly affect #973's CPU regression are: matmul hooks (this PR), mixmul_needs (this PR, follow-up commit), and Apple Silicon thread default (#976). FA is separate (#975). All other [jart] losses are either preserved upstream under a different name, scoped to non-LLM ops, UX/debugging features, or auxiliary-binary build hygiene.

Ready to merge from my side.

@aittalam
aittalam merged commit 42a4006 into main May 22, 2026
2 checks passed
@aittalam
aittalam deleted the fix/issue-973-moe-matmul-hook branch May 22, 2026 18:34
aittalam added a commit that referenced this pull request May 24, 2026
…md_gemm) (#978)

* llama-context: default -fa auto to off on CPU-only (partial fix for #975)

Adds a llama.cpp patch making `-fa auto` resolve to OFF when no GPU
devices are present (the CPU FA path is significantly slower than the
non-FA path on x86; see #975 for benchmarks). Explicit `-fa on` / `-fa
off` are unchanged, and the existing auto-resolution path still runs
whenever a GPU backend is available (CUDA, ROCm, Metal, Vulkan).

Recovers most of the user-visible CPU regression in 0.10.x that #974
left on the table. The second half of #975 — making the CPU FA kernel
itself faster — is independent and will be a separate PR.

* ggml-cpu: f32 VKQ accumulator in CPU flash-attn ext (partial fix for #975)

When V is f16 and the hardware lacks native f16 FMA, the existing FA
inner loop in ggml_compute_forward_flash_attn_ext_f16_one_chunk
round-trips f16<->f32 every KV iteration via _mm512_cvtph_ps /
_mm512_cvtps_ph (see simd-mappings.h GGML_F32Cx16_*) just to maintain
the VKQ16 accumulator. perf-record on Xeon Gold 6338 (AVX-512F, no
AVX-512-FP16) showed half the FA inner-loop work was these conversions
and the convert+store extended the critical-path chain.

This patch reorders the FA function to accumulate VKQ in f32 in that
case, matching the existing non-f16-V branch. f16 V is dequantized once
per KV iteration via the standard ggml_fp16_to_fp32_row helper, then
ggml_vec_mad_f32 does an f32 FMA into VKQ32 with no convert-back. On
hardware that has native f16 FMA (__AVX512FP16__,
__ARM_FEATURE_FP16_VECTOR_ARITHMETIC, __riscv_zvfh) the existing f16
path is preserved -- changing it would lose half the SIMD lane count.

Complements 43d62a6: that commit changes the default to -fa off on
CPU-only; this commit makes the explicit -fa on path significantly
faster on AVX-512F-without-FP16 / AVX2 / ARM-without-FP16-FMA. Both
target #975.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* llamafile/fa: AVX-512F helpers for CPU flash-attention (partial fix for #975)

Adds multi-arch helpers for the two hot per-element f16 ops in the
CPU flash-attention inner loop, with runtime dispatch via the existing
sgemm.cpp GemmFuncs struct. Routes ops.cpp's _one_chunk function to
call them when V is f16, falling back to the upstream ggml helpers on
false return.

Why this matters
----------------
Cosmocc compiles llama.cpp's ggml-cpu/{ops,vec}.cpp with baseline x86_64
ISA so the APE binary stays portable across all CPUs. The result is
AVX2-baseline codegen for ggml_vec_dot_f16 and ggml_fp16_to_fp32_row,
while upstream's cmake -march=native build gets AVX-512. Vanilla
llama.cpp at the *same submodule SHA* runs CPU flash-attention 4-8x
faster per-token on the same Xeon Gold 6338 hardware because of this
codegen difference, not any algorithmic difference.

We can't add -mavx512f to the default-compiled ops.cpp/vec.cpp without
breaking APE portability. Instead, this commit follows the iqk_mul_mat
pattern: implement the hot helpers in llamafile/ with per-arch
TARGET_ARCH flags (multi-arch fat-object), runtime-dispatched via
sgemm.cpp's existing GemmFuncs struct based on X86_HAVE(AVX512F).

Bench (Xeon Gold 6338, Qwen3-30B-A3B-Q4_K_M, --gpu disable, -fa on)
-------------------------------------------------------------------
                            pp2048      tg32
  before fa_helpers          27.91      7.08
  after  fa_helpers          28.82     18.20  <- decode beats upstream (17.34)
  upstream (cmake)          118.83     17.34

The tg gain comes from the patched _one_chunk function (decode path).
pp2048 stays low because prompt processing uses ggml's tiled FA function
which has its own per-element f16 loops not yet patched - that's a
separate commit.

Files
-----
* llamafile/fa_helpers_amd_avx512f.cpp - AVX-512F implementation using
  _mm512_cvtph_ps + _mm512_fmadd_ps + _mm512_reduce_add_ps. Compiled
  with -Xx86_64-mavx512f -Xx86_64-mfma -Xx86_64-mf16c (per-target via
  cosmocc's -Xx86_64- prefix, doesn't leak to the aarch64 build).
* llamafile/fa_helpers_unsupported.cpp - stubs returning false for CPUs
  without AVX-512F.
* llamafile/sgemm.cpp - added fa_vec_dot_f16 + fa_fp16_to_fp32_row
  function pointers to GemmFuncs, initialized in the existing
  X86_HAVE(AVX512F) branch.
* llamafile/sgemm.h - public API + per-arch declarations.
* llamafile/BUILD.mk - TINYBLAS_CPU_FA_HELPERS_SRCS + AVX-512F build
  rule.
* llama.cpp.patches/patches/ggml_src_ggml-cpu_ops.cpp.patch - extended
  with extern "C" forward declarations and call sites in _one_chunk.

CPUs without AVX-512F see no regression - they fall through to upstream's
AVX2 helpers, which is what they already used. ARM82 / AVX2 variants of
fa_helpers can be added in follow-up PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ggml-cpu: route tiled FA dequant through llamafile_fa_* (partial fix for #975)

Extends llama.cpp.patches/patches/ggml_src_ggml-cpu_ops.cpp.patch to
cover the *tiled* flash-attention function
(ggml_compute_forward_flash_attn_ext_tiled), which is the one prompt
processing dispatches to. The previous commit only patched _one_chunk
(used for decode), so tg recovered but pp2048 with -fa on stayed at
~28 t/s vs upstream's ~118.

Two hot loops touched:

* V-tile dequant (ops.cpp:8687-8693): direct substitution of
  ggml_fp16_to_fp32_row with llamafile_fa_fp16_to_fp32_row; falls back
  to upstream on false return.

* K-tile transpose-and-dequant (ops.cpp:8622-8635): the destination is
  strided (K_f32[dk * KV_TILE_SZ + tk]), so we dequant the full DK row
  into a 4 KB stack scratch buffer with the SIMD helper, then do a
  scalar transpose-copy into K_f32. The transpose copy is cheap (DK
  loads + DK strided stores, all hot in L1). Stack buffer sized for
  DK <= 1024 covers all realistic attention head dims (Qwen3=128,
  DeepSeek-MLA=576). If the helper returns false or DK exceeds the
  scratch buffer, falls back to upstream's scalar table-lookup loop.

CPUs without AVX-512F see no change (helper returns false → fallback
fires → same code as before). APE binary remains portable; the
AVX-512 instructions live only in llamafile/fa_helpers_amd_avx512f.o
which is compiled with per-target TARGET_ARCH flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests: numerical-equivalence tests for llamafile_fa_* helpers (#975)

Adds tests/fa_helpers_test.cpp comparing the new llamafile-side AVX-512
flash-attention helpers against upstream's ggml-cpu reference:

- llamafile_fa_fp16_to_fp32_row vs ggml_fp16_to_fp32_row: asserts
  BIT-IDENTICAL output. Both use _mm*_cvtph_ps which is IEEE-754
  deterministic per element, so equality should hold regardless of
  SIMD lane width.

- llamafile_fa_vec_dot_f16 vs ggml_vec_dot_f16: asserts within ULP
  tolerance. The two implementations sum in different chunk widths
  (AVX2 8-wide vs AVX-512 16-wide), so reduction order differs and
  bit equality isn't guaranteed.

Coverage:
- Normal inputs (N(0, 0.1), typical post-LayerNorm activations).
- Edge values (±0, ±INF, NaN, denormals, f16 min/max).
- Adversarial alternating ±1 (worst case for reduction-order drift).
- All zeros (must produce bit-exact 0.0f).
- Dispatch wiring sanity check (output buffer must be untouched
  if the helper reports unsupported).

Test sizes cover typical attention head dims (16, 32, 64, 80, 96,
128, 192, 256) plus odd tails (15, 17, 33, 129) and large (512,
1024). All inputs deterministic via fixed seeds.

On CPUs without AVX-512F the helpers return false; the corresponding
assertions are skipped and reported in the summary. The dispatch
wiring test always runs.

Wired into make check via tests/BUILD.mk. Test main() initializes
ggml's f16<->f32 lookup table directly (skipping the graph-compute
init dance) so the upstream reference works in a standalone test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* llamafile/fa: AVX-512 simd_gemm wrapper for tiled FA (closes #975 gap)

Closes the remaining pp2048 gap for CPU flash-attention. The tiled FA
function (ggml_compute_forward_flash_attn_ext_tiled) does two inline
GEMMs per KV tile: KQ = Q @ K and VKQ += KQ @ V. Both go through
ggml-cpu's static-inline simd_gemm in simd-gemm.h, which is compiled
into ops.cpp.o at whatever ISA cosmocc's baseline gives (AVX2 with the
GEMM_RM=6, GEMM_RN=2 tile config — 12 ymm-wide FMAs per inner
iteration). Upstream's cmake -march=native build gets AVX-512 with
GEMM_RM=4, GEMM_RN=4 (16 zmm-wide FMAs per inner iteration) which is
~2.7x peak FMA throughput on Skylake-X+ CPUs.

Same fix shape as fa_helpers (#975 second commit). Adds:

* llamafile/fa_simd_gemm_amd_avx512f.cpp — thin wrapper that includes
  simd-gemm.h and exposes llamafile_fa_simd_gemm_amd_avx512f. Compiled
  with -Xx86_64-mavx512f so the included static-inline simd_gemm picks
  the AVX-512 codegen path automatically. APE binary stays portable —
  AVX-512 instructions live only in this .o, routed at runtime only
  when X86_HAVE(AVX512F) is true.
* llamafile/fa_helpers_unsupported.cpp — stub returning false.
* llamafile/sgemm.h + sgemm.cpp — declarations + dispatcher entry.
* llamafile/BUILD.mk — wires the new source + per-target AVX-512 flags.
* llama.cpp.patches/patches/ggml_src_ggml-cpu_ops.cpp.patch — extended
  with two call sites in the tiled function (the KQ matmul and the VKQ
  accumulation), each with upstream fallback on false return.
* tests/fa_helpers_test.cpp + tests/BUILD.mk — adds simd_gemm tests
  comparing the wrapper against a scalar reference on typical FA tile
  shapes (Q4 x DK128 x KV64 etc.), with ULP tolerance for FMA
  associativity. 3 new test functions; 9 total in the test binary.

Bench (Xeon Gold 6338, Qwen3-30B-A3B-Q4_K_M, --gpu disable, -fa on)
-------------------------------------------------------------------
                            pp2048      tg32
  before this commit          29.28     17.97
  after  this commit         132.58     17.85  <- pp +4.5x, tg flat
  upstream llama.cpp         118.83     17.34

This PR (all 6 commits) now BEATS upstream on both pp (+11.6%) and tg
(+2.9%) for CPU-only FA on this Xeon. The pp2048 gap relative to FA-off
is also closed: -fa on 132.58 vs -fa off 116.56 — FA is genuinely a pp
win, not just a tg win.

Tests on Xeon: 11069 assertions pass (0 skipped); on Mac: 3 pass (73
skipped because Apple Silicon doesn't have AVX-512F, fallback to
upstream's simd_gemm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests/fa_helpers: address review feedback (3 fixes)

1. tests/fa_helpers_test.cpp:~159 — vec_dot_tol was multiplying by n
   twice, giving an O(n²·ε·max_abs) tolerance instead of the documented
   O(n·ε·max_abs) (Wilkinson-style summed-product bound). Fixed to
   `max(1e-5, 8 * n * eps * max_abs)`. Slightly tighter than before
   but still passes all 9 sub-tests on Xeon.

2. tests/fa_helpers_test.cpp:~270 — vec_dot_f16_adversarial_reduction_order
   was filling x and y with the same alternating-sign pattern, so every
   product was positive and the test didn't actually exercise the
   reduction-order sensitivity its name claimed. Fixed to use two
   different sign patterns:
     x[i] = (i & 1)       ? -10 : +10   → [+, -, +, -, ...]
     y[i] = ((i / 2) & 1) ? -10 : +10   → [+, +, -, -, ...]
   so products alternate [+, -, -, +, +, -, -, +, ...], partial sums
   oscillate by ±100, and AVX2 (8-wide) vs AVX-512 (16-wide) reduction
   trees pair the cancelling terms in different orders — actually
   stressing the floating-point drift the test was supposed to cover.
   Tolerance adjusted to `max(1e-4, 4 * n * eps * scale²)`. Also drops
   the now-unused fill_adversarial_f16 helper.

3. llamafile/fa_simd_gemm_amd_avx512f.cpp:14-15 — header comment cited
   pre-patch line numbers for the simd_gemm call sites (8653 / 8724).
   Updated to current post-patch lines (8660 / 8740).

Build clean and `make check` passes on Mac (3 PASSED, 73 skipped — no
AVX-512F) and Xeon Gold 6338 (11069 PASSED, 0 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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