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,994 @@
|
|
|
1
|
+
"""Block-wise step methods: per-block and subspace+block OT solves.
|
|
2
|
+
|
|
3
|
+
Extracted from optimizer.py for maintainability.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from .blockwise import (
|
|
14
|
+
split_particles,
|
|
15
|
+
reassemble_blocks,
|
|
16
|
+
split_subspace_to_blocks,
|
|
17
|
+
reassemble_blocks_to_subspace,
|
|
18
|
+
layout_flat_to_block_flat,
|
|
19
|
+
blocks_to_layout_flat,
|
|
20
|
+
blocks_to_layout_flat_batch,
|
|
21
|
+
)
|
|
22
|
+
from .costs import scale_cost_matrix
|
|
23
|
+
from .solvers._prelude import sanitize_cost
|
|
24
|
+
from .dynamics import apply_momentum, compute_momentum_coefficient, update_adaptive_radius
|
|
25
|
+
from .geometry import get_random_rotation_matrices
|
|
26
|
+
from .solvers import SinkhornSolver
|
|
27
|
+
from .solvers.base import SolverResult
|
|
28
|
+
from .adaptive_subspace import AdaptiveSubspace
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def step_blockwise(opt, closure: Callable) -> float:
|
|
34
|
+
"""Block-wise step: per-block OT solve with full-model closure calls.
|
|
35
|
+
|
|
36
|
+
Each block has its own polytope, rotation, and OT solve in
|
|
37
|
+
particle_dim space (typically 2D). For cost evaluation, the full
|
|
38
|
+
model config is reconstructed by replacing the probed particle row
|
|
39
|
+
within that block. Uses chunked evaluation to bound memory.
|
|
40
|
+
"""
|
|
41
|
+
state = opt._state
|
|
42
|
+
X = state.X # (total_particles, particle_dim)
|
|
43
|
+
iteration = state.iteration_count
|
|
44
|
+
device = X.device
|
|
45
|
+
blocks = opt._blocks
|
|
46
|
+
|
|
47
|
+
# Resolve epsilon and radii (scheduled radii bypass epsilon multiplication)
|
|
48
|
+
current_eps = opt._get_epsilon(iteration)
|
|
49
|
+
radius_mult = state.radius_multiplier if opt.use_adaptive_radius else 1.0
|
|
50
|
+
_sr = opt._get_step_radius(iteration)
|
|
51
|
+
_pr = opt._get_probe_radius(iteration)
|
|
52
|
+
step_r = _sr * (1.0 if hasattr(opt.step_radius, "at") else current_eps) * radius_mult
|
|
53
|
+
probe_r = _pr * (1.0 if hasattr(opt.probe_radius, "at") else current_eps) * radius_mult
|
|
54
|
+
|
|
55
|
+
# Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
|
|
56
|
+
probe_r = opt._apply_probe_radius_jitter(probe_r)
|
|
57
|
+
|
|
58
|
+
# Ensure 2D
|
|
59
|
+
if X.dim() == 1:
|
|
60
|
+
X = X.unsqueeze(0)
|
|
61
|
+
|
|
62
|
+
# Convert layout-indexed flat to block-indexed flat before splitting.
|
|
63
|
+
# Per-layer blocks pad each entry independently, creating different
|
|
64
|
+
# offsets from ParamLayout (contiguous concat + single end pad).
|
|
65
|
+
total_flat_size = sum(b.flat_end - b.flat_start for b in blocks)
|
|
66
|
+
block_flat = layout_flat_to_block_flat(
|
|
67
|
+
X.reshape(-1),
|
|
68
|
+
blocks,
|
|
69
|
+
opt.layout,
|
|
70
|
+
)
|
|
71
|
+
block_X_2d = block_flat.reshape(-1, opt._particle_dim)
|
|
72
|
+
all_block_particles = split_particles(block_X_2d, blocks)
|
|
73
|
+
|
|
74
|
+
# Resolve OT epsilon
|
|
75
|
+
ent_eps = opt._get_ent_epsilon(iteration)
|
|
76
|
+
ot_epsilon = ent_eps if ent_eps is not None else current_eps
|
|
77
|
+
|
|
78
|
+
updated_block_particles = []
|
|
79
|
+
new_block_duals = []
|
|
80
|
+
new_block_descent_dirs = [] # For biased rotation in next step
|
|
81
|
+
total_ent_cost = 0.0
|
|
82
|
+
# Per-block scalars accumulated as device tensors and summed once
|
|
83
|
+
# after the loop, to avoid one GPU->CPU sync per block per step.
|
|
84
|
+
block_disp_terms: list = []
|
|
85
|
+
block_model_loss_terms: list = []
|
|
86
|
+
all_converged = True
|
|
87
|
+
total_particles = 0
|
|
88
|
+
num_blocks_counted = 0
|
|
89
|
+
|
|
90
|
+
# Per-block descent directions for biased rotation (populated from previous step)
|
|
91
|
+
_block_descent_dirs = getattr(opt, "_prev_block_descent_directions", None)
|
|
92
|
+
|
|
93
|
+
probes = opt._probes.to(device=device, dtype=X.dtype)
|
|
94
|
+
chunk = opt.chunk_size or 1024 # default chunk for block-wise
|
|
95
|
+
|
|
96
|
+
# base_flat holds every block at its current value and is invariant across
|
|
97
|
+
# the block loop (updates are collected and applied after it). Build it once
|
|
98
|
+
# and reuse a single scatter buffer for every block and chunk.
|
|
99
|
+
base_flat = reassemble_blocks(all_block_particles, blocks, total_flat_size)
|
|
100
|
+
base_batch_buf = base_flat.new_empty((chunk, total_flat_size))
|
|
101
|
+
|
|
102
|
+
# Drop per-block dual momentum history across an epsilon jump so the
|
|
103
|
+
# warm-start isn't extrapolated over a large epsilon change (matches monolithic).
|
|
104
|
+
if state.last_solve_eps is not None and (
|
|
105
|
+
ot_epsilon / state.last_solve_eps > 2.0 or state.last_solve_eps / ot_epsilon > 2.0
|
|
106
|
+
):
|
|
107
|
+
state._prev_prev_block_duals = None
|
|
108
|
+
|
|
109
|
+
for block_idx, block in enumerate(blocks):
|
|
110
|
+
block_X = all_block_particles[block_idx]
|
|
111
|
+
block_dim = block.particle_dim
|
|
112
|
+
|
|
113
|
+
if block_X.dim() == 1:
|
|
114
|
+
block_X = block_X.unsqueeze(0)
|
|
115
|
+
P_block = block_X.shape[0]
|
|
116
|
+
|
|
117
|
+
# Per-block polytope
|
|
118
|
+
block_polytope_verts = opt._block_polytopes[block_idx].to(
|
|
119
|
+
device=device,
|
|
120
|
+
dtype=X.dtype,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Rotation matrices
|
|
124
|
+
rot_mats = get_random_rotation_matrices(
|
|
125
|
+
P_block,
|
|
126
|
+
block_dim,
|
|
127
|
+
device=device,
|
|
128
|
+
dtype=X.dtype,
|
|
129
|
+
generator=opt._generator,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Apply biased rotation per block. We keep the elementwise
|
|
133
|
+
# Gram-Schmidt loop here on purpose: per-layer block_dim is
|
|
134
|
+
# typically <=128 and small batched QR via cuSOLVER measured
|
|
135
|
+
# slower than this loop on RTX 5090. The monolithic step uses
|
|
136
|
+
# one big QR for the opposite reason.
|
|
137
|
+
if (
|
|
138
|
+
opt.biased_rotation
|
|
139
|
+
and _block_descent_dirs is not None
|
|
140
|
+
and block_idx < len(_block_descent_dirs)
|
|
141
|
+
and _block_descent_dirs[block_idx] is not None
|
|
142
|
+
and _block_descent_dirs[block_idx].shape == (P_block, block_dim)
|
|
143
|
+
):
|
|
144
|
+
bias_dir = _block_descent_dirs[block_idx]
|
|
145
|
+
bias_norms = torch.norm(bias_dir, dim=-1, keepdim=True).clamp(min=1e-10)
|
|
146
|
+
bias_dir_norm = bias_dir / bias_norms
|
|
147
|
+
rot_mats_orig = rot_mats.clone()
|
|
148
|
+
rot_mats[:, :, 0] = bias_dir_norm
|
|
149
|
+
for col in range(1, block_dim):
|
|
150
|
+
v = rot_mats[:, :, col].clone()
|
|
151
|
+
for prev_col in range(col):
|
|
152
|
+
proj = (v * rot_mats[:, :, prev_col]).sum(dim=-1, keepdim=True)
|
|
153
|
+
v = v - proj * rot_mats[:, :, prev_col]
|
|
154
|
+
raw_norm = torch.norm(v, dim=-1, keepdim=True)
|
|
155
|
+
norms_v = raw_norm.clamp(min=1e-10)
|
|
156
|
+
mask = (raw_norm > 1e-6).float()
|
|
157
|
+
rot_mats[:, :, col] = mask * (v / norms_v) + (1 - mask) * rot_mats_orig[:, :, col]
|
|
158
|
+
dets = torch.det(rot_mats)
|
|
159
|
+
flip = (dets < 0).unsqueeze(-1)
|
|
160
|
+
rot_mats[:, :, -1] = torch.where(flip, -rot_mats[:, :, -1], rot_mats[:, :, -1])
|
|
161
|
+
|
|
162
|
+
# Rotate + translate
|
|
163
|
+
X_vertices, rotated = opt._compiled.rotate_and_translate(
|
|
164
|
+
rot_mats,
|
|
165
|
+
block_polytope_verts,
|
|
166
|
+
block_X,
|
|
167
|
+
step_r,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Probe generation
|
|
171
|
+
X_probe = opt._compiled.compute_probe_points(
|
|
172
|
+
block_X,
|
|
173
|
+
rotated,
|
|
174
|
+
probes,
|
|
175
|
+
probe_r,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Build full params with only this block varying.
|
|
179
|
+
# For each probe (i, v, k), construct full flat config by
|
|
180
|
+
# assembling all blocks and replacing particle i in this block.
|
|
181
|
+
P, V, K, D = X_probe.shape
|
|
182
|
+
total_evals = P * V * K
|
|
183
|
+
|
|
184
|
+
losses_list = []
|
|
185
|
+
_all_indices = torch.arange(total_evals, device=device)
|
|
186
|
+
for chunk_start in range(0, total_evals, chunk):
|
|
187
|
+
chunk_end = min(chunk_start + chunk, total_evals)
|
|
188
|
+
chunk_size_actual = chunk_end - chunk_start
|
|
189
|
+
|
|
190
|
+
# Refill the reuse buffer with base_flat instead of allocating a
|
|
191
|
+
# fresh (chunk x total_flat_size) tensor each chunk.
|
|
192
|
+
base_batch = base_batch_buf[:chunk_size_actual]
|
|
193
|
+
base_batch.copy_(base_flat.unsqueeze(0).expand(chunk_size_actual, -1))
|
|
194
|
+
|
|
195
|
+
global_indices = _all_indices[chunk_start:chunk_end] # view, no alloc
|
|
196
|
+
i_idx = global_indices // (V * K)
|
|
197
|
+
vk = global_indices % (V * K)
|
|
198
|
+
v_idx = vk // K
|
|
199
|
+
k_idx = vk % K
|
|
200
|
+
|
|
201
|
+
# Vectorized scatter: replace probed particle rows in each config
|
|
202
|
+
local_range = torch.arange(chunk_size_actual, device=device)
|
|
203
|
+
row_starts = block.flat_start + i_idx * D
|
|
204
|
+
for d in range(D):
|
|
205
|
+
base_batch[local_range, row_starts + d] = X_probe[i_idx, v_idx, k_idx, d]
|
|
206
|
+
|
|
207
|
+
# Map block-indexed flat vector to layout-indexed flat vector.
|
|
208
|
+
# Per-layer blocks pad each entry independently, creating
|
|
209
|
+
# different offsets from ParamLayout.
|
|
210
|
+
batch_for_layout = blocks_to_layout_flat_batch(
|
|
211
|
+
base_batch,
|
|
212
|
+
blocks,
|
|
213
|
+
opt.layout,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Convert to param dicts and call closure
|
|
217
|
+
batched_params = opt.layout.batch_unflatten(batch_for_layout)
|
|
218
|
+
chunk_losses = closure(batched_params)
|
|
219
|
+
# Ensure FP32 for Sinkhorn solver numerical stability
|
|
220
|
+
chunk_losses = chunk_losses.float()
|
|
221
|
+
losses_list.append(chunk_losses)
|
|
222
|
+
|
|
223
|
+
losses = torch.cat(losses_list, dim=0)
|
|
224
|
+
if K == 1:
|
|
225
|
+
# K=1 fast path: no averaging needed
|
|
226
|
+
cost_matrix = losses.reshape(P, V)
|
|
227
|
+
else:
|
|
228
|
+
cost_matrix = losses.reshape(P, V, K).mean(dim=-1)
|
|
229
|
+
|
|
230
|
+
# Sanitize cost (FP32 promote + finite penalty), branch-free / no sync.
|
|
231
|
+
cost_matrix = sanitize_cost(cost_matrix)
|
|
232
|
+
|
|
233
|
+
# Per-block OT solve with dual momentum extrapolation
|
|
234
|
+
opt.solver.epsilon = ot_epsilon
|
|
235
|
+
block_a = torch.ones(P_block, device=device, dtype=X.dtype) / P_block
|
|
236
|
+
if opt._use_fused_softmax:
|
|
237
|
+
# Fused path: softmax + vertex-free projection in one compiled call.
|
|
238
|
+
scaled_cost = scale_cost_matrix(cost_matrix, opt.scale_cost)
|
|
239
|
+
X_new_block, transport_matrix = opt._compiled.fused_softmax_project(
|
|
240
|
+
scaled_cost,
|
|
241
|
+
ot_epsilon,
|
|
242
|
+
block_a,
|
|
243
|
+
block_polytope_verts,
|
|
244
|
+
rot_mats,
|
|
245
|
+
step_r,
|
|
246
|
+
block_X,
|
|
247
|
+
scale_cost_mean=False,
|
|
248
|
+
)
|
|
249
|
+
# Per-block model loss is tracked below; the OT cost is unused here.
|
|
250
|
+
ot_result = SolverResult(
|
|
251
|
+
matrix=transport_matrix,
|
|
252
|
+
cost=0.0,
|
|
253
|
+
f=None,
|
|
254
|
+
g=None,
|
|
255
|
+
converged=True,
|
|
256
|
+
n_iters=1,
|
|
257
|
+
ent_reg_cost=0.0,
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
init_f, init_g = state.block_duals[block_idx]
|
|
261
|
+
# Apply dual momentum per block
|
|
262
|
+
if (
|
|
263
|
+
opt._dual_momentum_beta > 0.0
|
|
264
|
+
and init_f is not None
|
|
265
|
+
and hasattr(state, "_prev_prev_block_duals")
|
|
266
|
+
and state._prev_prev_block_duals is not None
|
|
267
|
+
and block_idx < len(state._prev_prev_block_duals)
|
|
268
|
+
):
|
|
269
|
+
ppf, ppg = state._prev_prev_block_duals[block_idx]
|
|
270
|
+
if ppf is not None and ppg is not None:
|
|
271
|
+
beta_dm = opt._dual_momentum_beta
|
|
272
|
+
init_f = init_f + beta_dm * (init_f - ppf)
|
|
273
|
+
init_g = init_g + beta_dm * (init_g - ppg)
|
|
274
|
+
max_abs = 80.0 * max(ot_epsilon, 0.01)
|
|
275
|
+
init_f = init_f.clamp(-max_abs, max_abs)
|
|
276
|
+
init_g = init_g.clamp(-max_abs, max_abs)
|
|
277
|
+
|
|
278
|
+
# a defaults to uniform 1/P_block inside the solver (equals block_a);
|
|
279
|
+
# pass None to skip the host-syncing user-marginal validation.
|
|
280
|
+
solve_bw_kwargs = dict(
|
|
281
|
+
cost_matrix=cost_matrix,
|
|
282
|
+
init_f=init_f,
|
|
283
|
+
init_g=init_g,
|
|
284
|
+
scale_cost=opt.scale_cost,
|
|
285
|
+
)
|
|
286
|
+
if isinstance(opt.solver, SinkhornSolver):
|
|
287
|
+
# Forward previous solve's epsilon so warm-started duals get
|
|
288
|
+
# rescaled when the epsilon schedule moves.
|
|
289
|
+
last_eps = state.last_solve_eps
|
|
290
|
+
if last_eps is not None:
|
|
291
|
+
solve_bw_kwargs["init_eps"] = last_eps
|
|
292
|
+
ot_result = opt.solver.solve(**solve_bw_kwargs)
|
|
293
|
+
|
|
294
|
+
# Barycentric projection
|
|
295
|
+
X_new_block = opt._compiled.barycentric_projection(
|
|
296
|
+
ot_result.matrix,
|
|
297
|
+
block_a,
|
|
298
|
+
X_vertices,
|
|
299
|
+
)
|
|
300
|
+
# Non-fused solvers return a Python-float ent_reg_cost already.
|
|
301
|
+
total_ent_cost += ot_result.ent_reg_cost
|
|
302
|
+
|
|
303
|
+
# Track displacement and descent direction for biased rotation
|
|
304
|
+
block_descent = (X_new_block - block_X).detach()
|
|
305
|
+
block_disp_terms.append(torch.sum(block_descent**2, dim=-1).sum())
|
|
306
|
+
total_particles += P_block
|
|
307
|
+
new_block_descent_dirs.append(block_descent)
|
|
308
|
+
|
|
309
|
+
updated_block_particles.append(X_new_block)
|
|
310
|
+
new_block_duals.append(
|
|
311
|
+
(
|
|
312
|
+
ot_result.f.detach() if ot_result.f is not None else None,
|
|
313
|
+
ot_result.g.detach() if ot_result.g is not None else None,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
block_model_loss_terms.append(cost_matrix.mean().detach())
|
|
317
|
+
num_blocks_counted += 1
|
|
318
|
+
all_converged = all_converged and ot_result.converged
|
|
319
|
+
|
|
320
|
+
# Save per-block descent directions for biased rotation in next step
|
|
321
|
+
if opt.biased_rotation:
|
|
322
|
+
opt._prev_block_descent_directions = new_block_descent_dirs
|
|
323
|
+
|
|
324
|
+
# Reassemble and convert back to layout-indexed format
|
|
325
|
+
full_flat = reassemble_blocks(updated_block_particles, blocks, total_flat_size)
|
|
326
|
+
layout_flat_new = blocks_to_layout_flat(full_flat, blocks, opt.layout)
|
|
327
|
+
X_new_full = layout_flat_new.reshape(X.shape)
|
|
328
|
+
|
|
329
|
+
# Momentum (on full particles)
|
|
330
|
+
if opt.use_momentum and state.velocity is not None:
|
|
331
|
+
beta = compute_momentum_coefficient(
|
|
332
|
+
iteration,
|
|
333
|
+
opt.max_iterations,
|
|
334
|
+
opt.momentum_init,
|
|
335
|
+
opt.momentum_final,
|
|
336
|
+
)
|
|
337
|
+
X_final, vel_new = apply_momentum(
|
|
338
|
+
X,
|
|
339
|
+
X_new_full,
|
|
340
|
+
state.velocity,
|
|
341
|
+
beta,
|
|
342
|
+
opt.velocity_lr,
|
|
343
|
+
)
|
|
344
|
+
state.velocity = vel_new
|
|
345
|
+
state.X = X_final
|
|
346
|
+
else:
|
|
347
|
+
state.X = X_new_full
|
|
348
|
+
|
|
349
|
+
# NaN-safe state update - revert X, velocity, and duals if NaN after projection
|
|
350
|
+
_blockwise_nan_reverted = False
|
|
351
|
+
if not torch.isfinite(state.X).all():
|
|
352
|
+
state.X = X.clone()
|
|
353
|
+
state.block_duals = [(None, None) for _ in blocks]
|
|
354
|
+
# Reset velocity to prevent NaN propagation through momentum
|
|
355
|
+
if opt.use_momentum and state.velocity is not None:
|
|
356
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
357
|
+
# Clear cached state that could propagate the NaN-producing direction
|
|
358
|
+
opt._transport_direction_ema = None
|
|
359
|
+
opt._prev_descent_direction = None
|
|
360
|
+
opt._prev_descent_direction_finite = False
|
|
361
|
+
if opt._dual_momentum_beta > 0.0:
|
|
362
|
+
state._prev_prev_block_duals = None
|
|
363
|
+
if opt.biased_rotation:
|
|
364
|
+
opt._prev_block_descent_directions = None
|
|
365
|
+
_blockwise_nan_reverted = True
|
|
366
|
+
|
|
367
|
+
# Capture transport direction for amortized OT (matching monolithic L1660-1678)
|
|
368
|
+
if opt.amortize_steps > 1:
|
|
369
|
+
if _blockwise_nan_reverted:
|
|
370
|
+
opt._transport_direction = None
|
|
371
|
+
opt._transport_direction_ema = None
|
|
372
|
+
else:
|
|
373
|
+
raw_direction = (state.X - X).detach()
|
|
374
|
+
opt._transport_direction = raw_direction
|
|
375
|
+
alpha = opt.amortize_ema
|
|
376
|
+
if opt._transport_direction_ema is None:
|
|
377
|
+
opt._transport_direction_ema = raw_direction
|
|
378
|
+
else:
|
|
379
|
+
opt._transport_direction_ema = alpha * opt._transport_direction_ema + (1.0 - alpha) * raw_direction
|
|
380
|
+
|
|
381
|
+
# Reduce per-block accumulators with one host transfer each.
|
|
382
|
+
total_model_loss = torch.stack(block_model_loss_terms).sum().item() if block_model_loss_terms else 0.0
|
|
383
|
+
total_disp = torch.stack(block_disp_terms).sum().item() if block_disp_terms else 0.0
|
|
384
|
+
|
|
385
|
+
# Adaptive radius (use model loss, not OT regularized cost)
|
|
386
|
+
avg_model_loss = total_model_loss / num_blocks_counted if num_blocks_counted > 0 else total_ent_cost
|
|
387
|
+
if opt.use_adaptive_radius:
|
|
388
|
+
rm, sc, pl = update_adaptive_radius(
|
|
389
|
+
avg_model_loss,
|
|
390
|
+
state.prev_loss,
|
|
391
|
+
state.stagnation_count,
|
|
392
|
+
state.radius_multiplier,
|
|
393
|
+
stagnation_threshold=opt.stagnation_threshold,
|
|
394
|
+
stagnation_patience=opt.stagnation_patience,
|
|
395
|
+
radius_increase=opt.radius_increase,
|
|
396
|
+
radius_decrease=opt.radius_decrease,
|
|
397
|
+
radius_min=opt.radius_min,
|
|
398
|
+
radius_max=opt.radius_max,
|
|
399
|
+
)
|
|
400
|
+
state.radius_multiplier = rm
|
|
401
|
+
state.stagnation_count = sc
|
|
402
|
+
state.prev_loss = pl
|
|
403
|
+
|
|
404
|
+
# Update diagnostics
|
|
405
|
+
disp_sqnorm = total_disp / total_particles if total_particles > 0 else 0.0
|
|
406
|
+
state.costs.append(avg_model_loss)
|
|
407
|
+
state.linear_convergence.append(all_converged)
|
|
408
|
+
state.displacement_sqnorms.append(disp_sqnorm)
|
|
409
|
+
state.iteration_count += 1
|
|
410
|
+
# Only update block duals if no NaN revert occurred (otherwise stale NaN-causing
|
|
411
|
+
# duals would overwrite the clean reset done above)
|
|
412
|
+
if not _blockwise_nan_reverted:
|
|
413
|
+
# Save previous block duals for dual momentum extrapolation
|
|
414
|
+
if opt._dual_momentum_beta > 0.0:
|
|
415
|
+
state._prev_prev_block_duals = (
|
|
416
|
+
[
|
|
417
|
+
(f.clone() if f is not None else None, g.clone() if g is not None else None)
|
|
418
|
+
for f, g in state.block_duals
|
|
419
|
+
]
|
|
420
|
+
if state.block_duals is not None
|
|
421
|
+
else None
|
|
422
|
+
)
|
|
423
|
+
state.block_duals = new_block_duals
|
|
424
|
+
state.epsilon = current_eps
|
|
425
|
+
state.last_solve_eps = ot_epsilon
|
|
426
|
+
|
|
427
|
+
# Write back to model
|
|
428
|
+
opt._sync_model()
|
|
429
|
+
|
|
430
|
+
return avg_model_loss
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def step_subspace_blockwise(opt, closure: Callable) -> float:
|
|
434
|
+
"""Combined subspace + block-wise step: per-block OT in subspace coords.
|
|
435
|
+
|
|
436
|
+
This mode combines the benefits of:
|
|
437
|
+
1. Global subspace projection: Compresses full params (e.g., 100M) to
|
|
438
|
+
subspace coords (e.g., 256), reducing memory and enabling cross-layer
|
|
439
|
+
information sharing via the global projection matrix P.
|
|
440
|
+
2. Per-block OT decomposition: Splits subspace coords into blocks for
|
|
441
|
+
independent OT solves, reducing OT cost from O(N^2) to O(N^2/L).
|
|
442
|
+
|
|
443
|
+
Algorithm:
|
|
444
|
+
a) Get current subspace coords from state.X (flattened)
|
|
445
|
+
b) Split subspace coords into per-block particles
|
|
446
|
+
c) For each block:
|
|
447
|
+
- Sample polytope vertices in block's subspace particle space
|
|
448
|
+
- Compute cost matrix via GLOBAL evaluation:
|
|
449
|
+
* For each probe: apply global projection P to get full params
|
|
450
|
+
* Call closure to evaluate loss on full model
|
|
451
|
+
- Solve per-block OT
|
|
452
|
+
- Barycentric projection to update block particles
|
|
453
|
+
d) Reassemble updated blocks into new subspace coords
|
|
454
|
+
e) Update state.X
|
|
455
|
+
f) Check synchronized absorb (single rotation for all blocks)
|
|
456
|
+
g) If absorb: rotate projection, reset ALL block coords to zero
|
|
457
|
+
|
|
458
|
+
Note on cost evaluation (GLOBAL vs layer-local):
|
|
459
|
+
This implementation uses GLOBAL cost evaluation: each probe perturbs
|
|
460
|
+
ONE block's subspace coords, then applies the global projection P to
|
|
461
|
+
reconstruct full params, and evaluates the full model forward pass.
|
|
462
|
+
This captures cross-block interactions through the complete model.
|
|
463
|
+
"""
|
|
464
|
+
state = opt._state
|
|
465
|
+
X = state.X # (num_sub_particles, subspace_particle_dim)
|
|
466
|
+
iteration = state.iteration_count
|
|
467
|
+
device = X.device
|
|
468
|
+
blocks = opt._subspace_blocks
|
|
469
|
+
|
|
470
|
+
# Resolve epsilon and radii (scheduled radii bypass epsilon multiplication)
|
|
471
|
+
current_eps = opt._get_epsilon(iteration)
|
|
472
|
+
# Use CSA sigma or heuristic radius_multiplier
|
|
473
|
+
if opt.use_csa and state.use_csa:
|
|
474
|
+
radius_mult = state.sigma
|
|
475
|
+
elif opt.use_adaptive_radius:
|
|
476
|
+
radius_mult = state.radius_multiplier
|
|
477
|
+
else:
|
|
478
|
+
radius_mult = 1.0
|
|
479
|
+
_sr = opt._get_step_radius(iteration)
|
|
480
|
+
_pr = opt._get_probe_radius(iteration)
|
|
481
|
+
step_r = _sr * (1.0 if hasattr(opt.step_radius, "at") else current_eps) * radius_mult
|
|
482
|
+
probe_r = _pr * (1.0 if hasattr(opt.probe_radius, "at") else current_eps) * radius_mult
|
|
483
|
+
|
|
484
|
+
# Probe-radius jitter (Thm. 4.2 condition (iv); no-op when probe_radius_jitter == 0).
|
|
485
|
+
probe_r = opt._apply_probe_radius_jitter(probe_r)
|
|
486
|
+
|
|
487
|
+
# Ensure 2D
|
|
488
|
+
if X.dim() == 1:
|
|
489
|
+
X = X.unsqueeze(0)
|
|
490
|
+
|
|
491
|
+
# Get subspace dimension
|
|
492
|
+
sub_dim = opt.subspace.subspace_dim
|
|
493
|
+
|
|
494
|
+
# Save pre-step subspace coords for displacement tracking
|
|
495
|
+
_pre_step_sub_coords = None
|
|
496
|
+
if opt._adaptive or (opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa)):
|
|
497
|
+
_pre_step_sub_coords = state.X.reshape(-1)[:sub_dim].clone()
|
|
498
|
+
|
|
499
|
+
# Split subspace coords into per-block particles
|
|
500
|
+
subspace_coords_flat = X.reshape(-1)[:sub_dim]
|
|
501
|
+
all_block_particles = split_subspace_to_blocks(subspace_coords_flat, blocks)
|
|
502
|
+
|
|
503
|
+
# Resolve OT epsilon
|
|
504
|
+
ent_eps = opt._get_ent_epsilon(iteration)
|
|
505
|
+
ot_epsilon = ent_eps if ent_eps is not None else current_eps
|
|
506
|
+
|
|
507
|
+
updated_block_particles = []
|
|
508
|
+
new_block_duals = []
|
|
509
|
+
new_block_descent_dirs = [] # For biased rotation in next step
|
|
510
|
+
total_ent_cost = 0.0
|
|
511
|
+
# Per-block scalars accumulated as device tensors and summed once after
|
|
512
|
+
# the loop to avoid one GPU->CPU sync per block per step.
|
|
513
|
+
block_disp_terms: list = []
|
|
514
|
+
block_model_loss_terms: list = []
|
|
515
|
+
all_converged = True
|
|
516
|
+
total_particles = 0
|
|
517
|
+
num_blocks_counted = 0
|
|
518
|
+
|
|
519
|
+
probes = opt._probes.to(device=device, dtype=X.dtype)
|
|
520
|
+
chunk = opt.chunk_size or 512 # default chunk for combined mode
|
|
521
|
+
|
|
522
|
+
# base_subspace holds every block's coords and is invariant across the block
|
|
523
|
+
# loop (updates are applied after it). Build it once and reuse one scatter
|
|
524
|
+
# buffer for every block and chunk.
|
|
525
|
+
base_subspace = reassemble_blocks_to_subspace(all_block_particles, blocks, sub_dim)
|
|
526
|
+
base_batch_buf = base_subspace.new_empty((chunk, sub_dim))
|
|
527
|
+
|
|
528
|
+
# Per-block descent directions for biased rotation (populated from previous step)
|
|
529
|
+
_block_descent_dirs = getattr(opt, "_prev_block_descent_directions", None)
|
|
530
|
+
|
|
531
|
+
# Drop per-block dual momentum history across an epsilon jump so the
|
|
532
|
+
# warm-start isn't extrapolated over a large epsilon change (matches monolithic).
|
|
533
|
+
if state.last_solve_eps is not None and (
|
|
534
|
+
ot_epsilon / state.last_solve_eps > 2.0 or state.last_solve_eps / ot_epsilon > 2.0
|
|
535
|
+
):
|
|
536
|
+
state._prev_prev_block_duals = None
|
|
537
|
+
|
|
538
|
+
for block_idx, block in enumerate(blocks):
|
|
539
|
+
block_X = all_block_particles[block_idx]
|
|
540
|
+
block_dim = block.particle_dim
|
|
541
|
+
|
|
542
|
+
if block_X.dim() == 1:
|
|
543
|
+
block_X = block_X.unsqueeze(0)
|
|
544
|
+
P_block = block_X.shape[0]
|
|
545
|
+
|
|
546
|
+
# Per-block polytope (in subspace_particle_dim space)
|
|
547
|
+
block_polytope_verts = opt._subspace_block_polytopes[block_idx].to(
|
|
548
|
+
device=device,
|
|
549
|
+
dtype=X.dtype,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Rotation matrices for this block
|
|
553
|
+
rot_mats = get_random_rotation_matrices(
|
|
554
|
+
P_block,
|
|
555
|
+
block_dim,
|
|
556
|
+
device=device,
|
|
557
|
+
dtype=X.dtype,
|
|
558
|
+
generator=opt._generator,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Same Gram-Schmidt-vs-QR trade-off as in step_blockwise() above.
|
|
562
|
+
if (
|
|
563
|
+
opt.biased_rotation
|
|
564
|
+
and _block_descent_dirs is not None
|
|
565
|
+
and block_idx < len(_block_descent_dirs)
|
|
566
|
+
and _block_descent_dirs[block_idx] is not None
|
|
567
|
+
and _block_descent_dirs[block_idx].shape == (P_block, block_dim)
|
|
568
|
+
):
|
|
569
|
+
bias_dir = _block_descent_dirs[block_idx]
|
|
570
|
+
bias_norms = torch.norm(bias_dir, dim=-1, keepdim=True).clamp(min=1e-10)
|
|
571
|
+
bias_dir_norm = bias_dir / bias_norms
|
|
572
|
+
rot_mats_orig = rot_mats.clone()
|
|
573
|
+
rot_mats[:, :, 0] = bias_dir_norm
|
|
574
|
+
for col in range(1, block_dim):
|
|
575
|
+
v = rot_mats[:, :, col].clone()
|
|
576
|
+
for prev_col in range(col):
|
|
577
|
+
proj = (v * rot_mats[:, :, prev_col]).sum(dim=-1, keepdim=True)
|
|
578
|
+
v = v - proj * rot_mats[:, :, prev_col]
|
|
579
|
+
raw_norm = torch.norm(v, dim=-1, keepdim=True)
|
|
580
|
+
norms_v = raw_norm.clamp(min=1e-10)
|
|
581
|
+
mask = (raw_norm > 1e-6).float()
|
|
582
|
+
rot_mats[:, :, col] = mask * (v / norms_v) + (1 - mask) * rot_mats_orig[:, :, col]
|
|
583
|
+
dets = torch.det(rot_mats)
|
|
584
|
+
flip = (dets < 0).unsqueeze(-1)
|
|
585
|
+
rot_mats[:, :, -1] = torch.where(flip, -rot_mats[:, :, -1], rot_mats[:, :, -1])
|
|
586
|
+
|
|
587
|
+
# Rotate + translate
|
|
588
|
+
X_vertices, rotated = opt._compiled.rotate_and_translate(
|
|
589
|
+
rot_mats,
|
|
590
|
+
block_polytope_verts,
|
|
591
|
+
block_X,
|
|
592
|
+
step_r,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Probe generation
|
|
596
|
+
X_probe = opt._compiled.compute_probe_points(
|
|
597
|
+
block_X,
|
|
598
|
+
rotated,
|
|
599
|
+
probes,
|
|
600
|
+
probe_r,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# Build full params with only this block varying.
|
|
604
|
+
# For each probe (i, v, k):
|
|
605
|
+
# 1. Create full subspace coords by assembling all blocks
|
|
606
|
+
# 2. Replace particle i in this block with probe position
|
|
607
|
+
# 3. Apply global projection P to get full params
|
|
608
|
+
# 4. Evaluate closure on full params
|
|
609
|
+
P, V, K, D = X_probe.shape
|
|
610
|
+
total_evals = P * V * K
|
|
611
|
+
|
|
612
|
+
losses_list = []
|
|
613
|
+
_all_indices = torch.arange(total_evals, device=device)
|
|
614
|
+
for chunk_start in range(0, total_evals, chunk):
|
|
615
|
+
chunk_end = min(chunk_start + chunk, total_evals)
|
|
616
|
+
chunk_size_actual = chunk_end - chunk_start
|
|
617
|
+
|
|
618
|
+
# Refill the reuse buffer with base_subspace instead of allocating a
|
|
619
|
+
# fresh (chunk x sub_dim) tensor each chunk, then perturb this block.
|
|
620
|
+
base_batch = base_batch_buf[:chunk_size_actual]
|
|
621
|
+
base_batch.copy_(base_subspace.unsqueeze(0).expand(chunk_size_actual, -1))
|
|
622
|
+
|
|
623
|
+
global_indices = _all_indices[chunk_start:chunk_end] # view, no alloc
|
|
624
|
+
i_idx = global_indices // (V * K)
|
|
625
|
+
vk = global_indices % (V * K)
|
|
626
|
+
v_idx = vk // K
|
|
627
|
+
k_idx = vk % K
|
|
628
|
+
|
|
629
|
+
# Replace particle i in this block (vectorized)
|
|
630
|
+
# Block flat range: [block.flat_start, block.flat_end)
|
|
631
|
+
# Particle i occupies: [block.flat_start + i*D, block.flat_start + (i+1)*D)
|
|
632
|
+
row_starts = block.flat_start + i_idx * D
|
|
633
|
+
local_range = torch.arange(chunk_size_actual, device=device)
|
|
634
|
+
# Handle case where row_end exceeds sub_dim (padding region)
|
|
635
|
+
for d in range(D):
|
|
636
|
+
col_idx = row_starts + d
|
|
637
|
+
valid = col_idx < sub_dim
|
|
638
|
+
if valid.any():
|
|
639
|
+
base_batch[local_range[valid], col_idx[valid]] = X_probe[
|
|
640
|
+
i_idx[valid], v_idx[valid], k_idx[valid], d
|
|
641
|
+
]
|
|
642
|
+
|
|
643
|
+
# Apply global projection to get full params
|
|
644
|
+
# base_batch: (chunk_size, sub_dim)
|
|
645
|
+
# projection: (full_dim, sub_dim)
|
|
646
|
+
# reconstruct_batch needs projection argument for AdaptiveSubspace
|
|
647
|
+
# Match dtype with projection for mixed precision compatibility
|
|
648
|
+
if opt._mixed_precision and state.projection is not None:
|
|
649
|
+
base_batch = base_batch.to(dtype=state.projection.dtype)
|
|
650
|
+
if opt._adaptive or opt._cma_subspace:
|
|
651
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
652
|
+
state.projection,
|
|
653
|
+
state.base_params,
|
|
654
|
+
base_batch,
|
|
655
|
+
)
|
|
656
|
+
elif opt._hybrid:
|
|
657
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
658
|
+
state.hybrid_projections,
|
|
659
|
+
state.base_params,
|
|
660
|
+
base_batch,
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
chunk_params = state.subspace.reconstruct_batch(
|
|
664
|
+
state.base_params,
|
|
665
|
+
base_batch,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# Evaluate full model via closure
|
|
669
|
+
chunk_losses = closure(chunk_params)
|
|
670
|
+
# Ensure FP32 for Sinkhorn solver numerical stability
|
|
671
|
+
chunk_losses = chunk_losses.float()
|
|
672
|
+
losses_list.append(chunk_losses)
|
|
673
|
+
|
|
674
|
+
losses = torch.cat(losses_list, dim=0)
|
|
675
|
+
if K == 1:
|
|
676
|
+
# K=1 fast path: no averaging needed
|
|
677
|
+
cost_matrix = losses.reshape(P, V)
|
|
678
|
+
else:
|
|
679
|
+
cost_matrix = losses.reshape(P, V, K).mean(dim=-1)
|
|
680
|
+
|
|
681
|
+
# Sanitize cost (FP32 promote + finite penalty), branch-free / no sync.
|
|
682
|
+
cost_matrix = sanitize_cost(cost_matrix)
|
|
683
|
+
|
|
684
|
+
# Per-block OT solve with dual momentum extrapolation
|
|
685
|
+
opt.solver.epsilon = ot_epsilon
|
|
686
|
+
block_a = torch.ones(P_block, device=device, dtype=X.dtype) / P_block
|
|
687
|
+
if opt._use_fused_softmax:
|
|
688
|
+
scaled_cost = scale_cost_matrix(cost_matrix, opt.scale_cost)
|
|
689
|
+
X_new_block, transport_matrix = opt._compiled.fused_softmax_project(
|
|
690
|
+
scaled_cost,
|
|
691
|
+
ot_epsilon,
|
|
692
|
+
block_a,
|
|
693
|
+
block_polytope_verts,
|
|
694
|
+
rot_mats,
|
|
695
|
+
step_r,
|
|
696
|
+
block_X,
|
|
697
|
+
scale_cost_mean=False,
|
|
698
|
+
)
|
|
699
|
+
ot_result = SolverResult(
|
|
700
|
+
matrix=transport_matrix,
|
|
701
|
+
cost=0.0,
|
|
702
|
+
f=None,
|
|
703
|
+
g=None,
|
|
704
|
+
converged=True,
|
|
705
|
+
n_iters=1,
|
|
706
|
+
ent_reg_cost=0.0,
|
|
707
|
+
)
|
|
708
|
+
else:
|
|
709
|
+
init_f, init_g = state.block_duals[block_idx]
|
|
710
|
+
# Apply dual momentum per block
|
|
711
|
+
if (
|
|
712
|
+
opt._dual_momentum_beta > 0.0
|
|
713
|
+
and init_f is not None
|
|
714
|
+
and hasattr(state, "_prev_prev_block_duals")
|
|
715
|
+
and state._prev_prev_block_duals is not None
|
|
716
|
+
and block_idx < len(state._prev_prev_block_duals)
|
|
717
|
+
):
|
|
718
|
+
ppf, ppg = state._prev_prev_block_duals[block_idx]
|
|
719
|
+
if ppf is not None and ppg is not None:
|
|
720
|
+
beta_dm = opt._dual_momentum_beta
|
|
721
|
+
init_f = init_f + beta_dm * (init_f - ppf)
|
|
722
|
+
init_g = init_g + beta_dm * (init_g - ppg)
|
|
723
|
+
max_abs = 80.0 * max(ot_epsilon, 0.01)
|
|
724
|
+
init_f = init_f.clamp(-max_abs, max_abs)
|
|
725
|
+
init_g = init_g.clamp(-max_abs, max_abs)
|
|
726
|
+
|
|
727
|
+
# a defaults to uniform 1/P_block inside the solver (equals block_a);
|
|
728
|
+
# pass None to skip the host-syncing user-marginal validation.
|
|
729
|
+
solve_sbw_kwargs = dict(
|
|
730
|
+
cost_matrix=cost_matrix,
|
|
731
|
+
init_f=init_f,
|
|
732
|
+
init_g=init_g,
|
|
733
|
+
scale_cost=opt.scale_cost,
|
|
734
|
+
)
|
|
735
|
+
if isinstance(opt.solver, SinkhornSolver):
|
|
736
|
+
last_eps = state.last_solve_eps
|
|
737
|
+
if last_eps is not None:
|
|
738
|
+
solve_sbw_kwargs["init_eps"] = last_eps
|
|
739
|
+
ot_result = opt.solver.solve(**solve_sbw_kwargs)
|
|
740
|
+
|
|
741
|
+
# Barycentric projection for this block
|
|
742
|
+
X_new_block = opt._compiled.barycentric_projection(
|
|
743
|
+
ot_result.matrix,
|
|
744
|
+
block_a,
|
|
745
|
+
X_vertices,
|
|
746
|
+
)
|
|
747
|
+
total_ent_cost += ot_result.ent_reg_cost
|
|
748
|
+
|
|
749
|
+
# Track displacement and descent direction for biased rotation
|
|
750
|
+
block_descent = (X_new_block - block_X).detach()
|
|
751
|
+
block_disp_terms.append(torch.sum(block_descent**2, dim=-1).sum())
|
|
752
|
+
total_particles += P_block
|
|
753
|
+
new_block_descent_dirs.append(block_descent)
|
|
754
|
+
|
|
755
|
+
updated_block_particles.append(X_new_block)
|
|
756
|
+
new_block_duals.append(
|
|
757
|
+
(
|
|
758
|
+
ot_result.f.detach() if ot_result.f is not None else None,
|
|
759
|
+
ot_result.g.detach() if ot_result.g is not None else None,
|
|
760
|
+
)
|
|
761
|
+
)
|
|
762
|
+
block_model_loss_terms.append(cost_matrix.mean().detach())
|
|
763
|
+
num_blocks_counted += 1
|
|
764
|
+
all_converged = all_converged and ot_result.converged
|
|
765
|
+
|
|
766
|
+
# Save per-block descent directions for biased rotation in next step
|
|
767
|
+
if opt.biased_rotation:
|
|
768
|
+
opt._prev_block_descent_directions = new_block_descent_dirs
|
|
769
|
+
|
|
770
|
+
# Reassemble updated subspace coords from all blocks
|
|
771
|
+
new_subspace_coords = reassemble_blocks_to_subspace(updated_block_particles, blocks, sub_dim)
|
|
772
|
+
|
|
773
|
+
# Reshape back to (num_sub_particles, particle_dim) format for state.X
|
|
774
|
+
# Pad to match original X shape
|
|
775
|
+
padded_size = X.numel()
|
|
776
|
+
if new_subspace_coords.numel() < padded_size:
|
|
777
|
+
X_new_flat = torch.zeros(padded_size, device=device, dtype=X.dtype)
|
|
778
|
+
X_new_flat[:sub_dim] = new_subspace_coords
|
|
779
|
+
else:
|
|
780
|
+
X_new_flat = new_subspace_coords[:padded_size]
|
|
781
|
+
X_new_full = X_new_flat.reshape(X.shape)
|
|
782
|
+
|
|
783
|
+
# Momentum (on full particles)
|
|
784
|
+
if opt.use_momentum and state.velocity is not None:
|
|
785
|
+
beta = compute_momentum_coefficient(
|
|
786
|
+
iteration,
|
|
787
|
+
opt.max_iterations,
|
|
788
|
+
opt.momentum_init,
|
|
789
|
+
opt.momentum_final,
|
|
790
|
+
)
|
|
791
|
+
X_final, vel_new = apply_momentum(
|
|
792
|
+
X,
|
|
793
|
+
X_new_full,
|
|
794
|
+
state.velocity,
|
|
795
|
+
beta,
|
|
796
|
+
opt.velocity_lr,
|
|
797
|
+
)
|
|
798
|
+
state.velocity = vel_new
|
|
799
|
+
state.X = X_final
|
|
800
|
+
else:
|
|
801
|
+
state.X = X_new_full
|
|
802
|
+
|
|
803
|
+
# NaN-safe state update - revert X, velocity, and duals if NaN after projection
|
|
804
|
+
_blockwise_nan_reverted = False
|
|
805
|
+
if not torch.isfinite(state.X).all():
|
|
806
|
+
state.X = X.clone()
|
|
807
|
+
state.block_duals = [(None, None) for _ in blocks]
|
|
808
|
+
# Reset velocity to prevent NaN propagation through momentum
|
|
809
|
+
if opt.use_momentum and state.velocity is not None:
|
|
810
|
+
state.velocity = torch.zeros_like(state.velocity)
|
|
811
|
+
# Clear cached state that could propagate the NaN-producing direction
|
|
812
|
+
opt._transport_direction_ema = None
|
|
813
|
+
opt._prev_descent_direction = None
|
|
814
|
+
opt._prev_descent_direction_finite = False
|
|
815
|
+
if opt._dual_momentum_beta > 0.0:
|
|
816
|
+
state._prev_prev_block_duals = None
|
|
817
|
+
if opt.biased_rotation:
|
|
818
|
+
opt._prev_block_descent_directions = None
|
|
819
|
+
_blockwise_nan_reverted = True
|
|
820
|
+
|
|
821
|
+
# Capture transport direction for amortized OT (matching monolithic L1660-1678)
|
|
822
|
+
if opt.amortize_steps > 1:
|
|
823
|
+
if _blockwise_nan_reverted:
|
|
824
|
+
opt._transport_direction = None
|
|
825
|
+
opt._transport_direction_ema = None
|
|
826
|
+
else:
|
|
827
|
+
raw_direction = (state.X - X).detach()
|
|
828
|
+
opt._transport_direction = raw_direction
|
|
829
|
+
alpha = opt.amortize_ema
|
|
830
|
+
if opt._transport_direction_ema is None:
|
|
831
|
+
opt._transport_direction_ema = raw_direction
|
|
832
|
+
else:
|
|
833
|
+
opt._transport_direction_ema = alpha * opt._transport_direction_ema + (1.0 - alpha) * raw_direction
|
|
834
|
+
|
|
835
|
+
# Reduce per-block accumulators with one host transfer each.
|
|
836
|
+
total_model_loss = torch.stack(block_model_loss_terms).sum().item() if block_model_loss_terms else 0.0
|
|
837
|
+
total_disp = torch.stack(block_disp_terms).sum().item() if block_disp_terms else 0.0
|
|
838
|
+
|
|
839
|
+
# Adaptive radius (use model loss, not OT regularized cost)
|
|
840
|
+
avg_model_loss = total_model_loss / num_blocks_counted if num_blocks_counted > 0 else total_ent_cost
|
|
841
|
+
if opt.use_adaptive_radius:
|
|
842
|
+
rm, sc, pl = update_adaptive_radius(
|
|
843
|
+
avg_model_loss,
|
|
844
|
+
state.prev_loss,
|
|
845
|
+
state.stagnation_count,
|
|
846
|
+
state.radius_multiplier,
|
|
847
|
+
stagnation_threshold=opt.stagnation_threshold,
|
|
848
|
+
stagnation_patience=opt.stagnation_patience,
|
|
849
|
+
radius_increase=opt.radius_increase,
|
|
850
|
+
radius_decrease=opt.radius_decrease,
|
|
851
|
+
radius_min=opt.radius_min,
|
|
852
|
+
radius_max=opt.radius_max,
|
|
853
|
+
)
|
|
854
|
+
state.radius_multiplier = rm
|
|
855
|
+
state.stagnation_count = sc
|
|
856
|
+
state.prev_loss = pl
|
|
857
|
+
|
|
858
|
+
# Update diagnostics
|
|
859
|
+
disp_sqnorm = total_disp / total_particles if total_particles > 0 else 0.0
|
|
860
|
+
state.costs.append(avg_model_loss)
|
|
861
|
+
state.linear_convergence.append(all_converged)
|
|
862
|
+
state.displacement_sqnorms.append(disp_sqnorm)
|
|
863
|
+
state.iteration_count += 1
|
|
864
|
+
# Only update block duals if no NaN revert occurred (otherwise stale NaN-causing
|
|
865
|
+
# duals would overwrite the clean reset done above)
|
|
866
|
+
if not _blockwise_nan_reverted:
|
|
867
|
+
# Save previous block duals for dual momentum extrapolation.
|
|
868
|
+
if opt._dual_momentum_beta > 0.0:
|
|
869
|
+
state._prev_prev_block_duals = (
|
|
870
|
+
[
|
|
871
|
+
(f.clone() if f is not None else None, g.clone() if g is not None else None)
|
|
872
|
+
for f, g in state.block_duals
|
|
873
|
+
]
|
|
874
|
+
if state.block_duals is not None
|
|
875
|
+
else None
|
|
876
|
+
)
|
|
877
|
+
state.block_duals = new_block_duals
|
|
878
|
+
state.epsilon = current_eps
|
|
879
|
+
state.last_solve_eps = ot_epsilon
|
|
880
|
+
|
|
881
|
+
# Adaptive subspace: displacement tracking, absorb, and rotation
|
|
882
|
+
# For combined mode with AdaptiveSubspace, handle synchronized absorb
|
|
883
|
+
if opt._adaptive and isinstance(opt.subspace, AdaptiveSubspace):
|
|
884
|
+
adaptive_sub = opt.subspace
|
|
885
|
+
|
|
886
|
+
# 1. Compute displacement in subspace coords
|
|
887
|
+
post_step_sub_coords = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
|
|
888
|
+
displacement = post_step_sub_coords - _pre_step_sub_coords
|
|
889
|
+
|
|
890
|
+
# 2. Update displacement history (rolling buffer)
|
|
891
|
+
idx = state.displacement_history_idx
|
|
892
|
+
state.displacement_history[idx] = displacement
|
|
893
|
+
state.displacement_history_idx = (idx + 1) % adaptive_sub.displacement_history_size
|
|
894
|
+
state.displacement_history_count = min(
|
|
895
|
+
state.displacement_history_count + 1,
|
|
896
|
+
adaptive_sub.displacement_history_size,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# 3. Check for synchronized absorb trigger
|
|
900
|
+
# In combined mode, absorb resets ALL blocks to zero and rotates global P
|
|
901
|
+
should_absorb = adaptive_sub.should_absorb(
|
|
902
|
+
state.stagnation_count,
|
|
903
|
+
state.iteration_count,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
if should_absorb:
|
|
907
|
+
# SYNCHRONIZED ABSORB: fold perturbation into base, zero ALL block coords
|
|
908
|
+
full_flat_sub = state.X.reshape(-1)[: adaptive_sub.subspace_dim]
|
|
909
|
+
new_base, _zeroed = adaptive_sub.absorb(
|
|
910
|
+
state.projection,
|
|
911
|
+
state.base_params,
|
|
912
|
+
full_flat_sub,
|
|
913
|
+
)
|
|
914
|
+
state.base_params = new_base
|
|
915
|
+
# Reset ALL subspace coordinates (all blocks) to zero
|
|
916
|
+
state.X = torch.zeros_like(state.X)
|
|
917
|
+
# Single global projection rotation
|
|
918
|
+
# Sparse projection: create new SparseRandomProjection with fresh seed
|
|
919
|
+
from .projection import SparseRandomProjection
|
|
920
|
+
|
|
921
|
+
if isinstance(state.projection, SparseRandomProjection):
|
|
922
|
+
new_seed = state.projection.seed + state.absorb_count + 1000
|
|
923
|
+
state.projection = SparseRandomProjection(
|
|
924
|
+
full_dim=state.projection.full_dim,
|
|
925
|
+
subspace_dim=state.projection.subspace_dim,
|
|
926
|
+
seed=new_seed,
|
|
927
|
+
)
|
|
928
|
+
else:
|
|
929
|
+
state.projection = adaptive_sub.init_projection(
|
|
930
|
+
generator=opt._generator,
|
|
931
|
+
device=state.X.device,
|
|
932
|
+
dtype=state.X.dtype,
|
|
933
|
+
)
|
|
934
|
+
# Reset displacement history
|
|
935
|
+
state.displacement_history.zero_()
|
|
936
|
+
state.displacement_history_idx = 0
|
|
937
|
+
state.displacement_history_count = 0
|
|
938
|
+
# Reset ALL block duals (cost landscape changed)
|
|
939
|
+
state.block_duals = [(None, None) for _ in blocks]
|
|
940
|
+
# Increment absorb count
|
|
941
|
+
state.absorb_count += 1
|
|
942
|
+
# Invalidate cached cost/probe state (cost landscape changed after absorb)
|
|
943
|
+
opt._prev_cost_matrix = None
|
|
944
|
+
opt._prev_losses_3d = None
|
|
945
|
+
opt._prev_displacement_sqnorms = None
|
|
946
|
+
opt._prev_k_eff = None
|
|
947
|
+
opt._prev_step_r = None
|
|
948
|
+
opt._newton_direction = None
|
|
949
|
+
opt._prev_descent_direction = None
|
|
950
|
+
opt._prev_descent_direction_finite = False
|
|
951
|
+
# Reset per-block turbo state after absorb (cost landscape changed)
|
|
952
|
+
if opt._dual_momentum_beta > 0.0:
|
|
953
|
+
state._prev_prev_block_duals = None
|
|
954
|
+
if opt.biased_rotation:
|
|
955
|
+
opt._prev_block_descent_directions = None
|
|
956
|
+
# CMA-ES: Reset evolution paths and covariance after absorb
|
|
957
|
+
if opt._cma_subspace and (opt.use_covariance_adaptation or opt.use_csa):
|
|
958
|
+
state.p_c = torch.zeros_like(state.p_c)
|
|
959
|
+
state.p_sigma = torch.zeros_like(state.p_sigma)
|
|
960
|
+
state.C_diag = torch.ones_like(state.C_diag)
|
|
961
|
+
state.sigma = 1.0
|
|
962
|
+
else:
|
|
963
|
+
# Rotate projection basis for next step
|
|
964
|
+
# Sparse projection: use seed increment instead of QR rotation
|
|
965
|
+
from .projection import SparseRandomProjection
|
|
966
|
+
|
|
967
|
+
if isinstance(state.projection, SparseRandomProjection):
|
|
968
|
+
new_seed = state.projection.seed + state.iteration_count
|
|
969
|
+
state.projection = SparseRandomProjection(
|
|
970
|
+
full_dim=state.projection.full_dim,
|
|
971
|
+
subspace_dim=state.projection.subspace_dim,
|
|
972
|
+
seed=new_seed,
|
|
973
|
+
)
|
|
974
|
+
else:
|
|
975
|
+
hist = (
|
|
976
|
+
state.displacement_history[: state.displacement_history_count]
|
|
977
|
+
if state.displacement_history_count > 0
|
|
978
|
+
else None
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
state.projection = adaptive_sub.rotate(
|
|
982
|
+
state.projection,
|
|
983
|
+
step=state.iteration_count,
|
|
984
|
+
total_steps=opt.max_iterations,
|
|
985
|
+
displacement_history=hist,
|
|
986
|
+
generator=opt._generator,
|
|
987
|
+
)
|
|
988
|
+
# Reset ALL block duals after rotation (cost geometry changed)
|
|
989
|
+
state.block_duals = [(None, None) for _ in blocks]
|
|
990
|
+
|
|
991
|
+
# Write back to model
|
|
992
|
+
opt._sync_model()
|
|
993
|
+
|
|
994
|
+
return avg_model_loss
|