LMFuser 0.2.0__tar.gz → 0.4.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LMFuser
3
- Version: 0.2.0
3
+ Version: 0.4.0
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.2.0
20
+ Requires-Dist: lmfuser-data>=0.3.0
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.2.0"
7
+ version = "0.4.0"
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.2.0",
23
+ "LMFuser-data>=0.3.0",
24
24
  "tqdm",
25
25
  "wandb",
26
26
  "HyperArgs>=0.1.3",
@@ -10,7 +10,7 @@ from torch.optim import (
10
10
  Adadelta,
11
11
  Adagrad
12
12
  )
13
- from hyperargs import Conf, OptionArg, FloatArg, BoolArg, add_dependency, monitor_on
13
+ from hyperargs import Conf, OptionArg, FloatArg, BoolArg, add_dependency, monitor_on, IntArg
14
14
 
15
15
 
16
16
  class OptimizerConfigBase(Conf):
@@ -32,6 +32,8 @@ class AdamWConfig(OptimizerConfigBase):
32
32
  eps = FloatArg(1e-8, min_value=0.0, max_value=1.0)
33
33
  weight_decay = FloatArg(0.01, min_value=0.0, max_value=1.0)
34
34
  amsgrad = BoolArg(False)
35
+ # fused CUDA kernel (single multi-tensor launch); ignored on CPU
36
+ fused = BoolArg(default=False)
35
37
 
36
38
  def init_optimzier(
37
39
  self,
@@ -43,7 +45,8 @@ class AdamWConfig(OptimizerConfigBase):
43
45
  betas=(self.beta1.value(), self.beta2.value()), # type: ignore
44
46
  eps=self.eps.value(), # type: ignore
45
47
  weight_decay=self.weight_decay.value(), # type: ignore
46
- amsgrad=self.amsgrad.value() # type: ignore
48
+ amsgrad=self.amsgrad.value(), # type: ignore
49
+ fused=self.fused.value() or None, # type: ignore
47
50
  )
48
51
 
49
52
 
@@ -59,12 +59,86 @@ logger = logging.getLogger(__name__)
59
59
  T = TypeVar('T', bound=Conf)
60
60
 
61
61
 
62
+ def _compile_kwargs(compile_mode: str) -> dict:
63
+ return {} if compile_mode == 'default' else {'mode': compile_mode}
64
+
65
+
66
+ class _TqdmLogHandler(logging.Handler):
67
+ """Route logging through tqdm.write so log lines print above the progress
68
+ bar instead of tearing through it mid-refresh."""
69
+
70
+ def emit(self, record: logging.LogRecord) -> None:
71
+ try:
72
+ tqdm.write(self.format(record))
73
+ except Exception:
74
+ self.handleError(record)
75
+
76
+
77
+ class _DevicePrefetcher:
78
+ """Background fetch + side-stream H2D double buffering over a dataloader.
79
+
80
+ next() returns a device-resident batch whose copy has completed (event
81
+ sync on the compute stream, not the host)."""
82
+
83
+ def __init__(self, loader, device, precision: str) -> None:
84
+ import threading, queue as _q
85
+ self._loader = loader
86
+ self._device = device
87
+ self._precision = precision
88
+ self._stream = torch.cuda.Stream(device=device)
89
+ self._queue: _q.Queue = _q.Queue(maxsize=2)
90
+ self._stop = False
91
+ self._thread = threading.Thread(target=self._run, daemon=True)
92
+ self._thread.start()
93
+
94
+ def _cast(self, v: Tensor) -> Tensor:
95
+ if torch.is_floating_point(v):
96
+ dt = {'fp32': torch.float32, 'fp16': torch.float16, 'bf16': torch.bfloat16}[self._precision]
97
+ if v.dtype != dt:
98
+ v = v.to(dt)
99
+ return v
100
+
101
+ def _run(self) -> None:
102
+ it = iter(self._loader)
103
+ while not self._stop:
104
+ try:
105
+ batch = next(it)
106
+ except StopIteration:
107
+ it = iter(self._loader)
108
+ batch = next(it)
109
+ with torch.cuda.stream(self._stream):
110
+ dev = {}
111
+ ok = True
112
+ for k, v in batch.items():
113
+ if isinstance(v, Tensor):
114
+ v = self._cast(v)
115
+ dev[k] = v.pin_memory().to(self._device, non_blocking=True)
116
+ else:
117
+ dev[k] = v
118
+ ev = torch.cuda.Event()
119
+ ev.record(self._stream)
120
+ self._queue.put((dev, ev))
121
+
122
+ def next(self):
123
+ dev, ev = self._queue.get()
124
+ # make the COMPUTE stream wait for the copy — no host sync
125
+ torch.cuda.current_stream().wait_event(ev)
126
+ return dev
127
+
128
+ def close(self) -> None:
129
+ self._stop = True
130
+
131
+
62
132
  class Wrapper(nn.Module):
