Skip to content

feat(bench): ft bench decode — end-to-end tokens/s, with A/B across engine flags - #41

Open
gdevenyi wants to merge 3 commits into
FlashML-org:mainfrom
gdevenyi:bench/decode-end-to-end
Open

feat(bench): ft bench decode — end-to-end tokens/s, with A/B across engine flags#41
gdevenyi wants to merge 3 commits into
FlashML-org:mainfrom
gdevenyi:bench/decode-end-to-end

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 22, 2026

Copy link
Copy Markdown

Why

ft bench bw measures one kernel in isolation over a small synthetic bank. That is the right shape for calibrating a bandwidth ratio. It is the wrong shape for answering whether a configuration is faster, because a serving step is the kernel plus the PCIe gather it contends with, plus the KV traffic, the GPU-to-CPU handshake and the scheduler, on the real model at the real cache size.

The two can disagree completely. Confining the CPU MoE pool to one NUMA node measured +42% for bf16 and +30% for ds_fp4 on the microbenchmark, and -6.7% on tokens per second serving DeepSeek-V4-Flash. Under --moe-backend cpu it measured -28%. I withdrew that change on the strength of the second number.

Answering that question took a throwaway shell script: start a server, poll until ready, curl some completions, grep the throughput out of the log, kill it, repeat. I wrote that script three times in one afternoon. That is the argument for this command.

Usage

ft bench decode --model DIR
ft bench decode --model DIR --compare moe-backend=hybrid,offload
ft bench decode --model DIR --compare moe-cache-rate=0.1,0.25,0.5 --cycles 3
  cycle 1  moe-cache-rate=0.10            39.97 tok/s  (49s)
  cycle 1  moe-cache-rate=0.25            69.51 tok/s  (44s)
  cycle 2  moe-cache-rate=0.10            40.15 tok/s  (46s)
  cycle 2  moe-cache-rate=0.25            69.45 tok/s  (41s)

How it measures

Each measurement reloads the model in a fresh process, so no variant inherits another's cache state.

Prefill is subtracted rather than ignored. The command times two generations of different lengths and takes (n-1) / (t_n - t_1), which cancels the fixed prefill cost.

Variants run in alternating cycles rather than one after another, so a machine that drifts during the run spreads that drift across both arms instead of loading it onto whichever ran second.

With one cycle the command says so and declines to call a winner:

  Only one cycle per variant: nothing separates a real difference from drift.
  Use --cycles 2 or more before believing a small gap.

--concurrency N raises the request count so batch-dependent costs become visible, and sends distinct prompts so the radix cache cannot serve the prefill.

Testing

Run on main with this PR, serving DeepSeek-V4-Flash on one RTX 6000 Ada:

  cycle 1  baseline                       15.12 tok/s  (102s)

This PR adds no unit test. The command drives a real server, so the parts worth testing are the prefill subtraction arithmetic and the cycle scheduling, both of which are pure functions and could be tested without a model. Tell me if you want that before merge.

gdevenyi and others added 3 commits August 22, 2026 10:45
…s engine flags

`ft bench bw` measures one kernel in isolation over a small synthetic bank. That is
the right shape for calibrating a bandwidth ratio and the wrong shape for answering
"is this configuration faster", because a serving step is the kernel *plus* the
gather it contends with, the KV traffic, the handshake and the scheduler, on the
real model at the real cache size.

They can disagree completely. Confining the CPU MoE pool to one NUMA node measured
+42% (bf16) and +30% (ds_fp4) on the microbenchmark and -6.7% on tokens/s serving
DeepSeek-V4-Flash; under `--moe-backend cpu` it was -28%. Answering that took a
throwaway shell script -- start a server, poll for ready, curl some completions,
grep gen throughput out of the log, kill it, repeat. I wrote that script three
times in one afternoon, which is the actual argument for this command.

    ft bench decode --model DIR
    ft bench decode --model DIR --compare moe-backend=hybrid,offload
    ft bench decode --model DIR --compare moe-cache-rate=0.1,0.25,0.5 --cycles 3

Two things it does that the hand-rolled version did not:

* **Prefill is subtracted.** The same prompt is timed at `max_tokens=1` and at
  `max_tokens=n`; `(n-1)/(t_n - t_1)` is decode alone, so prompt length cannot
  quietly flatter the result. `ignore_eos` keeps every run the same length.
* **Variants alternate**, one full pass per cycle rather than all of A then all of
  B. Thermal drift and page-cache state move over minutes; blocked runs attribute
  that drift to the variant. A single cycle prints a warning saying so.

Each measurement runs in a fresh subprocess: expert banks are pinned and there is
no `cudaHostUnregister` binding, so an in-process teardown does not reliably give
the memory back and the next variant measures the last one's leftovers.

Validated against numbers previously obtained by hand through the HTTP server on
Qwen3.6-35B-A3B-FP8 -- same ordering and magnitude, and much steadier:

    moe-cache-rate=0.10    40.06 tok/s   (40.0-40.2)     server run: 44.16
    moe-cache-rate=0.25    69.48 tok/s   (69.4-69.5)     server run: 82.31

The offset is expected and intended: this reports steady-state decode with prefill
removed, so it is a tool for comparing configurations, not for quoting a serving
figure. The 0.5% spread across cycles is the point -- the server-log method
scattered by several percent, which is the same order as the effects being chased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
`ft serve` defaults offload-family backends to --moe-cache-auto when no cache-sizing
flag is given (prepare_server_args); the offline LLM path does not, so
`--compare moe-backend=hybrid,offload` died on "moe_cache_size=0 is too small"
before measuring anything. Mirror the CLI. An explicit --set moe-cache-size /
-rate / -auto still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
…rable

The command only ever generated one stream, which is the single-stream latency case
rather than the serving case. Anything whose cost depends on how many tokens a decode
step carries was invisible: expert dedup on the CPU MoE path, CUDA-graph batch
selection, the scheduler itself. Having just added expert dedup and then being unable
to measure it with this tool is the argument.

`--concurrency N` generates N streams at once and reports aggregate tokens/s. The
offline LLM already takes a list of prompts and decodes them together, so no server
or client fan-out is involved.

Two details that would otherwise make it measure the wrong thing:

  * `max_running_req` and `cuda_graph_max_bs` default up to N. Without room for the
    streams the scheduler serializes them, the decode batch never grows, and the run
    silently reports concurrency 1.
  * The prompts are made distinct. The radix cache shares the KV of identical
    prefixes, so N copies of one prompt would measure one stream plus N-1 cache hits.

On DeepSeek-V4-Flash it immediately answers a question the single-stream mode could
not: offload beats hybrid by 3.9% at one stream and by 15.7% at eight, so the gap
widens with load rather than closing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant