LMFuser 0.4.1__tar.gz → 0.4.3__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.
Files changed (28) hide show
  1. {lmfuser-0.4.1 → lmfuser-0.4.3}/PKG-INFO +2 -2
  2. {lmfuser-0.4.1 → lmfuser-0.4.3}/pyproject.toml +2 -2
  3. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/ddp_runner.py +312 -58
  4. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/task.py +16 -3
  5. lmfuser-0.4.3/src/lmfuser/utils.py +437 -0
  6. lmfuser-0.4.3/tests/_gather_worker.py +25 -0
  7. lmfuser-0.4.3/tests/_gather_worker_conflict.py +17 -0
  8. lmfuser-0.4.3/tests/_gather_worker_cpu.py +12 -0
  9. lmfuser-0.4.3/tests/_gather_worker_multi.py +37 -0
  10. lmfuser-0.4.3/tests/_gather_worker_spec.py +25 -0
  11. lmfuser-0.4.3/tests/_gather_worker_symmetry.py +155 -0
  12. lmfuser-0.4.3/tests/test_gather.py +106 -0
  13. lmfuser-0.4.3/tests/test_nan_grad_guard.py +170 -0
  14. lmfuser-0.4.3/tests/test_runner_robustness.py +245 -0
  15. lmfuser-0.4.1/src/lmfuser/utils.py +0 -248
  16. lmfuser-0.4.1/tests/test_runner_robustness.py +0 -114
  17. {lmfuser-0.4.1 → lmfuser-0.4.3}/.github/workflows/python-publish.yml +0 -0
  18. {lmfuser-0.4.1 → lmfuser-0.4.3}/.gitignore +0 -0
  19. {lmfuser-0.4.1 → lmfuser-0.4.3}/.vscode/settings.json +0 -0
  20. {lmfuser-0.4.1 → lmfuser-0.4.3}/LICENSE +0 -0
  21. {lmfuser-0.4.1 → lmfuser-0.4.3}/README.md +0 -0
  22. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/__init__.py +0 -0
  23. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/model_loader.py +0 -0
  24. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/optimizers.py +0 -0
  25. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/__init__.py +0 -0
  26. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/runner.py +0 -0
  27. {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/schedulers.py +0 -0
  28. {lmfuser-0.4.1 → lmfuser-0.4.3}/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.1
3
+ Version: 0.4.3
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.1
20
+ Requires-Dist: lmfuser-data>=0.3.2
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.1"
7
+ version = "0.4.3"
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.1",
23
+ "LMFuser-data>=0.3.2",
24
24
  "tqdm",
25
25
  "wandb",
26
26
  "HyperArgs>=0.1.3",
@@ -36,6 +36,12 @@ from torch.distributed.fsdp import FSDPModule, MixedPrecisionPolicy
36
36
  from tqdm import tqdm
37
37
  from hyperargs import Conf, StrArg, IntArg, FloatArg, OptionArg, BoolArg
38
38
  from lmfuser_data.interfaces import Batch
39
+ from lmfuser_data.utils import slowest_epoch
40
+ try:
41
+ from lmfuser_data import merge_cursors
42
+ except ImportError: # lmfuser-data < 0.3.7
43
+ def merge_cursors(into: dict, cursors: dict) -> None: # type: ignore[misc]
44
+ into.update(cursors)
39
45
  import wandb
40
46
  from wandb.wandb_run import Run
41
47
 
@@ -45,6 +51,7 @@ from ..utils import (
45
51
  get_local_rank,
46
52
  get_world_size,
47
53
  get_default_device,
54
+ torch_device,
48
55
  dist_init,
49
56
  dist_avg,
50
57
  batch_all_gather,
@@ -126,6 +133,8 @@ class _DevicePrefetcher:
126
133
  self._queue.put(None)
127
134
 
128
135
  def next(self):
136
+ if self._error is not None: # every later call, not just the first
137
+ raise RuntimeError('device prefetch thread died') from self._error
129
138
  item = self._queue.get()
130
139
  if item is None:
131
140
  raise RuntimeError('device prefetch thread died') from self._error
@@ -144,14 +153,42 @@ class _DevicePrefetcher:
144
153
  return dev
145
154
 
146
155
  def close(self) -> None:
156
+ # the thread parks in queue.put() on a full queue, so it never reaches
157
+ # the _stop check on its own — drain a slot to let it wake and exit
147
158
  self._stop = True
159
+ try:
160
+ while True:
161
+ self._queue.get_nowait()
162
+ except Exception:
163
+ pass
164
+ self._thread.join(timeout=5.0)
165
+
166
+
167
+ def _as_log_scalar(v: Any) -> float | None:
168
+ """A metric value reduced to a float for logging, or None if it is not a
169
+ scalar.
170
+
171
+ float() is what actually decides — it accepts python and numpy scalars,
172
+ 0-dim and single-element tensors, and rejects lists and multi-element
173
+ tensors — so attempt it rather than enumerate accepted types. The old
174
+ `isinstance(v, (float, int))` test silently dropped numpy.float32, numpy
175
+ ints, and 0-dim/1-element tensors from wandb, while numpy.float64 slipped
176
+ through only because it subclasses float. str/bytes are excluded so a
177
+ numeric-looking string is not quietly parsed into a metric.
178
+ """
179
+ if isinstance(v, (str, bytes)):
180
+ return None
181
+ try:
182
+ return float(v)
183
+ except (TypeError, ValueError):
184
+ return None
148
185
 
149
186
 
150
187
  class Wrapper(nn.Module):
151
188
 
152
189
  def __init__(self, model: nn.Module, compile_mode: str = 'disable') -> None:
153
190
  super().__init__()
154
- self.module = model.to(get_default_device())
191
+ self.module = model.to(torch_device())
155
192
  if compile_mode != 'disable':
156
193
  logger.critical(f'torch.compile enabled (mode={compile_mode})')
157
194
  self.forward = torch.compile(model.forward, **_compile_kwargs(compile_mode))
@@ -180,7 +217,7 @@ class DDPWraper(nn.Module):
180
217
  if get_world_size() > 1:
181
218
  logger.critical(f'DDP options: find_unused={find_unused} bf16_grads={bf16_grads}')
182
219
  self.model = DDP(
183
- model.to(get_default_device()),
220
+ model.to(torch_device()),
184
221
  find_unused_parameters=find_unused,
185
222
  )
186
223
  if bf16_grads:
@@ -236,8 +273,12 @@ class FSDP2Wrapper(nn.Module):
236
273
  except:
237
274
  return self.model.__getattribute__(name)
238
275
 
239
- def __call__(self, *args: Any, **kwds: Any) -> Any:
240
- return self.model(*args, **kwds) # type: ignore
276
+ # NOTE: no __call__ override. It used to return self.model(...), which
277
+ # bypassed forward() so the compiled callable built above was never
278
+ # invoked (compile_mode was a silent no-op under fsdp2, while still
279
+ # logging "torch.compile enabled") and any hook registered on the wrapper
280
+ # never fired. nn.Module.__call__ dispatches to forward() and is what the
281
+ # DDP wrapper already relies on.
241
282
 
242
283
 
243
284
  class DDPRunnerConfig(RunerConf):
@@ -256,6 +297,15 @@ class DDPRunnerConfig(RunerConf):
256
297
 
257
298
  grad_norm_clip = FloatArg(None, min_value=0.0, allow_none=True)
258
299
 
300
+ # When an optimizer step's gradients hold a NaN or Inf, skip that step
301
+ # instead of letting it reach the optimizer. A single non-finite gradient
302
+ # otherwise poisons the (fused) moment buffers permanently, so every step
303
+ # after it is NaN — which is exactly how a healthy run silently turns into
304
+ # `loss=nan` forever. CAVEAT: on the fp16 path GradScaler already skips
305
+ # non-finite steps as part of loss scaling and this switch has no effect
306
+ # there; it governs the bf16 / fp32 (no-scaler) path.
307
+ skip_nan_and_inf_grad = BoolArg(default=True)
308
+
259
309
  task_conf = Tasks()
260
310
 
261
311
  optimizer: OptimizerConfig = OptimizerConfig()
@@ -316,14 +366,18 @@ class DDPRunnerConfig(RunerConf):
316
366
 
317
367
  @property
318
368
  def _default_precision(self) -> torch.dtype:
319
- if self.model_precision == 'fp32':
320
- return torch.float32
321
- elif self.model_precision == 'fp16':
322
- return torch.float16
323
- elif self.model_precision == 'bf16':
324
- return torch.bfloat16
325
- else:
326
- raise ValueError(self.model_precision)
369
+ # `model_precision` is an OptionArg, and Arg defines no __eq__, so
370
+ # comparing it to a str was False on every branch and this property
371
+ # raised for every valid value. Read the value, as _num_acc_steps does.
372
+ precision = self.model_precision.value()
373
+ try:
374
+ return {
375
+ 'fp32': torch.float32,
376
+ 'fp16': torch.float16,
377
+ 'bf16': torch.bfloat16,
378
+ }[precision]
379
+ except KeyError:
380
+ raise ValueError(f'Unknown model precision "{precision}"')
327
381
 
328
382
  @property
329
383
  def _num_acc_steps(self) -> int:
@@ -340,6 +394,12 @@ class DDPRunnerConfig(RunerConf):
340
394
 
341
395
  class DDPRunner(Runner[DDPRunnerConfig]):
342
396
 
397
+ # Consecutive non-finite optimizer steps tolerated before the NaN/Inf grad
398
+ # guard aborts the run. The guard keeps the model from being poisoned, so a
399
+ # brief spike is skipped and forgotten; a sustained streak means the run has
400
+ # genuinely diverged and should fail loud rather than spin at a frozen model.
401
+ _NONFINITE_STREAK_ABORT = 100
402
+
343
403
  def __init__(self, config: DDPRunnerConfig, *args, **kwargs) -> None:
344
404
  super().__init__(config, *args, **kwargs)
345
405
  if get_world_size() > 1:
@@ -357,24 +417,29 @@ class DDPRunner(Runner[DDPRunnerConfig]):
357
417
  resume_path = config.resume_path.value()
358
418
  assert resume_path is not None, 'resume_path is None'
359
419
  self.load(resume_path)
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
420
+ # Data seed, derived deterministically and ONCE per configured seed.
421
+ #
422
+ # Two properties this must have, both learned the hard way:
423
+ # * identical on every rank `hash()` on a str is PYTHONHASHSEED-
424
+ # randomized, so it gave each rank a different seed (ranks then
425
+ # disagreed on task sampling) and made runs irreproducible;
426
+ # * identical across a resume — shard cursors store a row index into
427
+ # a permutation seeded by this value, so a seed that moves between
428
+ # a run and its own restart silently invalidates every cursor.
429
+ # It is therefore a pure function of config.seed: no step, no rank.
430
+ # Kept separate from config.seed so what lands in runner.json stays the
431
+ # value the user configured.
432
+ self.data_seed = (
433
+ int.from_bytes(
434
+ hashlib.sha256(f'original_seed_{self.config.seed.value()}'.encode()).digest()[:4],
435
+ 'big',
436
+ ) & 0x7FFFFFFF
372
437
  )
373
438
 
374
439
  self.train_data_loaders = config.task_conf.get_train_dataloaders(
375
440
  resume_states=getattr(self, '_resume_data_states', None),
376
441
  batch_size=config.sub_batch_size.value(), # type: ignore
377
- seed=config.seed.value(), # type: ignore
442
+ seed=self.data_seed, # type: ignore
378
443
  shuffle=config.shuffle_dataset.value(), # type: ignore
379
444
  prefetch_factor=config.row_prefetch.value(), # type: ignore
380
445
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -391,7 +456,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
391
456
 
392
457
  self.eval_data_loaders = config.task_conf.get_eval_dataloaders(
393
458
  batch_size=config.sub_batch_size.value(), # type: ignore
394
- seed=config.seed.value(), # type: ignore
459
+ seed=self.data_seed, # type: ignore
395
460
  shuffle=config.shuffle_dataset.value(), # type: ignore
396
461
  prefetch_factor=config.row_prefetch.value(), # type: ignore
397
462
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -405,7 +470,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
405
470
 
406
471
  self.test_data_loaders = config.task_conf.get_test_dataloaders(
407
472
  batch_size=config.sub_batch_size.value(), # type: ignore
408
- seed=config.seed.value(), # type: ignore
473
+ seed=self.data_seed, # type: ignore
409
474
  shuffle=config.shuffle_dataset.value(), # type: ignore
410
475
  prefetch_factor=config.row_prefetch.value(), # type: ignore
411
476
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -451,9 +516,16 @@ class DDPRunner(Runner[DDPRunnerConfig]):
451
516
  You can modify this fuction to control the task scheduler.
452
517
  Be sure that all ranks are selecting the same task at the same time.
453
518
  '''
454
- return self.task_rand_g.choices(
519
+ # Return the task's own index, not its position in train_task_idxs:
520
+ # callers use this to address self.tasks / self.train_data_loaders,
521
+ # which are indexed globally. The two coincide only when the trainable
522
+ # tasks happen to come first — with an eval-only task before a
523
+ # trainable one this trained the wrong task and then crashed on its
524
+ # missing dataloader.
525
+ position = self.task_rand_g.choices(
455
526
  list(range(len(self.train_task_idxs))), weights=self.train_task_weights, k=1
456
527
  )[0]
528
+ return self.train_task_idxs[position]
457
529
 
458
530
  def sample_eval_task_id(self) -> int:
459
531
  '''
@@ -461,9 +533,10 @@ class DDPRunner(Runner[DDPRunnerConfig]):
461
533
  You can modify this fuction to control the task scheduler.
462
534
  Be sure that all ranks are selecting the same task at the same time.
463
535
  '''
464
- return self.task_rand_g.choices(
536
+ position = self.task_rand_g.choices(
465
537
  list(range(len(self.eval_task_idxs))), weights=self.eval_task_weights, k=1
466
538
  )[0]
539
+ return self.eval_task_idxs[position]
467
540
 
468
541
  @property
469
542
  def fsdp_kwargs(self) -> dict:
@@ -520,7 +593,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
520
593
  state = rank_states[task_idx] if rank_states else None
521
594
  if state:
522
595
  for src, table in state.items():
523
- m.setdefault(src, {}).update(table)
596
+ # the loader's own rule, not dict.update: ranks own
597
+ # disjoint shards except on the round-robin
598
+ # fallback, where several read the same one and
599
+ # last-writer-wins silently rewinds whichever of
600
+ # them was enumerated first
601
+ merge_cursors(m.setdefault(src, {}), table)
524
602
  merged.append(m or None)
525
603
  return merged
526
604
  return per_task
@@ -539,8 +617,13 @@ class DDPRunner(Runner[DDPRunnerConfig]):
539
617
  model = self.model_loader.load_model()
540
618
  model.load_state_dict(full_states)
541
619
 
620
+ # the INNER model, matching set_optimizer_state_dict on the resume
621
+ # path: passing the wrapper prefixes every parameter FQN with
622
+ # "model.", and the two sides then disagree — every fsdp2 resume
623
+ # died with "Missing optimizer state for parameter ...", and
624
+ # strict=False silently restored nothing at all
542
625
  optim_states = get_optimizer_state_dict(
543
- model=self.model, # type: ignore
626
+ model=self.model.model, # type: ignore
544
627
  optimizers=self.optimizer,
545
628
  options=StateDictOptions(
546
629
  full_state_dict=True,
@@ -639,7 +722,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
639
722
  else:
640
723
  raise ValueError(f'Unknown model precision "{precision}"')
641
724
  assert isinstance(batch, dict)
642
- batch[key] = v.to(get_default_device()) if v.get_device() != get_default_device() else v
725
+ batch[key] = v.to(torch_device()) if v.get_device() != get_default_device() else v
643
726
  return batch
644
727
 
645
728
  def _prepare_train(
@@ -661,7 +744,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
661
744
  for state in self.optimizer.state.values():
662
745
  for k, v in state.items():
663
746
  if torch.is_tensor(v):
664
- state[k] = v.to(get_default_device(), non_blocking=True)
747
+ state[k] = v.to(torch_device(), non_blocking=True)
665
748
  elif isinstance(self.model, FSDP2Wrapper):
666
749
  set_optimizer_state_dict(
667
750
  model=self.model.model, # type: ignore
@@ -701,6 +784,11 @@ class DDPRunner(Runner[DDPRunnerConfig]):
701
784
  else:
702
785
  self.scaler = scaler
703
786
 
787
+ # Count of consecutive non-finite optimizer steps, kept on-device so the
788
+ # NaN/Inf grad guard never has to sync to update it. Created lazily on
789
+ # first use (we need the grads' device). See _one_train_step.
790
+ self._nf_streak: Union[Tensor, None] = None
791
+
704
792
  def _next_train_batch(self, task_idx: int) -> Batch:
705
793
  if self.train_data_loaders[task_idx] is None:
706
794
  raise ValueError(f'No train dataloader for task {task_idx}')
@@ -733,8 +821,47 @@ class DDPRunner(Runner[DDPRunnerConfig]):
733
821
 
734
822
  @property
735
823
  def epoch(self) -> int:
736
- epochs = [loader.epoch for loader in self.train_data_loaders if loader is not None]
737
- return max(epochs) + self.pre_epoch
824
+ """Completed epochs = the SLOWEST task's. See `slowest_epoch`.
825
+
826
+ `max` let the fastest task end the epoch for the whole run, so with
827
+ tasks of different sizes `stop_by: epoch` stopped while the largest
828
+ task had been round only partway. A task with weight 0.0 is never
829
+ sampled and is excluded so it cannot stall the run instead.
830
+ """
831
+ # Index by TASK id, not by position: train_data_loaders is per task
832
+ # and holds None for eval-only tasks, while train_task_weights is
833
+ # packed to the train tasks only. Zipping them pairs a loader with
834
+ # another task's weight as soon as any task is eval-only.
835
+ pairs = [
836
+ (self.train_data_loaders[idx].epoch, self.task_weights[idx]) # type: ignore
837
+ for idx in self.train_task_idxs
838
+ ]
839
+ if not pairs:
840
+ return self.pre_epoch
841
+ epochs, weights = zip(*pairs)
842
+ return slowest_epoch(list(epochs), list(weights)) + self.pre_epoch
843
+
844
+ def _pbar_status(self, **fields: Any) -> None:
845
+ """Update named markers shown after the progress bar.
846
+
847
+ Everything here is a point sample of something that does not change
848
+ every step, so each marker carries the step it refers to. Pass None to
849
+ drop a marker. (set_description would put the text in front of the bar
850
+ and leave it there — "model saved!" fronted the bar for the 25k steps
851
+ until the next save.)
852
+ """
853
+ if not hasattr(self, '_pbar_fields'):
854
+ self._pbar_fields: dict[str, str] = {}
855
+ for key, value in fields.items():
856
+ if value is None:
857
+ self._pbar_fields.pop(key, None)
858
+ else:
859
+ self._pbar_fields[key] = value
860
+ if getattr(self, '_pbar_train', None) is not None:
861
+ self._pbar_train.set_postfix_str(
862
+ ' '.join(f'{k}={v}' for k, v in self._pbar_fields.items()),
863
+ refresh=True,
864
+ )
738
865
 
739
866
  def step_log(self, data: dict[str, Any], console: bool = True) -> None:
740
867
  self._wandb
@@ -834,10 +961,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
834
961
  for k, v in subbatch_result.items():
835
962
  if k == 'loss':
836
963
  continue
837
- if isinstance(v, (float, int)):
838
- batch_datas[k].append(float(v))
839
- elif isinstance(v, (list)) and len(v) > 0 and isinstance(v[0], (float, int)):
840
- batch_datas[k].extend([float(i) for i in v]) # type: ignore
964
+ scalar = _as_log_scalar(v)
965
+ if scalar is not None:
966
+ batch_datas[k].append(scalar)
967
+ elif isinstance(v, (list, tuple)) and len(v) > 0:
968
+ floats = [s for s in (_as_log_scalar(i) for i in v)
969
+ if s is not None]
970
+ # all-or-nothing: a partially-convertible list is a type
971
+ # confusion, not a metric — logging half of it silently
972
+ # would be worse than dropping it.
973
+ if len(floats) == len(v):
974
+ batch_datas[k].extend(floats)
841
975
 
842
976
  if self.scaler is not None:
843
977
  self.scaler.scale(loss).backward()
@@ -855,7 +989,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
855
989
  # wandb: rank0's own segments plus the all-rank max of each — the
856
990
  # max is the diagnostic one (in DDP the slowest rank paces the
857
991
  # 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())
992
+ t = torch.tensor([fetch_ms, h2d_ms, traincall_ms], device=torch_device())
859
993
  if torch.distributed.is_initialized():
860
994
  torch.distributed.all_reduce(t, op=torch.distributed.ReduceOp.MAX)
861
995
  mx = t.tolist()
@@ -885,11 +1019,10 @@ class DDPRunner(Runner[DDPRunnerConfig]):
885
1019
  # the sampled step is part of the label: loss refreshes every
886
1020
  # metric_sync_freq steps while the bar ticks every step, and an
887
1021
  # 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
- )
1022
+ self._pbar_status(loss=f'{running_loss:.3g}@{self.step}')
891
1023
 
892
1024
  grad_norm_clip_val = self.config.grad_norm_clip.value()
1025
+ norm_t = None
893
1026
  if grad_norm_clip_val is not None:
894
1027
  if self.scaler is not None:
895
1028
  self.scaler.unscale_(self.optimizer)
@@ -922,6 +1055,60 @@ class DDPRunner(Runner[DDPRunnerConfig]):
922
1055
  f'{task.__class__.__name__}/train/hot_ratio': num_hot_params / num_total_params,
923
1056
  }, console=log_this)
924
1057
 
1058
+ # --- NaN/Inf gradient guard (no-scaler path only) ---------------------
1059
+ # The fp16 GradScaler already skips non-finite steps as part of loss
1060
+ # scaling; this covers the bf16 / fp32 path, which has no scaler and
1061
+ # would otherwise step straight into the optimizer and poison its state.
1062
+ if self.config.skip_nan_and_inf_grad.value() and self.scaler is None:
1063
+ if norm_t is None:
1064
+ # Clipping was off, so nothing has reduced the grads yet. One
1065
+ # foreach norm gives a non-finite signal without a host sync.
1066
+ grads = [p.grad for p in self.model.parameters()
1067
+ if p.grad is not None]
1068
+ norm_t = (torch.stack(torch._foreach_norm(grads)) if grads
1069
+ else torch.zeros(1, device=torch_device()))
1070
+ # A single GPU bool; `.all()` unifies the scalar (clipped) and the
1071
+ # per-tensor (unclipped) cases. No `.item()`, so no per-step sync.
1072
+ nonfinite = ~torch.isfinite(norm_t).all()
1073
+ if getattr(self.optimizer, '_step_supports_amp_scaling', False):
1074
+ # fused / amp-aware optimizer: hand it the found_inf flag and it
1075
+ # no-ops the update inside the kernel — the moment buffers are
1076
+ # never touched. This is exactly what GradScaler does for fp16.
1077
+ self.optimizer.grad_scale = None
1078
+ self.optimizer.found_inf = nonfinite.to(torch.float32)
1079
+ else:
1080
+ # fallback for optimizers without the found_inf channel (foreach
1081
+ # impls, Adadelta, fused=False): scrub the grads so a non-finite
1082
+ # step becomes a no-op. Done UNCONDITIONALLY — `if nonfinite:`
1083
+ # would read the GPU flag and reintroduce a per-step host sync,
1084
+ # the very thing this design avoids; nan_to_num is a passthrough
1085
+ # on the finite 99.99% of steps. torch has no
1086
+ # _foreach_nan_to_num_, hence the per-tensor loop.
1087
+ for param in self.model.parameters():
1088
+ if param.grad is not None:
1089
+ torch.nan_to_num_(param.grad, nan=0.0, posinf=0.0,
1090
+ neginf=0.0)
1091
+ # Consecutive non-finite steps, accumulated on-device. Read only on
1092
+ # log steps (which already sync), so persistent divergence fails
1093
+ # loud instead of the run silently spinning at a frozen model.
1094
+ if self._nf_streak is None:
1095
+ self._nf_streak = torch.zeros((), device=nonfinite.device,
1096
+ dtype=torch.long)
1097
+ self._nf_streak = torch.where(
1098
+ nonfinite, self._nf_streak + 1,
1099
+ torch.zeros_like(self._nf_streak))
1100
+ if log_this:
1101
+ streak = int(self._nf_streak.item())
1102
+ self.step_log({
1103
+ f'{task.__class__.__name__}/train/nonfinite_grad_streak':
1104
+ streak})
1105
+ if streak >= self._NONFINITE_STREAK_ABORT:
1106
+ raise RuntimeError(
1107
+ f'gradients were non-finite for {streak} consecutive '
1108
+ f'optimizer steps — training has diverged. '
1109
+ f'skip_nan_and_inf_grad kept the optimizer from being '
1110
+ f'poisoned, but the model is no longer learning.')
1111
+
925
1112
  if self.scaler is not None:
926
1113
  self.scaler.step(self.optimizer)
927
1114
  self.scaler.update()
@@ -939,8 +1126,9 @@ class DDPRunner(Runner[DDPRunnerConfig]):
939
1126
  other_step_results = defaultdict(list)
940
1127
  for r in subbatch_result_lst:
941
1128
  for k, v in r.items():
942
- if isinstance(v, (float, int)):
943
- other_step_results[k].append(float(v))
1129
+ scalar = _as_log_scalar(v)
1130
+ if scalar is not None:
1131
+ other_step_results[k].append(scalar)
944
1132
  all_other_step_results = batch_all_gather(other_step_results)
945
1133
  for k, v in all_other_step_results.items():
946
1134
  try:
@@ -952,11 +1140,14 @@ class DDPRunner(Runner[DDPRunnerConfig]):
952
1140
  save_step_freq = self.config.save_step_freq.value()
953
1141
  assert save_step_freq is not None
954
1142
  if self.step % save_step_freq == 0:
955
- self._pbar_train.set_description('begin to save the model', True)
1143
+ self._pbar_status(saving=str(self.step))
956
1144
  ckpt_path = self.config.checkpoint_directory.value()
957
1145
  assert ckpt_path is not None
958
1146
  self.save(ckpt_path)
959
- self._pbar_train.set_description('model saved!', True)
1147
+ # replaces the "saving" marker rather than adding to it, and stays
1148
+ # labelled with the step: set_description used to leave "model
1149
+ # saved!" fronting the bar for the next 25k steps
1150
+ self._pbar_status(saving=None, saved=str(self.step))
960
1151
  torch.distributed.barrier() if get_world_size() > 1 else ...
961
1152
 
962
1153
  eval_step_freq = self.config.eval_step_freq.value()
@@ -1035,7 +1226,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1035
1226
  if get_world_size() > 1:
1036
1227
  if isinstance(self.model, (DDPWraper, Wrapper)):
1037
1228
  stack.enter_context(self.model.model.no_sync())
1038
- elif isinstance(self.model, FSDPModule):
1229
+ elif isinstance(self.model, FSDP2Wrapper):
1039
1230
  self.model.set_requires_gradient_sync(False, recurse=True)
1040
1231
  for batch in tqdm(
1041
1232
  dataloader,
@@ -1055,8 +1246,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1055
1246
  batch_list.append(result)
1056
1247
 
1057
1248
  if len(batch_list) == 0:
1058
- raise RuntimeError(f'No eval data found in rank {get_global_rank()} '
1059
- f'for task {task.__class__.__name__}')
1249
+ # A rank with an empty slice must NOT raise here: every other
1250
+ # rank is heading into batch_all_gather below, and a rank that
1251
+ # leaves instead of entering it hangs them all — under NCCL
1252
+ # until the watchdog fires, or forever. Contribute nothing and
1253
+ # let the collective complete; an empty result surfaces as a
1254
+ # metric error afterwards, on every rank at once.
1255
+ logger.warning(
1256
+ f'no eval data in rank {get_global_rank()} for task '
1257
+ f'{task.__class__.__name__}; contributing nothing to the gather'
1258
+ )
1259
+ batch_list = [{}]
1060
1260
  cat_result = batch_list[0]
1061
1261
  for batch in batch_list[1:]:
1062
1262
  for k, v in batch.items():
@@ -1070,8 +1270,14 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1070
1270
 
1071
1271
  if task.__class__.__name__ not in self._all_eval_results:
1072
1272
  self._all_eval_results[task.__class__.__name__] = []
1073
- self._all_eval_results[task.__class__.__name__].append({'step': self.step, 'metrics': metrics})
1074
- if isinstance(self.model, FSDPModule):
1273
+ # `saved` lets checkpoint selection tell an eval that can be reloaded
1274
+ # from one that cannot: eval_step_freq and save_step_freq are
1275
+ # independent, so most evals happen at steps with nothing on disk.
1276
+ ckpt_dir = self.config.checkpoint_directory.value()
1277
+ saved = bool(ckpt_dir) and (Path(str(ckpt_dir)) / str(self.step)).is_dir()
1278
+ self._all_eval_results[task.__class__.__name__].append(
1279
+ {'step': self.step, 'metrics': metrics, 'saved': saved})
1280
+ if isinstance(self.model, FSDP2Wrapper):
1075
1281
  self.model.set_requires_gradient_sync(True, recurse=True)
1076
1282
 
1077
1283
  def _test_one_task(self, task_id: int, **kwargs: Any) -> None:
@@ -1087,7 +1293,35 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1087
1293
  if test_model_path is not None:
1088
1294
  if isinstance(test_model_path, int):
1089
1295
  test_model_path = Path(ckpt_path) / str(test_model_path)
1090
-
1296
+
1297
+ # A task picks the best step by DEV METRIC, but eval runs on its own
1298
+ # schedule and most eval steps have no checkpoint on disk. Loading
1299
+ # a path that isn't there kills the run after all the training is
1300
+ # already paid for, so fall back to the best step that was saved.
1301
+ if not Path(test_model_path).is_dir():
1302
+ results = self._all_eval_results.get(task.__class__.__name__, [])
1303
+ saved_only = [r for r in results if r.get('saved')]
1304
+ fallback = task.set_test_model_path(ckpt_path, {
1305
+ task.__class__.__name__: saved_only}) if saved_only else None
1306
+ if isinstance(fallback, int):
1307
+ fallback = Path(ckpt_path) / str(fallback)
1308
+ if fallback is not None and Path(fallback).is_dir():
1309
+ logger.warning(
1310
+ f'best dev step has no checkpoint on disk '
1311
+ f'({test_model_path}) — eval_step_freq and '
1312
+ f'save_step_freq disagree. Testing the best SAVED step '
1313
+ f'instead: {fallback}'
1314
+ )
1315
+ test_model_path = fallback
1316
+ else:
1317
+ logger.warning(
1318
+ f'best dev step has no checkpoint on disk '
1319
+ f'({test_model_path}) and no evaluated step was saved; '
1320
+ f'testing the in-memory model instead'
1321
+ )
1322
+ test_model_path = None
1323
+
1324
+ if test_model_path is not None:
1091
1325
  self.model_loader.model_path = test_model_path
1092
1326
  self._model = None
1093
1327
 
@@ -1098,7 +1332,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1098
1332
  if get_world_size() > 1:
1099
1333
  if isinstance(self.model, (DDPWraper, Wrapper)):
1100
1334
  stack.enter_context(self.model.model.no_sync())
1101
- elif isinstance(self.model, FSDPModule):
1335
+ elif isinstance(self.model, FSDP2Wrapper):
1102
1336
  self.model.set_requires_gradient_sync(False, recurse=True)
1103
1337
  for batch in tqdm(
1104
1338
  dataloader,
@@ -1118,8 +1352,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1118
1352
  batch_list.append(result)
1119
1353
 
1120
1354
  if len(batch_list) == 0:
1121
- raise RuntimeError(f'No test data found in rank {get_global_rank()} '
1122
- f'for task {task.__class__.__name__}')
1355
+ # A rank with an empty slice must NOT raise here: every other
1356
+ # rank is heading into batch_all_gather below, and a rank that
1357
+ # leaves instead of entering it hangs them all — under NCCL
1358
+ # until the watchdog fires, or forever. Contribute nothing and
1359
+ # let the collective complete; an empty result surfaces as a
1360
+ # metric error afterwards, on every rank at once.
1361
+ logger.warning(
1362
+ f'no test data in rank {get_global_rank()} for task '
1363
+ f'{task.__class__.__name__}; contributing nothing to the gather'
1364
+ )
1365
+ batch_list = [{}]
1123
1366
  cat_result = batch_list[0]
1124
1367
  for batch in batch_list[1:]:
1125
1368
  for k, v in batch.items():
@@ -1132,7 +1375,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1132
1375
  self.step_log({f'{task.__class__.__name__}/test/{k}': v})
1133
1376
 
1134
1377
  self._test_results[task.__class__.__name__] = {'step': self.step, 'metrics': metrics}
1135
- if isinstance(self.model, FSDPModule):
1378
+ if isinstance(self.model, FSDP2Wrapper):
1136
1379
  self.model.set_requires_gradient_sync(True, recurse=True)
1137
1380
 
1138
1381
  def eval(self, *args: Any, **kwargs: Any) -> None:
@@ -1226,6 +1469,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1226
1469
  with open(runner_path, 'r') as f:
1227
1470
  runner_state = json.load(f)
1228
1471
  self.step = int(runner_state['step'])
1229
- self.pre_epoch = int(runner_state['epoch'])
1472
+ # runner.json's `epoch` is the FULL count — it was written as
1473
+ # self.epoch, which already includes the loaders' progress.
1474
+ # When the data stream is restored too, the loaders replay that
1475
+ # progress and report it again, so adding it here counted every
1476
+ # completed epoch twice, and once more for every further
1477
+ # resume (2 + 2 = 4 after one). pre_epoch exists for the case
1478
+ # where the stream restarts from scratch and that history would
1479
+ # otherwise be lost; carry it only then.
1480
+ self.pre_epoch = (
1481
+ 0 if getattr(self, '_resume_data_states', None)
1482
+ else int(runner_state['epoch'])
1483
+ )
1230
1484
  else:
1231
1485
  logger.warning(f'runner.json not found in {directory}, skip loading runner state')