Skip to content

Commit a471d34

Browse files
xiaoyu-workCopilot
andauthored
Add canonical component manifests (#665)
## Summary - add immutable `ComponentDescriptor` and `ComponentManifest` types - resolve task roles, task module paths, and model `HF_COMPONENT_SOURCES` through one canonical API - switch `inspect_components` and build-time optimization role selection to the manifest - preserve the existing `ComponentSpec` and graph/weight behavior ## Stack - Design: #664 - **This PR: component manifest foundation** - Next: typed weight records and quantization codecs - Then: per-component checkpoint loader - Final: model adapter migration ## Validation - 78 manifest/inspection/task tests - 328 architecture and graph component tests --------- Signed-off-by: Xiaoyu Zhang <xiaoyuzhang@microsoft.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 8c38c06 commit a471d34

5 files changed

Lines changed: 344 additions & 17 deletions

File tree

src/mobius/_builder.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,18 @@ def build_from_module(
162162
if prune_prefill_prefix:
163163
task = _enable_prefill_prefix_pruning_task(task)
164164
resolved_task = get_task(task)
165+
component_manifest = resolved_task.component_manifest()
165166
configure_component_quantization(module, config, resolved_task)
166167
_cast_module_dtype(module, dtype)
167168
capabilities = ep_registry.require(execution_provider)
168169
with build_context(capabilities, dtype):
169170
package = resolved_task.build(module, config)
170171

171172
for name, model in package.items():
172-
role = resolved_task.model_roles.get(name) or _MODEL_ROLE_MAP.get(name, "decoder")
173+
descriptor = component_manifest.get(name)
174+
role = (
175+
descriptor.role if descriptor is not None else _MODEL_ROLE_MAP.get(name, "decoder")
176+
)
173177
optimize_model(
174178
model,
175179
ep=execution_provider,

src/mobius/_component_manifest.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Canonical metadata for the components of a model package."""
5+
6+
from __future__ import annotations
7+
8+
__all__ = [
9+
"ComponentDescriptor",
10+
"ComponentManifest",
11+
"get_hf_component_sources",
12+
"resolve_component_manifest",
13+
]
14+
15+
import dataclasses
16+
from collections.abc import Iterator, Mapping
17+
from types import MappingProxyType
18+
from typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
pass
22+
23+
24+
@dataclasses.dataclass(frozen=True)
25+
class ComponentDescriptor:
26+
"""One package component and all metadata needed to address it.
27+
28+
Attributes:
29+
name: Key used by :class:`~mobius.ModelPackage`.
30+
module_attribute_path: Dotted Python attribute path from the root
31+
:class:`onnxscript.nn.Module` passed to ``task.build()`` to the
32+
sub-module that constructs this component. The empty string means
33+
the root module itself. This is not a package key or checkpoint
34+
prefix.
35+
role: Task-defined optimization category. Current roles include
36+
``decoder``, ``encoder``, ``vision``, ``embedding``, and ``glue``.
37+
source_paths: Runtime HuggingFace ``named_modules()`` paths whose
38+
weights belong to this component.
39+
source_path_aliases: Pairs of ``(local_prefix, source_prefix)`` for
40+
component paths that cannot be aligned by a shared anchor segment.
41+
"""
42+
43+
name: str
44+
module_attribute_path: str
45+
role: str
46+
source_paths: tuple[str, ...] = ()
47+
source_path_aliases: tuple[tuple[str, str], ...] = ()
48+
49+
def __post_init__(self) -> None:
50+
if not self.name:
51+
raise ValueError("component name must not be empty")
52+
if not self.role:
53+
raise ValueError(f"component {self.name!r} must declare a role")
54+
if any(not path for path in self.source_paths):
55+
raise ValueError(
56+
f"component {self.name!r} source_paths must not contain empty paths"
57+
)
58+
if any(not local or not source for local, source in self.source_path_aliases):
59+
raise ValueError(
60+
f"component {self.name!r} source_path_aliases must contain "
61+
"non-empty local/source prefixes"
62+
)
63+
64+
def source_module_names(self, local_module_path: str) -> tuple[str, ...]:
65+
"""Candidate HuggingFace names for a component-local module path.
66+
67+
Source roots and Mobius paths commonly share an anchor segment even
68+
when their prefixes differ. For example, source root
69+
``model.language_model.layers`` and local path
70+
``model.layers.0.self_attn.q_proj`` share ``layers`` and resolve to
71+
``model.language_model.layers.0.self_attn.q_proj``.
72+
"""
73+
if not local_module_path:
74+
return self.source_paths
75+
76+
local_parts = local_module_path.split(".")
77+
candidates = [local_module_path]
78+
for local_prefix, source_prefix in self.source_path_aliases:
79+
if local_module_path == local_prefix:
80+
candidates.append(source_prefix)
81+
elif local_module_path.startswith(f"{local_prefix}."):
82+
suffix = local_module_path[len(local_prefix) + 1 :]
83+
candidates.append(f"{source_prefix}.{suffix}")
84+
for source_path in self.source_paths:
85+
source_parts = source_path.split(".")
86+
anchor = source_parts[-1]
87+
anchor_indices = [
88+
index for index, part in enumerate(local_parts) if part == anchor
89+
]
90+
if anchor_indices:
91+
for index in anchor_indices:
92+
suffix = local_parts[index + 1 :]
93+
candidates.append(".".join((*source_parts, *suffix)))
94+
return tuple(dict.fromkeys(candidates))
95+
96+
97+
@dataclasses.dataclass(frozen=True)
98+
class ComponentManifest(Mapping[str, ComponentDescriptor]):
99+
"""Ordered, immutable component metadata keyed by package component name."""
100+
101+
components: tuple[ComponentDescriptor, ...]
102+
_by_name: Mapping[str, ComponentDescriptor] = dataclasses.field(
103+
init=False,
104+
repr=False,
105+
compare=False,
106+
)
107+
108+
def __post_init__(self) -> None:
109+
by_name: dict[str, ComponentDescriptor] = {}
110+
for component in self.components:
111+
if component.name in by_name:
112+
raise ValueError(
113+
f"component manifest declares {component.name!r} more than once"
114+
)
115+
by_name[component.name] = component
116+
object.__setattr__(self, "_by_name", MappingProxyType(by_name))
117+
118+
def __getitem__(self, name: str) -> ComponentDescriptor:
119+
return self._by_name[name]
120+
121+
def __iter__(self) -> Iterator[str]:
122+
return iter(self._by_name)
123+
124+
def __len__(self) -> int:
125+
return len(self._by_name)
126+
127+
@property
128+
def names(self) -> tuple[str, ...]:
129+
"""Component names in task declaration order."""
130+
return tuple(self._by_name)
131+
132+
133+
def get_hf_component_sources(
134+
module_class: type,
135+
model_type: str,
136+
hf_config: object,
137+
) -> dict[str, tuple[str, ...]]:
138+
"""Read runtime HuggingFace component paths from a registered model class."""
139+
resolver = getattr(module_class, "get_hf_component_sources", None)
140+
if resolver is not None:
141+
resolved = resolver(model_type=model_type, hf_config=hf_config)
142+
else:
143+
resolved = getattr(module_class, "HF_COMPONENT_SOURCES", {})
144+
return {name: tuple(paths) for name, paths in resolved.items()}
145+
146+
147+
def resolve_component_manifest(
148+
task: object,
149+
*,
150+
module_class: type | None = None,
151+
model_type: str | None = None,
152+
hf_config: object | None = None,
153+
) -> ComponentManifest:
154+
"""Combine task roles/paths and model source ownership into one manifest."""
155+
roles = dict(getattr(task, "model_roles", {}) or {})
156+
component_spec = getattr(task, "components", None)
157+
module_paths = dict(component_spec.items()) if component_spec is not None else {}
158+
159+
component_sources: dict[str, tuple[str, ...]] = {}
160+
component_aliases: dict[str, tuple[tuple[str, str], ...]] = {}
161+
if module_class is not None and model_type is not None and hf_config is not None:
162+
component_sources = get_hf_component_sources(
163+
module_class,
164+
model_type,
165+
hf_config,
166+
)
167+
raw_aliases = getattr(module_class, "HF_COMPONENT_MODULE_ALIASES", {})
168+
component_aliases = {
169+
name: tuple(aliases.items()) for name, aliases in raw_aliases.items()
170+
}
171+
172+
ordered_names = tuple(dict.fromkeys((*roles, *module_paths)))
173+
descriptors = tuple(
174+
ComponentDescriptor(
175+
name=name,
176+
module_attribute_path=module_paths.get(
177+
name,
178+
"" if name == "model" else name,
179+
),
180+
role=roles.get(name, "decoder"),
181+
source_paths=component_sources.get(name, ()),
182+
source_path_aliases=component_aliases.get(name, ()),
183+
)
184+
for name in ordered_names
185+
)
186+
return ComponentManifest(descriptors)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Tests for canonical component manifest resolution."""
5+
6+
from __future__ import annotations
7+
8+
from typing import ClassVar
9+
10+
import pytest
11+
12+
from mobius._component_manifest import (
13+
ComponentDescriptor,
14+
ComponentManifest,
15+
resolve_component_manifest,
16+
)
17+
from mobius.tasks import ComponentSpec
18+
19+
20+
class _Task:
21+
model_roles: ClassVar[dict[str, str]] = {
22+
"decoder": "decoder",
23+
"vision_encoder": "encoder",
24+
"embedding": "embedding",
25+
}
26+
components = ComponentSpec(
27+
decoder="language",
28+
vision_encoder="vision.tower",
29+
embedding="embedding",
30+
)
31+
32+
33+
class _Model:
34+
HF_COMPONENT_SOURCES: ClassVar[dict[str, tuple[str, ...]]] = {
35+
"decoder": ("model.language_model.layers", "lm_head"),
36+
"vision_encoder": ("model.vision_tower", "model.projector"),
37+
"embedding": ("model.language_model.embed_tokens",),
38+
}
39+
HF_COMPONENT_MODULE_ALIASES: ClassVar[dict[str, dict[str, str]]] = {
40+
"vision_encoder": {
41+
"encoder": "model.vision_tower",
42+
"projector": "model.projector",
43+
}
44+
}
45+
46+
47+
def test_manifest_combines_task_and_model_metadata():
48+
manifest = resolve_component_manifest(
49+
_Task(),
50+
module_class=_Model,
51+
model_type="test",
52+
hf_config=object(),
53+
)
54+
55+
assert manifest.names == ("decoder", "vision_encoder", "embedding")
56+
assert manifest["decoder"] == ComponentDescriptor(
57+
name="decoder",
58+
module_attribute_path="language",
59+
role="decoder",
60+
source_paths=("model.language_model.layers", "lm_head"),
61+
)
62+
assert manifest["vision_encoder"].module_attribute_path == "vision.tower"
63+
assert manifest["vision_encoder"].role == "encoder"
64+
assert manifest["vision_encoder"].source_module_names("encoder.layers.0.q_proj") == (
65+
"encoder.layers.0.q_proj",
66+
"model.vision_tower.layers.0.q_proj",
67+
)
68+
69+
70+
def test_dynamic_source_resolver_is_authoritative():
71+
class _DynamicModel:
72+
@classmethod
73+
def get_hf_component_sources(cls, *, model_type, hf_config):
74+
assert model_type == "dynamic"
75+
assert hf_config == "config"
76+
return {"decoder": ("resolved.decoder",)}
77+
78+
manifest = resolve_component_manifest(
79+
_Task(),
80+
module_class=_DynamicModel,
81+
model_type="dynamic",
82+
hf_config="config",
83+
)
84+
85+
assert manifest["decoder"].source_paths == ("resolved.decoder",)
86+
assert manifest["vision_encoder"].source_paths == ()
87+
88+
89+
def test_descriptor_maps_local_path_to_huggingface_source_name():
90+
descriptor = ComponentDescriptor(
91+
name="decoder",
92+
module_attribute_path="decoder",
93+
role="decoder",
94+
source_paths=("model.language_model.layers", "lm_head"),
95+
)
96+
97+
assert descriptor.source_module_names("model.layers.0.per_layer_input_gate") == (
98+
"model.layers.0.per_layer_input_gate",
99+
"model.language_model.layers.0.per_layer_input_gate",
100+
)
101+
assert descriptor.source_module_names("lm_head") == ("lm_head",)
102+
103+
104+
def test_single_component_uses_root_module_path():
105+
class _SingleTask:
106+
model_roles: ClassVar[dict[str, str]] = {"model": "encoder"}
107+
components = None
108+
109+
manifest = resolve_component_manifest(_SingleTask())
110+
111+
assert manifest["model"].module_attribute_path == ""
112+
assert manifest["model"].role == "encoder"
113+
114+
115+
def test_duplicate_component_names_are_rejected():
116+
component = ComponentDescriptor("decoder", "decoder", "decoder")
117+
118+
with pytest.raises(ValueError, match="more than once"):
119+
ComponentManifest((component, component))

src/mobius/_inspect.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,9 @@ def _get_hf_component_sources(
114114
hf_config: object,
115115
) -> dict[str, tuple[str, ...]]:
116116
"""Read runtime HuggingFace component paths from a registered model class."""
117-
resolver = getattr(module_class, "get_hf_component_sources", None)
118-
if resolver is not None:
119-
return resolver(model_type=model_type, hf_config=hf_config)
120-
return getattr(module_class, "HF_COMPONENT_SOURCES", {})
117+
from mobius._component_manifest import get_hf_component_sources
118+
119+
return get_hf_component_sources(module_class, model_type, hf_config)
121120

122121

123122
def inspect_components(
@@ -151,23 +150,22 @@ def inspect_components(
151150
model_id, task, trust_remote_code
152151
)
153152
task_obj = get_task(resolved_task)
154-
roles = task_obj.model_roles or {}
155-
156-
# Runtime HF paths are owned by the registered model class. Most classes
157-
# declare a fixed ``HF_COMPONENT_SOURCES`` mapping; classes shared by
158-
# several HF layouts can resolve paths from the already-loaded config.
159-
component_sources: dict[str, tuple[str, ...]] = {}
153+
module_class = None
160154
if model_type is not None and hf_config is not None and model_type in registry:
161155
module_class = registry.get(model_type)
162-
component_sources = _get_hf_component_sources(module_class, model_type, hf_config)
156+
manifest = task_obj.component_manifest(
157+
module_class=module_class,
158+
model_type=model_type,
159+
hf_config=hf_config,
160+
)
163161

164162
components = [
165163
ComponentInfo(
166-
name=name,
167-
role=role,
168-
source_paths=tuple(component_sources.get(name, ())),
164+
name=component.name,
165+
role=component.role,
166+
source_paths=component.source_paths,
169167
)
170-
for name, role in roles.items()
168+
for component in manifest.values()
171169
]
172170
logger.debug(
173171
"inspect_components(%s): task=%s components=%s",

0 commit comments

Comments
 (0)