63
133
 
64
- def __init__(self, model: nn.Module) -> None:
134
+ def __init__(self, model: nn.Module, compile_mode: str = 'disable') -> None:
65
135
  super().__init__()
66
136
  self.module = model.to(get_default_device())
67
- self.forward = model.forward
137
+ if compile_mode != 'disable':
138
+ logger.critical(f'torch.compile enabled (mode={compile_mode})')
139
+ self.forward = torch.compile(model.forward, **_compile_kwargs(compile_mode))
140
+ else:
141
+ self.forward = model.forward
68
142
 
69
143
  def no_sync(self):
70
144
  # duck-type DDP for the accumulation path: a single process has no
@@ -83,12 +157,28 @@ class Wrapper(nn.Module):
83
157
 
84
158
  class DDPWraper(nn.Module):
85
159
 
86
- def __init__(self, model: nn.Module) -> None:
160
+ def __init__(self, model: nn.Module, compile_mode: str = 'disable', find_unused: bool = True, bf16_grads: bool = False) -> None:
87
161
  super().__init__()
88
- self.model = DDP(
89
- model.to(get_default_device()), find_unused_parameters=True
90
- ) if get_world_size() > 1 else Wrapper(model)
91
- self.forward = self.model.forward
162
+ if get_world_size() > 1:
163
+ logger.critical(f'DDP options: find_unused={find_unused} bf16_grads={bf16_grads}')
164
+ self.model = DDP(
165
+ model.to(get_default_device()),
166
+ find_unused_parameters=find_unused,
167
+ )
168
+ if bf16_grads:
169
+ from torch.distributed.algorithms.ddp_comm_hooks import default_hooks
170
+ self.model.register_comm_hook(None, default_hooks.bf16_compress_hook)
171
+ if compile_mode != 'disable':
172
+ # compile the DDP module so Dynamo's DDPOptimizer splits the
173
+ # graph at gradient-bucket boundaries (keeps comm overlap).
174
+ # OptimizedModule forwards attribute access to the DDP module,
175
+ # so .module / .no_sync() paths keep working.
176
+ logger.critical(f'torch.compile enabled (mode={compile_mode})')
177
+ self.model = torch.compile(self.model, **_compile_kwargs(compile_mode))
178
+ self.forward = self.model.forward
179
+ else:
180
+ self.model = Wrapper(model, compile_mode=compile_mode)
181
+ self.forward = self.model.forward
92
182
 
93
183
  def __getattr__(self, name: str) -> Any:
94
184
  try:
@@ -98,11 +188,28 @@ class DDPWraper(nn.Module):
98
188
 
99
189
 
100
190
  class FSDP2Wrapper(nn.Module):
101
- def __init__(self, model: FSDPModule) -> None:
191
+ def __init__(self, model: FSDPModule, compile_mode: str = 'disable') -> None:
102
192
  super().__init__()
103
193
  self.model = model
194
+ if compile_mode == 'reduce-overhead':
195
+ logger.critical(
196
+ 'compile_mode=reduce-overhead is unsupported with fsdp2 '
197
+ '(cudagraphs need static addresses; FSDP all-gathers allocate '
198
+ 'dynamically) — downgrading to "default".')
199
+ compile_mode = 'default'
200
+ if compile_mode != 'disable':
201
+ logger.critical(f'torch.compile enabled (mode={compile_mode})')
202
+ # compile the callable, NOT stored as a submodule: registering the
203
+ # OptimizedModule would duplicate every parameter under a second
204
+ # name and break optimizer construction / state_dict.
205
+ object.__setattr__(self, '_compiled_fwd',
206
+ torch.compile(model, **_compile_kwargs(compile_mode)))
207
+ else:
208
+ object.__setattr__(self, '_compiled_fwd', None)
104
209
 
105
210
  def forward(self, *args: Any, **kwargs: Any) -> Any:
211
+ if self._compiled_fwd is not None:
212
+ return self._compiled_fwd(*args, **kwargs)
106
213
  return self.model(*args, **kwargs) # type: ignore
