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.
- {lmfuser-0.4.1 → lmfuser-0.4.3}/PKG-INFO +2 -2
- {lmfuser-0.4.1 → lmfuser-0.4.3}/pyproject.toml +2 -2
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/ddp_runner.py +312 -58
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/task.py +16 -3
- lmfuser-0.4.3/src/lmfuser/utils.py +437 -0
- lmfuser-0.4.3/tests/_gather_worker.py +25 -0
- lmfuser-0.4.3/tests/_gather_worker_conflict.py +17 -0
- lmfuser-0.4.3/tests/_gather_worker_cpu.py +12 -0
- lmfuser-0.4.3/tests/_gather_worker_multi.py +37 -0
- lmfuser-0.4.3/tests/_gather_worker_spec.py +25 -0
- lmfuser-0.4.3/tests/_gather_worker_symmetry.py +155 -0
- lmfuser-0.4.3/tests/test_gather.py +106 -0
- lmfuser-0.4.3/tests/test_nan_grad_guard.py +170 -0
- lmfuser-0.4.3/tests/test_runner_robustness.py +245 -0
- lmfuser-0.4.1/src/lmfuser/utils.py +0 -248
- lmfuser-0.4.1/tests/test_runner_robustness.py +0 -114
- {lmfuser-0.4.1 → lmfuser-0.4.3}/.github/workflows/python-publish.yml +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/.gitignore +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/.vscode/settings.json +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/LICENSE +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/README.md +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/__init__.py +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/model_loader.py +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/optimizers.py +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/__init__.py +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/runners/runner.py +0 -0
- {lmfuser-0.4.1 → lmfuser-0.4.3}/src/lmfuser/schedulers.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
#
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
#
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
737
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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=
|
|
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.
|
|
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
|
-
|
|
943
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
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,
|
|
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
|
-
|
|
1122
|
-
|
|
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,
|
|
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
|
-
|
|
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')
|