ho22joshua Claude Opus 5.5 commited on
Commit
3f1977c
·
1 Parent(s): 6285343

feat: upgrade to Python 3.14 and PyTorch 2.14; add torch.compile and bfloat16

Browse files

- Python 3.14, torch 2.14.1 (cu130 wheels), NumPy 2; frozen parity
references pass unchanged
- trainer.compile (dynamic-shape torch.compile) and trainer.precision
(bfloat16 autocast; graph sums, loss, optimizer in float32)
- CUDA graph steps use device-tensor learning rates, so one capture per
bucket follows the schedule; checkpoints store portable optimizer state
- trainer=paper_fast: compile + CUDA graphs + bfloat16, 5.9 vs 19.2 ms per
GamGam batch for float32 eager on an A100
- loader workers fork explicitly (Python 3.14 defaults to forkserver);
ONNX export keeps the TorchScript exporter

Correction to 5b73bcb: the pre-change float32 eager step was 21.3 ms per
batch (not ~29 ms; that profile used unshuffled ttH-only batches), so the
default path improved by about 1.2x and the large gains are opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

.python-version CHANGED
@@ -1 +1 @@
1
- 3.12
 
1
+ 3.14
CHANGELOG.md CHANGED
@@ -11,8 +11,10 @@
11
  selection (`checkpoint=best_auc`), and an ensemble Slurm script.
12
  - `data.derived_branches` (object selection and tag thresholds) and
13
  `data.fold_modulus` for ntuples outside the Delphes layout.
14
- - Opt-in CUDA graph training steps (`trainer.cuda_graphs`) and TF32 matmuls
15
- (`trainer.allow_tf32`); `trainer=paper_fast` enables both.
 
 
16
  - `data.loader.num_workers`/`pin_memory` for background batch collation.
17
  - Per-epoch `history.jsonl`, `inference.metrics_output`, early stopping on
18
  `train_loss`, and macro precision/recall/F1 for multiclass tasks.
@@ -21,6 +23,12 @@
21
 
22
  ### Changed
23
 
 
 
 
 
 
 
24
  - DGL was removed. Preparation is vectorized and writes a model-neutral packed
25
  `EventCache` (cache schema 2) instead of pickled DGL graphs; graph batches
26
  (`TensorGraph`, edge-index layout) and transformer token batches are built
 
11
  selection (`checkpoint=best_auc`), and an ensemble Slurm script.
12
  - `data.derived_branches` (object selection and tag thresholds) and
13
  `data.fold_modulus` for ntuples outside the Delphes layout.
14
+ - Opt-in `torch.compile` (`trainer.compile`), bfloat16 autocast
15
+ (`trainer.precision`), CUDA graph training steps (`trainer.cuda_graphs`), and
16
+ TF32 matmuls (`trainer.allow_tf32`); `trainer=paper_fast` enables all four,
17
+ about 3x faster per GamGam epoch than float32 eager on an A100.
18
  - `data.loader.num_workers`/`pin_memory` for background batch collation.
19
  - Per-epoch `history.jsonl`, `inference.metrics_output`, early stopping on
20
  `train_loss`, and macro precision/recall/F1 for multiclass tasks.
 
23
 
24
  ### Changed
25
 
26
+ - The environment moved to Python 3.14 and PyTorch 2.14.1 (CUDA 13.0 wheels;
27
+ NumPy 2), which is required for `torch.compile`. Frozen ROOT-GNN parity
28
+ references are reproduced unchanged. ONNX export keeps the TorchScript
29
+ exporter (`dynamo=False`), and loader workers are explicitly forked because
30
+ Python 3.14 no longer forks by default.
31
+
32
  - DGL was removed. Preparation is vectorized and writes a model-neutral packed
33
  `EventCache` (cache schema 2) instead of pickled DGL graphs; graph batches
34
  (`TensorGraph`, edge-index layout) and transformer token batches are built
README_PROJECT.md CHANGED
@@ -21,7 +21,8 @@ backend.
21
 
22
  ## Installation
23
 
24
- The supported development environment is Python 3.12 (`>=3.12,<3.13`). Core
 
25
  development is supported on macOS and Linux:
26
 
27
  ```bash
 
21
 
22
  ## Installation
23
 
24
+ The supported development environment is Python 3.14 (`>=3.14,<3.15`) with
25
+ PyTorch 2.14 (CUDA 13.0 wheels on Linux x86-64). Core
26
  development is supported on macOS and Linux:
27
 
28
  ```bash
docs/configuration.md CHANGED
@@ -150,14 +150,21 @@ distributed preparation and should be benchmarked on the target machine.
150
 
151
  ## Training speed options
152
 
153
- `trainer.allow_tf32: true` uses TensorFloat-32 CUDA matrix multiplication and
154
- `trainer.cuda_graphs: true` captures complete training steps (forward,
155
- backward, Adam step) as CUDA graphs and replays them. Graph batches are padded
156
- into node/edge capacity buckets with discarded padding graphs, and steps are
157
- captured again when the learning rate changes. CUDA graphs require a CUDA
158
- device, a single process, a graph model, and Adam or AdamW. Both options are
159
- off by default: results then agree with float32 eager training to
160
- floating-point rounding rather than bitwise. See [performance.md](performance.md).
 
 
 
 
 
 
 
