Repository navigation
Expand file tree
/
Copy pathsweep.py
More file actions
1798 lines (1603 loc) · 84.8 KB
/
Copy pathsweep.py
File metadata and controls
1798 lines (1603 loc) · 84.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
""" timm Hyperparameter Sweep Orchestrator
Plan and run sweeps over train.py arguments, optionally with warmup-stable-decay (WSD) branching where one stable
trunk per hyperparameter combination feeds many independent cooldown branches resumed from its milestone checkpoints.
The planner writes one complete, effective train.py config per job (the same YAML --config accepts) and a plan.json
with the job dependency graph, fixed while workers run. The executor launches ready jobs on a pool of local GPUs.
Nothing about the search lives in train.py: every job is a plain `train.py --config` invocation that can be run by hand.
Quick sweeps need no spec file, the search space comes from --sweep and the base config from the trailing train.py
arguments:
python sweep.py run --name lr-wd --sweep lr=1e-4,3e-4,1e-3 --sweep weight_decay=0.05,0.1 --gpus 0,1,2,3 \\
-- --model vit_small_patch16_224 --data-dir /data/imagenet --sched cosine --epochs 100 --amp
A YAML spec adds random search, paired axes, repeats and WSD branching:
# sweep.yaml
name: vit-wsd
output: output/sweeps # the sweep lives in output/sweeps/vit-wsd
train: # base train.py config, same keys as a --config file
config: configs/vit_small.yaml # optional base config file, keys below override it
model: vit_small_patch16_224
data_dir: /data/imagenet
opt: adamw
warmup_epochs: 5
search:
mode: grid # or random (then n_trials, and distributions below are allowed)
seed: 42
repeats: 1 # > 1 repeats each trial with a different seed
space:
lr: [3e-4, 6e-4] # a list is a grid axis (a 'choice' in random mode)
weight_decay: {choice: [0.05, 0.1]}
# mixup: {uniform: [0.0, 0.4]}, drop_path: {loguniform: [0.05, 0.3]}, epochs: {int: [80, 120]}
zip: # paired axes, the lists are stepped together as one axis
- {batch_size: [128, 256], lr_base_size: [128, 256]}
metric:
name: top1 # a summary.csv eval_ metric, default is the train eval metric
direction: maximize
wsd: # omit for a plain sweep
points: [40, 80, 120] # completed stable epochs to branch cooldowns from
decay_epochs: [8, 16]
decay_fn: [1-sqrt, linear]
execution:
gpus_per_job: 1
python sweep.py plan sweep.yaml # write configs + plan.json + graph.dot, run nothing
python sweep.py run output/sweeps/vit-wsd --gpus 0,1,2,3
python sweep.py status output/sweeps/vit-wsd
python sweep.py results output/sweeps/vit-wsd
`run` on a sweep that was interrupted picks up where it stopped: finished jobs are skipped and a job with a partial
run resumes from its own last.pth.tar (train.py --resume auto). Workers on several machines sharing the sweep
directory can each `run` it. They coordinate through flock locks under state/locks, which every rank of a job holds
too: when a worker dies, its job isn't started again, nor the sweep replanned, until the job's last rank has exited.
A sweep shared by several hosts needs a filesystem whose flock works across them (e.g. NFSv4, or Lustre mounted with
-o flock).
Lock files are permanent: never delete or replace them to clear a busy sweep. A `running (gone)` status means the
training lock is free; run a worker to reconcile the recorded attempt. `status` only observes, it doesn't repair state.
A worker loads the plan once. Changing the plan needs --force and every worker and job of the sweep stopped, and once
the sweep has started its training jobs can only be extended: trials, cooldowns, or WSD points after the existing ones
(each trunk then continues from where it stopped). Reporting, checkpoint retention and W&B settings may also be updated
offline without retraining. Plan changes to existing training jobs as a new sweep.
Workers check that configs and launchers match the published plan generation. If planning was interrupted, re-run
the original planning command with --force to repair the files before restarting workers.
To extend a WSD sweep, stop every worker and training process, append a later point in the original spec, then:
python sweep.py plan sweep.yaml --force
python sweep.py run output/sweeps/vit-wsd --gpus 0,1,2,3
Keep wsd.decay_epochs explicit and unchanged: its default is 20% of the final point. Preserve existing trial IDs;
reordering or expanding grid axes can change them. With lr_noise, a trunk's schedule depends on its horizon, so adding
a later point to a started sweep requires a new sweep.
Paths in the generated configs and scripts are fixed when the plan is written, so a sweep directory can't be moved or
copied elsewhere and run from there: plan it again under the new location instead.
Requires Unix flock and PyTorch >= 2.5. Tested on Linux; macOS is expected to work but untested. On Windows use WSL2.
Job completion and milestone recovery read training checkpoints with `torch.load(weights_only=True)`, which needs
`safe_globals` for the `argparse.Namespace` they hold.
Hacked together by / Copyright 2026 Ross Wightman
"""
import argparse
import csv
import hashlib
import itertools
import json
import logging
import math
import os
import random
import re
import shutil
import signal
import socket
import stat
import subprocess
import sys
import threading
import time
import uuid
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
import torch
import yaml
try:
import fcntl
except ImportError: # native Windows, see main()
fcntl = None
import train as train_script
from timm.scheduler import create_scheduler_v2, scheduler_kwargs
from timm.utils import load_checkpoint_metadata
_logger = logging.getLogger('sweep')
PLAN_VERSION = 1
# training checkpoints are read with torch.load(weights_only=True), their argparse.Namespace needs safe_globals
TORCH_SUPPORTED = hasattr(torch.serialization, 'safe_globals')
TORCH_REQUIREMENT = 'sweep.py requires PyTorch >= 2.5 (torch.serialization.safe_globals), found ' + torch.__version__
PLAN_FILE = 'plan.json'
STATE_DIR = 'state'
LOCK_DIR = os.path.join(STATE_DIR, 'locks')
TIMM_ROOT = os.path.dirname(os.path.abspath(__file__))
# train.py arguments the planner or the executor owns, they can't be set in a base config or swept
RESERVED_KEYS = {
'output', 'experiment', 'resume', 'stop_epoch', 'save_epochs', # planner
'local_rank', 'wandb_resume_id', # host / executor
}
# additionally derived by the planner for every job of a WSD sweep
WSD_RESERVED_KEYS = {'sched', 'epochs', 'decay_epochs', 'lr_decay_fn'}
# left out of the logical config hash, they describe where a job runs and what it keeps rather than what it trains
OUTPUT_SETTINGS = ('checkpoint_hist', 'log_wandb', 'wandb_project', 'wandb_tags')
UNHASHED_KEYS = {'output', 'experiment', 'resume'} | set(OUTPUT_SETTINGS)
# derived from the sweep's WSD points for the trunk, the stable phase they bound trains the same at any horizon, so
# adding a later point extends a trunk instead of invalidating it
TRUNK_HORIZON_KEYS = {'epochs', 'save_epochs', 'decay_epochs', 'lr_decay_fn'}
# a job keeps these when a plan is updated, besides training the same, or it counts as changed
JOB_GRAPH_KEYS = ('trial', 'kind', 'dir', 'config', 'script', 'deps', 'inputs', 'gpus', 'end_epoch', 'wsd', 'artifacts')
PLAN_SETTINGS = ('metric', 'search', 'space_keys', 'wsd', 'trials')
# sweep defaults that differ from train.py's, any explicit setting overrides them
SWEEP_DEFAULTS = {
'checkpoint_hist': 1, # each kept checkpoint is a full copy w/ optimizer state, for every job of the sweep
}
SLUG_ABBREV = {
'lr': 'lr', 'lr_base': 'lrb', 'weight_decay': 'wd', 'batch_size': 'bs', 'drop': 'drop', 'drop_path': 'dp',
'smoothing': 'sm', 'mixup': 'mix', 'cutmix': 'cm', 'warmup_epochs': 'wu', 'epochs': 'ep', 'min_lr': 'minlr',
'momentum': 'mom', 'clip_grad': 'cg', 'layer_decay': 'ld', 'seed': 'seed', 'model': '', 'opt': '', 'sched': '',
'img_size': 'sz', 'reprob': 'rp', 'aa': 'aa', 'grad_accum_steps': 'ga', 'model_ema_decay': 'emad',
}
# --------------------------------------------------------------------------------------------------------------------
# spec -> trials
# --------------------------------------------------------------------------------------------------------------------
class SweepError(Exception):
pass
def value_slug(value: Any) -> str:
if isinstance(value, bool):
return str(int(value))
if isinstance(value, float):
return f'{value:g}'
if isinstance(value, (list, tuple)):
return 'x'.join(value_slug(v) for v in value)
return ''.join(c if c.isalnum() or c in '.-' else '_' for c in str(value))
def trial_slug(params: Dict[str, Any], max_len: int = 48) -> str:
parts = []
for key, value in params.items():
abbrev = SLUG_ABBREV.get(key, key.replace('_', ''))
parts.append(f'{abbrev}{value_slug(value)}')
slug = '-'.join(parts)
return slug[:max_len].rstrip('-')
def job_config_hash(cfg: Dict[str, Any], kind: str) -> str:
"""Identity of what a job trains, two jobs with the same hash produce the same run."""
exclude = UNHASHED_KEYS
if kind == 'wsd_stable':
exclude = exclude | {'stop_epoch'} # the segments of a trunk train one run, they share its identity
if cfg.get('lr_noise') is None: # lr noise windows scale with the horizon
exclude = exclude | TRUNK_HORIZON_KEYS
logical = {k: v for k, v in cfg.items() if k not in exclude}
return hashlib.sha1(json.dumps(logical, sort_keys=True, default=str).encode()).hexdigest()[:8]
def train_defaults() -> Dict[str, Any]:
"""train.py's argument defaults, as a config dict."""
return vars(train_script.parser.parse_args([]))
def parse_cli_value(text: str) -> Any:
for convert in (int, float):
try:
return convert(text)
except ValueError:
pass
try:
return yaml.safe_load(text)
except yaml.YAMLError:
return text
def numeric(value: Any) -> Any:
"""YAML 1.1 reads 1e-4 as a string, convert numeric strings the way the command line would."""
if isinstance(value, str):
for convert in (int, float):
try:
return convert(value)
except ValueError:
pass
return value
def normalize_axis(name: str, spec: Any) -> Dict[str, Any]:
"""Normalize a search space entry to {'choice': [...]} or a {distribution: [lo, hi]} dict."""
if isinstance(spec, dict):
if len(spec) != 1:
raise SweepError(f'search space "{name}" must be a list or a single {{distribution: args}} entry.')
(dist, args), = spec.items()
if dist == 'choice':
if not isinstance(args, (list, tuple)) or not args:
raise SweepError(f'search space "{name}" choice needs a non-empty list.')
return {'choice': [numeric(v) for v in args]}
if dist in ('uniform', 'loguniform', 'int'):
if not (isinstance(args, (list, tuple)) and len(args) == 2):
raise SweepError(f'search space "{name}" {dist} needs [low, high].')
lo, hi = numeric(args[0]), numeric(args[1])
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (lo, hi)):
raise SweepError(f'search space "{name}" {dist} needs numeric [low, high], got {args}.')
if dist == 'loguniform' and not (0 < lo <= hi):
raise SweepError(f'search space "{name}" loguniform needs 0 < low <= high.')
if lo > hi:
raise SweepError(f'search space "{name}" {dist} needs low <= high.')
return {dist: [lo, hi]}
raise SweepError(f'search space "{name}" has unknown distribution "{dist}".')
if isinstance(spec, (list, tuple)):
if not spec:
raise SweepError(f'search space "{name}" is an empty list.')
return {'choice': [numeric(v) for v in spec]}
return {'choice': [numeric(spec)]}
def normalize_zip_groups(groups: Any) -> List[Dict[str, List[Any]]]:
if not groups:
return []
if isinstance(groups, dict):
groups = [groups]
normalized = []
for group in groups:
if not isinstance(group, dict) or len(group) < 1:
raise SweepError('each zip group must be a mapping of argument name -> list of values.')
lengths = set()
for key, values in group.items():
if not isinstance(values, (list, tuple)) or not values:
raise SweepError(f'zip group entry "{key}" must be a non-empty list.')
lengths.add(len(values))
if len(lengths) != 1:
raise SweepError(f'zip group {sorted(group)} lists must have equal length, got {sorted(lengths)}.')
normalized.append({k: [numeric(x) for x in v] for k, v in group.items()})
return normalized
def sample_axis(rng: random.Random, axis: Dict[str, Any]) -> Any:
(dist, args), = axis.items()
if dist == 'choice':
return rng.choice(args)
lo, hi = args
if dist == 'uniform':
return rng.uniform(lo, hi)
if dist == 'loguniform':
return math.exp(rng.uniform(math.log(lo), math.log(hi)))
if dist == 'int':
return rng.randint(int(lo), int(hi))
raise SweepError(f'unknown distribution {dist}')
def expand_search(search: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Expand a search block into an ordered list of parameter dicts (one per trial, before repeats)."""
mode = search.get('mode', 'grid')
if mode not in ('grid', 'random'):
raise SweepError(f'search.mode must be grid or random, got {mode}.')
space = {k: normalize_axis(k, v) for k, v in (search.get('space') or {}).items()}
zip_groups = normalize_zip_groups(search.get('zip'))
for group in zip_groups:
clash = set(group) & set(space)
if clash:
raise SweepError(f'{sorted(clash)} appear in both search.space and search.zip.')
if not space and not zip_groups:
return [{}]
if mode == 'grid':
axes = []
for key, axis in space.items():
if 'choice' not in axis:
raise SweepError(f'search space "{key}" uses a distribution, only lists are valid for grid search.')
axes.append([{key: v} for v in axis['choice']])
for group in zip_groups:
n = len(next(iter(group.values())))
axes.append([{k: v[i] for k, v in group.items()} for i in range(n)])
trials = []
for combo in itertools.product(*axes):
params = {}
for part in combo:
params.update(part)
trials.append(params)
return trials
n_trials = search.get('n_trials')
if not n_trials or n_trials < 1:
raise SweepError('search.n_trials must be a positive integer for random search.')
rng = random.Random(search.get('seed', 42))
trials = []
for _ in range(n_trials):
params = {k: sample_axis(rng, axis) for k, axis in space.items()}
for group in zip_groups:
i = rng.randrange(len(next(iter(group.values()))))
params.update({k: v[i] for k, v in group.items()})
trials.append(params)
return trials
# --------------------------------------------------------------------------------------------------------------------
# spec -> plan
# --------------------------------------------------------------------------------------------------------------------
def check_reserved(keys, where: str, extra_reserved=()):
reserved = sorted((set(keys) & (RESERVED_KEYS | set(extra_reserved))))
if reserved:
raise SweepError(f'{reserved} in {where} are set by the sweep planner and cannot be specified directly.')
def validate_train_keys(cfg: Dict[str, Any], where: str):
"""Validate against train.py's parser, unknown keys are an error here (a sweep must not silently ignore them)."""
actions = train_script.config_arguments()
unknown = sorted(k for k in cfg if k not in actions)
if unknown:
raise SweepError(f'{unknown} in {where} match no train.py argument.')
try:
train_script.validate_config(cfg)
except SystemExit as e: # argparse error
raise SweepError(f'invalid value in {where}, see the error above.') from e
def build_base_config(train_block: Dict[str, Any], spec_dir: str, wsd: bool = False) -> Dict[str, Any]:
"""Full effective train.py config: parser defaults <- sweep defaults <- optional base config file <- train block."""
train_block = dict(train_block or {})
cfg = train_defaults()
cfg.update(SWEEP_DEFAULTS)
if wsd:
cfg['sched_on_updates'] = True # short decays are poorly sampled per epoch, any explicit setting overrides
base_path = train_block.pop('config', None)
if base_path:
base_path = base_path if os.path.isabs(base_path) else os.path.join(spec_dir, base_path)
base_cfg = train_script.load_config(base_path)
check_reserved(base_cfg, f'base config {base_path}')
cfg.update(base_cfg)
validate_train_keys(train_block, 'the train block')
cfg.update(train_block)
return cfg
def resolve_metric(spec: Dict[str, Any], base_cfg: Dict[str, Any]) -> Dict[str, Any]:
metric = dict(spec.get('metric') or {})
task_default = 'map' if base_cfg.get('task') == 'multilabel' else 'top1'
name = metric.get('name') or base_cfg.get('eval_metric') or task_default
direction = metric.get('direction') or ('minimize' if 'loss' in name else 'maximize')
if direction not in ('maximize', 'minimize'):
raise SweepError(f'metric.direction must be maximize or minimize, got {direction}.')
return {'name': name, 'direction': direction}
def schedule_horizon(point: int, decay_epochs: int, cfg: Dict[str, Any]) -> int:
"""`--epochs` that places a `decay_epochs` long WSD decay right after `point` completed epochs."""
return point + decay_epochs - (cfg['warmup_epochs'] if cfg['warmup_prefix'] else 0)
def total_epochs(cfg: Dict[str, Any]) -> int:
"""Epochs train.py will run for a config, computed by the scheduler factory train.py uses.
Cycle schedulers multiply the horizon by their cycle count, some add cooldown epochs and some don't, so the
factory is asked rather than re-deriving its rules. A throwaway optimizer stands in for the real one.
"""
ns = argparse.Namespace(**cfg)
optimizer = torch.optim.SGD([torch.nn.Parameter(torch.zeros(1))], lr=0.1)
eval_metric = cfg.get('eval_metric') or 'top1'
# per-update schedules get a large synthetic update count so fractional decays don't round to zero, the factory
# divides the total back to whole epochs
updates_per_epoch = 1_000_000 if cfg.get('sched_on_updates') else 1
try:
_, num_epochs = create_scheduler_v2(
optimizer, **scheduler_kwargs(ns, decreasing_metric=eval_metric == 'loss'),
updates_per_epoch=updates_per_epoch)
except (ValueError, AssertionError) as e:
raise SweepError(f'invalid schedule configuration: {e}') from e
return num_epochs
def normalize_wsd(wsd: Dict[str, Any], base_cfg: Dict[str, Any], train_block: Dict[str, Any]) -> Dict[str, Any]:
points = wsd.get('points')
if not points or not all(isinstance(p, int) and p > 0 for p in points):
raise SweepError('wsd.points must be a non-empty list of positive completed epoch counts.')
points = sorted(set(points))
decay_epochs = wsd.get('decay_epochs') or [max(1, round(0.2 * points[-1]))]
if not all(isinstance(d, int) and d > 0 for d in decay_epochs):
raise SweepError('wsd.decay_epochs must be positive integers.')
decay_fn = wsd.get('decay_fn') or ['1-sqrt']
if isinstance(decay_fn, str):
decay_fn = [decay_fn]
min_lr = wsd.get('min_lr')
if min_lr is not None and not isinstance(min_lr, (list, tuple)):
min_lr = [min_lr]
sched = train_block.get('sched', base_cfg.get('sched'))
if 'sched' in train_block and sched != 'wsd' or sched not in (None, 'cosine', 'wsd'): # cosine: parser default
raise SweepError(f'a WSD sweep sets sched=wsd, the train config has sched={sched}.')
if base_cfg.get('train_size_schedule') == 'progressive':
raise SweepError('WSD branches change --epochs, which also drives --train-size-schedule progressive. '
'Use a constant size schedule.')
if base_cfg.get('no_resume_opt'):
raise SweepError('WSD cooldowns must restore optimizer state, remove no_resume_opt.')
return {'points': points, 'decay_epochs': sorted(set(decay_epochs)), 'decay_fn': list(decay_fn), 'min_lr': min_lr}
def job_dir_of(job_id: str) -> str:
"""Directory of a job relative to the sweep root, `<trial>/<job>`, except that the WSD trunk segments of a trial
(`<trial>/stable-<point>`) share `<trial>/stable`.
"""
trial, name = job_id.split('/', 1)
return os.path.join(trial, 'stable') if re.fullmatch(r'stable-\d+', name) else job_id
def make_job(
job_id: str,
trial_id: str,
kind: str,
cfg: Dict[str, Any],
config_name: str = 'config.yaml',
deps: Optional[List[str]] = None,
inputs: Optional[List[str]] = None,
gpus: int = 1,
end_epoch: Optional[int] = None,
wsd: Optional[Dict[str, Any]] = None,
artifacts: Optional[List[str]] = None,
) -> Dict[str, Any]:
job_dir = job_dir_of(job_id)
return {
'id': job_id,
'trial': trial_id,
'kind': kind,
'dir': job_dir,
'config': os.path.join(job_dir, config_name),
'script': os.path.join(job_dir, config_name.replace('config', 'run').replace('.yaml', '.sh')),
'config_hash': job_config_hash(cfg, kind),
'output_settings': {k: cfg[k] for k in OUTPUT_SETTINGS},
'deps': deps or [],
'inputs': inputs or [],
'gpus': gpus,
'end_epoch': end_epoch if end_epoch is not None else total_epochs(cfg),
'wsd': wsd,
# checkpoints that must exist, at or past end_epoch, for the job to count as complete
'artifacts': [os.path.join(job_dir, 'last.pth.tar')] + list(artifacts or []),
'_cfg': cfg, # written to config.yaml, stripped from plan.json
}
def plan_sweep(spec: Dict[str, Any], spec_dir: str = '.') -> Dict[str, Any]:
"""Expand a sweep spec into trials and jobs. Pure, writes nothing."""
name = spec.get('name')
if not name:
raise SweepError('the sweep needs a name.')
output = spec.get('output') or os.path.join('output', 'sweeps')
root = os.path.abspath(os.path.join(spec_dir, output, name) if not os.path.isabs(output) else
os.path.join(output, name))
wsd_spec = spec.get('wsd')
check_reserved(spec.get('train') or {}, 'the train block', WSD_RESERVED_KEYS - {'sched'} if wsd_spec else ())
base_cfg = build_base_config(spec.get('train'), spec_dir, wsd=bool(wsd_spec))
search = dict(spec.get('search') or {})
space_keys = set(search.get('space') or {}) | {k for g in normalize_zip_groups(search.get('zip')) for k in g}
check_reserved(space_keys, 'the search space', WSD_RESERVED_KEYS if wsd_spec else ())
validate_train_keys({k: None for k in space_keys}, 'the search space')
repeats = int(search.get('repeats', 1) or 1)
if repeats > 1 and 'seed' in space_keys:
raise SweepError('use either search.repeats or a seed axis, not both.')
execution = dict(spec.get('execution') or {})
gpus_per_job = int(execution.get('gpus_per_job', 1) or 1)
metric = resolve_metric(spec, base_cfg)
wsd = normalize_wsd(wsd_spec, base_cfg, spec.get('train') or {}) if wsd_spec else None
trials, jobs = [], []
for index, params in enumerate(expand_search(search)):
validate_train_keys(params, f'trial {index} parameters')
for repeat in range(repeats):
trial_params = dict(params)
slug = trial_slug(trial_params)
if repeats > 1:
trial_params['seed'] = base_cfg['seed'] + repeat
trial_id = f'trial-{index:03d}' + (f'-{slug}' if slug else '') + (f'-r{repeat}' if repeats > 1 else '')
trial_cfg = dict(base_cfg)
trial_cfg.update(trial_params)
trial_dir = os.path.join(root, trial_id)
if wsd is not None and wsd['points'][0] <= trial_cfg['warmup_epochs']:
raise SweepError(f'the first WSD point ({wsd["points"][0]}) must be after the warmup '
f'({trial_cfg["warmup_epochs"]} epochs in {trial_id}).')
trial = {
'id': trial_id, 'index': index, 'repeat': repeat, 'params': trial_params, 'jobs': [],
}
trials.append(trial)
if wsd is None:
cfg = dict(trial_cfg, output=trial_dir, experiment='train', resume='auto')
job = make_job(f'{trial_id}/train', trial_id, 'train', cfg, gpus=gpus_per_job)
jobs.append(job)
trial['jobs'].append(job['id'])
continue
points = wsd['points']
trunk_cfg = dict(
trial_cfg, sched='wsd', epochs=schedule_horizon(points[-1], 1, trial_cfg), decay_epochs=1,
lr_decay_fn=trial_cfg.get('lr_decay_fn') or wsd['decay_fn'][0],
save_epochs=list(points), output=trial_dir, experiment='stable', resume='auto',
)
prev_job = None
stable_dir = os.path.join(trial_id, 'stable')
for point in points:
cfg = dict(trunk_cfg, stop_epoch=point)
job = make_job(
f'{trial_id}/stable-{point}', trial_id, 'wsd_stable', cfg,
config_name=f'config-{point}.yaml', deps=[prev_job] if prev_job else [], gpus=gpus_per_job,
end_epoch=point, wsd={'point': point},
artifacts=[os.path.join(stable_dir, f'milestone-{point}.pth.tar')],
)
jobs.append(job)
trial['jobs'].append(job['id'])
prev_job = job['id']
for decay_epochs in wsd['decay_epochs']:
for decay_fn in wsd['decay_fn']:
for min_lr in (wsd['min_lr'] or [None]):
branch = f'decay-p{point}-d{decay_epochs}-{value_slug(decay_fn)}'
branch_wsd = {'point': point, 'decay_epochs': decay_epochs, 'decay_fn': decay_fn}
cfg = dict(
trial_cfg, sched='wsd', epochs=schedule_horizon(point, decay_epochs, trial_cfg),
decay_epochs=decay_epochs, lr_decay_fn=decay_fn, output=trial_dir,
experiment=branch, stop_epoch=None, save_epochs=[],
resume=os.path.join(trial_dir, 'stable', f'milestone-{point}.pth.tar'),
)
if min_lr is not None:
branch += f'-minlr{value_slug(min_lr)}'
branch_wsd['min_lr'] = min_lr
cfg['min_lr'] = min_lr
cfg['experiment'] = branch
bjob = make_job(
f'{trial_id}/{branch}', trial_id, 'wsd_cooldown', cfg,
deps=[job['id']], inputs=[os.path.join(stable_dir, f'milestone-{point}.pth.tar')],
gpus=gpus_per_job, end_epoch=point + decay_epochs + trial_cfg['cooldown_epochs'],
wsd=branch_wsd,
)
jobs.append(bjob)
trial['jobs'].append(bjob['id'])
ids = [j['id'] for j in jobs]
if len(set(ids)) != len(ids):
dupes = sorted({i for i in ids if ids.count(i) > 1})
raise SweepError(f'duplicate job ids {dupes[:3]}, make the swept values distinguishable.')
return {
'version': PLAN_VERSION,
'name': name,
'root': root,
'created': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'python': sys.executable,
'train_script': os.path.join(TIMM_ROOT, 'train.py'),
'metric': metric,
'search': {k: search.get(k) for k in ('mode', 'seed', 'n_trials', 'repeats') if k in search},
'space_keys': sorted(space_keys),
'wsd': wsd,
'gpus_per_job': gpus_per_job,
'trials': trials,
'jobs': jobs,
}
# --------------------------------------------------------------------------------------------------------------------
# plan -> files
# --------------------------------------------------------------------------------------------------------------------
def run_script_text(job: Dict[str, Any], plan: Dict[str, Any]) -> str:
config_path = os.path.join(plan['root'], job['config'])
launcher = '"${PYTHON:-python}"'
if job['gpus'] > 1:
launcher += f' -m torch.distributed.run --standalone --nproc_per_node={job["gpus"]}'
return (
'#!/bin/bash\n'
f'# plan_generation: {plan["generation"]}\n'
f'# {job["id"]} ({job["kind"]}), generated by sweep.py for sweep "{plan["name"]}"\n'
'# Extra arguments are passed through to train.py, e.g. --resume auto to continue an interrupted run.\n'
'set -e\n'
f'cd "{TIMM_ROOT}"\n'
f'exec {launcher} train.py --config "{config_path}" "$@"\n'
)
def graph_dot(plan: Dict[str, Any]) -> str:
lines = ['digraph sweep {', ' rankdir=LR;', ' node [shape=box, fontsize=10];']
for trial in plan['trials']:
lines.append(f' subgraph "cluster_{trial["id"]}" {{')
lines.append(f' label="{trial["id"]}";')
for job_id in trial['jobs']:
lines.append(f' "{job_id}" [label="{job_id.split("/", 1)[1]}"];')
lines.append(' }')
for job in plan['jobs']:
for dep in job['deps']:
label = f' [label="{os.path.basename(job["inputs"][0])}"]' if job['inputs'] else ''
lines.append(f' "{dep}" -> "{job["id"]}"{label};')
lines.append('}')
return '\n'.join(lines) + '\n'
def write_file(path: str, text: str, executable: bool = False):
"""Atomically replace a file whose content changed, a job launching meanwhile never reads a partial file."""
try:
with open(path) as f:
if f.read() == text:
return
except OSError:
pass
tmp = f'{path}.{os.getpid()}.tmp'
with open(tmp, 'w') as f:
f.write(text)
if executable:
os.chmod(tmp, os.stat(tmp).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, path)
def plan_lock(root: str) -> 'Lock':
"""The sweep's plan lock. A planner writes under it exclusively, every worker and every rank of a job it started
hold it shared, so the plan only changes once all of them have stopped.
"""
return Lock(os.path.join(root, LOCK_DIR, 'plan.lock'))
def write_plan(plan: Dict[str, Any], spec: Dict[str, Any], force: bool = False) -> str:
"""Write a plan into its sweep directory.
An unchanged plan writes nothing, also while the sweep runs. A change needs `force` and every worker and job of
the sweep stopped. Once started, existing training is preserved: jobs may be added, reporting and output settings
changed.
"""
root = plan['root']
lock = plan_lock(root)
lock.acquire(exclusive=False, block=True) # compare without reading through another planner's writes
try:
changes = plan_changes(plan)
finally:
lock.release()
if changes is not None and not any(changes.values()):
_logger.info(f'Plan in {root} is unchanged.')
return root
check_plan_update(plan, changes, force)
if not lock.acquire(exclusive=True):
raise SweepError(
f'{root} is in use, its workers or jobs hold {lock.path}. Stop all of them before changing the plan.')
try:
changes = plan_changes(plan) # another planner may have written meanwhile
check_plan_update(plan, changes, force)
if changes is not None and not any(changes.values()):
return root
if changes is not None:
_logger.warning(f'Updating the plan in {root} ({describe_changes(changes)}).')
os.makedirs(os.path.join(root, STATE_DIR), exist_ok=True)
plan['generation'] = uuid.uuid4().hex
for job in plan['jobs']:
os.makedirs(os.path.join(root, job['dir']), exist_ok=True)
config_text = (
f'# {job["id"]} ({job["kind"]}), effective train.py config generated by sweep.py\n'
f'# plan_generation: {plan["generation"]}\n'
f'# config_hash: {job["config_hash"]}\n'
+ yaml.safe_dump(job['_cfg'], default_flow_style=False, sort_keys=False)
)
write_file(os.path.join(root, job['config']), config_text)
write_file(os.path.join(root, job['script']), run_script_text(job, plan), executable=True)
write_file(os.path.join(root, 'sweep.yaml'), yaml.safe_dump(spec, default_flow_style=False, sort_keys=False))
write_file(os.path.join(root, 'graph.dot'), graph_dot(plan))
serializable = dict(plan, jobs=[{k: v for k, v in j.items() if k != '_cfg'} for j in plan['jobs']])
write_file(os.path.join(root, PLAN_FILE), json.dumps(serializable, indent=2)) # publish the generation last
finally:
lock.release()
return root
def check_plan_update(plan: Dict[str, Any], changes: Optional[Dict[str, List[str]]], force: bool):
"""Raise unless the sweep's plan may be updated with these changes."""
if changes is None or not any(changes.values()):
return
root = plan['root']
if not force:
raise SweepError(
f'{os.path.join(root, PLAN_FILE)} exists with a different plan ({describe_changes(changes)}). Use a new '
f'sweep name, or --force to update it.')
if (changes['removed'] or changes['changed']) and sweep_started(root):
hint = (' New WSD points must come after the existing ones, and wsd.decay_epochs (by default 20% of the last '
'point) must stay the same.') if plan['wsd'] else ''
raise SweepError(
f'{root} has started, so its plan can only be extended with new jobs, this one has '
f'{describe_changes(changes)}. Plan the change as a new sweep.{hint}')
def mismatched_plan_files(plan: Dict[str, Any]) -> List[str]:
"""Configs and launchers missing the published generation. Called under the plan lock.
Each file is replaced atomically, with the generation on its second line. A planner interrupted before publishing
plan.json leaves different generations, which workers must refuse to run.
"""
expected = f'# plan_generation: {plan["generation"]}\n'
mismatched = []
for job in plan['jobs']:
for rel in (job['config'], job['script']):
try:
with open(os.path.join(plan['root'], rel)) as f:
f.readline()
if f.readline() == expected:
continue
except OSError:
pass
mismatched.append(rel)
return mismatched
def plan_changes(plan: Dict[str, Any]) -> Optional[Dict[str, List[str]]]:
"""Plan changes and incomplete generated files, None if the sweep has no plan yet."""
root = plan['root']
if not os.path.exists(os.path.join(root, PLAN_FILE)):
return None
old_plan = load_plan(root)
old = {j['id']: j for j in old_plan['jobs']}
new = {j['id']: j for j in plan['jobs']}
changed = [
job_id for job_id, job in new.items() if job_id in old and (
any(old[job_id].get(k) != job.get(k) for k in JOB_GRAPH_KEYS)
or old[job_id]['config_hash'] != job['config_hash'])
]
return {
'added': [i for i in new if i not in old],
'removed': [i for i in old if i not in new],
'changed': changed,
'output_settings': [
job_id for job_id, job in new.items()
if job_id in old and old[job_id]['output_settings'] != job['output_settings']
],
'settings': [k for k in PLAN_SETTINGS if old_plan.get(k) != plan.get(k)],
'files': mismatched_plan_files(old_plan),
}
def describe_changes(changes: Dict[str, List[str]]) -> str:
parts = []
for what, ids in changes.items():
if ids:
if what == 'settings':
parts.append('settings changed (' + ', '.join(ids) + ')')
continue
if what == 'output_settings':
what = 'with changed output settings'
unit = 'files' if what == 'files' else 'jobs'
if what == 'files':
what = 'missing or from another generation'
listed = '' if what == 'added' else ' (' + ', '.join(ids[:3]) + (', ...' if len(ids) > 3 else '') + ')'
parts.append(f'{len(ids)} {unit} {what}{listed}')
return ', '.join(parts)
def sweep_started(root: str) -> bool:
"""Whether any job of the sweep has started. Attempts are recorded before a job starts, outputs count too (a job
run by hand has no record).
"""
state_dir = os.path.join(root, STATE_DIR)
if os.path.isdir(state_dir) and any(n.endswith('.json') for n in os.listdir(state_dir)):
return True
return any(
os.path.exists(os.path.join(root, job['dir'], name))
for job in load_plan(root)['jobs'] for name in ('summary.csv', 'last.pth.tar')
)
def milestone_source(job_dir: str, point: int) -> Optional[str]:
"""A checkpoint in a trunk directory holding the state at `point` completed epochs, to recreate its milestone.
`last.pth.tar` (if it hasn't moved on) or the ranked `checkpoint-N` for that epoch hold the identical state.
"""
for candidate in ('last.pth.tar', f'checkpoint-{point - 1}.pth.tar'):
source = os.path.join(job_dir, candidate)
if os.path.isfile(source) and checkpoint_epochs(source) == point:
return source
return None
def load_plan(root: str) -> Dict[str, Any]:
plan_path = os.path.join(root, PLAN_FILE)
if not os.path.isfile(plan_path):
raise SweepError(f'{root} has no {PLAN_FILE}, run `sweep.py plan` first.')
with open(plan_path) as f:
plan = json.load(f)
if plan.get('version') != PLAN_VERSION:
raise SweepError(f'{plan_path} is plan version {plan.get("version")}, this sweep.py reads {PLAN_VERSION}.')
plan['root'] = os.path.abspath(root)
return plan
# --------------------------------------------------------------------------------------------------------------------
# job state
# --------------------------------------------------------------------------------------------------------------------
def summary_epochs(root: str, job: Dict[str, Any]) -> Optional[int]:
"""Completed epochs recorded in the job's summary.csv, None if there is no summary yet."""
path = os.path.join(root, job['dir'], 'summary.csv')
if not os.path.isfile(path):
return None
last = None
with open(path, newline='') as f:
for row in csv.DictReader(f):
try:
last = int(float(row['epoch']))
except (KeyError, ValueError, TypeError):
continue
return None if last is None else last + 1
def checkpoint_epochs(path: str) -> Optional[int]:
"""Completed epochs stored in a train.py checkpoint, None if it can't be read."""
try:
checkpoint = load_checkpoint_metadata(path)
except Exception as e: # a partially written or foreign file
_logger.warning(f'Could not read checkpoint {path}: {e}')
return None
if not isinstance(checkpoint, dict) or 'epoch' not in checkpoint:
return None
return checkpoint['epoch'] + (1 if checkpoint.get('version', 1) > 1 else 0)
class Lock:
"""An flock on a file under the sweep's state/locks, taken through a descriptor of its own.
Lock files are never removed or replaced, a replaced file would split the lock. A held lock is never upgraded in
place either: a failed non-blocking upgrade drops the shared lock already held on local filesystems.
"""
def __init__(self, path: str):
self.path = path
self.fd: Optional[int] = None
def acquire(self, exclusive: bool, block: bool = False) -> bool:
"""Take the lock, False if it is held elsewhere (and `block` is not set)."""
assert self.fd is None, 'release a held lock before taking it again'
os.makedirs(os.path.dirname(self.path), exist_ok=True)
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o666)
try:
fcntl.flock(self.fd, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | (0 if block else fcntl.LOCK_NB))
except BlockingIOError:
self.release()
return False
except OSError as e:
self.release()
raise SweepError(
f'could not lock {self.path} ({e}). Sweeps need a filesystem with working flock, across all hosts for '
f'a sweep shared by several.') from e
return True
def share(self):
"""Turn the held exclusive lock shared. Not atomic, a waiting shared locker may get in between."""
fcntl.flock(self.fd, fcntl.LOCK_SH)
def release(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
class JobState:
"""Per-job status record under <root>/state, plus the locks of the job's folder under <root>/state/locks.
The jobs training in one folder (the segments of a WSD trunk) share its locks:
* `.coord`: held exclusively by a worker while it starts, finishes or settles an attempt
* `.train`: exclusive while an attempt is reserved or settled, shared while it runs by its worker and by
every rank of the job, so a job whose worker died stays protected until its last rank exits
* `.attempt`: the id of the attempt ranks may join, empty once it is retired
"""
def __init__(self, root: str, job: Dict[str, Any]):
self.root = root
self.job = job
self.path = os.path.join(root, STATE_DIR, job['id'].replace('/', '__') + '.json')
locks = os.path.join(root, LOCK_DIR, job['dir'].replace('/', '__'))
self.coord_path, self.train_path, self.attempt_path = (locks + s for s in ('.coord', '.train', '.attempt'))
self.reload()
def reload(self):
"""Re-read the state file, other workers sharing the sweep directory may have changed it."""
self.data = {'id': self.job['id'], 'status': 'pending', 'attempts': []}
if os.path.isfile(self.path):
try:
with open(self.path) as f:
self.data = json.load(f)
except (OSError, json.JSONDecodeError):
_logger.warning(f'Unreadable state file {self.path}, treating {self.job["id"]} as pending.')
@property
def status(self) -> str:
return self.data['status']
@property
def attempts(self) -> List[Dict[str, Any]]:
return self.data['attempts']
def save(self):
tmp = self.path + f'.{os.getpid()}.tmp'
with open(tmp, 'w') as f:
json.dump(self.data, f, indent=2)
os.replace(tmp, self.path)
def set(self, status: str, **attempt_update):
self.data['status'] = status
if attempt_update and self.attempts:
self.attempts[-1].update(attempt_update)
self.save()
def start_attempt(self, attempt_id: str, gpus: List[int], command: List[str]):
self.attempts.append({
'attempt': len(self.attempts) + 1,
'id': attempt_id,
'host': socket.gethostname(),
'gpus': gpus,
'command': command,
'start': datetime.now(timezone.utc).isoformat(timespec='seconds'),
})
self.data['status'] = 'running'
self.save()
def set_live_attempt(self, attempt_id: str = ''):
"""Name the attempt the job's ranks may join, '' retires it: a rank starting late then exits untrained."""
write_file(self.attempt_path, attempt_id)
def attempt_gone(self) -> bool:
"""Whether nothing holds the folder's training lock, so a 'running' record has outlived its attempt."""
probe = Lock(self.train_path) # a descriptor of its own, it can't disturb a lock held here
if not probe.acquire(exclusive=True):
return False
probe.release()
return True
def short_job_id(job_id: str) -> str:
"""'trial-000-lr0.001-wd0.05/stable-18' -> 'trial-000/stable-18' for console prefixes."""
trial, _, job = job_id.partition('/')
parts = trial.split('-')
if len(parts) >= 2 and parts[0] == 'trial':
trial = '-'.join(parts[:2]) + ('-' + parts[-1] if parts[-1].startswith('r') and parts[-1][1:].isdigit() else '')
return f'{trial}/{job}' if job else trial
# --------------------------------------------------------------------------------------------------------------------
# executor
# --------------------------------------------------------------------------------------------------------------------
class Executor:
def __init__(
self,
plan: Dict[str, Any],
gpus: Optional[List[int]] = None,
max_parallel: Optional[int] = None,
retries: int = 0,
poll_interval: float = 10.,
dry_run: bool = False,
echo: bool = True,
):
# --gpus ids index into an inherited CUDA_VISIBLE_DEVICES (e.g. a Slurm allocation), as CUDA itself would
visible = os.environ.get('CUDA_VISIBLE_DEVICES')
self.devices = None if visible is None else [d.strip() for d in visible.split(',') if d.strip()]
if gpus is not None and self.devices is not None:
outside = [g for g in gpus if not 0 <= g < len(self.devices)]
if outside:
raise SweepError(
f'--gpus {outside} are outside the inherited CUDA_VISIBLE_DEVICES={visible}, the ids index into '
f'it ({len(self.devices)} devices).')
if gpus is not None:
too_big = sorted({j['gpus'] for j in plan['jobs'] if j['gpus'] > len(gpus)})
if too_big:
raise SweepError(
f'jobs need {too_big[-1]} GPUs but the pool has {len(gpus)} ({gpus}), they could never start. '
f'Give more GPUs with --gpus or plan the sweep with fewer GPUs per job.')