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.
- dragonscale/__init__.py +39 -0
- dragonscale/analysis/__init__.py +29 -0
- dragonscale/analysis/absorb.py +229 -0
- dragonscale/analysis/jacobian.py +59 -0
- dragonscale/analysis/report.py +208 -0
- dragonscale/analysis/rules.py +281 -0
- dragonscale/analysis/suggest.py +108 -0
- dragonscale/analysis/trace.py +171 -0
- dragonscale/functional/__init__.py +35 -0
- dragonscale/functional/layers.py +53 -0
- dragonscale/functional/rows.py +261 -0
- dragonscale/layer_types.py +131 -0
- dragonscale/monitor.py +82 -0
- dragonscale/nn/__init__.py +27 -0
- dragonscale/nn/gain.py +21 -0
- dragonscale/nn/layers.py +157 -0
- dragonscale/optim.py +143 -0
- dragonscale/patch/__init__.py +7 -0
- dragonscale/patch/forwards.py +24 -0
- dragonscale/patch/grouped.py +148 -0
- dragonscale/patch/patched.py +303 -0
- dragonscale/patch/session.py +195 -0
- dragonscale/patch/state.py +93 -0
- dragonscale/plan/__init__.py +9 -0
- dragonscale/plan/build.py +53 -0
- dragonscale/plan/layer_plan.py +81 -0
- dragonscale/plan/presets.py +27 -0
- dragonscale/plan/rules.py +39 -0
- dragonscale/plan/validate.py +119 -0
- dragonscale/spec/__init__.py +6 -0
- dragonscale/spec/defaults.py +36 -0
- dragonscale/spec/rows.py +68 -0
- dragonscale-0.1.0.dist-info/METADATA +463 -0
- dragonscale-0.1.0.dist-info/RECORD +61 -0
- dragonscale-0.1.0.dist-info/WHEEL +5 -0
- dragonscale-0.1.0.dist-info/licenses/LICENSE +201 -0
- dragonscale-0.1.0.dist-info/top_level.txt +3 -0
- dragonscale_common/__init__.py +8 -0
- dragonscale_common/imports.py +24 -0
- dragonscale_common/log.py +47 -0
- dragonscale_common/problems.py +16 -0
- dragonscale_common/registry.py +66 -0
- dragonscale_kernels/__init__.py +9 -0
- dragonscale_kernels/backends/__init__.py +1 -0
- dragonscale_kernels/backends/compile.py +20 -0
- dragonscale_kernels/backends/eager/__init__.py +22 -0
- dragonscale_kernels/backends/eager/rows.py +89 -0
- dragonscale_kernels/backends/eager/scale.py +35 -0
- dragonscale_kernels/backends/tilelang/__init__.py +3 -0
- dragonscale_kernels/backends/triton/__init__.py +23 -0
- dragonscale_kernels/backends/triton/common.py +47 -0
- dragonscale_kernels/backends/triton/grouped.py +172 -0
- dragonscale_kernels/backends/triton/rows.py +255 -0
- dragonscale_kernels/constraints.py +26 -0
- dragonscale_kernels/error_stats.py +56 -0
- dragonscale_kernels/errors.py +5 -0
- dragonscale_kernels/hardware.py +47 -0
- dragonscale_kernels/layout.py +42 -0
- dragonscale_kernels/ops.py +79 -0
- dragonscale_kernels/registry.py +92 -0
- dragonscale_kernels/tuning.py +148 -0
dragonscale/__init__.py
ADDED
|
@@ -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))
|