161
 
162
  ## Checkpoints and resolved configuration
163
 
 
150
 
151
  ## Training speed options
152
 
153
+ | Option | Effect |
154
+ | --- | --- |
155
+ | `trainer.compile: true` | `torch.compile` the model forward with dynamic shapes (Inductor); the first epoch includes about a minute of compilation |
156
+ | `trainer.precision: bfloat16` | bfloat16 autocast for the forward pass; parameters, optimizer, loss, and graph sums stay float32 |
157
+ | `trainer.allow_tf32: true` | TensorFloat-32 for float32 CUDA matrix multiplication |
158
+ | `trainer.cuda_graphs: true` | capture complete training steps (forward, backward, Adam step) as CUDA graphs and replay them |
159
+
160
+ For CUDA graphs, graph batches are padded into node/edge capacity buckets with
161
+ discarded padding graphs, and learning rates become device tensors that
162
+ schedulers update in place, so one capture per bucket serves the whole run.
163
+ They require a CUDA device, a single process, a graph model, and Adam or
164
+ AdamW; checkpoints store plain learning rates and resume in any mode. All four
165
+ options are off by default, and with any of them results agree with float32
166
+ eager training to floating-point rounding rather than bitwise.
167
+ `trainer=paper_fast` enables all four. See [performance.md](performance.md).
168
 
169
  ## Checkpoints and resolved configuration
170
 
docs/migration.md CHANGED
@@ -114,6 +114,14 @@ Intentional differences from the previous main:
114
  - the scheduler is now stepped before the epoch hook rather than after the
115
  early-stopping check; the stopping epoch still does not step it.
116
 
 
 
 
 
 
 
 
 
117
  ## Validation requirements
118
 
119
  Every new model family must provide unit tests for its representation and
 
114
  - the scheduler is now stepped before the epoch hook rather than after the
115
  early-stopping check; the stopping epoch still does not step it.
116
 
117
+ Environment upgrade (Python 3.12 / PyTorch 2.2.2 to Python 3.14 / PyTorch
118
+ 2.14.1, CUDA 13.0, NumPy 2): every frozen parity reference, including the
119
+ bitwise DGL forward outputs and prepared graphs, passes unchanged. ONNX export
120
+ passes `dynamo=False` to keep the TorchScript exporter and its `dynamic_axes`
121
+ contract (the default dynamo exporter needs `onnxscript`); it emits deprecation
122
+ warnings. CUDA graph steps hold learning rates as device tensors; checkpoints
123
+ store them as floats with `capturable` disabled.
124
+
125
  ## Validation requirements
126
 
127
  Every new model family must provide unit tests for its representation and
docs/performance.md CHANGED
@@ -28,9 +28,44 @@ threads) against the previous DGL graph cache:
28
  | Batch construction (1024 events) | 83 ms | 3.3 ms |
29
  | GPU training epoch | 166.5 ms/batch | 25.8 ms/batch |
30
 
31
- Training is now bound by the GPU step itself. Mixed precision,
32
- `torch.compile`, and size-aware batching are the next levers and have not been
33
- adopted without measurements; size-aware batching and streaming prediction
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  affect ordering or output semantics.
35
 
36
  ## CPU threads
 
28
  | Batch construction (1024 events) | 83 ms | 3.3 ms |
29
  | GPU training epoch | 166.5 ms/batch | 25.8 ms/batch |
30
 
31
+ At that point eager training became bound by host-side kernel launches (about
32
+ a thousand small kernels per step for this model), not by GPU arithmetic.
33
+
34
+ ## GamGam training step
35
+
36
+ Measured on the ATLAS Open Data GamGam task (1024-event batches of about 5,600
37
+ nodes and 30,000 edges; paper model: hidden size 64, 4 MLP layers, 4 graph
38
+ steps), one A100-SXM4-80GB, `OMP_NUM_THREADS=8`, loader workers with pinned
39
+ memory, steady-state `Trainer.train_epoch` over 300 shuffled batches:
40
+
41
+ | Options | torch 2.2.2, Python 3.12 | torch 2.14.1, Python 3.14 |
42
+ | --- | ---: | ---: |
43
+ | float32 eager (default) | 18.4 ms | 19.2 ms |
44
+ | bfloat16 eager | | 22.4 ms |
45
+ | CUDA graphs + TF32 | 8.1 ms | 8.5 ms |
46
+ | `torch.compile` | n/a | 13.0 ms |
47
+ | compile + CUDA graphs + TF32 | n/a | 6.1 ms |
48
+ | compile + CUDA graphs + bfloat16 (`paper_fast`) | n/a | 5.9 ms |
49
+
50
+ Before this work the float32 eager step (collation, transfer, and step on the
51
+ training thread) took 21.3 ms per batch against 17.7 ms now with the same
52
+ torch 2.2.2 stack, and epoch metrics used a pairwise ROC AUC that could not
53
+ evaluate the 2M-event training split at all. Training-set AUC after three epochs agreed
54
+ across all rows to within GPU run-to-run variation (0.862-0.867). A full
55
+ `trainer=paper_fast` epoch, including validation, takes about 12.5 s (37 s for
56
+ the first, which compiles) against about 38 s for float32 eager.
57
+
58
+ Observations:
59
+
60
+ - bfloat16 alone is slower: the model is small (64 features), so it is not
61
+ matrix-multiplication bound, and eager autocast adds cast kernels. Under
62
+ `torch.compile` the casts fuse and bfloat16 is on par with TF32.
63
+ - `torch.compile` alone removes about a third of the launch overhead; CUDA
64
+ graphs remove the rest, and the two compose.
65
+ - Graph sums accumulate in float32 under autocast (bfloat16 represents
66
+ integers exactly only to 256, and means divide by edge counts).
67
+
68
+ Size-aware batching and streaming prediction remain unexplored because they
69
  affect ordering or output semantics.