107
214
 
108
215
  def __getattr__(self, name: str) -> Any:
@@ -137,6 +244,37 @@ class DDPRunnerConfig(RunerConf):
137
244
  lr_scheduler: LRSchedulerConfig = LRSchedulerConfig()
138
245
 
139
246
  dp_type = OptionArg(default='ddp', options=['ddp', 'fsdp2'])
247
+ # torch.compile for the training model. 'default' = Inductor fusion;
248
+ # 'reduce-overhead' additionally wraps CUDA graphs (ddp only — with fsdp2 it
249
+ # is downgraded to 'default': cudagraphs' static-address requirement is
250
+ # incompatible with FSDP's dynamically allocated all-gather buffers).
251
+ # Explicit-by-config on purpose: anything that changes numerics or the
252
+ # execution path must be declared in the YAML, never auto-detected.
253
+ # NOTE 'disable' not 'off': YAML 1.1 parses bare off/on/yes/no as booleans
254
+ compile_mode = OptionArg(default='disable', options=['disable', 'default', 'reduce-overhead'])
255
+ # gather/log training metrics every N steps. At freq=1 the per-step cost is
256
+ # substantial for fast steps: a metrics all_gather + a dist_avg collective +
257
+ # ~6 wandb/logger calls + .item() syncs + a barrier — about half the step
258
+ # time for a 116M model. Logged values are point samples at the logged step.
259
+ metric_sync_freq = IntArg(1, min_value=1)
260
+ # DDP tuning. find_unused_parameters=1 (default, safest) supports tasks
261
+ # where a subset of parameters gets no grad on some steps (GAN/GRPO mode
262
+ # switching) at the cost of a per-step graph traversal and weaker comm
263
+ # overlap. Plain single-player training should set 0.
264
+ ddp_find_unused = BoolArg(default=True)
265
+ # compress gradient allreduce to bf16 (halves comm volume; fp32 master
266
+ # weights are untouched — only the wire format changes)
267
+ ddp_bf16_grads = BoolArg(default=False)
268
+ # double-buffered device prefetch: a background thread fetches batch N+1
269
+ # and copies it to the GPU on a side stream (pinned staging) while the GPU
270
+ # computes step N. Removes the serialized fetch+H2D segments from the step
271
+ # wall (measured ~40-120ms/step). Tensor-only batches.
272
+ device_prefetch = BoolArg(default=False)
273
+ # diagnostic: log per-step wall time split into fetch / h2d / traincall
274
+ # segments every metric_sync_freq steps. fetch = waiting on the loader,
275
+ # h2d = host-to-device copy (0 with device_prefetch), traincall = the
276
+ # task.train_step call including any GPU-queue wait it absorbs.
277
+ step_timing = BoolArg(default=False)
140
278
  model_precision = OptionArg(options=['fp32', 'fp16', 'bf16'], default='fp32')
141
279
  use_amp = BoolArg(default=False)
142
280
  amp_precision = OptionArg(options=['fp16', 'bf16'], default='fp16')
@@ -204,6 +342,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
204
342
  self.config.seed = self.config.seed.parse(hash(f'original_seed_{self.config.seed.value()}|step_{self.step}'))
205
343
 
