Skip to content

Commit ae21828

Browse files
[BugFix] FSDP2 SLURM restart
1 parent 35263d4 commit ae21828

3 files changed

Lines changed: 165 additions & 27 deletions

File tree

examples/imagenet1k_supervised_vit_fsdp2.py

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,30 +50,53 @@
5050

5151

5252
class TopkAccuracy(pl.Callback):
53-
"""Validation top-1 accuracy on the trained head's logits (``batch["logits"]``)."""
53+
"""Validation top-1 / top-5 accuracy on the head's logits (``batch["logits"]``).
54+
55+
Uses ``average="micro"`` (correct / total) — the standard ImageNet top-1
56+
every paper reports. ``torchmetrics``'s
57+
:class:`~torchmetrics.classification.MulticlassAccuracy` defaults to
58+
``average="macro"`` (mean of per-class recall); on the full balanced
59+
ImageNet val set macro and micro nearly coincide, but micro is the
60+
unambiguous convention, so we pin it and add top-5 alongside.
61+
"""
5462

5563
def __init__(self):
5664
super().__init__()
57-
self.acc = torchmetrics.classification.MulticlassAccuracy(NUM_CLASSES)
65+
MCA = torchmetrics.classification.MulticlassAccuracy
66+
self.top1 = MCA(NUM_CLASSES, top_k=1, average="micro")
67+
self.top5 = MCA(NUM_CLASSES, top_k=5, average="micro")
5868

5969
def on_validation_batch_end(self, trainer, pl_module, outputs, batch, batch_idx):
60-
self.acc.to(pl_module.device)
61-
self.acc.update(batch["logits"], batch["label"].long())
70+
self.top1.to(pl_module.device)
71+
self.top5.to(pl_module.device)
72+
self.top1.update(batch["logits"], batch["label"].long())
73+
self.top5.update(batch["logits"], batch["label"].long())
6274

6375
def on_validation_epoch_end(self, trainer, pl_module):
64-
pl_module.log("val/top1", self.acc.compute(), prog_bar=True, sync_dist=True)
65-
self.acc.reset()
76+
pl_module.log("val/top1", self.top1.compute(), prog_bar=True, sync_dist=True)
77+
pl_module.log("val/top5", self.top5.compute(), prog_bar=True, sync_dist=True)
78+
self.top1.reset()
79+
self.top5.reset()
6680

6781

68-
def build_loaders(batch_size, num_workers):
82+
def build_loaders(batch_size, num_workers, use_randaug=True, cpu_norm=False):
6983
# CPU does the bare minimum (decode + square resize) so the GPU aug pipeline
7084
# is the bottleneck-free fast path. Train images stay un-normalized [0,1]
7185
# floats for the kornia GPU ops; val is fully prepared on CPU.
72-
train_cpu = transforms.Compose(
73-
transforms.RGB(),
74-
transforms.Resize((256, 256)),
75-
transforms.ToImage(), # -> float tensor in [0, 1], no normalization
76-
)
86+
if cpu_norm:
87+
# debug path: full CPU pipeline producing normalized 224 crops, no gpu_transform
88+
train_cpu = transforms.Compose(
89+
transforms.RGB(),
90+
transforms.RandomResizedCrop((224, 224), scale=(0.08, 1.0)),
91+
transforms.RandomHorizontalFlip(p=0.5),
92+
transforms.ToImage(mean=_MEAN, std=_STD),
93+
)
94+
else:
95+
train_cpu = transforms.Compose(
96+
transforms.RGB(),
97+
transforms.Resize((256, 256)),
98+
transforms.ToImage(), # [0,1], GPU pipeline normalizes
99+
)
77100
val_cpu = transforms.Compose(
78101
transforms.RGB(),
79102
transforms.Resize((256, 256)),
@@ -84,14 +107,18 @@ def build_loaders(batch_size, num_workers):
84107
path="ILSVRC/imagenet-1k", split="train", transform=train_cpu
85108
)
86109
# Heavy augmentation, batched on GPU (DeiT/AugReg policy).
87-
train_ds.gpu_transform = GPUCompose(
110+
aug = [
88111
ToDevice(),
89112
GPURandomResizedCrop(224, scale=(0.08, 1.0)),
90113
GPURandomHorizontalFlip(p=0.5),
91-
GPURandAugment(n=2, m=9),
92-
GPUNormalize(mean=_MEAN, std=_STD),
93-
GPURandomErasing(p=0.25),
94-
)
114+
]
115+
if use_randaug:
116+
aug.append(GPURandAugment(n=2, m=9))
117+
aug.append(GPUNormalize(mean=_MEAN, std=_STD))
118+
if use_randaug:
119+
aug.append(GPURandomErasing(p=0.25))
120+
if not cpu_norm:
121+
train_ds.gpu_transform = GPUCompose(aug)
95122
val_ds = spt.data.HFDataset(
96123
path="ILSVRC/imagenet-1k", split="validation", transform=val_cpu
97124
)
@@ -113,20 +140,43 @@ def build_loaders(batch_size, num_workers):
113140
def main():
114141
ap = argparse.ArgumentParser()
115142
ap.add_argument("--backbone", default="vit_large_patch16_224")
143+
ap.add_argument("--strategy", default="fsdp2", help="fsdp2 | ddp | auto (debug)")
144+
ap.add_argument("--devices", default="auto", help="'auto' or an int (debug)")
116145
ap.add_argument("--epochs", type=int, default=300)
146+
ap.add_argument(
147+
"--max-steps", type=int, default=0, help=">0 caps steps (smoke test)"
148+
)
117149
ap.add_argument("--batch-size", type=int, default=64, help="per-GPU batch")
118150
ap.add_argument("--lr", type=float, default=1e-3)
119151
ap.add_argument("--weight-decay", type=float, default=0.05)
120152
ap.add_argument("--drop-path", type=float, default=0.4) # DeiT-III ViT-L
121153
ap.add_argument("--label-smoothing", type=float, default=0.1)
122154
ap.add_argument("--num-workers", type=int, default=12)
155+
ap.add_argument("--no-randaug", action="store_true", help="debug: drop RandAugment")
156+
ap.add_argument("--no-mixup", action="store_true", help="debug: drop Mixup/CutMix")
157+
ap.add_argument(
158+
"--cpu-norm",
159+
action="store_true",
160+
help="debug: normalize on CPU, no gpu_transform",
161+
)
162+
ap.add_argument(
163+
"--overfit-batches",
164+
type=int,
165+
default=0,
166+
help="debug: Lightning overfit_batches",
167+
)
123168
args = ap.parse_args()
124169

