LMFuser 0.4.1__tar.gz → 0.4.2__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 (27) hide show
  1. {lmfuser-0.4.1 → lmfuser-0.4.2}/PKG-INFO +2 -2
  2. {lmfuser-0.4.1 → lmfuser-0.4.2}/pyproject.toml +2 -2
  3. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/runners/ddp_runner.py +237 -58
  4. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/task.py +16 -3
  5. lmfuser-0.4.2/src/lmfuser/utils.py +437 -0
  6. lmfuser-0.4.2/tests/_gather_worker.py +25 -0
  7. lmfuser-0.4.2/tests/_gather_worker_conflict.py +17 -0
  8. lmfuser-0.4.2/tests/_gather_worker_cpu.py +12 -0
  9. lmfuser-0.4.2/tests/_gather_worker_multi.py +37 -0
  10. lmfuser-0.4.2/tests/_gather_worker_spec.py +25 -0
  11. lmfuser-0.4.2/tests/_gather_worker_symmetry.py +155 -0
  12. lmfuser-0.4.2/tests/test_gather.py +106 -0
  13. lmfuser-0.4.2/tests/test_runner_robustness.py +245 -0
  14. lmfuser-0.4.1/src/lmfuser/utils.py +0 -248
  15. lmfuser-0.4.1/tests/test_runner_robustness.py +0 -114
  16. {lmfuser-0.4.1 → lmfuser-0.4.2}/.github/workflows/python-publish.yml +0 -0
  17. {lmfuser-0.4.1 → lmfuser-0.4.2}/.gitignore +0 -0
  18. {lmfuser-0.4.1 → lmfuser-0.4.2}/.vscode/settings.json +0 -0
  19. {lmfuser-0.4.1 → lmfuser-0.4.2}/LICENSE +0 -0
  20. {lmfuser-0.4.1 → lmfuser-0.4.2}/README.md +0 -0
  21. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/__init__.py +0 -0
  22. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/model_loader.py +0 -0
  23. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/optimizers.py +0 -0
  24. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/runners/__init__.py +0 -0
  25. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/runners/runner.py +0 -0
  26. {lmfuser-0.4.1 → lmfuser-0.4.2}/src/lmfuser/schedulers.py +0 -0
  27. {lmfuser-0.4.1 → lmfuser-0.4.2}/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.2
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.2"
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):
@@ -316,14 +357,18 @@ class DDPRunnerConfig(RunerConf):
316
357
 
317
358
  @property
318
359
  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)
360
+ # `model_precision` is an OptionArg, and Arg defines no __eq__, so
361
+ # comparing it to a str was False on every branch and this property
362
+ # raised for every valid value. Read the value, as _num_acc_steps does.
363
+ precision = self.model_precision.value()
364
+ try:
365
+ return {
366
+ 'fp32': torch.float32,
367
+ 'fp16': torch.float16,
368
+ 'bf16': torch.bfloat16,
369
+ }[precision]
370
+ except KeyError:
371
+ raise ValueError(f'Unknown model precision "{precision}"')
327
372
 
328
373
  @property
329
374
  def _num_acc_steps(self) -> int:
@@ -357,24 +402,29 @@ class DDPRunner(Runner[DDPRunnerConfig]):
357
402
  resume_path = config.resume_path.value()
358
403
  assert resume_path is not None, 'resume_path is None'
359
404
  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
405
+ # Data seed, derived deterministically and ONCE per configured seed.
406
+ #
407
+ # Two properties this must have, both learned the hard way:
408
+ # * identical on every rank `hash()` on a str is PYTHONHASHSEED-
409
+ # randomized, so it gave each rank a different seed (ranks then
410
+ # disagreed on task sampling) and made runs irreproducible;
411
+ # * identical across a resume — shard cursors store a row index into
412
+ # a permutation seeded by this value, so a seed that moves between
413
+ # a run and its own restart silently invalidates every cursor.
414
+ # It is therefore a pure function of config.seed: no step, no rank.
415
+ # Kept separate from config.seed so what lands in runner.json stays the
416
+ # value the user configured.
417
+ self.data_seed = (
418
+ int.from_bytes(
419
+ hashlib.sha256(f'original_seed_{self.config.seed.value()}'.encode()).digest()[:4],
420
+ 'big',
421
+ ) & 0x7FFFFFFF
372
422
  )
