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,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