70
 
71
  ## CPU threads
docs/quickstart.md CHANGED
@@ -12,7 +12,8 @@ ROOT -> features -> EventCache (packed objects) -> graph or sequence batch -> mo
12
 
13
  ## Install
14
 
15
- The supported environment is Python 3.12. From the repository root:
 
16
 
17
  ```bash
18
  uv sync --dev
@@ -77,8 +78,8 @@ uv run gnn4colliders evaluate --config-name config_atlas_opendata_gamgam \
77
 
78
  Preparation takes about 15 s with 8 workers. Training follows the paper
79
  recipe and keeps `best.pt`, the epoch with the best validation AUC; add
80
- `trainer=paper_fast` for CUDA graph replay and TF32 (about 2x faster per
81
- epoch on an A100). The task, its configuration choices, and the comparison
82
  with the paper are described in
83
  [atlas_opendata_gamgam.md](atlas_opendata_gamgam.md).
84
 
 
12
 
13
  ## Install
14
 
15
+ The supported environment is Python 3.14 with PyTorch 2.14. From the
16
+ repository root:
17
 
18
  ```bash
19
  uv sync --dev
 
78
 
79
  Preparation takes about 15 s with 8 workers. Training follows the paper
80
  recipe and keeps `best.pt`, the epoch with the best validation AUC; add
81
+ `trainer=paper_fast` for `torch.compile`, CUDA graph replay, and a bfloat16
82
+ forward (about 3x faster per epoch on an A100). The task, its configuration choices, and the comparison
83
  with the paper are described in
84
  [atlas_opendata_gamgam.md](atlas_opendata_gamgam.md).
85
 
pyproject.toml CHANGED
@@ -7,14 +7,14 @@ name = "gnn4colliders"
7
  dynamic = ["version"]
8
  description = "Machine-learning tools for collider event analysis."
9
  readme = "README.md"
10
- requires-python = ">=3.12,<3.13"
11
  dependencies = [
12
  "awkward>=2.0",
13
  "hydra-core>=1.3",
14
  "huggingface-hub>=0.36,<1",
15
- "numpy>=1.24,<2",
16
  "scikit-learn>=1.3",
17
- "torch==2.2.2",
18
  "uproot>=5.0",
19
  ]
20
 
@@ -49,12 +49,12 @@ version = { attr = "gnn4colliders.__version__" }
49
 
50
  [tool.uv.sources]
51
  torch = [
52
- { index = "pytorch-cu121", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
53
  ]
54
 
55
  [[tool.uv.index]]
56
- name = "pytorch-cu121"
57
- url = "https://download.pytorch.org/whl/cu121"
58
  explicit = true
59
 
60
  [tool.pytest.ini_options]
 
7
  dynamic = ["version"]
8
  description = "Machine-learning tools for collider event analysis."
9
  readme = "README.md"
10
+ requires-python = ">=3.14,<3.15"
11
  dependencies = [
12
  "awkward>=2.0",
13
  "hydra-core>=1.3",
14
  "huggingface-hub>=0.36,<1",
15
+ "numpy>=2.1",
16
  "scikit-learn>=1.3",
17
+ "torch==2.14.1",
18
  "uproot>=5.0",
19
  ]
20
 
 
49
 
50
  [tool.uv.sources]
51
  torch = [
52
+ { index = "pytorch-cu130", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
53
  ]
54
 
55
  [[tool.uv.index]]
56
+ name = "pytorch-cu130"
57
+ url = "https://download.pytorch.org/whl/cu130"
58
  explicit = true
59
 
60
  [tool.pytest.ini_options]
src/gnn4colliders/config/application.py CHANGED
@@ -287,7 +287,7 @@ class _CheckpointPolicy:
287
  "epoch": epoch,
288
  "seconds": now - self.started,
289
  "learning_rate": [
290
- group["lr"] for group in self.trainer.optimizer.param_groups
291
  ],
292
  "train": {"loss": train_result.loss, **train_result.metrics},
293
  }
 
287
  "epoch": epoch,
288
  "seconds": now - self.started,
289
  "learning_rate": [
290
+ float(group["lr"]) for group in self.trainer.optimizer.param_groups
291
  ],
292
  "train": {"loss": train_result.loss, **train_result.metrics},
293
  }