373
423
 
374
424
  self.train_data_loaders = config.task_conf.get_train_dataloaders(
375
425
  resume_states=getattr(self, '_resume_data_states', None),
376
426
  batch_size=config.sub_batch_size.value(), # type: ignore
377
- seed=config.seed.value(), # type: ignore
427
+ seed=self.data_seed, # type: ignore
378
428
  shuffle=config.shuffle_dataset.value(), # type: ignore
379
429
  prefetch_factor=config.row_prefetch.value(), # type: ignore
380
430
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -391,7 +441,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
391
441
 
392
442
  self.eval_data_loaders = config.task_conf.get_eval_dataloaders(
393
443
  batch_size=config.sub_batch_size.value(), # type: ignore
394
- seed=config.seed.value(), # type: ignore
444
+ seed=self.data_seed, # type: ignore
395
445
  shuffle=config.shuffle_dataset.value(), # type: ignore
396
446
  prefetch_factor=config.row_prefetch.value(), # type: ignore
397
447
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -405,7 +455,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
405
455
 
406
456
  self.test_data_loaders = config.task_conf.get_test_dataloaders(
407
457
  batch_size=config.sub_batch_size.value(), # type: ignore
408
- seed=config.seed.value(), # type: ignore
458
+ seed=self.data_seed, # type: ignore
409
459
  shuffle=config.shuffle_dataset.value(), # type: ignore
410
460
  prefetch_factor=config.row_prefetch.value(), # type: ignore
411
461
  num_workers=config.num_row_workers.value(), # type: ignore
@@ -451,9 +501,16 @@ class DDPRunner(Runner[DDPRunnerConfig]):
451
501
  You can modify this fuction to control the task scheduler.
452
502
  Be sure that all ranks are selecting the same task at the same time.
453
503
  '''
454
- return self.task_rand_g.choices(
504
+ # Return the task's own index, not its position in train_task_idxs:
505
+ # callers use this to address self.tasks / self.train_data_loaders,
506
+ # which are indexed globally. The two coincide only when the trainable
507
+ # tasks happen to come first — with an eval-only task before a
508
+ # trainable one this trained the wrong task and then crashed on its
509
+ # missing dataloader.
510
+ position = self.task_rand_g.choices(
455
511
  list(range(len(self.train_task_idxs))), weights=self.train_task_weights, k=1
456
512
  )[0]
513
+ return self.train_task_idxs[position]
457
514
 
458
515
  def sample_eval_task_id(self) -> int:
459
516
  '''
@@ -461,9 +518,10 @@ class DDPRunner(Runner[DDPRunnerConfig]):
461
518
  You can modify this fuction to control the task scheduler.
462
519
  Be sure that all ranks are selecting the same task at the same time.
463
520
  '''
464
- return self.task_rand_g.choices(
521
+ position = self.task_rand_g.choices(
465
522
  list(range(len(self.eval_task_idxs))), weights=self.eval_task_weights, k=1
466
523
  )[0]
524
+ return self.eval_task_idxs[position]
467
525
 
468
526
  @property
469
527
  def fsdp_kwargs(self) -> dict:
@@ -520,7 +578,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
520
578
  state = rank_states[task_idx] if rank_states else None
521
579
  if state:
522
580
  for src, table in state.items():
523
- m.setdefault(src, {}).update(table)
581
+ # the loader's own rule, not dict.update: ranks own
582
+ # disjoint shards except on the round-robin
583
+ # fallback, where several read the same one and
584
+ # last-writer-wins silently rewinds whichever of
585
+ # them was enumerated first
586
+ merge_cursors(m.setdefault(src, {}), table)
524
587
  merged.append(m or None)
525
588
  return merged
526
589
  return per_task
@@ -539,8 +602,13 @@ class DDPRunner(Runner[DDPRunnerConfig]):
539
602
  model = self.model_loader.load_model()
540
603
  model.load_state_dict(full_states)
541
604
 
605
+ # the INNER model, matching set_optimizer_state_dict on the resume
606
+ # path: passing the wrapper prefixes every parameter FQN with
607
+ # "model.", and the two sides then disagree — every fsdp2 resume
608
+ # died with "Missing optimizer state for parameter ...", and
609
+ # strict=False silently restored nothing at all
542
610
  optim_states = get_optimizer_state_dict(
543
- model=self.model, # type: ignore
611
+ model=self.model.model, # type: ignore
544
612
  optimizers=self.optimizer,
545
613
  options=StateDictOptions(
546
614
  full_state_dict=True,
@@ -639,7 +707,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
639
707
  else:
640
708
  raise ValueError(f'Unknown model precision "{precision}"')
641
709
  assert isinstance(batch, dict)
642
- batch[key] = v.to(get_default_device()) if v.get_device() != get_default_device() else v
710
+ batch[key] = v.to(torch_device()) if v.get_device() != get_default_device() else v
643
711
  return batch
644
712
 
645
713
  def _prepare_train(
@@ -661,7 +729,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
661
729
  for state in self.optimizer.state.values():
662
730
  for k, v in state.items():
663
731
  if torch.is_tensor(v):
664
- state[k] = v.to(get_default_device(), non_blocking=True)
732
+ state[k] = v.to(torch_device(), non_blocking=True)
665
733
  elif isinstance(self.model, FSDP2Wrapper):
666
734
  set_optimizer_state_dict(
667
735
  model=self.model.model, # type: ignore
@@ -733,8 +801,47 @@ class DDPRunner(Runner[DDPRunnerConfig]):
733
801
 
734
802
  @property
735
803
  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
804
+ """Completed epochs = the SLOWEST task's. See `slowest_epoch`.
805
+
806
+ `max` let the fastest task end the epoch for the whole run, so with
807
+ tasks of different sizes `stop_by: epoch` stopped while the largest
808
+ task had been round only partway. A task with weight 0.0 is never
809
+ sampled and is excluded so it cannot stall the run instead.
810
+ """
811
+ # Index by TASK id, not by position: train_data_loaders is per task
812
+ # and holds None for eval-only tasks, while train_task_weights is
813
+ # packed to the train tasks only. Zipping them pairs a loader with
814
+ # another task's weight as soon as any task is eval-only.
815
+ pairs = [
816
+ (self.train_data_loaders[idx].epoch, self.task_weights[idx]) # type: ignore
817
+ for idx in self.train_task_idxs
818
+ ]
819
+ if not pairs:
820
+ return self.pre_epoch
821
+ epochs, weights = zip(*pairs)
822
+ return slowest_epoch(list(epochs), list(weights)) + self.pre_epoch
823
+
824
+ def _pbar_status(self, **fields: Any) -> None:
825
+ """Update named markers shown after the progress bar.
826
+
827
+ Everything here is a point sample of something that does not change
828
+ every step, so each marker carries the step it refers to. Pass None to
829
+ drop a marker. (set_description would put the text in front of the bar
830
+ and leave it there — "model saved!" fronted the bar for the 25k steps
831
+ until the next save.)
832
+ """
833
+ if not hasattr(self, '_pbar_fields'):
834
+ self._pbar_fields: dict[str, str] = {}
835
+ for key, value in fields.items():
836
+ if value is None:
837
+ self._pbar_fields.pop(key, None)
838
+ else:
839
+ self._pbar_fields[key] = value
840
+ if getattr(self, '_pbar_train', None) is not None:
841
+ self._pbar_train.set_postfix_str(
842
+ ' '.join(f'{k}={v}' for k, v in self._pbar_fields.items()),
843
+ refresh=True,
844
+ )
738
845
 
739
846
  def step_log(self, data: dict[str, Any], console: bool = True) -> None:
740
847
  self._wandb
@@ -834,10 +941,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
834
941
  for k, v in subbatch_result.items():
835
942
  if k == 'loss':
836
943
  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
944
+ scalar = _as_log_scalar(v)
945
+ if scalar is not None:
946
+ batch_datas[k].append(scalar)
947
+ elif isinstance(v, (list, tuple)) and len(v) > 0:
948
+ floats = [s for s in (_as_log_scalar(i) for i in v)
949
+ if s is not None]
950
+ # all-or-nothing: a partially-convertible list is a type
951
+ # confusion, not a metric — logging half of it silently
952
+ # would be worse than dropping it.
953
+ if len(floats) == len(v):
954
+ batch_datas[k].extend(floats)
841
955
 
842
956
  if self.scaler is not None:
843
957
  self.scaler.scale(loss).backward()
@@ -855,7 +969,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
855
969
  # wandb: rank0's own segments plus the all-rank max of each — the
856
970
  # max is the diagnostic one (in DDP the slowest rank paces the
857
971
  # 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())
972
+ t = torch.tensor([fetch_ms, h2d_ms, traincall_ms], device=torch_device())
859
973
  if torch.distributed.is_initialized():
860
974
  torch.distributed.all_reduce(t, op=torch.distributed.ReduceOp.MAX)
861
975
  mx = t.tolist()
@@ -885,9 +999,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
885
999
  # the sampled step is part of the label: loss refreshes every
886
1000
  # metric_sync_freq steps while the bar ticks every step, and an
887
1001
  # 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
- )
1002
+ self._pbar_status(loss=f'{running_loss:.3g}@{self.step}')
891
1003
 
892
1004
  grad_norm_clip_val = self.config.grad_norm_clip.value()
893
1005
  if grad_norm_clip_val is not None:
@@ -939,8 +1051,9 @@ class DDPRunner(Runner[DDPRunnerConfig]):
939
1051
  other_step_results = defaultdict(list)
940
1052
  for r in subbatch_result_lst:
941
1053
  for k, v in r.items():
942
- if isinstance(v, (float, int)):
943
- other_step_results[k].append(float(v))
1054
+ scalar = _as_log_scalar(v)
1055
+ if scalar is not None:
1056
+ other_step_results[k].append(scalar)
944
1057
  all_other_step_results = batch_all_gather(other_step_results)
945
1058
  for k, v in all_other_step_results.items():
946
1059
  try:
@@ -952,11 +1065,14 @@ class DDPRunner(Runner[DDPRunnerConfig]):
952
1065
  save_step_freq = self.config.save_step_freq.value()
953
1066
  assert save_step_freq is not None
954
1067
  if self.step % save_step_freq == 0:
955
- self._pbar_train.set_description('begin to save the model', True)
1068
+ self._pbar_status(saving=str(self.step))
956
1069
  ckpt_path = self.config.checkpoint_directory.value()
957
1070
  assert ckpt_path is not None
958
1071
  self.save(ckpt_path)
959
- self._pbar_train.set_description('model saved!', True)
1072
+ # replaces the "saving" marker rather than adding to it, and stays
1073
+ # labelled with the step: set_description used to leave "model
1074
+ # saved!" fronting the bar for the next 25k steps
1075
+ self._pbar_status(saving=None, saved=str(self.step))
960
1076
  torch.distributed.barrier() if get_world_size() > 1 else ...
961
1077
 
962
1078
  eval_step_freq = self.config.eval_step_freq.value()
@@ -1035,7 +1151,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1035
1151
  if get_world_size() > 1:
1036
1152
  if isinstance(self.model, (DDPWraper, Wrapper)):
1037
1153
  stack.enter_context(self.model.model.no_sync())
1038
- elif isinstance(self.model, FSDPModule):
1154
+ elif isinstance(self.model, FSDP2Wrapper):
1039
1155
  self.model.set_requires_gradient_sync(False, recurse=True)
1040
1156
  for batch in tqdm(
1041
1157
  dataloader,
@@ -1055,8 +1171,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1055
1171
  batch_list.append(result)
1056
1172
 
1057
1173
  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__}')
1174
+ # A rank with an empty slice must NOT raise here: every other
1175
+ # rank is heading into batch_all_gather below, and a rank that
1176
+ # leaves instead of entering it hangs them all — under NCCL
1177
+ # until the watchdog fires, or forever. Contribute nothing and
1178
+ # let the collective complete; an empty result surfaces as a
1179
+ # metric error afterwards, on every rank at once.
1180
+ logger.warning(
1181
+ f'no eval data in rank {get_global_rank()} for task '
1182
+ f'{task.__class__.__name__}; contributing nothing to the gather'
1183
+ )
1184
+ batch_list = [{}]
1060
1185
  cat_result = batch_list[0]
1061
1186
  for batch in batch_list[1:]:
1062
1187
  for k, v in batch.items():
@@ -1070,8 +1195,14 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1070
1195
 
1071
1196
  if task.__class__.__name__ not in self._all_eval_results:
1072
1197
  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):
1198
+ # `saved` lets checkpoint selection tell an eval that can be reloaded
1199
+ # from one that cannot: eval_step_freq and save_step_freq are
1200
+ # independent, so most evals happen at steps with nothing on disk.
1201
+ ckpt_dir = self.config.checkpoint_directory.value()
1202
+ saved = bool(ckpt_dir) and (Path(str(ckpt_dir)) / str(self.step)).is_dir()
1203
+ self._all_eval_results[task.__class__.__name__].append(
1204
+ {'step': self.step, 'metrics': metrics, 'saved': saved})
1205
+ if isinstance(self.model, FSDP2Wrapper):
1075
1206
  self.model.set_requires_gradient_sync(True, recurse=True)
1076
1207
 
1077
1208
  def _test_one_task(self, task_id: int, **kwargs: Any) -> None:
@@ -1087,7 +1218,35 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1087
1218
  if test_model_path is not None:
1088
1219
  if isinstance(test_model_path, int):
1089
1220
  test_model_path = Path(ckpt_path) / str(test_model_path)
1090
-
1221
+
1222
+ # A task picks the best step by DEV METRIC, but eval runs on its own
1223
+ # schedule and most eval steps have no checkpoint on disk. Loading
1224
+ # a path that isn't there kills the run after all the training is
1225
+ # already paid for, so fall back to the best step that was saved.
1226
+ if not Path(test_model_path).is_dir():
1227
+ results = self._all_eval_results.get(task.__class__.__name__, [])
1228
+ saved_only = [r for r in results if r.get('saved')]
1229
+ fallback = task.set_test_model_path(ckpt_path, {
1230
+ task.__class__.__name__: saved_only}) if saved_only else None
1231
+ if isinstance(fallback, int):
1232
+ fallback = Path(ckpt_path) / str(fallback)
1233
+ if fallback is not None and Path(fallback).is_dir():
1234
+ logger.warning(
1235
+ f'best dev step has no checkpoint on disk '
1236
+ f'({test_model_path}) — eval_step_freq and '
1237
+ f'save_step_freq disagree. Testing the best SAVED step '
1238
+ f'instead: {fallback}'
1239
+ )
1240
+ test_model_path = fallback
1241
+ else:
1242
+ logger.warning(
1243
+ f'best dev step has no checkpoint on disk '
1244
+ f'({test_model_path}) and no evaluated step was saved; '
1245
+ f'testing the in-memory model instead'
1246
+ )
1247
+ test_model_path = None
1248
+
1249
+ if test_model_path is not None:
1091
1250
  self.model_loader.model_path = test_model_path
1092
1251
  self._model = None
1093
1252
 
@@ -1098,7 +1257,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1098
1257
  if get_world_size() > 1:
1099
1258
  if isinstance(self.model, (DDPWraper, Wrapper)):
1100
1259
  stack.enter_context(self.model.model.no_sync())
1101
- elif isinstance(self.model, FSDPModule):
1260
+ elif isinstance(self.model, FSDP2Wrapper):
1102
1261
  self.model.set_requires_gradient_sync(False, recurse=True)
1103
1262
  for batch in tqdm(
1104
1263
  dataloader,
@@ -1118,8 +1277,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1118
1277
  batch_list.append(result)
1119
1278
 
1120
1279
  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__}')
1280
+ # A rank with an empty slice must NOT raise here: every other
1281
+ # rank is heading into batch_all_gather below, and a rank that
1282
+ # leaves instead of entering it hangs them all — under NCCL
1283
+ # until the watchdog fires, or forever. Contribute nothing and
1284
+ # let the collective complete; an empty result surfaces as a
1285
+ # metric error afterwards, on every rank at once.
1286
+ logger.warning(
1287
+ f'no test data in rank {get_global_rank()} for task '
1288
+ f'{task.__class__.__name__}; contributing nothing to the gather'
1289
+ )
1290
+ batch_list = [{}]
1123
1291
  cat_result = batch_list[0]
1124
1292
  for batch in batch_list[1:]:
1125
1293
  for k, v in batch.items():
@@ -1132,7 +1300,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1132
1300
  self.step_log({f'{task.__class__.__name__}/test/{k}': v})
1133
1301
 
1134
1302
  self._test_results[task.__class__.__name__] = {'step': self.step, 'metrics': metrics}
1135
- if isinstance(self.model, FSDPModule):
1303
+ if isinstance(self.model, FSDP2Wrapper):
1136
1304
  self.model.set_requires_gradient_sync(True, recurse=True)
1137
1305
 
1138
1306
  def eval(self, *args: Any, **kwargs: Any) -> None:
@@ -1226,6 +1394,17 @@ class DDPRunner(Runner[DDPRunnerConfig]):
1226
1394
  with open(runner_path, 'r') as f:
1227
1395
  runner_state = json.load(f)
1228
1396
  self.step = int(runner_state['step'])
1229
- self.pre_epoch = int(runner_state['epoch'])
1397
+ # runner.json's `epoch` is the FULL count — it was written as
1398
+ # self.epoch, which already includes the loaders' progress.
1399
+ # When the data stream is restored too, the loaders replay that
1400
+ # progress and report it again, so adding it here counted every
1401
+ # completed epoch twice, and once more for every further
1402
+ # resume (2 + 2 = 4 after one). pre_epoch exists for the case
1403
+ # where the stream restarts from scratch and that history would
1404
+ # otherwise be lost; carry it only then.
1405
+ self.pre_epoch = (
1406
+ 0 if getattr(self, '_resume_data_states', None)
1407
+ else int(runner_state['epoch'])
1408
+ )
1230
1409
  else:
1231
1410
  logger.warning(f'runner.json not found in {directory}, skip loading runner state')
@@ -89,7 +89,10 @@ class TaskBase(Conf, SubclassTracer):
89
89
  assert isinstance(num, int)
90
90
  if len(self.test_data_path_list) > num:
91
91
  self.test_data_path_list = self.test_data_path_list[:num]
92
- self.test_data_path_list = self.test_data_path_list[:num]
92
+ # was a duplicate of the line above, so shrinking the test list
93
+ # left the weights at their old length — paths and weights then
94
+ # disagreed. The train and eval monitors alongside get this right.
95
+ self.test_data_weights = self.test_data_weights[:num]
93
96
  elif len(self.test_data_path_list) < num:
94
97
  self.test_data_path_list += [StrArg('Enther the path to the data file.')] * (num - len(self.test_data_path_list))
95
98
  self.test_data_weights += [FloatArg(1.0, min_value=0.0, max_value=1.0)] * (num - len(self.test_data_weights))
@@ -250,7 +253,12 @@ class TaskBase(Conf, SubclassTracer):
250
253
  num_ranks=world_size,
251
254
  rank_idx=rank,
252
255
  collate_fn=self.get_collate_fn(),
253
- drop_last=False
256
+ drop_last=False,
257
+ # score every row exactly once: a padded sampler
258
+ # repeats rows to equalise rank counts, and those
259
+ # duplicates are gathered into the metric, so the
260
+ # same checkpoint scores differently on 2 vs 4 GPUs
261
+ exact_pass=True
254
262
  )
255
263
  elif dataloader_type == 'empty':
256
264
  self._eval_dataloader = EmptyDataLoader(init_step=0)
@@ -320,7 +328,12 @@ class TaskBase(Conf, SubclassTracer):
320
328
  num_ranks=world_size,
321
329
  rank_idx=rank,
322
330
  collate_fn=self.get_collate_fn(),
323
- drop_last=False
331
+ drop_last=False,
332
+ # score every row exactly once: a padded sampler
333
+ # repeats rows to equalise rank counts, and those
334
+ # duplicates are gathered into the metric, so the
335
+ # same checkpoint scores differently on 2 vs 4 GPUs
336
+ exact_pass=True
324
337
  )
325
338
  elif dataloader_type == 'empty':
326
339
  self._test_dataloader = EmptyDataLoader(init_step=0)