Skip to content

Commit c4304f9

Browse files
Fridah-nvclaude
andcommitted
refactor(export): scope the VLM export parent to a context manager
Review follow-ups on the per-layer VLM path: - Replace the _layerwise_export_parent module attribute with an export_parent() context manager over a ContextVar. The attribute held an nn.Module, so it had to bypass nn.Module.__setattr__ to avoid a parent-child cycle, and it was never cleared. - Reduce _force_attn_implementation to the model config and its direct sub-configs. Kimi-K3's remote code only rewrites text_config, so the recursive walk and its bookkeeping were unused. - Drop _MAX_DECODER_UNWRAP_DEPTH: descend through the wrappers first and read `layers` at the bottom, which generalises main's fixed two-level unwrap instead of bounding a shallow-first search. - Drop the tied-weight alias handling. Kimi-K3 sets tie_word_embeddings=False and tied weights are unsupported on main, so it belongs in a separate change. Testing: tests/gpu/torch/export/test_layerwise_export.py 25 passed; test_layerwise_calibrate.py and tests/examples/hf_ptq/test_example_utils.py pass. test_offload_export.py has 2 failures that reproduce on the branch without these changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
1 parent 6cfb8e3 commit c4304f9

5 files changed

Lines changed: 55 additions & 85 deletions

File tree

examples/hf_ptq/example_utils.py

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
AutoModelForCausalLM,
4242
AutoProcessor,
4343
AutoTokenizer,
44+
PretrainedConfig,
4445
PreTrainedTokenizerBase,
4546
ProcessorMixin,
4647
)
@@ -704,41 +705,17 @@ def _resolve_init_config(hf_config, auto_model_module, ckpt_path, config_kwargs)
704705

705706

706707
def _force_attn_implementation(model, attn_implementation: str) -> None:
707-
"""Set ``_attn_implementation`` on the model config and every nested sub-config.
708-
709-
Some remote modeling code overrides the requested attention implementation inside
710-
``__init__`` -- Kimi-K3 rewrites it to ``flash_attention_2`` unconditionally, ignoring
711-
``--attn_implementation``. Export runs a trace forward, so a backend whose compiled
712-
extension is unavailable in this environment fails there rather than at load.
713-
714-
Sub-configs are walked because multimodal models keep separate ones per tower, and
715-
remote code typically rewrites the *nested* config (Kimi-K3 rewrites ``text_config``
716-
from ``KimiLinearModel.__init__``). Layer modules hold a reference to the same config
717-
object, so overriding it here takes effect on the next forward. Only applied when the
718-
caller asked for an implementation explicitly.
719-
"""
720-
pending, seen, changed = [model.config], set(), []
721-
while pending:
722-
cfg = pending.pop()
723-
if cfg is None or id(cfg) in seen:
724-
continue
725-
seen.add(id(cfg))
726-
current = getattr(cfg, "_attn_implementation", None)
727-
if current is not None and current != attn_implementation:
728-
try:
729-
cfg._attn_implementation = attn_implementation
730-
changed.append(f"{type(cfg).__name__}: {current} -> {attn_implementation}")
731-
except Exception as e: # pragma: no cover - depends on remote config class
732-
warnings.warn(f"Could not apply attn_implementation on {type(cfg).__name__}: {e}")
733-
pending.extend(
734-
getattr(cfg, sub, None)
735-
for sub in ("text_config", "vision_config", "audio_config", "decoder", "encoder")
736-
)
708+
"""Re-apply the caller's ``attn_implementation`` after ``__init__``.
737709
738-
if changed:
739-
print("Re-applied the requested attention implementation after model init:")
740-
for line in changed:
741-
print(f" {line}")
710+
Kimi-K3's remote code rewrites ``text_config._attn_implementation`` to
711+
``flash_attention_2`` unconditionally, so an uninstalled backend fails later at the
712+
export trace forward instead of at load. Drop this once flash-attn is installed
713+
reliably here and the checkpoint is fixed upstream.
714+
"""
715+
sub_configs = (v for v in vars(model.config).values() if isinstance(v, PretrainedConfig))
716+
for cfg in (model.config, *sub_configs):
717+
if getattr(cfg, "_attn_implementation", None) not in (None, attn_implementation):
718+
cfg._attn_implementation = attn_implementation
742719

743720

744721
def _get_config_dtype(config):

