Skip to content

sm12x: hoist the E8M0 block-scale upcast out of the FP8 GEMM hot path - #38

Merged
jasl merged 1 commit into
jasl:codex/ds4-sm120-min-enablefrom
alexbi29:sm12x/hoist-e8m0-block-scale
Aug 3, 2026
Merged

sm12x: hoist the E8M0 block-scale upcast out of the FP8 GEMM hot path#38
jasl merged 1 commit into
jasl:codex/ds4-sm120-min-enablefrom
alexbi29:sm12x/hoist-e8m0-block-scale

Conversation

@alexbi29

@alexbi29 alexbi29 commented Aug 3, 2026

Copy link
Copy Markdown

w8a8_triton_block_scaled_mm() upcasts both scale tensors from float8_e8m0fnu
to fp32 on every call. As is per-token and genuinely dynamic, but Bs is the
weight scale — static after loading, re-derived on every forward.

DeepSeek-V4-Flash stores every dense FP8 weight scale as F8_E8M0 (wq_a,
wkv, wq_b, wo_a, wo_b, shared experts), and on SM12x these linears take
the Triton fallback rather than DeepGEMM, so the upcast runs ~256x per decode
step. Each one is two kernels (a uint8->int32 copy and a << 23 shift) plus
an allocation.

Fix

Cache the fp32 copy once in process_weights_after_loading and hand it to the
kernel from apply_weights. E8M0 is exponent-only, so the upcast is a pure bit
shift and the cached tensor is bit-identical to what the kernel computed per
call.

Two deliberate scoping decisions:

  • The original E8M0 parameter is left in place. DeepSeek-V4's fused o_proj
    and DSpark kernels read layer.weight_scale{,_inv} directly
    (models/deepseek_v4/nvidia/ops/o_proj.py:71, nvidia/dspark.py:268-282), so
    replacing it outright would break them. Cost is ~1.3 MB of extra weight memory.
  • Opt-in via prefers_fp32_block_scale, set only on
    TritonFp8BlockScaledMMKernel. DeepGEMM consumes ue8m0 natively and must keep
    the original tensor.

Measured

2x RTX PRO 6000 Blackwell (SM120), DeepSeek-V4-Flash-0731, TP=2 + EP, DSpark k=5.

Kernel counts per 25 decode steps (torch profiler, rank0):

kernel before after
unrolled_elementwise<direct_copy_kernel_cuda> 8850 calls / 23.68 ms 2839 / 6.71 ms
vectorized_elementwise<BUnaryFunctor<int,...>> (the << 23) 7550 calls / 13.94 ms absent

13,561 launches and 30.91 ms of GPU time removed per 25 steps (1.24 ms/step).

End-to-end decode, fixed work (96 requests x 600 tokens, concurrency 12), 6
samples per arm across 2 reload cycles:

arm max median
off 2305.3 2264.8 tok/s
on 2327.5 2293.2 tok/s
+1.0% +1.3%

Prefill (24 requests of ~8K tokens, concurrency 6): +0.5%, as expected — the
upcast is negligible beside ~539 us GEMMs.

w8a8_triton_block_scaled_mm() upcasts both scale tensors from
float8_e8m0fnu to fp32 on every call. `As` is per-token and genuinely
dynamic, but `Bs` is the weight scale: static after loading, re-derived
on every forward.

DeepSeek-V4-Flash stores every dense FP8 weight scale as F8_E8M0
(wq_a, wkv, wq_b, wo_a, wo_b, shared experts), and on SM12x these
linears take the Triton fallback rather than DeepGEMM, so the upcast
runs ~256 times per decode step. Each one is two kernels (a uint8->int32
copy and a `<< 23` shift) plus an allocation.

Cache the fp32 copy once in process_weights_after_loading and hand it to
the kernel from apply_weights. E8M0 is exponent-only, so the upcast is a
pure bit shift and the cached tensor is bit-identical to what the kernel
computed per call.

The original E8M0 parameter is deliberately left in place: DeepSeek-V4's
fused o_proj and DSpark kernels read layer.weight_scale{,_inv} directly
(models/deepseek_v4/nvidia/ops/o_proj.py, nvidia/dspark.py), so replacing
it outright would break them. Opt-in via prefers_fp32_block_scale, set
only on TritonFp8BlockScaledMMKernel — DeepGEMM consumes ue8m0 natively
and must keep the original.

Measured on 2x RTX PRO 6000 Blackwell (SM120), DeepSeek-V4-Flash-0731,
TP=2 + EP, DSpark k=5.

Kernel counts, per 25 decode steps (torch profiler, rank0):

  unrolled_elementwise<direct_copy_kernel_cuda>  8850 -> 2839 calls
                                                23.68 -> 6.71 ms
  vectorized_elementwise<BUnaryFunctor<int,...>> 7550 -> 0 calls
                                                13.94 -> 0 ms

13,561 launches and 30.91 ms of GPU time removed per 25 steps
(1.24 ms/step).

End-to-end decode, fixed work (96 requests x 600 tokens, concurrency 12),
6 samples per arm across 2 reload cycles:

  off: max 2305.3  median 2264.8 tok/s
  on:  max 2327.5  median 2293.2 tok/s
       +1.0% (max)      +1.3% (median)

Prefill (24 requests of ~8K tokens, concurrency 6): +0.5%, as expected —
the upcast is negligible beside ~539 us GEMMs.
@jasl
jasl merged commit 0286141 into jasl:codex/ds4-sm120-min-enable Aug 3, 2026
3 of 4 checks passed
@jasl

jasl commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Merged as 0286141 — thank you.

Verified against the tree before merging rather than from the description:

  • _upcast_e8m0_to_fp32 is view(uint8) → int32 → << 23 → view(float32) — a pure
    bit operation with no rounding, so the cached copy really is bit-identical and the
    cache cannot move numerics. That is what makes this safe to do once at load time.
  • fp8_utils.py:943,970 confirm the upcast runs per call on Bs, so this removes
    work rather than relocating it.
  • dspark.py:268-282 reads wo_b/wo_a weight_scale_inv and weight_scale
    directly.

That last point is the one I want to call out. Leaving the original E8M0 parameter in
place is not conservatism — it is required, and replacing it would have broken
DSpark at startup. That is exactly how PR #33 took down a contributor's 4-node cluster:
a rename dropped during a merge, on a path our gates cannot execute because DSpark is
dead code under method="mtp". You found the constraint by reading the consumers and
scoped around it, and documented why. That is the part that made this a five-minute
review instead of an afternoon.

On the numbers: the one carrying the merge is the launch count, not the throughput.
13,561 launches and 30.91 ms of GPU time removed per 25 decode steps from a profiler is
a countable fact. The +1.0–1.3% decode is below what our benchy can resolve — our
measured within-build CV for tg128 is 4–8%, so a 1% claim is invisible to us either
way. We are not vouching for that figure and do not need to; removing 13.5k launches
per 25 steps is self-evidently not a regression.

Same note as on #37: our CI hardware is 2× GB10 (SM121), so we cannot reproduce your
RTX PRO 6000 measurements. We do have an RTX PRO 6000 box of our own that is currently
out of service; when it is back we can start giving your SM120 numbers independent
confirmation instead of taking them on trust.

Also picked up your DSv4 illegal-memory-access report on vllm-project#41834
that one turned out to be more serious for this branch than the sampler chain you
traced, and the fix is in. Replying there separately.

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.

2 participants