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.
Files changed (52) hide show
  1. polystep/__init__.py +176 -0
  2. polystep/_compiled.py +334 -0
  3. polystep/_step_blockwise.py +994 -0
  4. polystep/_step_momentum.py +128 -0
  5. polystep/_step_monolithic.py +1070 -0
  6. polystep/adaptive_subspace.py +762 -0
  7. polystep/api.py +307 -0
  8. polystep/ask_tell.py +164 -0
  9. polystep/benchmarks/__init__.py +62 -0
  10. polystep/benchmarks/baselines.py +604 -0
  11. polystep/benchmarks/rl/__init__.py +13 -0
  12. polystep/benchmarks/rl/cartpole.py +260 -0
  13. polystep/benchmarks/rl/gym_evaluator.py +304 -0
  14. polystep/benchmarks/rl/metrics.py +53 -0
  15. polystep/benchmarks/rl/policies.py +133 -0
  16. polystep/benchmarks/utils.py +1262 -0
  17. polystep/blockwise.py +549 -0
  18. polystep/cma.py +400 -0
  19. polystep/cma_subspace.py +404 -0
  20. polystep/cost_nn.py +633 -0
  21. polystep/costs.py +86 -0
  22. polystep/dynamics.py +138 -0
  23. polystep/epsilon.py +217 -0
  24. polystep/geometry.py +410 -0
  25. polystep/hybrid_subspace.py +1335 -0
  26. polystep/layers/__init__.py +16 -0
  27. polystep/layers/attention.py +243 -0
  28. polystep/layers/rnn.py +276 -0
  29. polystep/objectives/__init__.py +25 -0
  30. polystep/objectives/base.py +77 -0
  31. polystep/objectives/synthetic.py +232 -0
  32. polystep/optimizer.py +1405 -0
  33. polystep/projection/__init__.py +5 -0
  34. polystep/projection/sparse.py +291 -0
  35. polystep/py.typed +0 -0
  36. polystep/quadratic_model.py +285 -0
  37. polystep/solver.py +654 -0
  38. polystep/solvers/__init__.py +34 -0
  39. polystep/solvers/_prelude.py +129 -0
  40. polystep/solvers/base.py +77 -0
  41. polystep/solvers/greedy.py +159 -0
  42. polystep/solvers/kl_softmax.py +200 -0
  43. polystep/solvers/sinkhorn.py +522 -0
  44. polystep/solvers/softmax.py +151 -0
  45. polystep/solvers/tempered_softmax.py +97 -0
  46. polystep/subspace.py +694 -0
  47. polystep/transform.py +365 -0
  48. polystep-0.7.0.dist-info/METADATA +285 -0
  49. polystep-0.7.0.dist-info/RECORD +52 -0
  50. polystep-0.7.0.dist-info/WHEEL +5 -0
  51. polystep-0.7.0.dist-info/licenses/LICENSE +191 -0
  52. 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