bench(bw): sweep decode batch size to measure cross-token expert dedup - #81
bench(bw): sweep decode batch size to measure cross-token expert dedup#81gdevenyi wants to merge 1 commit into
Conversation
|
Merged this alongside my dedup series (#46/#47/#49) — they answer the same question from opposite ends, and together they close it. This PR measures cross-token expert dedup as a property of the workload: how much reuse exists at a given batch size. My PRs exploit that reuse in the CPU MoE kernel by restructuring pass 1 and pass 2 around Measured on 2× Xeon Gold 6526Y (E=256, top_k=8, H=2048, I=768, uniform-random routing):
The speedups track Merge conflicts with my series were purely additive (both add keys to the same One suggestion from having chased this: report the reuse factor in the printed table, not just the JSON. The single most useful number here is "routes / unique", and when I was debugging my own dedup a printed reuse factor is what caught a toggle that had silently disabled itself — a |
`ft bench bw` drives the CPU MoE kernel at bs=1. Dedup is inert there by construction --
one token cannot collide with itself -- so the headline GB/s is identical with the change
in and out, and the bench reports nothing about the thing it is best placed to measure.
The evidence for the dedup series has so far lived only in commit messages, measured on
one machine, which is not something a reviewer or a user can re-run.
Add `--batch`: for each selected format, walk the given decode batch sizes over the same
synthetic banks and time each one twice, with dedup forced off and on, on identical
routing. Report routes, unique experts, the reuse factor, both arm times and the speedup.
Routing is uniform-random, distinct within a token and independent across tokens, so the
only collisions are the cross-token ones dedup removes. That is the pessimistic case: a
real router is skewed, so tokens collide more often than chance and reuse runs higher.
Both arms replay the same pre-drawn routing, so the comparison is between two kernels
rather than between two random draws, and each arm builds its own executor because the
extension latches FREETOKEN_CPU_MOE_DEDUP at construction.
Measured on a Ryzen 9 9950X (16c, DDR5-5600, ~59 GB/s host read ceiling), E=128,
canonical per-dtype geometries, otherwise-idle box:
bf16 bs routes unique reuse off on speedup
1 8 8 1.00x 1.25ms 1.28ms 0.97x
4 32 29 1.11x 5.06ms 4.79ms 1.06x
8 64 51 1.25x 10.05ms 8.32ms 1.21x
16 128 82 1.56x 19.97ms 13.27ms 1.51x
32 256 112 2.29x 39.91ms 18.22ms 2.19x
64 512 126 4.06x 79.63ms 20.67ms 3.85x
At bs=64, per format: bf16 3.85x, nvfp4 3.44x, fp8_block 3.31x, ds_fp4 2.96x, mxfp4 2.35x.
Speedup tracks the reuse factor to ~95% -- decode is DRAM-bound, so removing the duplicate
reads is very nearly all of it, and both passes being deduped is what gets it there.
Note these are larger than the numbers in the pass-1 commit (+66.8% at bs=64 on a 2x Xeon
Gold 6526Y): that box has far more memory bandwidth per core, so the same saved traffic
converts into less saved time. A consumer part is where this change pays best, which is
exactly the case the commit-message-only evidence could not show.
Opt-in and off by default, so the default run and its JSON verdict are byte-identical.
The new `batch_sweep` key is additive; bench_profile.py reads the profile entirely through
.get() and never inspects the version, so the runtime backend pick is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfyW9hhH934qb9KjqHcoHL
983dcaa to
5e9236f
Compare
Add the exact DeepSeek-V4 TP4 CPU-MoE geometry to the community batch-sweep benchmark so the ds_fp4 dedup path is measured at the DSpark verification widths used in production, rather than extrapolated from batch size one. Report adaptive width and the selector's startup-profiled draft/verify costs without synchronizing the serving hot path. Document both the 28-30 tok/s high-survival result and the independently measured low-acceptance regression so deployments use representative A/B evidence instead of a universal threshold. Based-on: FlashML-org#41 Based-on: FlashML-org#49 Based-on: FlashML-org#81
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.
Re-measured on main with this commit alone, using `ft bench bw --batch` from FlashML-org#81.
That is a different workload -- E=256, top_k=6, 9.00 MB experts -- so the figures
are not comparable with the sweep above, only the shape is:
bs routes uniq reuse dedup off dedup on speedup
1 8 8 1.00x 1.09ms 0.98ms 1.11x
8 64 52 1.23x 7.95ms 6.55ms 1.21x
16 128 82 1.56x 13.67ms 9.70ms 1.41x
32 256 112 2.29x 22.95ms 13.18ms 1.74x
64 512 126 4.06x 39.49ms 19.10ms 2.07x
The bs=1 row is worth noting: reuse is 1.00x, so the traffic model predicts nothing,
yet the step still runs 1.11x faster. The deduplicated walk is cheaper than the
per-route walk independently of reuse, which is why the larger batches come in above
what reuse alone predicts.
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.
Re-measured on main with this commit alone, `ft bench bw --batch` from FlashML-org#81 (the
other rig: E=256, top_k=6, 9.00 MB experts): 1.07x / 1.36x / 1.66x / 2.38x / 4.00x
at bs 1/8/16/32/64. There the step costs 9.91 ms at bs=64 against 9.81 ms at bs=32
while the route count doubles -- once both passes read each distinct expert once,
the cost is set by the 126 distinct experts and extra tokens selecting them are
nearly free.
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.
Re-measured on main with this commit alone, `ft bench bw --batch` from FlashML-org#81, on the
bf16 path: 1.09x / 1.36x / 1.66x / 2.37x / 3.96x at bs 1/8/16/32/64. Those match the
pass-2 commit within run-to-run spread, which is the expected result -- this commit
changes the fp4 bodies, not bf16. Its value is that ds_fp4 and mxfp4 deployments get
the same behaviour instead of falling back to the per-route walk.
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.
This commit adds no test of its own. The existing tests do not run a batch large
enough to force fp4 reuse, so the deduped fp4 path is exercised but its dedup is
not pinned against the per-route output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
What this adds
ft bench bwmeasures one decode step at batch 1. Cross-token expert dedup only does anything when several tokens in a batch route to the same expert, and at batch 1 no expert can repeat. The existing benchmark therefore cannot show whether dedup works.--batch 1,8,32,64sweeps the decode batch size and reports, per size, the route count, the distinct expert count, the resulting reuse factor, and the step cost with dedup on and off:The routing draw is made once per batch size and replayed for both arms, so the two sides do identical work. Each arm gets its own executor, because the dedup decision is cached per executor and a shared one would measure the same path twice.
Independent of the dedup work
This is one commit on
main. It touchesbenchbw.pyand its test, and nothing else, so it can be reviewed and merged on its own.It does not require the cpu-moe dedup PRs to be useful. Without them the
dedup offanddedup oncolumns report the same path, and the sweep still shows the route count, the distinct expert count and the reuse factor, which is the part that stands alone.Testing
tests/moe/test_benchbw_batch.pycovers the routing replay, the reuse arithmetic, and the per-arm executor. Onmainwith this PR alone: 16 passed.