Skip to content

bench(bw): sweep decode batch size to measure cross-token expert dedup - #81

Open
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:bench/dedup-batch-sweep
Open

bench(bw): sweep decode batch size to measure cross-token expert dedup#81
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:bench/dedup-batch-sweep

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 23, 2026

Copy link
Copy Markdown

What this adds

ft bench bw measures 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,64 sweeps 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:

  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 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 touches benchbw.py and 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 off and dedup on columns 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.py covers the routing replay, the reuse arithmetic, and the per-arm executor. On main with this PR alone: 16 passed.

@gdevenyi

Copy link
Copy Markdown
Author

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 (unique expert, row block) so each expert's rows are read from DRAM once per step instead of once per route.

Measured on 2× Xeon Gold 6526Y (E=256, top_k=8, H=2048, I=768, uniform-random routing):

bs routes unique reuse dedup off dedup on delta
16 128 104 1.23× 14.76 ms 11.34 ms +30%
32 256 156 1.64× 29.92 ms 17.21 ms +74%
64 512 213 2.40× 59.10 ms 22.45 ms +163%

The speedups track 1 / (2/3 / reuse + 1/3) to within 2%, so the reuse factor this PR measures is directly predictive of the achievable gain — which makes the two changes complementary rather than redundant. Having the sweep in ft bench bw means a user can see whether their batch sizes are in the regime where dedup pays before turning anything on.

Merge conflicts with my series were purely additive (both add keys to the same entry dict and parameters to _bench_format); resolved by keeping both in deploy/chatdnp-v2, no semantic conflict.

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 static-cached env read made an A/B measure the same path twice and report "+1.4%, no effect" for a change actually worth +163%.

`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
@gdevenyi
gdevenyi force-pushed the bench/dedup-batch-sweep branch from 983dcaa to 5e9236f Compare August 23, 2026 15:44
calvarado2004 added a commit to calvarado2004/FreeToken that referenced this pull request Aug 23, 2026
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
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Aug 23, 2026
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
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Aug 23, 2026
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
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Aug 23, 2026
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
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