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
polystep/cost_nn.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Vectorized NN cost evaluation via vmap + functional_call.
|
|
2
|
+
|
|
3
|
+
Evaluates a neural network at batched parameter configurations using
|
|
4
|
+
``torch.vmap`` for vectorized evaluation -- each candidate parameter set
|
|
5
|
+
is evaluated in parallel by swapping model parameters via
|
|
6
|
+
``torch.func.functional_call``. Falls back to a sequential Python loop
|
|
7
|
+
if vmap is incompatible with the model (one-time warning emitted).
|
|
8
|
+
|
|
9
|
+
When ``chunk_size`` is set, evaluations are batched to bound peak GPU
|
|
10
|
+
memory. ``auto_detect_chunk_size`` estimates a safe value based on model
|
|
11
|
+
size and available GPU memory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import warnings
|
|
17
|
+
from typing import Callable, Optional, TYPE_CHECKING, Union
|
|
18
|
+
|
|
19
|
+
import torch
|
|
20
|
+
import torch.nn as nn
|
|
21
|
+
from torch.func import functional_call, vmap
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .hybrid_subspace import HybridSubspace
|
|
25
|
+
from .transform import ParamLayout
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def auto_detect_chunk_size(
|
|
29
|
+
model: nn.Module,
|
|
30
|
+
safety_factor: float = 2.0,
|
|
31
|
+
compile_overhead: bool = False,
|
|
32
|
+
) -> Optional[int]:
|
|
33
|
+
"""Estimate safe vmap chunk_size from model size and GPU memory.
|
|
34
|
+
|
|
35
|
+
Returns None when the model is on CPU (no memory limit needed),
|
|
36
|
+
even if the machine has a GPU available. On GPU, estimates
|
|
37
|
+
per-evaluation memory as 4x parameter memory (conservative
|
|
38
|
+
heuristic for activations + intermediates) and divides available
|
|
39
|
+
GPU memory by this estimate.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
model: The model to estimate for.
|
|
43
|
+
safety_factor: Divisor for extra safety margin (default 2.0).
|
|
44
|
+
compile_overhead: When True, multiply the safety factor by 1.5
|
|
45
|
+
to account for the 10-20% extra peak memory ``torch.compile``
|
|
46
|
+
pulls in for CUDA graph capture.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Recommended chunk_size, or None if model is on CPU.
|
|
50
|
+
"""
|
|
51
|
+
# Check actual device of model parameters, not global CUDA availability
|
|
52
|
+
try:
|
|
53
|
+
param_device = next(model.parameters()).device
|
|
54
|
+
except StopIteration:
|
|
55
|
+
return None # No parameters
|
|
56
|
+
|
|
57
|
+
if param_device.type != "cuda":
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
param_bytes = sum(p.numel() * p.element_size() for p in model.parameters())
|
|
61
|
+
buffer_bytes = sum(b.numel() * b.element_size() for b in model.buffers())
|
|
62
|
+
# Heuristic: 4x (params + buffers) for activations + intermediates
|
|
63
|
+
per_eval_bytes = (param_bytes + buffer_bytes) * 4
|
|
64
|
+
|
|
65
|
+
if per_eval_bytes <= 0:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
# torch.compile reserves CUDA-graph workspaces and intermediate
|
|
69
|
+
# buffers that push peak memory ~10-20% above the eager path; bake
|
|
70
|
+
# the headroom into the safety factor so we don't OOM at first call.
|
|
71
|
+
effective_safety = safety_factor * 1.5 if compile_overhead else safety_factor
|
|
72
|
+
|
|
73
|
+
free_mem, _ = torch.cuda.mem_get_info(param_device)
|
|
74
|
+
chunk = max(1, int(free_mem / (per_eval_bytes * effective_safety)))
|
|
75
|
+
return chunk
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_UNSET = object() # sentinel distinguishing "not yet computed" from None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_vanilla_cross_entropy(loss_fn: nn.CrossEntropyLoss) -> bool:
|
|
82
|
+
"""True when a CrossEntropyLoss uses default reduction and no reweighting.
|
|
83
|
+
|
|
84
|
+
The batched-linear fast path reimplements cross-entropy with mean reduction
|
|
85
|
+
and no class weights, so it is only correct for an unconfigured loss. Weighted
|
|
86
|
+
classes, label smoothing, a custom ignore_index, or a non-mean reduction must
|
|
87
|
+
fall back to the vmap path, which calls the real loss_fn.
|
|
88
|
+
"""
|
|
89
|
+
return (
|
|
90
|
+
loss_fn.weight is None
|
|
91
|
+
and loss_fn.ignore_index == -100
|
|
92
|
+
and float(loss_fn.label_smoothing) == 0.0
|
|
93
|
+
and loss_fn.reduction == "mean"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NNCostEvaluator:
|
|
98
|
+
"""Vectorized NN cost evaluation via vmap + functional_call.
|
|
99
|
+
|
|
100
|
+
Evaluates a model at N batched parameter configurations. Uses
|
|
101
|
+
``torch.vmap`` for vectorized inference; falls back to a sequential
|
|
102
|
+
Python loop if vmap fails (one-time warning emitted).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
model: The ``nn.Module`` to evaluate. Will be put in eval mode.
|
|
106
|
+
loss_fn: Loss function with signature:
|
|
107
|
+
|
|
108
|
+
- ``loss_fn(output, targets) -> scalar`` (supervised), or
|
|
109
|
+
- ``loss_fn(output) -> scalar`` (unsupervised, targets=None).
|
|
110
|
+
chunk_size: vmap ``chunk_size`` for memory control.
|
|
111
|
+
``None`` = evaluate all at once (no chunking).
|
|
112
|
+
``"auto"`` = auto-detect from model size and GPU memory.
|
|
113
|
+
Positive int = evaluate in chunks of this size.
|
|
114
|
+
compile_vmap: If True, wrap the vmap evaluation in
|
|
115
|
+
``torch.compile(mode="default")`` for kernel fusion. Falls back
|
|
116
|
+
to eager on failure. Best for CUDA models. Default False.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
model: nn.Module,
|
|
122
|
+
loss_fn: Callable,
|
|
123
|
+
chunk_size: Union[None, int, str] = None,
|
|
124
|
+
compile_vmap: bool = False,
|
|
125
|
+
use_inplace: Optional[bool] = None,
|
|
126
|
+
):
|
|
127
|
+
self.model = model
|
|
128
|
+
self.loss_fn = loss_fn
|
|
129
|
+
self._chunk_size_raw = chunk_size
|
|
130
|
+
self._chunk_size_cached = _UNSET # lazily computed for "auto" mode
|
|
131
|
+
self._vmap_failed = False
|
|
132
|
+
self._warned = False
|
|
133
|
+
self._compile_vmap = compile_vmap
|
|
134
|
+
self._compiled_vmap_fn = None
|
|
135
|
+
self._compile_failed = False
|
|
136
|
+
|
|
137
|
+
# Force eval mode for consistent behavior (frozen BN stats, no dropout)
|
|
138
|
+
model.eval()
|
|
139
|
+
|
|
140
|
+
# Try to build fast batched-linear evaluator (MLP-only models).
|
|
141
|
+
# Only for a default CrossEntropyLoss: the bmm path hardcodes mean
|
|
142
|
+
# reduction with no class weights, so a configured loss (weights, label
|
|
143
|
+
# smoothing, custom ignore_index, non-mean reduction) must use vmap.
|
|
144
|
+
if isinstance(loss_fn, nn.CrossEntropyLoss) and _is_vanilla_cross_entropy(loss_fn):
|
|
145
|
+
self._batched_linear = BatchedLinearEvaluator.try_build(model, loss_fn)
|
|
146
|
+
else:
|
|
147
|
+
self._batched_linear = None
|
|
148
|
+
|
|
149
|
+
# Auto-detect whether to use memory-efficient in-place evaluation.
|
|
150
|
+
# For very large models (>500K params) on GPU, vmap materializes N
|
|
151
|
+
# copies of all intermediate activations simultaneously, causing
|
|
152
|
+
# O(N x activation) memory. In-place evaluation uses O(1 x activation)
|
|
153
|
+
# regardless of N. For models ≤500K params (e.g. MNISTNet ~102K,
|
|
154
|
+
# CIFAR10MLP ~199K), chunked vmap is fast and fits in GPU memory.
|
|
155
|
+
# Pass use_inplace=True/False to override auto-detection.
|
|
156
|
+
if use_inplace is not None:
|
|
157
|
+
self._use_inplace = use_inplace
|
|
158
|
+
else:
|
|
159
|
+
n_params = sum(p.numel() for p in model.parameters())
|
|
160
|
+
try:
|
|
161
|
+
on_gpu = next(model.parameters()).device.type == "cuda"
|
|
162
|
+
except StopIteration:
|
|
163
|
+
on_gpu = False
|
|
164
|
+
self._use_inplace = on_gpu and n_params > 500_000
|
|
165
|
+
|
|
166
|
+
# Cache frozen buffers from the real model (shared across all particles)
|
|
167
|
+
self._buffers = dict(model.named_buffers())
|
|
168
|
+
|
|
169
|
+
# Cache param dict for in-place evaluation (avoids O(L) module traversal per call)
|
|
170
|
+
self._param_dict_cache = dict(self.model.named_parameters())
|
|
171
|
+
|
|
172
|
+
def reset_vmap(self) -> None:
|
|
173
|
+
"""Reset the vmap failure flag so vmap is attempted again.
|
|
174
|
+
|
|
175
|
+
Useful after changing the model architecture (e.g., swapping layers),
|
|
176
|
+
moving the model to a different device, or upgrading PyTorch (vmap
|
|
177
|
+
op coverage expands across releases).
|
|
178
|
+
"""
|
|
179
|
+
self._vmap_failed = False
|
|
180
|
+
self._warned = False
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def chunk_size(self) -> Optional[int]:
|
|
184
|
+
"""Resolved chunk_size for vmap.
|
|
185
|
+
|
|
186
|
+
Returns None (no chunking), or a positive int. When
|
|
187
|
+
``chunk_size="auto"`` was passed, queries GPU memory once to compute
|
|
188
|
+
a safe value (returns None on CPU), then caches the result.
|
|
189
|
+
"""
|
|
190
|
+
if self._chunk_size_raw == "auto":
|
|
191
|
+
if self._chunk_size_cached is _UNSET:
|
|
192
|
+
self._chunk_size_cached = auto_detect_chunk_size(
|
|
193
|
+
self.model,
|
|
194
|
+
compile_overhead=self._compile_vmap,
|
|
195
|
+
)
|
|
196
|
+
return self._chunk_size_cached
|
|
197
|
+
return self._chunk_size_raw
|
|
198
|
+
|
|
199
|
+
@torch.inference_mode()
|
|
200
|
+
def evaluate(
|
|
201
|
+
self,
|
|
202
|
+
stacked_params: dict[str, torch.Tensor],
|
|
203
|
+
inputs: torch.Tensor,
|
|
204
|
+
targets: Optional[torch.Tensor] = None,
|
|
205
|
+
) -> torch.Tensor:
|
|
206
|
+
"""Evaluate model at batched parameters.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
stacked_params: ``{key: (N, *param_shape)}`` stacked param dicts
|
|
210
|
+
(from ``ParamLayout.batch_unflatten()``).
|
|
211
|
+
inputs: Input data batch (broadcast to all N evaluations).
|
|
212
|
+
targets: Optional targets (broadcast to all N evaluations).
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Losses tensor of shape ``(N,)``.
|
|
216
|
+
"""
|
|
217
|
+
# Model should already be in eval mode (set in __init__).
|
|
218
|
+
# Fast path: was_training is False 99.9% of the time (single bool check,
|
|
219
|
+
# no O(L) module traversal). Restore on exit for external callers.
|
|
220
|
+
was_training = self.model.training
|
|
221
|
+
if was_training:
|
|
222
|
+
self.model.eval()
|
|
223
|
+
|
|
224
|
+
# Match float inputs to the (maybe bf16) param dtype so bmm/vmap don't hit a
|
|
225
|
+
# dtype mismatch under mixed precision. Integer inputs and targets untouched.
|
|
226
|
+
if stacked_params:
|
|
227
|
+
param_dtype = next(iter(stacked_params.values())).dtype
|
|
228
|
+
if inputs.is_floating_point() and inputs.dtype != param_dtype:
|
|
229
|
+
inputs = inputs.to(param_dtype)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Fast path: batched bmm for Linear-only models (MLP)
|
|
233
|
+
# Only used for supervised (targets != None) with cross-entropy.
|
|
234
|
+
if self._batched_linear is not None and targets is not None:
|
|
235
|
+
return self._batched_linear.evaluate(stacked_params, inputs, targets)
|
|
236
|
+
|
|
237
|
+
# Memory-efficient path: in-place weight swap for large GPU models.
|
|
238
|
+
# Uses O(1 x activation) memory instead of O(N x activation).
|
|
239
|
+
if self._use_inplace:
|
|
240
|
+
return self._evaluate_inplace(stacked_params, inputs, targets)
|
|
241
|
+
|
|
242
|
+
if self._vmap_failed:
|
|
243
|
+
result = self._evaluate_loop(stacked_params, inputs, targets)
|
|
244
|
+
else:
|
|
245
|
+
try:
|
|
246
|
+
result = self._evaluate_vmap(stacked_params, inputs, targets)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
# Only catch vmap/functorch-related errors; re-raise real bugs
|
|
249
|
+
msg = str(e).lower()
|
|
250
|
+
is_vmap_issue = any(
|
|
251
|
+
k in msg for k in ("vmap", "functorch", "batched tensor", "batched", "randomness")
|
|
252
|
+
)
|
|
253
|
+
if not is_vmap_issue:
|
|
254
|
+
raise
|
|
255
|
+
if not self._warned:
|
|
256
|
+
warnings.warn(
|
|
257
|
+
f"vmap failed for {type(self.model).__name__}: {e}. Falling back to sequential evaluation.",
|
|
258
|
+
stacklevel=2,
|
|
259
|
+
)
|
|
260
|
+
self._warned = True
|
|
261
|
+
self._vmap_failed = True
|
|
262
|
+
result = self._evaluate_loop(stacked_params, inputs, targets)
|
|
263
|
+
return result
|
|
264
|
+
finally:
|
|
265
|
+
if was_training:
|
|
266
|
+
self.model.train()
|
|
267
|
+
|
|
268
|
+
def _evaluate_vmap(self, stacked_params, inputs, targets):
|
|
269
|
+
"""Vectorized evaluation via vmap + functional_call.
|
|
270
|
+
|
|
271
|
+
When ``compile_vmap=True`` (set at init), wraps the batched evaluation
|
|
272
|
+
in ``torch.compile(mode="reduce-overhead")`` for kernel fusion and
|
|
273
|
+
CUDA graph capture. Falls back to eager vmap on compilation failure.
|
|
274
|
+
"""
|
|
275
|
+
buffers = self._buffers
|
|
276
|
+
loss_fn = self.loss_fn
|
|
277
|
+
model = self.model
|
|
278
|
+
resolved_chunk = self.chunk_size
|
|
279
|
+
|
|
280
|
+
# inputs/targets are explicit args (in_dims=None), not closed-over, so a
|
|
281
|
+
# cached torch.compile graph does not bake in the first call's batch.
|
|
282
|
+
def single_eval(params, inputs, targets):
|
|
283
|
+
# Buffers override params intentionally: stacked_params contains only
|
|
284
|
+
# trainable entries from ParamLayout, while self._buffers holds frozen
|
|
285
|
+
# model state (e.g., BatchNorm running_mean/var, num_batches_tracked).
|
|
286
|
+
# If a key appears in both, the buffer value is authoritative because
|
|
287
|
+
# buffers are not part of the OT optimization and must stay frozen.
|
|
288
|
+
full_dict = {**params, **buffers}
|
|
289
|
+
output = functional_call(model, full_dict, (inputs,))
|
|
290
|
+
if targets is not None:
|
|
291
|
+
loss = loss_fn(output, targets)
|
|
292
|
+
else:
|
|
293
|
+
loss = loss_fn(output)
|
|
294
|
+
if loss.dim() > 0:
|
|
295
|
+
loss = loss.mean()
|
|
296
|
+
return loss
|
|
297
|
+
|
|
298
|
+
batched = vmap(single_eval, in_dims=(0, None, None), chunk_size=resolved_chunk)
|
|
299
|
+
|
|
300
|
+
# Compiled path: torch.compile on vmap for kernel fusion + CUDA graphs.
|
|
301
|
+
# Only attempted on CUDA with compile_vmap=True. Lazy-compiled on first call.
|
|
302
|
+
if self._compile_vmap and not self._compile_failed:
|
|
303
|
+
if self._compiled_vmap_fn is None:
|
|
304
|
+
try:
|
|
305
|
+
# Use "default" mode: kernel fusion without CUDA graphs.
|
|
306
|
+
# "reduce-overhead" (CUDA graphs) has tensor ownership conflicts
|
|
307
|
+
# with vmap's chunked output concatenation.
|
|
308
|
+
self._compiled_vmap_fn = torch.compile(
|
|
309
|
+
batched,
|
|
310
|
+
mode="default",
|
|
311
|
+
fullgraph=False,
|
|
312
|
+
)
|
|
313
|
+
except Exception:
|
|
314
|
+
self._compile_failed = True
|
|
315
|
+
self._compiled_vmap_fn = None
|
|
316
|
+
|
|
317
|
+
if self._compiled_vmap_fn is not None:
|
|
318
|
+
try:
|
|
319
|
+
return self._compiled_vmap_fn(stacked_params, inputs, targets)
|
|
320
|
+
except Exception:
|
|
321
|
+
# Compilation or execution failed - fall back to eager permanently
|
|
322
|
+
self._compile_failed = True
|
|
323
|
+
self._compiled_vmap_fn = None
|
|
324
|
+
|
|
325
|
+
return batched(stacked_params, inputs, targets)
|
|
326
|
+
|
|
327
|
+
def _evaluate_loop(self, stacked_params, inputs, targets):
|
|
328
|
+
"""Sequential fallback when vmap is incompatible."""
|
|
329
|
+
if not stacked_params:
|
|
330
|
+
return torch.zeros(0, device=inputs.device)
|
|
331
|
+
N = next(iter(stacked_params.values())).shape[0]
|
|
332
|
+
if N == 0:
|
|
333
|
+
return torch.zeros(0, device=inputs.device)
|
|
334
|
+
losses = []
|
|
335
|
+
for i in range(N):
|
|
336
|
+
params_i = {k: v[i] for k, v in stacked_params.items()}
|
|
337
|
+
full_dict = {**params_i, **self._buffers}
|
|
338
|
+
output = functional_call(self.model, full_dict, (inputs,))
|
|
339
|
+
if targets is not None:
|
|
340
|
+
loss = self.loss_fn(output, targets)
|
|
341
|
+
else:
|
|
342
|
+
loss = self.loss_fn(output)
|
|
343
|
+
if loss.dim() > 0:
|
|
344
|
+
loss = loss.mean()
|
|
345
|
+
losses.append(loss)
|
|
346
|
+
return torch.stack(losses)
|
|
347
|
+
|
|
348
|
+
def _evaluate_inplace(self, stacked_params, inputs, targets):
|
|
349
|
+
"""Memory-minimal evaluation via in-place weight swapping.
|
|
350
|
+
|
|
351
|
+
Evaluates N parameter configurations sequentially by directly
|
|
352
|
+
modifying model weights in-place. Uses only O(1 x activation)
|
|
353
|
+
memory regardless of N, making it feasible for large models
|
|
354
|
+
where vmap would OOM.
|
|
355
|
+
|
|
356
|
+
Inspired by MeZO's in-place perturbation strategy and ZO2's
|
|
357
|
+
sequential evaluation. Uses ``torch.inference_mode()`` to
|
|
358
|
+
eliminate autograd overhead and view tracking.
|
|
359
|
+
|
|
360
|
+
Wall-clock: ~Nx slower than vmap for small models, but for
|
|
361
|
+
large models where each forward pass already saturates the GPU,
|
|
362
|
+
the overhead is minimal (~2x vs vmap).
|
|
363
|
+
"""
|
|
364
|
+
if not stacked_params:
|
|
365
|
+
return torch.zeros(0, device=inputs.device)
|
|
366
|
+
N = next(iter(stacked_params.values())).shape[0]
|
|
367
|
+
device = inputs.device
|
|
368
|
+
losses = torch.empty(N, device=device)
|
|
369
|
+
|
|
370
|
+
# Save original weights (one copy, regardless of N)
|
|
371
|
+
original_params = {}
|
|
372
|
+
param_dict = self._param_dict_cache
|
|
373
|
+
if not stacked_params.keys() <= param_dict.keys():
|
|
374
|
+
# Model gained or renamed params since construction; refresh the cache
|
|
375
|
+
# so new keys aren't silently evaluated with stale weights.
|
|
376
|
+
param_dict = self._param_dict_cache = dict(self.model.named_parameters())
|
|
377
|
+
for key in stacked_params:
|
|
378
|
+
if key in param_dict:
|
|
379
|
+
original_params[key] = param_dict[key].data.clone()
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
for i in range(N):
|
|
383
|
+
# Swap weights in-place - no copies, just overwrite .data
|
|
384
|
+
for key in stacked_params:
|
|
385
|
+
if key in param_dict:
|
|
386
|
+
param_dict[key].data.copy_(stacked_params[key][i])
|
|
387
|
+
|
|
388
|
+
# Forward pass - already under inference_mode from evaluate()
|
|
389
|
+
output = self.model(inputs)
|
|
390
|
+
if targets is not None:
|
|
391
|
+
loss = self.loss_fn(output, targets)
|
|
392
|
+
else:
|
|
393
|
+
loss = self.loss_fn(output)
|
|
394
|
+
if loss.dim() > 0:
|
|
395
|
+
loss = loss.mean()
|
|
396
|
+
# .item()-free: store tensor directly, detach from graph
|
|
397
|
+
losses[i] = loss.detach()
|
|
398
|
+
finally:
|
|
399
|
+
# Always restore original weights, even on error
|
|
400
|
+
for key, orig in original_params.items():
|
|
401
|
+
if key in param_dict:
|
|
402
|
+
param_dict[key].data.copy_(orig)
|
|
403
|
+
|
|
404
|
+
return losses
|
|
405
|
+
|
|
406
|
+
def evaluate_subspace_inplace(
|
|
407
|
+
self,
|
|
408
|
+
subspace: "HybridSubspace",
|
|
409
|
+
projections: dict,
|
|
410
|
+
base_sd: dict[str, torch.Tensor],
|
|
411
|
+
flat_subspace_batch: torch.Tensor,
|
|
412
|
+
inputs: torch.Tensor,
|
|
413
|
+
targets: Optional[torch.Tensor] = None,
|
|
414
|
+
) -> torch.Tensor:
|
|
415
|
+
"""Fused subspace-reconstruct + forward via in-place weight swap.
|
|
416
|
+
|
|
417
|
+
Instead of materialising all ``N`` configurations' full weights
|
|
418
|
+
up front via ``reconstruct_batch`` and then evaluating, this
|
|
419
|
+
method reconstructs one configuration at a time directly into the
|
|
420
|
+
model parameters using ``apply_perturbation_inplace`` and runs a
|
|
421
|
+
single forward pass per configuration.
|
|
422
|
+
|
|
423
|
+
Peak memory is ``O(model_params + batch * activation)``
|
|
424
|
+
independent of ``N``; no stacked parameter dict is ever allocated.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
subspace: HybridSubspace with ``apply_perturbation_inplace``.
|
|
428
|
+
projections: Per-layer projection matrices.
|
|
429
|
+
base_sd: Base (unperturbed) state_dict.
|
|
430
|
+
flat_subspace_batch: (N, subspace_dim) subspace coordinates.
|
|
431
|
+
inputs: Input data batch.
|
|
432
|
+
targets: Optional targets.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Losses tensor of shape (N,).
|
|
436
|
+
"""
|
|
437
|
+
N = flat_subspace_batch.shape[0]
|
|
438
|
+
device = inputs.device
|
|
439
|
+
losses = torch.empty(N, device=device)
|
|
440
|
+
|
|
441
|
+
was_training = self.model.training
|
|
442
|
+
if was_training:
|
|
443
|
+
self.model.eval()
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
for i in range(N):
|
|
447
|
+
# Reconstruct weights for config i directly into model params
|
|
448
|
+
subspace.apply_perturbation_inplace(
|
|
449
|
+
projections,
|
|
450
|
+
self.model,
|
|
451
|
+
base_sd,
|
|
452
|
+
flat_subspace_batch[i],
|
|
453
|
+
)
|
|
454
|
+
# Forward pass - already under inference_mode from caller
|
|
455
|
+
output = self.model(inputs)
|
|
456
|
+
if targets is not None:
|
|
457
|
+
loss = self.loss_fn(output, targets)
|
|
458
|
+
else:
|
|
459
|
+
loss = self.loss_fn(output)
|
|
460
|
+
if loss.dim() > 0:
|
|
461
|
+
loss = loss.mean()
|
|
462
|
+
losses[i] = loss.detach()
|
|
463
|
+
finally:
|
|
464
|
+
# Restore base weights
|
|
465
|
+
param_dict = dict(self.model.named_parameters())
|
|
466
|
+
for key, base in base_sd.items():
|
|
467
|
+
if key in param_dict:
|
|
468
|
+
param_dict[key].data.copy_(base)
|
|
469
|
+
if was_training:
|
|
470
|
+
self.model.train()
|
|
471
|
+
|
|
472
|
+
return losses
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class BatchedLinearEvaluator:
|
|
476
|
+
"""Fast batched evaluation for pure-MLP models.
|
|
477
|
+
|
|
478
|
+
Replaces vmap + functional_call with explicit ``torch.bmm`` per Linear
|
|
479
|
+
layer, eliminating vmap dispatch overhead. For N parameter configs of a
|
|
480
|
+
k-layer MLP, performs k batched matmuls instead of N sequential forward
|
|
481
|
+
passes or a single vmap call.
|
|
482
|
+
|
|
483
|
+
Supported layers: ``nn.Linear``, ``nn.ReLU``, ``nn.LeakyReLU``,
|
|
484
|
+
``nn.Sigmoid``, ``nn.Tanh``, ``nn.GELU``, ``nn.SiLU``, ``nn.Flatten``,
|
|
485
|
+
and ``nn.Dropout`` (eval mode). Models containing any other layer
|
|
486
|
+
cause ``try_build`` to return ``None``.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
def __init__(self, model: nn.Module, loss_fn: Callable, layer_keys: list):
|
|
490
|
+
self.model = model
|
|
491
|
+
self.loss_fn = loss_fn
|
|
492
|
+
self._layer_keys = layer_keys # ordered list of (name, type_tag)
|
|
493
|
+
|
|
494
|
+
@classmethod
|
|
495
|
+
def try_build(cls, model: nn.Module, loss_fn: Callable) -> "BatchedLinearEvaluator | None":
|
|
496
|
+
"""Build if model is compatible, else return None."""
|
|
497
|
+
# The bmm plan is rebuilt from named_children(), which misses activations
|
|
498
|
+
# applied inline in a custom forward (e.g. torch.relu) and would compute a
|
|
499
|
+
# wrong, activation-free loss. Only trust genuine nn.Sequential forwards.
|
|
500
|
+
if type(model).forward is not nn.Sequential.forward:
|
|
501
|
+
return None
|
|
502
|
+
supported = (nn.Linear, nn.ReLU, nn.LeakyReLU, nn.Sigmoid, nn.Tanh, nn.GELU, nn.SiLU, nn.Flatten, nn.Dropout)
|
|
503
|
+
|
|
504
|
+
# Build an ordered (name, tag, module) plan. ``module`` is None for
|
|
505
|
+
# Linear layers (handled by bmm) and the actual nn.Module for
|
|
506
|
+
# activations so evaluate() applies their EXACT semantics
|
|
507
|
+
# (LeakyReLU.negative_slope, GELU.approximate, ...) instead of
|
|
508
|
+
# hardcoded functional defaults. Unsupported -> None (vmap fallback).
|
|
509
|
+
def _entry(full, submod):
|
|
510
|
+
if isinstance(submod, nn.Linear):
|
|
511
|
+
return (full, "linear", None)
|
|
512
|
+
# A non-default Flatten reshapes differently from the bmm path's
|
|
513
|
+
# leading flatten; defer those models to the (correct) vmap path.
|
|
514
|
+
if isinstance(submod, nn.Flatten) and (submod.start_dim, submod.end_dim) != (1, -1):
|
|
515
|
+
return None
|
|
516
|
+
if isinstance(submod, supported):
|
|
517
|
+
return (full, type(submod).__name__.lower(), submod)
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
layer_keys = []
|
|
521
|
+
for name, mod in model.named_children():
|
|
522
|
+
if isinstance(mod, nn.Sequential):
|
|
523
|
+
# Walk one level of Sequential
|
|
524
|
+
children = [(f"{name}.{sub}", m) for sub, m in mod.named_children()]
|
|
525
|
+
else:
|
|
526
|
+
children = [(name, mod)]
|
|
527
|
+
for full, submod in children:
|
|
528
|
+
entry = _entry(full, submod)
|
|
529
|
+
if entry is None:
|
|
530
|
+
return None # unsupported layer (Conv2d, non-default Flatten, ...)
|
|
531
|
+
layer_keys.append(entry)
|
|
532
|
+
if not layer_keys:
|
|
533
|
+
return None
|
|
534
|
+
# Verify all named parameters are covered by detected Linear layers.
|
|
535
|
+
# Models with extra parameters (e.g., learned scales) need vmap.
|
|
536
|
+
linear_param_keys = set()
|
|
537
|
+
for name, tag, _ in layer_keys:
|
|
538
|
+
if tag == "linear":
|
|
539
|
+
linear_param_keys.add(f"{name}.weight")
|
|
540
|
+
linear_param_keys.add(f"{name}.bias")
|
|
541
|
+
model_param_keys = {n for n, _ in model.named_parameters()}
|
|
542
|
+
if not model_param_keys.issubset(linear_param_keys):
|
|
543
|
+
return None
|
|
544
|
+
return cls(model, loss_fn, layer_keys)
|
|
545
|
+
|
|
546
|
+
@torch.inference_mode()
|
|
547
|
+
def evaluate(
|
|
548
|
+
self,
|
|
549
|
+
stacked_params: dict[str, torch.Tensor],
|
|
550
|
+
inputs: torch.Tensor,
|
|
551
|
+
targets: torch.Tensor | None = None,
|
|
552
|
+
) -> torch.Tensor:
|
|
553
|
+
"""Batched forward via bmm for Linear layers."""
|
|
554
|
+
N = next(iter(stacked_params.values())).shape[0]
|
|
555
|
+
# x: (N, B, features) - expand input across N param configs
|
|
556
|
+
if inputs.dim() == 2:
|
|
557
|
+
x = inputs.unsqueeze(0).expand(N, -1, -1) # (N, B, in_feat)
|
|
558
|
+
else:
|
|
559
|
+
# Flatten spatial dims for non-2D inputs
|
|
560
|
+
x = inputs.reshape(inputs.shape[0], -1).unsqueeze(0).expand(N, -1, -1)
|
|
561
|
+
|
|
562
|
+
for name, tag, module in self._layer_keys:
|
|
563
|
+
if tag == "linear":
|
|
564
|
+
w_key = f"{name}.weight"
|
|
565
|
+
b_key = f"{name}.bias"
|
|
566
|
+
W = stacked_params[w_key] # (N, out, in)
|
|
567
|
+
# bmm: (N, B, in) @ (N, in, out) -> (N, B, out)
|
|
568
|
+
x = torch.bmm(x, W.transpose(1, 2)) # (N, B, out)
|
|
569
|
+
if b_key in stacked_params:
|
|
570
|
+
x = x + stacked_params[b_key].unsqueeze(1) # (N, 1, out) broadcast
|
|
571
|
+
elif tag in ("flatten", "dropout"):
|
|
572
|
+
pass # input is pre-flattened; dropout is identity in eval mode
|
|
573
|
+
else:
|
|
574
|
+
# Activation: apply the real module so its configuration
|
|
575
|
+
# (LeakyReLU negative_slope, GELU approximate, ...) is exact.
|
|
576
|
+
x = module(x)
|
|
577
|
+
|
|
578
|
+
# x: (N, B, out_features) - compute per-config loss
|
|
579
|
+
if targets is not None:
|
|
580
|
+
tgt = targets.unsqueeze(0).expand(N, -1) # (N, B)
|
|
581
|
+
# Reshape for cross-entropy: (N*B, C) vs (N*B,)
|
|
582
|
+
losses = (
|
|
583
|
+
torch.nn.functional.cross_entropy(
|
|
584
|
+
x.reshape(N * targets.shape[0], -1),
|
|
585
|
+
tgt.reshape(-1),
|
|
586
|
+
reduction="none",
|
|
587
|
+
)
|
|
588
|
+
.reshape(N, -1)
|
|
589
|
+
.mean(dim=1)
|
|
590
|
+
) # (N,)
|
|
591
|
+
else:
|
|
592
|
+
losses = self.loss_fn(x).mean(dim=1) if x.dim() > 2 else self.loss_fn(x)
|
|
593
|
+
return losses
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def compute_nn_cost_matrix(
|
|
597
|
+
evaluator: NNCostEvaluator,
|
|
598
|
+
X_probe: torch.Tensor,
|
|
599
|
+
layout: ParamLayout,
|
|
600
|
+
inputs: torch.Tensor,
|
|
601
|
+
targets: Optional[torch.Tensor] = None,
|
|
602
|
+
) -> torch.Tensor:
|
|
603
|
+
"""Compute NN cost matrix from probe points via vectorized evaluation.
|
|
604
|
+
|
|
605
|
+
Flattens the (P, V, K) probe structure into a single batch dimension,
|
|
606
|
+
evaluates all probe points via the evaluator, then reshapes and averages
|
|
607
|
+
over the probe dimension K to produce the (P, V) cost matrix.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
evaluator: NNCostEvaluator instance.
|
|
611
|
+
X_probe: Probe points of shape ``(P, V, K, D)`` where D is the
|
|
612
|
+
particle flat dimension (rows * particle_dim).
|
|
613
|
+
layout: ParamLayout for converting particles to param dicts.
|
|
614
|
+
inputs: Input data batch (shared across all evaluations).
|
|
615
|
+
targets: Optional targets (shared across all evaluations).
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Cost matrix of shape ``(P, V)``.
|
|
619
|
+
"""
|
|
620
|
+
P, V, K, D = X_probe.shape
|
|
621
|
+
|
|
622
|
+
# Flatten (P, V, K) into single batch dimension N = P*V*K
|
|
623
|
+
flat_probes = X_probe.reshape(P * V * K, D)
|
|
624
|
+
|
|
625
|
+
# Convert to stacked param dicts: {key: (N, *param_shape)}
|
|
626
|
+
stacked_params = layout.batch_unflatten(flat_probes)
|
|
627
|
+
|
|
628
|
+
# Evaluate all probe points
|
|
629
|
+
losses = evaluator.evaluate(stacked_params, inputs, targets) # (N,)
|
|
630
|
+
|
|
631
|
+
# Reshape and average over probe dimension K
|
|
632
|
+
cost_matrix = losses.reshape(P, V, K).mean(dim=-1) # (P, V)
|
|
633
|
+
return cost_matrix
|