src/gnn4colliders/config/factories.py CHANGED
@@ -179,4 +179,6 @@ def build_trainer(
179
  scheduler_step=scheduler_step,
180
  distributed_context=distributed_context,
181
  cuda_graphs=bool(_get(config, "cuda_graphs", False)),
 
 
182
  )
 
179
  scheduler_step=scheduler_step,
180
  distributed_context=distributed_context,
181
  cuda_graphs=bool(_get(config, "cuda_graphs", False)),
182
+ compile_model=bool(_get(config, "compile", False)),
183
+ precision=str(_get(config, "precision", "float32")),
184
  )
src/gnn4colliders/configs/trainer/default.yaml CHANGED
@@ -18,3 +18,7 @@ early_stopping:
18
  # a single process, graph batches, and an Adam optimizer.
19
  allow_tf32: false
20
  cuda_graphs: false
 
 
 
 
 
18
  # a single process, graph batches, and an Adam optimizer.
19
  allow_tf32: false
20
  cuda_graphs: false
21
+ # torch.compile the model forward (dynamic shapes), and the forward precision:
22
+ # float32 or bfloat16 (autocast; loss, graph sums, and optimizer in float32).
23
+ compile: false
24
+ precision: float32
src/gnn4colliders/configs/trainer/paper.yaml CHANGED
@@ -19,3 +19,5 @@ early_stopping:
19
  min_delta: 0.0
20
  allow_tf32: false
21
  cuda_graphs: false
 
 
 
19
  min_delta: 0.0
20
  allow_tf32: false
21
  cuda_graphs: false
22
+ compile: false
23
+ precision: float32
src/gnn4colliders/configs/trainer/paper_fast.yaml CHANGED
@@ -2,7 +2,11 @@ defaults:
2
  - paper
3
  - _self_
4
 
5
- # The paper recipe with captured CUDA graph steps and TF32 matmuls: about 2x
6
- # faster per epoch on an A100 for the GamGam task (docs/performance.md).
7
- allow_tf32: true
 
 
8
  cuda_graphs: true
 
 
 
2
  - paper
3
  - _self_
4
 
5
+ # The paper recipe with a compiled model, captured CUDA graph steps, and a
6
+ # bfloat16 forward: about 3x faster per epoch than float32 eager on an A100
7
+ # for the GamGam task (docs/performance.md). Results agree with float32 to
8
+ # rounding, not bitwise.
9
+ compile: true
10
  cuda_graphs: true
11
+ precision: bfloat16
12
+ allow_tf32: true
src/gnn4colliders/data/loader.py CHANGED
@@ -114,6 +114,10 @@ class EventLoader:
114
  num_workers=self.num_workers,
115
  pin_memory=self.pin_memory,
116
  prefetch_factor=self.prefetch_factor,
 
 
 
 
117
  )
118
 
119
 
 
114
  num_workers=self.num_workers,
115
  pin_memory=self.pin_memory,
116
  prefetch_factor=self.prefetch_factor,
117
+ # Forked workers share the events copy-on-write; Python 3.14's
118
+ # default (forkserver) would pickle them to every worker. Workers
119
+ # only collate on the CPU, so forking a CUDA process is safe.
120
+ multiprocessing_context="fork",
121
  )
122
 
123
 
src/gnn4colliders/export/onnx.py CHANGED
@@ -304,6 +304,9 @@ def export_root_gnn_onnx(
304
  "logits": {0: "graphs"},
305
  },
306
  do_constant_folding=True,
 
 
 
307
  )
308
  validate_onnx(temporary)
309
  with torch.inference_mode():
 
304
  "logits": {0: "graphs"},
305
  },
306
  do_constant_folding=True,
307
+ # The TorchScript exporter keeps the dynamic_axes contract and
308
+ # needs no onnxscript dependency.
309
+ dynamo=False,
310
  )
311
  validate_onnx(temporary)
312
  with torch.inference_mode():
src/gnn4colliders/models/root_gnn/edge_network.py CHANGED
@@ -15,8 +15,10 @@ from .blocks import make_mlp
15
  def _sum_by_group(
16
  values: torch.Tensor, groups: torch.Tensor, count: int
17
  ) -> torch.Tensor:
18
- result = values.new_zeros((count, values.shape[1]))
19
- return result.index_add_(0, groups, values)
 
 
20
 
21
 
22
  def _gather(values: torch.Tensor, index: torch.Tensor) -> torch.Tensor:
@@ -30,8 +32,9 @@ def _mean_by_group(
30
  values: torch.Tensor, groups: torch.Tensor, sizes: torch.Tensor
31
  ) -> torch.Tensor:
32
  """Per-group means; ``sizes`` holds the (exact, integer) group sizes."""
