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.
Files changed (26) hide show
  1. {lmfuser-0.4.2 → lmfuser-0.4.3}/PKG-INFO +1 -1
  2. {lmfuser-0.4.2 → lmfuser-0.4.3}/pyproject.toml +1 -1
  3. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/ddp_runner.py +75 -0
  4. lmfuser-0.4.3/tests/test_nan_grad_guard.py +170 -0
  5. {lmfuser-0.4.2 → lmfuser-0.4.3}/.github/workflows/python-publish.yml +0 -0
  6. {lmfuser-0.4.2 → lmfuser-0.4.3}/.gitignore +0 -0
  7. {lmfuser-0.4.2 → lmfuser-0.4.3}/.vscode/settings.json +0 -0
  8. {lmfuser-0.4.2 → lmfuser-0.4.3}/LICENSE +0 -0
  9. {lmfuser-0.4.2 → lmfuser-0.4.3}/README.md +0 -0
  10. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/__init__.py +0 -0
  11. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/model_loader.py +0 -0
  12. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/optimizers.py +0 -0
  13. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/__init__.py +0 -0
  14. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/runners/runner.py +0 -0
  15. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/schedulers.py +0 -0
  16. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/task.py +0 -0
  17. {lmfuser-0.4.2 → lmfuser-0.4.3}/src/lmfuser/utils.py +0 -0
  18. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker.py +0 -0
  19. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_conflict.py +0 -0
  20. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_cpu.py +0 -0
  21. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_multi.py +0 -0
  22. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_spec.py +0 -0
  23. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/_gather_worker_symmetry.py +0 -0
  24. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_fsdp2_compile.py +0 -0
  25. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_gather.py +0 -0
  26. {lmfuser-0.4.2 → lmfuser-0.4.3}/tests/test_runner_robustness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LMFuser
3
- Version: 0.4.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "LMFuser"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  requires-python = ">= 3.11"
9
9
  description = "The LMFuser training framework."
10
10
  readme = "README.md"
@@ -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