206
344
  self.train_data_loaders = config.task_conf.get_train_dataloaders(
345
+ resume_states=getattr(self, '_resume_data_states', None),
207
346
  batch_size=config.sub_batch_size.value(), # type: ignore
208
347
  seed=config.seed.value(), # type: ignore
209
348
  shuffle=config.shuffle_dataset.value(), # type: ignore
@@ -321,6 +460,35 @@ class DDPRunner(Runner[DDPRunnerConfig]):
321
460
  else:
322
461
  raise ValueError(f'dp_type must be either "ddp" or "fsdp2", got "{self.dp_type}" instead.')
323
462
 
463
+ def _collect_data_states(self) -> list | None:
464
+ """Per-task shard-cursor tables, merged across ranks (rank0 only;
465
+ other ranks and loaders without cursor support return/contribute
466
+ None). A COLLECTIVE call when world_size > 1 — every rank must
467
+ reach it."""
468
+ per_task = [
469
+ loader.state_dict()
470
+ if loader is not None and hasattr(loader, 'state_dict') else None
471
+ for loader in self.train_data_loaders
472
+ ]
473
+ if not any(s is not None for s in per_task):
474
+ return None
475
+ if get_world_size() > 1:
476
+ gathered: list[Any] = [None] * get_world_size()
477
+ torch.distributed.all_gather_object(gathered, per_task)
478
+ if get_global_rank() != 0:
479
+ return None
480
+ merged: list = []
481
+ for task_idx in range(len(per_task)):
482
+ m: dict = {}
483
+ for rank_states in gathered:
484
+ state = rank_states[task_idx] if rank_states else None
485
+ if state:
486
+ for src, table in state.items():
487
+ m.setdefault(src, {}).update(table)
488
+ merged.append(m or None)
489
+ return merged
490
+ return per_task
491
+
324
492
  def save(self, directory: str | os.PathLike, *args, **kwargs) -> None:
325
493
  if isinstance(self.model, (DDPWraper, Wrapper)):
326
494
  model = self.model.model.module
@@ -346,11 +514,19 @@ class DDPRunner(Runner[DDPRunnerConfig]):
346
514
  else:
347
515
  raise ValueError(f'Unknown model type {type(self.model)}')
348
516
 
517
+ data_states = self._collect_data_states()
518
+
349
519
  if get_global_rank() == 0:
350
520
  path = Path(directory) / str(self.step)
351
521
  os.makedirs(path, exist_ok=True)
352
522
  self.model_loader.save_model(model, path) # type: ignore
353
523
 
524
+ if data_states is not None:
525
+ tmp_path = path / 'data_state.json.tmp'
526
+ with open(tmp_path, 'w') as f:
527
+ json.dump(data_states, f)
528
+ os.replace(tmp_path, path / 'data_state.json')
529
+
354
530
  optimizer_path = path / 'optimizer.pt'
355
531
  torch.save(optim_states, optimizer_path)
356
532
 
@@ -378,12 +554,18 @@ class DDPRunner(Runner[DDPRunnerConfig]):
378
554
  model = model.bfloat16() # type: ignore
379
555
  elif self.config.model_precision.value() == 'fp32':
380
556
  model = model.float() # type: ignore
557
+ compile_mode = self.config.compile_mode.value()
558
+ assert compile_mode is not None
381
559
  if isinstance(model, FSDPModule):
382
560
  logger.critical(f'wrapping model with FSDPModule')
383
- self._model = FSDP2Wrapper(model)
561
+ self._model = FSDP2Wrapper(model, compile_mode=compile_mode)
384
562
  else:
385
563
  logger.critical(f'wrapping model with DDPWraper')
386
- self._model = DDPWraper(model)
564
+ self._model = DDPWraper(
565
+ model, compile_mode=compile_mode,
566
+ find_unused=self.config.ddp_find_unused.value(),
567
+ bf16_grads=self.config.ddp_bf16_grads.value(),
568
+ )
387
569
  assert self._model is not None
388
570
  return self._model
389
571
 
@@ -513,11 +695,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
513
695
  epochs = [loader.epoch for loader in self.train_data_loaders if loader is not None]
514
696
  return max(epochs) + self.pre_epoch
515
697
 
516
- def step_log(self, data: dict[str, Any]) -> None:
698
+ def step_log(self, data: dict[str, Any], console: bool = True) -> None:
517
699
  self._wandb
518
700
  if get_global_rank() != 0:
519
701
  return
520
- self.logger.critical(f'step:{self.step}\t{data}')
702
+ if console:
703
+ self.logger.critical(f'step:{self.step}\t{data}')
521
704
  wandb.log(data, step=self.step)
522
705
 
523
706
  def _one_train_step(self, **kwargs: Any) -> None:
@@ -531,6 +714,9 @@ class DDPRunner(Runner[DDPRunnerConfig]):
531
714
 
532
715
  # calculate loss
533
716
  running_loss: float = 0.0
717
+ running_loss_t = None
718
+ _msf = self.config.metric_sync_freq.value() or 1
719
+ log_this = (self.step % _msf == 0)
534
720
  batch_datas: defaultdict[Hashable, List[float]] = defaultdict(list)
535
721
  subbatch_result_lst: list[Batch] = []
536
722
  for acc_idx in range(self.config._num_acc_steps):
@@ -558,13 +744,41 @@ class DDPRunner(Runner[DDPRunnerConfig]):
558
744
  self.model.set_requires_gradient_sync(True, recurse=True)
559
745
 
560
746
  # compute loss for each sub_batch
747
+ import time as _time
748
+ _tf0 = _time.perf_counter()
749
+ if self.config.device_prefetch.value():
750
+ if not hasattr(self, '_prefetchers'):
751
+ self._prefetchers = {}
752
+ pf = self._prefetchers.get(task_id)
753
+ if pf is None:
754
+ pf = _DevicePrefetcher(
755
+ self.train_data_loaders[task_id],
756
+ get_default_device(),
757
+ self.config.model_precision.value(),
758
+ )
759
+ self._prefetchers[task_id] = pf
760
+ _dev_batch = pf.next()
761
+ _tf1 = _tf2 = _time.perf_counter()
762
+ else:
763
+ _raw_batch = self._next_train_batch(task_id)
764
+ _tf1 = _time.perf_counter()
765
+ _dev_batch = self._batch_to_device(_raw_batch)
766
+ _tf2 = _time.perf_counter()
767
+ if self.config.step_timing.value():
768
+ if not hasattr(self, '_phase_acc'):
769
+ self._phase_acc = [0.0, 0.0, 0.0, 0]
770
+ self._phase_acc[0] += _tf1 - _tf0
771
+ self._phase_acc[1] += _tf2 - _tf1
772
+ _tf2b = _time.perf_counter()
561
773
  subbatch_result = task.train_step(
562
774
  model=self.model, # type: ignore
563
- batch=self._batch_to_device(self._next_train_batch(task_id)),
564
- step=self.step,
775
+ batch=_dev_batch,
776
+ step=self.step,
565
777
  device=get_local_rank(),
566
778
  acc_step=acc_idx,
567
779
  )
780
+ if self.config.step_timing.value():
781
+ self._phase_acc[2] += _time.perf_counter() - _tf2b
568
782
  if isinstance(subbatch_result, torch.Tensor):
569
783
  subbatch_result = {'loss': subbatch_result}
570
784
  if 'loss' not in subbatch_result:
@@ -575,7 +789,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
575
789
  subbatch_result_lst.append(subbatch_result) # type: ignore
576
790
  assert isinstance(loss, Tensor)
577
791
  loss = loss / self.config._num_acc_steps
578
- running_loss += loss.item()
792
+ running_loss_t = loss.detach() if running_loss_t is None else running_loss_t + loss.detach()
579
793
  for k, v in subbatch_result.items():
580
794
  if k == 'loss':
581
795
  continue
@@ -589,30 +803,66 @@ class DDPRunner(Runner[DDPRunnerConfig]):
589
803
  else:
590
804
  loss.backward()
591
805
 
592
- running_loss = dist_avg(running_loss)
593
- self.step_log({f'{task.__class__.__name__}/train/loss': running_loss})
594
- self.step_log({'train/epoch': self.epoch})
595
- for k, v in batch_datas.items():
596
- try:
597
- avg = sum(v) / len(v)
598
- except:
599
- continue
600
- self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
601
- self._pbar_train.set_description(
602
- f'train loss: {running_loss:.3g}', refresh=True
603
- )
806
+ if log_this and getattr(self, '_phase_acc', None):
807
+ n = max(self._phase_acc[3], 1)
808
+ fetch_ms = self._phase_acc[0] / n * 1000
809
+ h2d_ms = self._phase_acc[1] / n * 1000
810
+ traincall_ms = self._phase_acc[2] / n * 1000
811
+ logger.critical(
812
+ f'[timing] fetch={fetch_ms:.1f}ms h2d={h2d_ms:.1f}ms '
813
+ f'traincall={traincall_ms:.1f}ms per-step (over {n} steps)')
814
+ # wandb: rank0's own segments plus the all-rank max of each — the
815
+ # max is the diagnostic one (in DDP the slowest rank paces the
816
+ # whole step, e.g. a data-starved rank shows up as fetch_ms_max)
817
+ t = torch.tensor([fetch_ms, h2d_ms, traincall_ms], device=get_default_device())
818
+ if torch.distributed.is_initialized():
819
+ torch.distributed.all_reduce(t, op=torch.distributed.ReduceOp.MAX)
820
+ mx = t.tolist()
821
+ self.step_log({
822
+ 'timing/fetch_ms': fetch_ms,
823
+ 'timing/h2d_ms': h2d_ms,
824
+ 'timing/traincall_ms': traincall_ms,
825
+ 'timing/fetch_ms_max': mx[0],
826
+ 'timing/h2d_ms_max': mx[1],
827
+ 'timing/traincall_ms_max': mx[2],
828
+ })
829
+ self._phase_acc = [0.0, 0.0, 0.0, 0]
830
+ if getattr(self, '_phase_acc', None) is not None:
831
+ self._phase_acc[3] += 1
832
+ if log_this:
833
+ running_loss = running_loss_t.item() if running_loss_t is not None else 0.0
834
+ running_loss = dist_avg(running_loss)
835
+ self.step_log({f'{task.__class__.__name__}/train/loss': running_loss})
836
+ self.step_log({'train/epoch': self.epoch})
837
+ if log_this:
838
+ for k, v in batch_datas.items():
839
+ try:
840
+ avg = sum(v) / len(v)
841
+ except:
842
+ continue
843
+ self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
844
+ # the sampled step is part of the label: loss refreshes every
845
+ # metric_sync_freq steps while the bar ticks every step, and an
846
+ # unlabeled value reads as a frozen metric
847
+ self._pbar_train.set_postfix_str(
848
+ f'loss={running_loss:.3g}@{self.step}', refresh=True
849
+ )
604
850
 
605
851
  grad_norm_clip_val = self.config.grad_norm_clip.value()
606
852
  if grad_norm_clip_val is not None:
607
853
  if self.scaler is not None:
608
854
  self.scaler.unscale_(self.optimizer)
609
- norm = clip_grad_norm_(
855
+ norm_t = clip_grad_norm_(
610
856
  parameters=self.model.parameters(), # type: ignore
611
857
  max_norm=grad_norm_clip_val
612
- ).item()
613
- norm = dist_avg(norm)
614
- self.step_log({f'{task.__class__.__name__}/train/grad_norm': norm})
858
+ )
859
+ if log_this:
860
+ norm = dist_avg(norm_t.item())
861
+ self.step_log({f'{task.__class__.__name__}/train/grad_norm': norm})
615
862
 
863
+ # pure host-side work — no .item() sync, no collective — so it runs and
864
+ # reports every step (wandb enqueue is async; console line only on
865
+ # log steps to keep the terminal readable)
616
866
  num_hot_params = 0
617
867
  num_freeze_params = 0
618
868
  for param in self.model.parameters(): # type: ignore
@@ -624,13 +874,12 @@ class DDPRunner(Runner[DDPRunnerConfig]):
624
874
  num_total_params = num_hot_params + num_freeze_params
625
875
  if num_total_params == 0:
626
876
  raise RuntimeError('The model contains no parameters.')
627
-
628
877
  self.step_log({
629
878
  f'{task.__class__.__name__}/train/num_hot_params': num_hot_params,
630
879
  f'{task.__class__.__name__}/train/num_freeze_params': num_freeze_params,
631
880
  f'{task.__class__.__name__}/train/num_total_params': num_total_params,
632
881
  f'{task.__class__.__name__}/train/hot_ratio': num_hot_params / num_total_params,
633
- })
882
+ }, console=log_this)
634
883
 