33
- sums = _sum_by_group(values, groups, int(sizes.numel()))
34
- return sums / sizes.to(values.dtype)[:, None].clamp_min(1)
 
35
 
36
 
37
  class EdgeNetwork(nn.Module):
 
15
  def _sum_by_group(
16
  values: torch.Tensor, groups: torch.Tensor, count: int
17
  ) -> torch.Tensor:
18
+ # Sums accumulate in at least float32, also under bfloat16 autocast.
19
+ dtype = torch.promote_types(values.dtype, torch.float32)
20
+ result = values.new_zeros((count, values.shape[1]), dtype=dtype)
21
+ return result.index_add_(0, groups, values.to(dtype))
22
 
23
 
24
  def _gather(values: torch.Tensor, index: torch.Tensor) -> torch.Tensor:
 
32
  values: torch.Tensor, groups: torch.Tensor, sizes: torch.Tensor
33
  ) -> torch.Tensor:
34
  """Per-group means; ``sizes`` holds the (exact, integer) group sizes."""
35
+ # ``shape[0]`` stays symbolic under torch.compile (no recompile per size).
36
+ sums = _sum_by_group(values, groups, sizes.shape[0])
37
+ return sums / sizes.to(sums.dtype)[:, None].clamp_min(1)
38
 
39
 
40
  class EdgeNetwork(nn.Module):
src/gnn4colliders/training/checkpoint.py CHANGED
@@ -57,6 +57,21 @@ def _state_dict(model: nn.Module) -> dict[str, Any]:
57
  }
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def _rng_state() -> dict[str, Any]:
61
  state: dict[str, Any] = {
62
  "python": random.getstate(),
@@ -151,7 +166,7 @@ class CheckpointManager:
151
  "epoch": epoch,
152
  "global_step": int(trainer_state.global_step),
153
  "model_state_dict": _state_dict(model),
154
- "optimizer_state_dict": optimizer.state_dict()
155
  if optimizer is not None
156
  else None,
157
  "scheduler_state_dict": scheduler.state_dict()
 
57
  }
58
 
59
 
60
+ def _portable_optimizer_state(optimizer: torch.optim.Optimizer) -> dict[str, Any]:
61
+ """Optimizer state without CUDA-graph settings (tensor learning rates and
62
+ ``capturable``), so any training mode can resume from it."""
63
+ state = optimizer.state_dict()
64
+ groups = []
65
+ for group in state["param_groups"]:
66
+ group = dict(group)
67
+ if isinstance(group.get("lr"), torch.Tensor):
68
+ group["lr"] = float(group["lr"])
69
+ if "capturable" in group:
70
+ group["capturable"] = False
71
+ groups.append(group)
72
+ return {**state, "param_groups": groups}
73
+
74
+
75
  def _rng_state() -> dict[str, Any]:
