Skip to content

Commit 2562c0a

Browse files
committed
fix: functional-closure passthrough works for per-candidate optimizers
DE/GA evaluate one candidate at a time by calling closure() directly as a zero-argument function. The vmap passthrough previously set _current_closure to the functional closure (which requires a flat_params argument), so DE crashed with 'missing 1 required positional argument' under set_functional_closure. Now set_functional_closure also stores a zero-arg plain equivalent (loss_fn(model), evaluating the model's current weights), and step() installs that as the passthrough. Population-synchronous optimizers (PSO/GWO/WOA/CMA-ES) still take the batched vmap path inside _evaluate_fitness; per-candidate optimizers (DE/GA) now run correctly on the sequential path. All 5 algorithms verified with the functional closure. gpu_nn: construct optimizers by signature introspection (swarm_size vs population_size) and wrap each cell in try/except so one failure can't kill the sweep or lose the report.
1 parent a0b81ce commit 2562c0a

2 files changed

Lines changed: 42 additions & 17 deletions

File tree

swarmtorch/base/swarm_optimizer.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,18 @@ def loss_fn(forward):
181181
if model is None or loss_fn is None:
182182
self._functional_closure = None
183183
self._functional_model = None
184+
self._plain_from_functional = None
184185
return
185186

187+
# A zero-argument plain closure equivalent: evaluate the model in its
188+
# *current* parameter state. Optimizers that evaluate one candidate at
189+
# a time (e.g. DE, GA) call ``closure()`` directly after writing a
190+
# candidate into the model; they use this, while population-synchronous
191+
# optimizers (PSO, GWO, ...) take the batched vmap path inside
192+
# ``_evaluate_fitness``. Passing the live ``model`` as the ``forward``
193+
# callable makes ``loss_fn(model)`` evaluate the current weights.
194+
self._plain_from_functional = lambda: loss_fn(model)
195+
186196
from torch.func import functional_call
187197

188198
param_specs: list[tuple[str, torch.Size, int]] = [
@@ -227,15 +237,15 @@ def step(self, closure: Any = None) -> Any:
227237
here — ``_update_positions`` evaluates it once per particle.
228238
"""
229239
# Stash the closure so subclasses can pull it from
230-
# ``self._current_closure`` inside ``_update_positions``.
231-
# If the user has registered a functional closure (vmap fast path)
232-
# but didn't supply a plain closure, install a passthrough
233-
# sentinel so the existing ``if closure is None: return`` guards
234-
# in subclass ``_update_positions`` don't short-circuit. The
235-
# actual fitness evaluation still goes through the vmap path
236-
# inside ``_evaluate_fitness``.
240+
# ``self._current_closure`` inside ``_update_positions``. If the user
241+
# registered a functional closure (vmap fast path) but didn't supply a
242+
# plain closure, install a *zero-argument* plain equivalent so that
243+
# (a) the ``if closure is None: return`` guards don't short-circuit and
244+
# (b) optimizers that call ``closure()`` directly per candidate (DE,
245+
# GA) still work. Population-synchronous optimizers ignore this and take
246+
# the batched vmap path inside ``_evaluate_fitness``.
237247
if closure is None and getattr(self, "_functional_closure", None) is not None:
238-
self._current_closure = self._functional_closure
248+
self._current_closure = self._plain_from_functional
239249
else:
240250
self._current_closure = closure
241251

swarmtorch/benchmark/gpu_nn.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,21 @@ def _run_one(algo_cls, hidden, swarm_size, max_fe, seed, device, use_vmap):
7070
x = torch.randn(batch, in_dim, device=device)
7171
y = torch.randint(0, out_dim, (batch,), device=device)
7272

73-
try:
74-
opt = algo_cls(model.parameters(), swarm_size=swarm_size, device=device,
75-
init_strategy="model")
76-
except TypeError:
77-
opt = algo_cls(model.parameters(), swarm_size=swarm_size, device=device)
73+
# Build kwargs by introspection: some optimizers name the population
74+
# ``swarm_size`` (PSO/GWO/WOA/CMAES) and others ``population_size`` (DE/GA).
75+
import inspect
76+
77+
sig = inspect.signature(algo_cls.__init__)
78+
params = sig.parameters
79+
accepts_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
80+
kwargs: dict[str, Any] = {"device": device}
81+
if "swarm_size" in params or accepts_kw:
82+
kwargs["swarm_size"] = swarm_size
83+
elif "population_size" in params:
84+
kwargs["population_size"] = swarm_size
85+
if "init_strategy" in params or accepts_kw:
86+
kwargs["init_strategy"] = "model"
87+
opt = algo_cls(model.parameters(), **kwargs)
7888

7989
if use_vmap:
8090
opt.set_functional_closure(model, lambda fwd: F.cross_entropy(fwd(x), y))
@@ -130,10 +140,15 @@ def run_nn_speedup(
130140
for seed in seeds:
131141
for vname, dev, use_vmap in variants:
132142
cell += 1
133-
# warm-up (untimed)
134-
_run_one(ALGOS[algo], h, ss, ss, seed, dev, use_vmap)
135-
wall, best, npar, fe = _run_one(
136-
ALGOS[algo], h, ss, max_fe, seed, dev, use_vmap)
143+
try:
144+
# warm-up (untimed)
145+
_run_one(ALGOS[algo], h, ss, ss, seed, dev, use_vmap)
146+
wall, best, npar, fe = _run_one(
147+
ALGOS[algo], h, ss, max_fe, seed, dev, use_vmap)
148+
except Exception as e: # one bad cell must not kill the sweep
149+
print(f"[gpu_nn] {cell}/{total} {algo} {vname} FAILED: {e}",
150+
flush=True)
151+
continue
137152
rec = NNBenchResult(
138153
algorithm=algo, n_params=npar, swarm_size=ss,
139154
variant=vname, device=dev, seed=seed,

0 commit comments

Comments
 (0)