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
278 changes: 275 additions & 3 deletions .agents/specs/nemotron-h-model.md

Large diffs are not rendered by default.

774 changes: 774 additions & 0 deletions src/vllm/model_executor/layers/quantization/modelopt_mixed_precision.h

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ vllm_cpp_add_test(test_nvfp4_dequant vllm/test_nvfp4_dequant.cpp)
vllm_cpp_add_test(test_awq_gptq_dequant vllm/test_awq_gptq_dequant.cpp)
vllm_cpp_add_test(test_mxfp4_dequant vllm/test_mxfp4_dequant.cpp)
vllm_cpp_add_test(test_ct_nvfp4_emulation vllm/test_ct_nvfp4_emulation.cpp)
# MODEL-TEXT-nemotron-h W1 (#517): the ModelOpt MIXED_PRECISION per-module
# quant-algo resolver. Two arms, deliberately two BINARIES: the curated fixture
# arm is always-on, and the exhaustive real-config arm exits 77 (Skipped) when
# the 30B checkpoint is not staged. Folding them into one target would let the
# skip swallow the always-on gate as well.
#
# The resolver header is INTERNAL (src/, not include/) until a loader consumes
# it, so both targets take ${CMAKE_SOURCE_DIR}/src on the include path — the
# same arrangement test_linear_method and test_model_registry already use.
vllm_cpp_add_test(test_modelopt_mixed_precision
vllm/model_executor/layers/quantization/test_modelopt_mixed_precision.cpp)
target_include_directories(test_modelopt_mixed_precision PRIVATE
${CMAKE_SOURCE_DIR}/src)
target_compile_definitions(test_modelopt_mixed_precision PRIVATE
MODELOPT_MIXED_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures/modelopt_mixed_precision")
vllm_cpp_add_test(test_modelopt_mixed_precision_checkpoint
vllm/model_executor/layers/quantization/test_modelopt_mixed_precision_checkpoint.cpp)
# tests/parity as well as src/: the exhaustive arm resolves its checkpoint
# through the SINGLE pinned parity::Nemotron35LightningSnapshot() rather than
# joining $CHECKPOINT_ROOT by hand, so that one resolver owns the revision pin
# for both env spellings (#517 LOW-3) -- the same arrangement test_qwen36_weights
# uses for the 35B pin.
target_include_directories(test_modelopt_mixed_precision_checkpoint PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/tests/parity)
vllm_cpp_add_test(test_gguf_dequant vllm/test_gguf_dequant.cpp)
vllm_cpp_add_test(test_gguf_nvfp4 vllm/test_gguf_nvfp4.cpp)
# gguf_nvfp4_goldens.inc lives next to the test source.
Expand Down
107 changes: 107 additions & 0 deletions tests/fixtures/modelopt_mixed_precision/curated_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{
"_derivation": {
"purpose": "Curated ModelOpt MIXED_PRECISION fixture for the per-module quant-algo resolver (row MODEL-TEXT-nemotron-h-nemotron-hfor-causal-lm W1, issue #517).",
"derived_from": {
"checkpoint": "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4",
"revision": "29f2d1746d8f41e316523194b19018707749b1b1",
"file": "config.json -> quantization_config",
"real_shape": "5981 quantized_layers (5935 W4A16_NVFP4 group_size 16, 46 FP8), 72 ignore entries, kv_cache_scheme fp8"
},
"real_entries_copied_verbatim": [
"producer / quant_method / quant_algo / kv_cache_scheme are the real values",
"backbone.layers.0.mixer.in_proj -> FP8 (real, mamba in_proj)",
"backbone.layers.0.mixer.out_proj -> FP8 (real, mamba out_proj)",
"backbone.layers.1.mixer.experts.{0,1}.{up,down}_proj -> W4A16_NVFP4 g16 (real; the real map has 128 experts per MoE layer, 2 are kept here)",
"backbone.layers.1.mixer.shared_experts.{up,down}_proj -> W4A16_NVFP4 g16 (real)",
"lm_head -> W4A16_NVFP4 g16 (real, and really a BARE key with no 'model.' prefix)",
"ignore: backbone.embeddings, backbone.layers.0.mixer.conv1d, backbone.layers.1.mixer.gate, backbone.layers.{5,12}.mixer.{q,k,v,o}_proj, mtp* (all real, verbatim)"
],
"synthetic_entries_and_why": [
"The real checkpoint resolves ALL 5981 entries by the DIRECT strategy alone (verified: strategy histogram {direct: 5981}). It therefore cannot exercise the packed/fused, .experts-special-case, or fused_projection_shards strategies, nor a shard disagreement, nor an unknown algo. Those five paths exist in the pinned upstream (modelopt.py:2412-2487) and each gets a synthetic 'synthetic.*' entry below.",
"synthetic.layers.0.moe.{up,down}_proj -> the '.experts' special case: ModelOpt lists '...moe.up_proj' while a FusedMoE layer's prefix is '...moe.experts' (upstream comment at modelopt.py:2455-2457).",
"synthetic.layers.1.self_attn.{q,k,v}_proj (all FP8) -> packed_modules_mapping strategy 2 agreement.",
"synthetic.layers.2.self_attn.{q,k,v}_proj (q,v FP8 / k W4A16_NVFP4) -> packed strategy 2 DISAGREEMENT, which upstream RAISES on.",
"synthetic.layers.3.mlp.{gate,up}_proj (both FP8) -> fused_projection_shards fallback (strategy 5) agreement, reached with NO packed mapping registered.",
"synthetic.layers.4.mlp.{gate,up}_proj (gate FP8 / up W4A16_NVFP4) -> fused_projection_shards DISAGREEMENT, which upstream RAISES on.",
"synthetic.layers.5.mixer.in_proj -> quant_algo 'AWQ_LITE', an algorithm ModelOpt never emits: the resolver must REFUSE IT BY NAME rather than fall through to a supported path or to unquantized.",
"synthetic.layers.6.mixer.in_proj -> quant_algo 'FP8_PB_WO', a REAL ModelOpt algo name (upstream QUANT_ALGOS, modelopt.py:105-120) that the MIXED_PRECISION consumer has no branch for. Upstream silently returns UnquantizedLinearMethod for it; we refuse by name (spec stop condition: a silent fallback is invisible to a token gate).",
"language_model.model.layers.0.mlp.down_proj AND model.language_model.layers.9.mlp.up_proj -> the language_model/model prefix SWAP in _quantized_layer_prefix_candidates (modelopt.py:2491-2505), one entry per DIRECTION. One direction alone leaves the other branch untested (my own mutation run proved it).",
"ignore: legacy_substr.mixer -> the pre-ModelOpt-0.39 SUBSTRING exclusion rule (modelopt.py:165-174), which no exact match and no wildcard can reach.",
"ignore: synthetic.layers.7.self_attn.q_proj ONLY (k/v absent) -> is_layer_skipped's partial-fused-exclusion RAISE (quant_utils.py:549-556).",
"synthetic.layers.8.moe.{a,b}_proj (a FP8 / b W4A16_NVFP4) -> strategies 3 and 4 return the FIRST matching child in map order (modelopt.py:2450, :2458). A parent whose children AGREE cannot tell first from last: synthetic.layers.2.self_attn is q/k/v = FP8/W4A16/FP8, so its first and its last child are both FP8 and 'return the last match' survives it. These two disagree AND sort the same way under insertion and lexicographic order (a < b), so they isolate first-vs-last from the ordered_json question that synthetic.layers.2.self_attn pins.",
"language_model.model.layers.5.self_attn.q_proj (FP8) AND model.language_model.layers.5.self_attn.k_proj (W4A16_NVFP4) -> the strategy-2 / strategy-5 ASYMMETRY (modelopt.py:2429-2447 vs :2463-2486). Strategy 2 accumulates ONE algo set across ALL base prefix candidates; strategy 5 rebuilds it per candidate. Every other fused fixture entry has a single prefix candidate, where the two are indistinguishable. Split across the two language_model spellings, the union is {FP8, W4A16_NVFP4} and RAISES, while a per-candidate rebuild would return FP8 from the first spelling and never see the second."
]
},
"architectures": ["NemotronHForCausalLM"],
"model_type": "nemotron_h",
"quantization_config": {
"producer": "modelopt 0.44.0rc5",
"quant_method": "modelopt",
"quant_algo": "MIXED_PRECISION",
"kv_cache_scheme": {
"dynamic": false,
"num_bits": 8,
"type": "float"
},
"ignore": [
"backbone.embeddings",
"backbone.layers.0.mixer.conv1d",
"backbone.layers.1.mixer.gate",
"backbone.layers.5.mixer.q_proj",
"backbone.layers.5.mixer.k_proj",
"backbone.layers.5.mixer.v_proj",
"backbone.layers.5.mixer.o_proj",
"backbone.layers.12.mixer.q_proj",
"backbone.layers.12.mixer.k_proj",
"backbone.layers.12.mixer.v_proj",
"backbone.layers.12.mixer.o_proj",
"mtp*",
"synthetic.layers.7.self_attn.q_proj",
"legacy_substr.mixer"
],
"quantized_layers": {
"backbone.layers.0.mixer.in_proj": { "quant_algo": "FP8" },
"backbone.layers.0.mixer.out_proj": { "quant_algo": "FP8" },
"backbone.layers.1.mixer.experts.0.up_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"backbone.layers.1.mixer.experts.0.down_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"backbone.layers.1.mixer.experts.1.up_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"backbone.layers.1.mixer.experts.1.down_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"backbone.layers.1.mixer.shared_experts.up_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"backbone.layers.1.mixer.shared_experts.down_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"lm_head": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },

"synthetic.layers.0.moe.up_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"synthetic.layers.0.moe.down_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },

"synthetic.layers.1.self_attn.q_proj": { "quant_algo": "FP8" },
"synthetic.layers.1.self_attn.k_proj": { "quant_algo": "FP8" },
"synthetic.layers.1.self_attn.v_proj": { "quant_algo": "FP8" },

"synthetic.layers.2.self_attn.q_proj": { "quant_algo": "FP8" },
"synthetic.layers.2.self_attn.k_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },
"synthetic.layers.2.self_attn.v_proj": { "quant_algo": "FP8" },

"synthetic.layers.3.mlp.gate_proj": { "quant_algo": "FP8" },
"synthetic.layers.3.mlp.up_proj": { "quant_algo": "FP8" },

"synthetic.layers.4.mlp.gate_proj": { "quant_algo": "FP8" },
"synthetic.layers.4.mlp.up_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },

"synthetic.layers.5.mixer.in_proj": { "quant_algo": "AWQ_LITE" },
"synthetic.layers.6.mixer.in_proj": { "quant_algo": "FP8_PB_WO" },

"synthetic.layers.7.self_attn.q_proj": { "quant_algo": "FP8" },
"synthetic.layers.7.self_attn.k_proj": { "quant_algo": "FP8" },
"synthetic.layers.7.self_attn.v_proj": { "quant_algo": "FP8" },

"language_model.model.layers.0.mlp.down_proj": { "quant_algo": "FP8" },
"model.language_model.layers.9.mlp.up_proj": { "quant_algo": "FP8" },

"synthetic.layers.8.moe.a_proj": { "quant_algo": "FP8" },
"synthetic.layers.8.moe.b_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 },

"language_model.model.layers.5.self_attn.q_proj": { "quant_algo": "FP8" },
"model.language_model.layers.5.self_attn.k_proj": { "quant_algo": "W4A16_NVFP4", "group_size": 16 }
}
}
}
66 changes: 59 additions & 7 deletions tests/parity/hf_snapshot.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ inline constexpr const char* kQwen27NvfP4Revision =
inline constexpr const char* kNemotron35LightningNvfP4Revision =
"29f2d1746d8f41e316523194b19018707749b1b1";

