Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/design/sm70_dflash2_nvfp4_prefill_promotion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# SM70 DFlash2 NVFP4 Prefill Promotion

## Scope

Promote the existing bounded QPN2-packed NVFP4 prefill operator for the
quality-audited Qwen3.8-27B DFlash2 contract. The change does not alter kernel
arithmetic, the DFlash2 draft model, target verification, sampling, attention,
or prefix-cache semantics.

Integration base: `onecat/main` at
`62ad1e02693f4c857f3b7547cef1860ee54e8053`.

## Retained evidence

No target-only baseline is rerun in this campaign. Historical evidence is:

| Contract | 32K prompt tok/s | 64K prompt tok/s |
| --- | ---: | ---: |
| DFlash2 before the D256 closure | 3125.12 | 2588.53 |
| DFlash2 with the D256 sidecar | 3476.53 | 3103.02 |
| DFlash2 with D256 and QPN2-packed prefill | 3959.14 | 3450.29 |

The last row used the same NVFP4 target, official BF16 DFlash2 draft, TP4
V100, FP8 E5M2 target KV, FP16 draft KV, prefix cache, Mamba alignment, and
CUDA Graph decode contract. All retained requests were uncorrupted and kept
the same first-token hash. Raw artifacts are under
`/data/minimax-h3/task-cache/v100-dflash2-prefill-32k64k-20260827/`.

The often-quoted 5170.96 tok/s exact-8K and 2438.89 tok/s 256K results are
FP8 target-only contracts. They remain useful upper-bound references but are
not relabeled as NVFP4 DFlash2 measurements.

## Dispatch change

The QPN2 decode and bounded prefill implementations are already in main but
both environment gates default to off. They now resolve to on only when all of
the following hold:

- speculative method is `dflash` with seven draft tokens and the checkpoint
exposes the DFlash2 selector contract (`selector_top_k=16`);
- PP1/TP4, `max_num_seqs=1`, and no DBO or microbatching;
- the existing exact Qwen3.8 dense NVFP4 layer-shape gate passes.

Explicit `VLLM_SM70_NVFP4_QPN2=0` and
`VLLM_SM70_NVFP4_QPN2_PREFILL=0` remain hard rollbacks. Other speculative
methods, target-only service, different TP, and concurrent service retain the
existing route.

## Promotion gates

- focused resolver, shape, zero-copy-layout, and dispatch tests: 5 passed;
- latest-main DFlash2 route hit plus cold 32K/64K throughput: passed;
- unchanged short decode path, acceptance behavior, and structured-output
health before marking the Draft PR ready.

## Latest-main remote validation

PR commit `8aec0f5e4419f44c70232edd49b68058b9fa224a` was exercised on the
four-V100 deployment host with both `VLLM_SM70_NVFP4_QPN2` and
`VLLM_SM70_NVFP4_QPN2_PREFILL` unset. The runtime contract was the official
BF16 DFlash2 draft with seven speculative tokens, NVFP4 target, TP4, FP8 E5M2
target KV, FP16 draft KV, 256K maximum length, 4096 batched-token budget,
prefix cache plus Mamba alignment, probabilistic draft sampling, and CUDA
Graph decode.

The run hit all required routes: automatic QPN2 decode, automatic
QPN2-packed bounded prefill for `M>=1024`, the fixed-SHA D256 sidecar, direct
paged chunked prefill, the FP8 E5M2 prefill bridge, and the context-only
DFlash2 chunk path.

| Case | Mean request wall | Request-wall tok/s | Pure-prefill tok/s |
| --- | ---: | ---: | ---: |
| 32K | 8.0618 s | 4064.62 | 4069.25 |
| 64K | 18.3891 s | 3563.85 | 3566.94 |

Relative to the retained pre-closure DFlash2 measurements, request-wall
throughput improved by 30.1% at 32K and 37.7% at 64K. Relative to the retained
QPN2-packed q4096 run, the remote result was 5.5% and 5.2% faster,
respectively; this cross-host delta is recorded but is not used as a kernel
claim.