examples/hf_ptq/hf_ptq.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
has_spec_opt,
7979
save_expert_token_count_table,
8080
)
81-
from modelopt.torch.export.layerwise_export import EXPORT_PARENT_ATTR
81+
from modelopt.torch.export.layerwise_export import export_parent
8282
from modelopt.torch.export.model_utils import get_language_model_from_vl, is_multimodal_model
8383
from modelopt.torch.quantization.config import need_calibration
8484
from modelopt.torch.quantization.plugins.accelerate import init_quantized_weights
@@ -759,11 +759,12 @@ def mono_quantize(
759759
language_model, quant_cfg["algorithm"], forward_loop=calibrate_loop
760760
)
761761
else:
762-
if args.layerwise_export and language_model is not full_model:
763-
# Into __dict__: nn.Module.__setattr__ would register the parent as a
764-
# submodule of its own child and make named_modules() recurse forever.
765-
language_model.__dict__[EXPORT_PARENT_ATTR] = full_model
766-
language_model = mtq.quantize(language_model, quant_cfg, forward_loop=calibrate_loop)
762+
# A VLM calibrates its language model but must export the whole thing.
763+
parent = full_model if args.layerwise_export else language_model
764+
with export_parent(parent):
765+
language_model = mtq.quantize(
766+
language_model, quant_cfg, forward_loop=calibrate_loop
767+
)
767768

768769
# For VL models, update full_model to use the quantized language model
769770
if is_nemotron_vl_model:

modelopt/torch/export/layerwise_export.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""Write each decoder layer's quantized checkpoint shard as soon as it is calibrated."""
1717

1818
import contextlib
19+
import contextvars
1920
import json
2021
import warnings
2122
from pathlib import Path
@@ -146,26 +147,36 @@ def assert_layerwise_export_supported(model: nn.Module) -> None:
146147
)
147148

148149

149-
#: Set by the caller on the calibrated submodel when the checkpoint must describe a larger
150-
#: model. Multimodal pipelines calibrate the extracted language model, but the shards and
151-
#: config have to describe the whole VLM.
152-
EXPORT_PARENT_ATTR = "_layerwise_export_parent"
150+
_export_parent: contextvars.ContextVar[nn.Module | None] = contextvars.ContextVar(
151+
"layerwise_export_parent", default=None
152+
)
153153

154154

155-
def resolve_export_parent(model: nn.Module) -> nn.Module:
156-
"""Return the model the checkpoint should describe: the marked parent, or ``model``.
155+
@contextlib.contextmanager
156+
def export_parent(parent: nn.Module):
157+
"""Export the checkpoint for ``parent`` while calibration runs on one of its submodules.
157158
158-
The decoder layers are the same objects either way, so walking the parent yields
159-
parent-namespace tensor names, the full config and the untouched towers with no
160-
prefixing. Membership is checked by identity, not by name.
159+
Multimodal pipelines calibrate the extracted language model, but the shards and config
160+
have to describe the whole VLM. The decoder layers are the same objects either way, so
161+
walking the parent yields parent-namespace tensor names, the full config and the
162+
untouched towers with no prefixing.
161163
"""
162-
parent = getattr(model, EXPORT_PARENT_ATTR, None)
164+
token = _export_parent.set(parent)
165+
try:
166+
yield
167+
finally:
168+
_export_parent.reset(token)
169+
170+
171+
def _resolve_export_parent(model: nn.Module) -> nn.Module:
172+
"""Return the model the checkpoint should describe. Membership is by identity, not name."""
173+
parent = _export_parent.get()
163174
if parent is None or parent is model:
164175
return model
165176
if all(m is not model for m in parent.modules()):
166177
raise ValueError(
167-
f"{EXPORT_PARENT_ATTR} was set to a {type(parent).__name__} that does not "
168-
"contain the calibrated model."
178+
f"export_parent() was given a {type(parent).__name__} that does not contain the "
179+
"calibrated model."
169180
)
170181
return parent
171182

@@ -220,7 +231,7 @@ def __init__(
220231
221232
Runs before calibration, so nothing amax-dependent exists yet.
222233
"""
223-
model = resolve_export_parent(model)
234+
model = _resolve_export_parent(model)
224235
assert_layerwise_export_supported(model)
225236
# Splits regroup tensors across the whole state dict; no per-layer pass reverses that.
226237
_assert_no_split_rules(model)
@@ -275,16 +286,6 @@ def __init__(
275286
"match the original HF hub checkpoint."
276287
)
277288

