dragonscale 0.1.0__py3-none-any.whl

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.
Files changed (61) hide show
  1. dragonscale/__init__.py +39 -0
  2. dragonscale/analysis/__init__.py +29 -0
  3. dragonscale/analysis/absorb.py +229 -0
  4. dragonscale/analysis/jacobian.py +59 -0
  5. dragonscale/analysis/report.py +208 -0
  6. dragonscale/analysis/rules.py +281 -0
  7. dragonscale/analysis/suggest.py +108 -0
  8. dragonscale/analysis/trace.py +171 -0
  9. dragonscale/functional/__init__.py +35 -0
  10. dragonscale/functional/layers.py +53 -0
  11. dragonscale/functional/rows.py +261 -0
  12. dragonscale/layer_types.py +131 -0
  13. dragonscale/monitor.py +82 -0
  14. dragonscale/nn/__init__.py +27 -0
  15. dragonscale/nn/gain.py +21 -0
  16. dragonscale/nn/layers.py +157 -0
  17. dragonscale/optim.py +143 -0
  18. dragonscale/patch/__init__.py +7 -0
  19. dragonscale/patch/forwards.py +24 -0
  20. dragonscale/patch/grouped.py +148 -0
  21. dragonscale/patch/patched.py +303 -0
  22. dragonscale/patch/session.py +195 -0
  23. dragonscale/patch/state.py +93 -0
  24. dragonscale/plan/__init__.py +9 -0
  25. dragonscale/plan/build.py +53 -0
  26. dragonscale/plan/layer_plan.py +81 -0
  27. dragonscale/plan/presets.py +27 -0
  28. dragonscale/plan/rules.py +39 -0
  29. dragonscale/plan/validate.py +119 -0
  30. dragonscale/spec/__init__.py +6 -0
  31. dragonscale/spec/defaults.py +36 -0
  32. dragonscale/spec/rows.py +68 -0
  33. dragonscale-0.1.0.dist-info/METADATA +463 -0
  34. dragonscale-0.1.0.dist-info/RECORD +61 -0
  35. dragonscale-0.1.0.dist-info/WHEEL +5 -0
  36. dragonscale-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. dragonscale-0.1.0.dist-info/top_level.txt +3 -0
  38. dragonscale_common/__init__.py +8 -0
  39. dragonscale_common/imports.py +24 -0
  40. dragonscale_common/log.py +47 -0
  41. dragonscale_common/problems.py +16 -0
  42. dragonscale_common/registry.py +66 -0
  43. dragonscale_kernels/__init__.py +9 -0
  44. dragonscale_kernels/backends/__init__.py +1 -0
  45. dragonscale_kernels/backends/compile.py +20 -0
  46. dragonscale_kernels/backends/eager/__init__.py +22 -0
  47. dragonscale_kernels/backends/eager/rows.py +89 -0
  48. dragonscale_kernels/backends/eager/scale.py +35 -0
  49. dragonscale_kernels/backends/tilelang/__init__.py +3 -0
  50. dragonscale_kernels/backends/triton/__init__.py +23 -0
  51. dragonscale_kernels/backends/triton/common.py +47 -0
  52. dragonscale_kernels/backends/triton/grouped.py +172 -0
  53. dragonscale_kernels/backends/triton/rows.py +255 -0
  54. dragonscale_kernels/constraints.py +26 -0
  55. dragonscale_kernels/error_stats.py +56 -0
  56. dragonscale_kernels/errors.py +5 -0
  57. dragonscale_kernels/hardware.py +47 -0
  58. dragonscale_kernels/layout.py +42 -0
  59. dragonscale_kernels/ops.py +79 -0
  60. dragonscale_kernels/registry.py +92 -0
  61. dragonscale_kernels/tuning.py +148 -0
