trainloop 0.7.0__tar.gz → 0.8.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,11 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: trainloop
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Minimal PyTorch training loop with hooks and checkpointing.
5
5
  Author: Karim Knaebel
6
6
  Author-email: Karim Knaebel <contact@knaebel.dev>
7
- Requires-Dist: pillow>=11.3.0
8
- Requires-Dist: torch>=2.0.0
7
+ Requires-Dist: torch>=2.1.0
9
8
  Requires-Python: >=3.10
10
9
  Description-Content-Type: text/markdown
11
10
 
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "trainloop"
3
+ version = "0.8.0"
4
+ description = "Minimal PyTorch training loop with hooks and checkpointing."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = ["torch>=2.1.0"]
8
+
9
+ [[project.authors]]
10
+ name = "Karim Knaebel"
11
+ email = "contact@knaebel.dev"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.9.0,<0.10.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.4.0",
20
+ "ruff>=0.11.13",
21
+ "zensical>=0.0.10",
22
+ "mkdocstrings-python>=2.0.1",
23
+ "pillow>=11.3.0",
24
+ "wandb>=0.20.1",
25
+ "numpy>=2.2.6",
26
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "trainloop"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Minimal PyTorch training loop with hooks and checkpointing."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,8 +8,7 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
- "pillow>=11.3.0",
12
- "torch>=2.0.0",
11
+ "torch>=2.1.0",
13
12
  ]
14
13
 
15
14
  [build-system]
@@ -22,6 +21,7 @@ dev = [
22
21
  "ruff>=0.11.13",
23
22
  "zensical>=0.0.10",
24
23
  "mkdocstrings-python>=2.0.1",
24
+ "pillow>=11.3.0",
25
25
  "wandb>=0.20.1",
26
26
  "numpy>=2.2.6",
27
27
  ]