278-
# Tied aliases the whole-model export omits and lets the loader re-tie.
279-
raw_tied = (
280-
set(getattr(model, "_tied_weights_keys", None) or [])
281-
if getattr(model.config, "tie_word_embeddings", False)
282-
else set()
283-
)
284-
self._tied_alias_keys = (
285-
{self._name_mapper(k) for k in raw_tied} if self._name_mapper else raw_tied
286-
)
287-
288289
def export_layer(
289290
self,
290291
layer_idx: int,
@@ -480,8 +481,6 @@ def _collect(self, out: dict[str, torch.Tensor], full_key: str, tensor: torch.Te
480481
return
481482
if self._name_mapper is not None:
482483
new_key = self._name_mapper(new_key)
483-
if new_key in self._tied_alias_keys:
484-
return
485484
out[new_key] = new_value.detach().contiguous().cpu()
486485

487486
def _write_index(self) -> None:

modelopt/torch/quantization/plugins/huggingface.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,30 +1796,22 @@ def is_homogeneous_hf_model(model: nn.Module) -> bool:
17961796
return len(layer_classes) == 1
17971797

17981798

1799-
#: How deep to unwrap before giving up. Each level is one of the wrappers below, and no
1800-
#: released architecture nests more than a handful; the bound only stops a cycle.
1801-
_MAX_DECODER_UNWRAP_DEPTH = 8
1802-
1803-
18041799
def get_homogeneous_hf_decoder_layers(model: nn.Module) -> nn.ModuleList | None:
18051800
if not _is_supported_hf_model(model):
18061801
return None
18071802

1808-
# Unwrap iteratively rather than testing each wrapper once: multimodal models nest them
1809-
# in either order and to arbitrary depth. Kimi-K3 keeps its layers at
1810-
# ``language_model.model.layers``, so a single ``.model`` then ``.language_model`` walk
1811-
# stops on the intermediate wrapper and reports the architecture unsupported.
1812-
decoder = model
1813-
for _ in range(_MAX_DECODER_UNWRAP_DEPTH):
1814-
if hasattr(decoder, "layers"):
1815-
return decoder.layers
1803+
# Descend through every wrapper before reading ``layers``: multimodal models nest them
1804+
# in either order, e.g. Kimi-K3 keeps its layers at ``language_model.model.layers``.
1805+
decoder, seen = model, set()
1806+
while id(decoder) not in seen:
1807+
seen.add(id(decoder))
18161808
for attr in ("model", "language_model"):
18171809
inner = getattr(decoder, attr, None)
18181810
if isinstance(inner, nn.Module):
18191811
decoder = inner
18201812
break
18211813
else:
1822-
return None
1814+
return getattr(decoder, "layers", None)
18231815

18241816
return None
18251817

tests/gpu/torch/export/test_layerwise_export.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131

3232
import modelopt.torch.quantization as mtq
3333
from modelopt.torch.export.layerwise_export import (
34-
EXPORT_PARENT_ATTR,
3534
LayerwiseExporter,
35+
export_parent,
3636
layer_shard_name,
3737
)
3838
from modelopt.torch.export.model_utils import get_language_model_from_vl
@@ -490,7 +490,10 @@ def test_awq_is_refused(tmp_path, make_cfg, method, match):
490490

491491
def _build_vlm():
492492
torch.manual_seed(0)
493-
model = get_tiny_gemma3vl().cuda().eval()
493+
# tie_word_embeddings=False: per-layer export does not dedup tied weights on
494+
# transformers 4 (see the TODO in unified_export_hf_streaming), and Kimi-K3 does not tie,
495+
# so keep this test on the namespace/towers/config behaviour it exists for.
496+
model = get_tiny_gemma3vl(tie_word_embeddings=False).cuda().eval()
494497
# is_multimodal_model reads this, and the tiny fixtures leave it unset.
495498
model.config.architectures = ["Gemma3ForConditionalGeneration"]
496499
return model
@@ -519,11 +522,9 @@ def test_vlm_export_matches_whole_model_export(tmp_path):
519522

520523
vlm = _build_vlm()
521524
language_model = get_language_model_from_vl(vlm)[-1]
522-
# Into __dict__: nn.Module.__setattr__ would register the parent as a submodule of its
523-
# own child and make named_modules() recurse forever.
524-
language_model.__dict__[EXPORT_PARENT_ATTR] = vlm
525525
export_dir = tmp_path / "fused"
526-
mtq.quantize(language_model, _layerwise_cfg(export_dir, tmp_path / "ckpt"), _calib_vlm)
526+
with export_parent(vlm):
527+
mtq.quantize(language_model, _layerwise_cfg(export_dir, tmp_path / "ckpt"), _calib_vlm)
527528

528529
exported = _load_checkpoint(export_dir)
529530
_assert_same_checkpoint(_load_checkpoint(baseline_dir), exported)

0 commit comments

Comments
 (0)