635
884
  if self.scaler is not None:
636
885
  self.scaler.step(self.optimizer)
@@ -642,21 +891,22 @@ class DDPRunner(Runner[DDPRunnerConfig]):
642
891
  current_lr = current_lr[0]
643
892
  self.step_log(
644
893
  {f'{task.__class__.__name__}/train/learning_rate': current_lr}
645
- )
894
+ ) if log_this else None
646
895
  self.scheduler.step()
647
896
 
648
- other_step_results = defaultdict(list)
649
- for r in subbatch_result_lst:
650
- for k, v in r.items():
651
- if isinstance(v, (float, int)):
652
- other_step_results[k].append(float(v))
653
- all_other_step_results = batch_all_gather(other_step_results)
654
- for k, v in all_other_step_results.items():
655
- try:
656
- avg = sum(v) / len(v)
657
- except:
658
- continue
659
- self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
897
+ if log_this:
898
+ other_step_results = defaultdict(list)
899
+ for r in subbatch_result_lst:
900
+ for k, v in r.items():
901
+ if isinstance(v, (float, int)):
902
+ other_step_results[k].append(float(v))
903
+ all_other_step_results = batch_all_gather(other_step_results)
904
+ for k, v in all_other_step_results.items():
905
+ try:
906
+ avg = sum(v) / len(v)
907
+ except:
908
+ continue
909
+ self.step_log({f'{task.__class__.__name__}/train/{k}': avg})
660
910
 
