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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LMFuser
3
- Version: 0.3.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.2.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.3.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.2.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()), find_unused_parameters=True
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
- 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
+ )
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
- if get_global_rank() == 0:
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
- runner_path = path / 'runner.json'
405
- with open(runner_path, 'w') as f:
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(model, compile_mode=compile_mode)
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
- self.logger.critical(f'step:{self.step}\t{data}')
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=self._batch_to_device(self._next_train_batch(task_id)),
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
- running_loss += loss.item()
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
- running_loss = dist_avg(running_loss)
639
- self.step_log({f'{task.__class__.__name__}/train/loss': running_loss})
640
- self.step_log({'train/epoch': self.epoch})
641
- for k, v in batch_datas.items():
642
- try:
643
- avg = sum(v) / len(v)
644
- except:
645
- continue
646
- self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
647
- self._pbar_train.set_description(
648
- f'train loss: {running_loss:.3g}', refresh=True
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
- norm = clip_grad_norm_(
896
+ norm_t = clip_grad_norm_(
656
897
  parameters=self.model.parameters(), # type: ignore
657
898
  max_norm=grad_norm_clip_val
658
- ).item()
659
- norm = dist_avg(norm)
660
- self.step_log({f'{task.__class__.__name__}/train/grad_norm': norm})
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
- other_step_results = defaultdict(list)
695
- for r in subbatch_result_lst:
696
- for k, v in r.items():
697
- if isinstance(v, (float, int)):
698
- other_step_results[k].append(float(v))
699
- all_other_step_results = batch_all_gather(other_step_results)
700
- for k, v in all_other_step_results.items():
701
- try:
702
- avg = sum(v) / len(v)
703
- except:
704
- continue
705
- self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
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
- torch.distributed.barrier() if get_world_size() > 1 else ...
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