metrik-torch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metrik_torch-0.1.0/.gitignore +89 -0
- metrik_torch-0.1.0/PKG-INFO +49 -0
- metrik_torch-0.1.0/README.md +31 -0
- metrik_torch-0.1.0/pyproject.toml +50 -0
- metrik_torch-0.1.0/src/metrik/torch/__init__.py +23 -0
- metrik_torch-0.1.0/src/metrik/torch/extract.py +273 -0
- metrik_torch-0.1.0/src/metrik/torch/loader.py +211 -0
- metrik_torch-0.1.0/src/metrik/torch/metrik_plugin.json +20 -0
- metrik_torch-0.1.0/src/metrik/torch/py.typed +0 -0
- metrik_torch-0.1.0/src/metrik/torch/roles.py +90 -0
- metrik_torch-0.1.0/tests/test_extract.py +332 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Metrik — .gitignore
|
|
2
|
+
#
|
|
3
|
+
# NOTE: the previous version of this file contained `docs/*`, `.gitignore`, and `.claude`,
|
|
4
|
+
# which excluded the entire documentation tree — i.e. every Phase 0/1 deliverable — from
|
|
5
|
+
# version control. Replaced deliberately. docs/ is the project's primary output right now.
|
|
6
|
+
|
|
7
|
+
# --- Build output that must never be committed ---
|
|
8
|
+
# ADR 016: diagram sources live in docs/diagrams/*.mmd; rendered images are build output.
|
|
9
|
+
docs/static/diagrams/
|
|
10
|
+
# ADR 015: generated reference docs are committed on purpose (drift detection), so they
|
|
11
|
+
# are NOT ignored here — see docs/reference/.
|
|
12
|
+
|
|
13
|
+
# --- Python ---
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*.so
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
dist/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.eggs/
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
env/
|
|
25
|
+
|
|
26
|
+
# Tooling caches
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.ruff_cache/
|
|
30
|
+
.hypothesis/
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
htmlcov/
|
|
34
|
+
coverage.xml
|
|
35
|
+
.benchmarks/
|
|
36
|
+
.tox/
|
|
37
|
+
.nox/
|
|
38
|
+
|
|
39
|
+
# uv (ADR 013) — uv.lock IS committed; the cache is not.
|
|
40
|
+
.uv/
|
|
41
|
+
|
|
42
|
+
# --- Node / web (ADR 009) ---
|
|
43
|
+
node_modules/
|
|
44
|
+
.next/
|
|
45
|
+
out/
|
|
46
|
+
.turbo/
|
|
47
|
+
*.tsbuildinfo
|
|
48
|
+
.pnpm-store/
|
|
49
|
+
|
|
50
|
+
# --- Tauri (ADR 010) ---
|
|
51
|
+
apps/desktop/src-tauri/target/
|
|
52
|
+
apps/desktop/src-tauri/gen/
|
|
53
|
+
|
|
54
|
+
# --- Docs site (ADR 015) ---
|
|
55
|
+
docs/.docusaurus/
|
|
56
|
+
docs/build/
|
|
57
|
+
|
|
58
|
+
# --- Metrik runtime data ---
|
|
59
|
+
# The artifact store and blob store (ADR 006). Content-addressed, machine-generated,
|
|
60
|
+
# gigabytes. Never in git.
|
|
61
|
+
.metrik/
|
|
62
|
+
*.metrik-store/
|
|
63
|
+
# Local config may hold API keys (ADR 018).
|
|
64
|
+
metrik.local.toml
|
|
65
|
+
.env
|
|
66
|
+
.env.*
|
|
67
|
+
!.env.example
|
|
68
|
+
|
|
69
|
+
# Model weights and checkpoints — always too large, often license-encumbered (plan.md §5.7).
|
|
70
|
+
*.safetensors
|
|
71
|
+
*.gguf
|
|
72
|
+
*.onnx
|
|
73
|
+
*.pt
|
|
74
|
+
*.pth
|
|
75
|
+
*.bin
|
|
76
|
+
# ...except the tiny-random fixtures, which are committed on purpose (ADR 017).
|
|
77
|
+
!tests/fixtures/**
|
|
78
|
+
|
|
79
|
+
# --- Editors and OS ---
|
|
80
|
+
.vscode/
|
|
81
|
+
!.vscode/extensions.json
|
|
82
|
+
.idea/
|
|
83
|
+
*.swp
|
|
84
|
+
.DS_Store
|
|
85
|
+
Thumbs.db
|
|
86
|
+
desktop.ini
|
|
87
|
+
|
|
88
|
+
# --- Local agent/tooling settings ---
|
|
89
|
+
.claude/settings.local.json
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metrik-torch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Metrik torch loader — observed ModelGraph extraction from a real nn.Module tree.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Asmodeus14/Metrik
|
|
6
|
+
Project-URL: Repository, https://github.com/Asmodeus14/Metrik
|
|
7
|
+
Project-URL: Issues, https://github.com/Asmodeus14/Metrik/issues
|
|
8
|
+
Author: The Metrik Authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: metrik-sdk==0.1.0
|
|
15
|
+
Provides-Extra: torch
|
|
16
|
+
Requires-Dist: torch>=2.2; extra == 'torch'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# metrik-torch
|
|
20
|
+
|
|
21
|
+
Observed `ModelGraph` extraction from a real `nn.Module` tree.
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ pip install "metrik-torch[torch]"
|
|
25
|
+
$ metrik explore ./models/TinyLlama-1.1B --loader torch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The counterpart to `metrik-explorer`, which reads safetensors headers and `config.json` and
|
|
29
|
+
never imports torch. This package walks the actual module tree, so module classes, parameter
|
|
30
|
+
ownership, and **weight tying** are observed facts rather than claims from a config file.
|
|
31
|
+
|
|
32
|
+
Tying in particular: an untied `lm_head` has exactly the same shape as the embedding it does
|
|
33
|
+
not share, so shape is not evidence. Here it is established by storage identity.
|
|
34
|
+
|
|
35
|
+
## What it still does not know
|
|
36
|
+
|
|
37
|
+
Dataflow. `nn.Module` records containment, not what feeds what, so `edges` is `None` and
|
|
38
|
+
`fidelity` is `structural`. Recovering real edges needs tracing, which breaks on the dynamic
|
|
39
|
+
control flow most model implementations have. A consumer that needs edges checks `fidelity`
|
|
40
|
+
and refuses rather than reading a containment tree as a dataflow graph.
|
|
41
|
+
|
|
42
|
+
No forward pass, no device transfer, no weight value read — module structure and parameter
|
|
43
|
+
metadata only.
|
|
44
|
+
|
|
45
|
+
## Why torch is an optional extra
|
|
46
|
+
|
|
47
|
+
`uv sync --all-packages --dev` stays torch-free so the CI matrix keeps running in about a
|
|
48
|
+
minute. One dedicated job installs the extra and exercises extraction. See
|
|
49
|
+
[ADR 007](../../docs/planning/adr/007-ml-substrate.md).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# metrik-torch
|
|
2
|
+
|
|
3
|
+
Observed `ModelGraph` extraction from a real `nn.Module` tree.
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
$ pip install "metrik-torch[torch]"
|
|
7
|
+
$ metrik explore ./models/TinyLlama-1.1B --loader torch
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The counterpart to `metrik-explorer`, which reads safetensors headers and `config.json` and
|
|
11
|
+
never imports torch. This package walks the actual module tree, so module classes, parameter
|
|
12
|
+
ownership, and **weight tying** are observed facts rather than claims from a config file.
|
|
13
|
+
|
|
14
|
+
Tying in particular: an untied `lm_head` has exactly the same shape as the embedding it does
|
|
15
|
+
not share, so shape is not evidence. Here it is established by storage identity.
|
|
16
|
+
|
|
17
|
+
## What it still does not know
|
|
18
|
+
|
|
19
|
+
Dataflow. `nn.Module` records containment, not what feeds what, so `edges` is `None` and
|
|
20
|
+
`fidelity` is `structural`. Recovering real edges needs tracing, which breaks on the dynamic
|
|
21
|
+
control flow most model implementations have. A consumer that needs edges checks `fidelity`
|
|
22
|
+
and refuses rather than reading a containment tree as a dataflow graph.
|
|
23
|
+
|
|
24
|
+
No forward pass, no device transfer, no weight value read — module structure and parameter
|
|
25
|
+
metadata only.
|
|
26
|
+
|
|
27
|
+
## Why torch is an optional extra
|
|
28
|
+
|
|
29
|
+
`uv sync --all-packages --dev` stays torch-free so the CI matrix keeps running in about a
|
|
30
|
+
minute. One dedicated job installs the extra and exercises extraction. See
|
|
31
|
+
[ADR 007](../../docs/planning/adr/007-ml-substrate.md).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metrik-torch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Metrik torch loader — observed ModelGraph extraction from a real nn.Module tree."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "The Metrik Authors" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# metrik-sdk ONLY, same as every other first-party producer: no privileged access, enforced by
|
|
20
|
+
# the `sdk-dogfooding` contract.
|
|
21
|
+
#
|
|
22
|
+
# torch is deliberately NOT here. It is an optional extra, so `uv sync --all-packages --dev`
|
|
23
|
+
# stays torch-free and the seven-way CI matrix keeps running in about a minute. A single
|
|
24
|
+
# dedicated job installs the extra and exercises extraction. The alternative -- torch as a hard
|
|
25
|
+
# dependency -- would put a multi-hundred-megabyte download in front of every contributor and
|
|
26
|
+
# every matrix job, to test one code path on one OS's worth of behaviour.
|
|
27
|
+
dependencies = [
|
|
28
|
+
"metrik-sdk==0.1.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# CPU is enough: this extracts structure, it never runs a forward pass. Anything that needs an
|
|
33
|
+
# accelerator belongs in the profiler, behind the GPU tier.
|
|
34
|
+
torch = ["torch>=2.2"]
|
|
35
|
+
|
|
36
|
+
# Discovered through the registry like any third-party plugin, so `metrik explore --loader
|
|
37
|
+
# torch` never imports this package by name.
|
|
38
|
+
[project.entry-points."metrik.loaders"]
|
|
39
|
+
torch = "metrik.torch:TorchLoader"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/Asmodeus14/Metrik"
|
|
43
|
+
Repository = "https://github.com/Asmodeus14/Metrik"
|
|
44
|
+
Issues = "https://github.com/Asmodeus14/Metrik/issues"
|
|
45
|
+
|
|
46
|
+
[tool.uv.sources]
|
|
47
|
+
metrik-sdk = { workspace = true }
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/metrik"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Metrik torch loader -- observed ModelGraph extraction from a real ``nn.Module`` tree.
|
|
2
|
+
|
|
3
|
+
``torch`` is an optional extra. Importing this package without it raises immediately with a
|
|
4
|
+
remediation rather than failing later inside extraction, because "No module named 'torch'"
|
|
5
|
+
surfacing from three frames deep in a loader reads as a Metrik bug rather than a missing
|
|
6
|
+
install.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from metrik.torch.extract import ROOT_NODE_ID, extract
|
|
12
|
+
from metrik.torch.loader import INFO, TorchLoader, TorchLoaderParams
|
|
13
|
+
from metrik.torch.roles import role_of_module, tensor_kind_of
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"INFO",
|
|
17
|
+
"ROOT_NODE_ID",
|
|
18
|
+
"TorchLoader",
|
|
19
|
+
"TorchLoaderParams",
|
|
20
|
+
"extract",
|
|
21
|
+
"role_of_module",
|
|
22
|
+
"tensor_kind_of",
|
|
23
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Extracting a :class:`~metrik.sdk.ModelGraph` from a real ``nn.Module`` tree.
|
|
2
|
+
|
|
3
|
+
``artifacts.md`` §6.2, ``method: torch_module_tree``. This is the *observed* counterpart to the
|
|
4
|
+
Explorer's ``config_only`` path: the module tree is walked, so module classes, parameter
|
|
5
|
+
ownership, and weight tying are facts about the object rather than claims from a config file.
|
|
6
|
+
|
|
7
|
+
What it still does **not** know is dataflow. ``nn.Module`` records containment -- which module
|
|
8
|
+
holds which -- and nothing about what feeds what. Recovering that needs tracing, which breaks
|
|
9
|
+
on the dynamic control flow most model implementations have. So ``fidelity`` stays
|
|
10
|
+
``structural`` and ``edges`` stays ``None``; a consumer that needs real edges checks the field
|
|
11
|
+
and refuses rather than reading a containment tree as a dataflow graph.
|
|
12
|
+
|
|
13
|
+
Nothing here runs a forward pass, moves a tensor to a device, or reads a weight value. The
|
|
14
|
+
module tree and parameter *metadata* are all that is touched, so extraction is fast and cannot
|
|
15
|
+
accidentally become a benchmark.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from metrik.sdk import (
|
|
23
|
+
IR_VERSION,
|
|
24
|
+
Block,
|
|
25
|
+
ExtractionInfo,
|
|
26
|
+
ExtractionMethod,
|
|
27
|
+
Fidelity,
|
|
28
|
+
ModelGraph,
|
|
29
|
+
Node,
|
|
30
|
+
NodeKind,
|
|
31
|
+
TensorDecl,
|
|
32
|
+
TensorKind,
|
|
33
|
+
Topology,
|
|
34
|
+
)
|
|
35
|
+
from metrik.torch.roles import role_of_module, tensor_kind_of
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING: # pragma: no cover - torch is an optional extra
|
|
38
|
+
import torch.nn as nn
|
|
39
|
+
|
|
40
|
+
__all__ = ["ROOT_NODE_ID", "extract"]
|
|
41
|
+
|
|
42
|
+
#: The root module has no name in ``named_modules()``, and an empty ``node_id`` is not legal.
|
|
43
|
+
#: Angle brackets cannot occur in a Python attribute path, so this cannot collide with a real
|
|
44
|
+
#: module -- including the very common case of a root whose child is itself called ``model``.
|
|
45
|
+
ROOT_NODE_ID = "<model>"
|
|
46
|
+
|
|
47
|
+
#: torch dtype repr -> the safetensors spelling, so a graph from this loader and one from the
|
|
48
|
+
#: header reader use one vocabulary. An unlisted dtype passes through as its torch name rather
|
|
49
|
+
#: than being coerced into a wrong one.
|
|
50
|
+
_DTYPE_NAMES = {
|
|
51
|
+
"torch.float32": "F32",
|
|
52
|
+
"torch.float16": "F16",
|
|
53
|
+
"torch.bfloat16": "BF16",
|
|
54
|
+
"torch.float64": "F64",
|
|
55
|
+
"torch.int8": "I8",
|
|
56
|
+
"torch.uint8": "U8",
|
|
57
|
+
"torch.int32": "I32",
|
|
58
|
+
"torch.int64": "I64",
|
|
59
|
+
"torch.bool": "BOOL",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dtype_name(dtype: Any) -> str:
|
|
64
|
+
return _DTYPE_NAMES.get(str(dtype), str(dtype))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _qualified(name: str) -> str:
|
|
68
|
+
return name or ROOT_NODE_ID
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract(
|
|
72
|
+
model: nn.Module,
|
|
73
|
+
*,
|
|
74
|
+
loader_name: str,
|
|
75
|
+
loader_version: str,
|
|
76
|
+
config: dict[str, Any] | None = None,
|
|
77
|
+
topology: Topology | None = None,
|
|
78
|
+
) -> ModelGraph:
|
|
79
|
+
"""Walk ``model`` and build the graph.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
topology: Declared topology, usually read from ``config.json``. Merged *under* what
|
|
83
|
+
the module tree observes: an observed value always wins, because the config is a
|
|
84
|
+
claim and the tree is the object. ``tied_embeddings`` is the case that matters --
|
|
85
|
+
configs get it wrong, and here it is checked by identity.
|
|
86
|
+
"""
|
|
87
|
+
nodes: list[Node] = []
|
|
88
|
+
tensors: list[TensorDecl] = []
|
|
89
|
+
unsupported: set[str] = set()
|
|
90
|
+
|
|
91
|
+
# Storage identity -> the first name that referred to it. Two parameters that are the same
|
|
92
|
+
# object are tied, and this is *observed*: no config key, no shape heuristic. Shape
|
|
93
|
+
# equality is not evidence -- an untied lm_head has the same shape as the embedding it does
|
|
94
|
+
# not share -- so identity is the only sound test, and torch gives it to us directly.
|
|
95
|
+
seen_storage: dict[int, str] = {}
|
|
96
|
+
|
|
97
|
+
for name, module in model.named_modules():
|
|
98
|
+
node_id = _qualified(name)
|
|
99
|
+
role = role_of_module(name, type(module).__name__)
|
|
100
|
+
if role.value == "other" and _has_own_parameters(module):
|
|
101
|
+
# Only report a miss for modules that actually own weights. Containers like
|
|
102
|
+
# `ModuleList` are legitimately unclassifiable and listing them would drown the
|
|
103
|
+
# signal that matters: a *parameterised* module we failed to understand.
|
|
104
|
+
unsupported.add(type(module).__name__)
|
|
105
|
+
|
|
106
|
+
own = _own_tensors(module)
|
|
107
|
+
for tensor_name, tensor, is_buffer in own:
|
|
108
|
+
full = f"{name}.{tensor_name}" if name else tensor_name
|
|
109
|
+
key = _storage_key(tensor)
|
|
110
|
+
tied_to = seen_storage.get(key)
|
|
111
|
+
if tied_to is None:
|
|
112
|
+
seen_storage[key] = full
|
|
113
|
+
tensors.append(
|
|
114
|
+
TensorDecl(
|
|
115
|
+
name=full,
|
|
116
|
+
shape=tuple(tensor.shape),
|
|
117
|
+
dtype=_dtype_name(tensor.dtype),
|
|
118
|
+
numel=tensor.numel(),
|
|
119
|
+
# element_size() rather than a dtype table: it is right for every dtype
|
|
120
|
+
# torch supports, including ones added after this was written.
|
|
121
|
+
bytes_on_disk=tensor.numel() * tensor.element_size(),
|
|
122
|
+
kind=TensorKind.BUFFER if is_buffer else tensor_kind_of(full, role),
|
|
123
|
+
owner_node=node_id,
|
|
124
|
+
tied_to=tied_to,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
nodes.append(
|
|
129
|
+
Node(
|
|
130
|
+
node_id=node_id,
|
|
131
|
+
qualified_name=node_id,
|
|
132
|
+
kind=NodeKind.MODULE,
|
|
133
|
+
role=role,
|
|
134
|
+
module_class=type(module).__name__,
|
|
135
|
+
layer_index=_layer_index(name),
|
|
136
|
+
param_tensors=tuple(sorted(f"{name}.{n}" if name else n for n, _, _ in own)),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
nodes.sort(key=lambda n: n.node_id)
|
|
141
|
+
tensors.sort(key=lambda t: t.name)
|
|
142
|
+
blocks = _blocks_of(model)
|
|
143
|
+
|
|
144
|
+
return ModelGraph(
|
|
145
|
+
ir_version=IR_VERSION,
|
|
146
|
+
nodes=tuple(nodes),
|
|
147
|
+
# Containment is not dataflow. See the module docstring.
|
|
148
|
+
edges=None,
|
|
149
|
+
tensors=tuple(tensors),
|
|
150
|
+
blocks=tuple(blocks),
|
|
151
|
+
topology=_merge_topology(topology, tensors, blocks, config),
|
|
152
|
+
extraction=ExtractionInfo(
|
|
153
|
+
method=ExtractionMethod.TORCH_MODULE_TREE,
|
|
154
|
+
fidelity=Fidelity.STRUCTURAL,
|
|
155
|
+
loader_name=loader_name,
|
|
156
|
+
loader_version=loader_version,
|
|
157
|
+
unsupported=tuple(sorted(unsupported)),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _storage_key(tensor: Any) -> int:
|
|
163
|
+
"""Identify the underlying storage.
|
|
164
|
+
|
|
165
|
+
``data_ptr()`` rather than ``id(tensor)``: tying shares *storage*, and two Parameter
|
|
166
|
+
objects can wrap one allocation. A meta-device or otherwise unallocated tensor reports 0,
|
|
167
|
+
which would make every such tensor look tied to every other, so those fall back to object
|
|
168
|
+
identity and are simply never reported as tied.
|
|
169
|
+
"""
|
|
170
|
+
pointer = int(tensor.data_ptr())
|
|
171
|
+
return pointer if pointer else -id(tensor)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _has_own_parameters(module: nn.Module) -> bool:
|
|
175
|
+
return any(True for _ in module.parameters(recurse=False))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _own_tensors(module: nn.Module) -> list[tuple[str, Any, bool]]:
|
|
179
|
+
"""Parameters and buffers declared on this module, not on its children.
|
|
180
|
+
|
|
181
|
+
``recurse=False`` is what makes ``owner_node`` meaningful: with recursion every ancestor
|
|
182
|
+
would claim every descendant's weights, and parameter counts per node would multiply by
|
|
183
|
+
tree depth.
|
|
184
|
+
"""
|
|
185
|
+
out: list[tuple[str, Any, bool]] = [
|
|
186
|
+
(n, p, False) for n, p in module.named_parameters(recurse=False)
|
|
187
|
+
]
|
|
188
|
+
out += [(n, b, True) for n, b in module.named_buffers(recurse=False)]
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _layer_index(name: str) -> int | None:
|
|
193
|
+
"""The decoder layer this module sits in, if any.
|
|
194
|
+
|
|
195
|
+
Read from the qualified name, which is where torch puts ``ModuleList`` indices. Modules
|
|
196
|
+
outside the repeating stack get ``None`` rather than 0 -- collapsing them into layer 0
|
|
197
|
+
makes the first layer look anomalously large in every depth profile.
|
|
198
|
+
"""
|
|
199
|
+
for part in name.split("."):
|
|
200
|
+
if part.isdigit():
|
|
201
|
+
return int(part)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _blocks_of(model: nn.Module) -> list[Block]:
|
|
206
|
+
"""Group the repeating stack.
|
|
207
|
+
|
|
208
|
+
Every ``ModuleList`` is a repeat group, keyed by its own qualified name. That is what lets
|
|
209
|
+
32 identical decoder layers be recognised as one structure rather than 32 unrelated ones,
|
|
210
|
+
which is what makes per-layer plans and visualizations comparable across model depths.
|
|
211
|
+
"""
|
|
212
|
+
import torch.nn as nn_
|
|
213
|
+
|
|
214
|
+
blocks: list[Block] = []
|
|
215
|
+
for name, module in model.named_modules():
|
|
216
|
+
if not isinstance(module, nn_.ModuleList) or not len(module):
|
|
217
|
+
continue
|
|
218
|
+
for index, child in enumerate(module):
|
|
219
|
+
child_name = f"{name}.{index}" if name else str(index)
|
|
220
|
+
blocks.append(
|
|
221
|
+
Block(
|
|
222
|
+
index=index,
|
|
223
|
+
block_class=type(child).__name__,
|
|
224
|
+
repeat_group_id=name or ROOT_NODE_ID,
|
|
225
|
+
node_ids=tuple(
|
|
226
|
+
sorted(
|
|
227
|
+
f"{child_name}.{sub}" if sub else child_name
|
|
228
|
+
for sub, _ in child.named_modules()
|
|
229
|
+
)
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return blocks
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _merge_topology(
|
|
237
|
+
declared: Topology | None,
|
|
238
|
+
tensors: list[TensorDecl],
|
|
239
|
+
blocks: list[Block],
|
|
240
|
+
config: dict[str, Any] | None,
|
|
241
|
+
) -> Topology:
|
|
242
|
+
"""Combine what was declared with what was observed, observation winning.
|
|
243
|
+
|
|
244
|
+
Only two fields are genuinely observable from a module tree without tracing, and both are
|
|
245
|
+
worth overriding the config for:
|
|
246
|
+
|
|
247
|
+
``n_layers`` -- counted from the repeating stack. A config can disagree with the weights it
|
|
248
|
+
ships, and the weights are what will be loaded.
|
|
249
|
+
|
|
250
|
+
``tied_embeddings`` -- established by storage identity above. This is the field configs
|
|
251
|
+
most often get wrong, and getting it wrong changes the parameter count by the size of the
|
|
252
|
+
entire embedding matrix.
|
|
253
|
+
"""
|
|
254
|
+
base = declared.model_dump() if declared is not None else {}
|
|
255
|
+
|
|
256
|
+
observed_layers = len({b.repeat_group_id for b in blocks}) and max(
|
|
257
|
+
(
|
|
258
|
+
len([b for b in blocks if b.repeat_group_id == g])
|
|
259
|
+
for g in {b.repeat_group_id for b in blocks}
|
|
260
|
+
),
|
|
261
|
+
default=0,
|
|
262
|
+
)
|
|
263
|
+
if observed_layers:
|
|
264
|
+
base["n_layers"] = observed_layers
|
|
265
|
+
|
|
266
|
+
base["tied_embeddings"] = any(t.tied_to is not None for t in tensors)
|
|
267
|
+
|
|
268
|
+
if config and base.get("vocab_size") is None:
|
|
269
|
+
vocab = config.get("vocab_size")
|
|
270
|
+
if isinstance(vocab, int) and not isinstance(vocab, bool):
|
|
271
|
+
base["vocab_size"] = vocab
|
|
272
|
+
|
|
273
|
+
return Topology(**base)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The ``ModelLoader`` plugin the registry resolves for ``--loader torch``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from metrik.sdk import (
|
|
10
|
+
DeterminismClass,
|
|
11
|
+
Emission,
|
|
12
|
+
Omission,
|
|
13
|
+
Params,
|
|
14
|
+
PartialInfo,
|
|
15
|
+
ProducerInfo,
|
|
16
|
+
ReasonCode,
|
|
17
|
+
UnsupportedError,
|
|
18
|
+
UserError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["INFO", "TorchLoader", "TorchLoaderParams"]
|
|
22
|
+
|
|
23
|
+
INFO = ProducerInfo(name="torch", version="0.0.1", api_version="1.0.0", kind="builtin")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TorchLoaderParams(Params):
|
|
27
|
+
"""Everything that changes what is extracted.
|
|
28
|
+
|
|
29
|
+
``trust_remote_code`` is here and defaults off because loading a model with custom code
|
|
30
|
+
executes the model author's Python. It is recorded in the artifact, so a graph extracted
|
|
31
|
+
with it on is distinguishable from one extracted without.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
uri: str = ""
|
|
35
|
+
trust_remote_code: bool = False
|
|
36
|
+
#: Instantiate on the meta device: shapes and structure without allocating weights. On by
|
|
37
|
+
#: default because extraction never reads a weight value, so materialising gigabytes to
|
|
38
|
+
#: inspect the tree is pure cost. Turn it off only to inspect a model already in memory.
|
|
39
|
+
meta_device: bool = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_torch() -> Any:
|
|
43
|
+
"""Import torch, or fail with something a user can act on.
|
|
44
|
+
|
|
45
|
+
A bare ImportError three frames into extraction reads as a Metrik bug. This names the
|
|
46
|
+
install that fixes it.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
import torch
|
|
50
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
51
|
+
raise UnsupportedError(
|
|
52
|
+
"the torch loader needs PyTorch, which is not installed",
|
|
53
|
+
remediation=(
|
|
54
|
+
"Install it with `pip install 'metrik-torch[torch]'`, or use the header-only "
|
|
55
|
+
"loader with `metrik explore --loader explorer`."
|
|
56
|
+
),
|
|
57
|
+
) from exc
|
|
58
|
+
return torch
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TorchLoader:
|
|
62
|
+
"""Builds a ModelGraph by walking a real module tree."""
|
|
63
|
+
|
|
64
|
+
info = INFO
|
|
65
|
+
params_model = TorchLoaderParams
|
|
66
|
+
|
|
67
|
+
def can_load(self, uri: str) -> bool:
|
|
68
|
+
path = Path(uri).expanduser()
|
|
69
|
+
return (path / "config.json").is_file() if path.is_dir() else False
|
|
70
|
+
|
|
71
|
+
def graph_of_module(self, module: Any, *, config: dict[str, Any] | None = None) -> Any:
|
|
72
|
+
"""Extract from an already-instantiated module.
|
|
73
|
+
|
|
74
|
+
The seam that makes this testable without transformers or a checkpoint: a plain
|
|
75
|
+
``nn.Module`` built in a test exercises exactly the code a real model does.
|
|
76
|
+
"""
|
|
77
|
+
from metrik.torch.extract import extract
|
|
78
|
+
|
|
79
|
+
return extract(
|
|
80
|
+
module,
|
|
81
|
+
loader_name=INFO.name,
|
|
82
|
+
loader_version=INFO.version,
|
|
83
|
+
config=config,
|
|
84
|
+
topology=_declared_topology(config or {}),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def load(self, uri: str, params: TorchLoaderParams | None = None) -> Any:
|
|
88
|
+
"""Instantiate the model from a directory and extract its graph."""
|
|
89
|
+
params = params or TorchLoaderParams()
|
|
90
|
+
torch = _require_torch()
|
|
91
|
+
path = Path(uri).expanduser()
|
|
92
|
+
config_path = path / "config.json"
|
|
93
|
+
if not config_path.is_file():
|
|
94
|
+
raise UserError(
|
|
95
|
+
f"{uri} has no config.json, so there is nothing to instantiate",
|
|
96
|
+
remediation=(
|
|
97
|
+
"Point at a model directory, or use `--loader explorer` to analyse "
|
|
98
|
+
"weights without instantiating anything."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
from transformers import AutoConfig, AutoModelForCausalLM
|
|
105
|
+
except ImportError as exc:
|
|
106
|
+
raise UnsupportedError(
|
|
107
|
+
"instantiating a model from a directory needs transformers",
|
|
108
|
+
remediation=(
|
|
109
|
+
"Install it with `pip install transformers`, or use "
|
|
110
|
+
"`metrik explore --loader explorer` for a header-only analysis."
|
|
111
|
+
),
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
auto_config = AutoConfig.from_pretrained(
|
|
115
|
+
str(path), trust_remote_code=params.trust_remote_code
|
|
116
|
+
)
|
|
117
|
+
if params.meta_device:
|
|
118
|
+
# Structure only. No weights are allocated, which is what makes inspecting a 70B
|
|
119
|
+
# model on a laptop possible -- and is safe precisely because extraction never
|
|
120
|
+
# reads a value.
|
|
121
|
+
with torch.device("meta"):
|
|
122
|
+
# transformers ships no annotations for from_config, so these two calls are
|
|
123
|
+
# untyped in a strict context. Ignored narrowly rather than by relaxing the
|
|
124
|
+
# whole module: everything else here should stay checked.
|
|
125
|
+
model = AutoModelForCausalLM.from_config( # type: ignore[no-untyped-call]
|
|
126
|
+
auto_config, trust_remote_code=params.trust_remote_code
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
model = AutoModelForCausalLM.from_config( # type: ignore[no-untyped-call]
|
|
130
|
+
auto_config, trust_remote_code=params.trust_remote_code
|
|
131
|
+
)
|
|
132
|
+
return self.graph_of_module(model, config=config)
|
|
133
|
+
|
|
134
|
+
def provide_graph(self, spine: Any, uri: str, fingerprint_id: Any) -> Emission:
|
|
135
|
+
"""The graph step, in the shape the explore chain expects.
|
|
136
|
+
|
|
137
|
+
Presence of this method is what marks a loader as able to supply a graph to someone
|
|
138
|
+
else's pipeline, so the CLI never has to know how any particular loader is built.
|
|
139
|
+
|
|
140
|
+
Resolves the fingerprint through the spine rather than being handed it, because the
|
|
141
|
+
spine records what a producer reads and checks it against what the producer declares.
|
|
142
|
+
Being passed the artifact directly would leave nothing observed and make that check
|
|
143
|
+
vacuous (``sdk-contracts.md`` §5).
|
|
144
|
+
"""
|
|
145
|
+
fingerprint = spine.get(fingerprint_id)
|
|
146
|
+
graph = self.load(uri)
|
|
147
|
+
return self.emission(graph, fingerprint.envelope.content_hash)
|
|
148
|
+
|
|
149
|
+
def emission(self, graph: Any, fingerprint_hash: Any) -> Emission:
|
|
150
|
+
"""Wrap an extracted graph as an artifact emission.
|
|
151
|
+
|
|
152
|
+
The omissions are not optional decoration. ``edges`` is null here, and a null field
|
|
153
|
+
with no recorded reason is indistinguishable from a bug (``artifacts.md`` §2.3) --
|
|
154
|
+
it also vanished from the CLI's "not determined" panel, so the gap became invisible
|
|
155
|
+
precisely where a reader would look for it. Anything a producer cannot determine is
|
|
156
|
+
named, with a reason, every time.
|
|
157
|
+
"""
|
|
158
|
+
payload = graph.model_dump(mode="json", exclude_none=True)
|
|
159
|
+
payload["fingerprint"] = str(fingerprint_hash)
|
|
160
|
+
|
|
161
|
+
omissions = [
|
|
162
|
+
Omission(
|
|
163
|
+
field_path="edges",
|
|
164
|
+
reason_code=ReasonCode.MISSING_CAPABILITY,
|
|
165
|
+
detail=("nn.Module records containment, not dataflow, so no edges were observed"),
|
|
166
|
+
remediation=(
|
|
167
|
+
"Dataflow needs tracing, which is not implemented and fails on the "
|
|
168
|
+
"dynamic control flow most model implementations have."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
]
|
|
172
|
+
if graph.extraction.unsupported:
|
|
173
|
+
named = ", ".join(graph.extraction.unsupported)
|
|
174
|
+
omissions.append(
|
|
175
|
+
Omission(
|
|
176
|
+
field_path="nodes[].role",
|
|
177
|
+
reason_code=ReasonCode.MISSING_CAPABILITY,
|
|
178
|
+
detail=(
|
|
179
|
+
f"{len(graph.extraction.unsupported)} parameterised module class(es) "
|
|
180
|
+
f"were not classified: {named}"
|
|
181
|
+
),
|
|
182
|
+
remediation=(
|
|
183
|
+
"Those nodes are tagged `other`; check "
|
|
184
|
+
"GraphSummary.classification_confidence before using this for a "
|
|
185
|
+
"cross-architecture claim."
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return Emission(
|
|
191
|
+
artifact_type="metrik.ModelGraph",
|
|
192
|
+
schema_version=1,
|
|
193
|
+
payload=payload,
|
|
194
|
+
determinism=DeterminismClass.DETERMINISTIC,
|
|
195
|
+
inputs={"fingerprint": fingerprint_hash},
|
|
196
|
+
partial=PartialInfo(complete=False, omissions=tuple(omissions)),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _declared_topology(config: dict[str, Any]) -> Any:
|
|
201
|
+
"""Reuse the Explorer's config reader when it is installed.
|
|
202
|
+
|
|
203
|
+
Imported lazily and optionally rather than depended on: duplicating the alias tables would
|
|
204
|
+
guarantee they drift, but a hard dependency would make the torch loader unusable without
|
|
205
|
+
the header-only one. Absent it, the module tree still supplies what it observes.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
from metrik.explorer.topology import topology_of
|
|
209
|
+
except ImportError: # pragma: no cover - explorer is installed in this workspace
|
|
210
|
+
return None
|
|
211
|
+
return topology_of(config)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"torch": {
|
|
3
|
+
"name": "torch",
|
|
4
|
+
"kind": "model_loader",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"metrik_api": ">=1.0,<2.0",
|
|
7
|
+
"summary": "Observed ModelGraph extraction from a real nn.Module tree. Structural fidelity: no dataflow edges.",
|
|
8
|
+
"homepage": "https://github.com/Asmodeus14/Metrik",
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"requires_device": [],
|
|
11
|
+
"requires_gradients": false,
|
|
12
|
+
"requires_calibration": false,
|
|
13
|
+
"requires_container": false,
|
|
14
|
+
"determinism": "deterministic",
|
|
15
|
+
"device_sensitive": false,
|
|
16
|
+
"writes_files": false,
|
|
17
|
+
"needs_network": false,
|
|
18
|
+
"executes_untrusted_code": true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Mapping a module to its canonical :class:`~metrik.sdk.RoleTag`.
|
|
2
|
+
|
|
3
|
+
Classified from the **qualified name**, not the module class. Almost every projection in a
|
|
4
|
+
transformer is an ``nn.Linear``, so the class says nothing; the name says which one it is. The
|
|
5
|
+
class is still recorded on the node, and is what identifies an unclassifiable *parameterised*
|
|
6
|
+
module in ``ExtractionInfo.unsupported``.
|
|
7
|
+
|
|
8
|
+
This is pattern matching on naming conventions, which is a heuristic and will eventually
|
|
9
|
+
misclassify something. That is why nothing here guesses: a name that matches nothing is
|
|
10
|
+
``OTHER``, its class is reported as unsupported, and the share of parameters landing in
|
|
11
|
+
``OTHER`` is a confidence signal a consumer can act on (``artifacts.md`` §6.2).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from metrik.sdk import RoleTag, TensorKind
|
|
20
|
+
|
|
21
|
+
__all__ = ["role_of_module", "tensor_kind_of"]
|
|
22
|
+
|
|
23
|
+
#: Matched in order, first hit wins. Order is load-bearing in two places: the fused patterns
|
|
24
|
+
#: must precede the single-projection ones, or ``qkv_proj`` is read as ``q_proj``; and the
|
|
25
|
+
#: expert patterns must precede the plain MLP ones, or every expert is filed as a dense MLP
|
|
26
|
+
#: and a MoE model's parameter breakdown becomes meaningless.
|
|
27
|
+
_PATTERNS: Final[tuple[tuple[re.Pattern[str], RoleTag], ...]] = tuple(
|
|
28
|
+
(re.compile(pattern), role)
|
|
29
|
+
for pattern, role in (
|
|
30
|
+
# MoE first.
|
|
31
|
+
(r"(^|\.)(gate|router)$", RoleTag.MOE_ROUTER),
|
|
32
|
+
(r"experts?\.\d+\.(w1|gate_proj)$", RoleTag.MOE_EXPERT_GATE),
|
|
33
|
+
(r"experts?\.\d+\.(w3|up_proj)$", RoleTag.MOE_EXPERT_UP),
|
|
34
|
+
(r"experts?\.\d+\.(w2|down_proj)$", RoleTag.MOE_EXPERT_DOWN),
|
|
35
|
+
(r"shared_expert", RoleTag.MOE_SHARED),
|
|
36
|
+
# Fused before single.
|
|
37
|
+
(r"(qkv_proj|c_attn|Wqkv|in_proj)$", RoleTag.ATTN_QKV_FUSED),
|
|
38
|
+
(r"gate_up_proj$", RoleTag.MLP_FUSED),
|
|
39
|
+
# Attention projections.
|
|
40
|
+
(r"(q_proj|query|wq)$", RoleTag.ATTN_Q),
|
|
41
|
+
(r"(k_proj|key|wk)$", RoleTag.ATTN_K),
|
|
42
|
+
(r"(v_proj|value|wv)$", RoleTag.ATTN_V),
|
|
43
|
+
(r"(o_proj|out_proj|dense|wo|c_proj)$", RoleTag.ATTN_O),
|
|
44
|
+
# MLP.
|
|
45
|
+
(r"gate_proj$", RoleTag.MLP_GATE),
|
|
46
|
+
(r"(up_proj|fc1|c_fc|w1)$", RoleTag.MLP_UP),
|
|
47
|
+
(r"(down_proj|fc2|w2)$", RoleTag.MLP_DOWN),
|
|
48
|
+
# Norms. `input_layernorm` guards the attention block, `post_attention_layernorm` the
|
|
49
|
+
# MLP -- naming that reads backwards until you notice it is named for what precedes it.
|
|
50
|
+
(r"(input_layernorm|ln_1|attention_norm|pre_attn_norm)$", RoleTag.ATTN_NORM),
|
|
51
|
+
(r"(post_attention_layernorm|ln_2|ffn_norm|pre_mlp_norm)$", RoleTag.MLP_NORM),
|
|
52
|
+
# Final norm: anchored to the top level, so a per-layer `model.layers.3.norm` cannot
|
|
53
|
+
# match it.
|
|
54
|
+
(r"^(model\.norm|transformer\.ln_f|norm|ln_f)$", RoleTag.NORM_FINAL),
|
|
55
|
+
# Embeddings and head.
|
|
56
|
+
(r"(embed_tokens|wte|tok_embeddings|word_embeddings)$", RoleTag.EMBED_TOKENS),
|
|
57
|
+
(r"(embed_positions|wpe|position_embeddings)$", RoleTag.EMBED_POS),
|
|
58
|
+
(r"(lm_head|output|score)$", RoleTag.LM_HEAD),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: Module classes that hold parameters but carry no role of their own -- reporting them as
|
|
63
|
+
#: unsupported would be noise, since there is nothing to classify.
|
|
64
|
+
_CONTAINERS: Final[frozenset[str]] = frozenset({"ModuleList", "Sequential", "ModuleDict"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def role_of_module(qualified_name: str, module_class: str) -> RoleTag:
|
|
68
|
+
"""Classify one module. Returns ``OTHER`` rather than guessing."""
|
|
69
|
+
if not qualified_name or module_class in _CONTAINERS:
|
|
70
|
+
return RoleTag.OTHER
|
|
71
|
+
for pattern, role in _PATTERNS:
|
|
72
|
+
if pattern.search(qualified_name):
|
|
73
|
+
return role
|
|
74
|
+
return RoleTag.OTHER
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def tensor_kind_of(tensor_name: str, role: RoleTag) -> TensorKind:
|
|
78
|
+
"""Classify a parameter from its name and its owner's role.
|
|
79
|
+
|
|
80
|
+
Checked in this order deliberately: a norm's ``bias`` is a bias, not a norm scale, and a
|
|
81
|
+
norm's ``weight`` is a scale rather than a weight matrix. Getting that backwards puts
|
|
82
|
+
per-tensor statistics for a scalar scale in the same bucket as a projection matrix.
|
|
83
|
+
"""
|
|
84
|
+
if tensor_name.endswith(".bias") or tensor_name == "bias":
|
|
85
|
+
return TensorKind.BIAS
|
|
86
|
+
if role in (RoleTag.ATTN_NORM, RoleTag.MLP_NORM, RoleTag.NORM_FINAL):
|
|
87
|
+
return TensorKind.NORM_SCALE
|
|
88
|
+
if role in (RoleTag.EMBED_TOKENS, RoleTag.EMBED_POS):
|
|
89
|
+
return TensorKind.EMBEDDING
|
|
90
|
+
return TensorKind.WEIGHT
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Extraction against a hand-built module tree.
|
|
2
|
+
|
|
3
|
+
No transformers, no checkpoint, no download. A plain ``nn.Module`` shaped like a decoder-only
|
|
4
|
+
transformer exercises exactly the code a real model does, and keeps this runnable in the one
|
|
5
|
+
CI job that installs torch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
pytest.importorskip("torch", reason="metrik-torch[torch] is not installed")
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
from torch import nn
|
|
18
|
+
|
|
19
|
+
from metrik.sdk import ExtractionMethod, Fidelity, RoleTag, TensorKind
|
|
20
|
+
from metrik.torch import ROOT_NODE_ID, TorchLoader, extract
|
|
21
|
+
|
|
22
|
+
pytestmark = pytest.mark.unit
|
|
23
|
+
|
|
24
|
+
HIDDEN, HEADS, KV_HEADS, INTER, VOCAB, LAYERS = 64, 4, 2, 176, 512, 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Attention(nn.Module):
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
head_dim = HIDDEN // HEADS
|
|
31
|
+
self.q_proj = nn.Linear(HIDDEN, HEADS * head_dim, bias=False)
|
|
32
|
+
self.k_proj = nn.Linear(HIDDEN, KV_HEADS * head_dim, bias=False)
|
|
33
|
+
self.v_proj = nn.Linear(HIDDEN, KV_HEADS * head_dim, bias=False)
|
|
34
|
+
self.o_proj = nn.Linear(HEADS * head_dim, HIDDEN, bias=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Mlp(nn.Module):
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.gate_proj = nn.Linear(HIDDEN, INTER, bias=False)
|
|
41
|
+
self.up_proj = nn.Linear(HIDDEN, INTER, bias=False)
|
|
42
|
+
self.down_proj = nn.Linear(INTER, HIDDEN, bias=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DecoderLayer(nn.Module):
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.self_attn = Attention()
|
|
49
|
+
self.mlp = Mlp()
|
|
50
|
+
self.input_layernorm = nn.LayerNorm(HIDDEN)
|
|
51
|
+
self.post_attention_layernorm = nn.LayerNorm(HIDDEN)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Inner(nn.Module):
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.embed_tokens = nn.Embedding(VOCAB, HIDDEN)
|
|
58
|
+
self.layers = nn.ModuleList(DecoderLayer() for _ in range(LAYERS))
|
|
59
|
+
self.norm = nn.LayerNorm(HIDDEN)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TinyCausalLM(nn.Module):
|
|
63
|
+
def __init__(self, *, tie: bool = False) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.model = Inner()
|
|
66
|
+
self.lm_head = nn.Linear(HIDDEN, VOCAB, bias=False)
|
|
67
|
+
if tie:
|
|
68
|
+
# Exactly how transformers ties: the same Parameter object under two names.
|
|
69
|
+
self.lm_head.weight = self.model.embed_tokens.weight
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def graph() -> object:
|
|
74
|
+
return extract(TinyCausalLM(), loader_name="torch", loader_version="0.0.1")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _node(graph: object, node_id: str) -> object:
|
|
78
|
+
return next(n for n in graph.nodes if n.node_id == node_id) # type: ignore[attr-defined]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tensor(graph: object, name: str) -> object:
|
|
82
|
+
return next(t for t in graph.tensors if t.name == name) # type: ignore[attr-defined]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- structure ------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_node_ids_are_qualified_names(graph: object) -> None:
|
|
89
|
+
ids = {n.node_id for n in graph.nodes} # type: ignore[attr-defined]
|
|
90
|
+
assert "model.layers.1.self_attn.q_proj" in ids
|
|
91
|
+
assert "model.embed_tokens" in ids
|
|
92
|
+
# The root has no name in named_modules(), and an empty id is not legal.
|
|
93
|
+
assert ROOT_NODE_ID in ids
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_the_root_id_cannot_collide_with_a_real_module(graph: object) -> None:
|
|
97
|
+
"""A root whose child is itself called `model` is the common case, so the sentinel uses
|
|
98
|
+
characters that cannot occur in a Python attribute path."""
|
|
99
|
+
assert "model" in {n.node_id for n in graph.nodes} # type: ignore[attr-defined]
|
|
100
|
+
assert ROOT_NODE_ID != "model"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_module_classes_are_recorded(graph: object) -> None:
|
|
104
|
+
"""The thing the header-only loader fundamentally cannot know."""
|
|
105
|
+
assert _node(graph, "model.layers.0.self_attn.q_proj").module_class == "Linear" # type: ignore[attr-defined]
|
|
106
|
+
assert _node(graph, "model.layers.0").module_class == "DecoderLayer" # type: ignore[attr-defined]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_parameters_are_owned_by_the_module_that_declares_them(graph: object) -> None:
|
|
110
|
+
"""`recurse=False` is what makes owner_node meaningful: with recursion every ancestor
|
|
111
|
+
would claim every descendant's weights and per-node counts would multiply by depth."""
|
|
112
|
+
assert _tensor(graph, "model.layers.0.self_attn.q_proj.weight").owner_node == ( # type: ignore[attr-defined]
|
|
113
|
+
"model.layers.0.self_attn.q_proj"
|
|
114
|
+
)
|
|
115
|
+
assert _node(graph, "model.layers.0").param_tensors == () # type: ignore[attr-defined]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_layer_index_is_none_outside_the_stack(graph: object) -> None:
|
|
119
|
+
"""0 would make the first layer look anomalously large in every depth profile."""
|
|
120
|
+
assert _node(graph, "model.layers.2.mlp.up_proj").layer_index == 2 # type: ignore[attr-defined]
|
|
121
|
+
assert _node(graph, "model.embed_tokens").layer_index is None # type: ignore[attr-defined]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- roles ----------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize(
|
|
128
|
+
("node_id", "role"),
|
|
129
|
+
[
|
|
130
|
+
("model.layers.0.self_attn.q_proj", RoleTag.ATTN_Q),
|
|
131
|
+
("model.layers.0.self_attn.k_proj", RoleTag.ATTN_K),
|
|
132
|
+
("model.layers.0.self_attn.v_proj", RoleTag.ATTN_V),
|
|
133
|
+
("model.layers.0.self_attn.o_proj", RoleTag.ATTN_O),
|
|
134
|
+
("model.layers.0.mlp.gate_proj", RoleTag.MLP_GATE),
|
|
135
|
+
("model.layers.0.mlp.down_proj", RoleTag.MLP_DOWN),
|
|
136
|
+
("model.layers.0.input_layernorm", RoleTag.ATTN_NORM),
|
|
137
|
+
("model.layers.0.post_attention_layernorm", RoleTag.MLP_NORM),
|
|
138
|
+
("model.embed_tokens", RoleTag.EMBED_TOKENS),
|
|
139
|
+
("model.norm", RoleTag.NORM_FINAL),
|
|
140
|
+
("lm_head", RoleTag.LM_HEAD),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
def test_roles(graph: object, node_id: str, role: RoleTag) -> None:
|
|
144
|
+
assert _node(graph, node_id).role is role # type: ignore[attr-defined]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_a_per_layer_norm_is_not_the_final_norm(graph: object) -> None:
|
|
148
|
+
"""`model.norm` is anchored, so a hypothetical `model.layers.3.norm` cannot match it --
|
|
149
|
+
which would otherwise put a per-layer norm in the final-norm role."""
|
|
150
|
+
assert _node(graph, "model.norm").role is RoleTag.NORM_FINAL # type: ignore[attr-defined]
|
|
151
|
+
assert _node(graph, "model.layers.0.input_layernorm").role is not RoleTag.NORM_FINAL # type: ignore[attr-defined]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_containers_are_not_reported_as_unsupported(graph: object) -> None:
|
|
155
|
+
"""A ModuleList is legitimately unclassifiable. Listing it would drown the signal that
|
|
156
|
+
matters: a *parameterised* module we failed to understand."""
|
|
157
|
+
assert "ModuleList" not in graph.extraction.unsupported # type: ignore[attr-defined]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_an_unrecognised_parameterised_module_is_reported() -> None:
|
|
161
|
+
class Mystery(nn.Module):
|
|
162
|
+
def __init__(self) -> None:
|
|
163
|
+
super().__init__()
|
|
164
|
+
self.weird_thing = nn.Linear(4, 4)
|
|
165
|
+
|
|
166
|
+
result = extract(Mystery(), loader_name="torch", loader_version="0.0.1")
|
|
167
|
+
assert "Linear" in result.extraction.unsupported
|
|
168
|
+
assert _node(result, "weird_thing").role is RoleTag.OTHER # type: ignore[attr-defined]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# -- weight tying, observed rather than declared ---------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_tying_is_detected_by_storage_identity() -> None:
|
|
175
|
+
"""The headline improvement over the header-only loader, which cannot see this at all:
|
|
176
|
+
an untied lm_head has exactly the same shape as the embedding it does not share."""
|
|
177
|
+
graph = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
|
|
178
|
+
head = _tensor(graph, "lm_head.weight")
|
|
179
|
+
assert head.tied_to == "model.embed_tokens.weight" # type: ignore[attr-defined]
|
|
180
|
+
assert graph.topology.tied_embeddings is True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_an_untied_model_reports_no_tying(graph: object) -> None:
|
|
184
|
+
assert _tensor(graph, "lm_head.weight").tied_to is None # type: ignore[attr-defined]
|
|
185
|
+
assert graph.topology.tied_embeddings is False # type: ignore[attr-defined]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_a_tied_weight_is_counted_once() -> None:
|
|
189
|
+
untied = extract(TinyCausalLM(), loader_name="torch", loader_version="0.0.1")
|
|
190
|
+
tied = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
|
|
191
|
+
assert untied.param_count - tied.param_count == VOCAB * HIDDEN
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# -- blocks ---------------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_the_decoder_stack_is_one_repeat_group(graph: object) -> None:
|
|
198
|
+
"""What lets N identical layers be recognised as one structure rather than N unrelated
|
|
199
|
+
ones, which is what makes per-layer plans comparable across model depths."""
|
|
200
|
+
groups = {b.repeat_group_id for b in graph.blocks} # type: ignore[attr-defined]
|
|
201
|
+
assert groups == {"model.layers"}
|
|
202
|
+
assert len(graph.blocks) == LAYERS # type: ignore[attr-defined]
|
|
203
|
+
assert {b.block_class for b in graph.blocks} == {"DecoderLayer"} # type: ignore[attr-defined]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_layer_count_is_observed_from_the_stack(graph: object) -> None:
|
|
207
|
+
assert graph.topology.n_layers == LAYERS # type: ignore[attr-defined]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# -- fidelity -------------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_extraction_is_structural_with_no_edges(graph: object) -> None:
|
|
214
|
+
"""nn.Module records containment, not dataflow. Claiming edges here would be fabricating
|
|
215
|
+
them, and the IR rejects the combination outright."""
|
|
216
|
+
assert graph.extraction.method is ExtractionMethod.TORCH_MODULE_TREE # type: ignore[attr-defined]
|
|
217
|
+
assert graph.extraction.fidelity is Fidelity.STRUCTURAL # type: ignore[attr-defined]
|
|
218
|
+
assert graph.edges is None # type: ignore[attr-defined]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_tensor_kinds_distinguish_a_norm_scale_from_a_weight(graph: object) -> None:
|
|
222
|
+
assert _tensor(graph, "model.layers.0.input_layernorm.weight").kind is TensorKind.NORM_SCALE # type: ignore[attr-defined]
|
|
223
|
+
assert _tensor(graph, "model.layers.0.input_layernorm.bias").kind is TensorKind.BIAS # type: ignore[attr-defined]
|
|
224
|
+
assert _tensor(graph, "model.embed_tokens.weight").kind is TensorKind.EMBEDDING # type: ignore[attr-defined]
|
|
225
|
+
assert _tensor(graph, "model.layers.0.mlp.up_proj.weight").kind is TensorKind.WEIGHT # type: ignore[attr-defined]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# -- determinism ----------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_two_extractions_of_one_model_are_identical() -> None:
|
|
232
|
+
"""Nodes and tensors are sorted precisely so this holds: module traversal order is not
|
|
233
|
+
stable across transformers versions, and an unsorted graph would hash differently for an
|
|
234
|
+
unchanged model."""
|
|
235
|
+
model = TinyCausalLM()
|
|
236
|
+
first = extract(model, loader_name="torch", loader_version="0.0.1")
|
|
237
|
+
second = extract(model, loader_name="torch", loader_version="0.0.1")
|
|
238
|
+
assert first.model_dump_json() == second.model_dump_json()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_extraction_never_reads_a_weight_value() -> None:
|
|
242
|
+
"""Proved on the meta device, where the tensors have shapes but no storage at all. If
|
|
243
|
+
extraction touched a value this would raise; succeeding means only metadata was read --
|
|
244
|
+
which is what makes inspecting a 70B model on a laptop possible.
|
|
245
|
+
"""
|
|
246
|
+
with torch.device("meta"):
|
|
247
|
+
model = TinyCausalLM(tie=True)
|
|
248
|
+
graph = extract(model, loader_name="torch", loader_version="0.0.1")
|
|
249
|
+
assert graph.param_count > 0
|
|
250
|
+
assert _node(graph, "model.layers.0.self_attn.q_proj").module_class == "Linear" # type: ignore[attr-defined]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_tying_survives_the_meta_device() -> None:
|
|
254
|
+
"""The loader instantiates on `meta` by default, and every meta tensor reports
|
|
255
|
+
`data_ptr() == 0` -- so the obvious storage test would silently stop detecting tying on
|
|
256
|
+
the one path users actually take. It holds because tying shares the same Parameter object,
|
|
257
|
+
so identity still separates them, and the meta and real counts must agree exactly.
|
|
258
|
+
"""
|
|
259
|
+
with torch.device("meta"):
|
|
260
|
+
meta = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
|
|
261
|
+
real = extract(TinyCausalLM(tie=True), loader_name="torch", loader_version="0.0.1")
|
|
262
|
+
|
|
263
|
+
assert _tensor(meta, "lm_head.weight").tied_to == "model.embed_tokens.weight" # type: ignore[attr-defined]
|
|
264
|
+
assert meta.topology.tied_embeddings is True
|
|
265
|
+
assert meta.param_count == real.param_count
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_meta_tensors_are_not_all_reported_as_tied_to_each_other() -> None:
|
|
269
|
+
"""Every meta tensor reports data_ptr() == 0. Keying tying on that alone would make the
|
|
270
|
+
entire model look like one shared weight, and the parameter count collapse to a single
|
|
271
|
+
tensor -- a wrong number that looks like a very efficient model.
|
|
272
|
+
"""
|
|
273
|
+
with torch.device("meta"):
|
|
274
|
+
model = TinyCausalLM()
|
|
275
|
+
graph = extract(model, loader_name="torch", loader_version="0.0.1")
|
|
276
|
+
tied = [t for t in graph.tensors if t.tied_to is not None]
|
|
277
|
+
assert tied == []
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# -- the loader contract ----------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_the_loader_extracts_from_an_instantiated_module() -> None:
|
|
284
|
+
graph = TorchLoader().graph_of_module(TinyCausalLM(), config={"vocab_size": VOCAB})
|
|
285
|
+
assert graph.topology.vocab_size == VOCAB
|
|
286
|
+
assert graph.extraction.loader_name == "torch"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_can_load_requires_a_directory_with_a_config(tmp_path: Path) -> None:
|
|
290
|
+
loader = TorchLoader()
|
|
291
|
+
assert not loader.can_load(str(tmp_path))
|
|
292
|
+
(tmp_path / "config.json").write_text("{}", encoding="utf-8")
|
|
293
|
+
assert loader.can_load(str(tmp_path))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# -- honesty ---------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_the_emission_declares_its_missing_edges() -> None:
|
|
300
|
+
"""A null field with no recorded reason is indistinguishable from a bug.
|
|
301
|
+
|
|
302
|
+
Caught by running it: the graph had `edges: None` and no omission, so it silently dropped
|
|
303
|
+
out of the CLI's "not determined" panel -- disappearing from exactly the place a reader
|
|
304
|
+
looks to find out what was not determined.
|
|
305
|
+
"""
|
|
306
|
+
from metrik.core.hashing import Digest
|
|
307
|
+
|
|
308
|
+
loader = TorchLoader()
|
|
309
|
+
graph = loader.graph_of_module(TinyCausalLM())
|
|
310
|
+
emission = loader.emission(graph, Digest.parse("b3:" + "11" * 32))
|
|
311
|
+
|
|
312
|
+
assert emission.partial is not None
|
|
313
|
+
assert not emission.partial.complete
|
|
314
|
+
paths = {o.field_path for o in emission.partial.omissions}
|
|
315
|
+
assert "edges" in paths
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_unclassified_modules_are_surfaced_as_an_omission() -> None:
|
|
319
|
+
"""A graph where a parameterised module went unrecognised should say so, because the
|
|
320
|
+
share of parameters tagged `other` is what a consumer checks before making a
|
|
321
|
+
cross-architecture claim."""
|
|
322
|
+
from metrik.core.hashing import Digest
|
|
323
|
+
|
|
324
|
+
class Mystery(nn.Module):
|
|
325
|
+
def __init__(self) -> None:
|
|
326
|
+
super().__init__()
|
|
327
|
+
self.weird_thing = nn.Linear(4, 4)
|
|
328
|
+
|
|
329
|
+
loader = TorchLoader()
|
|
330
|
+
emission = loader.emission(loader.graph_of_module(Mystery()), Digest.parse("b3:" + "22" * 32))
|
|
331
|
+
assert emission.partial is not None
|
|
332
|
+
assert "nodes[].role" in {o.field_path for o in emission.partial.omissions}
|