661
911
  save_step_freq = self.config.save_step_freq.value()
662
912
  assert save_step_freq is not None
@@ -666,7 +916,7 @@ class DDPRunner(Runner[DDPRunnerConfig]):
666
916
  assert ckpt_path is not None
667
917
  self.save(ckpt_path)
668
918
  self._pbar_train.set_description('model saved!', True)
669
- torch.distributed.barrier() if get_world_size() > 1 else ...
919
+ torch.distributed.barrier() if get_world_size() > 1 else ...
670
920
 
671
921
  eval_step_freq = self.config.eval_step_freq.value()
672
922
  assert eval_step_freq is not None
@@ -680,6 +930,16 @@ class DDPRunner(Runner[DDPRunnerConfig]):
680
930
  self.eval() # evaluate first
681
931
  self._prepare_train()
682
932
 
933
+ # route log lines through tqdm.write for the training run so they
934
+ # print cleanly above the bar (plain StreamHandlers tear through it)
935
+ root_logger = logging.getLogger()
936
+ if not any(isinstance(h, _TqdmLogHandler) for h in root_logger.handlers):
937
+ tqdm_handler = _TqdmLogHandler()
938
+ tqdm_handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
939
+ for h in [x for x in root_logger.handlers if type(x) is logging.StreamHandler]:
940
+ root_logger.removeHandler(h)
941
+ root_logger.addHandler(tqdm_handler)
942
+
683
943
  self._last_epoch = self.epoch
