Skip to content

perf(cpu-moe): dedup experts in pass 2 as well - #47

Open
gdevenyi wants to merge 2 commits into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-dedup-pass2
Open

perf(cpu-moe): dedup experts in pass 2 as well#47
gdevenyi wants to merge 2 commits into
FlashML-org:mainfrom
gdevenyi:perf/cpu-moe-dedup-pass2

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 22, 2026

Copy link
Copy Markdown

What this adds

#46 deduplicates the expert reads in pass 1. Pass 2 still reads down once per route.

This PR reuses the same deduplicated route list for pass 2. Pass 2 accumulates into a per-token output rather than reading a shared operand, so it needs a scatter step that #46 did not.

Stacked on #46

This PR contains #46's commit. The two touch the same code in cpu_moe_ext.cpp and pass 2 reuses the route list that #46 builds, so they cannot be separated without duplicating that work. Review #46 first.

Measurements

Measured on main with this PR alone, using the batch sweep from #81 as the measuring tool. Same machine and workload as #46, so the rows compare directly.

batch reuse dedup off dedup on this PR #46 alone
1 1.00x 1.01 ms 0.95 ms 1.07x 1.11x
8 1.23x 7.89 ms 5.81 ms 1.36x 1.21x
16 1.56x 13.70 ms 8.25 ms 1.66x 1.41x
32 2.29x 23.33 ms 9.81 ms 2.38x 1.74x
64 4.06x 39.61 ms 9.91 ms 4.00x 2.07x

At batch 64 the cost stops rising with the batch: 9.91 ms against 9.81 ms at batch 32, while the route count doubles from 256 to 512. Once both passes read each distinct expert once, the step costs what the 126 distinct experts cost, and adding more tokens that select the same experts is nearly free.

That is the point of the pair. #46 alone reaches 2.07x at batch 64 because it still pays full price for one third of the bytes. Removing that third takes the same workload to 4.00x.

Testing

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

gdevenyi and others added 2 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
@gdevenyi
gdevenyi force-pushed the perf/cpu-moe-dedup-pass2 branch from 9883682 to a60a654 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