76
  state: dict[str, Any] = {
77
  "python": random.getstate(),
 
166
  "epoch": epoch,
167
  "global_step": int(trainer_state.global_step),
168
  "model_state_dict": _state_dict(model),
169
+ "optimizer_state_dict": _portable_optimizer_state(optimizer)
170
  if optimizer is not None
171
  else None,
172
  "scheduler_state_dict": scheduler.state_dict()
src/gnn4colliders/training/cuda_graphs.py CHANGED
@@ -9,7 +9,7 @@ captures serve a whole epoch.
9
 
10
  Results agree with eager training to floating-point rounding, not bitwise:
11
  matrix multiplications see padded row counts and the optimizer runs in its
12
- ``capturable`` form.
13
  """
14
 
15
  from __future__ import annotations
@@ -30,15 +30,31 @@ class _Capture:
30
  buffers: StaticGraphBatch
31
  loss: torch.Tensor
32
  logits: torch.Tensor
33
- learning_rates: tuple[float, ...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  class CudaGraphTrainStep:
37
  """Replay captured training steps for graph batches on a CUDA device.
38
 
39
- The model must not synchronize with the host. A step is captured for each
40
- ``(graphs, nodes, edges)`` bucket and captured again whenever a learning
41
- rate changes, because captured kernels hold the rate as a constant.
42
  """
43
 
44
  def __init__(
@@ -53,8 +69,7 @@ class CudaGraphTrainStep:
53
  ) -> None:
54
  if not isinstance(optimizer, (torch.optim.Adam, torch.optim.AdamW)):
55
  raise ValueError("CUDA graph training supports the Adam optimizers")
56
- for group in optimizer.param_groups:
57
- group["capturable"] = True
58
  self.model, self.task, self.optimizer = model, task, optimizer
59
  self.forward = forward
60
  self.node_multiple, self.edge_multiple = node_multiple, edge_multiple
@@ -64,13 +79,9 @@ class CudaGraphTrainStep:
64
  def supports(batch: Any) -> bool:
65
  return isinstance(batch, GraphBatch) and batch.graph.node_features.is_cuda
66
 
67
- def _learning_rates(self) -> tuple[float, ...]:
68
- return tuple(float(group["lr"]) for group in self.optimizer.param_groups)
69
-
70
- def _capture(
71
- self, key: tuple[int, int, int], batch: GraphBatch, rates: tuple[float, ...]
72
- ) -> _Capture:
73
- self._captures.pop(key, None)
74
  buffers = StaticGraphBatch(batch, key)
75
  buffers.load(batch)
76
  # Gradients allocated during capture belong to this graph's pool.
@@ -83,7 +94,7 @@ class CudaGraphTrainStep:
83
  loss = self.task.loss(logits, buffers.batch)
84
  loss.backward()
85
  self.optimizer.step()
86
- capture = _Capture(graph, buffers, loss, logits, rates)
87
  self._captures[key] = capture
88
  return capture
89
 
@@ -92,10 +103,9 @@ class CudaGraphTrainStep:
92
  key = graph_batch_capacity(
93
  batch, node_multiple=self.node_multiple, edge_multiple=self.edge_multiple
94
  )
95
- rates = self._learning_rates()
96
  capture = self._captures.get(key)
97
- if capture is None or capture.learning_rates != rates:
98
- capture = self._capture(key, batch, rates)
99
  capture.buffers.load(batch)
100
  capture.graph.replay()
101
  return capture.loss.detach().clone(), capture.logits.detach().clone()
 
9
 
10
  Results agree with eager training to floating-point rounding, not bitwise:
11
  matrix multiplications see padded row counts and the optimizer runs in its
12
+ ``capturable`` form with device-tensor learning rates.
13
  """
14
 
15
  from __future__ import annotations
 
30
  buffers: StaticGraphBatch
31
  loss: torch.Tensor
32
  logits: torch.Tensor
33
+
34
+
35
+ def make_capturable(optimizer: torch.optim.Optimizer) -> None:
36
+ """Prepare an Adam optimizer for capture (idempotent).
37
+
38
+ Learning rates become device tensors, which schedulers update in place,
39
+ so captured steps follow the schedule without being captured again.
40
+ Optimizer step counters move to the parameters' device.
41
+ """
42
+ for group in optimizer.param_groups:
43
+ device = group["params"][0].device
44
+ group["capturable"] = True
45
+ if not isinstance(group["lr"], torch.Tensor):
46
+ group["lr"] = torch.tensor(float(group["lr"]), device=device)
47
+ for parameter in group["params"]:
48
+ state = optimizer.state.get(parameter, {})
49
+ if isinstance(state.get("step"), torch.Tensor):
50
+ state["step"] = state["step"].to(device)
51
 
52
 
53
  class CudaGraphTrainStep:
54
  """Replay captured training steps for graph batches on a CUDA device.
55
 
56
+ The model must not synchronize with the host. A step is captured once per
57
+ ``(graphs, nodes, edges)`` capacity bucket.
 
58
  """
59
 
60
  def __init__(
 
69
  ) -> None:
70
  if not isinstance(optimizer, (torch.optim.Adam, torch.optim.AdamW)):
71
  raise ValueError("CUDA graph training supports the Adam optimizers")
72
+ make_capturable(optimizer)
 
73
  self.model, self.task, self.optimizer = model, task, optimizer
74
  self.forward = forward
75
  self.node_multiple, self.edge_multiple = node_multiple, edge_multiple
 
79
  def supports(batch: Any) -> bool:
80
  return isinstance(batch, GraphBatch) and batch.graph.node_features.is_cuda
81
 
82
+ def _capture(self, key: tuple[int, int, int], batch: GraphBatch) -> _Capture:
83
+ # A restored checkpoint may have replaced the optimizer settings.
84
+ make_capturable(self.optimizer)
 
 
 
 
85
  buffers = StaticGraphBatch(batch, key)
86
  buffers.load(batch)
87
  # Gradients allocated during capture belong to this graph's pool.
 
94
  loss = self.task.loss(logits, buffers.batch)
95
  loss.backward()
96
  self.optimizer.step()
97
+ capture = _Capture(graph, buffers, loss, logits)
98
  self._captures[key] = capture
99
  return capture
100
 
 
103
  key = graph_batch_capacity(
104
  batch, node_multiple=self.node_multiple, edge_multiple=self.edge_multiple
105
  )
 
106
  capture = self._captures.get(key)
107
+ if capture is None:
108
+ capture = self._capture(key, batch)
109
  capture.buffers.load(batch)
110
  capture.graph.replay()
111
  return capture.loss.detach().clone(), capture.logits.detach().clone()
src/gnn4colliders/training/trainer.py CHANGED
@@ -33,8 +33,22 @@ CUDA_GRAPH_WARMUP_STEPS = 3
33
  _POSITIONAL_ARGUMENTS: dict[type, int] = {}
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _positional_arguments(model: nn.Module) -> int:
37
  """Number of positional ``forward`` parameters, cached per model class."""
 
38
  cached = _POSITIONAL_ARGUMENTS.get(type(model))
39
  if cached is not None:
40
  return cached
@@ -99,9 +113,13 @@ class Trainer:
99
  scheduler_step: str = "epoch",
100
  distributed_context: DistributedContext | None = None,
101
  cuda_graphs: bool = False,
 
 
102
  ) -> None:
103
  if scheduler_step not in {"epoch", "validation"}:
104
  raise ValueError("scheduler_step must be 'epoch' or 'validation'")
 
 
105
  self.distributed_context = (
106
  distributed_context or DistributedContext.single_process(device)
107
  )
@@ -113,6 +131,13 @@ class Trainer:
113
  self.early_stopping = early_stopping
114
  self.scheduler_step = scheduler_step
115
  self.state = TrainerState()
 
 
 
 
 
 
 
116
  self._graph_step = None
117
  if cuda_graphs:
118
  if self.distributed_context.enabled:
@@ -122,9 +147,19 @@ class Trainer:
122
  from .cuda_graphs import CudaGraphTrainStep
123
 
124
  self._graph_step = CudaGraphTrainStep(
125
- self.model, task, optimizer, _model_forward
126
  )
127
 
 
 
 
 
 
 
 
 
 
 
128
  def _step(self, batch: Any) -> tuple[torch.Tensor, torch.Tensor]:
129
  """One optimizer step; returns the detached loss and logits.
130
 
@@ -132,7 +167,7 @@ class Trainer:
132
  batch while this one runs.
133
  """
134
  self.optimizer.zero_grad(set_to_none=True)
135
- logits = _model_forward(self.model, batch)
136
  loss = self.task.loss(logits, batch)
137
  loss.backward()
138
  self.optimizer.step()
@@ -206,7 +241,7 @@ class Trainer:
206
  with torch.inference_mode():
207
  for batch in loader:
208
  moved = batch.to(self.device, non_blocking=True)
209
- outputs.append(_model_forward(self.model, moved).detach())
210
  batches.append(_summary(moved))
211
  if not outputs:
212
  raise ValueError("cannot evaluate an empty loader")
 
33
  _POSITIONAL_ARGUMENTS: dict[type, int] = {}
34
 
35
 
36
+ #: ``trainer.precision`` values and their autocast dtype (``None``: float32).
37
+ PRECISIONS = {"float32": None, "bfloat16": torch.bfloat16}
38
+
39
+
40
+ def _unwrapped(model: nn.Module) -> nn.Module:
41
+ """The user module inside ``torch.compile`` and DDP wrappers."""
42
+ while True:
43
+ inner = getattr(model, "_orig_mod", None) or getattr(model, "module", None)
44
+ if not isinstance(inner, nn.Module):
45
+ return model
46
+ model = inner
47
+
48
+
49
  def _positional_arguments(model: nn.Module) -> int:
50
  """Number of positional ``forward`` parameters, cached per model class."""
51
+ model = _unwrapped(model)
52
  cached = _POSITIONAL_ARGUMENTS.get(type(model))
53
  if cached is not None:
54
  return cached
 
113
  scheduler_step: str = "epoch",
114
  distributed_context: DistributedContext | None = None,
115
  cuda_graphs: bool = False,
116
+ compile_model: bool = False,
117
+ precision: str = "float32",
118
  ) -> None:
119
  if scheduler_step not in {"epoch", "validation"}:
120
  raise ValueError("scheduler_step must be 'epoch' or 'validation'")
121
+ if precision not in PRECISIONS:
122
+ raise ValueError(f"precision must be one of {sorted(PRECISIONS)}")
123
  self.distributed_context = (
124
  distributed_context or DistributedContext.single_process(device)
125
  )
 
131
  self.early_stopping = early_stopping
132
  self.scheduler_step = scheduler_step
133
  self.state = TrainerState()
134
+ self.precision = precision
135
+ self._autocast_dtype = PRECISIONS[precision]
136
+ # ``self.model`` stays the eager module for checkpoints and callers;
137
+ # forward passes go through the compiled wrapper when requested.
138
+ self._forward_module = (
139
+ torch.compile(self.model, dynamic=True) if compile_model else self.model
140
+ )
141
  self._graph_step = None
142
  if cuda_graphs:
143
  if self.distributed_context.enabled:
 
147
  from .cuda_graphs import CudaGraphTrainStep
148
 
149
  self._graph_step = CudaGraphTrainStep(
150
+ self.model, task, optimizer, lambda _model, batch: self._forward(batch)
151
  )
152
 
153
+ def _forward(self, batch: Any) -> torch.Tensor:
154
+ """Model logits, in float32 even when computed under autocast."""
155
+ if self._autocast_dtype is None:
156
+ return _model_forward(self._forward_module, batch)
157
+ with torch.autocast(
158
+ device_type=torch.device(self.device).type, dtype=self._autocast_dtype
159
+ ):
160
+ logits = _model_forward(self._forward_module, batch)
161
+ return logits.float()
162
+
163
  def _step(self, batch: Any) -> tuple[torch.Tensor, torch.Tensor]:
164
  """One optimizer step; returns the detached loss and logits.
165
 
 
167
  batch while this one runs.
168
  """
169
  self.optimizer.zero_grad(set_to_none=True)
170
+ logits = self._forward(batch)
171
  loss = self.task.loss(logits, batch)