684
944
  stop_metric = self.config.stop_by.value()
685
945
  assert stop_metric in ('step', 'epoch')
@@ -707,6 +967,8 @@ class DDPRunner(Runner[DDPRunnerConfig]):
707
967
  while not self._should_stop():
708
968
  self._one_train_step()
709
969
  if stop_metric == 'step':
970
+ # per-step update is fine: tqdm only renders every mininterval
971
+ # (0.1s); the call itself is just a counter bump
710
972
  self._pbar_train.update(1)
711
973
  elif stop_metric == 'epoch':
712
974
  current_epoch = self.epoch
@@ -905,6 +1167,19 @@ class DDPRunner(Runner[DDPRunnerConfig]):
905
1167
  else:
906
1168
  logger.warning(f'scheduler.pt not found in {directory}, skip loading scheduler')
907
1169
 
1170
+ data_state_path = Path(directory) / 'data_state.json'
1171
+ if data_state_path.exists():
1172
+ with open(data_state_path, 'r') as f:
1173
+ self._resume_data_states = json.load(f)
1174
+ n = sum(
1175
+ len(tab)
1176
+ for task_state in self._resume_data_states if task_state
1177
+ for tab in task_state.values()
1178
+ )
1179
+ logger.info(f'loaded data stream state ({n} shard cursors)')
1180
+ else:
1181
+ logger.info('no data_state.json in checkpoint — data stream restarts from scratch')
1182
+
908
1183
  runner_path = Path(directory) / 'runner.json'
909
1184
  if runner_path.exists():
910
1185
  with open(runner_path, 'r') as f:
@@ -110,6 +110,7 @@ class TaskBase(Conf, SubclassTracer):
110
110
  num_batch_workers: int = 4,
111
111
  batch_queue_depth: int = 4,
112
112
  batch_slot_mb: int = 128,
113
+ resume_state: dict | None = None,
113
114
  ) -> None | DataLoader | PyTorchDataLoader | BatchDataLoader | EmptyDataLoader:
114
115
  if self.num_train_data_path.value() == 0:
115
116
  return None
@@ -143,6 +144,9 @@ class TaskBase(Conf, SubclassTracer):
143
144
  num_ranks=world_size,
144
145
  rank_idx=rank,
145
146
  worker_timeout=worker_timeout,
147
+ # only forward when present: lmfuser-data < 0.3.0 has no
148
+ # resume_state parameter
149
+ **({'resume_state': resume_state} if resume_state else {}),
146
150
  )
147
151
  elif dataloader_type == 'sharded':
148
152
  self._train_dataloader = DataLoader(
@@ -430,6 +434,7 @@ class Tasks(Conf):
430
434
  num_batch_workers: int = 4,
431
435
  batch_queue_depth: int = 4,
432
436
  batch_slot_mb: int = 128,
437
+ resume_states: list[dict | None] | None = None,
433
438
  ) -> list[DataLoader | None | PyTorchDataLoader | BatchDataLoader | EmptyDataLoader]:
434
439
  return [
435
440
  task.conf._get_train_dataloader(
@@ -447,8 +452,9 @@ class Tasks(Conf):
447
452
  num_batch_workers=num_batch_workers,
448
453
  batch_queue_depth=batch_queue_depth,
449
454
  batch_slot_mb=batch_slot_mb,
455
+ resume_state=(resume_states[i] if resume_states else None),
450
456
  )
451
- for task in self.tasks
457
+ for i, task in enumerate(self.tasks)
452
458
  ]
