Synchronous successive halving has a problem: at each rung, you wait for all surviving configs to finish before promoting. With multiple workers, fast configs finish first and idle waiting for stragglers.
Synchronous SH: workers idle while waiting for slow trials at the rung boundary.
ASHA (Asynchronous Successive Halving) fixes this: promote configs to the next rung the moment they qualify, without waiting for the rest of the cohort. Workers always have something to do.
State-of-the-art-grade HPO performance, with one parallelism mechanism that handles both early stopping and parallel dispatch.
ASHA Setup
Import Syne Tune’s ASHA scheduler and reuse the same objective family as the random-search deck. The only new idea is that max_epochs becomes the resource budget:
from d2l import torch as d2limport logging# Use INFO level so the periodic Syne Tune tuning-status table appears,# but use a clean format that drops the "INFO:syne_tune.tuner:" prefix.logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)import matplotlib.pyplot as plt# Silence Syne Tune's import-time chatter about optional AWS dependencies# (sagemaker, s3fs) and Ray Tune. We use the local PythonBackend, so those# are not needed. Suppress both print() and logging.info() during imports.import contextlib, io_root = logging.getLogger()_prev_level = _root.level_root.setLevel(logging.WARNING)try:with contextlib.redirect_stdout(io.StringIO()):from syne_tune.config_space import loguniform, randintfrom syne_tune.backend.python_backend.python_backend import PythonBackendfrom syne_tune.optimizer.baselines import ASHAfrom syne_tune import Tuner, StoppingCriterionfrom syne_tune.experiments import load_experimentfinally: _root.setLevel(_prev_level)# Silence the per-trial subprocess-command spam from local_backend and# drop the per-trial scheduling / completion lines from the tuner logger.# Keep the periodic "tuning status (last metric is reported)" updates so# the reader can still see progress over time.class _DropPerTrialNoise(logging.Filter): _DROP = ("results of trials will be saved","scheduled ","Trial trial_id ", )deffilter(self, record): msg = record.getMessage()returnnotany(s in msg for s inself._DROP)logging.getLogger("syne_tune.backend.local_backend").setLevel(logging.WARNING)logging.getLogger("syne_tune.tuner").addFilter(_DropPerTrialNoise())
Objective with Epochs as Budget
The objective reports validation error after every epoch, so the scheduler can decide whether to stop, continue, or promote a trial without waiting for full training:
def hpo_objective_lenet_synetune(learning_rate, batch_size, max_epochs):from d2l import torch as d2lfrom syne_tune import Reporter model = d2l.LeNet(lr=learning_rate, num_classes=10) trainer = d2l.HPOTrainer(max_epochs=1, num_gpus=1) data = d2l.FashionMNIST(batch_size=batch_size) model.apply_init([next(iter(data.get_dataloader(True)))[0]], d2l.init_cnn) report = Reporter()for epoch inrange(1, max_epochs +1):if epoch ==1:# Initialize the state of Trainer trainer.fit(model=model, data=data)else: trainer.fit_epoch() validation_error = d2l.numpy(trainer.validation_error().cpu()) report(epoch=epoch, validation_error=float(validation_error))
ASHA Configuration
Rung budgets grow geometrically:
r_i = r_{\min}\eta^i,\quad r_i \le r_{\max}.
With \eta=2, roughly half the trials advance at each rung and survivors receive twice as much training budget:
Per-rung “halving books”: each book holds the configs that have completed up to budget r_i. When a worker frees up, look across all rungs for any config that qualifies for promotion (top 1/\eta at its rung); if none, sample a fresh config:
Promotion rule: after at least \eta trials are observed at rung i, promote a config only if it is in the best \lfloor n_i/\eta \rfloor scores at that rung.
# Each LeNet trial fits in well under 7 GB of GPU memory, so we can pack# multiple trials per device. `PythonBackend(rotate_gpus=True)` (the# default) round-robins trials across detected GPUs and falls back to# sharing when `n_workers > num_gpus`. Allocate 7 GB per slot — this# yields 3 slots on a 24 GB card and 4 slots on a 32 GB card after# driver overhead, e.g. 4×24 GB → 12 slots; 2×32 GB → 8.import torch_GB =1024**3n_workers =sum( torch.cuda.get_device_properties(i).total_memory // (7* _GB)for i inrange(torch.cuda.device_count())) or1max_wallclock_time =15*60# 15 minutes
max_resource_level = 10, as inferred from config_space
Master random_seed = 3937097272
ASHA scheduler (cont.)
The tuner launch is intentionally omitted from the lecture slide: its console output is backend bookkeeping, not a conceptual step. The relevant result is the incumbent curve loaded from the completed experiment.