125170
# Speed knobs (TF32 + autotuned cuDNN); bf16 comes from the Trainer.
126171
torch.set_float32_matmul_precision("high")
127172
torch.backends.cudnn.benchmark = True
128173

129-
train_loader, val_loader = build_loaders(args.batch_size, args.num_workers)
174+
train_loader, val_loader = build_loaders(
175+
args.batch_size,
176+
args.num_workers,
177+
use_randaug=not args.no_randaug,
178+
cpu_norm=args.cpu_norm,
179+
)
130180
data = spt.data.DataModule(train=train_loader, val=val_loader)
131181

132182
backbone = getattr(spt.backbone, args.backbone)(
@@ -141,9 +191,18 @@ def main():
141191

142192
def forward(self, batch, stage):
143193
if self.training:
144-
images, soft = mixup(batch["image"], batch["label"])
194+
if args.no_mixup:
195+
images, target = batch["image"], batch["label"]
196+
else:
197+
images, target = mixup(batch["image"], batch["label"])
145198
logits = self.backbone(images)
146-
batch["loss"] = F.cross_entropy(logits, soft)
199+
loss = F.cross_entropy(logits, target)
200+
batch["loss"] = loss
201+
# Log train loss so we can actually see whether the backbone learns
202+
# (CE starts near ln(1000)=6.9; flat there = frozen, dropping = learning).
203+
self.log(
204+
"train/loss", loss.detach(), prog_bar=True, on_step=True, sync_dist=True
205+
)
147206
else:
148207
batch["logits"] = self.backbone(batch["image"])
149208
return batch
@@ -162,17 +221,22 @@ def forward(self, batch, stage):
162221
},
163222
)
164223