All six measured requests reported `is_corrupted=false`, returned token ID
`248046`, and retained output-token SHA256
`54363ddee68f4a5db81c9d37e5fb738d28f5b67dc7f725ad7333172b1ea157da`.
That hash is identical to the retained pre-closure, D256-only, and
QPN2-packed runs. The operators themselves were not changed by this PR; its
only source change is strict-contract default routing with explicit rollback.

The raw result and log are retained at
`/data/minimax-h3/task-cache/v100-dflash2-nvfp4-prefill-promotion-20260829/remote-four-v100/`.
Their SHA256 values are `e6d6033492cc3c5af37dc90219f07c33eb1559907743ecf7355c13c861606beb`
and `949531b1bf03a2921ed664a95f19e0af0adc7b842fd7183087798cff5984e654`,
respectively.

The M<=8 QPN2 decode route was already enabled in the merged DFlash2 quality
audit. That audit passed 24/24 structured API cases, 5/5 long alternating
prefix-state cases, three-seed MBPP/HumanEval/LiveCodeBench gates, and a
target-only versus DFlash2 PPL comparison of `5.4993116/5.4993622`. This PR
does not change that operator or the verifier/sampler path. Its newly promoted
large-M route reuses the previously bitwise-equal packed prefill operator, and
the latest-main long-prefill run preserved the retained output hash.
91 changes: 91 additions & 0 deletions tests/quantization/test_sm70_nvfp4_qpn2.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,97 @@ def test_nvfp4_qpn2_is_default_off_with_explicit_on(monkeypatch):
envs.disable_envs_cache()


def _runtime_config(
*, method="dflash", draft_tokens=7, selector_top_k=16, tp=4, max_num_seqs=1
):
return SimpleNamespace(
parallel_config=SimpleNamespace(
pipeline_parallel_size=1,
tensor_parallel_size=tp,
enable_dbo=False,
ubatch_size=0,
),
scheduler_config=SimpleNamespace(max_num_seqs=max_num_seqs),
speculative_config=(
None
if method is None
else SimpleNamespace(
method=method,
num_speculative_tokens=draft_tokens,
draft_model_config=SimpleNamespace(
hf_config=SimpleNamespace(
dflash_config={"selector_top_k": selector_top_k}
)
),
)
),
)


def test_nvfp4_qpn2_dflash2_default_contract(monkeypatch):
monkeypatch.delenv("VLLM_SM70_NVFP4_QPN2", raising=False)
monkeypatch.delenv("VLLM_SM70_NVFP4_QPN2_PREFILL", raising=False)
monkeypatch.setattr(
nvfp4_scheme, "get_current_vllm_config", lambda: _runtime_config()
)
envs.disable_envs_cache()
try:
assert nvfp4_scheme._sm70_nvfp4_qpn2_enabled()
assert nvfp4_scheme._sm70_nvfp4_qpn2_prefill_enabled()

monkeypatch.setattr(
nvfp4_scheme,
"get_current_vllm_config",
lambda: _runtime_config(method=None),
)
assert not nvfp4_scheme._sm70_nvfp4_qpn2_enabled()
assert not nvfp4_scheme._sm70_nvfp4_qpn2_prefill_enabled()

monkeypatch.setattr(
nvfp4_scheme,
"get_current_vllm_config",
lambda: _runtime_config(draft_tokens=5),
)
assert not nvfp4_scheme._sm70_nvfp4_qpn2_enabled()

monkeypatch.setattr(
nvfp4_scheme,
"get_current_vllm_config",
lambda: _runtime_config(selector_top_k=0),
)
assert not nvfp4_scheme._sm70_nvfp4_qpn2_enabled()

monkeypatch.setattr(
nvfp4_scheme,
"get_current_vllm_config",
lambda: _runtime_config(tp=2),
)
assert not nvfp4_scheme._sm70_nvfp4_qpn2_prefill_enabled()

monkeypatch.setattr(
nvfp4_scheme,
"get_current_vllm_config",
lambda: _runtime_config(max_num_seqs=2),
)
assert not nvfp4_scheme._sm70_nvfp4_qpn2_prefill_enabled()
finally:
envs.disable_envs_cache()


