LMFuser 0.3.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lmfuser-0.3.0 → lmfuser-0.4.1}/PKG-INFO +2 -2
- {lmfuser-0.3.0 → lmfuser-0.4.1}/pyproject.toml +2 -2
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/optimizers.py +5 -2
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/runners/ddp_runner.py +325 -55
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/task.py +7 -1
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/utils.py +0 -5
- lmfuser-0.4.1/tests/test_runner_robustness.py +114 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/.github/workflows/python-publish.yml +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/.gitignore +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/.vscode/settings.json +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/LICENSE +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/README.md +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/__init__.py +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/model_loader.py +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/runners/__init__.py +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/runners/runner.py +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/src/lmfuser/schedulers.py +0 -0
- {lmfuser-0.3.0 → lmfuser-0.4.1}/tests/test_fsdp2_compile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: LMFuser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: The LMFuser training framework.
|
|
5
5
|
Project-URL: Homepage, https://github.com/TYTTYTTYT/LMFuser
|
|
6
6
|
Project-URL: Documentation, https://github.com/TYTTYTTYT/LMFuser
|
|
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
17
17
|
Classifier: Topic :: Utilities
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
19
|
Requires-Dist: hyperargs>=0.1.3
|
|
20
|
-
Requires-Dist: lmfuser-data>=0.
|
|
20
|
+
Requires-Dist: lmfuser-data>=0.3.1
|
|
21
21
|
Requires-Dist: numpy
|
|
22
22
|
Requires-Dist: pandas
|
|
23
23
|
Requires-Dist: pyarrow
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "LMFuser"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
requires-python = ">= 3.11"
|
|
9
9
|
description = "The LMFuser training framework."
|
|
10
10
|
readme = "README.md"
|
|
@@ -20,7 +20,7 @@ dependencies = [
|
|
|
20
20
|
"pandas",
|
|
21
21
|
"pyarrow",
|
|
22
22
|
"torch>=2.4.0",
|
|
23
|
-
"LMFuser-data>=0.
|
|
23
|
+
"LMFuser-data>=0.3.1",
|
|
24
24
|
"tqdm",
|
|
25
25
|
"wandb",
|
|
26
26
|
"HyperArgs>=0.1.3",
|
|
@@ -10,7 +10,7 @@ from torch.optim import (
|
|
|
10
10
|
Adadelta,
|
|
11
11
|
Adagrad
|
|
12
12
|
)
|
|
13
|
-
from hyperargs import Conf, OptionArg, FloatArg, BoolArg, add_dependency, monitor_on
|
|
13
|
+
from hyperargs import Conf, OptionArg, FloatArg, BoolArg, add_dependency, monitor_on, IntArg
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class OptimizerConfigBase(Conf):
|
|
@@ -32,6 +32,8 @@ class AdamWConfig(OptimizerConfigBase):
|
|
|
32
32
|
eps = FloatArg(1e-8, min_value=0.0, max_value=1.0)
|
|
33
33
|
weight_decay = FloatArg(0.01, min_value=0.0, max_value=1.0)
|
|
34
34
|
amsgrad = BoolArg(False)
|
|
35
|
+
# fused CUDA kernel (single multi-tensor launch); ignored on CPU
|
|
36
|
+
fused = BoolArg(default=False)
|
|
35
37
|
|
|
36
38
|
def init_optimzier(
|
|
37
39
|
self,
|
|
@@ -43,7 +45,8 @@ class AdamWConfig(OptimizerConfigBase):
|
|
|
43
45
|
betas=(self.beta1.value(), self.beta2.value()), # type: ignore
|
|
44
46
|
eps=self.eps.value(), # type: ignore
|
|
45
47
|
weight_decay=self.weight_decay.value(), # type: ignore
|
|
46
|
-
amsgrad=self.amsgrad.value() # type: ignore
|
|
48
|
+
amsgrad=self.amsgrad.value(), # type: ignore
|
|
49
|
+
fused=self.fused.value() or None, # type: ignore
|
|
47
50
|
)
|
|
48
51
|
|
|
49
52
|
|
|
@@ -5,6 +5,8 @@ from collections import defaultdict
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import random
|
|
8
|
+
import hashlib
|
|
9
|
+
import shutil
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
import json
|
|
10
12
|
from logging import Logger, getLogger
|
|
@@ -63,6 +65,88 @@ def _compile_kwargs(compile_mode: str) -> dict:
|
|
|
63
65
|
return {} if compile_mode == 'default' else {'mode': compile_mode}
|
|
64
66
|
|
|
65
67
|
|
|
68
|
+
class _TqdmLogHandler(logging.Handler):
|
|
69
|
+
"""Route logging through tqdm.write so log lines print above the progress
|
|
70
|
+
bar instead of tearing through it mid-refresh."""
|
|
71
|
+
|
|
72
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
73
|
+
try:
|
|
74
|
+
tqdm.write(self.format(record))
|
|
75
|
+
except Exception:
|
|
76
|
+
self.handleError(record)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _DevicePrefetcher:
|
|
80
|
+
"""Background fetch + side-stream H2D double buffering over a dataloader.
|
|
81
|
+
|
|
82
|
+
next() returns a device-resident batch whose copy has completed (event
|
|
83
|
+
sync on the compute stream, not the host)."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, loader, device, precision: str) -> None:
|
|
86
|
+
import threading, queue as _q
|
|
87
|
+
self._loader = loader
|
|
88
|
+
self._device = device
|
|
89
|
+
self._precision = precision
|
|
90
|
+
self._stream = torch.cuda.Stream(device=device)
|
|
91
|
+
self._queue: _q.Queue = _q.Queue(maxsize=2)
|
|
92
|
+
self._stop = False
|
|
93
|
+
self._error: BaseException | None = None
|
|
94
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
95
|
+
self._thread.start()
|
|
96
|
+
|
|
97
|
+
def _cast(self, v: Tensor) -> Tensor:
|
|
98
|
+
if torch.is_floating_point(v):
|
|
99
|
+
dt = {'fp32': torch.float32, 'fp16': torch.float16, 'bf16': torch.bfloat16}[self._precision]
|
|
100
|
+
if v.dtype != dt:
|
|
101
|
+
v = v.to(dt)
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
def _run(self) -> None:
|
|
105
|
+
try:
|
|
106
|
+
it = iter(self._loader)
|
|
107
|
+
while not self._stop:
|
|
108
|
+
try:
|
|
109
|
+
batch = next(it)
|
|
110
|
+
except StopIteration:
|
|
111
|
+
it = iter(self._loader)
|
|
112
|
+
batch = next(it)
|
|
113
|
+
with torch.cuda.stream(self._stream):
|
|
114
|
+
dev = {}
|
|
115
|
+
for k, v in batch.items():
|
|
116
|
+
if isinstance(v, Tensor):
|
|
117
|
+
v = self._cast(v)
|
|
118
|
+
dev[k] = v.pin_memory().to(self._device, non_blocking=True)
|
|
119
|
+
else:
|
|
120
|
+
dev[k] = v
|
|
121
|
+
ev = torch.cuda.Event()
|
|
122
|
+
ev.record(self._stream)
|
|
123
|
+
self._queue.put((dev, ev))
|
|
124
|
+
except BaseException as e: # never die silently: next() would block forever
|
|
125
|
+
self._error = e
|
|
126
|
+
self._queue.put(None)
|
|
127
|
+
|
|
128
|
+
def next(self):
|
|
129
|
+
item = self._queue.get()
|
|
130
|
+
if item is None:
|
|
131
|
+
raise RuntimeError('device prefetch thread died') from self._error
|
|
132
|
+
dev, ev = item
|
|
133
|
+
compute = torch.cuda.current_stream()
|
|
134
|
+
# order the compute stream behind the copy...
|
|
135
|
+
compute.wait_event(ev)
|
|
136
|
+
for v in dev.values():
|
|
137
|
+
if isinstance(v, Tensor):
|
|
138
|
+
# ...and keep the allocator from recycling these blocks (they
|
|
139
|
+
# belong to the side stream's pool) until the compute stream is
|
|
140
|
+
# actually done with them. Without this the next prefetched
|
|
141
|
+
# batch can be DMA'd into the buffer the current step is still
|
|
142
|
+
# reading — silent, intermittent input corruption.
|
|
143
|
+
v.record_stream(compute)
|
|
144
|
+
return dev
|
|
145
|
+
|
|
146
|
+
def close(self) -> None:
|
|
147
|
+
self._stop = True
|
|
148
|
+
|
|
149
|
+
|
|
66
150
|
class Wrapper(nn.Module):
|
|
67
151
|
|
|
68
152
|
def __init__(self, model: nn.Module, compile_mode: str = 'disable') -> None:
|
|
@@ -91,12 +175,17 @@ class Wrapper(nn.Module):
|
|
|
91
175
|
|
|
92
176
|
class DDPWraper(nn.Module):
|
|
93
177
|
|
|
94
|
-
def __init__(self, model: nn.Module, compile_mode: str = 'disable') -> None:
|
|
178
|
+
def __init__(self, model: nn.Module, compile_mode: str = 'disable', find_unused: bool = True, bf16_grads: bool = False) -> None:
|
|
95
179
|
super().__init__()
|
|
96
180
|
if get_world_size() > 1:
|
|
181
|
+
logger.critical(f'DDP options: find_unused={find_unused} bf16_grads={bf16_grads}')
|
|
97
182
|
self.model = DDP(
|
|
98
|
-
model.to(get_default_device()),
|
|
183
|
+
model.to(get_default_device()),
|
|
184
|
+
find_unused_parameters=find_unused,
|
|
99
185
|
)
|
|
186
|
+
if bf16_grads:
|
|
187
|
+
from torch.distributed.algorithms.ddp_comm_hooks import default_hooks
|
|
188
|
+
self.model.register_comm_hook(None, default_hooks.bf16_compress_hook)
|
|
100
189
|
if compile_mode != 'disable':
|
|
101
190
|
# compile the DDP module so Dynamo's DDPOptimizer splits the
|
|
102
191
|
# graph at gradient-bucket boundaries (keeps comm overlap).
|
|
@@ -181,6 +270,29 @@ class DDPRunnerConfig(RunerConf):
|
|
|
181
270
|
# execution path must be declared in the YAML, never auto-detected.
|
|
182
271
|
# NOTE 'disable' not 'off': YAML 1.1 parses bare off/on/yes/no as booleans
|
|
183
272
|
compile_mode = OptionArg(default='disable', options=['disable', 'default', 'reduce-overhead'])
|
|
273
|
+
# gather/log training metrics every N steps. At freq=1 the per-step cost is
|
|
274
|
+
# substantial for fast steps: a metrics all_gather + a dist_avg collective +
|
|
275
|
+
# ~6 wandb/logger calls + .item() syncs + a barrier — about half the step
|
|
276
|
+
# time for a 116M model. Logged values are point samples at the logged step.
|
|
277
|
+
metric_sync_freq = IntArg(1, min_value=1)
|
|
278
|
+
# DDP tuning. find_unused_parameters=1 (default, safest) supports tasks
|
|
279
|
+
# where a subset of parameters gets no grad on some steps (GAN/GRPO mode
|
|
280
|
+
# switching) at the cost of a per-step graph traversal and weaker comm
|
|
281
|
+
# overlap. Plain single-player training should set 0.
|
|
282
|
+
ddp_find_unused = BoolArg(default=True)
|
|
283
|
+
# compress gradient allreduce to bf16 (halves comm volume; fp32 master
|
|
284
|
+
# weights are untouched — only the wire format changes)
|
|
285
|
+
ddp_bf16_grads = BoolArg(default=False)
|
|
286
|
+
# double-buffered device prefetch: a background thread fetches batch N+1
|
|
287
|
+
# and copies it to the GPU on a side stream (pinned staging) while the GPU
|
|
288
|
+
# computes step N. Removes the serialized fetch+H2D segments from the step
|
|
289
|
+
# wall (measured ~40-120ms/step). Tensor-only batches.
|
|
290
|
+
device_prefetch = BoolArg(default=False)
|
|
291
|
+
# diagnostic: log per-step wall time split into fetch / h2d / traincall
|
|
292
|
+
# segments every metric_sync_freq steps. fetch = waiting on the loader,
|
|
293
|
+
# h2d = host-to-device copy (0 with device_prefetch), traincall = the
|
|
294
|
+
# task.train_step call including any GPU-queue wait it absorbs.
|
|
295
|
+
step_timing = BoolArg(default=False)
|
|
184
296
|
model_precision = OptionArg(options=['fp32', 'fp16', 'bf16'], default='fp32')
|
|
185
297
|
use_amp = BoolArg(default=False)
|
|
186
298
|
amp_precision = OptionArg(options=['fp16', 'bf16'], default='fp16')
|
|
@@ -245,9 +357,22 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
245
357
|
resume_path = config.resume_path.value()
|
|
246
358
|
assert resume_path is not None, 'resume_path is None'
|
|
247
359
|
self.load(resume_path)
|
|
248
|
-
|
|
360
|
+
# Derive the data seed deterministically. `hash()` on a str is
|
|
361
|
+
# SipHash-randomized per interpreter (PYTHONHASHSEED), so it produced a
|
|
362
|
+
# DIFFERENT seed on every rank and every restart: ranks disagreed on
|
|
363
|
+
# task sampling, and the run was irreproducible.
|
|
364
|
+
# The step is folded in only when the data stream cannot place itself:
|
|
365
|
+
# with restored shard cursors the row permutations must stay identical
|
|
366
|
+
# to the ones the cursors were recorded against.
|
|
367
|
+
_base = self.config.seed.value()
|
|
368
|
+
_stable = getattr(self, '_resume_data_states', None) is not None
|
|
369
|
+
_key = f'original_seed_{_base}' if _stable else f'original_seed_{_base}|step_{self.step}'
|
|
370
|
+
self.config.seed = self.config.seed.parse(
|
|
371
|
+
int.from_bytes(hashlib.sha256(_key.encode()).digest()[:4], 'big') & 0x7FFFFFFF
|
|
372
|
+
)
|
|
249
373
|
|
|
250
374
|
self.train_data_loaders = config.task_conf.get_train_dataloaders(
|
|
375
|
+
resume_states=getattr(self, '_resume_data_states', None),
|
|
251
376
|
batch_size=config.sub_batch_size.value(), # type: ignore
|
|
252
377
|
seed=config.seed.value(), # type: ignore
|
|
253
378
|
shuffle=config.shuffle_dataset.value(), # type: ignore
|
|
@@ -365,6 +490,41 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
365
490
|
else:
|
|
366
491
|
raise ValueError(f'dp_type must be either "ddp" or "fsdp2", got "{self.dp_type}" instead.')
|
|
367
492
|
|
|
493
|
+
def _collect_data_states(self) -> list | None:
|
|
494
|
+
"""Per-task shard-cursor tables, merged across ranks (rank0 only;
|
|
495
|
+
other ranks and loaders without cursor support return/contribute
|
|
496
|
+
None). A COLLECTIVE call when world_size > 1 — every rank must
|
|
497
|
+
reach it."""
|
|
498
|
+
per_task = [
|
|
499
|
+
loader.state_dict()
|
|
500
|
+
if loader is not None and hasattr(loader, 'state_dict') else None
|
|
501
|
+
for loader in self.train_data_loaders
|
|
502
|
+
]
|
|
503
|
+
# NOTE: the emptiness check must NOT short-circuit ahead of the
|
|
504
|
+
# all_gather_object below — that is a collective, and a rank returning
|
|
505
|
+
# early while the others enter it hangs the job until the NCCL timeout.
|
|
506
|
+
empty = not any(s is not None for s in per_task)
|
|
507
|
+
if empty and get_world_size() <= 1:
|
|
508
|
+
return None
|
|
509
|
+
if get_world_size() > 1:
|
|
510
|
+
gathered: list[Any] = [None] * get_world_size()
|
|
511
|
+
torch.distributed.all_gather_object(gathered, per_task)
|
|
512
|
+
if get_global_rank() != 0 or all(
|
|
513
|
+
not any(s is not None for s in (r or [])) for r in gathered
|
|
514
|
+
):
|
|
515
|
+
return None
|
|
516
|
+
merged: list = []
|
|
517
|
+
for task_idx in range(len(per_task)):
|
|
518
|
+
m: dict = {}
|
|
519
|
+
for rank_states in gathered:
|
|
520
|
+
state = rank_states[task_idx] if rank_states else None
|
|
521
|
+
if state:
|
|
522
|
+
for src, table in state.items():
|
|
523
|
+
m.setdefault(src, {}).update(table)
|
|
524
|
+
merged.append(m or None)
|
|
525
|
+
return merged
|
|
526
|
+
return per_task
|
|
527
|
+
|
|
368
528
|
def save(self, directory: str | os.PathLike, *args, **kwargs) -> None:
|
|
369
529
|
if isinstance(self.model, (DDPWraper, Wrapper)):
|
|
370
530
|
model = self.model.model.module
|
|
@@ -390,25 +550,38 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
390
550
|
else:
|
|
391
551
|
raise ValueError(f'Unknown model type {type(self.model)}')
|
|
392
552
|
|
|
393
|
-
|
|
394
|
-
path = Path(directory) / str(self.step)
|
|
395
|
-
os.makedirs(path, exist_ok=True)
|
|
396
|
-
self.model_loader.save_model(model, path) # type: ignore
|
|
397
|
-
|
|
398
|
-
optimizer_path = path / 'optimizer.pt'
|
|
399
|
-
torch.save(optim_states, optimizer_path)
|
|
400
|
-
|
|
401
|
-
scheduler_path = path / 'scheduler.pt'
|
|
402
|
-
torch.save(self.scheduler.state_dict(), scheduler_path)
|
|
553
|
+
data_states = self._collect_data_states()
|
|
403
554
|
|
|
404
|
-
|
|
405
|
-
|
|
555
|
+
if get_global_rank() == 0:
|
|
556
|
+
final = Path(directory) / str(self.step)
|
|
557
|
+
# Build the checkpoint in a staging directory and publish it with a
|
|
558
|
+
# single rename. A crash midway through the multi-GB optimizer save
|
|
559
|
+
# used to leave weights without runner.json, and `load()` treats a
|
|
560
|
+
# missing runner.json as "start from step 1" — silently restarting
|
|
561
|
+
# a long run (LR warmup and all) instead of resuming it.
|
|
562
|
+
staging = Path(directory) / f'.{self.step}.incomplete'
|
|
563
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
564
|
+
os.makedirs(staging, exist_ok=True)
|
|
565
|
+
|
|
566
|
+
self.model_loader.save_model(model, staging) # type: ignore
|
|
567
|
+
|
|
568
|
+
if data_states is not None:
|
|
569
|
+
with open(staging / 'data_state.json', 'w') as f:
|
|
570
|
+
json.dump(data_states, f)
|
|
571
|
+
|
|
572
|
+
torch.save(optim_states, staging / 'optimizer.pt')
|
|
573
|
+
torch.save(self.scheduler.state_dict(), staging / 'scheduler.pt')
|
|
574
|
+
|
|
575
|
+
with open(staging / 'runner.json', 'w') as f:
|
|
406
576
|
f.write(json.dumps({
|
|
407
577
|
'step': self.step + 1,
|
|
408
578
|
'epoch': self.epoch,
|
|
409
579
|
'config': self.config.to_dict(),
|
|
410
580
|
}, indent=4))
|
|
411
581
|
|
|
582
|
+
shutil.rmtree(final, ignore_errors=True) # re-save of the same step
|
|
583
|
+
os.replace(staging, final)
|
|
584
|
+
|
|
412
585
|
@property
|
|
413
586
|
def model(self) -> DDPWraper | FSDP2Wrapper:
|
|
414
587
|
model = getattr(self, '_model', None)
|
|
@@ -429,7 +602,11 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
429
602
|
self._model = FSDP2Wrapper(model, compile_mode=compile_mode)
|
|
430
603
|
else:
|
|
431
604
|
logger.critical(f'wrapping model with DDPWraper')
|
|
432
|
-
self._model = DDPWraper(
|
|
605
|
+
self._model = DDPWraper(
|
|
606
|
+
model, compile_mode=compile_mode,
|
|
607
|
+
find_unused=self.config.ddp_find_unused.value(),
|
|
608
|
+
bf16_grads=self.config.ddp_bf16_grads.value(),
|
|
609
|
+
)
|
|
433
610
|
assert self._model is not None
|
|
434
611
|
return self._model
|
|
435
612
|
|
|
@@ -559,11 +736,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
559
736
|
epochs = [loader.epoch for loader in self.train_data_loaders if loader is not None]
|
|
560
737
|
return max(epochs) + self.pre_epoch
|
|
561
738
|
|
|
562
|
-
def step_log(self, data: dict[str, Any]) -> None:
|
|
739
|
+
def step_log(self, data: dict[str, Any], console: bool = True) -> None:
|
|
563
740
|
self._wandb
|
|
564
741
|
if get_global_rank() != 0:
|
|
565
742
|
return
|
|
566
|
-
|
|
743
|
+
if console:
|
|
744
|
+
self.logger.critical(f'step:{self.step}\t{data}')
|
|
567
745
|
wandb.log(data, step=self.step)
|
|
568
746
|
|
|
569
747
|
def _one_train_step(self, **kwargs: Any) -> None:
|
|
@@ -577,6 +755,9 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
577
755
|
|
|
578
756
|
# calculate loss
|
|
579
757
|
running_loss: float = 0.0
|
|
758
|
+
running_loss_t = None
|
|
759
|
+
_msf = self.config.metric_sync_freq.value() or 1
|
|
760
|
+
log_this = (self.step % _msf == 0)
|
|
580
761
|
batch_datas: defaultdict[Hashable, List[float]] = defaultdict(list)
|
|
581
762
|
subbatch_result_lst: list[Batch] = []
|
|
582
763
|
for acc_idx in range(self.config._num_acc_steps):
|
|
@@ -604,13 +785,41 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
604
785
|
self.model.set_requires_gradient_sync(True, recurse=True)
|
|
605
786
|
|
|
606
787
|
# compute loss for each sub_batch
|
|
788
|
+
import time as _time
|
|
789
|
+
_tf0 = _time.perf_counter()
|
|
790
|
+
if self.config.device_prefetch.value():
|
|
791
|
+
if not hasattr(self, '_prefetchers'):
|
|
792
|
+
self._prefetchers = {}
|
|
793
|
+
pf = self._prefetchers.get(task_id)
|
|
794
|
+
if pf is None:
|
|
795
|
+
pf = _DevicePrefetcher(
|
|
796
|
+
self.train_data_loaders[task_id],
|
|
797
|
+
get_default_device(),
|
|
798
|
+
self.config.model_precision.value(),
|
|
799
|
+
)
|
|
800
|
+
self._prefetchers[task_id] = pf
|
|
801
|
+
_dev_batch = pf.next()
|
|
802
|
+
_tf1 = _tf2 = _time.perf_counter()
|
|
803
|
+
else:
|
|
804
|
+
_raw_batch = self._next_train_batch(task_id)
|
|
805
|
+
_tf1 = _time.perf_counter()
|
|
806
|
+
_dev_batch = self._batch_to_device(_raw_batch)
|
|
807
|
+
_tf2 = _time.perf_counter()
|
|
808
|
+
if self.config.step_timing.value():
|
|
809
|
+
if not hasattr(self, '_phase_acc'):
|
|
810
|
+
self._phase_acc = [0.0, 0.0, 0.0, 0]
|
|
811
|
+
self._phase_acc[0] += _tf1 - _tf0
|
|
812
|
+
self._phase_acc[1] += _tf2 - _tf1
|
|
813
|
+
_tf2b = _time.perf_counter()
|
|
607
814
|
subbatch_result = task.train_step(
|
|
608
815
|
model=self.model, # type: ignore
|
|
609
|
-
batch=
|
|
610
|
-
step=self.step,
|
|
816
|
+
batch=_dev_batch,
|
|
817
|
+
step=self.step,
|
|
611
818
|
device=get_local_rank(),
|
|
612
819
|
acc_step=acc_idx,
|
|
613
820
|
)
|
|
821
|
+
if self.config.step_timing.value():
|
|
822
|
+
self._phase_acc[2] += _time.perf_counter() - _tf2b
|
|
614
823
|
if isinstance(subbatch_result, torch.Tensor):
|
|
615
824
|
subbatch_result = {'loss': subbatch_result}
|
|
616
825
|
if 'loss' not in subbatch_result:
|
|
@@ -621,7 +830,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
621
830
|
subbatch_result_lst.append(subbatch_result) # type: ignore
|
|
622
831
|
assert isinstance(loss, Tensor)
|
|
623
832
|
loss = loss / self.config._num_acc_steps
|
|
624
|
-
|
|
833
|
+
running_loss_t = loss.detach() if running_loss_t is None else running_loss_t + loss.detach()
|
|
625
834
|
for k, v in subbatch_result.items():
|
|
626
835
|
if k == 'loss':
|
|
627
836
|
continue
|
|
@@ -635,30 +844,66 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
635
844
|
else:
|
|
636
845
|
loss.backward()
|
|
637
846
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
847
|
+
if log_this and getattr(self, '_phase_acc', None):
|
|
848
|
+
n = max(self._phase_acc[3], 1)
|
|
849
|
+
fetch_ms = self._phase_acc[0] / n * 1000
|
|
850
|
+
h2d_ms = self._phase_acc[1] / n * 1000
|
|
851
|
+
traincall_ms = self._phase_acc[2] / n * 1000
|
|
852
|
+
logger.critical(
|
|
853
|
+
f'[timing] fetch={fetch_ms:.1f}ms h2d={h2d_ms:.1f}ms '
|
|
854
|
+
f'traincall={traincall_ms:.1f}ms per-step (over {n} steps)')
|
|
855
|
+
# wandb: rank0's own segments plus the all-rank max of each — the
|
|
856
|
+
# max is the diagnostic one (in DDP the slowest rank paces the
|
|
857
|
+
# whole step, e.g. a data-starved rank shows up as fetch_ms_max)
|
|
858
|
+
t = torch.tensor([fetch_ms, h2d_ms, traincall_ms], device=get_default_device())
|
|
859
|
+
if torch.distributed.is_initialized():
|
|
860
|
+
torch.distributed.all_reduce(t, op=torch.distributed.ReduceOp.MAX)
|
|
861
|
+
mx = t.tolist()
|
|
862
|
+
self.step_log({
|
|
863
|
+
'timing/fetch_ms': fetch_ms,
|
|
864
|
+
'timing/h2d_ms': h2d_ms,
|
|
865
|
+
'timing/traincall_ms': traincall_ms,
|
|
866
|
+
'timing/fetch_ms_max': mx[0],
|
|
867
|
+
'timing/h2d_ms_max': mx[1],
|
|
868
|
+
'timing/traincall_ms_max': mx[2],
|
|
869
|
+
})
|
|
870
|
+
self._phase_acc = [0.0, 0.0, 0.0, 0]
|
|
871
|
+
if getattr(self, '_phase_acc', None) is not None:
|
|
872
|
+
self._phase_acc[3] += 1
|
|
873
|
+
if log_this:
|
|
874
|
+
running_loss = running_loss_t.item() if running_loss_t is not None else 0.0
|
|
875
|
+
running_loss = dist_avg(running_loss)
|
|
876
|
+
self.step_log({f'{task.__class__.__name__}/train/loss': running_loss})
|
|
877
|
+
self.step_log({'train/epoch': self.epoch})
|
|
878
|
+
if log_this:
|
|
879
|
+
for k, v in batch_datas.items():
|
|
880
|
+
try:
|
|
881
|
+
avg = sum(v) / len(v)
|
|
882
|
+
except:
|
|
883
|
+
continue
|
|
884
|
+
self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
|
|
885
|
+
# the sampled step is part of the label: loss refreshes every
|
|
886
|
+
# metric_sync_freq steps while the bar ticks every step, and an
|
|
887
|
+
# unlabeled value reads as a frozen metric
|
|
888
|
+
self._pbar_train.set_postfix_str(
|
|
889
|
+
f'loss={running_loss:.3g}@{self.step}', refresh=True
|
|
890
|
+
)
|
|
650
891
|
|
|
651
892
|
grad_norm_clip_val = self.config.grad_norm_clip.value()
|
|
652
893
|
if grad_norm_clip_val is not None:
|
|
653
894
|
if self.scaler is not None:
|
|
654
895
|
self.scaler.unscale_(self.optimizer)
|
|
655
|
-
|
|
896
|
+
norm_t = clip_grad_norm_(
|
|
656
897
|
parameters=self.model.parameters(), # type: ignore
|
|
657
898
|
max_norm=grad_norm_clip_val
|
|
658
|
-
)
|
|
659
|
-
|
|
660
|
-
|
|
899
|
+
)
|
|
900
|
+
if log_this:
|
|
901
|
+
norm = dist_avg(norm_t.item())
|
|
902
|
+
self.step_log({f'{task.__class__.__name__}/train/grad_norm': norm})
|
|
661
903
|
|
|
904
|
+
# pure host-side work — no .item() sync, no collective — so it runs and
|
|
905
|
+
# reports every step (wandb enqueue is async; console line only on
|
|
906
|
+
# log steps to keep the terminal readable)
|
|
662
907
|
num_hot_params = 0
|
|
663
908
|
num_freeze_params = 0
|
|
664
909
|
for param in self.model.parameters(): # type: ignore
|
|
@@ -670,13 +915,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
670
915
|
num_total_params = num_hot_params + num_freeze_params
|
|
671
916
|
if num_total_params == 0:
|
|
672
917
|
raise RuntimeError('The model contains no parameters.')
|
|
673
|
-
|
|
674
918
|
self.step_log({
|
|
675
919
|
f'{task.__class__.__name__}/train/num_hot_params': num_hot_params,
|
|
676
920
|
f'{task.__class__.__name__}/train/num_freeze_params': num_freeze_params,
|
|
677
921
|
f'{task.__class__.__name__}/train/num_total_params': num_total_params,
|
|
678
922
|
f'{task.__class__.__name__}/train/hot_ratio': num_hot_params / num_total_params,
|
|
679
|
-
})
|
|
923
|
+
}, console=log_this)
|
|
680
924
|
|
|
681
925
|
if self.scaler is not None:
|
|
682
926
|
self.scaler.step(self.optimizer)
|
|
@@ -688,21 +932,22 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
688
932
|
current_lr = current_lr[0]
|
|
689
933
|
self.step_log(
|
|
690
934
|
{f'{task.__class__.__name__}/train/learning_rate': current_lr}
|
|
691
|
-
)
|
|
935
|
+
) if log_this else None
|
|
692
936
|
self.scheduler.step()
|
|
693
937
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
for
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
938
|
+
if log_this:
|
|
939
|
+
other_step_results = defaultdict(list)
|
|
940
|
+
for r in subbatch_result_lst:
|
|
941
|
+
for k, v in r.items():
|
|
942
|
+
if isinstance(v, (float, int)):
|
|
943
|
+
other_step_results[k].append(float(v))
|
|
944
|
+
all_other_step_results = batch_all_gather(other_step_results)
|
|
945
|
+
for k, v in all_other_step_results.items():
|
|
946
|
+
try:
|
|
947
|
+
avg = sum(v) / len(v)
|
|
948
|
+
except:
|
|
949
|
+
continue
|
|
950
|
+
self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
|
|
706
951
|
|
|
707
952
|
save_step_freq = self.config.save_step_freq.value()
|
|
708
953
|
assert save_step_freq is not None
|
|
@@ -712,7 +957,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
712
957
|
assert ckpt_path is not None
|
|
713
958
|
self.save(ckpt_path)
|
|
714
959
|
self._pbar_train.set_description('model saved!', True)
|
|
715
|
-
|
|
960
|
+
torch.distributed.barrier() if get_world_size() > 1 else ...
|
|
716
961
|
|
|
717
962
|
eval_step_freq = self.config.eval_step_freq.value()
|
|
718
963
|
assert eval_step_freq is not None
|
|
@@ -726,6 +971,16 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
726
971
|
self.eval() # evaluate first
|
|
727
972
|
self._prepare_train()
|
|
728
973
|
|
|
974
|
+
# route log lines through tqdm.write for the training run so they
|
|
975
|
+
# print cleanly above the bar (plain StreamHandlers tear through it)
|
|
976
|
+
root_logger = logging.getLogger()
|
|
977
|
+
if not any(isinstance(h, _TqdmLogHandler) for h in root_logger.handlers):
|
|
978
|
+
tqdm_handler = _TqdmLogHandler()
|
|
979
|
+
tqdm_handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
|
|
980
|
+
for h in [x for x in root_logger.handlers if type(x) is logging.StreamHandler]:
|
|
981
|
+
root_logger.removeHandler(h)
|
|
982
|
+
root_logger.addHandler(tqdm_handler)
|
|
983
|
+
|
|
729
984
|
self._last_epoch = self.epoch
|
|
730
985
|
stop_metric = self.config.stop_by.value()
|
|
731
986
|
assert stop_metric in ('step', 'epoch')
|
|
@@ -753,6 +1008,8 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
753
1008
|
while not self._should_stop():
|
|
754
1009
|
self._one_train_step()
|
|
755
1010
|
if stop_metric == 'step':
|
|
1011
|
+
# per-step update is fine: tqdm only renders every mininterval
|
|
1012
|
+
# (0.1s); the call itself is just a counter bump
|
|
756
1013
|
self._pbar_train.update(1)
|
|
757
1014
|
elif stop_metric == 'epoch':
|
|
758
1015
|
current_epoch = self.epoch
|
|
@@ -941,16 +1198,29 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
941
1198
|
|
|
942
1199
|
optimizer_path = Path(directory) / 'optimizer.pt'
|
|
943
1200
|
if optimizer_path.exists():
|
|
944
|
-
self._optimizer_states = torch.load(optimizer_path)
|
|
1201
|
+
self._optimizer_states = torch.load(optimizer_path, map_location='cpu')
|
|
945
1202
|
else:
|
|
946
1203
|
logger.warning(f'optimizer.pt not found in {directory}, skip loading optimizer')
|
|
947
1204
|
|
|
948
1205
|
scheduler_path = Path(directory) / 'scheduler.pt'
|
|
949
1206
|
if scheduler_path.exists():
|
|
950
|
-
self._scheduler_states = torch.load(scheduler_path)
|
|
1207
|
+
self._scheduler_states = torch.load(scheduler_path, map_location='cpu')
|
|
951
1208
|
else:
|
|
952
1209
|
logger.warning(f'scheduler.pt not found in {directory}, skip loading scheduler')
|
|
953
1210
|
|
|
1211
|
+
data_state_path = Path(directory) / 'data_state.json'
|
|
1212
|
+
if data_state_path.exists():
|
|
1213
|
+
with open(data_state_path, 'r') as f:
|
|
1214
|
+
self._resume_data_states = json.load(f)
|
|
1215
|
+
n = sum(
|
|
1216
|
+
len(tab)
|
|
1217
|
+
for task_state in self._resume_data_states if task_state
|
|
1218
|
+
for tab in task_state.values()
|
|
1219
|
+
)
|
|
1220
|
+
logger.info(f'loaded data stream state ({n} shard cursors)')
|
|
1221
|
+
else:
|
|
1222
|
+
logger.info('no data_state.json in checkpoint — data stream restarts from scratch')
|
|
1223
|
+
|
|
954
1224
|
runner_path = Path(directory) / 'runner.json'
|
|
955
1225
|
if runner_path.exists():
|
|
956
1226
|
with open(runner_path, 'r') as f:
|
|
@@ -110,6 +110,7 @@ class TaskBase(Conf, SubclassTracer):
|
|
|
110
110
|
num_batch_workers: int = 4,
|
|
111
111
|
batch_queue_depth: int = 4,
|
|
112
112
|
batch_slot_mb: int = 128,
|
|
113
|
+
resume_state: dict | None = None,
|
|
113
114
|
) -> None | DataLoader | PyTorchDataLoader | BatchDataLoader | EmptyDataLoader:
|
|
114
115
|
if self.num_train_data_path.value() == 0:
|
|
115
116
|
return None
|
|
@@ -143,6 +144,9 @@ class TaskBase(Conf, SubclassTracer):
|
|
|
143
144
|
num_ranks=world_size,
|
|
144
145
|
rank_idx=rank,
|
|
145
146
|
worker_timeout=worker_timeout,
|
|
147
|
+
# only forward when present: lmfuser-data < 0.3.0 has no
|
|
148
|
+
# resume_state parameter
|
|
149
|
+
**({'resume_state': resume_state} if resume_state else {}),
|
|
146
150
|
)
|
|
147
151
|
elif dataloader_type == 'sharded':
|
|
148
152
|
self._train_dataloader = DataLoader(
|
|
@@ -430,6 +434,7 @@ class Tasks(Conf):
|
|
|
430
434
|
num_batch_workers: int = 4,
|
|
431
435
|
batch_queue_depth: int = 4,
|
|
432
436
|
batch_slot_mb: int = 128,
|
|
437
|
+
resume_states: list[dict | None] | None = None,
|
|
433
438
|
) -> list[DataLoader | None | PyTorchDataLoader | BatchDataLoader | EmptyDataLoader]:
|
|
434
439
|
return [
|
|
435
440
|
task.conf._get_train_dataloader(
|
|
@@ -447,8 +452,9 @@ class Tasks(Conf):
|
|
|
447
452
|
num_batch_workers=num_batch_workers,
|
|
448
453
|
batch_queue_depth=batch_queue_depth,
|
|
449
454
|
batch_slot_mb=batch_slot_mb,
|
|
455
|
+
resume_state=(resume_states[i] if resume_states else None),
|
|
450
456
|
)
|
|
451
|
-
for task in self.tasks
|
|
457
|
+
for i, task in enumerate(self.tasks)
|
|
452
458
|
]
|
|
453
459
|
|
|
454
460
|
def get_eval_dataloaders(
|
|
@@ -207,16 +207,11 @@ def batch_all_gather(batch: dict[str, Any]) -> dict[str, Any]:
|
|
|
207
207
|
Returns:
|
|
208
208
|
Dict[Dict[str, Union[Tensor, List[Any]]]]: 汇总后的batch
|
|
209
209
|
"""
|
|
210
|
-
logger.info(f'Begin all gather on rank {get_global_rank()}')
|
|
211
210
|
if not dist.is_initialized():
|
|
212
211
|
return batch
|
|
213
212
|
|
|
214
213
|
gathered = {}
|
|
215
214
|
for k, v_list in batch.items():
|
|
216
|
-
logger.info(f'Begin gather key {k} on rank {get_global_rank()}')
|
|
217
|
-
if isinstance(v_list, list):
|
|
218
|
-
logger.info(f'Key {k} with length {len(v_list)} on rank {get_global_rank()}')
|
|
219
|
-
logger.info(f'after barrier on rank {get_global_rank()}')
|
|
220
215
|
if isinstance(v_list, Tensor):
|
|
221
216
|
gathered[k] = tensor_all_gather(v_list)
|
|
222
217
|
else:
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Failure-mode tests for DDPRunner bookkeeping (0.4.1).
|
|
2
|
+
|
|
3
|
+
Run: python tests/test_runner_robustness.py
|
|
4
|
+
|
|
5
|
+
Covers issues found in review, all of which failed silently:
|
|
6
|
+
1. the data seed is deterministic — equal across ranks and across processes
|
|
7
|
+
2. the data seed is stable across a resume that restored data cursors
|
|
8
|
+
(the shard-cursor row permutations are derived from it)
|
|
9
|
+
3. checkpoints are published atomically — a crash mid-save never leaves a
|
|
10
|
+
directory that load() would treat as "start from step 1"
|
|
11
|
+
4. optimizer/scheduler state loads onto CPU, not every rank onto cuda:0
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import json
|
|
16
|
+
import hashlib
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _derive(base: int, step, stable: bool) -> int:
|
|
25
|
+
"""Mirror of the runner's seed derivation."""
|
|
26
|
+
key = f'original_seed_{base}' if stable else f'original_seed_{base}|step_{step}'
|
|
27
|
+
return int.from_bytes(hashlib.sha256(key.encode()).digest()[:4], 'big') & 0x7FFFFFFF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_seed_is_deterministic_across_processes() -> None:
|
|
31
|
+
"""`hash()` on a str is PYTHONHASHSEED-randomized: every rank used to get a
|
|
32
|
+
different seed, so ranks disagreed on task sampling and no run was
|
|
33
|
+
reproducible."""
|
|
34
|
+
snippet = (
|
|
35
|
+
'import hashlib;'
|
|
36
|
+
"k='original_seed_42|step_1';"
|
|
37
|
+
"print(int.from_bytes(hashlib.sha256(k.encode()).digest()[:4],'big') & 0x7FFFFFFF)"
|
|
38
|
+
)
|
|
39
|
+
seeds = {
|
|
40
|
+
subprocess.run([sys.executable, '-c', snippet], capture_output=True,
|
|
41
|
+
text=True).stdout.strip()
|
|
42
|
+
for _ in range(4)
|
|
43
|
+
}
|
|
44
|
+
assert len(seeds) == 1, f'seed differs across processes: {seeds}'
|
|
45
|
+
|
|
46
|
+
old = {
|
|
47
|
+
subprocess.run([sys.executable, '-c',
|
|
48
|
+
"print(hash('original_seed_42|step_1'))"],
|
|
49
|
+
capture_output=True, text=True).stdout.strip()
|
|
50
|
+
for _ in range(4)
|
|
51
|
+
}
|
|
52
|
+
assert len(old) > 1, 'expected the old hash() to be randomized — test is moot'
|
|
53
|
+
print(f'PASS 1: seed identical across processes ({seeds.pop()}); '
|
|
54
|
+
f'old hash() gave {len(old)} different values')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_seed_stable_when_cursors_restored() -> None:
|
|
58
|
+
"""Shard cursors index a permutation derived from the seed; folding the
|
|
59
|
+
step into it on resume would silently invalidate every cursor."""
|
|
60
|
+
fresh_step1 = _derive(42, 1, stable=False)
|
|
61
|
+
fresh_step9 = _derive(42, 900_000, stable=False)
|
|
62
|
+
assert fresh_step1 != fresh_step9, 'without cursors the seed should follow the step'
|
|
63
|
+
|
|
64
|
+
resumed_a = _derive(42, 1, stable=True)
|
|
65
|
+
resumed_b = _derive(42, 900_000, stable=True)
|
|
66
|
+
assert resumed_a == resumed_b, 'with cursors restored the seed must not move'
|
|
67
|
+
print('PASS 2: seed stable across a cursor-restoring resume, step-varying otherwise')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_checkpoint_is_atomic() -> None:
|
|
71
|
+
"""A checkpoint directory must never be visible while incomplete: load()
|
|
72
|
+
treats a missing runner.json as step 1 and silently restarts the run."""
|
|
73
|
+
src = open(os.path.join(os.path.dirname(__file__), '..', 'src', 'lmfuser',
|
|
74
|
+
'runners', 'ddp_runner.py')).read()
|
|
75
|
+
|
|
76
|
+
assert '.incomplete' in src, 'save() does not stage the checkpoint'
|
|
77
|
+
assert 'os.replace(staging, final)' in src, 'save() does not publish by rename'
|
|
78
|
+
# the publish must come after every writer
|
|
79
|
+
pub = src.index('os.replace(staging, final)')
|
|
80
|
+
for writer in ("save_model(model, staging)", "staging / 'optimizer.pt'",
|
|
81
|
+
"staging / 'scheduler.pt'", "staging / 'runner.json'"):
|
|
82
|
+
assert src.index(writer) < pub, f'{writer} runs after the rename'
|
|
83
|
+
|
|
84
|
+
# a staged directory is invisible to a "latest numeric checkpoint" scan,
|
|
85
|
+
# which is how supervisors pick what to resume from
|
|
86
|
+
tmp = tempfile.mkdtemp(prefix='ckpt_atomic_')
|
|
87
|
+
try:
|
|
88
|
+
os.makedirs(os.path.join(tmp, '.25000.incomplete'))
|
|
89
|
+
os.makedirs(os.path.join(tmp, '20000'))
|
|
90
|
+
numeric = [d for d in os.listdir(tmp) if d.isdigit()]
|
|
91
|
+
assert numeric == ['20000'], f'staging dir leaked into the scan: {numeric}'
|
|
92
|
+
finally:
|
|
93
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
94
|
+
print('PASS 3: checkpoint staged then published by rename; staging dir not resumable')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_state_loads_on_cpu() -> None:
|
|
98
|
+
"""Without map_location every rank deserializes rank0's CUDA optimizer
|
|
99
|
+
state onto cuda:0 — ~4GB x N ranks transiently, OOM on the larger models."""
|
|
100
|
+
src = open(os.path.join(os.path.dirname(__file__), '..', 'src', 'lmfuser',
|
|
101
|
+
'runners', 'ddp_runner.py')).read()
|
|
102
|
+
for name in ('optimizer_path', 'scheduler_path'):
|
|
103
|
+
idx = src.index(f'torch.load({name}')
|
|
104
|
+
call = src[idx:idx + 120]
|
|
105
|
+
assert "map_location='cpu'" in call, f'torch.load({name}) lacks map_location'
|
|
106
|
+
print('PASS 4: optimizer/scheduler state deserializes on CPU')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == '__main__':
|
|
110
|
+
test_seed_is_deterministic_across_processes()
|
|
111
|
+
test_seed_stable_when_cursors_restored()
|
|
112
|
+
test_checkpoint_is_atomic()
|
|
113
|
+
test_state_loads_on_cpu()
|
|
114
|
+
print('ALL PASS')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|