File size: 10,797 Bytes
ecc81b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """A JSON description of a model's N-D architecture.
The viewer reads this; the library never imports anything JS-adjacent and never
opens a socket. Keeping the boundary at a versioned document also makes the
spec useful on its own β for diffing two configs, for debugging a schedule, and
for documenting what a model actually does along each axis.
import torch_dimensions as td
spec = td.spec(model) # plain dict, json.dumps-able
Everything here is derived without running a forward pass, so a spec can be
taken before any data exists.
"""
from __future__ import annotations
import math
from typing import Any, cast
import torch
import torch.nn as nn
from torch_dimensions.lattice import Lattice
from torch_dimensions.plan import ScanPlan
__all__ = ["SPEC_VERSION", "spec"]
SPEC_FORMAT = "torch-dimensions/architecture"
# v2: layers describe what their *family* actually does. v1 assumed every
# model was a scan, so a kernel-family model's spec claimed one spatial sweep
# per layer β sweeps that never happen β and the viewer drew them. See
# DEBUG.md #26.
SPEC_VERSION = 2
def _rle(flags: torch.Tensor) -> list[int]:
"""Run-length encode a flattened bool mask, starting with a False run.
Lossless and compact, which matters because the viewer needs every cell's
presence to render and a coordinate list would be enormous on a large
lattice. A fully present lattice encodes as ``[0, n]``.
"""
runs: list[int] = []
current = False
count = 0
for value in flags.reshape(-1).tolist():
if bool(value) == current:
count += 1
else:
runs.append(count)
current, count = bool(value), 1
runs.append(count)
return runs
def lattice_spec(lat: Lattice) -> dict[str, Any]:
"""Describe a lattice, including which cells exist."""
axes: list[dict[str, Any]] = []
if lat.time:
# Time has no static size; saying so beats emitting a fake one.
axes.append({"name": "time", "size": None, "dynamic": True})
for name, size in zip(lat.names or (), lat.shape, strict=True):
axes.append({"name": name, "size": size, "dynamic": False})
present = torch.ones(lat.shape, dtype=torch.bool) if lat.valid is None else lat.valid
return {
"shape": list(lat.shape),
"names": list(lat.axis_names),
"time": lat.time,
"rank": lat.rank,
"n_axes": lat.n_axes,
"axes": axes,
"cells": {
"total": lat.n_cells,
"present": lat.n_valid,
"dense": lat.is_dense,
# RLE over the flattened lattice in row-major order.
"present_rle": _rle(present),
},
}
def plan_spec(plan: ScanPlan, lat: Lattice) -> list[dict[str, Any]]:
"""Per-layer sweep schedule, with axes named rather than indexed.
The scan family's layer description: one axis, one direction, per layer.
The other families do something else and say so β see :func:`layers_spec`.
"""
resolved = plan.resolve(lat) if not plan.is_resolved() else plan
return [
{
"layer": i,
"kind": "scan",
"axis": lat.axis_names[cast(int, step.axis)],
"axis_index": cast(int, step.axis),
"reverse": step.reverse,
"axes": [lat.axis_names[cast(int, step.axis)]],
}
for i, step in enumerate(resolved)
]
def _family(nd: nn.Module) -> str:
"""Which composition family this model uses.
Was hardcoded to ``"scan"``, which made every kernel-family spec claim to
be something it is not (DEBUG.md #26).
"""
from torch_dimensions.compose.attention import AxialKernel
from torch_dimensions.compose.flatten import Flatten
from torch_dimensions.compose.scan import AxialScan
if isinstance(nd, AxialScan):
return "scan"
if isinstance(nd, AxialKernel):
return "kernel"
if isinstance(nd, Flatten):
return "flatten"
return type(nd).__name__
def flatten_layers_spec(nd: Any, lat: Lattice) -> list[dict[str, Any]]:
"""Per-layer description for the joint (flatten) family.
Every layer mixes every axis at once, so there is no axis to name and no
direction to give. ``axes`` lists what the layer actually spans, which for
this family is the whole lattice.
"""
spanned = [n for n in lat.axis_names if n != "time" or nd.join_time]
return [
{
"layer": i,
"kind": "flatten",
"axis": None,
"axis_index": None,
"reverse": False,
"axes": spanned,
# Present cells per timestep. With `join_time` the actual sequence
# is this times the (dynamic) number of timesteps, which is why
# the static document reports the part it can know.
"tokens": nd.seq_len,
"joins_time": bool(nd.join_time),
"mixer": type(nd.mixers[i]).__name__,
"n_params": _n_params(nd.mixers[i]),
}
for i in range(len(nd.plan))
]
def kernel_layers_spec(nd: Any, lat: Lattice) -> list[dict[str, Any]]:
"""Per-layer description for the kernel family.
Every layer contracts **all** the spatial axes β not one per layer β and
then, in the hybrid form, sweeps the mixer along time. Describing this with
the scan family's schema produced a document claiming layer 1 swept ``h``
with an LSTM, which is not what runs and is what the viewer drew.
"""
spatial = [lat.axis_names[a] for a in nd.spatial_axes]
has_mixer = getattr(nd, "mixers", None) is not None
out = []
for i in range(len(nd.plan)):
mixer = nd.mixers[i] if has_mixer else None
out.append(
{
"layer": i,
"kind": "kernel",
# The axis actually *swept*, which for this family is time or
# nothing at all.
"axis": "time" if has_mixer else None,
"axis_index": 0 if has_mixer else None,
"reverse": False,
"axes": [*spatial, *(["time"] if has_mixer else [])],
"contracted": spatial,
"mixer": type(mixer).__name__ if mixer is not None else None,
"n_params": _n_params(mixer) if mixer is not None else 0,
}
)
return out
def _n_params(module: nn.Module) -> int:
return sum(p.numel() for p in module.parameters())
def sweeps_spec(plan: ScanPlan, lat: Lattice) -> dict[str, Any]:
"""Which directions each axis is actually swept in, and which are missed.
Surfaced explicitly because "every layer sweeps this axis the same way" is
invisible in code and obvious in a picture β it is the failure the viewer
exists to make loud. Derived from :meth:`ScanPlan.coverage`, the one place
that computation lives.
"""
cov = plan.coverage(lat)
return {
"directions": cov.directions(),
"unswept_axes": list(cov.unswept),
"pinned_axes": list(cov.pinned),
"coverage": cov.to_dict(),
}
def spec(model: nn.Module) -> dict[str, Any]:
"""Build the architecture spec for a model.
Works on any model exposing ``.lattice`` and ``.nd`` β the shape every
model in the scan family has. Anything else raises rather than emitting a
half-filled document.
"""
describe = getattr(model, "to_spec", None)
if callable(describe):
return cast(dict, describe())
raise TypeError(
f"{type(model).__name__} does not describe itself; implement to_spec() "
"or pass one of the library's models"
)
def scan_model_spec(model: nn.Module) -> dict[str, Any]:
"""The spec for a composed model. Used by the models' ``to_spec``.
Named for the scan family because that is all there was when it was
written; it now describes whichever family the model actually uses.
"""
lat = cast(Lattice, model.lattice)
nd: Any = model.nd # Module.__getattr__ erases the type
plan: ScanPlan = nd.plan
family = _family(nd)
if family == "kernel":
layers = kernel_layers_spec(nd, lat)
spatial = [lat.axis_names[a] for a in nd.spatial_axes]
has_mixer = getattr(nd, "mixers", None) is not None
mixed = {*spatial, *(["time"] if has_mixer else [])}
sweeps: dict[str, Any] = {
# Only the axis a mixer actually sweeps has a direction. The
# kernels are not directional at all β a contraction has no
# forward or backward β so listing them here would invent a
# property the model does not have.
"directions": {"time": "forward"} if has_mixer else {},
"contracted_axes": spatial,
"unswept_axes": [n for n in lat.axis_names if n not in mixed],
"pinned_axes": ["time"] if has_mixer else [],
"coverage": None,
}
elif family == "flatten":
layers = flatten_layers_spec(nd, lat)
spanned = [n for n in lat.axis_names if n != "time" or nd.join_time]
sweeps = {
# Nothing is swept and nothing is contracted: one operator spans
# the whole lattice at once, with no direction to report.
"directions": {},
"contracted_axes": [],
"joint_axes": spanned,
"unswept_axes": [n for n in lat.axis_names if n not in spanned],
"pinned_axes": [],
"coverage": None,
}
else:
layers = plan_spec(plan, lat)
mixers = [
{"layer": i, "type": type(m).__name__, "n_params": _n_params(m)}
for i, m in enumerate(nd.mixers)
]
for layer, mixer in zip(layers, mixers, strict=True):
layer.update({"mixer": mixer["type"], "n_params": mixer["n_params"]})
sweeps = {**sweeps_spec(plan, lat), "contracted_axes": []}
d_model: int = nd.d_model
in_proj = getattr(model, "in_proj", None)
d_input = in_proj.in_features if isinstance(in_proj, nn.Linear) else d_model
lead = ["B", "T"] if lat.time else ["B"]
return {
"format": SPEC_FORMAT,
"version": SPEC_VERSION,
"model": {
"kind": type(model).__name__,
"d_model": d_model,
"d_input": d_input,
"n_layers": len(layers),
"n_params": _n_params(model),
},
"nd_method": {
"name": type(nd).__name__,
"family": family,
},
"lattice": lattice_spec(lat),
"layers": layers,
"sweeps": sweeps,
"io": {
"input": [*lead, *lat.shape, d_input],
"output": [*lead, *lat.shape, d_model],
"cells_per_step": math.prod(lat.shape),
},
}
|