Repository navigation
Add a NemotronH aggressive-mse NVFP4 PTQ recipe #2703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
46c33a8
5541796
669db85
2b177e3
c306a93
b874d7b
5b6fe12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,11 +28,25 @@ | |
| from __future__ import annotations | ||
|
|
||
| import contextlib | ||
| import inspect | ||
| from typing import Any | ||
|
|
||
| import torch | ||
|
|
||
|
|
||
| def _accepted_kwargs(fn, kwargs: dict) -> dict: | ||
| """Drop keys ``fn`` cannot accept, so a wrapper with a narrow forward signature does not | ||
| trade one ``AttributeError`` for a ``TypeError``. Callables taking ``**kwargs`` are left alone. | ||
| """ | ||
| try: | ||
| params = inspect.signature(fn).parameters | ||
| except (TypeError, ValueError): | ||
| return kwargs | ||
| if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()): | ||
| return kwargs | ||
| return {k: v for k, v in kwargs.items() if k in params} | ||
|
|
||
|
|
||
| def safe_nemotron_vl_forward(full_model: torch.nn.Module, batch: dict[str, Any]) -> None: | ||
| """Run a minimal multimodal forward for Nemotron VL that avoids wrapper output packaging.""" | ||
| pixel_values = batch.get("pixel_values") | ||
|
|
@@ -67,6 +81,23 @@ def safe_nemotron_vl_forward(full_model: torch.nn.Module, batch: dict[str, Any]) | |
| ): | ||
| pixel_values = pixel_values.to(dtype=vision_dtype) | ||
|
|
||
| # InternVL-style Nemotron VL exposes `extract_feature(pixel_values)`; the newer omni wrappers | ||
| # (e.g. NemotronH_Omni_Reasoning_V3 / nemotron_h_omni) do not -- they merge the vision | ||
| # embeddings via masked_scatter inside their own forward, and do not necessarily define | ||
| # `img_context_token_id` either. Hand the batch to the model's own forward before touching | ||
| # anything InternVL-specific, carrying the dtype-cast pixel_values. | ||
| if not hasattr(full_model, "extract_feature"): | ||
| # Only the batch's own entries, plus the dtype-cast pixel_values. The synthesized | ||
| # `image_flags` above is deliberately NOT injected: it is built on pixel_values.device, | ||
| # while a sharded wrapper runs its vision tower elsewhere and indexes image_embeds with | ||
| # it, so a synthesized tensor raises "indices should be ... on the same device". | ||
| # Wrappers that need image_flags either receive the batch's own or build their own. | ||
| fallback = {k: v for k, v in batch.items() if v is not None} | ||
| fallback["pixel_values"] = pixel_values | ||
| fallback["use_cache"] = False | ||
| full_model(**_accepted_kwargs(full_model.forward, fallback)) | ||
| return | ||
|
|
||
| # Token embeddings | ||
| inputs_embeds = full_model.language_model.get_input_embeddings()(input_ids) | ||
| image_flags_s = image_flags.squeeze(-1) | ||
|
|
@@ -75,10 +106,22 @@ def safe_nemotron_vl_forward(full_model: torch.nn.Module, batch: dict[str, Any]) | |
| flat_embeds = inputs_embeds.reshape(b * n, c) | ||
| flat_ids = input_ids.reshape(b * n) | ||
| selected = flat_ids == full_model.img_context_token_id | ||
| # A bool mask must sit on the device of the tensor it indexes, not the one it was derived | ||
| # from. Under a sharded device_map the embedding table can run on a different GPU than the | ||
| # batch, so `selected` -- built from `input_ids` -- follows `flat_embeds`. Both the fast | ||
| # path and the except-branch retry below index with it. | ||
| if selected.device != flat_embeds.device: | ||
| selected = selected.to(flat_embeds.device) | ||
|
|
||
| # Vision embeddings | ||
| vit_embeds = full_model.extract_feature(pixel_values) | ||
| # Same rule for the image filter: the vision tower returns on its own device, while | ||
| # `image_flags` arrived with the batch (or was synthesized on `pixel_values.device`). | ||
| if image_flags_s.device != vit_embeds.device: | ||
| image_flags_s = image_flags_s.to(vit_embeds.device) | ||
| vit_embeds = vit_embeds[image_flags_s == 1] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[P2] The earlier cross-device-mask concern is still unresolved.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in db20808 — the concern was still live, and the alignment I added in 74615bd sat after both indexing operations, so it never applied to either mask. Each mask now moves to the device of the tensor it indexes, immediately before the indexing: On the sharded test: this group runs CPU-only, so the topology is simulated rather than allocated — the convention Evidence they bite, run against the pre-fix code in a throwaway copy of the tree: Worth noting for the day-0 omni path specifically: those wrappers have no |
||
| # The merge target lives on the embedding device; bring the filtered vision embeddings over. | ||
| if vit_embeds.device != flat_embeds.device: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Align the index masks before the cross-device transfer. With a sharded device_map, image_flags_s remains on the batch device, so vit_embeds[image_flags_s == 1] can fail before this new transfer if the vision tower runs on another GPU. Likewise, selected remains on input_ids.device while it indexes flat_embeds on the embedding device; the except block retries with the same mask. Please move each mask to the device of the tensor it indexes before indexing.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, and thanks — the transfer I added in 74615bd was in the wrong place. It moved
The point about the Verified by running the new tests against the pre-fix code in a throwaway copy of the tree — the three sharded cases fail there, |
||
| vit_embeds = vit_embeds.to(flat_embeds.device) | ||
| try: | ||
| flat_embeds[selected] = flat_embeds[selected] * 0.0 + vit_embeds.reshape(-1, c) | ||
| except Exception: | ||
|
|
@@ -88,6 +131,16 @@ def safe_nemotron_vl_forward(full_model: torch.nn.Module, batch: dict[str, Any]) | |
|
|
||
| inputs_embeds = flat_embeds.reshape(b, n, c) | ||
|
|
||
| # `inputs_embeds` comes off the embedding table, so under a sharded device_map it can sit on a | ||
| # different device than the mask/ids that came in with the batch. Align them to the embedding | ||
| # device so the LLM call starts consistent; accelerate's hooks handle later blocks. | ||
| _lm_dev = getattr(full_model.language_model.get_input_embeddings().weight, "device", None) | ||
| if _lm_dev is not None: | ||
| if attention_mask is not None and attention_mask.device != _lm_dev: | ||
| attention_mask = attention_mask.to(_lm_dev) | ||
| if position_ids is not None and position_ids.device != _lm_dev: | ||
| position_ids = position_ids.to(_lm_dev) | ||
|
|
||
| # LLM forward (drives activation stats) | ||
| full_model.language_model( | ||
| inputs_embeds=inputs_embeds, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
|
|
||
| # NemotronH -- aggressive NVFP4 tier, weight-MSE calibration. | ||
| # | ||
| # For hybrid Mamba-MoE NemotronH checkpoints, including the omni VL wrappers. Broader FP8 weight | ||
| # coverage than the conservative tier; the MTP rules apply only when the checkpoint carries an | ||
| # MTP tail and are inert otherwise. | ||
| # | ||
| # MoE routed + shared experts NVFP4 W4A4, static weight scales | ||
| # attention q/k/v/o, Mamba in/out_proj, lm_head FP8 | ||
| # MTP block-1 routed + shared experts NVFP4 W4A4, static weight scales | ||
| # latent MoE, embeddings, router, C-RADIO vision, Parakeet audio BF16 | ||
| # KV cache FP8 (see the kv rules below) | ||
| # | ||
| # quant_cfg is ordered and the LAST match wins, so the blanket disable comes first and the | ||
| # vision / MTP rules come last. Only configs/numerics/* is imported, so every rule that | ||
| # applies to this model is visible in this file. | ||
|
|
||
| imports: | ||
| nvfp4: configs/numerics/nvfp4 | ||
| nvfp4_static: configs/numerics/nvfp4_static | ||
| fp8: configs/numerics/fp8 | ||
|
|
||
| metadata: | ||
| recipe_type: ptq | ||
| description: >- | ||
| NemotronH aggressive tier, weight-MSE calibrated: NVFP4 routed and shared MoE experts with | ||
| static weight scales and dynamic NVFP4 activations, the same for the MTP block-1 experts; | ||
| FP8 attention projections, Mamba in/out_proj and lm_head; BF16 latent MoE and vision tower. | ||
| FP8 KV cache, with the backbone cast at a fixed range and MTP calibrated. | ||
| quantize: | ||
| algorithm: | ||
| method: mse | ||
| # Sweeps the 128 e4m3 scale values; requires static weight quantizers (below). | ||
| fp8_scale_sweep: true | ||
| # Required for VLMs: decoder layers sit under `model.language_model.layers`, | ||
| # where layerwise_calibrate cannot find them. | ||
| layerwise: | ||
| enable: false | ||
| quant_cfg: | ||
| - quantizer_name: '*' | ||
| enable: false | ||
| - quantizer_name: '*.experts.*weight_quantizer' | ||
| cfg: | ||
| $import: nvfp4_static | ||
| - quantizer_name: '*.experts.*input_quantizer' | ||
| cfg: | ||
| $import: nvfp4 | ||
| - quantizer_name: '*shared_experts*weight_quantizer' | ||
| cfg: | ||
| $import: nvfp4_static | ||
| - quantizer_name: '*shared_experts*input_quantizer' | ||
| cfg: | ||
| $import: nvfp4 | ||
| - quantizer_name: '*[qkvo]_proj.weight_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*[qkvo]_proj.input_quantizer' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Scope these FP8 attention patterns to the language model. They also match q/k/v/o projections in the optional Parakeet audio encoder (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks Zhiyu, the bf16 ckpt only contains the vision and the language model I see @meenchen has an exclusion for the audio, speech and audio_projector here https://github.com/NVIDIA/Model-Optimizer/pull/2582/changes#r4232236633:~:text=%2B-,disabled_layers,-%3A Would adding a similar disabling suffice to ensure that the q/k/v/o projections are scoped in the language model only?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a model_type specific recipe, this nemotron_h model type does not seem to have audio_tower and sound_encoder, so no need to add these.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it seems like the model had the support but the config declared null in this case |
||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*mixer.in_proj.weight_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*mixer.in_proj.input_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*mixer.out_proj.weight_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*mixer.out_proj.input_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| # Backbone KV casts at a fixed FP8 range: no _amax buffer, so no k_scale/v_scale is | ||
| # exported, matching configs/ptq/units/kv_fp8_cast. MTP KV below stays calibrated and | ||
| # keeps its pair. | ||
| - quantizer_name: '*[kv]_bmm_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| use_constant_amax: true | ||
| - quantizer_name: '*mtp.*' | ||
| enable: false | ||
| - quantizer_name: '*mtp.layers.1.mixer.experts.*weight_quantizer' | ||
| cfg: | ||
| $import: nvfp4_static | ||
| - quantizer_name: '*mtp.layers.1.mixer.experts.*input_quantizer' | ||
| cfg: | ||
| $import: nvfp4 | ||
| - quantizer_name: '*mtp.layers.1.mixer.shared_experts.*weight_quantizer' | ||
| cfg: | ||
| $import: nvfp4_static | ||
| - quantizer_name: '*mtp.layers.1.mixer.shared_experts.*input_quantizer' | ||
| cfg: | ||
| $import: nvfp4 | ||
| # Restated so this rule cannot inherit the backbone setting above. | ||
| - quantizer_name: '*mtp.layers.0.*[kv]_bmm_quantizer' | ||
| cfg: | ||
| $import: fp8 | ||
| use_constant_amax: false | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The existing tree-wide recipe tests validate loading and algorithm construction, not this recipe's effective quantizer selection/precedence. Please add a focused NemotronH regression test (following
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 74615bd as Ten cases:
One thing worth recording, since it bit me: the KV quantizers cannot be set in the mock's Verified: 7 passed in |
||
| - quantizer_name: '*embed_vision*' | ||
| enable: false | ||
| - quantizer_name: '*lm_head*weight_quantizer' | ||
| enable: true | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*lm_head*input_quantizer' | ||
| enable: true | ||
| cfg: | ||
| $import: fp8 | ||
| - quantizer_name: '*vision_model*' | ||
| enable: false | ||
| - quantizer_name: '*vision_projector*' | ||
| enable: false | ||
| - quantizer_name: '*radio_model*' | ||
| enable: false | ||
| - quantizer_name: '*vision_tower*' | ||
| enable: false | ||
| - quantizer_name: '*visual*' | ||
| enable: false | ||
| - quantizer_name: '*multi_modal_projector*' | ||
| enable: false | ||
| - quantizer_name: 'mlp1.*' | ||
| enable: false | ||
| - quantizer_name: '*.mlp1.*' | ||
| enable: false | ||
| # Audio branch of the omni wrapper, present when the config carries a `sound_config`. | ||
| # The Parakeet encoder names its projections `self_attn.{q,k,v,o,relative_k}_proj`, so the | ||
| # FP8 attention rules above match it, and its attention class is registered for KV-cache | ||
| # quantization like any other. Image calibration supplies no audio, so those input | ||
| # quantizers collect no amax and the export drops the input scale while still writing an | ||
| # FP8 weight scale -- a layer that claims W8A8 with no activation scale. Keep it BF16. | ||
| - quantizer_name: '*audio_tower*' | ||
| enable: false | ||
| - quantizer_name: '*sound_encoder*' | ||
| enable: false | ||
| - quantizer_name: '*sound_projector*' | ||
| enable: false | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The direct-helper tests reach this branch, but the production caller in
create_vlm_calibration_loop()invokessafe_nemotron_vl_forward()only when the wrapper hasimg_context_token_id. The omni wrappers described here as lacking that attribute instead executefull_model(**call_kwargs), bypassing both this dtype-carrying fallback and itsuse_cache=Falsesafeguard. Update the caller's dispatch to include the intended omni family without rerouting unrelated encoder-decoder VLMs, and test through the returned calibration loop using a wrapper withoutimg_context_token_id.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 74615bd.
You're right that the direct-helper tests were exercising a path the production caller never
took.
create_vlm_calibration_loop()now computes the dispatch once, before the batch loop:Two deliberate narrowings:
not hasattr(full_model, "extract_feature")keeps the new arm pointed at exactly the branchthe fallback implements. A Nemotron VL model that has
extract_featurebut noimg_context_token_idwould otherwise be sent into the InternVL branch and hit anAttributeErroronfull_model.img_context_token_id.not is_enc_deckeeps Nemotron-Parse on the plain forward. It satisfiesis_nemotron_vl(),but by the time dispatch runs its batch has been renamed to
decoder_input_ids;safe_nemotron_vl_forward()looks forinput_ids, would return without a forward pass, andcalibration would be silently skipped rather than loudly broken.
tests/examples/hf_ptq/test_nemotron_vl_calib.pynow drives the returned calibration loop, notjust the helper:
test_calibration_loop_routes_an_omni_wrapper_through_the_fallbackassertsthe wrapper's forward received
use_cache=Falseand bf16pixel_values, andtest_calibration_loop_keeps_the_plain_forward_for_encoder_decoder_vlmsasserts the enc-decwrapper got
decoder_input_idsand fp32pixel_values— i.e. the fallback was not applied.