165-
trainer = dict(
166-
strategy="fsdp2",
224+
# Pass a real pl.Trainer (not a config dict): the Manager wraps a dict
225+
# trainer in OmegaConf, which can't hold callback *instances*
226+
# (UnsupportedValueType). Callback instances are fine on a built Trainer.
227+
devices = args.devices if args.devices == "auto" else int(args.devices)
228+
trainer = pl.Trainer(
229+
strategy=args.strategy,
167230
precision="bf16-mixed",
168231
accelerator="gpu",
169-
devices="auto",
232+
devices=devices,
170233
max_epochs=args.epochs,
234+
max_steps=args.max_steps if args.max_steps > 0 else -1,
235+
overfit_batches=args.overfit_batches if args.overfit_batches > 0 else 0.0,
171236
callbacks=[
172237
pl.pytorch.callbacks.LearningRateMonitor(logging_interval="step"),
173238
TopkAccuracy(),
174239
],
175-
gradient_clip_val=1.0,
176240
num_sanity_val_steps=0,
177241
enable_checkpointing=True,
178242
)

stable_pretraining/manager.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -528,9 +528,13 @@ def __init__(
528528
"regardless of where the process is launched from."
529529
)
530530
p = p.with_suffix(".ckpt")
531-
if not p.is_file():
531+
# ``exists()`` not ``is_file()``: a distributed (FSDP2) checkpoint
532+
# is a directory of ``*.distcp`` shards, not a single file. Accept
533+
# both so an FSDP2 ``last.ckpt`` can be passed as a fresh-run
534+
# ``ckpt_path`` too.
535+
if not p.exists():
532536
raise FileNotFoundError(
533-
f"`ckpt_path` was set to {p} but no such file exists. "
537+
f"`ckpt_path` was set to {p} but no such file/dir exists. "
534538
"Refusing to silently start training from scratch."
535539
)
536540
ckpt_path = p
@@ -1243,7 +1247,13 @@ def _resolve_load_path(self, run_dir: Path) -> tuple[Optional[str], Optional[boo
12431247
"picks up exactly where it left off. The user ckpt_path "
12441248
"is only consumed on the FIRST (fresh) invocation."
12451249
)
1246-
if not last_ckpt.is_file():
1250+
# ``exists()`` not ``is_file()``: an FSDP2 *distributed* checkpoint
1251+
# is saved as a DIRECTORY of ``*.distcp`` shards (one per rank),
1252+
# while a plain checkpoint is a single file. Both are valid
1253+
# ``last.ckpt`` forms — gating on ``is_file()`` made every sharded
1254+
# (FSDP2 / save_distributed_checkpoint=True) run refuse to resume
1255+
# on requeue even though the checkpoint was written correctly.
1256+
if not last_ckpt.exists():
12471257
raise RuntimeError(
12481258
f"REQUEUE but no last.ckpt to resume from at {last_ckpt}. "
12491259
"The original run was preempted before saving its first "

stable_pretraining/tests/unit/test_manager.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,67 @@ def test_no_label(self):
502502
def test_with_label(self):
503503
# Should not raise; label is purely cosmetic.
504504
print_signal_info("post-fit")
505+
506+
507+
@pytest.mark.unit
508+
class TestDistributedCheckpointResume:
509+
"""FSDP2 distributed checkpoints are directories, not single files.
510+
511+
They are saved as a directory of ``*.distcp`` shards (one per rank).
512+
Regression for a bug where requeue-resume and fresh-run ``ckpt_path``
513+
validation both used ``Path.is_file()``, which is ``False`` for the
514+
directory form — so every sharded (FSDP2 / ``save_distributed_checkpoint``)
515+
run refused to resume on SLURM requeue even though ``last.ckpt`` was on disk.
516+
"""
517+
518+
@staticmethod
519+
def _make_distributed_ckpt(path: Path) -> Path:
520+
"""Create a fake distributed checkpoint (a directory of shards)."""
521+
path.mkdir(parents=True)
522+
(path / "__0_0.distcp").touch()
523+
(path / ".metadata").touch()
524+
return path
525+
526+
def test_requeue_accepts_directory_last_ckpt(
527+
self, manager_factory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
528+
):
529+
run_dir = tmp_path / "run"
530+
last_ckpt = self._make_distributed_ckpt(run_dir / "checkpoints" / "last.ckpt")
531+
manager = manager_factory(
532+
callbacks=[], ckpt_path=None, trainer_enable_checkpointing=True
533+
)
534+
monkeypatch.setattr(
535+
"stable_pretraining.manager._is_slurm_requeue", lambda: True
536+
)
537+
path, weights_only = manager._resolve_load_path(run_dir)
538+
assert path == str(last_ckpt)
539+
# Requeue always full-restores (optimizer/scheduler/RNG).
540+
assert weights_only is False
541+
542+
def test_requeue_missing_last_ckpt_still_raises(
543+
self, manager_factory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
544+
):
545+
run_dir = tmp_path / "run"
546+
(run_dir / "checkpoints").mkdir(parents=True) # no last.ckpt at all
547+
manager = manager_factory(
548+
callbacks=[], ckpt_path=None, trainer_enable_checkpointing=True
549+
)
550+
monkeypatch.setattr(
551+
"stable_pretraining.manager._is_slurm_requeue", lambda: True
552+
)
553+
with pytest.raises(RuntimeError, match="no last.ckpt"):
554+
manager._resolve_load_path(run_dir)
555+
556+
def test_init_accepts_directory_ckpt_path(self, tmp_path: Path):
557+
"""A distributed-checkpoint directory is a valid fresh-run ``ckpt_path``."""
558+
ckpt_dir = self._make_distributed_ckpt(tmp_path / "last.ckpt")
559+
trainer = BoringTrainer(
560+
default_root_dir=str(tmp_path), enable_checkpointing=True
561+
)
562+
manager = Manager(
563+
trainer=trainer,
564+
module=BoringModule(),
565+
data=BoringDataModule(),
566+
ckpt_path=str(ckpt_dir),
567+
)
568+
assert manager.ckpt_path == ckpt_dir

0 commit comments

Comments
 (0)