def test_nvfp4_qpn2_dflash2_explicit_rollback(monkeypatch):
monkeypatch.setattr(
nvfp4_scheme, "get_current_vllm_config", lambda: _runtime_config()
)
monkeypatch.setenv("VLLM_SM70_NVFP4_QPN2", "0")
monkeypatch.setenv("VLLM_SM70_NVFP4_QPN2_PREFILL", "0")
envs.disable_envs_cache()
try:
assert not nvfp4_scheme._sm70_nvfp4_qpn2_enabled()
assert not nvfp4_scheme._sm70_nvfp4_qpn2_prefill_enabled()
finally:
envs.disable_envs_cache()


def test_nvfp4_qpn2_shape_gate_is_exact_tp4():
layer = SimpleNamespace(
tp_size=4,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Callable
import os
from collections.abc import Callable, Mapping

import torch
from torch.nn.parameter import Parameter
Expand Down Expand Up @@ -51,6 +52,46 @@ def _is_sm70_nvfp4_qpn4_runtime_contract() -> bool:
return max_num_seqs == 1 and speculative_config is None


def _is_sm70_dflash2_nvfp4_qpn2_runtime_contract() -> bool:
"""Admit the quality-audited single-request DFlash2 TP4 route."""
vllm_config = get_current_vllm_config()
parallel_config = vllm_config.parallel_config
scheduler_config = vllm_config.scheduler_config
speculative_config = getattr(vllm_config, "speculative_config", None)
draft_model_config = getattr(speculative_config, "draft_model_config", None)
draft_hf_config = getattr(draft_model_config, "hf_config", None)
dflash_config = getattr(draft_hf_config, "dflash_config", None) or {}
selector_top_k = (
int(dflash_config.get("selector_top_k", 0) or 0)
if isinstance(dflash_config, Mapping)
else 0
)
return bool(
getattr(speculative_config, "method", None) == "dflash"
and int(getattr(speculative_config, "num_speculative_tokens", 0) or 0) == 7
and selector_top_k == 16
and parallel_config.pipeline_parallel_size == 1
and parallel_config.tensor_parallel_size == 4
and scheduler_config.max_num_seqs == 1
and not getattr(parallel_config, "enable_dbo", False)
and int(getattr(parallel_config, "ubatch_size", 0) or 0) <= 1
)


def _sm70_nvfp4_qpn2_enabled() -> bool:
"""Use the accepted DFlash2 default while retaining an explicit rollback."""
if os.getenv("VLLM_SM70_NVFP4_QPN2") is not None:
return envs.VLLM_SM70_NVFP4_QPN2
return _is_sm70_dflash2_nvfp4_qpn2_runtime_contract()


def _sm70_nvfp4_qpn2_prefill_enabled() -> bool:
"""Promote the bitwise-equal bounded prefill route only with DFlash2."""
if os.getenv("VLLM_SM70_NVFP4_QPN2_PREFILL") is not None:
return envs.VLLM_SM70_NVFP4_QPN2_PREFILL
return _is_sm70_dflash2_nvfp4_qpn2_runtime_contract()


_SM70_NVFP4_QPN4_REQUIRED_OPS = (
"nvfp4_qpn4_prepare_sm70",
"nvfp4_qpn4_prepare_scale_code_sm70",
Expand Down Expand Up @@ -300,7 +341,7 @@ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
"Insufficient memory for the bounded SM70 NVFP4 QPN4 "
"prefill workspace; retaining TurboMind."
)
use_qpn2 = bool(envs.VLLM_SM70_NVFP4_QPN2 and _is_qpn2_layer(layer))
use_qpn2 = bool(_sm70_nvfp4_qpn2_enabled() and _is_qpn2_layer(layer))
if use_qpn2:
missing_ops = _missing_qpn2_ops()
if missing_ops:
Expand All @@ -315,7 +356,7 @@ def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
)
qpn2_global_scale = float(layer.weight_global_scale.item())
qpn2_prefill_workspace = None
if envs.VLLM_SM70_NVFP4_QPN2_PREFILL:
if _sm70_nvfp4_qpn2_prefill_enabled():
missing_prefill_ops = _missing_qpn2_prefill_ops()
if missing_prefill_ops:
logger.warning_once(
Expand Down
Loading