polystep 0.7.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.
- polystep/__init__.py +176 -0
- polystep/_compiled.py +334 -0
- polystep/_step_blockwise.py +994 -0
- polystep/_step_momentum.py +128 -0
- polystep/_step_monolithic.py +1070 -0
- polystep/adaptive_subspace.py +762 -0
- polystep/api.py +307 -0
- polystep/ask_tell.py +164 -0
- polystep/benchmarks/__init__.py +62 -0
- polystep/benchmarks/baselines.py +604 -0
- polystep/benchmarks/rl/__init__.py +13 -0
- polystep/benchmarks/rl/cartpole.py +260 -0
- polystep/benchmarks/rl/gym_evaluator.py +304 -0
- polystep/benchmarks/rl/metrics.py +53 -0
- polystep/benchmarks/rl/policies.py +133 -0
- polystep/benchmarks/utils.py +1262 -0
- polystep/blockwise.py +549 -0
- polystep/cma.py +400 -0
- polystep/cma_subspace.py +404 -0
- polystep/cost_nn.py +633 -0
- polystep/costs.py +86 -0
- polystep/dynamics.py +138 -0
- polystep/epsilon.py +217 -0
- polystep/geometry.py +410 -0
- polystep/hybrid_subspace.py +1335 -0
- polystep/layers/__init__.py +16 -0
- polystep/layers/attention.py +243 -0
- polystep/layers/rnn.py +276 -0
- polystep/objectives/__init__.py +25 -0
- polystep/objectives/base.py +77 -0
- polystep/objectives/synthetic.py +232 -0
- polystep/optimizer.py +1405 -0
- polystep/projection/__init__.py +5 -0
- polystep/projection/sparse.py +291 -0
- polystep/py.typed +0 -0
- polystep/quadratic_model.py +285 -0
- polystep/solver.py +654 -0
- polystep/solvers/__init__.py +34 -0
- polystep/solvers/_prelude.py +129 -0
- polystep/solvers/base.py +77 -0
- polystep/solvers/greedy.py +159 -0
- polystep/solvers/kl_softmax.py +200 -0
- polystep/solvers/sinkhorn.py +522 -0
- polystep/solvers/softmax.py +151 -0
- polystep/solvers/tempered_softmax.py +97 -0
- polystep/subspace.py +694 -0
- polystep/transform.py +365 -0
- polystep-0.7.0.dist-info/METADATA +285 -0
- polystep-0.7.0.dist-info/RECORD +52 -0
- polystep-0.7.0.dist-info/WHEEL +5 -0
- polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
- polystep-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
"""Per-layer projections with coordinated rotation.
|
|
2
|
+
|
|
3
|
+
:class:`HybridSubspace` combines :class:`LinearSubspace`'s per-layer
|
|
4
|
+
projections with :class:`AdaptiveSubspace`'s synchronized rotation:
|
|
5
|
+
|
|
6
|
+
- **Per-layer projections.** Each parameter entry has its own
|
|
7
|
+
``P_layer`` of shape ``(num_params, num_coords)``. Per-layer
|
|
8
|
+
projections cover more parameters per step than a single global one
|
|
9
|
+
(empirically ~4.3% vs ~0.25% on MNIST MLPs).
|
|
10
|
+
- **Synchronized rotation.** All layer projections rotate on the same
|
|
11
|
+
schedule (every ``rotation_interval`` steps; ``0`` disables rotation).
|
|
12
|
+
- **``1/sqrt(num_coords)`` scaling** for unit-variance output, matching
|
|
13
|
+
:class:`LinearSubspace`'s convention rather than QR-orthonormal columns.
|
|
14
|
+
- **Displacement-biased rotation.** In ``'displacement'`` mode each layer
|
|
15
|
+
rotates toward its own slice of the displacement history.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
from polystep import HybridSubspace, ParamLayout
|
|
20
|
+
import torch.nn as nn
|
|
21
|
+
|
|
22
|
+
model = nn.Sequential(nn.Linear(784, 128), nn.Linear(128, 10))
|
|
23
|
+
layout = ParamLayout.from_module(model)
|
|
24
|
+
hybrid = HybridSubspace.auto_from_layout(layout)
|
|
25
|
+
projections = hybrid.init_projections(torch.device('cpu'), torch.float32)
|
|
26
|
+
projections = hybrid.rotate_all(projections, step=i, total_steps=100)
|
|
27
|
+
|
|
28
|
+
See Also:
|
|
29
|
+
:class:`LinearSubspace`: fixed per-layer projections (no rotation).
|
|
30
|
+
:class:`AdaptiveSubspace`: single global rotating projection.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import math
|
|
36
|
+
import warnings
|
|
37
|
+
from dataclasses import dataclass, replace
|
|
38
|
+
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING, Union
|
|
39
|
+
|
|
40
|
+
import torch
|
|
41
|
+
|
|
42
|
+
from .projection.sparse import SparseRandomProjection
|
|
43
|
+
from .subspace import _stable_entry_seed
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
import torch.nn as nn
|
|
47
|
+
from .blockwise import BlockConfig
|
|
48
|
+
from .transform import ParamLayout
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class LayerProjectionSpec:
|
|
53
|
+
"""Describes the projection mapping for a single parameter entry.
|
|
54
|
+
|
|
55
|
+
Each layer has its own projection matrix P_layer of shape
|
|
56
|
+
(num_params, num_coords) that maps subspace coordinates to parameter
|
|
57
|
+
perturbations.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
entry_key: state_dict key (e.g., "fc1.weight").
|
|
61
|
+
original_shape: Original parameter shape.
|
|
62
|
+
num_params: Total elements in this parameter (d_out * d_in for 2D).
|
|
63
|
+
num_coords: Number of subspace coordinates allocated to this layer.
|
|
64
|
+
flat_start: Start offset into the global subspace vector.
|
|
65
|
+
flat_end: End offset into the global subspace vector.
|
|
66
|
+
is_projected: True for 2D+ params (uses projection), False for 1D
|
|
67
|
+
(biases pass through directly without projection).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
entry_key: str
|
|
71
|
+
original_shape: Tuple[int, ...]
|
|
72
|
+
num_params: int
|
|
73
|
+
num_coords: int
|
|
74
|
+
flat_start: int
|
|
75
|
+
flat_end: int
|
|
76
|
+
is_projected: bool = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _scale_specs_to_budget(
|
|
80
|
+
specs: list,
|
|
81
|
+
max_dim: "Optional[int]",
|
|
82
|
+
current_dim: int,
|
|
83
|
+
) -> "Tuple[list, int]":
|
|
84
|
+
"""Proportionally scale projected specs to fit within max_dim budget.
|
|
85
|
+
|
|
86
|
+
Only scales specs that are already projected (2D+ weight matrices).
|
|
87
|
+
Unprojected 1D params (biases, LayerNorm) are kept at full size since
|
|
88
|
+
they are tiny and projecting them adds overhead without benefit.
|
|
89
|
+
|
|
90
|
+
Returns (new_specs, new_total_dim). No-op when max_dim is None or
|
|
91
|
+
current_dim already fits.
|
|
92
|
+
"""
|
|
93
|
+
if max_dim is None or current_dim <= max_dim:
|
|
94
|
+
return specs, current_dim
|
|
95
|
+
|
|
96
|
+
# Compute budget available for projected specs after reserving 1D params
|
|
97
|
+
unprojected_dim = sum(s.num_coords for s in specs if not s.is_projected)
|
|
98
|
+
projected_dim = current_dim - unprojected_dim
|
|
99
|
+
target_projected = max(1, max_dim - unprojected_dim)
|
|
100
|
+
|
|
101
|
+
if projected_dim <= target_projected:
|
|
102
|
+
return specs, current_dim
|
|
103
|
+
|
|
104
|
+
scale = target_projected / projected_dim
|
|
105
|
+
new_specs: list = []
|
|
106
|
+
new_offset = 0
|
|
107
|
+
for spec in specs:
|
|
108
|
+
if spec.is_projected:
|
|
109
|
+
new_coords = max(1, round(spec.num_coords * scale))
|
|
110
|
+
new_coords = min(new_coords, spec.num_params)
|
|
111
|
+
new_specs.append(
|
|
112
|
+
replace(
|
|
113
|
+
spec,
|
|
114
|
+
num_coords=new_coords,
|
|
115
|
+
flat_start=new_offset,
|
|
116
|
+
flat_end=new_offset + new_coords,
|
|
117
|
+
is_projected=True,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
# Keep 1D params at full size, no projection
|
|
122
|
+
new_specs.append(
|
|
123
|
+
replace(
|
|
124
|
+
spec,
|
|
125
|
+
flat_start=new_offset,
|
|
126
|
+
flat_end=new_offset + spec.num_coords,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
new_offset += new_specs[-1].num_coords
|
|
130
|
+
return new_specs, new_offset
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class HybridSubspace:
|
|
135
|
+
"""Hybrid subspace compression with per-layer projections and global rotation.
|
|
136
|
+
|
|
137
|
+
Combines LinearSubspace's per-layer structure with AdaptiveSubspace's
|
|
138
|
+
synchronized rotation coordination. Each layer has its own projection
|
|
139
|
+
matrix, but all projections rotate together on the same schedule.
|
|
140
|
+
|
|
141
|
+
Two rotation modes are supported:
|
|
142
|
+
|
|
143
|
+
- ``'random'``: Regenerates all layer projections with new seeds.
|
|
144
|
+
Simple but effective for exploration.
|
|
145
|
+
|
|
146
|
+
- ``'displacement'``: Uses SVD of recent displacement history to retain
|
|
147
|
+
productive directions per layer. The fraction of SVD-derived directions
|
|
148
|
+
increases linearly from ``svd_ratio_init`` to ``svd_ratio_final``.
|
|
149
|
+
|
|
150
|
+
Example::
|
|
151
|
+
|
|
152
|
+
from polystep import HybridSubspace
|
|
153
|
+
from polystep.transform import ParamLayout
|
|
154
|
+
import torch.nn as nn
|
|
155
|
+
|
|
156
|
+
model = nn.Sequential(nn.Linear(784, 128), nn.Linear(128, 10))
|
|
157
|
+
layout = ParamLayout.from_module(model)
|
|
158
|
+
|
|
159
|
+
# Auto rank selection based on compression ratio
|
|
160
|
+
hybrid = HybridSubspace.auto_from_layout(layout, min_rank=4, max_rank=64)
|
|
161
|
+
|
|
162
|
+
# Initialize projections
|
|
163
|
+
projections = hybrid.init_projections(torch.device('cpu'), torch.float32)
|
|
164
|
+
|
|
165
|
+
# Rotate all projections together
|
|
166
|
+
projections = hybrid.rotate_all(projections, step=1, total_steps=100)
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
specs: Per-layer projection specifications.
|
|
170
|
+
subspace_dim: Total subspace dimension (sum of all layer num_coords).
|
|
171
|
+
compression_ratio: subspace_dim / total_params.
|
|
172
|
+
seed: Base seed for deterministic projection generation.
|
|
173
|
+
rotation_mode: 'random' or 'displacement' (default 'displacement').
|
|
174
|
+
rotation_interval: Rotate every N steps. ``0`` disables rotation
|
|
175
|
+
(default; rotating hurts accuracy on small MLPs because it
|
|
176
|
+
re-randomizes already-discovered descent directions).
|
|
177
|
+
svd_ratio_init: Starting SVD ratio for displacement mode (default 0.0).
|
|
178
|
+
svd_ratio_final: Ending SVD ratio for displacement mode (default 0.5).
|
|
179
|
+
displacement_history_size: Rolling window for displacement history (default 5).
|
|
180
|
+
absorb_mode: 'stagnation' or 'periodic' (default 'stagnation').
|
|
181
|
+
absorb_patience: Steps of stagnation before absorb (default 20).
|
|
182
|
+
absorb_interval: Periodic absorb interval; 0 = disabled (default 0).
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
specs: Tuple[LayerProjectionSpec, ...]
|
|
186
|
+
subspace_dim: int
|
|
187
|
+
compression_ratio: float
|
|
188
|
+
seed: int = 0
|
|
189
|
+
rotation_mode: str = "displacement"
|
|
190
|
+
rotation_interval: int = 0
|
|
191
|
+
svd_ratio_init: float = 0.0
|
|
192
|
+
svd_ratio_final: float = 0.5
|
|
193
|
+
displacement_history_size: int = 5
|
|
194
|
+
absorb_mode: str = "stagnation"
|
|
195
|
+
absorb_patience: int = 20
|
|
196
|
+
absorb_interval: int = 0
|
|
197
|
+
sparse_threshold_bytes: int = 1_000_000_000 # 1GB: layers exceeding this use sparse projection
|
|
198
|
+
projection_mode: str = "random" # 'random' (dense Gaussian) or 'structured' (block-diagonal)
|
|
199
|
+
|
|
200
|
+
# Track total params for compression ratio calculation
|
|
201
|
+
_total_params: int = 0
|
|
202
|
+
# Budget control settings (preserved across rank transitions)
|
|
203
|
+
_max_subspace_dim: Optional[int] = None
|
|
204
|
+
|
|
205
|
+
def __post_init__(self) -> None:
|
|
206
|
+
if self.rotation_interval != 0:
|
|
207
|
+
warnings.warn(
|
|
208
|
+
"HybridSubspace works best with rotation_interval=0. "
|
|
209
|
+
"Non-zero values cause dual potential resets that degrade accuracy.",
|
|
210
|
+
stacklevel=2,
|
|
211
|
+
)
|
|
212
|
+
if self.compression_ratio == 0.0 and self._total_params > 0:
|
|
213
|
+
object.__setattr__(self, "compression_ratio", self.subspace_dim / self._total_params)
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Factory methods
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def from_layout(
|
|
221
|
+
cls,
|
|
222
|
+
layout: "ParamLayout",
|
|
223
|
+
rank: int,
|
|
224
|
+
seed: int = 0,
|
|
225
|
+
max_subspace_dim: Optional[int] = None,
|
|
226
|
+
**kwargs,
|
|
227
|
+
) -> "HybridSubspace":
|
|
228
|
+
"""Create a HybridSubspace with fixed rank from a ParamLayout.
|
|
229
|
+
|
|
230
|
+
Uses the SAME formula as LinearSubspace for num_coords per layer:
|
|
231
|
+
num_coords = d_out * effective_rank + effective_rank * d_in.
|
|
232
|
+
This makes subspace_dim identical, so switching from LinearSubspace
|
|
233
|
+
is a drop-in replacement.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
layout: ParamLayout describing the model's parameter structure.
|
|
237
|
+
rank: Rank parameter (controls compression level).
|
|
238
|
+
seed: Seed for deterministic projection matrices.
|
|
239
|
+
max_subspace_dim: Optional cap on total subspace dimension.
|
|
240
|
+
When set, all layers' num_coords are proportionally scaled
|
|
241
|
+
down to fit within this budget. Default None (no cap).
|
|
242
|
+
**kwargs: Additional arguments (rotation_mode, svd_ratio_*, etc.).
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
HybridSubspace with per-layer LayerProjectionSpecs.
|
|
246
|
+
|
|
247
|
+
Example::
|
|
248
|
+
|
|
249
|
+
layout = ParamLayout.from_module(model)
|
|
250
|
+
hybrid = HybridSubspace.from_layout(layout, rank=8)
|
|
251
|
+
"""
|
|
252
|
+
specs = []
|
|
253
|
+
offset = 0
|
|
254
|
+
|
|
255
|
+
for entry in layout.entries:
|
|
256
|
+
shape = entry.shape
|
|
257
|
+
if len(shape) >= 2:
|
|
258
|
+
d_out = shape[0]
|
|
259
|
+
d_in = math.prod(shape[1:])
|
|
260
|
+
effective_rank = min(rank, d_in, d_out)
|
|
261
|
+
# Same formula as LinearSubspace for drop-in compatibility
|
|
262
|
+
num_coords = d_out * effective_rank + effective_rank * d_in
|
|
263
|
+
num_params = math.prod(shape)
|
|
264
|
+
specs.append(
|
|
265
|
+
LayerProjectionSpec(
|
|
266
|
+
entry_key=entry.key,
|
|
267
|
+
original_shape=shape,
|
|
268
|
+
num_params=num_params,
|
|
269
|
+
num_coords=num_coords,
|
|
270
|
+
flat_start=offset,
|
|
271
|
+
flat_end=offset + num_coords,
|
|
272
|
+
is_projected=True,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
offset += num_coords
|
|
276
|
+
else:
|
|
277
|
+
# 1D param (bias, LayerNorm): full perturbation, no projection
|
|
278
|
+
num_elements = entry.numel
|
|
279
|
+
specs.append(
|
|
280
|
+
LayerProjectionSpec(
|
|
281
|
+
entry_key=entry.key,
|
|
282
|
+
original_shape=shape,
|
|
283
|
+
num_params=num_elements,
|
|
284
|
+
num_coords=num_elements,
|
|
285
|
+
flat_start=offset,
|
|
286
|
+
flat_end=offset + num_elements,
|
|
287
|
+
is_projected=False,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
offset += num_elements
|
|
291
|
+
|
|
292
|
+
# Apply budget cap if specified
|
|
293
|
+
specs, offset = _scale_specs_to_budget(specs, max_subspace_dim, offset)
|
|
294
|
+
|
|
295
|
+
total_params = layout.total_params
|
|
296
|
+
compression = offset / total_params if total_params > 0 else 0.0
|
|
297
|
+
|
|
298
|
+
return cls(
|
|
299
|
+
specs=tuple(specs),
|
|
300
|
+
subspace_dim=offset,
|
|
301
|
+
compression_ratio=compression,
|
|
302
|
+
seed=seed,
|
|
303
|
+
_total_params=total_params,
|
|
304
|
+
_max_subspace_dim=max_subspace_dim,
|
|
305
|
+
**kwargs,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def auto_from_layout(
|
|
310
|
+
cls,
|
|
311
|
+
layout: "ParamLayout",
|
|
312
|
+
compression_ratio: int = 16,
|
|
313
|
+
min_rank: int = 4,
|
|
314
|
+
max_rank: int = 64,
|
|
315
|
+
seed: int = 0,
|
|
316
|
+
max_subspace_dim: Optional[int] = None,
|
|
317
|
+
**kwargs,
|
|
318
|
+
) -> "HybridSubspace":
|
|
319
|
+
"""Create a HybridSubspace with auto per-layer rank selection.
|
|
320
|
+
|
|
321
|
+
Same auto-rank logic as LinearSubspace: per-layer rank proportional
|
|
322
|
+
to min(d_in, d_out) without user tuning.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
layout: ParamLayout describing the model's parameter structure.
|
|
326
|
+
compression_ratio: Divisor for rank computation (default 16).
|
|
327
|
+
min_rank: Minimum rank per layer (default 4).
|
|
328
|
+
max_rank: Maximum rank per layer (default 64).
|
|
329
|
+
seed: Seed for deterministic projection matrices.
|
|
330
|
+
max_subspace_dim: Optional cap on total subspace dimension.
|
|
331
|
+
**kwargs: Additional arguments (rotation_mode, svd_ratio_*, etc.).
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
HybridSubspace with per-layer auto-selected ranks.
|
|
335
|
+
|
|
336
|
+
Example::
|
|
337
|
+
|
|
338
|
+
layout = ParamLayout.from_module(model)
|
|
339
|
+
hybrid = HybridSubspace.auto_from_layout(layout, min_rank=4, max_rank=64)
|
|
340
|
+
"""
|
|
341
|
+
specs = []
|
|
342
|
+
offset = 0
|
|
343
|
+
|
|
344
|
+
for entry in layout.entries:
|
|
345
|
+
shape = entry.shape
|
|
346
|
+
if len(shape) >= 2:
|
|
347
|
+
d_out = shape[0]
|
|
348
|
+
d_in = math.prod(shape[1:])
|
|
349
|
+
min_dim = min(d_in, d_out)
|
|
350
|
+
auto_rank = max(min_rank, min(max_rank, min_dim // compression_ratio))
|
|
351
|
+
effective_rank = min(auto_rank, d_in, d_out)
|
|
352
|
+
num_coords = d_out * effective_rank + effective_rank * d_in
|
|
353
|
+
num_params = math.prod(shape)
|
|
354
|
+
specs.append(
|
|
355
|
+
LayerProjectionSpec(
|
|
356
|
+
entry_key=entry.key,
|
|
357
|
+
original_shape=shape,
|
|
358
|
+
num_params=num_params,
|
|
359
|
+
num_coords=num_coords,
|
|
360
|
+
flat_start=offset,
|
|
361
|
+
flat_end=offset + num_coords,
|
|
362
|
+
is_projected=True,
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
offset += num_coords
|
|
366
|
+
else:
|
|
367
|
+
num_elements = entry.numel
|
|
368
|
+
specs.append(
|
|
369
|
+
LayerProjectionSpec(
|
|
370
|
+
entry_key=entry.key,
|
|
371
|
+
original_shape=shape,
|
|
372
|
+
num_params=num_elements,
|
|
373
|
+
num_coords=num_elements,
|
|
374
|
+
flat_start=offset,
|
|
375
|
+
flat_end=offset + num_elements,
|
|
376
|
+
is_projected=False,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
offset += num_elements
|
|
380
|
+
|
|
381
|
+
# Apply budget cap if specified
|
|
382
|
+
specs, offset = _scale_specs_to_budget(specs, max_subspace_dim, offset)
|
|
383
|
+
|
|
384
|
+
total_params = layout.total_params
|
|
385
|
+
compression = offset / total_params if total_params > 0 else 0.0
|
|
386
|
+
|
|
387
|
+
return cls(
|
|
388
|
+
specs=tuple(specs),
|
|
389
|
+
subspace_dim=offset,
|
|
390
|
+
compression_ratio=compression,
|
|
391
|
+
seed=seed,
|
|
392
|
+
_total_params=total_params,
|
|
393
|
+
_max_subspace_dim=max_subspace_dim,
|
|
394
|
+
**kwargs,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# Projection management
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def init_projections(
|
|
402
|
+
self,
|
|
403
|
+
device: torch.device,
|
|
404
|
+
dtype: torch.dtype,
|
|
405
|
+
) -> Dict[str, Union[torch.Tensor, SparseRandomProjection]]:
|
|
406
|
+
"""Generate per-layer projection matrices.
|
|
407
|
+
|
|
408
|
+
Creates a projection matrix P_layer of shape (num_params, num_coords)
|
|
409
|
+
for each layer using deterministic seeded random generation. Each
|
|
410
|
+
projection is scaled by 1/sqrt(num_coords) for unit-variance output,
|
|
411
|
+
matching LinearSubspace's scaling convention.
|
|
412
|
+
|
|
413
|
+
Layers whose dense projection would exceed ``sparse_threshold_bytes``
|
|
414
|
+
automatically use ``SparseRandomProjection`` instead, reducing memory
|
|
415
|
+
by ~100-1000x for large layers.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
device: Target device for projection matrices.
|
|
419
|
+
dtype: Target dtype for projection matrices.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Dict mapping entry_key to projection matrix (dense Tensor or
|
|
423
|
+
SparseRandomProjection for large layers).
|
|
424
|
+
|
|
425
|
+
Example::
|
|
426
|
+
|
|
427
|
+
projections = hybrid.init_projections(torch.device('cpu'), torch.float32)
|
|
428
|
+
for key, P in projections.items():
|
|
429
|
+
print(f'{key}: {type(P).__name__}')
|
|
430
|
+
"""
|
|
431
|
+
projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]] = {}
|
|
432
|
+
for spec in self.specs:
|
|
433
|
+
if not spec.is_projected:
|
|
434
|
+
continue # 1D params add coords directly; storing an eye wastes O(n^2)
|
|
435
|
+
dense_bytes = spec.num_params * spec.num_coords * 4 # float32
|
|
436
|
+
if spec.is_projected and dense_bytes > self.sparse_threshold_bytes:
|
|
437
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, 0)
|
|
438
|
+
projections[spec.entry_key] = SparseRandomProjection(
|
|
439
|
+
full_dim=spec.num_params,
|
|
440
|
+
subspace_dim=spec.num_coords,
|
|
441
|
+
seed=entry_seed,
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
P = self._get_projection(spec, device, dtype, step=0)
|
|
445
|
+
projections[spec.entry_key] = P
|
|
446
|
+
return projections
|
|
447
|
+
|
|
448
|
+
def _get_projection(
|
|
449
|
+
self,
|
|
450
|
+
spec: LayerProjectionSpec,
|
|
451
|
+
device: torch.device,
|
|
452
|
+
dtype: torch.dtype,
|
|
453
|
+
step: int = 0,
|
|
454
|
+
) -> torch.Tensor:
|
|
455
|
+
"""Generate the projection matrix for a parameter entry.
|
|
456
|
+
|
|
457
|
+
Generates P of shape (num_params, num_coords) deterministically from
|
|
458
|
+
self.seed, spec.entry_key, and step. Dispatches on ``projection_mode``:
|
|
459
|
+
|
|
460
|
+
- ``'random'``: Dense Gaussian projection with 1/sqrt(num_coords) scaling.
|
|
461
|
+
- ``'structured'``: Block-diagonal projection preserving weight matrix
|
|
462
|
+
row structure (SubZero-inspired).
|
|
463
|
+
|
|
464
|
+
For 1D parameters (biases), returns identity-like mapping since
|
|
465
|
+
num_params == num_coords (regardless of projection_mode).
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
spec: LayerProjectionSpec for this parameter.
|
|
469
|
+
device: Target device.
|
|
470
|
+
dtype: Target dtype.
|
|
471
|
+
step: Current step (used for rotation seed).
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Projection matrix P of shape (num_params, num_coords).
|
|
475
|
+
"""
|
|
476
|
+
# 1D params: identity-like projection
|
|
477
|
+
if spec.num_params == spec.num_coords:
|
|
478
|
+
return torch.eye(
|
|
479
|
+
spec.num_params,
|
|
480
|
+
dtype=dtype,
|
|
481
|
+
device=device,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if self.projection_mode == "structured":
|
|
485
|
+
return self._init_structured_projection(spec, device, dtype, step)
|
|
486
|
+
|
|
487
|
+
# Default: dense random Gaussian projection with QR-orthogonal columns.
|
|
488
|
+
# QR orthogonalization (inspired by SubZero, ICCV 2025) gives lower
|
|
489
|
+
# variance perturbations than i.i.d. scaled Gaussian columns, improving
|
|
490
|
+
# per-step signal quality at negligible extra cost.
|
|
491
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, step)
|
|
492
|
+
gen = torch.Generator(device="cpu")
|
|
493
|
+
gen.manual_seed(entry_seed)
|
|
494
|
+
|
|
495
|
+
# Generate on CPU then move (Generator doesn't support CUDA)
|
|
496
|
+
P_raw = torch.randn(
|
|
497
|
+
spec.num_params,
|
|
498
|
+
spec.num_coords,
|
|
499
|
+
generator=gen,
|
|
500
|
+
dtype=dtype,
|
|
501
|
+
device="cpu",
|
|
502
|
+
)
|
|
503
|
+
# QR-orthogonalize columns when num_params >= num_coords (tall matrix).
|
|
504
|
+
# This produces orthonormal columns, giving isotropic perturbations.
|
|
505
|
+
# For wide matrices (more coords than params), fall back to scaled Gaussian.
|
|
506
|
+
if spec.num_params >= spec.num_coords:
|
|
507
|
+
# geqrf has no bf16/fp16 CPU kernel; orthogonalize in fp32, cast back.
|
|
508
|
+
qr_in = P_raw.float() if P_raw.dtype in (torch.bfloat16, torch.float16) else P_raw
|
|
509
|
+
P, _ = torch.linalg.qr(qr_in, mode="reduced") # (num_params, num_coords)
|
|
510
|
+
P = P.to(dtype=P_raw.dtype)
|
|
511
|
+
else:
|
|
512
|
+
P = P_raw * (1.0 / math.sqrt(spec.num_coords))
|
|
513
|
+
P = P.to(device=device)
|
|
514
|
+
|
|
515
|
+
return P
|
|
516
|
+
|
|
517
|
+
def _init_structured_projection(
|
|
518
|
+
self,
|
|
519
|
+
spec: LayerProjectionSpec,
|
|
520
|
+
device: torch.device,
|
|
521
|
+
dtype: torch.dtype,
|
|
522
|
+
step: int = 0,
|
|
523
|
+
) -> torch.Tensor:
|
|
524
|
+
"""Create block-diagonal structured projection for a layer.
|
|
525
|
+
|
|
526
|
+
For a weight matrix of shape (d_out, d_in), treats each row as a group.
|
|
527
|
+
The projection matrix is block-diagonal: each d_in-sized block gets its
|
|
528
|
+
own small random projection. This preserves within-row correlations.
|
|
529
|
+
|
|
530
|
+
SubZero insight (Malladi et al. 2024): instead of projecting all
|
|
531
|
+
d_out*d_in params together (losing structure), project groups of d_in
|
|
532
|
+
params independently (preserving row structure of the weight matrix).
|
|
533
|
+
|
|
534
|
+
For layers where d_out*d_in = num_params:
|
|
535
|
+
- Split into d_out blocks of d_in params each
|
|
536
|
+
- Each block gets a (d_in, block_coords) random projection
|
|
537
|
+
- Assemble into a block-diagonal (num_params, num_coords) matrix
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
spec: LayerProjectionSpec for this parameter.
|
|
541
|
+
device: Target device.
|
|
542
|
+
dtype: Target dtype.
|
|
543
|
+
step: Current step (used for rotation seed).
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Block-diagonal projection matrix of shape (num_params, num_coords).
|
|
547
|
+
"""
|
|
548
|
+
d_out = spec.original_shape[0]
|
|
549
|
+
d_in = spec.num_params // d_out
|
|
550
|
+
|
|
551
|
+
# Distribute coords across blocks
|
|
552
|
+
coords_per_block = max(1, math.ceil(spec.num_coords / d_out))
|
|
553
|
+
actual_total_coords = coords_per_block * d_out
|
|
554
|
+
|
|
555
|
+
# Deterministic seed from global seed + entry key + step
|
|
556
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, step)
|
|
557
|
+
gen = torch.Generator(device="cpu")
|
|
558
|
+
gen.manual_seed(entry_seed)
|
|
559
|
+
|
|
560
|
+
# Build block-diagonal projection on CPU
|
|
561
|
+
P = torch.zeros(spec.num_params, actual_total_coords, dtype=dtype, device="cpu")
|
|
562
|
+
for i in range(d_out):
|
|
563
|
+
row_start = i * d_in
|
|
564
|
+
row_end = row_start + d_in
|
|
565
|
+
col_start = i * coords_per_block
|
|
566
|
+
col_end = col_start + coords_per_block
|
|
567
|
+
block = torch.randn(
|
|
568
|
+
d_in,
|
|
569
|
+
coords_per_block,
|
|
570
|
+
generator=gen,
|
|
571
|
+
dtype=dtype,
|
|
572
|
+
device="cpu",
|
|
573
|
+
)
|
|
574
|
+
block.mul_(1.0 / math.sqrt(coords_per_block))
|
|
575
|
+
P[row_start:row_end, col_start:col_end] = block
|
|
576
|
+
|
|
577
|
+
# Handle rounding: pad or truncate to match num_coords
|
|
578
|
+
if actual_total_coords < spec.num_coords:
|
|
579
|
+
pad = torch.zeros(
|
|
580
|
+
spec.num_params,
|
|
581
|
+
spec.num_coords - actual_total_coords,
|
|
582
|
+
dtype=dtype,
|
|
583
|
+
device="cpu",
|
|
584
|
+
)
|
|
585
|
+
P = torch.cat([P, pad], dim=1)
|
|
586
|
+
elif actual_total_coords > spec.num_coords:
|
|
587
|
+
P = P[:, : spec.num_coords]
|
|
588
|
+
|
|
589
|
+
return P.to(device=device)
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------
|
|
592
|
+
# Rotation coordination
|
|
593
|
+
# ------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def rotate_all(
|
|
596
|
+
self,
|
|
597
|
+
projections: Dict[str, torch.Tensor],
|
|
598
|
+
step: int,
|
|
599
|
+
total_steps: int,
|
|
600
|
+
displacement_history: Optional[torch.Tensor] = None,
|
|
601
|
+
) -> Dict[str, torch.Tensor]:
|
|
602
|
+
"""Rotate all layer projections according to the configured mode.
|
|
603
|
+
|
|
604
|
+
All layers rotate together on the same schedule (synchronized rotation).
|
|
605
|
+
For 'random' mode, regenerates all projections with new seeds.
|
|
606
|
+
For 'displacement' mode, uses SVD of the layer's portion of displacement
|
|
607
|
+
history to keep productive directions.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
projections: Dict of current projection matrices {entry_key: P_layer}.
|
|
611
|
+
step: Current optimization step (0-indexed).
|
|
612
|
+
total_steps: Total number of optimization steps.
|
|
613
|
+
displacement_history: Optional tensor of shape
|
|
614
|
+
(history_len, subspace_dim) with recent displacement vectors
|
|
615
|
+
in global subspace coordinates. Required for displacement mode.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Dict of new projection matrices {entry_key: P_layer}.
|
|
619
|
+
|
|
620
|
+
Example::
|
|
621
|
+
|
|
622
|
+
# Random rotation
|
|
623
|
+
projections = hybrid.rotate_all(projections, step=1, total_steps=100)
|
|
624
|
+
|
|
625
|
+
# Displacement-biased rotation
|
|
626
|
+
displacement = torch.randn(5, hybrid.subspace_dim) * 0.1
|
|
627
|
+
projections = hybrid.rotate_all(
|
|
628
|
+
projections, step=1, total_steps=100,
|
|
629
|
+
displacement_history=displacement
|
|
630
|
+
)
|
|
631
|
+
"""
|
|
632
|
+
# Skip rotation if rotation is disabled (interval <= 0) or not on interval
|
|
633
|
+
if self.rotation_interval <= 0:
|
|
634
|
+
return projections
|
|
635
|
+
if self.rotation_interval > 1 and step % self.rotation_interval != 0:
|
|
636
|
+
return projections
|
|
637
|
+
|
|
638
|
+
# Get device/dtype from first dense projection (sparse tensors lack .device/.dtype)
|
|
639
|
+
device, dtype = torch.device("cpu"), torch.float32 # safe defaults
|
|
640
|
+
for p in projections.values():
|
|
641
|
+
if isinstance(p, torch.Tensor):
|
|
642
|
+
device, dtype = p.device, p.dtype
|
|
643
|
+
break
|
|
644
|
+
|
|
645
|
+
# Determine rotation mode
|
|
646
|
+
use_random = (
|
|
647
|
+
self.rotation_mode == "random" or displacement_history is None or displacement_history.shape[0] == 0
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if not use_random:
|
|
651
|
+
# Check if displacement history has meaningful magnitude and is finite.
|
|
652
|
+
# Squared-norm avoids sqrt; explicit .item() makes GPU-CPU sync visible.
|
|
653
|
+
if not torch.isfinite(displacement_history).all():
|
|
654
|
+
use_random = True
|
|
655
|
+
elif (displacement_history * displacement_history).sum().item() < 1e-20:
|
|
656
|
+
use_random = True
|
|
657
|
+
|
|
658
|
+
if use_random:
|
|
659
|
+
return self._rotate_all_random(device, dtype, step)
|
|
660
|
+
else:
|
|
661
|
+
svd_ratio = self.get_svd_ratio(step, total_steps)
|
|
662
|
+
return self._rotate_all_displacement(
|
|
663
|
+
projections,
|
|
664
|
+
displacement_history,
|
|
665
|
+
svd_ratio,
|
|
666
|
+
device,
|
|
667
|
+
dtype,
|
|
668
|
+
step,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _rotate_all_random(
|
|
672
|
+
self,
|
|
673
|
+
device: torch.device,
|
|
674
|
+
dtype: torch.dtype,
|
|
675
|
+
step: int,
|
|
676
|
+
) -> Dict[str, Union[torch.Tensor, SparseRandomProjection]]:
|
|
677
|
+
"""Regenerate all projections with new seeds (random mode).
|
|
678
|
+
|
|
679
|
+
For sparse layers, creates a new SparseRandomProjection with an
|
|
680
|
+
updated seed. For dense layers, regenerates the dense projection.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
device: Target device.
|
|
684
|
+
dtype: Target dtype.
|
|
685
|
+
step: Current step (used for seed).
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
Dict of new projection matrices.
|
|
689
|
+
"""
|
|
690
|
+
new_projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]] = {}
|
|
691
|
+
for spec in self.specs:
|
|
692
|
+
if not spec.is_projected:
|
|
693
|
+
continue # 1D params add coords directly; storing an eye wastes O(n^2)
|
|
694
|
+
dense_bytes = spec.num_params * spec.num_coords * 4
|
|
695
|
+
if spec.is_projected and dense_bytes > self.sparse_threshold_bytes:
|
|
696
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, step)
|
|
697
|
+
new_projections[spec.entry_key] = SparseRandomProjection(
|
|
698
|
+
full_dim=spec.num_params,
|
|
699
|
+
subspace_dim=spec.num_coords,
|
|
700
|
+
seed=entry_seed,
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
P = self._get_projection(spec, device, dtype, step=step)
|
|
704
|
+
new_projections[spec.entry_key] = P
|
|
705
|
+
return new_projections
|
|
706
|
+
|
|
707
|
+
def _rotate_all_displacement(
|
|
708
|
+
self,
|
|
709
|
+
projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]],
|
|
710
|
+
displacement_history: torch.Tensor,
|
|
711
|
+
svd_ratio: float,
|
|
712
|
+
device: torch.device,
|
|
713
|
+
dtype: torch.dtype,
|
|
714
|
+
step: int,
|
|
715
|
+
) -> Dict[str, Union[torch.Tensor, SparseRandomProjection]]:
|
|
716
|
+
"""Rotate all projections using displacement-biased SVD (displacement mode).
|
|
717
|
+
|
|
718
|
+
For each layer, extracts that layer's portion of the displacement history,
|
|
719
|
+
computes SVD to find productive directions, and combines with random
|
|
720
|
+
directions for the new projection. Sparse layers fall back to random
|
|
721
|
+
rotation since SVD-based rotation requires dense matmul.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
projections: Current projection matrices (dense or sparse).
|
|
725
|
+
displacement_history: Shape (history_len, subspace_dim).
|
|
726
|
+
svd_ratio: Fraction of num_coords to fill with SVD directions.
|
|
727
|
+
device: Target device.
|
|
728
|
+
dtype: Target dtype.
|
|
729
|
+
step: Current step.
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Dict of new projection matrices.
|
|
733
|
+
"""
|
|
734
|
+
new_projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]] = {}
|
|
735
|
+
|
|
736
|
+
for spec in self.specs:
|
|
737
|
+
if not spec.is_projected:
|
|
738
|
+
continue # 1D params have no stored projection
|
|
739
|
+
P = projections[spec.entry_key]
|
|
740
|
+
if isinstance(P, SparseRandomProjection):
|
|
741
|
+
# Sparse layers: fall back to random rotation (new seed)
|
|
742
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, step)
|
|
743
|
+
new_projections[spec.entry_key] = SparseRandomProjection(
|
|
744
|
+
full_dim=spec.num_params,
|
|
745
|
+
subspace_dim=spec.num_coords,
|
|
746
|
+
seed=entry_seed,
|
|
747
|
+
)
|
|
748
|
+
else:
|
|
749
|
+
# Dense layers: displacement-biased SVD rotation
|
|
750
|
+
layer_disp = displacement_history[:, spec.flat_start : spec.flat_end]
|
|
751
|
+
new_P = self._rotate_layer_displacement(
|
|
752
|
+
P,
|
|
753
|
+
spec,
|
|
754
|
+
layer_disp,
|
|
755
|
+
svd_ratio,
|
|
756
|
+
device,
|
|
757
|
+
dtype,
|
|
758
|
+
step,
|
|
759
|
+
)
|
|
760
|
+
new_projections[spec.entry_key] = new_P
|
|
761
|
+
|
|
762
|
+
return new_projections
|
|
763
|
+
|
|
764
|
+
def _rotate_layer_displacement(
|
|
765
|
+
self,
|
|
766
|
+
P_old: torch.Tensor,
|
|
767
|
+
spec: LayerProjectionSpec,
|
|
768
|
+
layer_displacement: torch.Tensor,
|
|
769
|
+
svd_ratio: float,
|
|
770
|
+
device: torch.device,
|
|
771
|
+
dtype: torch.dtype,
|
|
772
|
+
step: int,
|
|
773
|
+
) -> torch.Tensor:
|
|
774
|
+
"""Rotate a single layer's projection using displacement-biased SVD.
|
|
775
|
+
|
|
776
|
+
Projects the layer's displacement history to parameter space, computes
|
|
777
|
+
SVD to find productive directions, keeps top k_svd directions, and fills
|
|
778
|
+
the remainder with random directions. Scaling matches _get_projection:
|
|
779
|
+
unit-norm QR columns when tall, 1/sqrt(num_coords) Gaussian when wide.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
P_old: Current projection matrix for this layer, shape (num_params, num_coords).
|
|
783
|
+
spec: LayerProjectionSpec for this layer.
|
|
784
|
+
layer_displacement: Shape (history_len, num_coords) displacement history.
|
|
785
|
+
svd_ratio: Fraction of num_coords to fill with SVD directions.
|
|
786
|
+
device: Target device.
|
|
787
|
+
dtype: Target dtype.
|
|
788
|
+
step: Current step.
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
New projection matrix of shape (num_params, num_coords).
|
|
792
|
+
"""
|
|
793
|
+
# 1D params: return identity
|
|
794
|
+
if spec.num_params == spec.num_coords:
|
|
795
|
+
return torch.eye(spec.num_params, dtype=dtype, device=device)
|
|
796
|
+
|
|
797
|
+
k_svd = max(1, int(svd_ratio * spec.num_coords))
|
|
798
|
+
k_random = spec.num_coords - k_svd
|
|
799
|
+
|
|
800
|
+
# Project displacement history to full parameter space
|
|
801
|
+
# layer_displacement: (history_len, num_coords)
|
|
802
|
+
# P_old: (num_params, num_coords)
|
|
803
|
+
# D_full: (num_params, history_len)
|
|
804
|
+
D_full = P_old @ layer_displacement.T
|
|
805
|
+
|
|
806
|
+
# Guard against non-finite values
|
|
807
|
+
if not torch.isfinite(D_full).all():
|
|
808
|
+
return self._get_projection(spec, device, dtype, step=step)
|
|
809
|
+
|
|
810
|
+
# SVD of the full-space displacement matrix
|
|
811
|
+
if k_svd < min(D_full.shape) // 2 and min(D_full.shape) > 6:
|
|
812
|
+
# Randomized SVD: faster when k_svd << rank
|
|
813
|
+
U_top, S_top, V_top = torch.pca_lowrank(D_full, q=k_svd, niter=2)
|
|
814
|
+
else:
|
|
815
|
+
U, S, Vh = torch.linalg.svd(D_full, full_matrices=False)
|
|
816
|
+
k_svd = min(k_svd, U.shape[1])
|
|
817
|
+
U_top = U[:, :k_svd]
|
|
818
|
+
k_random = spec.num_coords - k_svd
|
|
819
|
+
|
|
820
|
+
# Generate random directions for the remainder directly on target device
|
|
821
|
+
entry_seed = _stable_entry_seed(self.seed, spec.entry_key, step, "random")
|
|
822
|
+
gen = torch.Generator(device=device)
|
|
823
|
+
gen.manual_seed(entry_seed)
|
|
824
|
+
|
|
825
|
+
Z_random = torch.randn(
|
|
826
|
+
spec.num_params,
|
|
827
|
+
k_random,
|
|
828
|
+
generator=gen,
|
|
829
|
+
dtype=dtype,
|
|
830
|
+
device=device,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
if U_top.device != Z_random.device:
|
|
834
|
+
U_top = U_top.to(device=Z_random.device)
|
|
835
|
+
|
|
836
|
+
# Match _get_projection so magnitude is continuous across the first
|
|
837
|
+
# rotation: unit-norm QR when tall, 1/sqrt(num_coords) Gaussian when wide
|
|
838
|
+
# (reduced QR would drop columns in the wide case).
|
|
839
|
+
combined = torch.cat([U_top, Z_random], dim=1)
|
|
840
|
+
if spec.num_params >= spec.num_coords:
|
|
841
|
+
# QR requires float32 on CPU (BF16 unsupported); upcast and convert back
|
|
842
|
+
orig_dtype = combined.dtype
|
|
843
|
+
if combined.dtype == torch.bfloat16 and combined.device.type == "cpu":
|
|
844
|
+
combined = combined.float()
|
|
845
|
+
Q, _ = torch.linalg.qr(combined)
|
|
846
|
+
combined = Q.to(orig_dtype) if Q.dtype != orig_dtype else Q
|
|
847
|
+
else:
|
|
848
|
+
combined = combined * (1.0 / math.sqrt(spec.num_coords))
|
|
849
|
+
|
|
850
|
+
return combined
|
|
851
|
+
|
|
852
|
+
def get_svd_ratio(self, step: int, total_steps: int) -> float:
|
|
853
|
+
"""Compute SVD ratio at the given step via linear interpolation.
|
|
854
|
+
|
|
855
|
+
The ratio starts at ``svd_ratio_init`` (step 0) and linearly
|
|
856
|
+
increases to ``svd_ratio_final`` (step = total_steps).
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
step: Current optimization step.
|
|
860
|
+
total_steps: Total number of steps.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
SVD ratio in [svd_ratio_init, svd_ratio_final].
|
|
864
|
+
"""
|
|
865
|
+
progress = min(1.0, step / max(1, total_steps))
|
|
866
|
+
return self.svd_ratio_init + progress * (self.svd_ratio_final - self.svd_ratio_init)
|
|
867
|
+
|
|
868
|
+
# ------------------------------------------------------------------
|
|
869
|
+
# Absorb coordination
|
|
870
|
+
# ------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
def should_absorb(self, stagnation_count: int, iteration: int) -> bool:
|
|
873
|
+
"""Check whether an absorb-and-rotate should be triggered.
|
|
874
|
+
|
|
875
|
+
Matches AdaptiveSubspace's absorb logic: triggers on stagnation
|
|
876
|
+
(stagnation_count >= absorb_patience) or periodically
|
|
877
|
+
(iteration % absorb_interval == 0).
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
stagnation_count: Number of consecutive steps without improvement.
|
|
881
|
+
iteration: Current iteration number (0-indexed).
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
True if absorb should be triggered based on the configured mode.
|
|
885
|
+
|
|
886
|
+
Example::
|
|
887
|
+
|
|
888
|
+
if hybrid.should_absorb(stagnation_count=25, iteration=100):
|
|
889
|
+
# Trigger absorb and rotate
|
|
890
|
+
pass
|
|
891
|
+
"""
|
|
892
|
+
if self.absorb_mode == "stagnation":
|
|
893
|
+
return stagnation_count >= self.absorb_patience
|
|
894
|
+
elif self.absorb_mode == "periodic" and self.absorb_interval > 0:
|
|
895
|
+
return iteration > 0 and iteration % self.absorb_interval == 0
|
|
896
|
+
return False
|
|
897
|
+
|
|
898
|
+
# ------------------------------------------------------------------
|
|
899
|
+
# Core reconstruction methods (matching LinearSubspace contract)
|
|
900
|
+
# ------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
def apply_perturbation(
|
|
903
|
+
self,
|
|
904
|
+
projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]],
|
|
905
|
+
base_sd: Dict[str, torch.Tensor],
|
|
906
|
+
flat_subspace: torch.Tensor,
|
|
907
|
+
) -> Dict[str, torch.Tensor]:
|
|
908
|
+
"""Reconstruct full state_dict from base params + flat subspace vector.
|
|
909
|
+
|
|
910
|
+
For each layer, slices the subspace coordinates, applies the per-layer
|
|
911
|
+
projection matrix P_layer, reshapes to original parameter shape, and
|
|
912
|
+
adds to the base parameter. For 1D params (biases), adds chunk directly
|
|
913
|
+
without projection. Supports both dense tensors and SparseRandomProjection.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
projections: Dict mapping entry_key to per-layer projection matrices
|
|
917
|
+
(dense Tensor or SparseRandomProjection for large layers).
|
|
918
|
+
base_sd: Base state_dict with original parameter values.
|
|
919
|
+
flat_subspace: 1D tensor of shape (subspace_dim,).
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
New state_dict with perturbed parameters.
|
|
923
|
+
|
|
924
|
+
Example::
|
|
925
|
+
|
|
926
|
+
projections = hybrid.init_projections(device, dtype)
|
|
927
|
+
base_sd = model.state_dict()
|
|
928
|
+
coords = torch.randn(hybrid.subspace_dim) * 0.1
|
|
929
|
+
perturbed_sd = hybrid.apply_perturbation(projections, base_sd, coords)
|
|
930
|
+
model.load_state_dict(perturbed_sd)
|
|
931
|
+
"""
|
|
932
|
+
result = {}
|
|
933
|
+
for spec in self.specs:
|
|
934
|
+
chunk = flat_subspace[spec.flat_start : spec.flat_end]
|
|
935
|
+
base = base_sd[spec.entry_key]
|
|
936
|
+
|
|
937
|
+
if spec.is_projected:
|
|
938
|
+
P = projections[spec.entry_key]
|
|
939
|
+
if isinstance(P, SparseRandomProjection):
|
|
940
|
+
# Sparse path: project coords to full param space, add to base
|
|
941
|
+
delta = P.project(chunk) # (num_params,)
|
|
942
|
+
result[spec.entry_key] = (base.reshape(-1) + delta).reshape(spec.original_shape)
|
|
943
|
+
else:
|
|
944
|
+
# Dense path: fused add + projection via addmm
|
|
945
|
+
result_flat = torch.addmm(
|
|
946
|
+
base.reshape(1, -1),
|
|
947
|
+
chunk.unsqueeze(0),
|
|
948
|
+
P.t(),
|
|
949
|
+
)
|
|
950
|
+
result[spec.entry_key] = result_flat.reshape(spec.original_shape)
|
|
951
|
+
else:
|
|
952
|
+
# 1D param (bias): add coords directly
|
|
953
|
+
result[spec.entry_key] = base + chunk.reshape(spec.original_shape)
|
|
954
|
+
|
|
955
|
+
return result
|
|
956
|
+
|
|
957
|
+
def build_fused_projection(
|
|
958
|
+
self,
|
|
959
|
+
projections: Dict[str, Union[torch.Tensor, "SparseRandomProjection"]],
|
|
960
|
+
) -> None:
|
|
961
|
+
"""Build a fused block-diagonal projection matrix from per-layer projections.
|
|
962
|
+
|
|
963
|
+
Combines all dense per-layer projection matrices into a single
|
|
964
|
+
block-diagonal matrix for efficient single-matmul reconstruction.
|
|
965
|
+
Only used when the fused matrix is small enough (<256 MB) to avoid
|
|
966
|
+
memory regression from dense storage of a sparse block-diagonal.
|
|
967
|
+
|
|
968
|
+
Called once after each rotation (in optimizer.py after rotate_all).
|
|
969
|
+
The fused matrix is cached in ``self._fused_P`` and reused across
|
|
970
|
+
all ``reconstruct_batch`` calls until the next rotation.
|
|
971
|
+
"""
|
|
972
|
+
dense_blocks = []
|
|
973
|
+
self._fused_dense_specs = [] # specs participating in fused matmul
|
|
974
|
+
self._fused_sparse_specs = [] # specs needing per-layer sparse path
|
|
975
|
+
self._fused_bias_specs = [] # 1D params (identity, no projection)
|
|
976
|
+
total_params = 0
|
|
977
|
+
total_coords = 0
|
|
978
|
+
|
|
979
|
+
for spec in self.specs:
|
|
980
|
+
P = projections.get(spec.entry_key)
|
|
981
|
+
if not spec.is_projected:
|
|
982
|
+
self._fused_bias_specs.append(spec)
|
|
983
|
+
elif isinstance(P, SparseRandomProjection):
|
|
984
|
+
self._fused_sparse_specs.append((spec, P))
|
|
985
|
+
else:
|
|
986
|
+
self._fused_dense_specs.append((spec, total_params))
|
|
987
|
+
dense_blocks.append(P)
|
|
988
|
+
total_params += spec.num_params
|
|
989
|
+
total_coords += spec.num_coords
|
|
990
|
+
|
|
991
|
+
# Only fuse if the dense block-diagonal matrix is < 256 MB.
|
|
992
|
+
# Otherwise the zero-padding wastes more memory than it saves in
|
|
993
|
+
# kernel launch overhead.
|
|
994
|
+
fused_bytes = total_params * total_coords * 4
|
|
995
|
+
max_fused_bytes = 256 * 1024 * 1024 # 256 MB
|
|
996
|
+
|
|
997
|
+
if dense_blocks and fused_bytes <= max_fused_bytes:
|
|
998
|
+
self._fused_P = torch.block_diag(*dense_blocks)
|
|
999
|
+
self._fused_total_dense_params = total_params
|
|
1000
|
+
else:
|
|
1001
|
+
self._fused_P = None
|
|
1002
|
+
self._fused_total_dense_params = 0
|
|
1003
|
+
|
|
1004
|
+
def apply_perturbation_inplace(
|
|
1005
|
+
self,
|
|
1006
|
+
projections: Dict[str, Union[torch.Tensor, "SparseRandomProjection"]],
|
|
1007
|
+
model: "nn.Module",
|
|
1008
|
+
base_sd: Dict[str, torch.Tensor],
|
|
1009
|
+
flat_subspace: torch.Tensor,
|
|
1010
|
+
param_dict: Optional[Dict[str, torch.Tensor]] = None,
|
|
1011
|
+
) -> None:
|
|
1012
|
+
"""Apply perturbation directly to model weights in-place.
|
|
1013
|
+
|
|
1014
|
+
EGGROLL-inspired: instead of materializing a full perturbed state_dict
|
|
1015
|
+
and loading it, this modifies model parameters' ``.data`` tensors
|
|
1016
|
+
directly. Avoids allocating a new dict and reduces memory to one
|
|
1017
|
+
addmm result per layer (immediately written into the param tensor).
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
projections: Per-layer projection matrices.
|
|
1021
|
+
model: The model whose parameters will be modified in-place.
|
|
1022
|
+
base_sd: Base (unperturbed) state_dict values.
|
|
1023
|
+
flat_subspace: 1D tensor of shape (subspace_dim,).
|
|
1024
|
+
param_dict: Optional pre-built dict from ``model.named_parameters()``.
|
|
1025
|
+
Avoids re-traversing the module tree when called in a loop.
|
|
1026
|
+
"""
|
|
1027
|
+
if param_dict is None:
|
|
1028
|
+
param_dict = dict(model.named_parameters())
|
|
1029
|
+
for spec in self.specs:
|
|
1030
|
+
key = spec.entry_key
|
|
1031
|
+
if key not in param_dict:
|
|
1032
|
+
continue
|
|
1033
|
+
chunk = flat_subspace[spec.flat_start : spec.flat_end]
|
|
1034
|
+
base = base_sd[key]
|
|
1035
|
+
param = param_dict[key]
|
|
1036
|
+
|
|
1037
|
+
if spec.is_projected:
|
|
1038
|
+
P = projections[key]
|
|
1039
|
+
if isinstance(P, SparseRandomProjection):
|
|
1040
|
+
delta = P.project(chunk)
|
|
1041
|
+
param.data.copy_((base.reshape(-1) + delta).reshape(spec.original_shape))
|
|
1042
|
+
else:
|
|
1043
|
+
# Fused: base + coords @ P^T. reshape(1, -1) is a view only
|
|
1044
|
+
# when param.data is contiguous; otherwise it is a copy and an
|
|
1045
|
+
# out= write would be silently discarded, so copy_ instead.
|
|
1046
|
+
if param.data.is_contiguous():
|
|
1047
|
+
torch.addmm(
|
|
1048
|
+
base.reshape(1, -1),
|
|
1049
|
+
chunk.unsqueeze(0),
|
|
1050
|
+
P.t(),
|
|
1051
|
+
out=param.data.reshape(1, -1),
|
|
1052
|
+
)
|
|
1053
|
+
else:
|
|
1054
|
+
res = torch.addmm(base.reshape(1, -1), chunk.unsqueeze(0), P.t())
|
|
1055
|
+
param.data.copy_(res.reshape(spec.original_shape))
|
|
1056
|
+
else:
|
|
1057
|
+
param.data.copy_(base + chunk.reshape(spec.original_shape))
|
|
1058
|
+
|
|
1059
|
+
def reconstruct_batch(
|
|
1060
|
+
self,
|
|
1061
|
+
projections: Dict[str, Union[torch.Tensor, SparseRandomProjection]],
|
|
1062
|
+
base_sd: Dict[str, torch.Tensor],
|
|
1063
|
+
flat_subspace_batch: torch.Tensor,
|
|
1064
|
+
) -> Dict[str, torch.Tensor]:
|
|
1065
|
+
"""Vectorized reconstruction for N probe points.
|
|
1066
|
+
|
|
1067
|
+
Uses a fused block-diagonal matmul for all dense layers (single cuBLAS
|
|
1068
|
+
call instead of per-layer calls), with fallback to per-layer for sparse
|
|
1069
|
+
projections and 1D biases.
|
|
1070
|
+
|
|
1071
|
+
If ``build_fused_projection`` has not been called, falls back to the
|
|
1072
|
+
per-layer loop for all entries.
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
projections: Dict mapping entry_key to per-layer projection matrices
|
|
1076
|
+
(dense Tensor or SparseRandomProjection for large layers).
|
|
1077
|
+
base_sd: Base state_dict with original parameter values.
|
|
1078
|
+
flat_subspace_batch: 2D tensor of shape (N, subspace_dim).
|
|
1079
|
+
|
|
1080
|
+
Returns:
|
|
1081
|
+
Dict ``{key: (N, *original_shape)}`` with batched perturbed params.
|
|
1082
|
+
"""
|
|
1083
|
+
N = flat_subspace_batch.shape[0]
|
|
1084
|
+
result = {}
|
|
1085
|
+
|
|
1086
|
+
# Fast path: fused block-diagonal matmul for all dense layers
|
|
1087
|
+
if getattr(self, "_fused_P", None) is not None and self._fused_dense_specs:
|
|
1088
|
+
# Gather subspace coords for all dense layers.
|
|
1089
|
+
# If dense specs are laid out contiguously (common case), use a
|
|
1090
|
+
# single slice instead of a 72-iteration Python loop + torch.cat.
|
|
1091
|
+
first_start = self._fused_dense_specs[0][0].flat_start
|
|
1092
|
+
last_spec = self._fused_dense_specs[-1][0]
|
|
1093
|
+
last_end = last_spec.flat_end
|
|
1094
|
+
total_dense_coords = sum(s.num_coords for s, _ in self._fused_dense_specs)
|
|
1095
|
+
if last_end - first_start == total_dense_coords:
|
|
1096
|
+
# Contiguous layout - single slice
|
|
1097
|
+
fused_coords = flat_subspace_batch[:, first_start:last_end]
|
|
1098
|
+
else:
|
|
1099
|
+
# Non-contiguous - fallback to cat
|
|
1100
|
+
coord_slices = []
|
|
1101
|
+
for spec, _ in self._fused_dense_specs:
|
|
1102
|
+
coord_slices.append(flat_subspace_batch[:, spec.flat_start : spec.flat_end])
|
|
1103
|
+
fused_coords = torch.cat(coord_slices, dim=1) # (N, total_dense_coords)
|
|
1104
|
+
|
|
1105
|
+
# Single matmul: (N, total_dense_coords) @ (total_dense_coords, total_dense_params)
|
|
1106
|
+
fused_delta = fused_coords @ self._fused_P.t() # (N, total_dense_params)
|
|
1107
|
+
|
|
1108
|
+
# Split into per-layer deltas and add to base
|
|
1109
|
+
for spec, param_offset in self._fused_dense_specs:
|
|
1110
|
+
base = base_sd[spec.entry_key]
|
|
1111
|
+
delta = fused_delta[:, param_offset : param_offset + spec.num_params]
|
|
1112
|
+
result_flat = base.reshape(1, -1) + delta
|
|
1113
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1114
|
+
|
|
1115
|
+
# Sparse layers: per-layer fallback
|
|
1116
|
+
for spec, P in self._fused_sparse_specs:
|
|
1117
|
+
chunk = flat_subspace_batch[:, spec.flat_start : spec.flat_end]
|
|
1118
|
+
base = base_sd[spec.entry_key]
|
|
1119
|
+
delta = P.project(chunk)
|
|
1120
|
+
result_flat = base.reshape(1, -1) + delta
|
|
1121
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1122
|
+
|
|
1123
|
+
# 1D biases: direct add
|
|
1124
|
+
for spec in self._fused_bias_specs:
|
|
1125
|
+
chunk = flat_subspace_batch[:, spec.flat_start : spec.flat_end]
|
|
1126
|
+
base = base_sd[spec.entry_key]
|
|
1127
|
+
delta = chunk.reshape(N, *spec.original_shape)
|
|
1128
|
+
result[spec.entry_key] = base.unsqueeze(0) + delta
|
|
1129
|
+
else:
|
|
1130
|
+
# Fallback: per-layer loop (no fused projection built yet)
|
|
1131
|
+
for spec in self.specs:
|
|
1132
|
+
chunk = flat_subspace_batch[:, spec.flat_start : spec.flat_end]
|
|
1133
|
+
base = base_sd[spec.entry_key]
|
|
1134
|
+
|
|
1135
|
+
if spec.is_projected:
|
|
1136
|
+
P = projections[spec.entry_key]
|
|
1137
|
+
if isinstance(P, SparseRandomProjection):
|
|
1138
|
+
delta = P.project(chunk)
|
|
1139
|
+
result_flat = base.reshape(1, -1) + delta
|
|
1140
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1141
|
+
else:
|
|
1142
|
+
result_flat = base.reshape(1, -1) + chunk @ P.t()
|
|
1143
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1144
|
+
else:
|
|
1145
|
+
delta = chunk.reshape(N, *spec.original_shape)
|
|
1146
|
+
result[spec.entry_key] = base.unsqueeze(0) + delta
|
|
1147
|
+
|
|
1148
|
+
return result
|
|
1149
|
+
|
|
1150
|
+
def reconstruct_base(
|
|
1151
|
+
self,
|
|
1152
|
+
projections: Dict[str, Union[torch.Tensor, "SparseRandomProjection"]],
|
|
1153
|
+
base_sd: Dict[str, torch.Tensor],
|
|
1154
|
+
flat_subspace: torch.Tensor,
|
|
1155
|
+
) -> Dict[str, torch.Tensor]:
|
|
1156
|
+
"""Reconstruct full params from subspace coords (single config, no batch dim).
|
|
1157
|
+
|
|
1158
|
+
Returns unbatched tensors suitable as a base for delta reconstruction.
|
|
1159
|
+
"""
|
|
1160
|
+
result = {}
|
|
1161
|
+
for spec in self.specs:
|
|
1162
|
+
chunk = flat_subspace[spec.flat_start : spec.flat_end]
|
|
1163
|
+
base = base_sd[spec.entry_key]
|
|
1164
|
+
if spec.is_projected:
|
|
1165
|
+
P = projections[spec.entry_key]
|
|
1166
|
+
if isinstance(P, SparseRandomProjection):
|
|
1167
|
+
delta = P.project(chunk.unsqueeze(0)).squeeze(0)
|
|
1168
|
+
result[spec.entry_key] = (base.reshape(-1) + delta).reshape(spec.original_shape)
|
|
1169
|
+
else:
|
|
1170
|
+
result[spec.entry_key] = (base.reshape(-1) + chunk @ P.t()).reshape(spec.original_shape)
|
|
1171
|
+
else:
|
|
1172
|
+
result[spec.entry_key] = base + chunk.reshape(spec.original_shape)
|
|
1173
|
+
return result
|
|
1174
|
+
|
|
1175
|
+
def reconstruct_batch_delta(
|
|
1176
|
+
self,
|
|
1177
|
+
projections: Dict[str, Union[torch.Tensor, "SparseRandomProjection"]],
|
|
1178
|
+
base_reconstructed: Dict[str, torch.Tensor],
|
|
1179
|
+
flat_subspace_base: torch.Tensor,
|
|
1180
|
+
flat_subspace_batch: torch.Tensor,
|
|
1181
|
+
) -> Dict[str, torch.Tensor]:
|
|
1182
|
+
"""Delta-based batched reconstruction: only recompute layers affected by changes.
|
|
1183
|
+
|
|
1184
|
+
Instead of reconstructing all layers from scratch for each probe,
|
|
1185
|
+
computes delta = (probe_coords - base_coords) and applies only to
|
|
1186
|
+
layers whose coord ranges overlap with the changed positions.
|
|
1187
|
+
|
|
1188
|
+
This is much faster when probe configs differ from base in only a few
|
|
1189
|
+
contiguous coords (e.g., one particle's pdim-dimensional coordinates).
|
|
1190
|
+
|
|
1191
|
+
Args:
|
|
1192
|
+
projections: Per-layer projection matrices.
|
|
1193
|
+
base_reconstructed: Pre-computed full reconstruction from reconstruct_base().
|
|
1194
|
+
flat_subspace_base: 1D tensor (subspace_dim,) - the base subspace coords.
|
|
1195
|
+
flat_subspace_batch: 2D tensor (N, subspace_dim) - the probed configs.
|
|
1196
|
+
|
|
1197
|
+
Returns:
|
|
1198
|
+
Dict {key: (N, *shape)} with batched perturbed params.
|
|
1199
|
+
"""
|
|
1200
|
+
N = flat_subspace_batch.shape[0]
|
|
1201
|
+
# Compute delta coords: where batch differs from base
|
|
1202
|
+
delta_coords = flat_subspace_batch - flat_subspace_base.unsqueeze(0) # (N, sub_dim)
|
|
1203
|
+
|
|
1204
|
+
result = {}
|
|
1205
|
+
for spec in self.specs:
|
|
1206
|
+
delta_chunk = delta_coords[:, spec.flat_start : spec.flat_end] # (N, num_coords)
|
|
1207
|
+
|
|
1208
|
+
# Check if any probe actually modifies this layer's coords
|
|
1209
|
+
# Use a fast check: if all deltas are zero, just broadcast base
|
|
1210
|
+
has_delta = delta_chunk.any()
|
|
1211
|
+
|
|
1212
|
+
base_val = base_reconstructed[spec.entry_key] # (*shape)
|
|
1213
|
+
|
|
1214
|
+
if not has_delta:
|
|
1215
|
+
# No change in this layer - broadcast base
|
|
1216
|
+
result[spec.entry_key] = base_val.unsqueeze(0).expand(N, *spec.original_shape)
|
|
1217
|
+
elif spec.is_projected:
|
|
1218
|
+
P = projections[spec.entry_key]
|
|
1219
|
+
if isinstance(P, SparseRandomProjection):
|
|
1220
|
+
delta_params = P.project(delta_chunk) # (N, num_params)
|
|
1221
|
+
result_flat = base_val.reshape(1, -1) + delta_params
|
|
1222
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1223
|
+
else:
|
|
1224
|
+
delta_params = delta_chunk @ P.t() # (N, num_params)
|
|
1225
|
+
result_flat = base_val.reshape(1, -1) + delta_params
|
|
1226
|
+
result[spec.entry_key] = result_flat.reshape(N, *spec.original_shape)
|
|
1227
|
+
else:
|
|
1228
|
+
delta = delta_chunk.reshape(N, *spec.original_shape)
|
|
1229
|
+
result[spec.entry_key] = base_val.unsqueeze(0) + delta
|
|
1230
|
+
|
|
1231
|
+
return result
|
|
1232
|
+
|
|
1233
|
+
def absorb(
|
|
1234
|
+
self,
|
|
1235
|
+
projections: Dict[str, torch.Tensor],
|
|
1236
|
+
base_sd: Dict[str, torch.Tensor],
|
|
1237
|
+
flat_subspace: torch.Tensor,
|
|
1238
|
+
) -> Tuple[Dict[str, torch.Tensor], torch.Tensor]:
|
|
1239
|
+
"""Fold subspace perturbation into base weights and zero the subspace.
|
|
1240
|
+
|
|
1241
|
+
This operation "absorbs" the current perturbation into the base parameters,
|
|
1242
|
+
allowing exploration of a new region of parameter space on subsequent steps.
|
|
1243
|
+
After absorb, the projection matrices should be regenerated via rotate_all
|
|
1244
|
+
to ensure diverse exploration.
|
|
1245
|
+
|
|
1246
|
+
Args:
|
|
1247
|
+
projections: Dict mapping entry_key to per-layer projection matrices.
|
|
1248
|
+
base_sd: Base state_dict to absorb perturbation into.
|
|
1249
|
+
flat_subspace: Current subspace vector of shape (subspace_dim,).
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
Tuple of (new_base_sd, zeroed_subspace_vector).
|
|
1253
|
+
|
|
1254
|
+
Example::
|
|
1255
|
+
|
|
1256
|
+
if hybrid.should_absorb(stagnation_count, iteration):
|
|
1257
|
+
new_base, zero_coords = hybrid.absorb(projections, base_sd, coords)
|
|
1258
|
+
# Regenerate all projections for fresh exploration
|
|
1259
|
+
projections = hybrid.init_projections(device, dtype)
|
|
1260
|
+
"""
|
|
1261
|
+
new_sd = self.apply_perturbation(projections, base_sd, flat_subspace)
|
|
1262
|
+
return new_sd, torch.zeros_like(flat_subspace)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
# ------------------------------------------------------------------
|
|
1266
|
+
# Block creation for per-layer OT decomposition
|
|
1267
|
+
# ------------------------------------------------------------------
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def create_hybrid_blocks(
|
|
1271
|
+
hybrid: HybridSubspace,
|
|
1272
|
+
particle_dim: int = 8,
|
|
1273
|
+
) -> "List[BlockConfig]":
|
|
1274
|
+
"""Create blocks for per-layer OT decomposition in hybrid mode.
|
|
1275
|
+
|
|
1276
|
+
Creates one BlockConfig per LayerProjectionSpec, allowing independent
|
|
1277
|
+
OT solves per layer while maintaining global cost evaluation through
|
|
1278
|
+
the shared projection matrices.
|
|
1279
|
+
|
|
1280
|
+
Unlike create_subspace_blocks (which evenly divides a global subspace),
|
|
1281
|
+
this function respects layer boundaries -- each block corresponds exactly
|
|
1282
|
+
to one layer's subspace coordinates.
|
|
1283
|
+
|
|
1284
|
+
Note: Block flat ranges are computed with contiguous offsets (accounting for
|
|
1285
|
+
padding between layers) and may differ from the spec flat ranges. When
|
|
1286
|
+
splitting/reassembling, use the block's flat_start/flat_end, not the spec's.
|
|
1287
|
+
|
|
1288
|
+
Args:
|
|
1289
|
+
hybrid: HybridSubspace instance with per-layer specs.
|
|
1290
|
+
particle_dim: Dimension of each particle within a block (default 8).
|
|
1291
|
+
Higher values give more polytope vertices (2*dim for orthoplex)
|
|
1292
|
+
but fewer particles per block.
|
|
1293
|
+
|
|
1294
|
+
Returns:
|
|
1295
|
+
List of BlockConfig, one per layer in hybrid.specs.
|
|
1296
|
+
|
|
1297
|
+
Example::
|
|
1298
|
+
|
|
1299
|
+
hybrid = HybridSubspace.from_layout(layout, rank=4)
|
|
1300
|
+
blocks = create_hybrid_blocks(hybrid, particle_dim=8)
|
|
1301
|
+
# Use blocks for per-layer OT decomposition
|
|
1302
|
+
|
|
1303
|
+
See Also:
|
|
1304
|
+
``blockwise.create_subspace_blocks`` for uniform subspace division.
|
|
1305
|
+
``blockwise.BlockConfig`` for block configuration details.
|
|
1306
|
+
"""
|
|
1307
|
+
from .blockwise import BlockConfig
|
|
1308
|
+
|
|
1309
|
+
blocks = []
|
|
1310
|
+
offset = 0 # Track contiguous offset accounting for padding
|
|
1311
|
+
|
|
1312
|
+
for i, spec in enumerate(hybrid.specs):
|
|
1313
|
+
# Pad layer's num_coords to be divisible by particle_dim
|
|
1314
|
+
num_coords = spec.flat_end - spec.flat_start
|
|
1315
|
+
padded_coords = num_coords + (-num_coords % particle_dim)
|
|
1316
|
+
num_particles = padded_coords // particle_dim
|
|
1317
|
+
|
|
1318
|
+
# Use contiguous offset, not spec.flat_start, to avoid gaps/overlaps
|
|
1319
|
+
blocks.append(
|
|
1320
|
+
BlockConfig(
|
|
1321
|
+
name=spec.entry_key,
|
|
1322
|
+
leaf_indices=(i,), # Index into hybrid.specs
|
|
1323
|
+
flat_start=offset,
|
|
1324
|
+
flat_end=offset + padded_coords,
|
|
1325
|
+
num_particles=num_particles,
|
|
1326
|
+
particle_dim=particle_dim,
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
offset += padded_coords
|
|
1330
|
+
|
|
1331
|
+
# Note: total offset (sum of padded block dims) may exceed hybrid.subspace_dim
|
|
1332
|
+
# due to per-block padding. Callers must allocate vectors of size `offset`,
|
|
1333
|
+
# not hybrid.subspace_dim, when using these blocks.
|
|
1334
|
+
|
|
1335
|
+
return blocks
|