Skip to content

Commit f8d8270

Browse files
committed
perf: vectorize SineCosine/MFO updates; sep-CMA-ES for high dims
SineCosine and MFO used per-element Python double loops (swarm x dim) with a .item() sync per element -- ~18000 GPU<->CPU syncs per step at d=200, which made a single run take ~65 minutes and repeatedly exhausted Kaggle sessions. Both updates are elementwise and now fully vectorized with torch ops: SineCosine drops from ~23s/step to ~0.018s/step (about 1000x), MFO similarly. CMA-ES now switches to pycma's separable/diagonal covariance (sep-CMA-ES, O(d) memory) above diagonal_threshold=5000 dimensions, so it can run on NN-scale parameter vectors instead of OOMing on a d x d covariance matrix (a 250k-param net would need ~500 GB). Emits a warning when the fallback engages. Full suite: 94 passed, 1 skipped.
1 parent a357deb commit f8d8270

2 files changed

Lines changed: 52 additions & 25 deletions

File tree

swarmtorch/bio_inspired/model_training/sine_cosine.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,16 @@ def _update_positions(self) -> None:
3232
a = 2
3333
r1 = a - self.iteration_count * (a / max_iter)
3434

35-
for i in range(self.swarm_size):
36-
for j in range(self.positions.shape[1]):
37-
r2 = torch.rand(1, device=self.device).item() * 2 * 3.14159
38-
r3 = torch.rand(1, device=self.device).item() * 2
39-
r4 = torch.rand(1, device=self.device).item()
40-
41-
if r4 < 0.5:
42-
self.positions[i, j] = self.positions[i, j] + r1 * math.sin(
43-
r2
44-
) * torch.abs(r3 * self.best_position[j] - self.positions[i, j])
45-
else:
46-
self.positions[i, j] = self.positions[i, j] + r1 * math.cos(
47-
r2
48-
) * torch.abs(r3 * self.best_position[j] - self.positions[i, j])
35+
# Vectorized SCA update (formerly a per-element Python double loop,
36+
# which forced millions of GPU<->CPU syncs and dominated wall-clock).
37+
# r2 in [0, 2*pi), r3 in [0, 2), r4 in [0, 1) per element.
38+
r2 = torch.rand_like(self.positions) * (2 * math.pi)
39+
r3 = torch.rand_like(self.positions) * 2
40+
r4 = torch.rand_like(self.positions)
41+
42+
target = torch.abs(r3 * self.best_position.unsqueeze(0) - self.positions)
43+
trig = torch.where(r4 < 0.5, torch.sin(r2), torch.cos(r2))
44+
self.positions = self.positions + r1 * trig * target
4945

5046
self._set_params(self.best_position)
5147
self.iteration_count += 1
@@ -87,17 +83,26 @@ def _update_positions(self) -> None:
8783

8884
max_iter = 1000
8985
t = (self.iteration_count / max_iter) * 2 - 1
90-
91-
for i in range(self.swarm_size):
92-
for j in range(self.positions.shape[1]):
93-
flame_idx = int(i * self.flames.shape[0] / self.swarm_size)
94-
distance = torch.abs(self.positions[i, j] - self.flames[flame_idx, j])
95-
b = 1
96-
t_val = t * (1 - j / self.positions.shape[1])
97-
self.positions[i, j] = (
98-
distance * math.exp(b * t_val) * math.cos(2 * 3.14159 * t_val)
99-
+ self.flames[flame_idx, j]
100-
)
86+
b = 1.0
87+
d = self.positions.shape[1]
88+
n_flames = self.flames.shape[0]
89+
90+
# Vectorized MFO update (was a per-element Python double loop).
91+
# Each particle i spirals around flame floor(i * n_flames / swarm).
92+
flame_idx = (
93+
torch.arange(self.swarm_size, device=self.device) * n_flames
94+
// self.swarm_size
95+
).clamp(max=n_flames - 1)
96+
chosen = self.flames[flame_idx] # (swarm_size, d)
97+
98+
# t_val decays across dimensions: t * (1 - j/d), broadcast over particles.
99+
j = torch.arange(d, device=self.device, dtype=self.positions.dtype)
100+
t_val = (t * (1 - j / d)).unsqueeze(0) # (1, d)
101+
102+
distance = torch.abs(self.positions - chosen)
103+
self.positions = (
104+
distance * torch.exp(b * t_val) * torch.cos(2 * math.pi * t_val) + chosen
105+
)
101106

102107
self._set_params(self.best_position)
103108
self.iteration_count += 1

swarmtorch/evolutionary/model_training/cmaes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def __init__(
5555
device: str = "cpu",
5656
init_strategy: str = "model",
5757
init_sigma: float = 0.1,
58+
diagonal_threshold: int = 5000,
5859
**kwargs: Any,
5960
) -> None:
6061
# CMA-ES picks its own default popsize if ``swarm_size`` is None.
@@ -70,6 +71,11 @@ def __init__(
7071
)
7172
self._user_swarm_size = swarm_size
7273
self.sigma0 = sigma0
74+
# Above this dimension, the full d x d covariance matrix is
75+
# infeasible (a 250k-param net needs ~500 GB). Switch pycma to its
76+
# separable/diagonal covariance (sep-CMA-ES) automatically, which
77+
# is O(d) memory and the standard high-dimensional variant.
78+
self.diagonal_threshold = diagonal_threshold
7379
self.iteration_count = 0
7480
self.best_position: torch.Tensor | None = None
7581
self.best_fitness = torch.tensor(float("inf"), device=self.device)
@@ -99,6 +105,22 @@ def _init_swarm(self) -> None:
99105
if self._user_swarm_size is not None and self._user_swarm_size > 0:
100106
opts["popsize"] = int(self._user_swarm_size)
101107

108+
# High-dimensional problems (e.g. NN weight spaces) cannot hold a
109+
# full covariance matrix. Use the separable/diagonal variant so the
110+
# optimizer runs in O(d) instead of O(d^2) and never OOMs.
111+
if d > self.diagonal_threshold:
112+
import warnings
113+
114+
warnings.warn(
115+
f"CMA-ES: d={d} exceeds diagonal_threshold="
116+
f"{self.diagonal_threshold}; using separable (diagonal) "
117+
f"covariance (sep-CMA-ES) to stay feasible.",
118+
stacklevel=2,
119+
)
120+
# CMA_diagonal=True keeps the covariance diagonal for the whole
121+
# run; this is the documented pycma high-dimensional mode.
122+
opts["CMA_diagonal"] = True
123+
102124
self._es = cma.CMAEvolutionStrategy(x0, sigma0, opts)
103125
# Resolved population size (pycma may pick its own default).
104126
self.swarm_size = int(self._es.popsize)

0 commit comments

Comments
 (0)