453
459
 
454
460
  def get_eval_dataloaders(
@@ -207,16 +207,11 @@ def batch_all_gather(batch: dict[str, Any]) -> dict[str, Any]:
207
207
  Returns:
208
208
  Dict[Dict[str, Union[Tensor, List[Any]]]]: 汇总后的batch
209
209
  """
210
- logger.info(f'Begin all gather on rank {get_global_rank()}')
211
210
  if not dist.is_initialized():
212
211
  return batch
213
212
 
214
213
  gathered = {}
215
214
  for k, v_list in batch.items():
216
- logger.info(f'Begin gather key {k} on rank {get_global_rank()}')
217
- if isinstance(v_list, list):
218
- logger.info(f'Key {k} with length {len(v_list)} on rank {get_global_rank()}')
219
- logger.info(f'after barrier on rank {get_global_rank()}')
220
215
  if isinstance(v_list, Tensor):
221
216
  gathered[k] = tensor_all_gather(v_list)
222
217
  else:
@@ -0,0 +1,72 @@
1
+ """FSDP2Wrapper + compile_mode coverage (synthetic, single GPU).
2
+
3
+ Run: torchrun --nproc_per_node=1 tests/test_fsdp2_compile.py
4
+
5
+ Covers, per compile_mode in {disable, default, reduce-overhead}:
6
+ * forward/backward/optimizer step run under fully_shard;
7
+ * parameters are NOT duplicated by compilation (the OptimizedModule must not
8
+ be registered as a submodule);
9
+ * 'reduce-overhead' downgrades to 'default' with a critical log.
10
+ """
11
+ import logging
12
+ import os
13
+ import sys
14
+
15
+ import torch
16
+ from torch import nn
17
+ import torch.distributed as dist
18
+ from torch.distributed.fsdp import fully_shard
19
+
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
21
+ from lmfuser.runners.ddp_runner import FSDP2Wrapper # noqa: E402
22
+
23
+
24
+ def build_model() -> nn.Module:
25
+ torch.manual_seed(0)
26
+ return nn.Sequential(
27
+ nn.Linear(64, 128), nn.GELU(), nn.Linear(128, 64),
28
+ )
29
+
30
+
31
+ def run_mode(mode: str) -> None:
32
+ model = build_model().cuda()
33
+ n_params_before = len(list(model.parameters()))
34
+ fully_shard(model)
35
+
36
+ records: list[str] = []
37
+ handler = logging.Handler()
38
+ handler.emit = lambda r: records.append(r.getMessage()) # type: ignore
39
+ logging.getLogger('lmfuser.runners.ddp_runner').addHandler(handler)
40
+
41
+ wrapper = FSDP2Wrapper(model, compile_mode=mode)
42
+
43
+ # ---- parameter registration must not be duplicated by compile ----
44
+ n_params_after = len(list(wrapper.parameters()))
45
+ assert n_params_after == n_params_before, \
46
+ f'[{mode}] parameter duplication: {n_params_before} -> {n_params_after}'
47
+ opt = torch.optim.AdamW(wrapper.parameters(), lr=1e-3) # raises on duplicates
48
+
49
+ # ---- train a few steps ----
50
+ for _ in range(3):
51
+ x = torch.randn(8, 64, device='cuda')
52
+ loss = wrapper(x).square().mean()
53
+ loss.backward()
54
+ opt.step()
55
+ opt.zero_grad()
56
+ assert torch.isfinite(loss).item(), f'[{mode}] non-finite loss'
57
+
58
+ # ---- downgrade warning ----
59
+ if mode == 'reduce-overhead':
60
+ assert any('downgrading' in m for m in records), \
61
+ '[reduce-overhead] downgrade warning not logged'
62
+
63
+ print(f'PASS fsdp2 compile_mode={mode} (loss={loss.item():.4f})')
64
+
65
+
66
+ if __name__ == '__main__':
67
+ dist.init_process_group(backend='nccl')
68
+ torch.cuda.set_device(0)
69
+ for mode in ('disable', 'default', 'reduce-overhead'):
70
+ run_mode(mode)
71
+ dist.destroy_process_group()
72
+ print('FSDP2_COMPILE_TESTS_PASS')
File without changes
File without changes
File without changes
File without changes
File without changes