[EPD][Model Loader] Skip language-model checkpoint shards for `--mm-encoder-only` (#58086)

Signed-off-by: grYe99 <guorongye99@gmail.com> Co-authored-by: grYe99 <guorongye99@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

GuorongYe committed Sep 24, 2026 at 15:41 UTC 1291bdb85edc3cf0a1232be408b7e866b9d3f10a
5 files changed +483 -14
tests/model_executor/model_loader/test_mm_encoder_only_weight_filter.py new
+243
@@ -0,0 +1,243 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for --mm-encoder-only safetensors shard filtering."""
4 +
5 +import json
6 +import os
7 +import tempfile
8 +
9 +from transformers.utils import SAFE_WEIGHTS_INDEX_NAME
10 +
11 +from vllm.model_executor.model_loader.weight_utils import (
12 + filter_mm_encoder_only_safetensors_files,
13 + resolve_mm_encoder_only_lm_prefixes,
14 +)
15 +from vllm.model_executor.models.utils import WeightsMapper
16 +
17 +
18 +def _write_index(folder: str, weight_map: dict[str, str]) -> None:
19 + with open(os.path.join(folder, SAFE_WEIGHTS_INDEX_NAME), "w") as f:
20 + json.dump({"weight_map": weight_map}, f)
21 +
22 +
23 +def _qwen25_mapper() -> WeightsMapper:
24 + """Qwen2.5-VL dual layout mapper (nested + flat catch-all)."""
25 + return WeightsMapper(
26 + orig_to_new_prefix={
27 + "model.language_model.": "language_model.model.",
28 + "model.visual.": "visual.",
29 + "lm_head.": "language_model.lm_head.",
30 + "model.": "language_model.model.",
31 + }
32 + )
33 +
34 +
35 +def _qwen3_mapper() -> WeightsMapper:
36 + return WeightsMapper(
37 + orig_to_new_prefix={
38 + "model.visual.": "visual.",
39 + "lm_head.": "language_model.lm_head.",
40 + "model.language_model.": "language_model.model.",
41 + }
42 + )
43 +
44 +
45 +def _molmo_mapper() -> WeightsMapper:
46 + return WeightsMapper(
47 + orig_to_new_prefix={
48 + "model.vision_backbone.": "vision_backbone.",
49 + "model.transformer.blocks.": "model.layers.",
50 + "model.transformer.ln_f.": "model.norm.",
51 + "model.transformer.mlp.down_proj.": "lm_head.",
52 + }
53 + )
54 +
55 +
56 +def test_keeps_vision_shards_drops_language_only():
57 + with tempfile.TemporaryDirectory() as folder:
58 + files = [
59 + os.path.join(folder, "model-00001-of-000003.safetensors"),
60 + os.path.join(folder, "model-00002-of-000003.safetensors"),
61 + os.path.join(folder, "model-00003-of-000003.safetensors"),
62 + ]
63 + for path in files:
64 + open(path, "wb").close()
65 +
66 + _write_index(
67 + folder,
68 + {
69 + "language_model.layers.0.weight": "model-00001-of-000003.safetensors",
70 + "language_model.layers.1.weight": "model-00002-of-000003.safetensors",
71 + "vision_tower.blocks.0.weight": "model-00003-of-000003.safetensors",
72 + "mm_projector.weight": "model-00003-of-000003.safetensors",
73 + },
74 + )
75 +
76 + kept = filter_mm_encoder_only_safetensors_files(
77 + files,
78 + folder,
79 + SAFE_WEIGHTS_INDEX_NAME,
80 + ("language_model.",),
81 + )
82 + assert kept == [files[2]]
83 +
84 +
85 +def test_keeps_mixed_shard_with_vision_and_lm():
86 + with tempfile.TemporaryDirectory() as folder:
87 + mixed = os.path.join(folder, "model-00001-of-000001.safetensors")
88 + open(mixed, "wb").close()
89 + _write_index(
90 + folder,
91 + {
92 + "language_model.embed.weight": "model-00001-of-000001.safetensors",
93 + "vision_tower.patch.weight": "model-00001-of-000001.safetensors",
94 + },
95 + )
96 +
97 + kept = filter_mm_encoder_only_safetensors_files(
98 + [mixed],
99 + folder,
100 + SAFE_WEIGHTS_INDEX_NAME,
101 + ("language_model.",),
102 + )
103 + assert kept == [mixed]
104 +
105 +
106 +def test_no_index_returns_unchanged():
107 + with tempfile.TemporaryDirectory() as folder:
108 + files = [os.path.join(folder, "model.safetensors")]
109 + open(files[0], "wb").close()
110 + kept = filter_mm_encoder_only_safetensors_files(
111 + files,
112 + folder,
113 + SAFE_WEIGHTS_INDEX_NAME,
114 + ("language_model.",),
115 + )
116 + assert kept == files
117 +
118 +
119 +def test_resolve_returns_module_prefixes_without_hf_hardcode():
120 + """No global Qwen HF nest list — just vLLM module prefixes."""
121 + prefixes = resolve_mm_encoder_only_lm_prefixes(["language_model"])
122 + assert prefixes == ("language_model.",)
123 + # With Qwen mapper, still module prefixes (keys classified via mapper).
124 + assert resolve_mm_encoder_only_lm_prefixes(
125 + ["language_model"], weights_mapper=_qwen25_mapper()
126 + ) == ("language_model.",)
127 +
128 +
129 +def test_resolve_empty_language_model_names_fail_closed():
130 + """Missing/empty ``_language_model_names`` → None (no language_model. default)."""
131 + assert resolve_mm_encoder_only_lm_prefixes(None) is None
132 + assert resolve_mm_encoder_only_lm_prefixes([]) is None
133 + assert resolve_mm_encoder_only_lm_prefixes(()) is None
134 +
135 +
136 +def test_resolve_skips_filter_for_shared_hf_root():
137 + """Shared HF root (Molmo-shaped mapper) → fail-closed, not magic name list."""
138 + assert (
139 + resolve_mm_encoder_only_lm_prefixes(["model"], weights_mapper=_molmo_mapper())
140 + is None
141 + )
142 + # Without a mapper, identity layout: module prefix is returned as-is.
143 + assert resolve_mm_encoder_only_lm_prefixes(["model"]) == ("model.",)
144 + assert resolve_mm_encoder_only_lm_prefixes(["model."]) == ("model.",)
145 +
146 +
147 +def test_resolve_keeps_llm_prefix_without_model_broadening():
148 + prefixes = resolve_mm_encoder_only_lm_prefixes(["llm"])
149 + assert prefixes == ("llm.",)
150 +
151 +
152 +def test_qwen3_nested_index_skips_lm_keeps_visual():
153 + with tempfile.TemporaryDirectory() as folder:
154 + files = [
155 + os.path.join(folder, "model-00001-of-000002.safetensors"),
156 + os.path.join(folder, "model-00002-of-000002.safetensors"),
157 + ]
158 + for path in files:
159 + open(path, "wb").close()
160 + _write_index(
161 + folder,
162 + {
163 + "model.language_model.layers.0.weight": (
164 + "model-00001-of-000002.safetensors"
165 + ),
166 + "lm_head.weight": "model-00001-of-000002.safetensors",
167 + "model.visual.blocks.0.weight": "model-00002-of-000002.safetensors",
168 + },
169 + )
170 + prefixes = resolve_mm_encoder_only_lm_prefixes(
171 + ["language_model"], weights_mapper=_qwen3_mapper()
172 + )
173 + assert prefixes is not None
174 + kept = filter_mm_encoder_only_safetensors_files(
175 + files,
176 + folder,
177 + SAFE_WEIGHTS_INDEX_NAME,
178 + prefixes,
179 + weights_mapper=_qwen3_mapper(),
180 + )
181 + assert kept == [files[1]]
182 +
183 +
184 +def test_qwen25_flat_index_skips_lm_keeps_visual():
185 + with tempfile.TemporaryDirectory() as folder:
186 + files = [
187 + os.path.join(folder, "model-00001-of-000002.safetensors"),
188 + os.path.join(folder, "model-00002-of-000002.safetensors"),
189 + ]
190 + for path in files:
191 + open(path, "wb").close()
192 + _write_index(
193 + folder,
194 + {
195 + "model.layers.0.self_attn.q_proj.weight": (
196 + "model-00001-of-000002.safetensors"
197 + ),
198 + "model.embed_tokens.weight": "model-00001-of-000002.safetensors",
199 + "model.norm.weight": "model-00001-of-000002.safetensors",
200 + "lm_head.weight": "model-00001-of-000002.safetensors",
201 + "visual.blocks.0.weight": "model-00002-of-000002.safetensors",
202 + },
203 + )
204 + prefixes = resolve_mm_encoder_only_lm_prefixes(
205 + ["language_model"], weights_mapper=_qwen25_mapper()
206 + )
207 + assert prefixes is not None
208 + kept = filter_mm_encoder_only_safetensors_files(
209 + files,
210 + folder,
211 + SAFE_WEIGHTS_INDEX_NAME,
212 + prefixes,
213 + weights_mapper=_qwen25_mapper(),
214 + )
215 + assert kept == [files[1]]
216 +
217 +
218 +def test_molmo_shaped_index_not_filtered_when_deny_disabled():
219 + """Shared-root LM attr → resolve None; callers leave the file list alone."""
220 + assert (
221 + resolve_mm_encoder_only_lm_prefixes(["model"], weights_mapper=_molmo_mapper())
222 + is None
223 + )
224 + with tempfile.TemporaryDirectory() as folder:
225 + files = [
226 + os.path.join(folder, "vision.safetensors"),
227 + os.path.join(folder, "lm.safetensors"),
228 + ]
229 + for path in files:
230 + open(path, "wb").close()
231 + _write_index(
232 + folder,
233 + {
234 + "model.vision_backbone.patch.weight": "vision.safetensors",
235 + "model.transformer.blocks.0.weight": "lm.safetensors",
236 + },
237 + )
238 + # Document why raw HF startswith("model.") is unsafe without fail-closed:
239 + # whole-shard deny would also drop vision.
240 + if_applied_naively = filter_mm_encoder_only_safetensors_files(
241 + files, folder, SAFE_WEIGHTS_INDEX_NAME, ("model.",)
242 + )
243 + assert if_applied_naively == []
tests/model_executor/model_loader/test_registry.py
+1 -1
@@ -79,7 +79,7 @@ def test_default_loader_hf_still_falls_back_to_pt(tmp_path):
79 # Control: load_format="hf" still picks up .pt weights via fallback.
80 (tmp_path / "model.pt").write_bytes(b"\x00\x00\x00\x00")
81 loader = DefaultModelLoader(LoadConfig(load_format="hf"))
82 - _, files, use_safetensors = loader._prepare_weights(
82 + _, files, use_safetensors, _ = loader._prepare_weights(
83 str(tmp_path),
84 None,
85 None,
vllm/model_executor/model_loader/default_loader.py
+64 -10
@@ -1,11 +1,13 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from __future__ import annotations
4 +
5 import dataclasses
6 import glob
7 import os
8 import time
9 from collections.abc import Generator, Iterable
8 -from typing import cast
10 +from typing import TYPE_CHECKING, cast
11
12 import torch
13 from torch import nn
@@ -25,6 +27,7 @@ from vllm.model_executor.model_loader.weight_utils import (
27 fastsafetensors_weights_iterator,
28 filter_duplicate_safetensors_files,
29 filter_files_not_needed_for_inference,
30 + filter_mm_encoder_only_safetensors_files,
31 get_quant_config,
32 instanttensor_weights_iterator,
33 maybe_download_from_modelscope,
@@ -32,11 +35,15 @@ from vllm.model_executor.model_loader.weight_utils import (
35 multi_thread_safetensors_weights_iterator,
36 np_cache_weights_iterator,
37 pt_weights_iterator,
38 + resolve_mm_encoder_only_lm_prefixes,
39 safetensors_weights_iterator,
40 )
41 from vllm.tracing import instrument
42 from vllm.transformers_utils.repo_utils import list_filtered_repo_files
43
44 +if TYPE_CHECKING:
45 + from vllm.model_executor.models.utils import WeightsMapper
46 +
47 logger = init_logger(__name__)
48
49
@@ -74,6 +81,9 @@ class DefaultModelLoader(BaseModelLoader):
81 def __init__(self, load_config: LoadConfig):
82 super().__init__(load_config)
83 self.local_expert_ids: set[int] | None = None
84 + # Set in load_weights when --mm-encoder-only; used to drop LM-only shards.
85 + self._encoder_only_lm_prefixes: tuple[str, ...] | None = None
86 + self._encoder_only_weights_mapper: WeightsMapper | None = None
87
88 extra_config = load_config.model_loader_extra_config
89 if not isinstance(extra_config, dict):
@@ -132,7 +142,7 @@ class DefaultModelLoader(BaseModelLoader):
142 revision: str | None,
143 fall_back_to_pt: bool,
144 allow_patterns_overrides: list[str] | None,
135 - ) -> tuple[str, list[str], bool]:
145 + ) -> tuple[str, list[str], bool, str]:
146 """Prepare weights for the model.
147
148 If the model is not local, it will be downloaded."""
@@ -239,20 +249,40 @@ class DefaultModelLoader(BaseModelLoader):
249 f"Cannot find any model weights with `{model_name_or_path}`"
250 )
251
242 - return hf_folder, hf_weights_files, use_safetensors
252 + return hf_folder, hf_weights_files, use_safetensors, index_file
253
254 def _get_weights_iterator(
245 - self, source: "Source"
255 + self, source: Source
256 ) -> Generator[tuple[str, torch.Tensor], None, None]:
257 """Get an iterator for the model weights based on the load format."""
258 extra_config = self.load_config.model_loader_extra_config
249 - hf_folder, hf_weights_files, use_safetensors = self._prepare_weights(
250 - source.model_or_path,
251 - source.subfolder,
252 - source.revision,
253 - source.fall_back_to_pt,
254 - source.allow_patterns_overrides,
259 + hf_folder, hf_weights_files, use_safetensors, index_file = (
260 + self._prepare_weights(
261 + source.model_or_path,
262 + source.subfolder,
263 + source.revision,
264 + source.fall_back_to_pt,
265 + source.allow_patterns_overrides,
266 + )
267 )
268 + if (
269 + self._encoder_only_lm_prefixes is not None
270 + and use_safetensors
271 + and hf_weights_files
272 + ):
273 + hf_weights_files = filter_mm_encoder_only_safetensors_files(
274 + hf_weights_files,
275 + hf_folder,
276 + index_file,
277 + self._encoder_only_lm_prefixes,
278 + weights_mapper=self._encoder_only_weights_mapper,
279 + )
280 + if not hf_weights_files:
281 + raise RuntimeError(
282 + "mm-encoder-only shard filter removed all weight files for "
283 + f"`{source.model_or_path}`; check language_model prefixes "
284 + f"{self._encoder_only_lm_prefixes}"
285 + )
286 if self.load_config.load_format == "npcache":
287 # Currently np_cache only support *.bin checkpoints
288 assert use_safetensors is False
@@ -348,6 +378,29 @@ class DefaultModelLoader(BaseModelLoader):
378 allow_patterns_overrides=None,
379 )
380
381 + def _init_mm_encoder_only_weight_filter(
382 + self, model: nn.Module, model_config: ModelConfig
383 + ) -> None:
384 + """Skip pure language-model safetensors shards under --mm-encoder-only."""
385 + mm_config = model_config.multimodal_config
386 + if mm_config is None or not mm_config.mm_encoder_only:
387 + self._encoder_only_lm_prefixes = None
388 + self._encoder_only_weights_mapper = None
389 + return
390 +
391 + # Derive from _language_model_names; fail-closed on shared HF roots
392 + # (Molmo/Phi-4-MM/Muse). Qwen nested/flat keys classified via mapper.
393 + weights_mapper = cast(
394 + "WeightsMapper | None", getattr(model, "hf_to_vllm_mapper", None)
395 + )
396 + self._encoder_only_lm_prefixes = resolve_mm_encoder_only_lm_prefixes(
397 + getattr(model, "_language_model_names", None),
398 + weights_mapper=weights_mapper,
399 + )
400 + self._encoder_only_weights_mapper = (
401 + weights_mapper if self._encoder_only_lm_prefixes is not None else None
402 + )
403 +
404 def _init_ep_weight_filter(self, model_config: ModelConfig) -> None:
405 """Compute local expert ids for EP weight filtering.
406
@@ -423,6 +476,7 @@ class DefaultModelLoader(BaseModelLoader):
476 self.load_config.safetensors_load_strategy = "torchao"
477
478 self._init_ep_weight_filter(model_config)
479 + self._init_mm_encoder_only_weight_filter(model, model_config)
480
481 loaded_weights = model.load_weights(self.get_all_weights(model_config, model))
482
vllm/model_executor/model_loader/weight_utils.py
+169 -1
@@ -15,7 +15,7 @@ import time
15 from collections.abc import Callable, Generator, Iterable
16 from contextlib import contextmanager
17 from pathlib import Path
18 -from typing import IO, Any
18 +from typing import IO, TYPE_CHECKING, Any
19
20 import filelock
21 import huggingface_hub.constants
@@ -61,6 +61,9 @@ except ImportError:
61
62 from vllm.model_executor.layers.quantization.torchao import torchao_version_at_least
63
64 +if TYPE_CHECKING:
65 + from vllm.model_executor.models.utils import WeightsMapper
66 +
67 logger = init_logger(__name__)
68
69 # use system-level temp directory for file locks, so that multiple users
@@ -605,6 +608,171 @@ def filter_files_not_needed_for_inference(hf_weights_files: list[str]) -> list[s
608 return hf_weights_files
609
610
611 +def _normalize_module_prefixes(
612 + language_model_names: Iterable[str] | None,
613 +) -> tuple[str, ...]:
614 + names = tuple(language_model_names or ())
615 + return tuple(n if n.endswith(".") else f"{n}." for n in names)
616 +
617 +
618 +def _mapped_weight_name(weights_mapper: "WeightsMapper | None", key: str) -> str | None:
619 + """Apply ``WeightsMapper.map_name`` when present; else identity."""
620 + if weights_mapper is None:
621 + return key
622 + return weights_mapper.map_name(key)
623 +
624 +
625 +def _hf_prefix_maps_to_lm(
626 + new_name: str | None, lm_module_prefixes: tuple[str, ...]
627 +) -> bool:
628 + if not new_name:
629 + return False
630 + new_p = new_name if new_name.endswith(".") else f"{new_name}."
631 + return any(new_p.startswith(p) or p.startswith(new_p) for p in lm_module_prefixes)
632 +
633 +
634 +def _shared_hf_root_module_prefixes(
635 + lm_module_prefixes: tuple[str, ...],
636 + weights_mapper: "WeightsMapper | None",
637 +) -> tuple[str, ...]:
638 + """Module prefixes that are also an HF root for non-LM weights.
639 +
640 + E.g. Molmo/Phi-4-MM/Muse mark LM as ``model`` while vision HF keys stay
641 + under ``model.vision_*`` / ``model.embed_tokens_extend.*``. Detected via
642 + ``WeightsMapper.orig_to_new_prefix`` (no hard-coded name list).
643 + """
644 + if weights_mapper is None:
645 + return ()
646 + mapping = weights_mapper.orig_to_new_prefix
647 + if not mapping:
648 + return ()
649 +
650 + non_lm_origs: list[str] = []
651 + for orig, new in mapping.items():
652 + orig_p = orig if orig.endswith(".") else f"{orig}."
653 + if not _hf_prefix_maps_to_lm(new, lm_module_prefixes):
654 + non_lm_origs.append(orig_p)
655 +
656 + if not non_lm_origs:
657 + return ()
658 +
659 + shared = [
660 + p
661 + for p in lm_module_prefixes
662 + if any(orig.startswith(p) for orig in non_lm_origs)
663 + ]
664 + return tuple(shared)
665 +
666 +
667 +def resolve_mm_encoder_only_lm_prefixes(
668 + language_model_names: Iterable[str] | None,
669 + *,
670 + weights_mapper: "WeightsMapper | None" = None,
671 +) -> tuple[str, ...] | None:
672 + """Resolve vLLM LM *module* prefixes for ``--mm-encoder-only`` shard skip.
673 +
674 + Prefixes come from ``_language_model_names``. Classification of HF index
675 + keys is done later via optional ``weights_mapper`` (see
676 + ``filter_mm_encoder_only_safetensors_files``), so this helper does **not**
677 + hard-code Qwen/HF nest lists.
678 +
679 + Returns ``None`` (leave the safetensors file list unchanged) when
680 + ``_language_model_names`` is empty/missing, or when a module prefix is a
681 + shared HF checkpoint root for both LM and non-LM weights — fail-closed so
682 + Molmo / Phi-4-MM / Muse cannot under-load the encoder.
683 + """
684 + prefixes = _normalize_module_prefixes(language_model_names)
685 + if not prefixes:
686 + return None
687 + shared = _shared_hf_root_module_prefixes(prefixes, weights_mapper)
688 + if shared:
689 + logger.warning_once(
690 + "mm-encoder-only whole-shard filter not applied: LM module "
691 + "prefix(es) %s share an HF checkpoint root with non-LM weights "
692 + "(via WeightsMapper). Encoder-only load keeps the full "
693 + "safetensors file list; finer HF prefixes or key-level "
694 + "filtering can enable skip later.",
695 + shared,
696 + )
697 + return None
698 + return prefixes
699 +
700 +
701 +def filter_mm_encoder_only_safetensors_files(
702 + hf_weights_files: list[str],
703 + hf_folder: str,
704 + index_file: str,
705 + language_model_prefixes: Iterable[str],
706 + *,
707 + weights_mapper: "WeightsMapper | None" = None,
708 +) -> list[str]:
709 + """Drop safetensors shards that only contain language-model weights.
710 +
711 + Used with ``--mm-encoder-only`` so Encoder-only EPD instances avoid reading
712 + pure LM shards from disk/DRAM.
713 +
714 + Each HF index key is classified by mapping through ``weights_mapper`` (when
715 + provided) and testing the *vLLM* name against ``language_model_prefixes``.
716 + Without a mapper, HF names are compared directly (identity checkpoint
717 + layout, e.g. Kimi ``language_model.*``).
718 +
719 + A shard is kept if it contains any non-LM key. Without an index file,
720 + returns ``hf_weights_files`` unchanged.
721 + """
722 + prefixes = tuple(language_model_prefixes)
723 + if not prefixes:
724 + return hf_weights_files
725 +
726 + index_path = os.path.join(hf_folder, index_file)
727 + if not os.path.isfile(index_path):
728 + logger.warning_once(
729 + "mm-encoder-only shard filter skipped: index file %s not found",
730 + index_path,
731 + )
732 + return hf_weights_files
733 +
734 + with open(index_path) as f:
735 + weight_map: dict[str, str] = json.load(f)["weight_map"]
736 +
737 + keys_by_file: dict[str, list[str]] = {}
738 + for weight_name, weight_file in weight_map.items():
739 + keys_by_file.setdefault(weight_file, []).append(weight_name)
740 +
741 + def _is_lm_key(key: str) -> bool:
742 + mapped = _mapped_weight_name(weights_mapper, key)
743 + if mapped is None:
744 + return False
745 + return any(mapped.startswith(p) for p in prefixes)
746 +
747 + def _is_lm_only(weight_file: str) -> bool:
748 + keys = keys_by_file.get(weight_file)
749 + if not keys:
750 + # Not listed in the index (e.g. extra matched glob) — keep.
751 + return False
752 + return all(_is_lm_key(key) for key in keys)
753 +
754 + kept: list[str] = []
755 + skipped = 0
756 + for path in hf_weights_files:
757 + rel = os.path.relpath(path, hf_folder)
758 + # Index weight_map values are usually basenames; tolerate either form.
759 + basename = os.path.basename(path)
760 + if _is_lm_only(rel) or _is_lm_only(basename):
761 + skipped += 1
762 + continue
763 + kept.append(path)
764 +
765 + if skipped:
766 + logger.info_once(
767 + "mm-encoder-only: skipped %d/%d safetensors shard(s) that only "
768 + "contain language-model weights (prefixes=%s)",
769 + skipped,
770 + skipped + len(kept),
771 + prefixes,
772 + )
773 + return kept
774 +
775 +
776 # explicitly use pure text format, with a newline at the end
777 # this makes it impossible to see the animation in the progress bar
778 # but will avoid messing up with ray or multiprocessing, which wraps
vllm/model_executor/models/utils.py
+6 -2
@@ -78,11 +78,15 @@ class WeightsMapper:
78 orig_to_new_suffix={**self.orig_to_new_suffix, **other.orig_to_new_suffix},
79 )
80
81 - def _map_name(self, key: str) -> str | None:
82 - """Map a weight name (backward-compatible wrapper that discards shard_id)."""
81 + def map_name(self, key: str) -> str | None:
82 + """Map a weight name; returns ``None`` if the weight should be ignored."""
83 result = self._map_name_with_shard(key)
84 return result[0] if result is not None else None
85
86 + def _map_name(self, key: str) -> str | None:
87 + """Backward-compatible alias for :meth:`map_name`."""
88 + return self.map_name(key)
89 +
90 def _map_name_with_shard(self, key: str) -> tuple[str, ShardIds | None] | None:
91 """Map a weight name and extract any shard_id metadata.
92