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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LMFuser
3
- Version: 0.4.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.3.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.4.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.3.0",
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
- it = iter(self._loader)
103
- while not self._stop:
104
- try:
105
- batch = next(it)
106
- except StopIteration:
107
- it = iter(self._loader)
108
- batch = next(it)
109
- with torch.cuda.stream(self._stream):
110
- dev = {}
111
- ok = True
112
- for k, v in batch.items():
113
- if isinstance(v, Tensor):
114
- v = self._cast(v)
115
- dev[k] = v.pin_memory().to(self._device, non_blocking=True)
116
- else:
117
- dev[k] = v
118
- ev = torch.cuda.Event()
119
- ev.record(self._stream)
120
- self._queue.put((dev, ev))
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
- dev, ev = self._queue.get()
124
- # make the COMPUTE stream wait for the copy — no host sync
125
- torch.cuda.current_stream().wait_event(ev)
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
- self.config.seed = self.config.seed.parse(hash(f'original_seed_{self.config.seed.value()}|step_{self.step}'))
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
- if not any(s is not None for s in per_task):
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
- path = Path(directory) / str(self.step)
521
- os.makedirs(path, exist_ok=True)
522
- self.model_loader.save_model(model, path) # type: ignore
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
- tmp_path = path / 'data_state.json.tmp'
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
- optimizer_path = path / 'optimizer.pt'
531
- torch.save(optim_states, optimizer_path)
572
+ torch.save(optim_states, staging / 'optimizer.pt')
573
+ torch.save(self.scheduler.state_dict(), staging / 'scheduler.pt')
532
574
 
533
- scheduler_path = path / 'scheduler.pt'
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