@@ -0,0 +1,39 @@
1
+ """DragonScale: unit-row weights and free-scale analysis.
2
+
3
+ import dragonscale as ds
4
+
5
+ report = ds.analysis.free_scales(model, x) # which scales the loss cannot see
6
+ session = ds.apply(model, "unit_gain") # every Linear / Conv / Embedding, same function
7
+ optimizer = torch.optim.AdamW(model.parameters()) # gains are module parameters
8
+ hooks = ds.optim.attach(optimizer, session) # tangent steps, rows kept at the radius
9
+ ...
10
+ session.bake() # stock modules with the unit-row weights
11
+ """
12
+
13
+ from dragonscale import analysis, functional, layer_types, monitor, nn, optim, spec
14
+ from dragonscale.patch import Session, apply
15
+ from dragonscale.plan import PRESETS, LayerPlan, Plan, Rule, make_plan, preset, validate
16
+ from dragonscale.spec import RowSpec, defaults
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "PRESETS",
22
+ "LayerPlan",
23
+ "Plan",
24
+ "RowSpec",
25
+ "Rule",
26
+ "Session",
27
+ "analysis",
28
+ "apply",
29
+ "defaults",
30
+ "functional",
31
+ "layer_types",
32
+ "make_plan",
33
+ "monitor",
34
+ "nn",
35
+ "optim",
36
+ "preset",
37
+ "spec",
38
+ "validate",
39
+ ]
@@ -0,0 +1,29 @@
1
+ """Free-scale analysis: degree propagation over a real forward, the free directions it leaves,
2
+ the scale-Jacobian spectrum, the plan that removes the dangerous ones, and scale moves.
3
+
4
+ report = ds.analysis.free_scales(model, x)
5
+ print(report.describe())
6
+ plan = ds.analysis.suggest(model, x, level="absorb")
7
+ session = ds.apply(model, ds.analysis.absorb_plan(model, plan, x))
8
+ """
9
+
10
+ from dragonscale.analysis.absorb import absorb, absorb_plan
11
+ from dragonscale.analysis.jacobian import ScaleJacobian, scale_jacobian
12
+ from dragonscale.analysis.report import Direction, FreeScales, free_scales
13
+ from dragonscale.analysis.rules import register_degree_rule
14
+ from dragonscale.analysis.suggest import suggest
15
+ from dragonscale.analysis.trace import DegreeTrace, trace_degrees
16
+
17
+ __all__ = [
18
+ "DegreeTrace",
19
+ "Direction",
20
+ "FreeScales",
21
+ "ScaleJacobian",
22
+ "absorb",
23
+ "absorb_plan",
24
+ "free_scales",
25
+ "register_degree_rule",
26
+ "scale_jacobian",
27
+ "suggest",
28
+ "trace_degrees",
29
+ ]
@@ -0,0 +1,229 @@
1
+ """`absorb`: move a source's per-channel scale into its consumers' input columns, keeping the
2
+ function; `absorb_plan` makes a plan's moves and absorbs its `init="absorb"` layers.
3
+
4
+ The source is a module with a layer type (its rows are rescaled to the radius) or a norm with an
5
+ affine (its weight is divided by its magnitude). Consumers are given, or found by
6
+ `find_consumers`: the modules with a layer type whose first input carries the source's degree,
7
+ taken in call order until the move is a free direction. The move must be free on the given inputs,
8
+ and the output must not change.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import replace
13
+
14
+ import torch
15
+ from torch import Tensor, nn
16
+
17
+ from dragonscale.analysis.report import FreeScales, solve
18
+ from dragonscale.analysis.trace import DegreeTrace, trace_degrees
19
+ from dragonscale.functional.rows import row_norms
20
+ from dragonscale.layer_types import layer_type_of
21
+ from dragonscale.plan.layer_plan import Plan
22
+ from dragonscale.spec.rows import RowSpec
23
+ from dragonscale_common.problems import raise_problems
24
+ from dragonscale_kernels.layout import as_rows
25
+ from dragonscale_kernels.ops import FLOOR
26
+
27
+
28
+ def _source(
29
+ module: nn.Module, spec: RowSpec | None
30
+ ) -> tuple[Tensor, Tensor | None, RowSpec | None]:
31
+ """The source weight, its bias, and its row spec (None for a norm). A layer's spec must index
32
+ rows by output channel: rows along `out_axis`, or the layer type's own default."""
33
+ layer_type = layer_type_of(module)
34
+ if layer_type is not None:
35
+ default = layer_type.spec(module)
36
+ spec = spec or default
37
+ plain = spec.view is None and tuple(spec.row_dims) == (layer_type.out_axis,)
38
+ same = (tuple(spec.row_dims), spec.view) == (tuple(default.row_dims), default.view)
39
+ if not (plain or same):
40
+ raise ValueError(f"{layer_type.name}: absorb needs rows along the output channels")
41
+ bias = getattr(module, layer_type.bias, None) if layer_type.bias else None
42
+ return getattr(module, layer_type.weight), bias, spec
43
+ weight = getattr(module, "weight", None)
44
+ if not isinstance(weight, Tensor) or weight.dim() != 1:
45
+ raise ValueError(f"{type(module).__name__} is neither a layer type nor an affine norm")
46
+ return weight, getattr(module, "bias", None), None
47
+
48
+
49
+ def _outputs(model: nn.Module, inputs, kwargs) -> list[Tensor]:
50
+ out = model(*inputs, **kwargs)
51
+ flat = (
52
+ out.values()
53
+ if isinstance(out, dict)
54
+ else (out if isinstance(out, tuple | list) else (out,))
55
+ )
56
+ return [t.detach().clone() for t in flat if isinstance(t, Tensor) and t.is_floating_point()]
57
+
58
+
59
+ def _input_weight(module: nn.Module, channels: int) -> tuple[Tensor, int] | None:
60
+ """The weight of a module with an input-channel axis of size `channels`, and that axis."""
61
+ layer_type = layer_type_of(module)
62
+ if layer_type is None or layer_type.in_axis is None:
63
+ return None
64
+ weight = getattr(module, layer_type.weight)
65
+ return (weight, layer_type.in_axis) if weight.shape[layer_type.in_axis] == channels else None
66
+
67
+
68
+ def _coeffs(params: dict[int, str], weight: Tensor, bias, consumers) -> dict[str, float]:
69
+ """Source weight (and bias) up, every consumer weight down."""
70
+ coeffs = {params[id(weight)]: 1.0}
71
+ if isinstance(bias, Tensor):
72
+ coeffs[params[id(bias)]] = 1.0
73
+ for cw, _ in consumers:
74
+ coeffs[params[id(cw)]] = coeffs.get(params[id(cw)], 0.0) - 1.0
75
+ return coeffs
76
+
77
+
78
+ def _tied(model: nn.Module, weight: Tensor) -> list[str]:
79
+ """Every module that holds `weight` directly."""
80
+ return [p for p, m in model.named_modules() if any(q is weight for q in m.parameters(False))]
81
+
82
+
83
+ def find_consumers(
84
+ model: nn.Module, source: str, trace: DegreeTrace, report: FreeScales
85
+ ) -> list[str] | None:
86
+ """The consumers of `source` in call order, added until the move is free; None when there is
87
+ no such set, the source weight is tied, or its rows are not along the output channels."""
88
+ modules = dict(model.named_modules())
89
+ params = {id(p): n for n, p in model.named_parameters()}
90
+ try:
91
+ weight, bias, spec = _source(modules[source], None)
92
+ except ValueError:
93
+ return None
94
+ if len(_tied(model, weight)) > 1:
95
+ return None
96
+ channels = weight.numel() if spec is None else spec.rows(weight.shape)
97
+ k = trace.names.index(params[id(weight)])
98
+ chosen: list[str] = []
99
+ for path, degree in trace.module_inputs.items():
100
+ if path == source or abs(degree[k].item()) < 1e-9:
101
+ continue
102
+ if _input_weight(modules[path], channels) is None:
103
+ continue
104
+ chosen.append(path)
105
+ consumers = [_input_weight(modules[p], channels) for p in chosen]
106
+ if report.contains(_coeffs(params, weight, bias, consumers)):
107
+ return chosen
108
+ return None
109
+
110
+
111
+ def _restore_modes(modes: list[tuple[nn.Module, bool]]) -> None:
112
+ """Each module back to its training mode, parents first."""
113
+ for module, training in modes:
114
+ if module.training != training:
115
+ module.train(training)
116
+
117
+
118
+ def absorb(
119
+ model: nn.Module,
120
+ source: str,
121
+ *inputs,
122
+ into: Sequence[str] | None = None,
123
+ spec: RowSpec | None = None,
124
+ rtol: float = 1e-4,
125
+ **kwargs,
126
+ ) -> float:
127
+ """Move `source`'s channel scale into `into` (None: found); returns the largest relative
128
+ output change. Runs the model in eval mode with TF32 off, and restores both after."""
129
+ modes = [(m, m.training) for m in model.modules()]
130
+ tf32 = torch.backends.cuda.matmul.allow_tf32, torch.backends.cudnn.allow_tf32
131
+ model.eval()
132
+ torch.backends.cuda.matmul.allow_tf32 = torch.backends.cudnn.allow_tf32 = False
133
+ try:
134
+ return _absorb(model, source, inputs, into, spec, rtol, kwargs)
135
+ finally:
136
+ torch.backends.cuda.matmul.allow_tf32, torch.backends.cudnn.allow_tf32 = tf32
137
+ _restore_modes(modes)
138
+
139
+
140
+ @torch.no_grad()
141
+ def _absorb(
142
+ model: nn.Module,
143
+ source: str,
144
+ inputs: tuple,
145
+ into: Sequence[str] | None,
146
+ spec: RowSpec | None,
147
+ rtol: float,
148
+ kwargs: dict,
149
+ ) -> float:
150
+ modules = dict(model.named_modules())
151
+ params = {id(p): n for n, p in model.named_parameters()}
152
+ weight, bias, spec = _source(modules[source], spec)
153
+ holders = _tied(model, weight)
154
+ if len(holders) > 1:
155
+ raise ValueError(f"{source}: its weight is tied to {holders}; absorb does not move it")
156
+ scale = weight.abs() if spec is None else row_norms(weight, spec) / spec.radius_of(weight.shape)
157
+ channels = scale.numel()
158
+
159
+ # Consumers: given, or found from the degree trace
160
+ trace = trace_degrees(model, *inputs, **kwargs)
161
+ report = solve(trace)
162
+ if into is None:
163
+ into = find_consumers(model, source, trace, report)
164
+ if into is None:
165
+ raise ValueError(f"{source}: no set of consumers makes a free direction; pass `into`")
166
+ consumers, problems = [], []
167
+ for path in into:
168
+ found = _input_weight(modules[path], channels)
169
+ if found is None:
170
+ problems.append(f"{path}: no input-channel axis of size {channels}")
171
+ else:
172
+ consumers.append(found)
173
+ raise_problems(problems)
174
+ if not report.contains(_coeffs(params, weight, bias, consumers)):
175
+ raise ValueError(f"absorbing {source} into {list(into)} is not a free direction")
176
+
177
+ before = _outputs(model, inputs, kwargs)
178
+ saved = [
179
+ weight.clone(),
180
+ None if bias is None else bias.clone(),
181
+ *(cw.clone() for cw, _ in consumers),
182
+ ]
183
+ factor = torch.where(scale > FLOOR, scale, torch.ones_like(scale))
184
+
185
+ # Source channels divided by their scale, consumer columns multiplied by it
186
+ if spec is None:
187
+ weight.div_(factor.to(weight.dtype))
188
+ else:
189
+ view = spec.view_of(weight.shape)
190
+ rows = weight.reshape(view) / as_rows(factor.to(weight.dtype), view, spec.row_dims)
191
+ weight.copy_(rows.reshape(weight.shape))
192
+ if isinstance(bias, Tensor):
193
+ bias.div_(factor.to(bias.dtype))
194
+ for cw, axis in consumers:
195
+ shape = [1] * cw.dim()
196
+ shape[axis] = -1
197
+ cw.mul_(factor.to(cw.dtype).reshape(shape))
198
+
199
+ # The output must not change; undo otherwise
200
+ after = _outputs(model, inputs, kwargs)
201
+ change = max(
202
+ ((a - b).abs().max() / b.abs().max().clamp_min(1e-30)).item()
203
+ for a, b in zip(after, before, strict=True)
204
+ )
205
+ if change > rtol:
206
+ for target, value in zip([weight, bias, *(cw for cw, _ in consumers)], saved, strict=True):
207
+ if value is not None:
208
+ target.copy_(value)
209
+ raise ValueError(f"absorbing {source} into {list(into)} changed the output by {change:.2e}")
210
+ return change
211
+
212
+
213
+ def absorb_plan(model: nn.Module, plan: Plan, *inputs, rtol: float = 1e-4, **kwargs) -> Plan:
214
+ """Make the plan's moves in order, then absorb every other `init="absorb"` layer; returns the
215
+ plan without moves, ready for `ds.apply`. A moved norm's affine weight is frozen."""
216
+ modules = dict(model.named_modules())
217
+ layers = {layer.path: layer for layer in plan}
218
+ moved = set()
219
+ for source, into in plan.moves:
220
+ layer = layers.get(source)
221
+ spec = None if layer is None else layer.spec
222
+ absorb(model, source, *inputs, into=into, spec=spec, rtol=rtol, **kwargs)
223
+ if layer_type_of(modules[source]) is None:
224
+ modules[source].weight.requires_grad_(False)
225
+ moved.add(source)
226
+ for layer in plan:
227
+ if layer.init == "absorb" and layer.tied_to is None and layer.path not in moved:
228
+ absorb(model, layer.path, *inputs, spec=layer.spec, rtol=rtol, **kwargs)
229
+ return replace(plan, moves=())
@@ -0,0 +1,59 @@
1
+ """`scale_jacobian`: `J[m, k] = <theta_k, grad_theta_k (rho_m . f(x))>` over random output
2
+ projections; `J s` is how fast the output moves along the log-scale direction `s`."""
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import torch
7
+ from torch import Tensor, nn
8
+ from torch.utils._pytree import tree_leaves
9
+
10
+ from dragonscale.analysis.trace import parameter_groups
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ScaleJacobian:
15
+ """`matrix` is `[projections, groups]`; its singular values and right singular vectors."""
16
+
17
+ names: list[str]
18
+ matrix: Tensor
19
+ singular: Tensor
20
+ directions: Tensor
21
+
22
+ def pinning(self, s: Tensor) -> float:
23
+ """`|J s| / (|J| |s|)`: 0 for a free direction, small for a weakly pinned one."""
24
+ scale = self.matrix.norm().item() * s.norm().item()
25
+ return (self.matrix @ s).norm().item() / max(scale, 1e-300)
26
+
27
+
28
+ def scale_jacobian(
29
+ model: nn.Module, *inputs, projections: int = 32, seed: int = 0, **kwargs
30
+ ) -> ScaleJacobian:
31
+ """The scale Jacobian of `model(*inputs, **kwargs)` over `projections` random projections;
32
+ the column of a parameter without `requires_grad` is 0."""
33
+ names, params = parameter_groups(model)
34
+ trained = [p for p in params if p.requires_grad]
35
+ with torch.enable_grad():
36
+ out = model(*inputs, **kwargs)
37
+ flat = torch.cat(
38
+ [
39
+ t.reshape(-1)
40
+ for t in tree_leaves(out)
41
+ if isinstance(t, Tensor) and t.is_floating_point()
42
+ ]
43
+ )
44
+ generator = torch.Generator(device="cpu").manual_seed(seed)
45
+ rows = []
46
+ for _ in range(projections):
47
+ rho = torch.randn(flat.shape, generator=generator, dtype=flat.dtype).to(flat.device)
48
+ grads = torch.autograd.grad(
49
+ (flat * rho).sum(), trained, retain_graph=True, allow_unused=True
50
+ )
51
+ by_param = {id(p): g for p, g in zip(trained, grads, strict=True)}
52
+ row = [
53
+ 0.0 if by_param.get(id(p)) is None else (by_param[id(p)] * p).sum().item()
54
+ for p in params
55
+ ]
56
+ rows.append(row)
57
+ matrix = torch.tensor(rows, dtype=torch.float64)
58
+ _, singular, vh = torch.linalg.svd(matrix, full_matrices=False)
59
+ return ScaleJacobian(names, matrix, singular, vh)
@@ -0,0 +1,208 @@
1
+ """`free_scales`: the null space of a degree trace's constraints, as named directions, each
2
+ labelled parameter-only, activation-moving, or dangerous (activation-moving with a matrix in it).
3
+
4
+ A weakly pinned direction is free under every constraint except anchors: only a fixed-scale addend
5
+ holds it, and once the scaled term outgrows that addend the direction is nearly free."""
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import torch
10
+ from torch import Tensor, nn
11
+
12
+ from dragonscale.analysis.trace import DegreeTrace, trace_degrees
13
+
14
+ TOL = 1e-9
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Direction:
19
+ """One free direction: the log-scale coefficient of every group in it, and its matrices
20
+ (`fast_ends`)."""
21
+
22
+ coeffs: dict[str, float]
23
+ moves_activations: bool
24
+ fast_ends: tuple[str, ...]
25
+ anchors: tuple[str, ...] = ()
26
+
27
+ @property
28
+ def dangerous(self) -> bool:
29
+ return self.moves_activations and bool(self.fast_ends)
30
+
31
+ def describe(self) -> str:
32
+ terms = " ".join(f"{c:+g}*{name}" for name, c in self.coeffs.items())
33
+ label = "dangerous" if self.dangerous else (
34
+ "activation-moving" if self.moves_activations else "parameter-only"
35
+ ) # fmt: skip
36
+ held = f" (held by: {'; '.join(self.anchors)})" if self.anchors else ""
37
+ return f"{label}: {terms}{held}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class FreeScales:
42
+ """Every free direction of one model on one input, in reduced row-echelon form, the weakly
43
+ pinned directions with the anchors that hold them (`weak`), and the architecture notes a weight
44
+ patch cannot address (`notes`)."""
45
+
46
+ names: list[str]
47
+ shapes: list[tuple[int, ...]]
48
+ directions: list[Direction]
49
+ basis: Tensor
50
+ constraints: int
51
+ unknown: dict[str, int]
52
+ notes: list[str]
53
+ weak: list[Direction]
54
+
55
+ @property
56
+ def dangerous(self) -> list[Direction]:
57
+ return [d for d in self.directions if d.dangerous]
58
+
59
+ def vector(self, coeffs: dict[str, float]) -> Tensor:
60
+ """A direction over every group from `{name: coefficient}`."""
61
+ out = torch.zeros(len(self.names), dtype=torch.float64)
62
+ for name, c in coeffs.items():
63
+ out[self.names.index(name)] = c
64
+ return out
65
+
66
+ def contains(self, coeffs: dict[str, float]) -> bool:
67
+ """Whether the direction `{name: coefficient}` is free."""
68
+ s = self.vector(coeffs)
69
+ if self.basis.numel() == 0:
70
+ return bool(s.abs().max() < TOL)
71
+ residual = s - self.basis @ torch.linalg.lstsq(self.basis, s[:, None]).solution[:, 0]
72
+ return bool(residual.norm() <= 1e-7 * max(s.norm().item(), 1.0))
73
+
74
+ def describe(self) -> str:
75
+ lines = [
76
+ f"{len(self.directions)} free direction(s), {len(self.dangerous)} dangerous, "
77
+ f"{self.constraints} constraints over {len(self.names)} groups"
78
+ ]
79
+ alone = [d for d in self.directions if len(d.coeffs) == 1 and not d.moves_activations]
80
+ lines += [f" {d.describe()}" for d in self.directions if d not in alone]
81
+ if alone:
82
+ names = ", ".join(next(iter(d.coeffs)) for d in alone)
83
+ lines.append(f" parameter-only, one group each ({len(alone)}): {names}")
84
+ if self.weak:
85
+ lines.append(f"{len(self.weak)} weakly pinned direction(s), held only by anchors:")
86
+ lines += [f" {d.describe()}" for d in self.weak]
87
+ if self.unknown:
88
+ lines.append(f" ops without a rule (treated as nonlinear): {dict(self.unknown)}")
89
+ if self.notes:
90
+ lines.append("architecture:")
91
+ lines += [f" {note}" for note in self.notes]
92
+ return "\n".join(lines)
93
+
94
+
95
+ def null_space(a: Tensor) -> Tensor:
96
+ """An orthonormal basis of `{s : a s = 0}`, `[groups, free]`."""
97
+ gram = a.T @ a
98
+ values, vectors = torch.linalg.eigh(gram)
99
+ scale = max(values.abs().max().item(), 1.0)
100
+ return vectors[:, values <= 1e-10 * scale]
101
+
102
+
103
+ def echelon(basis: Tensor) -> Tensor:
104
+ """The rows of `basis.T` in reduced row-echelon form: one readable vector per direction."""
105
+ m = basis.T.clone()
106
+ rows, cols = m.shape
107
+ pivot_row = 0
108
+ for col in range(cols):
109
+ if pivot_row == rows:
110
+ break
111
+ best = pivot_row + m[pivot_row:, col].abs().argmax().item()
112
+ if m[best, col].abs() < 1e-8:
113
+ continue
114
+ m[[pivot_row, best]] = m[[best, pivot_row]]
115
+ m[pivot_row] /= m[pivot_row, col].clone()
116
+ for r in range(rows):
117
+ if r != pivot_row:
118
+ m[r] -= m[r, col] * m[pivot_row]
119
+ pivot_row += 1
120
+ m[m.abs() < 1e-9] = 0.0
121
+ return m
122
+
123
+
124
+ def directions_of(result: DegreeTrace, basis: Tensor) -> list[Direction]:
125
+ """Readable directions spanning `basis`, labelled, each with the anchors it moves against."""
126
+ degrees = result.degrees()
127
+ anchors = result.anchor_matrix()
128
+ out = []
129
+ for row in echelon(basis):
130
+ coeffs = {n: round(c, 6) for n, c in zip(result.names, row.tolist(), strict=True) if c}
131
+ moves = bool((degrees @ row).abs().max() > TOL)
132
+ fast_ends = tuple(n for n in coeffs if len(result.shapes[result.names.index(n)]) >= 2)
133
+ held = []
134
+ if result.anchors:
135
+ for source, value in zip(result.anchor_sources, (anchors @ row).tolist(), strict=True):
136
+ if abs(value) > TOL and source not in held:
137
+ held.append(source)
138
+ out.append(Direction(coeffs, moves, fast_ends, tuple(held)))
139
+ return out
140
+
141
+
142
+ def complement(inner: Tensor, outer: Tensor) -> Tensor:
143
+ """An orthonormal basis of the part of span(`outer`) orthogonal to span(`inner`)."""
144
+ if outer.numel() == 0:
145
+ return outer
146
+ rest = outer - inner @ (inner.T @ outer) if inner.numel() else outer
147
+ u, sigma, _ = torch.linalg.svd(rest, full_matrices=False)
148
+ return u[:, sigma > 1e-8 * max(sigma.max().item(), 1.0)] if sigma.numel() else rest
149
+
150
+
151
+ def architecture_notes(
152
+ result: DegreeTrace, basis: Tensor, directions: list[Direction], weak: list[Direction]
153
+ ) -> list[str]:
154
+ """Attention calls whose q and k trade scale freely, parameters of two or more modules that
155
+ scale the residual stream together (an activation-moving direction with every coefficient
156
+ positive), and dangerous weakly pinned directions."""
157
+ notes = []
158
+ if basis.numel():
159
+ free_qk = [
160
+ i for i, (dq, _) in enumerate(result.attention) if (basis.T @ dq).abs().max() > TOL
161
+ ]
162
+ if free_qk:
163
+ notes.append(
164
+ f"attention calls {free_qk}: q and k trade scale freely; "
165
+ "normalize q and k (QK-norm) with a gain on one side only"
166
+ )
167
+ for d in directions:
168
+ modules = {name.rpartition(".")[0] for name in d.coeffs}
169
+ if d.moves_activations and len(modules) >= 2 and all(c > 0 for c in d.coeffs.values()):
170
+ notes.append(
171
+ f"{', '.join(d.coeffs)} scale the residual stream together; "
172
+ "give the model input a fixed scale (an embedding: mode 'unit')"
173
+ )
174
+ for d in weak:
175
+ if d.dangerous:
176
+ notes.append(
177
+ f"{', '.join(d.coeffs)} are held only by a fixed-scale addend "
178
+ f"({'; '.join(d.anchors)}); once they outgrow it this scale is nearly free. Let "
179
+ "the output read this stream directly, without a norm in between, so the loss "
180
+ "sets its scale"
181
+ )
182
+ return notes
183
+
184
+
185
+ def solve(result: DegreeTrace) -> FreeScales:
186
+ """The free directions of a degree trace, and its weakly pinned ones."""
187
+ hard = result.matrix()
188
+ basis = null_space(torch.cat([hard, result.anchor_matrix()]))
189
+ weak_basis = complement(basis, null_space(hard))
190
+ directions = [
191
+ Direction(d.coeffs, d.moves_activations, d.fast_ends) for d in directions_of(result, basis)
192
+ ]
193
+ weak = directions_of(result, weak_basis) if weak_basis.numel() else []
194
+ return FreeScales(
195
+ result.names,
196
+ result.shapes,
197
+ directions,
198
+ basis,
199
+ len(result.constraints) + len(result.anchors),
200
+ dict(result.unknown),
201
+ architecture_notes(result, basis, directions, weak),
202
+ weak,
203
+ )
204
+
205
+
206
+ def free_scales(model: nn.Module, *inputs, **kwargs) -> FreeScales:
207
+ """Trace `model(*inputs, **kwargs)` once and return its free directions."""
208
+ return solve(trace_degrees(model, *inputs, **kwargs))