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
41 changes: 35 additions & 6 deletions modelopt/torch/export/quant_aware_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,14 @@ def _apply_split_rule(state_dict: dict[str, torch.Tensor], rule: SplitRule) -> N
state_dict[target_key] = _split_leaf_tensor(leaf, tensor, n, idx, rule.dim)


def _apply_merge_rule(state_dict: dict[str, torch.Tensor], rule: _MergeRule) -> None:
"""Merge all complete tensor groups matched by a reversed ``WeightConverter``."""
def _apply_merge_rule(
state_dict: dict[str, torch.Tensor], rule: _MergeRule, unmatched: list[str] | None = None
) -> None:
"""Merge all complete tensor groups matched by a reversed ``WeightConverter``.

A rule matching no key is refused, unless ``unmatched`` is given: it then gets the refusal's
message instead (see :func:`revert_weight_conversion_quant_aware`).
"""
converter = rule.converter
source_patterns = tuple(_as_list(converter.source_patterns))
groups: dict[str, dict[str, str]] = {}
Expand All @@ -216,11 +222,15 @@ def _apply_merge_rule(state_dict: dict[str, torch.Tensor], rule: _MergeRule) ->
sources[source_pattern] = key

if not groups:
raise QuantConversionUnsupportedError(
message = (
f"merge rule for {source_patterns} matched no state-dict key "
f"(scope_prefix={getattr(converter, 'scope_prefix', None)!r}, "
f"base_model_prefix={getattr(converter, 'base_model_prefix', None)!r})"
)
if unmatched is None:
raise QuantConversionUnsupportedError(message)
unmatched.append(message)
return
Comment on lines +230 to +233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The deferral covers only the zero-groups case; a partially present group still raises unconditionally at the missing check just below (line ~237), and that failure is equally rank-dependent.

revert_weight_conversion_quant_aware's new docstring states the precondition — "a state_dict that holds one rank's share of the model, with each merge group whole on one rank" — but nothing enforces it. A writer that shards by individual tensor rather than by module would put q_proj.weight and k_proj.weight on different ranks; each of those ranks then has a non-empty groups with missing non-empty and raises QuantConversionUnsupportedError, while a rank holding none of the three records a message and returns normally. Since this exception is the project's designated "fall back to legacy" signal, those ranks would take different paths — and divergent paths in a distributed export are a hang rather than a clean error (per the distributed guidance in CLAUDE.md: every rank must issue the same collectives in the same order).

Not blocking here — the behavior change is opt-in and the default path is unchanged. But since the invariant lives only in a docstring and is satisfied purely by [3/3]'s sharding choice, consider either (a) asserting it where the per-rank share is constructed in [2/3]/[3/3], or (b) routing the missing refusal through unmatched too, so a rule's outcome is always collective rather than sometimes raised and sometimes recorded. Saying which you intend in [3/3]'s description would make the series easier to review.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precondition is upheld in [2/3] and [3/3], and a partial group's refusal is collective there.

  • One owner per group. [3/3]'s _merge_groups lists each merge rule's group, with its weights' quantization state, from the same rules the reverse uses. [2/3]'s _materialize_owned puts each group, and any group it shares a key with, on one owner, and refuses on every rank if the ranks group the keys differently. So each group is whole on one rank, and no rank sees part of one.
  • A refusal on one rank reaches every rank. [3/3]'s _revert_whole_sd runs the reverse inside the failure-carrying all_reduce. An exception on any rank, this missing refusal included, makes every rank keep its in-memory names together, so no rank takes a different collective path.

Recording a partial group instead of raising it would be wrong: no rank can complete a split group, so the refusal is real. [3/3]'s description now says this.


for target_key, sources in groups.items():
missing = [pattern for pattern in source_patterns if pattern not in sources]
Expand Down Expand Up @@ -330,6 +340,8 @@ def apply_reverse_rules(
rename_rules: list[RenameRule],
merge_rules: list[_MergeRule] | None = None,
source_keys: set[str] | None = None,
*,
unmatched_merges: list[str] | None = None,
) -> dict[str, torch.Tensor]:
"""Apply quant-aware reverse conversion: tensor transforms first, then renames.

Expand All @@ -342,12 +354,15 @@ def apply_reverse_rules(
``source_keys`` are the tensor names of the checkpoint the model was loaded from.
They let a rename that would otherwise invent a name resolve to the spelling the
checkpoint actually carries; omitting them applies the rules unchanged.

``unmatched_merges``, when given, collects the refusal message of each merge rule that
matched no key instead of raising; see :func:`revert_weight_conversion_quant_aware`.
"""
out = dict(state_dict)
for rule in split_rules:
_apply_split_rule(out, rule)
for merge_rule in merge_rules or []:
_apply_merge_rule(out, merge_rule)
_apply_merge_rule(out, merge_rule, unmatched_merges)

rename = _SourceAwareRenamer(rename_rules, source_keys)
renamed: dict[str, torch.Tensor] = {}
Expand All @@ -359,21 +374,35 @@ def apply_reverse_rules(
return renamed


def revert_weight_conversion_quant_aware(model, state_dict: dict[str, torch.Tensor]):
def revert_weight_conversion_quant_aware(
model, state_dict: dict[str, torch.Tensor], *, unmatched_merges: list[str] | None = None
):
"""Reverse a transformers conversion_mapping on a quantized state dict.

Builds reverse rules from the model's conversion mapping and applies them
carrying companion scale tensors. Raises :class:`QuantConversionUnsupportedError`
when the mapping uses an op that cannot be reversed quant-aware yet, so the
caller can fall back to the legacy behavior.

``unmatched_merges`` is for a ``state_dict`` that holds one rank's share of the model,
with each merge group whole on one rank: a merge rule matching none of its keys appends
the refusal's message there instead of raising, since other ranks may hold the rule's
groups. The caller refuses a rule that no rank matched.
"""
split_rules, merge_rules, rename_rules, expert_fused_leaves, source_keys = _build_reverse_rules(
model
)
if not split_rules and not merge_rules and not rename_rules:
return state_dict
_assert_experts_pre_expanded(state_dict, expert_fused_leaves)
return apply_reverse_rules(state_dict, split_rules, rename_rules, merge_rules, source_keys)
return apply_reverse_rules(
state_dict,
split_rules,
rename_rules,
merge_rules,
source_keys,
unmatched_merges=unmatched_merges,
)


def build_reverse_name_mapper(model, *, tensor_keys: bool = False):
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/torch/export/test_quant_aware_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,30 @@ def test_radio_merge_that_matches_no_keys_raises():
revert_weight_conversion_quant_aware(model, state_dict)


def test_radio_merge_that_matches_no_keys_is_recorded_for_one_ranks_share():
"""Given ``unmatched_merges``, as for one rank's share of a model, whose other ranks may hold
the rule's keys, the refusal is recorded there instead of raised, and the rest still applies."""
unmatched_rule = _radio_qkv_conversion()
_set_scope_attr(unmatched_rule, "scope_prefix", "unused_model")
model = types.SimpleNamespace(_weight_conversions=[unmatched_rule, _radio_qkv_conversion()])
parts = [torch.full((2, 2), float(i)) for i in range(3)]
state_dict = {
"language_model.layers.0.weight": torch.randn(2, 2),
**{
f"vision_model.encoder.layer.0.attention.{part}_proj.weight": tensor
for part, tensor in zip("qkv", parts)
},
}
unmatched: list = []

out = revert_weight_conversion_quant_aware(model, state_dict, unmatched_merges=unmatched)

assert len(unmatched) == 1 and "matched no state-dict key" in unmatched[0]
fused = "vision_model.encoder.layer.0.attn.qkv.weight"
assert out.keys() == {"language_model.layers.0.weight", fused}
assert torch.equal(out[fused], torch.cat(parts))


@pytest.mark.parametrize(
("leaf", "tensors", "message"),
[
Expand Down
Loading