@@ -1,8 +1,8 @@
1
1
  from .hooks import (
2
2
  BaseHook,
3
3
  CheckpointingHook,
4
- CudaMaxMemoryHook,
5
- EmaHook,
4
+ CUDAMaxMemoryHook,
5
+ EMAHook,
6
6
  ImageFileLoggerHook,
7
7
  ProgressHook,
8
8
  StatsHook,
@@ -12,16 +12,16 @@ from .hooks import (
12
12
  from .trainer import BaseTrainer, LossNoneWarning, map_nested_tensor
13
13
 
14
14
  __all__ = [
15
- "BaseTrainer",
16
15
  "BaseHook",
16
+ "BaseTrainer",
17
+ "CUDAMaxMemoryHook",
17
18
  "CheckpointingHook",
18
- "CudaMaxMemoryHook",
19
+ "EMAHook",
20
+ "ImageFileLoggerHook",
21
+ "LossNoneWarning",
19
22
  "ProgressHook",
20
23
  "StatsHook",
21
24
  "TrainingStats",
22
- "EmaHook",
23
25
  "WandbHook",
24
- "ImageFileLoggerHook",
25
- "LossNoneWarning",
26
26
  "map_nested_tensor",
27
27
  ]
@@ -5,21 +5,20 @@ import sys
5
5
  import tempfile
6
6
  import time
7
7
  import warnings
8
+ from collections.abc import Callable, Iterable, Sequence
8
9
  from dataclasses import dataclass
9
10
  from datetime import timedelta
10
11
  from numbers import Number
11
12
  from pathlib import Path
12
- from typing import Any, Callable, Iterable, Literal, Sequence
13
+ from typing import Any, Literal
13
14
 
14
15
  import torch
15
16
  import torch.distributed as dist
16
- from PIL import Image
17
- from PIL.Image import Image as PILImage
18
17
  from torch.distributed.checkpoint.state_dict import (
19
18
  get_model_state_dict,
20
19
  set_model_state_dict,
21
20
  )
22
- from torch.optim.swa_utils import AveragedModel, get_ema_avg_fn
21
+ from torch.optim.swa_utils import AveragedModel, get_ema_multi_avg_fn
23
22
 
24
23
  try:
25
24
  import wandb
@@ -27,8 +26,19 @@ except ImportError:
27
26
  # only needed for WandbHook
28
27
  pass
29
28
 
29
+ try:
30
+ from PIL import Image
31
+ from PIL.Image import Image as PILImage
32
+ except ImportError:
33
+ # only needed for WandbHook JPEG conversion
34
+ pass
35
+
30
36
  from .trainer import BaseTrainer, Records
31
- from .utils import flatten_nested_dict, key_average
37
+ from .utils import (
38
+ flatten_nested_dict,
39
+ key_average,
40
+ log_state_dict_incompatible_keys,
41
+ )
32
42
 
33
43
 
34
44
  def _dist_is_initialized() -> bool:
@@ -85,8 +95,8 @@ class TrainingStats:
85
95
  grad_norm: float | None
86
96
  step_time: float
87
97
  data_time: float
88
- non_finite_grad_retry_count: float
89
- max_memory: float | None
98
+ non_finite_grad_retries: float
99
+ cuda_max_memory: float | None
90
100
  records: Records
91
101
  param_groups: list[dict[str, Any]]
92
102
 
@@ -119,8 +129,8 @@ class StatsHook(BaseHook):
119
129
  self.grad_norms = []
120
130
  self.data_times = []
121
131
  self.step_times = []
122
- self.non_finite_grad_retry_counts = []
123
- self.max_memories = []
132
+ self.non_finite_grad_retries = []
133
+ self.cuda_max_memories = []
124
134
 
125
135
  def on_before_step(self, trainer: BaseTrainer):
126
136
  super().on_before_step(trainer)
@@ -137,11 +147,11 @@ class StatsHook(BaseHook):
137
147
  self.records_ls.append(key_average(trainer.step_info["records"]))
138
148
  self.data_times.append(sum(trainer.step_info["data_time"])) # total
139
149
  self.step_times.append(trainer.step_info["step_time"])
140
- self.non_finite_grad_retry_counts.append(
141
- trainer.step_info["non_finite_grad_retry_count"]
150
+ self.non_finite_grad_retries.append(
151
+ trainer.step_info["non_finite_grad_retries"]
142
152
  )
143
- if "max_memory" in trainer.step_info:
144
- self.max_memories.append(trainer.step_info["max_memory"])
153
+ if "cuda_max_memory" in trainer.step_info:
154
+ self.cuda_max_memories.append(trainer.step_info["cuda_max_memory"])
145
155
 
146
156
  if trainer.step % self.interval == 0 or trainer.step == trainer.max_steps:
147
157
  # aggregate over steps
@@ -150,10 +160,12 @@ class StatsHook(BaseHook):
150
160
  records = key_average(self.records_ls)
151
161
  data_time = sum(self.data_times) / len(self.data_times)
152
162
  step_time = sum(self.step_times) / len(self.step_times)
153
- non_finite_grad_retry_count = sum(self.non_finite_grad_retry_counts) / len(
154
- self.non_finite_grad_retry_counts
163
+ non_finite_grad_retries = sum(self.non_finite_grad_retries) / len(
164
+ self.non_finite_grad_retries
165
+ )
166
+ cuda_max_memory = (
167
+ max(self.cuda_max_memories) if self.cuda_max_memories else None
155
168
  )
156
- max_memory = max(self.max_memories) if self.max_memories else None
157
169
 
158
170
  if self.sync and _dist_world_size() > 1:
159
171
  # aggregate accross all ranks
@@ -168,18 +180,18 @@ class StatsHook(BaseHook):
168
180
  "records": records,
169
181
  "data_time": data_time,
170
182
  "step_time": step_time,
171
- "non_finite_grad_retry_count": non_finite_grad_retry_count,
172
- "max_memory": max_memory,
183
+ "non_finite_grad_retries": non_finite_grad_retries,
184
+ "cuda_max_memory": cuda_max_memory,
173
185
  },
174
186
  )
175
187
  records = key_average([stat["records"] for stat in gathered])
176
188
  data_time = sum(stat["data_time"] for stat in gathered) / len(gathered)
177
189
  step_time = sum(stat["step_time"] for stat in gathered) / len(gathered)
178
- non_finite_grad_retry_count = sum(
179
- stat["non_finite_grad_retry_count"] for stat in gathered
190
+ non_finite_grad_retries = sum(
191
+ stat["non_finite_grad_retries"] for stat in gathered
180
192
  ) / len(gathered)
181
- if "max_memory" in trainer.step_info:
182
- max_memory = max(stat["max_memory"] for stat in gathered)
193
+ if "cuda_max_memory" in trainer.step_info:
194
+ cuda_max_memory = max(stat["cuda_max_memory"] for stat in gathered)
183
195
 
184
196
  self.callback(
185
197
  trainer,
@@ -188,8 +200,8 @@ class StatsHook(BaseHook):
188
200
  grad_norm=grad_norm.item() if grad_norm is not None else None,
189
201
  step_time=step_time,
190
202
  data_time=data_time,
191
- non_finite_grad_retry_count=non_finite_grad_retry_count,
192
- max_memory=max_memory,
203
+ non_finite_grad_retries=non_finite_grad_retries,
204
+ cuda_max_memory=cuda_max_memory,
193
205
  records=records,
194
206
  param_groups=self.param_groups,
195
207
  ),
@@ -277,8 +289,8 @@ class ProgressHook(StatsHook):
277
289
  + f" step {stats.step_time:.4f}{'s' if self.show_units else ''} data {stats.data_time:.4f}{'s' if self.show_units else ''}"
278
290
  + (f" eta {eta}" if eta is not None else "")
279
291
  + (
280
- f" mem {stats.max_memory:#.3g}{'GiB' if self.show_units else ''}"
281
- if stats.max_memory is not None
292
+ f" mem {stats.cuda_max_memory:#.3g}{'GiB' if self.show_units else ''}"
293
+ if stats.cuda_max_memory is not None
282
294
  else ""
283
295
  )
284
296
  + f" loss {stats.loss:.4f}"
@@ -333,7 +345,7 @@ class CheckpointingHook(BaseHook):
333
345
  | None = None, # save and keep checkpoints at these steps
334
346
  path: Path | str = "checkpoints",
335
347
  load: Path | str | Literal["latest"] | None = "latest",
336
- exit_signals: list[signal.Signals] | signal.Signals = None,
348
+ exit_signals: list[signal.Signals] | signal.Signals | None = None,
337
349
  exit_code: int | Literal["128+signal"] = "128+signal",
338
350
  exit_wait: timedelta | float = 0.0,
339
351
  ):
@@ -551,39 +563,54 @@ class CheckpointingHook(BaseHook):
551
563
  return False
552
564
 
553
565
 
554
- class CudaMaxMemoryHook(BaseHook):
566
+ class CUDAMaxMemoryHook(BaseHook):
555
567
  """Record peak CUDA memory per step into ``trainer.step_info``."""
556
568
 
557
569
  def on_before_step(self, trainer: BaseTrainer):
558
570
  torch.cuda.reset_peak_memory_stats(trainer.device)
559
571
 
560
572
  def on_after_step(self, trainer: BaseTrainer):
561
- trainer.step_info["max_memory"] = torch.cuda.max_memory_allocated(
573
+ trainer.step_info["cuda_max_memory"] = torch.cuda.max_memory_allocated(
562
574
  trainer.device
563
575
  ) / (1024**3) # GiB
564
576
 
565
577
 
566
- class EmaHook(BaseHook):
578
+ class EMAHook(BaseHook):
567
579
  """Maintain an exponential moving average of model weights.
568
580
 
569
581
  Args:
570
582
  decay: EMA decay rate.
583
+ use_buffers: Whether to include model buffers in the EMA.
571
584
  """
572
585
 
573
- def __init__(self, decay: float):
586
+ def __init__(self, decay: float = 0.999, use_buffers: bool = False):
574
587
  self.decay = decay
588
+ self.use_buffers = use_buffers
575
589
 
576
590
  def on_before_train(self, trainer: BaseTrainer):
577
591
  trainer.logger.info("=> Creating EMA model ...")
578
592
  # Note that AveragedModel does not seem to support FSDP. It will crash here.
579
- self.ema_model = AveragedModel(trainer.model, avg_fn=get_ema_avg_fn(self.decay))
593
+ self.ema_model = AveragedModel(
594
+ trainer.model,
595
+ multi_avg_fn=get_ema_multi_avg_fn(self.decay),
596
+ use_buffers=self.use_buffers,
597
+ )
598
+ # TODO: could be useful to implement a decay warmup via custom avg_fn
580
599
 
581
600
  def on_after_step(self, trainer: BaseTrainer):
582
601
  self.ema_model.update_parameters(trainer.model)
583
602
 
584
603
  def on_load_state_dict(self, trainer: BaseTrainer, state_dict: dict):
585
604
  trainer.logger.info("=> Loading EMA model state ...")
586
- set_model_state_dict(self.ema_model, state_dict["ema_model"])
605
+ incompatible_keys = set_model_state_dict(
606
+ self.ema_model, state_dict["ema_model"]
607
+ )
608
+ # This currently doesn't do anything because strict=True is implicit.
609
+ log_state_dict_incompatible_keys(
610
+ trainer.logger,
611
+ incompatible_keys.missing_keys,
612
+ incompatible_keys.unexpected_keys,
613
+ )
587
614
 
588
615
  def on_state_dict(self, trainer: BaseTrainer, state_dict: dict):
589
616
  # Note: sadly, we need to keep the AveragedModel wrapper, to save its n_averaged buffer
@@ -653,9 +680,18 @@ class WandbHook(BaseHook):
653
680
  trainer.logger.debug(f"Dry run log. Would log: {data}")
654
681
 
655
682
  def on_log_images(self, trainer: BaseTrainer, records: dict, dry_run: bool = False):
683
+ """Note that the final component of each flattened key becomes the image caption,
684
+ and the remaining components become the W&B key. For example,
685
+ ``foo/bar/name1`` and ``foo/bar/name2`` are logged as two images under
686
+ ``foo/bar``, so W&B displays them together in one panel.
687
+
688
+ The list is replaced rather than extended when the same W&B key is logged
689
+ again at the same step. Callers should therefore collect all images for a
690
+ panel and pass them in a single call per step.
691
+ """
656
692
  if _dist_rank() == 0:
657
693
  wandb_data = {}
658
- for k, img in flatten_nested_dict({"vis": records}).items():
694
+ for k, img in flatten_nested_dict(records).items():
659
695
  file_type = self.image_format(k)
660
696
  wandb_data.setdefault("/".join(k[:-1]), []).append(
661
697
  wandb.Image(
@@ -673,7 +709,7 @@ class WandbHook(BaseHook):
673
709
  trainer.logger.debug(f"Dry run log. Would log: {wandb_data}")
674
710
 
675
711
  @staticmethod
676
- def _ensure_jpeg_compatible(img: PILImage, bg_color: tuple = (255, 255, 255)):
712
+ def _ensure_jpeg_compatible(img: "PILImage", bg_color: tuple = (255, 255, 255)):
677
713
  if img.mode in ("RGB", "L"):
678
714
  return img
679
715
  elif img.mode in ("RGBA", "LA"):
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import time
3
3
  import warnings
4
+ from collections.abc import Callable, Iterable, Iterator
4
5
  from contextlib import closing, nullcontext
5
6
  from logging import Logger
6
7
  from numbers import Number
@@ -8,21 +9,20 @@ from pathlib import Path
8
9
  from typing import (
9
10
  TYPE_CHECKING,
10
11
  Any,
11
- Callable,
12
- Iterable,
13
- Iterator,
14
12
  TypeAlias,
15
13
  Union,
16
14
  )
17
15
 
18
16
  import torch
19
- import torch.nn as nn
17
+ from torch import nn
20
18
  from torch.distributed.checkpoint.state_dict import (
21
19
  StateDictOptions,
22
20
  get_state_dict,
23
21
  set_state_dict,
24
22
  )
25
23
 
24
+ from .utils import log_state_dict_incompatible_keys
25
+
26
26
  if TYPE_CHECKING:
27
27
  from .hooks import BaseHook
28
28
 
@@ -154,13 +154,18 @@ class BaseTrainer:
154
154
  self.grad_scaler.load_state_dict(training_state["grad_scaler"])
155
155
 
156
156
  self.logger.info("=> Loading model and optimizer state ...")
157
- set_state_dict(
157
+ incompatible_keys = set_state_dict(
158
158
  self.model,
159
159
  self.optimizer,
160
160
  model_state_dict=state_dict["model"],
161
161
  optim_state_dict=training_state["optimizer"],
162
162
  options=self.state_dict_options,
163
163
  )
164
+ log_state_dict_incompatible_keys(
165
+ self.logger,
166
+ incompatible_keys.missing_keys,
167
+ incompatible_keys.unexpected_keys,
168
+ )
164
169
 
165
170
  self.logger.info("=> Loading hook states ...")
166
171
  for h in self.hooks:
@@ -203,8 +208,8 @@ class BaseTrainer:
203
208
 
204
209
  reset_step_info()
205
210
  self.step_info["data_time"] = []
206
- non_finite_grad_retry_count = 0
207
- self.step_info["non_finite_grad_retry_count"] = non_finite_grad_retry_count
211
+ non_finite_grad_retries = 0
212
+ self.step_info["non_finite_grad_retries"] = non_finite_grad_retries
208
213
  i_acc = 0
209
214
  while i_acc < self.gradient_accumulation_steps:
210
215
  is_accumulating = i_acc < self.gradient_accumulation_steps - 1
@@ -278,15 +283,14 @@ class BaseTrainer:
278
283
  )
279
284
  ):
280
285
  if self.max_non_finite_grad_retries is None or (
281
- non_finite_grad_retry_count
282
- < self.max_non_finite_grad_retries
286
+ non_finite_grad_retries < self.max_non_finite_grad_retries
283
287
  ):
284
- non_finite_grad_retry_count += 1
285
- self.step_info["non_finite_grad_retry_count"] = (
286
- non_finite_grad_retry_count
288
+ non_finite_grad_retries += 1
289
+ self.step_info["non_finite_grad_retries"] = (
290
+ non_finite_grad_retries
287
291
  )
288
292
  self.logger.warning(
289
- f"Gradient is non-finite. Retrying step {self.step} (retry {non_finite_grad_retry_count}"
293
+ f"Gradient is non-finite. Retrying step {self.step} (retry {non_finite_grad_retries}"
290
294
  + (
291
295
  f"/{self.max_non_finite_grad_retries})."
292
296
  if self.max_non_finite_grad_retries is not None
@@ -1,7 +1,9 @@
1
1
  # modified from: https://github.com/microsoft/MoGe/blob/6b8b43db567ca4b08615c39b42cffd6c76cada29/moge/utils/tools.py
2
2
 
3
3
  import math
4
- from typing import Any, Generator, MutableMapping
4
+ from collections.abc import Generator, MutableMapping
5
+ from logging import Logger
6
+ from typing import Any
5
7
 
6
8
 
7
9
  def traverse_nested_dict_keys(
@@ -50,7 +52,7 @@ def key_average(list_of_dicts: list, exclude_nan: bool = False) -> dict[str, Any
50
52
 
51
53
 
52
54
  def flatten_nested_dict(
53
- d: dict[str, Any], parent_key: tuple[str, ...] = None
55
+ d: dict[str, Any], parent_key: tuple[str, ...] | None = None
54
56
  ) -> dict[tuple[str, ...], Any]:
55
57
  """
56
58
  Flattens a nested dictionary into a single-level dictionary, with keys as tuples.
@@ -65,3 +67,20 @@ def flatten_nested_dict(
65
67
  else:
66
68
  items.append((new_key, v))
67
69
  return dict(items)
70
+
71
+
72
+ def log_state_dict_incompatible_keys(
73
+ logger: Logger, missing_keys: list[str], unexpected_keys: list[str]
74
+ ) -> None:
75
+ if missing_keys:
76
+ logger.warning(
77
+ "Missing keys in state_dict: {}.".format(
78
+ ", ".join(f'"{key}"' for key in missing_keys)
79
+ )
80
+ )
81
+ if unexpected_keys:
82
+ logger.warning(
83
+ "Unexpected keys in state_dict: {}.".format(
84
+ ", ".join(f'"{key}"' for key in unexpected_keys)
85
+ )
86
+ )
File without changes