LMFuser 0.4.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.4.0 → lmfuser-0.4.1}/PKG-INFO +2 -2
- {lmfuser-0.4.0 → lmfuser-0.4.1}/pyproject.toml +2 -2
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/runners/ddp_runner.py +81 -40
- lmfuser-0.4.1/tests/test_runner_robustness.py +114 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/.github/workflows/python-publish.yml +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/.gitignore +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/.vscode/settings.json +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/LICENSE +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/README.md +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/__init__.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/model_loader.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/optimizers.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/runners/__init__.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/runners/runner.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/schedulers.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/task.py +0 -0
- {lmfuser-0.4.0 → lmfuser-0.4.1}/src/lmfuser/utils.py +0 -0
- {lmfuser-0.4.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.4.
|
|
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.3.
|
|
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.4.
|
|
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.3.
|
|
23
|
+
"LMFuser-data>=0.3.1",
|
|
24
24
|
"tqdm",
|
|
25
25
|
"wandb",
|
|
26
26
|
"HyperArgs>=0.1.3",
|
|
@@ -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
|
|
@@ -88,6 +90,7 @@ class _DevicePrefetcher:
|
|
|
88
90
|
self._stream = torch.cuda.Stream(device=device)
|
|
89
91
|
self._queue: _q.Queue = _q.Queue(maxsize=2)
|
|
90
92
|
self._stop = False
|
|
93
|
+
self._error: BaseException | None = None
|
|
91
94
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
92
95
|
self._thread.start()
|
|
93
96
|
|
|
@@ -99,30 +102,45 @@ class _DevicePrefetcher:
|
|
|
99
102
|
return v
|
|
100
103
|
|
|
101
104
|
def _run(self) -> None:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
121
127
|
|
|
122
128
|
def next(self):
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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)
|
|
126
144
|
return dev
|
|
127
145
|
|
|
128
146
|
def close(self) -> None:
|
|
@@ -339,7 +357,19 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
339
357
|
resume_path = config.resume_path.value()
|
|
340
358
|
assert resume_path is not None, 'resume_path is None'
|
|
341
359
|
self.load(resume_path)
|
|
342
|
-
|
|
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
|
+
)
|
|
343
373
|
|
|
344
374
|
self.train_data_loaders = config.task_conf.get_train_dataloaders(
|
|
345
375
|
resume_states=getattr(self, '_resume_data_states', None),
|
|
@@ -470,12 +500,18 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
470
500
|
if loader is not None and hasattr(loader, 'state_dict') else None
|
|
471
501
|
for loader in self.train_data_loaders
|
|
472
502
|
]
|
|
473
|
-
|
|
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:
|
|
474
508
|
return None
|
|
475
509
|
if get_world_size() > 1:
|
|
476
510
|
gathered: list[Any] = [None] * get_world_size()
|
|
477
511
|
torch.distributed.all_gather_object(gathered, per_task)
|
|
478
|
-
if get_global_rank() != 0
|
|
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
|
+
):
|
|
479
515
|
return None
|
|
480
516
|
merged: list = []
|
|
481
517
|
for task_idx in range(len(per_task)):
|
|
@@ -517,30 +553,35 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
517
553
|
data_states = self._collect_data_states()
|
|
518
554
|
|
|
519
555
|
if get_global_rank() == 0:
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
523
567
|
|
|
524
568
|
if data_states is not None:
|
|
525
|
-
|
|
526
|
-
with open(tmp_path, 'w') as f:
|
|
569
|
+
with open(staging / 'data_state.json', 'w') as f:
|
|
527
570
|
json.dump(data_states, f)
|
|
528
|
-
os.replace(tmp_path, path / 'data_state.json')
|
|
529
571
|
|
|
530
|
-
|
|
531
|
-
torch.save(
|
|
572
|
+
torch.save(optim_states, staging / 'optimizer.pt')
|
|
573
|
+
torch.save(self.scheduler.state_dict(), staging / 'scheduler.pt')
|
|
532
574
|
|
|
533
|
-
|
|
534
|
-
torch.save(self.scheduler.state_dict(), scheduler_path)
|
|
535
|
-
|
|
536
|
-
runner_path = path / 'runner.json'
|
|
537
|
-
with open(runner_path, 'w') as f:
|
|
575
|
+
with open(staging / 'runner.json', 'w') as f:
|
|
538
576
|
f.write(json.dumps({
|
|
539
577
|
'step': self.step + 1,
|
|
540
578
|
'epoch': self.epoch,
|
|
541
579
|
'config': self.config.to_dict(),
|
|
542
580
|
}, indent=4))
|
|
543
581
|
|
|
582
|
+
shutil.rmtree(final, ignore_errors=True) # re-save of the same step
|
|
583
|
+
os.replace(staging, final)
|
|
584
|
+
|
|
544
585
|
@property
|
|
545
586
|
def model(self) -> DDPWraper | FSDP2Wrapper:
|
|
546
587
|
model = getattr(self, '_model', None)
|
|
@@ -1157,13 +1198,13 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
1157
1198
|
|
|
1158
1199
|
optimizer_path = Path(directory) / 'optimizer.pt'
|
|
1159
1200
|
if optimizer_path.exists():
|
|
1160
|
-
self._optimizer_states = torch.load(optimizer_path)
|
|
1201
|
+
self._optimizer_states = torch.load(optimizer_path, map_location='cpu')
|
|
1161
1202
|
else:
|
|
1162
1203
|
logger.warning(f'optimizer.pt not found in {directory}, skip loading optimizer')
|
|
1163
1204
|
|
|
1164
1205
|
scheduler_path = Path(directory) / 'scheduler.pt'
|
|
1165
1206
|
if scheduler_path.exists():
|
|
1166
|
-
self._scheduler_states = torch.load(scheduler_path)
|
|
1207
|
+
self._scheduler_states = torch.load(scheduler_path, map_location='cpu')
|
|
1167
1208
|
else:
|
|
1168
1209
|
logger.warning(f'scheduler.pt not found in {directory}, skip loading scheduler')
|
|
1169
1210
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|