LMFuser 0.4.2__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.2 → lmfuser-0.4.3}/PKG-INFO +1 -1
- {lmfuser-0.4.2 → lmfuser-0.4.3}/pyproject.toml +1 -1
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/ddp_runner.py +75 -0
- lmfuser-0.4.3/tests/test_nan_grad_guard.py +170 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/.github/workflows/python-publish.yml +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/.gitignore +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/.vscode/settings.json +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/LICENSE +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/README.md +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/__init__.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/model_loader.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/optimizers.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/__init__.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/runner.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/schedulers.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/task.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/utils.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_conflict.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_cpu.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_multi.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_spec.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_symmetry.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_fsdp2_compile.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_gather.py +0 -0
- {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_runner_robustness.py +0 -0
|
@@ -297,6 +297,15 @@ class DDPRunnerConfig(RunerConf):
|
|
|
297
297
|
|
|
298
298
|
grad_norm_clip = FloatArg(None, min_value=0.0, allow_none=True)
|
|
299
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
|
+
|
|
300
309
|
task_conf = Tasks()
|
|
301
310
|
|
|
302
311
|
optimizer: OptimizerConfig = OptimizerConfig()
|
|
@@ -385,6 +394,12 @@ class DDPRunnerConfig(RunerConf):
|
|
|
385
394
|
|
|
386
395
|
class DDPRunner(Runner[DDPRunnerConfig]):
|
|
387
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
|
+
|
|
388
403
|
def __init__(self, config: DDPRunnerConfig, *args, **kwargs) -> None:
|
|
389
404
|
super().__init__(config, *args, **kwargs)
|
|
390
405
|
if get_world_size() > 1:
|
|
@@ -769,6 +784,11 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
769
784
|
else:
|
|
770
785
|
self.scaler = scaler
|
|
771
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
|
+
|
|
772
792
|
def _next_train_batch(self, task_idx: int) -> Batch:
|
|
773
793
|
if self.train_data_loaders[task_idx] is None:
|
|
774
794
|
raise ValueError(f'No train dataloader for task {task_idx}')
|
|
@@ -1002,6 +1022,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
1002
1022
|
self._pbar_status(loss=f'{running_loss:.3g}@{self.step}')
|
|
1003
1023
|
|
|
1004
1024
|
grad_norm_clip_val = self.config.grad_norm_clip.value()
|
|
1025
|
+
norm_t = None
|
|
1005
1026
|
if grad_norm_clip_val is not None:
|
|
1006
1027
|
if self.scaler is not None:
|
|
1007
1028
|
self.scaler.unscale_(self.optimizer)
|
|
@@ -1034,6 +1055,60 @@ class DDPRunner(Runner[DDPRunnerConfig]):
|
|
|
1034
1055
|
f'{task.__class__.__name__}/train/hot_ratio': num_hot_params / num_total_params,
|
|
1035
1056
|
}, console=log_this)
|
|
1036
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
|
+
|
|
1037
1112
|
if self.scaler is not None:
|
|
1038
1113
|
self.scaler.step(self.optimizer)
|
|
1039
1114
|
self.scaler.update()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The NaN/Inf gradient guard: skip a non-finite optimizer step instead of
|
|
2
|
+
letting it poison the (fused) moment buffers forever.
|
|
3
|
+
|
|
4
|
+
These test the two mechanisms the guard in DDPRunner._one_train_step relies on
|
|
5
|
+
— the fused optimizer's found_inf channel and the foreach fallback — plus the
|
|
6
|
+
consecutive-streak accounting and abort, and a microbenchmark showing the
|
|
7
|
+
per-step cost is negligible. The guard's rank-consistency is by construction:
|
|
8
|
+
its signal is the post-clip (hence post-all-reduce) grad norm, identical on
|
|
9
|
+
every rank, so all ranks skip or step together.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
_DATA_SRC = os.path.join(os.path.dirname(__file__), '..', '..', 'LMFuser-Data', 'src')
|
|
18
|
+
if os.path.isdir(_DATA_SRC):
|
|
19
|
+
sys.path.insert(0, _DATA_SRC)
|
|
20
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
21
|
+
|
|
22
|
+
HAS_CUDA = torch.cuda.is_available()
|
|
23
|
+
DEV = 'cuda' if HAS_CUDA else 'cpu'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _guard_decision(norm_t):
|
|
27
|
+
"""The guard's core signal, exactly as in _one_train_step: one GPU bool,
|
|
28
|
+
no host sync. `.all()` unifies the scalar (clipped) and per-tensor
|
|
29
|
+
(unclipped) forms of norm_t."""
|
|
30
|
+
return ~torch.isfinite(norm_t).all()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_fused_found_inf_skips_and_does_not_poison() -> None:
|
|
34
|
+
"""A fused / amp-aware optimizer no-ops the step when found_inf is set, and
|
|
35
|
+
the moment buffers are never touched — so the next clean step is normal."""
|
|
36
|
+
if not HAS_CUDA:
|
|
37
|
+
print('SKIP: fused path needs CUDA')
|
|
38
|
+
return
|
|
39
|
+
p = torch.nn.Parameter(torch.ones(8, device=DEV))
|
|
40
|
+
opt = torch.optim.AdamW([p], lr=0.1, fused=True)
|
|
41
|
+
assert getattr(opt, '_step_supports_amp_scaling', False), \
|
|
42
|
+
'fused AdamW should advertise amp-scaling support'
|
|
43
|
+
|
|
44
|
+
# a non-finite gradient with found_inf set must be a no-op
|
|
45
|
+
p.grad = torch.tensor([float('nan'), float('inf'), -float('inf'), 1., 2.,
|
|
46
|
+
3., 4., 5.], device=DEV)
|
|
47
|
+
nonfinite = _guard_decision(torch.tensor(float('nan'), device=DEV))
|
|
48
|
+
opt.grad_scale = None
|
|
49
|
+
opt.found_inf = nonfinite.to(torch.float32)
|
|
50
|
+
opt.step()
|
|
51
|
+
assert torch.allclose(p.detach(), torch.ones(8, device=DEV)), \
|
|
52
|
+
'params moved on a step that should have been skipped'
|
|
53
|
+
assert not any(torch.isnan(v).any().item() for v in opt.state[p].values()
|
|
54
|
+
if torch.is_tensor(v)), 'moment buffers were poisoned'
|
|
55
|
+
|
|
56
|
+
# the very next finite step must update normally
|
|
57
|
+
p.grad = torch.ones(8, device=DEV)
|
|
58
|
+
opt.grad_scale = None
|
|
59
|
+
opt.found_inf = _guard_decision(torch.ones((), device=DEV)).to(torch.float32)
|
|
60
|
+
opt.step()
|
|
61
|
+
assert not torch.allclose(p.detach(), torch.ones(8, device=DEV)), \
|
|
62
|
+
'a clean step after a skip failed to update'
|
|
63
|
+
print('PASS: fused found_inf skips the bad step, keeps state, resumes clean')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_foreach_fallback_scrubs_grads() -> None:
|
|
67
|
+
"""Optimizers without the found_inf channel (foreach, Adadelta, fused=False)
|
|
68
|
+
take the fallback: scrub the grads so the step is a near-no-op. The scrub
|
|
69
|
+
must actually remove nan/inf (a multiply would leave 0*inf = nan)."""
|
|
70
|
+
p = torch.nn.Parameter(torch.ones(4, device=DEV))
|
|
71
|
+
opt = torch.optim.Adam([p], lr=0.1, foreach=True)
|
|
72
|
+
assert not getattr(opt, '_step_supports_amp_scaling', False), \
|
|
73
|
+
'foreach Adam should NOT advertise amp-scaling — it takes the fallback'
|
|
74
|
+
|
|
75
|
+
p.grad = torch.tensor([float('nan'), float('inf'), -float('inf'), 2.],
|
|
76
|
+
device=DEV)
|
|
77
|
+
# fallback: torch has no _foreach_nan_to_num_, so per-tensor scrub
|
|
78
|
+
for param in (p,):
|
|
79
|
+
torch.nan_to_num_(param.grad, nan=0.0, posinf=0.0, neginf=0.0)
|
|
80
|
+
assert torch.isfinite(p.grad).all(), 'scrub left a non-finite gradient'
|
|
81
|
+
opt.step()
|
|
82
|
+
assert torch.isfinite(p.detach()).all(), 'params went non-finite after scrub'
|
|
83
|
+
assert not any(torch.isnan(v).any().item() for v in opt.state[p].values()
|
|
84
|
+
if torch.is_tensor(v)), 'moment buffers were poisoned'
|
|
85
|
+
print('PASS: foreach fallback scrubs nan/inf, step stays finite')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_streak_accumulates_and_resets_on_device() -> None:
|
|
89
|
+
"""The consecutive-nonfinite counter increments on bad steps and resets on
|
|
90
|
+
a good one, all on-device (no host sync until the log-step read)."""
|
|
91
|
+
streak = torch.zeros((), device=DEV, dtype=torch.long)
|
|
92
|
+
pattern = [True, True, True, False, True] # bad, bad, bad, good, bad
|
|
93
|
+
seen = []
|
|
94
|
+
for bad in pattern:
|
|
95
|
+
nonfinite = torch.tensor(bad, device=DEV)
|
|
96
|
+
streak = torch.where(nonfinite, streak + 1, torch.zeros_like(streak))
|
|
97
|
+
seen.append(int(streak.item()))
|
|
98
|
+
assert seen == [1, 2, 3, 0, 1], f'streak sequence wrong: {seen}'
|
|
99
|
+
print(f'PASS: streak accumulates/resets correctly {seen}')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_guard_is_gated_and_norm_hoisted_in_source() -> None:
|
|
103
|
+
"""Structural guarantees that are easy to regress: the guard only runs on
|
|
104
|
+
the no-scaler path and under the switch, and norm_t is hoisted so the
|
|
105
|
+
unclipped path still has a signal."""
|
|
106
|
+
src = open(os.path.join(os.path.dirname(__file__), '..', 'src', 'lmfuser',
|
|
107
|
+
'runners', 'ddp_runner.py')).read()
|
|
108
|
+
assert 'self.config.skip_nan_and_inf_grad.value() and self.scaler is None' \
|
|
109
|
+
in src, 'guard is not gated on both the switch and the no-scaler path'
|
|
110
|
+
assert '_step_supports_amp_scaling' in src, 'guard does not branch on the ' \
|
|
111
|
+
'optimizer capability (would break non-fused optimizers)'
|
|
112
|
+
# norm_t must be initialised before the clip block so the unclipped path
|
|
113
|
+
# does not hit a NameError / stale value
|
|
114
|
+
assert 'norm_t = None\n' in src, 'norm_t is not hoisted above the clip block'
|
|
115
|
+
print('PASS: guard gated on switch+no-scaler, branches on capability, '
|
|
116
|
+
'norm_t hoisted')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_per_step_overhead_is_negligible() -> None:
|
|
120
|
+
"""Microbenchmark: the guard's added per-step work (isfinite + where +
|
|
121
|
+
two attribute sets) against a real fused optimizer step on a ~100M-param
|
|
122
|
+
model. No new host sync, so the added cost should be a small fraction of
|
|
123
|
+
the step it guards."""
|
|
124
|
+
if not HAS_CUDA:
|
|
125
|
+
print('SKIP: benchmark needs CUDA')
|
|
126
|
+
return
|
|
127
|
+
# ~100M params spread over many tensors, like a real model's grads
|
|
128
|
+
params = [torch.nn.Parameter(torch.randn(4096, 4096, device=DEV))
|
|
129
|
+
for _ in range(6)]
|
|
130
|
+
opt = torch.optim.AdamW(params, lr=1e-3, fused=True)
|
|
131
|
+
for p in params:
|
|
132
|
+
p.grad = torch.randn_like(p)
|
|
133
|
+
norm_t = torch.tensor(1.0, device=DEV)
|
|
134
|
+
|
|
135
|
+
def guard_ops():
|
|
136
|
+
nonfinite = ~torch.isfinite(norm_t).all()
|
|
137
|
+
opt.grad_scale = None
|
|
138
|
+
opt.found_inf = nonfinite.to(torch.float32)
|
|
139
|
+
|
|
140
|
+
# warmup
|
|
141
|
+
for _ in range(5):
|
|
142
|
+
guard_ops(); opt.step()
|
|
143
|
+
torch.cuda.synchronize()
|
|
144
|
+
|
|
145
|
+
N = 200
|
|
146
|
+
t0 = time.time()
|
|
147
|
+
for _ in range(N):
|
|
148
|
+
opt.step()
|
|
149
|
+
torch.cuda.synchronize()
|
|
150
|
+
step_ms = (time.time() - t0) / N * 1e3
|
|
151
|
+
|
|
152
|
+
t0 = time.time()
|
|
153
|
+
for _ in range(N):
|
|
154
|
+
guard_ops(); opt.step()
|
|
155
|
+
torch.cuda.synchronize()
|
|
156
|
+
guarded_ms = (time.time() - t0) / N * 1e3
|
|
157
|
+
|
|
158
|
+
overhead_pct = (guarded_ms - step_ms) / step_ms * 100
|
|
159
|
+
print(f'PASS: step={step_ms:.3f}ms guarded={guarded_ms:.3f}ms '
|
|
160
|
+
f'overhead={overhead_pct:+.1f}% of one optimizer step '
|
|
161
|
+
f'(no new per-step host sync)')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == '__main__':
|
|
165
|
+
test_fused_found_inf_skips_and_does_not_poison()
|
|
166
|
+
test_foreach_fallback_scrubs_grads()
|
|
167
|
+
test_streak_accumulates_and_resets_on_device()
|
|
168
|
+
test_guard_is_gated_and_norm_hoisted_in_source()
|
|
169
|
+
test_per_step_overhead_is_negligible()
|
|
170
|
+
print('ALL PASS')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|