perf(cpu-moe): opt-in AMX tile GEMM for the deduped bf16 pass 1 - #52
perf(cpu-moe): opt-in AMX tile GEMM for the deduped bf16 pass 1#52gdevenyi wants to merge 4 commits into
Conversation
|
Independent support for the opt-in default here, from the paper (arXiv:2608.16157 §4.1), describing the CPU worker pool:
Bandwidth-bound is the design intent, not an accident of this machine — which is the whole reason an arithmetic optimisation measures ~0% here, and why it should stay behind a flag until someone has a part where the balance differs. The local ISA sweep puts a number on the ceiling: avx2 60.7 → avx512f 64.9 → avx512bf16 67.0 GB/s on the DSV4 geometry, so doubling the vector width buys ~7%, and nothing downstream of that can buy more. §5.3 also shows the balance moving with the host, which is the case for carrying these paths at all:
The channel count changes how bandwidth-starved the CPU path is by a large factor. This machine has only 4 of 8 channels populated per socket (166 GB/s per socket against a possible 333), so it sits toward the starved end and is close to a worst case for any compute-side optimisation. |
1c6b0ca to
7642309
Compare
Pass 1 split its work by (token, route, row block), so when two tokens in a decode
batch routed to the same expert, that expert's gate_up rows were streamed from DRAM
twice. The GEMV is DRAM-bound, so that is the whole cost.
Group the routes by expert (counting sort over the task's ids) and split pass 1 by
(unique expert, row block) instead, with the routes for that expert as the inner
loop. Each weight row is then read from memory once and reused from L1 across every
token routed to it -- both the gate row and the up row are 4 KiB at H=4096, so 8 KiB
stays resident across the inner loop. No kernel changes: the reuse comes from loop
order, so every format on the gemm1_dot path (bf16, nvfp4, fp8_block, q4_0) benefits
at once.
Measured on 2x Xeon Gold 6526Y, E=256, top_k=8, H=2048, I=768, uniform-random
routing (the pessimistic case -- real routing is skewed, so collisions are more
common):
bs routes unique reuse off on delta
4 32 30 1.07x 3.54ms 3.39ms +4.3%
8 64 56 1.14x 7.12ms 6.80ms +4.6%
16 128 104 1.23x 14.51ms 13.07ms +11.0%
32 256 156 1.64x 29.97ms 21.97ms +36.4%
64 512 213 2.40x 59.12ms 35.44ms +66.8%
Those track a simple traffic model to within 2%: pass 1 is about two thirds of the
bytes (gate_up is [2I, H] against down's [H, I]), so the expected speedup is
1 / (2/3 / reuse + 1/3) -- 1.35x at bs=32 and 1.64x at bs=64 against 1.36x and 1.67x
measured.
Inert below bs=2 and skipped when every route already has a distinct expert, so
single-stream decode keeps exactly the old work split. mxfp4 and ds_fp4 own their
pass-1 bodies and are untouched; they would follow the same shape.
Pass 2 is deliberately not deduped. Its work items are per-token and own their
output rows exclusively; deduping it would have several experts accumulating into
the same y row and needs a reduction, which is a separate change.
`FREETOKEN_CPU_MOE_DEDUP=0` restores the old split, and
`FREETOKEN_CPU_MOE_DEDUP_DEBUG=1` reports the reuse factor -- worth having, since a
first attempt at the toggle cached the env in a function-local static and silently
disabled both arms of the A/B, which read as "dedup does nothing" (+1.4%).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
Pass 1 dedup left the down projection reading each expert once per route. Deduping
it the same way -- a work item per (expert, row block) -- would have several experts
summing into the same y row and need a cross-worker reduction.
Give one work item every token for its H-block instead. The rows are then owned
outright, the accumulation happens in a private fp32 buffer, and an expert's down
rows are still read once and reused across the tokens routed to it. The block is
HBLK_DD = 8 rather than 32 because the item count drops from tokens * n_hblk to
n_hblk, and H/8 keeps ~8 items per worker at H=2048 on a 32-core part.
On top of pass 1 (same rig: 2x Xeon Gold 6526Y, E=256, top_k=8, H=2048, I=768,
uniform-random routing):
bs reuse off pass1 pass1+pass2
8 1.14x 7.27ms +4.6% +11.8%
16 1.23x 14.63ms +11.0% +25.3%
32 1.64x 29.94ms +36.4% +78.8%
64 2.40x 59.18ms +66.8% +158.2%
2.58x at bs=64, slightly ahead of the 2.40x the traffic model predicts -- the
smaller H-block also helps locality.
Numerics: the fp32 accumulation now runs in expert order rather than route order, so
the last bits differ from the non-deduped path. That is the same latitude the kernel
already takes between ISA tiers, and the GPU-comparison test (bs 1/2/5/16) covers it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
Both fp4 formats own their pass bodies, so neither got anything from the generic
dedup. Give them the same treatment: pass 1 splits by (unique expert, row block)
with the expert's routes inner, and pass 2 shares one deduped body -- one work item
owns an H-block for every token, accumulating in a private fp32 buffer.
ds_fp4 reuses its gate/up rows from L1 exactly as the generic path does. mxfp4's
mxgemv computes a whole tile per token, so its reuse is the tile staying resident
across the expert's routes -- 128 KiB at H=4096, L2 rather than L1, still not DRAM.
ds_fp4 (E=128 H=4096 I=2048 top_k=6) mxfp4 (E=64 H=2880 I=2880 top_k=4)
bs reuse off on delta bs reuse off on delta
8 1.20x 9.24ms 7.21ms +28.2% 8 1.39x 7.43ms 5.57ms +33.3%
16 1.35x 17.44ms 12.89ms +35.2% 16 1.45x 13.68ms 10.72ms +27.6%
32 1.94x 33.86ms 19.62ms +72.5% 32 2.33x 26.57ms 14.70ms +80.7%
The deduped pass-2 block size has to be format-aware, and getting it wrong is
expensive. For the row-major formats the block is just "which output rows", so 8
rows is free and keeps the worker pool fed now that the item count has dropped from
tokens * n_hblk to n_hblk. mxfp4's bank is transposed, so the same number becomes
mxgemv's `ncol` -- the dimension it vectorizes over -- and below 16 every call falls
into mxgemv's scalar tail. Measured at HBLK_DD = 8, mxfp4 dedup ran 2.7x SLOWER than
not deduping (-63% at bs=8, -62% at bs=32). It keeps the full 32-row block.
Covered by the existing GPU-comparison tests, which run these formats at batch sizes
where dedup engages: test_cpu_decode_mxfp4_matches_gpu_splitk at bs 1/4/8 and
test_cpu_decode_dsfp4_matches_gpu at bs 1/3/8.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
TDPBF16PS computes C[16x16 fp32] += A[16x32 bf16] x B[32x16 bf16]. Only one mapping
of the expert GEMV onto it works: the obvious one (tokens as A's rows) needs
B = weights as [K, N] and the bank is [N, K], with no transposed-B form of the
instruction. So A is 16 consecutive output rows -- K contiguous, loading straight
from the bank at stride H -- B is the routed tokens' activations transposed and
VNNI-interleaved, and C comes out as [16 rows x N tokens].
N is the token count, so this only exists on top of the expert dedup: at batch size
1 fifteen of the tile's sixteen columns would be empty.
It is off by default (FREETOKEN_CPU_MOE_AMX=1), because on this machine it is worth
approximately nothing, and the measurements say precisely why.
Deduped bf16 pass 1, E=256 (realistic MoE decode, 2.40 routes/expert at bs=64):
bs amx off amx on delta
8 6.39ms 6.29ms +1.5%
16 11.57ms 11.57ms 0.0%
32 17.11ms 17.17ms -0.3%
64 23.80ms 23.11ms +3.0%
That is mostly an empty-tile artifact -- with 256 experts the average expert sees
2.4 tokens, so a 16-wide tile runs at ~15% occupancy. Re-running with E=32, where
bs=64 gives exactly 16 routes per expert and the tiles fill completely:
bs reuse amx off amx on delta
32 8.00x 3.53ms 3.36ms +5.2%
64 16.00x 4.48ms 4.13ms +8.4%
So with perfect tile occupancy AMX is worth 8.4%, which is the answer the ISA sweep
already gave: avx2 -> avx512f -> avx512bf16 moves 60.7 -> 64.9 -> 67.0 GB/s on this
part, so ~10% is all that *any* arithmetic improvement can buy against the memory
wall. AMX delivers what is available and not a byte more.
Two things follow for anyone reviving this. The tile width is mismatched with MoE
decode: filling 16 columns needs ~16 tokens routed to one expert, which at E=256 and
top_k=8 means a batch around 512 -- prefill scale, not decode. And the ceiling is a
property of the machine: on a part with more DRAM bandwidth per core the same tiles
would have more headroom to claim.
Correctness runs under the existing GPU-comparison tests with AMX forced on. AMX
tile state is an extended XSAVE feature, so it also requests ARCH_REQ_XCOMP_PERM
once per process; a refused request leaves amx_ok false and the scalar path in place.
FREETOKEN_CPU_MOE_AMX_DEBUG=1 reports whether it actually engaged, which is worth
having -- an earlier toggle in this series silently disabled itself and read as
"the optimization does nothing".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
7642309 to
81d8b86
Compare
merge resolution The union of the FlashML-org#45 and FlashML-org#52 conflict left select_fp8dot's closing brace after the ds_fp4 W4A8 block that FlashML-org#45 inserts, so every definition that followed parsed as a nested function. Deployment branch only.
What this adds
Intel AMX multiplies bf16 tiles in a single instruction against dedicated tile registers. This PR uses it for the deduplicated bf16 pass 1, where the deduplicated walk has already gathered several tokens that share one expert. That gathering is what makes a tile worth filling.
The path is off by default and turns on with
FREETOKEN_CPU_MOE_AMX=1. It also requiresamx_bf16andamx_tile, and a successfulARCH_REQ_XCOMP_PERMrequest. If any of that is missing, the existing AVX-512 path runs.Stacked on #49, #47 and #46
This PR contains all three. AMX needs several rows of the same expert in one tile, which only exists because those PRs deduplicate the route list. Review them first.
Measurements
Measured on
mainwith this PR alone. Two Xeon Gold 6526Y.ft bench bw --dtype bf16 --reps 5:FREETOKEN_CPU_MOE_AMX=0FREETOKEN_CPU_MOE_AMX=1That is 7.5%.
Why the number is small, and why I think it is still right
The kernel is limited by memory bandwidth, not by multiply throughput. An ISA sweep of the same kernel makes the ceiling visible:
avx2gives 60.7 GB/s,avx512f64.9, andavx512bf1667.0. About 10% is all that any arithmetic change can buy on this machine. AMX returns 7.5% of that and nothing more, which is the honest shape of the result rather than a disappointing one.The test machine has four of eight memory channels populated per socket, which caps measured STREAM read at about 265 GB/s instead of about 530. On a fully populated machine the memory limit sits further out and AMX has more room. I cannot measure that here, so I am not claiming it.
Testing
tests/moe/test_cpu_moe.pyonmainwith this PR: 20 passed, with the flag off.The AMX path itself has no dedicated test. It is exercised only when the flag is set, so the suite above does not cover it. A test that runs the deduplicated pass 1 with the flag on and off and compares outputs would close that gap. Tell me if you want it before merge.