Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Changelog
- Update HuggingFace checkpoint export to use name-based tied-weight deduplication instead of the previous address-based approach. The address-based deduplication could incorrectly drop an untied weight that happened to share memory with a tied one, producing an incomplete checkpoint (observed as a false positive on MiniMax-M2.7).
- Fix EAGLE-3 training with context parallelism (``--cp_size > 1`` in ``examples/speculative_decoding``), which failed to start on ``accelerate >= 1.13`` and then raised ``got mixed torch.Tensor and DTensor``.
- Polygraphy minimum dependency upgraded to ``0.53.4`` to solve ONNX AutoCast failures when marking optional graph outputs.
- Fix layerwise calibration miscalibrating every module downstream of attention within a decoder layer; models using an explicit sliding-window mask raised a shape mismatch instead. Re-run calibration from a fresh ``layerwise.checkpoint_dir`` for any recipe with ``layerwise.enable: true``, including the shipped ``nvfp4_experts_only-kv_fp8_layerwise*`` and ``nvfp4_default-kv_none-gptq`` recipes; algorithms that derive weight scales or weight updates from activations (``gptq``, ``awq_lite``, ``awq_clip``, ``local_hessian``, ``smoothquant``) change their exported weights too, not just activation scales.

0.46 (2026-08-17)
^^^^^^^^^^^^^^^^^
Expand Down
15 changes: 0 additions & 15 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2125,21 +2125,6 @@ def _set_layer_status(status: str):

def _layer_forward_loop(m, _inputs=layer_inputs):
for args, kwargs_input in _inputs:
# Reset past_key_values to prevent the KV cache from
# accumulating across multiple forward replays (e.g.
# max_calibrate then Hessian collection in GPTQ).
# The layer doesn't need stale KV data — each replay
# should start with a fresh cache.
if (
"past_key_values" in kwargs_input
and kwargs_input["past_key_values"] is not None
):
kwargs_input = dict(kwargs_input)
cache = kwargs_input["past_key_values"]
if hasattr(cache, "reset"):
cache.reset()
else:
kwargs_input["past_key_values"] = None
m(*args, **kwargs_input)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

is_last = layer_idx + 1 >= num_layers
Expand Down
18 changes: 17 additions & 1 deletion modelopt/torch/quantization/utils/layerwise_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ class _EarlyStopForwardError(Exception):
"""Raised to halt the forward pass after capturing layer inputs."""


def _with_empty_kv_cache(kwargs_input: dict) -> dict:
"""Drop the attention cache from captured layer inputs.

Captured kwargs are replayed many times -- by ``calib_func``, once per calibration
pass, and by the ``run`` branch below -- so a retained cache would let a layer attend
over the keys and values its own earlier replay wrote. Clearing once at capture keeps
every consumer independent, and keeps the cache out of ``next_inputs.pt``.
Comment thread
Fridah-nv marked this conversation as resolved.
Outdated

Not ``Cache.reset()``: that zeroes the key/value tensors but keeps them at full
length, leaving the replay attending over an all-zero cache instead of none.
"""
if kwargs_input.get("past_key_values") is None:
Comment thread
Fridah-nv marked this conversation as resolved.
Outdated
return kwargs_input
return {**kwargs_input, "past_key_values": None}


@dataclass
class _LayerCalibState:
"""Mutable per-layer state used during layerwise calibration.
Expand Down Expand Up @@ -241,7 +257,7 @@ def _patched_forward(self, *args, **kwargs):
return output

if info.mode == "capture":
info.collected_inputs.append((args, kwargs))
info.collected_inputs.append((args, _with_empty_kv_cache(kwargs)))
raise _EarlyStopForwardError()

return self._original_forward(*args, **kwargs)
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/torch/quantization/test_layerwise_calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import pytest
import torch
import torch.nn as nn
from _test_utils.torch.transformers_models import get_tiny_llama

import modelopt.torch.quantization as mtq
from modelopt.torch.quantization.model_calib import layerwise_calibrate
Expand Down Expand Up @@ -830,6 +831,49 @@ def fwd(m):
_assert_amax_close(_collect_amax(model_lw), seq_amax, "layerwise vs sequential")


def test_layerwise_replay_does_not_attend_over_its_own_kv_cache():
"""A layer replayed during calibration must not see the keys and values its
own earlier run wrote.

The equivalence test above uses a model with no attention cache, so it cannot
reach this: only the captured ``past_key_values`` makes a replay stateful.
With the cache left in place, ``o_proj`` saw an all-zero attention output on
every layer but the last (the one with no preceding capture pass) and its
input amax collapsed to exactly 0.0, while ``down_proj`` picked up a
plausible but wrong value from the residual alone.
"""
calib_data = [torch.randint(0, 32, (2, 8)) for _ in range(2)]

def fwd(m):
for batch in calib_data:
m(batch)

def calibrate(algorithm):
torch.manual_seed(0)
model = get_tiny_llama(num_hidden_layers=4).eval()
cfg = copy.deepcopy(mtq.FP8_DEFAULT_CFG)
cfg["algorithm"] = algorithm
mtq.quantize(model, cfg, forward_loop=fwd)
return model

sequential = calibrate({"method": "max"})
layerwise = calibrate({"method": "max", "layerwise": {"enable": True}})

expected = _collect_amax(sequential)
assert expected, "sequential calibration populated no amax values"
layerwise_amax = _collect_amax(layerwise)
_assert_amax_close(layerwise_amax, expected, "layerwise vs sequential (KV cache)")

# Pinned separately from the comparison: a future regression that made both
# paths collapse to zero would still satisfy the equality above.
collapsed = [
name
for name, amax in layerwise_amax.items()
if name.endswith("input_quantizer") and not torch.count_nonzero(amax)
]
assert not collapsed, f"activation amax collapsed to zero: {collapsed}"


def test_layerwise_no_qdq_captures_inputs_before_calib_func_mutates_weights(monkeypatch):
"""A destructive ``calib_func`` (zeros weights) must not affect what is
captured for downstream layers under ``qdq_from_prev=False`` — otherwise
Expand Down
Loading