Skip to content

perf(cpu-moe): opt-in AMX tile GEMM for the deduped bf16 pass 1 - #52

Open
gdevenyi wants to merge 4 commits into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-amx
Open

perf(cpu-moe): opt-in AMX tile GEMM for the deduped bf16 pass 1#52
gdevenyi wants to merge 4 commits into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-amx

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 22, 2026

Copy link
Copy Markdown

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 requires amx_bf16 and amx_tile, and a successful ARCH_REQ_XCOMP_PERM request. 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 main with this PR alone. Two Xeon Gold 6526Y. ft bench bw --dtype bf16 --reps 5:

CPU MoE ratio to PCIe
FREETOKEN_CPU_MOE_AMX=0 67.7 GB/s 2.69x
FREETOKEN_CPU_MOE_AMX=1 72.8 GB/s 2.89x

That 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: avx2 gives 60.7 GB/s, avx512f 64.9, and avx512bf16 67.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.py on main with 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.

@gdevenyi

Copy link
Copy Markdown
Author

Independent support for the opt-in default here, from the paper (arXiv:2608.16157 §4.1), describing the CPU worker pool:

Their kernels consume expert weights using architecture-specific SIMD and in-kernel dequantization, which keeps the path bandwidth-bound.

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:

moving from the many-channel server to a dual-channel consumer desktop costs FreeToken 4% of its decode rate, while llama.cpp keeps only 80% of its rate as its CPU-resident experts starve on two DDR5 channels

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.

gdevenyi and others added 4 commits August 23, 2026 12:24
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
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Aug 23, 2026
 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.
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