DeepSeek-V4.1-Flash: the routed experts on the four cards¶
src/models/deepseek_v4_1/device_experts.py holds one backbone layer's routed experts as fixed
arenas on world cards and consumes the checkpoint's packed fp4 directly: the kernel
moe_single_token_fp4_forward takes [E, N, K/2] uint8 codes beside [E, N, K/32] E8M0 scales and
dequantizes inside the kernel, so no bf16 expert matrix is ever built anywhere. That is the whole
point of the path — the host's 0.122 s per expert is 99.7% the expansion, and this arithmetic is the
one the checkpoint ships with.
This page is what the path costs and what it is worth. The companion page, what the released checkpoint costs to run on one host, is the other half: the same model with the experts on the CPU, at 15 to 42 s per generated token.
Run record¶
| Model | DeepSeek-V4.1-Flash, released checkpoint, fp8 dense + packed-fp4 experts |
| Checkpoint | /mnt/data3/DeepSeek-V4.1-Flash, 48 shards, 475.24 GiB (SMR disk, /dev/sda) |
| Runtime | PyTorch resident, src/models/deepseek_v4_1, no native engine; moe_single_token_fp4_forward from the built pocketllm_cpp CUDA extension |
| Commit | df3ed3d on feature/v41-backbone-runtime plus the uncommitted device_experts.py; the ordering fix and its re-measured step are in the launch |
| GPUs | 4 x RTX 2080 Ti, 22528 MiB each; expert-parallel world=4 across all four and world=1 on cuda:0, both measured |
| CPU / RAM | 2 x Xeon E5-2696 v4, 88 hardware threads, 1007 GiB RAM |
| Software | Python 3.11.14, torch 2.9.1+cu128, deepseek conda env |
| Prompt | The capital of France is (5 tokens), greedy, 4 new tokens |
| Warm/cold | Both: the cost probe runs the same prompt twice in one process, and its GiB/s column is what separates a warm pass from a cold one |
Scripts: /tmp/probe_device_experts.py (the class against expert_forward on a captured
activation), /tmp/probe_fp4_parity.py (the kernel against the host expert on real activations, and
the EP4 decomposition), /tmp/probe_stage.py (the staging and H2D terms), /tmp/probe_h2d.py (the
PCIe rates), /tmp/probe_first_tokens.py (the top five at each of the first tokens, on either path),
/tmp/probe_device_cost.py (a whole step, phase by phase), and /tmp/probe_launch_cost.py and
/tmp/probe_launch_split.py, which are the two that priced and then fixed the launch ordering. All
but the first-token probe read /tmp/v41_activations.pt, which /tmp/probe_capture.py writes; that
one runs the loop itself and reads nothing. They are throwaway probes, not checked-in benchmarks; the
numbers they produced are what this page records.
The split¶
Per row, the layer's 6 routed experts are sorted by global id and dealt round-robin, so card c owns
sorted positions c and c + world: 2, 2, 1, 1 over four cards, from the real routing of layer
0's captured prefill row, card 0 took experts 128 and 251, card 1 took 137 and 277, card 2 took 155
and card 3 took 206. The split is a property of the routing and not of the order the gate emitted it
in, which is what makes two runs that route to the same six experts stage the same bytes into the
same rows.
Each card's arena is a fixed ceil(topk / world) = 2 rows, so nothing resizes between steps and
nothing is allocated per token. The pinned arena holds every card's rows laid end to end — one
allocation rather than one per card — 143.4 MiB per side at world=4 against 107.6 MiB at
world=1, both with two buffers so a row can stage while the DMA that read the previous one is still
in flight. The four-card arena is the larger of the two because 6 does not divide by 4: two of its
eight rows are empty in every token. That is 0.3 GiB of pinned RAM for the four-card path, which is
not a reason to rebalance it.
The cards never talk to each other. Each holds its own arena, is handed the same [1, 5120]
activation, and returns [1, 5120] fp32; the host sums the world partials. That is 20 KiB back per
card per layer, 3.2 MB per token across four cards, and it is why there is no NCCL, no all-to-all and
no collective here to debug. It also makes world=1 the single-card configuration rather than a
second implementation, which is what Verification item 3 in the plan asked for: the same code, one
flag.
The dense tree stays on the host in this phase. It is 16.79 GiB, it works, and moving it is worth 0.4–0.6 s/token on its own; doing both at once would put two independent sources of divergence inside one debugging session.
Two conventions that had to be settled before the first run¶
Both looked like blockers first, and both came out in the kernel's favour. They are recorded here because a page that only reports the numbers leaves the next reader to re-derive them.
The scale. fp4_block_scale (cuda_kernel_impl.cu:2388) is
__int_as_float(max(0, byte - 1) << 23). __int_as_float places its argument in the exponent
field, so the value is 2**(byte - 1 - 127) and not 2**(byte - 1); with the LUT's doubled e2m1
levels that is level * 2**(byte - 127), bit for bit what torch's float8_e8m0fnu reads on the same
byte and what cpp_engine/backends/cuda/kernels/fp4_ops.cu:243 computes as exp2f(code - 127). The
- 1 absorbs the LUT's * 2. tests/test_moe_single_token_fp4.py draws scales 124..130 against a
reference that reads them as 2**(byte - 127) and reproduces the kernel to 2.9e-5 relative. An
earlier reading of this file recorded the opposite and was a misreading of the shift: there is no
kernel bug here and no rebasing anywhere in the path.
The layout. The release stores an expert's w1.weight as (2304, 2560) I8 beside w1.scale
(2304, 160) F8_E8M0 — [N, K/2] beside [N, K/32] with N = inter_dim and K = dim, which is
the op's ABI verbatim — and w2 as (5120, 1152) beside (5120, 72) as [dim, inter/2]. So an
arena row is the checkpoint's own tensor: no transpose, no per-element scale expansion, no rebasing.
DeviceRoutedExperts._check_shapes refuses the transposed release rather than reading it, because
w1 and w2 swapped would still give every arena a plausible shape and every kernel a plausible
answer.
Correctness¶
Three measurements, from the outside in. The activations are real — /tmp/probe_capture.py captures
a row at each layer while the actual checkpoint runs — because the kernel quantizes a row with a
single scale, so what the precision costs depends on that row's dynamic range and a Gaussian of the
wrong width would answer a different question.
| Comparison | Max abs | Relative | Cosine | Argmax |
|---|---|---|---|---|
kernel vs expert_forward, layer 20 row 2 (worst of 69 rows) |
0.08572, 6.715% of the output scale | — | 0.997356 | differs on 7 of 69 rows |
kernel vs expert_forward, typical row |
— | 2–5% of scale | 0.997–0.9998 | — |
| four arenas summed vs one call over all six experts | 5.960e-08 | 5.607e-08 | — | same |
world=4 vs world=1, whole class, same activation |
5.960e-08 | — | — | same |
world=4 vs the host path |
— | 1.5–2.4% of scale | 0.9993–0.9997 | — |
The first row is the whole cost of the device path and it is the arithmetic the checkpoint ships with: int8 activations × fp4 weights against the host's bf16 × bf16, two different precision classes by construction. 6.7% of the output scale on the worst of 69 rows, with the argmax moving on 7 of them, is what that costs at the layer level; it does not accumulate into a wrong token (below).
The next two rows are the ones that say the implementation is exact. 5.960e-08 is fp32
association order across four partial sums and nothing else — the number recurs because both
comparisons are summing the same four partials in a different order — and it is 2^-24 of a unit-scale
result. So the split, the deal, the staging, the arena row assignment and the weight permutation are
all accounted for: what is left between this path and the host is the kernel's own precision.
And the end of it, the checked-in generation loop, greedy, --max-new-tokens 4, both worlds, the
verbatim tail of each run:
$ ... -m src.models.deepseek_v4_1.generate --checkpoint /mnt/data3/DeepSeek-V4.1-Flash \
--prompt "The capital of France is" --max-new-tokens 4 --expert-device cuda --expert-world 4
loaded in 73.7 s
routed experts: DeviceRoutedExperts on 40 layers, world 4
prompt 5 tokens: ['The', 'Ġcapital', 'Ġof', 'ĠFrance', 'Ġis']
3 tokens in 7.2 s (2.41 s/token), stopped on eos
The capital of France is Paris.<|end▁of▁sentence|>
$ ... --expert-device cuda --expert-world 1
loaded in 72.2 s
routed experts: DeviceRoutedExperts on 40 layers, world 1
prompt 5 tokens: ['The', 'Ġcapital', 'Ġof', 'ĠFrance', 'Ġis']
3 tokens in 8.6 s (2.87 s/token), stopped on eos
The capital of France is Paris.<|end▁of▁sentence|>
--max-new-tokens 4 and 3 tokens, because token 3 is the EOS the model actually chose. The routed
experts: line is the flag's own report of what it built, and it exists because "it silently fell
back to the host" is the failure this flag has: it walks the layers and prints the class it found
plus the world read off the object rather than off the argument.
The two worlds agree with each other token for token. The host path does not agree with them at
token 3, so that token gets its own probe rather than its own sentence: /tmp/probe_first_tokens.py
runs the same loop, same prefill, same greedy pick, with the distribution printed at every step.
| Path | Token 1 | Token 2 | Token 3 |
|---|---|---|---|
world=4, on the cards |
' Paris' 22.295 |
'.' 22.933 |
EOS 17.953 against ' The' 17.452 |
| the host, same loop | ' Paris' 20.605 |
'.' 20.873 |
' The' 17.513 against EOS 17.051 |
The third token is a half-logit knife edge on both paths, and they fall on opposite sides of it. The device's margin is 0.501 logits and the host's is 0.462, on a distribution whose leader sits at 18 to 23 — this is not a token either path has an opinion about, and the difference that decides it is the fp4 kernel's: the first two tokens score 1.690 and 2.060 logits higher on the cards than on the host, which is the 6.7%-of-scale layer difference above showing up where it can flip an argmax.
And the host path is not a single answer at that token either. probe_accept.py's stepwise decode
of the same prompt — the host page's recorded ' Paris.<|end▁of▁sentence|>' — emits EOS there from
the same host weights, and the host page measures a prefill against a stepwise decode of this prompt
at 6.0748 max abs logits, two orders of magnitude above the 1.69 the cards move it. So the side of a
0.5-logit tie this token lands on was never a property of the host weights; both device worlds land on
the side the host's own stepwise decode lands on.
That is the acceptance bar the plan set, met: all three configurations agree on the first two
tokens and each continues coherently — the cards end the sentence at the period, the host builds
' The Eiffel' out of it — and the one token they disagree about is a near-tie on a distribution
that does not distinguish its two candidates. Both device worlds and both device runs put EOS on the
same token, and neither is a wrong answer at it.
What a step costs¶
/tmp/probe_device_cost.py instruments DeviceRoutedExperts itself and runs the prompt through the
backbone twice in one process. Each row is one MoE row: a decode step is 40 of them (one per layer, 6
experts each) and a 5-token prefill is 200.
These two tables are the run that established the shape of the step, and they predate the ordering
fix in the launch — read their Launch
column as the 0.27 s that fix took to 0.15 s. They are kept as measured rather than back-edited; the
re-measured step is in that section.
world=4, routed experts on cuda:0..3, 2 rows per card:
| Pass | Step | Wall | Stage | Upload | Launch | Other | GiB/s |
|---|---|---|---|---|---|---|---|
| 1 | 0 (prefill, 200 rows) | 5.00 s | 1.94 s | 0.29 s | 1.34 s | 1.42 s | 10.83 |
| 1 | 1 | 1.26 s | 0.34 s | 0.03 s | 0.27 s | 0.62 s | 12.46 |
| 1 | 2 | 1.28 s | 0.33 s | 0.04 s | 0.27 s | 0.65 s | 12.79 |
| 2 | 0 (prefill, 200 rows) | 4.50 s | 1.89 s | 0.17 s | 1.34 s | 1.11 s | 11.13 |
| 2 | 1 | 1.42 s | 0.34 s | 0.03 s | 0.27 s | 0.78 s | 12.32 |
| 2 | 2 | 1.34 s | 0.33 s | 0.03 s | 0.27 s | 0.71 s | 12.71 |
world=1, routed experts on cuda:0, 6 rows per card:
| Pass | Step | Wall | Stage | Upload | Launch | Other | GiB/s |
|---|---|---|---|---|---|---|---|
| 1 | 0 (prefill, 200 rows) | 13.74 s | 9.20 s | 0.10 s | 2.38 s | 2.07 s | 2.28 |
| 1 | 1 | 2.36 s | 0.93 s | 0.02 s | 0.48 s | 0.94 s | 4.53 |
| 1 | 2 | 2.22 s | 1.02 s | 0.02 s | 0.48 s | 0.70 s | 4.11 |
| 2 | 0 (prefill, 200 rows) | 5.52 s | 1.88 s | 0.06 s | 2.31 s | 1.27 s | 11.18 |
| 2 | 1 | 1.40 s | 0.32 s | 0.01 s | 0.46 s | 0.60 s | 12.94 |
| 2 | 2 | 1.39 s | 0.33 s | 0.01 s | 0.46 s | 0.59 s | 12.91 |
Before the ordering fix a decode step was 1.23–1.42 s at world=4 across three runs of two probes,
and 1.40 s at world=1, warm; a 5-token prefill is 4.50–5.00 s and 5.52 s. After it the step is
1.06–1.14 s at world=4, which is measured below.
Every one of those runs returns 'The capital of France is Paris.<|end▁of▁sentence|>' with tokens
[11111, 16, 1]. Against the host path's 15.3 s for a warm decode step and 14.5–29 s of expert
expansion alone, the step is an order of magnitude and the expansion is gone entirely.
The second probe that measures a step — /tmp/probe_token_cost.py --expert-device cuda
--expert-world 4, the same file the host page uses for the host path — attributes it in the
coarser pair the two paths share: 1.23 s of step, 0.73 s of it inside DeviceRoutedExperts and
0.51 s of it the dense tree, the head and the layer glue. That probe also reports zero misses and
then divides by them, because its miss counters are CheckpointRoutedExperts's and this path has no
window at all; the device path's per-step cost is the same whether a step repeats an expert or not,
which is the second thing it says. Both of its figures predate the ordering fix and both keep their
shape after it: the class's 0.73 s falls to 0.48 s (0.30 staging + 0.15 launch + 0.03 upload) and
the 0.51 s outside it is unchanged at 0.55–0.67 s, which sums to the 1.06–1.14 s step measured below.
The checked-in loop's own numbers are whole-request and do not decompose, which is the one thing
to read them for and not for anything else: its timer wraps the entire generate() call — the 5-token
prefill plus three decode steps — and divides by the three new tokens, so the 2.41 s/token it
prints is not a step and this page does not quote it as one. What it does give is a second,
independent instrument on the difference between the two worlds: 8.6 s against 7.2 s is 1.4 s,
while the probes' own world-1-minus-world-4 is 0.5–1.0 s of prefill (5.52 against 4.50–5.00) plus
nothing to 0.5 s over three decode steps (1.40 against 1.23–1.42) — 0.5 s to 1.5 s. Same sign, same
size, from code that shares nothing with the probes. On the absolute the loop is 1.0–2.1 s under what
the probes' per-step figures sum to; the probes are the instrumented side of that pair, so the step
figure this page quotes is theirs.
Whole-request, the same command with the flag unset is the comparison the flag exists to make, and it
is measured on the same prompt in the same session: 28.00–42.37 s per new token on the host against
2.41–2.87 s on the cards, five runs, prefill included at both ends. The host figures are 168.0 s
for 6 tokens, 142.0 s for 4 and the companion page's 169.5 s and 124.6 s; the flag-unset run in
this session is the second of those, so the host path is unchanged by this work and still the default.
The GiB/s column is the staging rate and it is what makes the page-cache state readable off the
run. The world=1 pass 1 is the only column that caught a cold cache — 2.28 GiB/s at the prefill and
4.1–4.5 for its decode steps, against 11.2–12.9 in the same run's pass 2 — so a cold first token
surfaces as a staging time and not as anything else. The world=4 run found the same pages resident
in both passes, which earlier device probes had made them. The controlled comparison is pass 1
against pass 2 within a run, and where the two runs disagree about what was resident the GiB/s
column says so rather than averaging it away.
The four cards are worth 0.1–0.2 s, and that is the one prediction the plan got wrong¶
The plan projected a token of 0.6–1.0 s on four cards against 1.2–2.0 s on one, with the H2D falling from 0.51 s to 0.13 s as the four links aggregate. The aggregation is real — measured, one link moves 10.47 GiB/s and four move 38.56 GiB/s together, so the 4.20 GiB of a step is 0.11 s of transfer against 0.51 s — but it is not what the step spends its time on, and the measured split says why:
| Term | world=1 |
world=4 |
|---|---|---|
| staging, host, per decode step | 0.33 s | 0.33 s |
| the copy chains, enqueued | 0.01 s | 0.03–0.04 s |
_launch, four kernels plus the H2D they wait on |
0.46 s | 0.27 s → 0.15 s |
| wall | 1.40 s | 1.23–1.42 s → 1.06–1.14 s |
Staging is identical on both and it is host work. It does not care how many cards read the bytes, so what the split buys is the kernels and the partials they hand back — 0.19 s of a step before the ordering fix and 0.31 s of the re-measured 1.06 s step after it — and nothing at all of the 0.33 s that is now the largest single term. The plan's table had H2D on the critical path and staging as the unmeasured question; the measurement says the reverse. That is the honest reading of four-against-one and it is why the follow-on that would matter keeps the packed rows on the card rather than staging them again — the wider arena, below.
The launch was four kernels serialized, not one plus copies¶
The phase this page named as "the obvious next thing to instrument" was the 0.27 s of launch: 160
card-calls of 1.7 ms each against the 0.83 ms an isolated call of that shape costs on one device. The
guess was that the per-call allocation and the pageable, synchronous copies were the missing
millisecond. /tmp/probe_launch_split.py priced it by measuring one whole row of four cards three
ways, one change apart, on layer 0's real activation and real arenas:
| A row of four cards | Per row | Per token, 40 layers |
|---|---|---|
| as it was: allocate per call, pageable transfers, D2H drained inside the card loop | 3,633.4 µs | 145.3 ms |
| preallocated, pinned, still one card drained at a time | 3,452.5 µs | 138.1 ms |
| preallocated, every card issued before any card is drained | 1,009.8 µs | 40.4 ms |
The allocation and the pageable transfers were worth 1.05x. The serialization was worth 3.42x, and
3.60x end to end. _launch ran a pageable, synchronous D2H of card 0's partial inside the card
loop, and a pageable D2H cannot return until the kernel that produced it has finished — so the host
could not launch card 1's kernel until card 0 was done, and the four cards were four kernels added up
rather than four kernels in flight. The fix is ordering, and it fits inside the arenas and the pinned
buffers the class already had: no arena growth, no wider arena, no multi-token kernel.
_launch is now issue-then-drain. A per-instance _row_scratch holds one pinned activation, one
pinned weight vector, per-card device copies of both, per-card device index vectors, and per-card
pinned results; the row's route weights are gathered once with index_select into card order so each
card's slice is contiguous; every card's H2D and kernel are issued first, with no drain anywhere in
that loop; then each card's D2H into pinned memory, one torch.cuda.Event recorded per card and
waited on once each. One blocking call per row instead of four blocking calls interleaved with the
launches. The result is clone()d, so a caller that keeps it is not handed a view of a reused buffer.
It is also bit-neutral: probe_launch_split.py's part 3 compares the pipelined row against the
row it replaced and gets exact True, max|d| 0.000e+00 for both the preallocated and the pipelined
versions. The class-level parity numbers are unchanged to the last digit — world=4 1.532% / 2.146% /
1.913% of scale at cosine 0.999267 / 0.999327 / 0.999700, argmax correct on all three rows, and
world=4 against world=1 still never above 5.960e-08.
Re-measured in a real step, warm (/tmp/probe_launch_cost.py --world 4, which also wraps
_take_buffer — no other probe had):
| Phase | Step 1 | Step 2 |
|---|---|---|
| wall | 1.06 s | 1.14 s |
_take_buffer |
0.00 s | 0.00 s |
_stage |
0.30 s | 0.29 s |
_upload |
0.03 s | 0.04 s |
_launch |
0.16 s | 0.15 s |
| everything else | 0.57 s | 0.66 s |
Both passes decode 'The capital of France is Paris.<|end▁of▁sentence|>', and the 0.16 s is the
0.27 s the phase measured before. In the class's own isolation the same change is world=4
17.4 ms → 14.8 ms per row-layer, 0.70 s → 0.59 s per token, and world=1 now measures
21.9 ms, 0.88 s per token. The one-card configuration moves too and by more of its own total,
which is the tell that the change is not about the four cards: its launch phase was the 0.46–0.48 s
of a single card paying a pageable synchronous D2H per row four times over, and that cost is the same
whether one card is behind it or four.
Two things that had to be checked rather than assumed. First, the hypothesis was wrong in an
informative way: pinned-and-reused, the change this work was originally scoped as, is 1.05x of a row
and does not reach the target on its own. Second, a real step's _take_buffer is 0.00 s — on
every decode step and on the cold prefill too — so the wait for the previous upload's DMA is already
satisfied and this page's earlier suspicion that part of the 0.33 s staging figure was really PCIe is
retired, not confirmed. With two pinned buffers the buffer being staged was read by a DMA issued a row
and a launch earlier; a third buffer would buy nothing.
A measurement hazard, stated because it is in the numbers above. The "before" run of
probe_launch_cost.py caught a cold page cache — its pass 1 prefill measured 185.92 s, 167.23 s of it
staging at 0.13 GiB/s, against the 3.92 s and 11.94 GiB/s of the "after" run where the pages were
already resident. So stage is not controlled between the two runs and its 0.30 s comes from the
after run alone; launch and take are the controlled terms, and they are the ones the change is
about. _launch decomposes the same way: of its 0.15 s, one kernel's worth of arithmetic is the
40.4 ms the isolated row costs, and the remaining ~0.11 s is the H2D the kernels wait on — a card's
arena copy is ordered behind that card's previous kernel, so the transfer, unlike the issue, is on the
device's critical path and not the host's.
Where the 1.06 s goes¶
This section used to be an inference — three phases of a 1.3 s step with a tenth left unattributed,
and a closing admission that "the waits inside _take_buffer" were the obvious next thing to
instrument. /tmp/probe_launch_cost.py is that instrument, and the answer is measured:
| Term | Per decode step | Per token |
|---|---|---|
_stage, host page cache → pinned, 4.20 GiB |
0.30 s | 0.30 s |
_launch, four kernels plus the H2D they wait on |
0.15 s | 0.15 s |
_upload, the copy chains enqueued |
0.03 s | 0.03 s |
_take_buffer, the wait for the previous DMA |
0.00 s | 0.00 s |
| the host dense tree, the gate, the shared experts, the head, the layer glue | 0.55–0.67 s | 0.55–0.67 s |
| wall | 1.06–1.14 s |
Three of those need their numbers held apart from the isolated ones, and each is now a measurement
rather than a caveat. _take_buffer is zero on every decode step and on the cold prefill, so the
wait for the previous upload's DMA is always already satisfied; a third pinned buffer would buy
nothing. _upload's 0.03 s is issue and not transfer, which the zero above confirms rather than
assumes — the transfer is not hiding in the buffer handshake. And _launch's 0.15 s is the 1,009.8 µs
row measured above against the 0.83 ms an isolated call of that shape costs, so roughly a quarter of
it is arithmetic and the rest is the pinned H2D each kernel's arena copy is ordered behind.
What that leaves is the honest headline: the host's own dense stack is now the largest single term of a device step, 0.55–0.67 s against the 0.30 s of staging, and it is the same 0.51 s the earlier coarse probe measured from the other direction. Moving it is worth 0.4–0.6 s/token on its own and is still not done.
On one device and serialized, the same arithmetic measures: 2,077.3 µs per layer for one call over all six experts against 3,302.6 µs for four calls of 2/2/1/1 — 83.1 ms against 132.1 ms per token. That was the upper bound the four cards were supposed to beat by overlapping, and it turned out they were not overlapping at all; the 1,009.8 µs row above is what they cost once they do, which is below even the single-call figure because the four device chains run concurrently.
The staging rate, and the number that was wrong¶
The plan's gate was this term, and the figure it had to retire was 0.33 GiB/s, from a probe that
timed ckpt.reader.load(k).pin_memory() per tensor — a fresh cudaHostAlloc and copy, 1,920 times
per token, inside the timed region. A device path allocates its arena once and copy_s into it, and
that is what this class does. Measured, warm:
| Operation | Rate |
|---|---|
copy_ into a pre-allocated pinned arena — what the path does |
11.49 GiB/s |
copy_ between two pinned buffers, the ceiling |
13.77 GiB/s |
pin_memory() on a freshly loaded tensor |
12.45 GiB/s |
reader.load alone, allocate and clone |
8.10 GiB/s |
| H2D, one card | 10.47 GiB/s |
| H2D, four cards at once | 38.56 GiB/s |
| the same staging across four threads and four arenas | regresses: 733 ms against 365 ms |
The staging rate in a real step is 12.3–14.0 GiB/s, which is the isolated copy_ and not the
allocation storm: a decode step stages 40 rows × 6 experts × 17.9 MiB = 4.20 GiB in 0.30–0.34 s
across the runs that measured it. Prefill is the same rate over 200 rows — 21.0 GiB in 1.89–1.94 s,
10.8–11.1 GiB/s. The 0.33 GiB/s figure measured an allocation pattern no device path uses, and an
earlier reading of the host page drew a conclusion from it; the staging model in the plan was right
and no correction was needed.
Threading was measured and regresses — 733 ms against 365 ms — because a 3 MiB copy_ is already at
what one core pulls out of the page cache. The loop here is deliberately single-threaded; the
parallelism that pays is the four links, not four threads.
The staging is a read of /mnt/data3, an SMR disk, so the floor under a cold first token is the
disk: one scattered expert row is 1308.0 ms cold against 11.2 ms warm.
What this does not do yet¶
All three are separate measurements rather than separate opinions.
- Nothing is cached on the device between rows. The arena is the two rows this row needs and it
is refilled every row, so a prefill of
nrows pays 4.20 GiBntimes: the 5-token prefill above stages 21.0 GiB, five times a decode step's traffic, because two tokens that route to the same expert each stage it. A wider arena plusmoe_multi_token_fp4_forward— one slot per distinct expert the batch hit, its tokens contiguous — is the shape that fixes it, and it is a follow-on rather than a knob, because an arena size and an eviction policy only mean something once that measurement exists. - The staging does not overlap the launch. A row is strictly serialized today: the host cannot
stage row
k+1until_launchhas returned for rowk. With the launch at 0.15 s and the staging at 0.30 s, a one-row-deep pipeline — stage rowk+1while rowk's kernels run, drain rowkat the top of rowk+1— is worth up to the launch, and the isolated row puts a ceiling on it: 1,009.8 µs of device work against the 7.3 ms the same row's staging costs, so the device side would be fully hidden. It needs one more generation of the activation, the weights and the partials and it changes the shape of the row loop rather than any of its parts, so it is the next follow-on with its own measurement. - The dense tree is still host code, now the largest single term at 0.55–0.67 s of the 1.06–1.14 s step — worth 0.4–0.6 s/token on its own.
Reproducing¶
# the kernel against the host expert on real activations, the EP4 decomposition, and the
# 2/2/1/1-against-one-call kernel cost -- needs /tmp/v41_activations.pt from probe_capture.py
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_capture.py
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_fp4_parity.py
# the class against expert_forward, and world=4 against world=1 on the same activation
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_device_experts.py --world 4
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_device_experts.py --world 1
# staging, threading, pinning and H2D, each priced on its own
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_stage.py
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_h2d.py
# a whole step, phase by phase, twice through the same prompt so pass 2 is a warm page cache
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_device_cost.py --world 4
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_device_cost.py --world 1
# the same step with `_take_buffer` wrapped as well, which probe_device_cost does not do -- and the
# one that caught a cold page cache in its first pass, so read its `GiB/s` column before its `stage`
/home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_launch_cost.py --world 4
# one row of four cards, three ways, one change apart, and the three checked against each other
PYTHONPATH=. /home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_launch_split.py --layer 0
# the checked-in loop on the device path, which is what the text above is produced by -- the
# `routed experts:` line it prints is the flag's own report that it did not fall back
/home/lvyufeng/miniconda3/envs/deepseek/bin/python -u -m src.models.deepseek_v4_1.generate \
--checkpoint /mnt/data3/DeepSeek-V4.1-Flash --prompt "The capital of France is" \
--max-new-tokens 4 --expert-device cuda --expert-world 4
/home/lvyufeng/miniconda3/envs/deepseek/bin/python -u -m src.models.deepseek_v4_1.generate \
--checkpoint /mnt/data3/DeepSeek-V4.1-Flash --prompt "The capital of France is" \
--max-new-tokens 4 --expert-device cuda --expert-world 1
# the same flags off, so the host path and the default -- this is what the whole-request comparison
# above is against, and the check that this work did not move it
/home/lvyufeng/miniconda3/envs/deepseek/bin/python -u -m src.models.deepseek_v4_1.generate \
--checkpoint /mnt/data3/DeepSeek-V4.1-Flash --prompt "The capital of France is" --max-new-tokens 4
# the distribution behind the first three tokens, on either path -- this is what says the one token
# the two disagree about is a half-logit tie rather than a wrong answer
PYTHONPATH=. /home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_first_tokens.py \
--device cuda --world 4
PYTHONPATH=. /home/lvyufeng/miniconda3/envs/deepseek/bin/python /tmp/probe_first_tokens.py
Each takes about 90 s, most of it the ~70 s load. The three probes that touch a card want all four
of them free. probe_stage.py and probe_h2d.py report whichever page-cache state they find, and
probe_device_cost.py prints a GiB/s column so the same is true of it and readable rather than
silent.
The host path stays the default: the device path needs --expert-device (or
DEEPSEEK_V41_EXPERT_DEVICE) and falls back to CheckpointRoutedExperts with one line on progress
if the extension is unloadable, the card is missing or the checkpoint's expert is laid out the other
way round.