// The directory this checkpoint is staged under inside `$CHECKPOINT_ROOT`. It
// is a `hf download --local-dir` tree, not an HF cache repo, so the revision
// does not appear in the PATH the way `snapshots/<rev>/` does -- see
// Nemotron35LightningSnapshot below for where it does appear.
inline constexpr const char* kNemotron35LightningLocalDirName =
"nemotron-3.5-lightning-30b-nvfp4";

// Snapshot directory for `<repo>` at `revision`, or "" when it is not cached
// (the caller then emits its loud SKIP). `env_override`, when set and non-empty,
// names an explicit snapshot directory for a deliberate different-checkpoint
Expand All @@ -65,14 +72,59 @@ inline std::string HfSnapshot(const char* repo_dir, const char* revision,
return snap.string();
}

// The Nemotron-3.5-Lightning gate model (#517). Unlike the Qwen pins above,
// this one is NOT in the HF cache: it is staged on the NAS as a `local_dir`
// snapshot at `$CHECKPOINT_ROOT/nemotron-3.5-lightning-30b-nvfp4`, so there is
// no `models--org--name/snapshots/<rev>` layout to resolve. The env override is
// therefore the ONLY reachable path, and the cache spelling below exists so the
// revision still names what the golden belongs to. Absent env var => "" => the
// caller emits its loud SKIP, which is the intended behavior off the gate host.
// The Nemotron-3.5-Lightning gate model (#517), and the ONE resolver for it.
//
// Unlike the Qwen pins above, this one is NOT in the HF cache: it is staged on
// the NAS as a `hf download --local-dir` tree at
// `$CHECKPOINT_ROOT/nemotron-3.5-lightning-30b-nvfp4`, so there is no
// `models--org--name/snapshots/<rev>` directory whose NAME carries the revision.
//
// LOW-3 (#517). Two env vars used to reach this same checkpoint --
// `VT_NEMOTRON35_SNAPSHOT` here and `CHECKPOINT_ROOT` in
// test_modelopt_mixed_precision_checkpoint.cpp -- and NEITHER enforced the
// revision: the cache spelling above is unreachable for a `local_dir` tree, and
// an env override is deliberately not revision-checked. So the pin named the
// revision the goldens belong to and could not refuse a different one, which is
// the exact failure `kQwen27NvfP4Revision` exists because of. Both spellings now
// resolve HERE, and the `local_dir` layout does record its revision, just not in
// the path: `hf download --local-dir` writes a per-revision file manifest at
// `<dir>/.cache/huggingface/trees/<revision>.json`. That is what is checked.
//
// Resolution order, in the order the code checks it, and why:
//
// 1. `VT_NEMOTRON35_SNAPSHOT`, when set and non-empty -> the explicit-directory
// escape `HfSnapshot` documents, with its semantics UNCHANGED, including
// that a set-but-wrong override refuses rather than falling back. First, so
// that setting it OVERRIDES `CHECKPOINT_ROOT` rather than racing it.
// 2. Otherwise `CHECKPOINT_ROOT` -> `<root>/<kNemotron35LightningLocalDirName>`,
// and the revision manifest MUST be present. This is the DEFAULT path every
// gate takes, so it is the one that has to carry the pin: a re-download of
// the same repo name lands a different revision under the identical path,
// and a gate that cannot tell would substitute it silently. Missing
// manifest => "" => the caller's loud skip, never a substitution.
// 3. Otherwise the ordinary HF cache layout, for a host that fetched it that
// way.
//
// Only (2) is revision-gated, deliberately: naming ONE directory outright is
// the deliberate different-checkpoint run the override exists for, while naming
// a ROOT is not.
//
// Absent both env vars => "" => the caller emits its loud SKIP, which is the
// intended behavior off the gate host.
inline std::string Nemotron35LightningSnapshot() {
namespace fs = std::filesystem;
std::error_code ec;
const char* over = std::getenv("VT_NEMOTRON35_SNAPSHOT");
const char* root = std::getenv("CHECKPOINT_ROOT");
if ((over == nullptr || *over == '\0') && root != nullptr && *root != '\0') {
const fs::path dir = fs::path(root) / kNemotron35LightningLocalDirName;
const fs::path tree = dir / ".cache/huggingface/trees" /
(std::string(kNemotron35LightningNvfP4Revision) +
".json");
if (!fs::exists(dir / "config.json", ec)) return "";
if (!fs::exists(tree, ec)) return "";
return dir.string();
}
return HfSnapshot("models--nvidia--NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4",
kNemotron35LightningNvfP4Revision,
"VT_NEMOTRON35_SNAPSHOT");
Expand Down
Loading
Loading