Skip to content

perf(cpu-moe): dedup experts across the batch in pass 1 - #46

Open
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-expert-dedup
Open

perf(cpu-moe): dedup experts across the batch in pass 1#46
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-expert-dedup

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 22, 2026

Copy link
Copy Markdown

What this adds

The CPU MoE decode kernel walks the routed experts one route at a time. When several tokens in a batch route to the same expert, it reads that expert's weights once per token.

This PR builds a deduplicated route list before pass 1 and reads each distinct expert once, accumulating into every token that selected it. The weights are the large operand, so the saving tracks how often experts repeat.

Measurements

Measured on main with this PR alone, using the batch sweep from #81 as the measuring tool. Two Xeon Gold 6526Y, bf16 experts of 9.00 MB, 256 experts, top-6.

batch routes distinct reuse dedup off dedup on speedup
1 8 8 1.00x 1.09 ms 0.98 ms 1.11x
8 64 52 1.23x 7.95 ms 6.55 ms 1.21x
16 128 82 1.56x 13.67 ms 9.70 ms 1.41x
32 256 112 2.29x 22.95 ms 13.18 ms 1.74x
64 512 126 4.06x 39.49 ms 19.10 ms 2.07x

Both arms replay the same routing draw, so the comparison uses identical work.

At batch 1 there is nothing to deduplicate. The 1.11x there is the deduplicated path being slightly cheaper to walk, not reuse.

Why the numbers land where they do

Pass 1 reads gate_up, shaped [2I, H]. Pass 2 reads down, shaped [H, I]. Pass 1 is therefore about two thirds of the bytes. This PR deduplicates pass 1 only, so reuse alone predicts 1 / (2/3 / reuse + 1/3):

batch reuse predicted from reuse measured
32 2.29x 1.60x 1.74x
64 4.06x 2.01x 2.07x

Measured is above predicted at both sizes, and the batch-1 row says why. At batch 1 nothing repeats, reuse is 1.00x, and the formula predicts no gain at all, yet the step still runs 1.11x faster. So the deduplicated walk is itself cheaper than the per-route walk, independent of reuse.

Folding that constant in gives 1.78x and 2.23x, which now sit slightly above the measured 1.74x and 2.07x. The measurements land between the two estimates. I read that as the reuse term carrying the effect, with a small fixed saving on top, rather than as either model being exact.

#47 deduplicates pass 2 as well and takes the remaining third.

Cost and safety

The dedup list is built once per layer per step. FREETOKEN_CPU_MOE_DEDUP=0 restores the old path for A/B testing.

Testing

tests/moe/test_cpu_moe.py on main with this PR: 20 passed.

@gdevenyi

Copy link
Copy Markdown
Author

Re-reading the paper (arXiv:2608.16157) I want to head off a likely objection, because §4.1 already contains the word "deduplicates":

For each MoE layer, one GPU kernel deduplicates the routed experts, classifies them against the residency table, derives the bandwidth-based fetch count q, selects eviction victims, and rewrites logical routed IDs into physical slot IDs or a special CPU-assignment flag.

That is a different dedup, on the other side of the boundary:

§4.1 device-side this PR
what is deduped which unique experts to fetch over PCIe which weight rows to read from host DRAM
where GPU control kernel CPU MoE worker pool
saves duplicate PCIe transfers duplicate DRAM reads

The CPU executor never sees the deduplicated set. §4.1 says the kernel "rewrites logical routed IDs into physical slot IDs or a special CPU-assignment flag", so what reaches the workers is still per-route: t->ids[tok * top_k + k]. Two tokens routed to the same expert arrive as two independent routes, and pass 1 streamed that expert's gate_up rows from DRAM once per route.

So the two compose rather than overlap — §4.1 stops the expert crossing PCIe twice, this stops it crossing the memory bus twice once it is already in host RAM.

This also lines up with the paper's own characterisation of the CPU path in the same section:

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

Which is exactly why the win here tracks the traffic model to within 2% and why it comes from loop order rather than any kernel change.

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
@gdevenyi
gdevenyi force-pushed the perf/cpu-moe-expert-dedup branch from 126fe5c to 49d8a0f Compare August 23, 2026 16:24
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