172
  loss.backward()
173
  self.optimizer.step()
 
241
  with torch.inference_mode():
242
  for batch in loader:
243
  moved = batch.to(self.device, non_blocking=True)
244
+ outputs.append(self._forward(moved).detach())
245
  batches.append(_summary(moved))
246
  if not outputs:
247
  raise ValueError("cannot evaluate an empty loader")
tests/fixtures/root_gnn_dgl_reference/generate.py CHANGED
@@ -14,10 +14,10 @@ from pathlib import Path
14
 
15
  import numpy as np
16
  import torch
 
17
 
18
  from gnn4colliders.compat import load_legacy_checkpoint
19
  from gnn4colliders.data import GraphDataLoader, GraphDataset
20
- from gnn4colliders.graphs.preparation import build_graph_samples, open_event_source
21
  from gnn4colliders.models.root_gnn import (
22
  EdgeNetwork,
23
  FineTunedEdgeNetwork,
 
14
 
15
  import numpy as np
16
  import torch
17
+ from gnn4colliders.graphs.preparation import build_graph_samples, open_event_source
18
 
19
  from gnn4colliders.compat import load_legacy_checkpoint
20
  from gnn4colliders.data import GraphDataLoader, GraphDataset
 
21
  from gnn4colliders.models.root_gnn import (
22
  EdgeNetwork,
23
  FineTunedEdgeNetwork,
tests/unit/training/test_lifecycle_policies.py CHANGED
@@ -123,3 +123,62 @@ def test_cuda_graph_steps_match_eager_gradients():
123
  assert logits.shape == (16, 3)
124
  for left, right in zip(eager.parameters(), graphed.parameters()):
125
  torch.testing.assert_close(right.grad, left.grad, rtol=1e-4, atol=1e-6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  assert logits.shape == (16, 3)
124
  for left, right in zip(eager.parameters(), graphed.parameters()):
125
  torch.testing.assert_close(right.grad, left.grad, rtol=1e-4, atol=1e-6)
126
+
127
+
128
+ def test_bfloat16_autocast_trains_with_float32_logits_and_weights():
129
+ events = _events(seed=6)
130
+ trainer = _trainer(events, precision="bfloat16")
131
+ batch = next(iter(EventLoader(events, collate_graph_batch, 8)))
132
+ loss, logits = trainer._step(batch)
133
+ assert logits.dtype == torch.float32 and torch.isfinite(loss)
134
+ assert all(p.dtype == torch.float32 for p in trainer.model.parameters())
135
+ reference = _trainer(events)._forward(batch)
136
+ torch.testing.assert_close(
137
+ _trainer(events, precision="bfloat16")._forward(batch),
138
+ reference,
139
+ rtol=5e-2,
140
+ atol=5e-2,
141
+ )
142
+
143
+
144
+ def test_unknown_precision_is_rejected():
145
+ with pytest.raises(ValueError, match="precision"):
146
+ _trainer(_events(), precision="float16")
147
+
148
+
149
+ @pytest.mark.gpu
150
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA unavailable")
151
+ def test_compiled_graph_steps_with_bfloat16_track_eager_training():
152
+ events = _events(96, seed=7)
153
+ loader = EventLoader(events, collate_graph_batch, 16)
154
+
155
+ def run(**options):
156
+ trainer = _trainer(events, device="cuda", **options)
157
+ return [trainer.train_epoch(loader).loss for _ in range(3)]
158
+
159
+ eager = run()
160
+ fast = run(compile_model=True, cuda_graphs=True, precision="bfloat16")
161
+ assert fast == pytest.approx(eager, rel=5e-2)
162
+
163
+
164
+ @pytest.mark.gpu
165
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA unavailable")
166
+ def test_cuda_graph_steps_follow_the_learning_rate_schedule(tmp_path):
167
+ from gnn4colliders.training import CheckpointManager
168
+
169
+ events = _events(96, seed=8)
170
+ loader = EventLoader(events, collate_graph_batch, 16)
171
+ trainer = _trainer(events, device="cuda", cuda_graphs=True)
172
+ trainer.train_epoch(loader) # warmup steps, then captured replays
173
+ # A zero rate must reach the already captured steps.
174
+ torch.optim.lr_scheduler.ExponentialLR(trainer.optimizer, gamma=0.0).step()
175
+ before = [p.detach().clone() for p in trainer.model.parameters()]
176
+ trainer.train_epoch(loader)
177
+ for old, new in zip(before, trainer.model.parameters()):
178
+ assert torch.equal(old, new)
179
+ # Checkpoints stay loadable by eager training on any device.
180
+ path = CheckpointManager(tmp_path).save(
181
+ model=trainer.model, trainer_state=trainer.state, optimizer=trainer.optimizer
182
+ )
183
+ groups = CheckpointManager.load(path)["optimizer_state_dict"]["param_groups"]
184
+ assert all(type(g["lr"]) is float and not g["capturable"] for g in groups)
uv.lock CHANGED
The diff for this file is too large to render. See raw diff