ho22joshua commited on
Commit
13fd35c
·
1 Parent(s): ee44c00

feat: add distributed training execution

Browse files
docs/perlmutter.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Perlmutter execution
2
+
3
+ The package does not encode Perlmutter paths, modules, or allocation policy.
4
+ Create the uv environment in the project location appropriate for your
5
+ account, select a site-compatible GPU/driver environment, and use the same
6
+ Hydra configuration as on a workstation.
7
+
8
+ ## Single GPU
9
+
10
+ ```bash
11
+ uv run gnn4colliders train \
12
+ data.cache.path=/path/to/graphs.pt \
13
+ environment=perlmutter \
14
+ trainer.device=cuda \
15
+ trainer.max_epochs=10
16
+ ```
17
+
18
+ The equivalent Slurm wrapper is:
19
+
20
+ ```bash
21
+ sbatch scripts/slurm/train_single_gpu.sh \
22
+ data.cache.path=/path/to/graphs.pt trainer.max_epochs=10
23
+ ```
24
+
25
+ ## Single-node DDP
26
+
27
+ ```bash
28
+ GPUS_PER_NODE=4 sbatch scripts/slurm/train_multi_gpu.sh \
29
+ data.cache.path=/path/to/graphs.pt trainer.max_epochs=10
30
+ ```
31
+
32
+ The wrapper uses `torchrun`; `batch_size` and `num_workers` are per GPU.
33
+ Effective batch size is `data.batch_size * number_of_processes`, and only rank
34
+ 0 writes the shared checkpoint/config/prediction artifacts.
35
+
36
+ ## Multi-node DDP
37
+
38
+ ```bash
39
+ GPUS_PER_NODE=4 sbatch scripts/slurm/train_multi_node.sh \
40
+ data.cache.path=/path/to/graphs.pt trainer.max_epochs=10
41
+ ```
42
+
43
+ Use the provided script as a template and adapt only allocation/account
44
+ settings required by the site. Evaluation and prediction can use
45
+ `scripts/slurm/evaluate.sh`; prediction currently gathers moderate-size
46
+ results in memory.
47
+
48
+ ## Checks and common failures
49
+
50
+ ```bash
51
+ uv run python -c "import torch, dgl; print(torch.__version__, dgl.__version__, torch.cuda.is_available())"
52
+ uv run gnn4colliders --help
53
+ ```
54
+
55
+ An unavailable DGL wheel, incompatible driver, missing cache, or mismatched
56
+ cache schema should be fixed in the environment/input rather than hidden with
57
+ package-level path changes. GPU kernels and distributed execution are not
58
+ promised bitwise deterministic.
src/gnn4colliders/config/application.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import logging
 
6
  from pathlib import Path
7
  from typing import Any
8
 
@@ -10,6 +11,7 @@ import torch
10
  from omegaconf import DictConfig, OmegaConf
11
 
12
  from gnn4colliders.data import (
 
13
  GraphDataLoader,
14
  GraphDataset,
15
  GraphSample,
@@ -18,6 +20,12 @@ from gnn4colliders.data import (
18
  SplitDefinition,
19
  select_split,
20
  )
 
 
 
 
 
 
21
  from gnn4colliders.features import build_node_features
22
  from gnn4colliders.graphs import build_dgl_graph
23
  from gnn4colliders.inference import Predictor, write_npz
@@ -55,7 +63,9 @@ def load_samples(config: DictConfig) -> GraphDataset:
55
  return GraphDataset(cache.load())
56
 
57
 
58
- def loaders(config: DictConfig) -> dict[str, GraphDataLoader]:
 
 
59
  samples = load_samples(config)
60
  split = SplitDefinition(
61
  train_folds=frozenset(config.data.splits.train_folds),
@@ -65,22 +75,41 @@ def loaders(config: DictConfig) -> dict[str, GraphDataLoader]:
65
  result = {}
66
  for name in ("train", "validation", "test"):
67
  selected = GraphDataset(select_split(samples.samples, split, name))
68
- result[name] = GraphDataLoader(
69
- selected,
70
- int(config.data.batch_size),
71
- shuffle=bool(config.data.shuffle) if name == "train" else False,
72
- seed=int(config.data.seed),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  )
74
  return result
75
 
76
 
77
  def prepare(config: DictConfig) -> Path:
 
 
 
 
78
  data = config.data
79
  if not data.files:
80
  raise ValueError("prepare requires data.files")
81
- feature_branches = data.get("feature_branches")
82
- object_types = data.get("object_types")
83
- scales = data.get("scales")
84
  if feature_branches is None or object_types is None or scales is None:
85
  raise ValueError(
86
  "prepare requires data.feature_branches, object_types, and scales"
@@ -90,8 +119,7 @@ def prepare(config: DictConfig) -> Path:
90
  tree_name=str(data.tree_name),
91
  label=data.get("label", 1),
92
  feature_branches=feature_branches,
93
- tracking_info=data.get("tracking_info", []),
94
- global_features=data.get("global_features", []),
95
  fold_var=str(data.get("fold_var", "eventNumber")),
96
  weight_var=data.get("weight_var"),
97
  )
@@ -131,57 +159,124 @@ def _model_and_task(config: DictConfig, loader: GraphDataLoader):
131
 
132
  def train(config: DictConfig) -> Path:
133
  validate_config(plain(config))
134
- seed_everything(int(config.trainer.seed))
135
- run_root = Path(config.environment.output_root)
136
- save_resolved(config, run_root)
137
- split_loaders = loaders(config)
138
- model, task = _model_and_task(config, split_loaders["train"])
139
- trainer = build_trainer(plain(config.trainer), model=model, task=task)
140
- if config.checkpoint.resume:
141
- payload = CheckpointManager.load(config.checkpoint.resume, map_location="cpu")
142
- restore_training_state(
143
- payload,
144
- model=model,
145
- trainer=trainer,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  optimizer=trainer.optimizer,
147
  scheduler=trainer.scheduler,
148
  early_stopping=trainer.early_stopping,
 
 
149
  )
150
- validation_loader = (
151
- split_loaders["validation"] if len(split_loaders["validation"]) else None
152
- )
153
- trainer.fit(
154
- split_loaders["train"], validation_loader, epochs=int(config.trainer.max_epochs)
155
- )
156
- manager = CheckpointManager(config.checkpoint.directory)
157
- return manager.save(
158
- model=model,
159
- trainer_state=trainer.state,
160
- optimizer=trainer.optimizer,
161
- scheduler=trainer.scheduler,
162
- early_stopping=trainer.early_stopping,
163
- task_config=plain(config.task),
164
- model_config=plain(config.model),
165
- )
166
 
167
 
168
  def predict_or_evaluate(config: DictConfig, *, evaluate: bool = False) -> Any:
169
  validate_config(plain(config))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  split_loaders = loaders(config)
171
- model, task = _model_and_task(config, split_loaders[config.inference.split])
172
- payload = CheckpointManager.load(config.inference.checkpoint, map_location="cpu")
173
- from gnn4colliders.training import load_model_weights
174
-
175
- load_model_weights(model, payload)
176
- predictor = Predictor(model, task, device=str(config.trainer.device))
177
- result = (
178
- predictor.evaluate(split_loaders[config.inference.split])
179
- if evaluate
180
- else predictor.predict(split_loaders[config.inference.split])
 
 
 
 
181
  )
182
- if not evaluate:
183
- output = Path(config.inference.output)
184
- if str(config.inference.format).lower() != "npz":
185
- raise ValueError("only npz inference output is currently supported")
186
- write_npz(result, output)
187
- return result
 
3
  from __future__ import annotations
4
 
5
  import logging
6
+ import os
7
  from pathlib import Path
8
  from typing import Any
9
 
 
11
  from omegaconf import DictConfig, OmegaConf
12
 
13
  from gnn4colliders.data import (
14
+ DistributedGraphDataLoader,
15
  GraphDataLoader,
16
  GraphDataset,
17
  GraphSample,
 
20
  SplitDefinition,
21
  select_split,
22
  )
23
+ from gnn4colliders.distributed import (
24
+ DistributedContext,
25
+ barrier,
26
+ finalize,
27
+ initialize,
28
+ )
29
  from gnn4colliders.features import build_node_features
30
  from gnn4colliders.graphs import build_dgl_graph
31
  from gnn4colliders.inference import Predictor, write_npz
 
63
  return GraphDataset(cache.load())
64
 
65
 
66
+ def loaders(
67
+ config: DictConfig, context: DistributedContext | None = None
68
+ ) -> dict[str, GraphDataLoader]:
69
  samples = load_samples(config)
70
  split = SplitDefinition(
71
  train_folds=frozenset(config.data.splits.train_folds),
 
75
  result = {}
76
  for name in ("train", "validation", "test"):
77
  selected = GraphDataset(select_split(samples.samples, split, name))
78
+ loader_type = (
79
+ DistributedGraphDataLoader
80
+ if context is not None and context.enabled
81
+ else GraphDataLoader
82
+ )
83
+ result[name] = (
84
+ loader_type(
85
+ selected,
86
+ int(config.data.batch_size),
87
+ context,
88
+ shuffle=bool(config.data.shuffle) if name == "train" else False,
89
+ seed=int(config.data.seed),
90
+ )
91
+ if loader_type is DistributedGraphDataLoader
92
+ else loader_type(
93
+ selected,
94
+ int(config.data.batch_size),
95
+ shuffle=bool(config.data.shuffle) if name == "train" else False,
96
+ seed=int(config.data.seed),
97
+ )
98
  )
99
  return result
100
 
101
 
102
  def prepare(config: DictConfig) -> Path:
103
+ if bool(config.distributed.enabled) or int(os.environ.get("WORLD_SIZE", "1")) > 1:
104
+ raise ValueError(
105
+ "prepare does not support distributed launch; run it once on rank 0"
106
+ )
107
  data = config.data
108
  if not data.files:
109
  raise ValueError("prepare requires data.files")
110
+ feature_branches = plain(data.get("feature_branches"))
111
+ object_types = plain(data.get("object_types"))
112
+ scales = plain(data.get("scales"))
113
  if feature_branches is None or object_types is None or scales is None:
114
  raise ValueError(
115
  "prepare requires data.feature_branches, object_types, and scales"
 
119
  tree_name=str(data.tree_name),
120
  label=data.get("label", 1),
121
  feature_branches=feature_branches,
122
+ global_features=plain(data.get("global_features", [])),
 
123
  fold_var=str(data.get("fold_var", "eventNumber")),
124
  weight_var=data.get("weight_var"),
125
  )
 
159
 
160
  def train(config: DictConfig) -> Path:
161
  validate_config(plain(config))
162
+ context = initialize(
163
+ enabled=True if config.distributed.enabled else None,
164
+ backend=config.distributed.backend,
165
+ device=config.trainer.device,
166
+ )
167
+ try:
168
+ seed_everything(int(config.trainer.seed) + context.rank)
169
+ run_root = Path(config.environment.output_root)
170
+ if context.is_main_process:
171
+ save_resolved(config, run_root)
172
+ barrier(context)
173
+ split_loaders = loaders(config, context)
174
+ model, task = _model_and_task(config, split_loaders["train"])
175
+ trainer = build_trainer(
176
+ plain(config.trainer), model=model, task=task, distributed_context=context
177
+ )
178
+ if config.checkpoint.resume:
179
+ payload = CheckpointManager.load(
180
+ config.checkpoint.resume, map_location="cpu"
181
+ )
182
+ restore_training_state(
183
+ payload,
184
+ model=trainer.model,
185
+ trainer=trainer,
186
+ optimizer=trainer.optimizer,
187
+ scheduler=trainer.scheduler,
188
+ early_stopping=trainer.early_stopping,
189
+ )
190
+ validation_loader = (
191
+ split_loaders["validation"] if len(split_loaders["validation"]) else None
192
+ )
193
+ trainer.fit(
194
+ split_loaders["train"],
195
+ validation_loader,
196
+ epochs=int(config.trainer.max_epochs),
197
+ )
198
+ if not context.is_main_process:
199
+ barrier(context)
200
+ return (
201
+ Path(config.checkpoint.directory)
202
+ / f"epoch_{trainer.state.epoch:04d}.pt"
203
+ )
204
+ manager = CheckpointManager(config.checkpoint.directory)
205
+ path = manager.save(
206
+ model=trainer.model,
207
+ trainer_state=trainer.state,
208
  optimizer=trainer.optimizer,
209
  scheduler=trainer.scheduler,
210
  early_stopping=trainer.early_stopping,
211
+ task_config=plain(config.task),
212
+ model_config=plain(config.model),
213
  )
214
+ barrier(context)
215
+ return path
216
+ finally:
217
+ finalize(context)
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
  def predict_or_evaluate(config: DictConfig, *, evaluate: bool = False) -> Any:
221
  validate_config(plain(config))
222
+ context = initialize(
223
+ enabled=True if config.distributed.enabled else None,
224
+ backend=config.distributed.backend,
225
+ device=config.trainer.device,
226
+ )
227
+ try:
228
+ split_loaders = loaders(config, context)
229
+ model, task = _model_and_task(config, split_loaders[config.inference.split])
230
+ payload = CheckpointManager.load(
231
+ config.inference.checkpoint, map_location="cpu"
232
+ )
233
+ from gnn4colliders.training import load_model_weights
234
+
235
+ load_model_weights(model, payload)
236
+ predictor = Predictor(
237
+ model,
238
+ task,
239
+ device=str(config.trainer.device),
240
+ distributed_context=context,
241
+ )
242
+ result = (
243
+ predictor.evaluate(split_loaders[config.inference.split])
244
+ if evaluate
245
+ else predictor.predict(split_loaders[config.inference.split])
246
+ )
247
+ if not evaluate and context.is_main_process:
248
+ output = Path(config.inference.output)
249
+ if str(config.inference.format).lower() != "npz":
250
+ raise ValueError("only npz inference output is currently supported")
251
+ write_npz(result, output)
252
+ barrier(context)
253
+ return result
254
+ finally:
255
+ finalize(context)
256
+
257
+
258
+ def export_onnx(config: DictConfig) -> Path:
259
+ """Export a checkpoint using the first prepared graph batch."""
260
+ if bool(config.distributed.enabled) or int(os.environ.get("WORLD_SIZE", "1")) > 1:
261
+ raise ValueError("ONNX export must run on a single process")
262
+ checkpoint = config.export.checkpoint
263
+ if not checkpoint:
264
+ raise ValueError("export.checkpoint is required")
265
+ if str(config.export.format).lower() != "onnx":
266
+ raise ValueError("only export.format=onnx is supported")
267
  split_loaders = loaders(config)
268
+ split = str(config.export.split)
269
+ if split not in split_loaders:
270
+ raise ValueError(f"unsupported export split {split!r}")
271
+ example_batch = next(iter(split_loaders[split]), None)
272
+ if example_batch is None:
273
+ raise ValueError(f"export split {split!r} is empty")
274
+ from gnn4colliders.export import export_checkpoint_to_onnx
275
+
276
+ return export_checkpoint_to_onnx(
277
+ checkpoint,
278
+ config.export.output,
279
+ example_batch=example_batch,
280
+ opset=int(config.export.opset),
281
+ overwrite=bool(config.export.overwrite),
282
  )
 
 
 
 
 
 
src/gnn4colliders/distributed/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal PyTorch DDP support for GNN4Colliders applications."""
2
+
3
+ from .collectives import barrier, broadcast_bool, gather_objects, gather_tensor
4
+ from .context import DistributedContext, finalize, initialize, process_group
5
+ from .model import prepare_model
6
+ from .sampler import DistributedIndices
7
+
8
+ __all__ = [
9
+ "DistributedContext",
10
+ "DistributedIndices",
11
+ "barrier",
12
+ "broadcast_bool",
13
+ "finalize",
14
+ "gather_objects",
15
+ "gather_tensor",
16
+ "initialize",
17
+ "prepare_model",
18
+ "process_group",
19
+ ]
src/gnn4colliders/distributed/collectives.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small tensor/object collectives used by training and inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+ import torch.distributed as dist
9
+
10
+ from .context import DistributedContext
11
+
12
+
13
+ def broadcast_bool(value: bool, context: DistributedContext, *, src: int = 0) -> bool:
14
+ if not context.enabled:
15
+ return value
16
+ tensor = torch.tensor([int(value)], device=context.device)
17
+ dist.broadcast(tensor, src=src)
18
+ return bool(tensor.item())
19
+
20
+
21
+ def gather_tensor(tensor: torch.Tensor, context: DistributedContext) -> torch.Tensor:
22
+ """All-gather a first-dimension-sharded tensor, including unequal shards."""
23
+
24
+ if not context.enabled:
25
+ return tensor
26
+ local = tensor.contiguous()
27
+ size = torch.tensor([local.shape[0]], device=local.device, dtype=torch.long)
28
+ sizes = [torch.zeros_like(size) for _ in range(context.world_size)]
29
+ dist.all_gather(sizes, size)
30
+ max_size = max(int(item.item()) for item in sizes)
31
+ padded = torch.zeros(
32
+ (max_size, *local.shape[1:]), dtype=local.dtype, device=local.device
33
+ )
34
+ padded[: local.shape[0]] = local
35
+ gathered = [torch.empty_like(padded) for _ in range(context.world_size)]
36
+ dist.all_gather(gathered, padded)
37
+ return torch.cat(
38
+ [part[: int(length.item())] for part, length in zip(gathered, sizes)], dim=0
39
+ )
40
+
41
+
42
+ def gather_objects(value: Any, context: DistributedContext) -> list[Any]:
43
+ if not context.enabled:
44
+ return [value]
45
+ gathered: list[Any] = [None] * context.world_size
46
+ dist.all_gather_object(gathered, value)
47
+ return gathered
48
+
49
+
50
+ def barrier(context: DistributedContext) -> None:
51
+ if context.enabled:
52
+ dist.barrier()
src/gnn4colliders/distributed/context.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Process-group context and rank-local device selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+ from datetime import timedelta
9
+ from typing import Iterator
10
+
11
+ import torch
12
+ import torch.distributed as dist
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class DistributedContext:
17
+ enabled: bool = False
18
+ rank: int = 0
19
+ local_rank: int = 0
20
+ world_size: int = 1
21
+ device: torch.device = torch.device("cpu")
22
+
23
+ @property
24
+ def is_main_process(self) -> bool:
25
+ return self.rank == 0
26
+
27
+ @classmethod
28
+ def single_process(cls, device: torch.device | str = "cpu") -> "DistributedContext":
29
+ return cls(device=torch.device(device))
30
+
31
+
32
+ def _launched() -> bool:
33
+ return "RANK" in os.environ or "WORLD_SIZE" in os.environ
34
+
35
+
36
+ def initialize(
37
+ *,
38
+ enabled: bool | None = None,
39
+ backend: str | None = None,
40
+ device: torch.device | str | None = None,
41
+ timeout_seconds: int = 1800,
42
+ ) -> DistributedContext:
43
+ """Initialize ``torchrun``'s ``env://`` process group when requested."""
44
+
45
+ launched = _launched()
46
+ if enabled is False and launched and int(os.environ.get("WORLD_SIZE", "1")) > 1:
47
+ raise ValueError(
48
+ "distributed launcher variables are present but distributed.enabled=false"
49
+ )
50
+ active = launched if enabled is None else enabled
51
+ if not active:
52
+ requested = torch.device(
53
+ device or ("cuda" if torch.cuda.is_available() else "cpu")
54
+ )
55
+ if requested.type == "cuda" and not torch.cuda.is_available():
56
+ requested = torch.device("cpu")
57
+ return DistributedContext.single_process(requested)
58
+
59
+ try:
60
+ rank = int(os.environ["RANK"])
61
+ local_rank = int(os.environ.get("LOCAL_RANK", rank))
62
+ world_size = int(os.environ["WORLD_SIZE"])
63
+ except KeyError as error:
64
+ raise ValueError(
65
+ "distributed execution requires torchrun RANK and WORLD_SIZE"
66
+ ) from error
67
+ if world_size < 2:
68
+ return DistributedContext.single_process(device or "cpu")
69
+ requested = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
70
+ if requested.type == "cuda":
71
+ if not torch.cuda.is_available():
72
+ raise RuntimeError(
73
+ "CUDA was requested for distributed execution but is unavailable"
74
+ )
75
+ torch.cuda.set_device(local_rank)
76
+ assigned = torch.device("cuda", local_rank)
77
+ selected_backend = backend or "nccl"
78
+ else:
79
+ assigned = torch.device("cpu")
80
+ selected_backend = backend or "gloo"
81
+ if not dist.is_initialized():
82
+ dist.init_process_group(
83
+ backend=selected_backend,
84
+ init_method="env://",
85
+ timeout=timedelta(seconds=timeout_seconds),
86
+ )
87
+ return DistributedContext(True, rank, local_rank, world_size, assigned)
88
+
89
+
90
+ def finalize(context: DistributedContext) -> None:
91
+ """Destroy a process group created for ``context``."""
92
+
93
+ if context.enabled and dist.is_available() and dist.is_initialized():
94
+ dist.destroy_process_group()
95
+
96
+
97
+ @contextmanager
98
+ def process_group(**kwargs: object) -> Iterator[DistributedContext]:
99
+ context = initialize(**kwargs)
100
+ try:
101
+ yield context
102
+ finally:
103
+ finalize(context)
src/gnn4colliders/distributed/model.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DDP model wrapping kept outside architecture implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from torch import nn
6
+ from torch.nn.parallel import DistributedDataParallel
7
+
8
+ from .context import DistributedContext
9
+
10
+
11
+ def prepare_model(model: nn.Module, context: DistributedContext) -> nn.Module:
12
+ model = model.to(context.device)
13
+ if not context.enabled:
14
+ return model
15
+ kwargs = (
16
+ {"device_ids": [context.local_rank], "output_device": context.local_rank}
17
+ if context.device.type == "cuda"
18
+ else {}
19
+ )
20
+ return DistributedDataParallel(model, **kwargs)
src/gnn4colliders/distributed/sampler.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Index sharding for the project's deterministic graph loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from .context import DistributedContext
8
+
9
+
10
+ class DistributedIndices:
11
+ """Epoch-aware rank partitioning without evaluation padding duplicates."""
12
+
13
+ def __init__(
14
+ self,
15
+ size: int,
16
+ context: DistributedContext,
17
+ *,
18
+ shuffle: bool,
19
+ drop_last: bool = False,
20
+ seed: int = 0,
21
+ pad: bool = True,
22
+ ) -> None:
23
+ self.size = size
24
+ self.context = context
25
+ self.shuffle = shuffle
26
+ self.drop_last = drop_last
27
+ self.seed = seed
28
+ self.pad = pad
29
+ self.epoch = 0
30
+
31
+ def set_epoch(self, epoch: int) -> None:
32
+ self.epoch = epoch
33
+
34
+ def indices(self) -> list[int]:
35
+ values = torch.arange(self.size, dtype=torch.long)
36
+ if self.shuffle:
37
+ generator = torch.Generator().manual_seed(self.seed + self.epoch)
38
+ values = values[torch.randperm(self.size, generator=generator)]
39
+ if not self.context.enabled:
40
+ return values.tolist()
41
+ if self.drop_last:
42
+ usable = self.size - self.size % self.context.world_size
43
+ values = values[:usable]
44
+ elif self.pad and self.size % self.context.world_size:
45
+ extra = self.context.world_size - self.size % self.context.world_size
46
+ values = torch.cat((values, values[:extra]))
47
+ return values[self.context.rank :: self.context.world_size].tolist()
tests/unit/distributed/__init__.py ADDED
File without changes
tests/unit/distributed/test_ddp_cpu.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import multiprocessing as mp
2
+ import os
3
+ import socket
4
+
5
+ import pytest
6
+ import torch
7
+ from torch import nn
8
+
9
+ from gnn4colliders.distributed import finalize, initialize, prepare_model
10
+
11
+
12
+ def _port() -> int:
13
+ with socket.socket() as sock:
14
+ sock.bind(("127.0.0.1", 0))
15
+ return sock.getsockname()[1]
16
+
17
+
18
+ def _worker(rank: int, port: int, queue) -> None:
19
+ os.environ.update(
20
+ RANK=str(rank),
21
+ LOCAL_RANK=str(rank),
22
+ WORLD_SIZE="2",
23
+ MASTER_ADDR="127.0.0.1",
24
+ MASTER_PORT=str(port),
25
+ )
26
+ context = initialize(enabled=True, backend="gloo", device="cpu")
27
+ try:
28
+ torch.manual_seed(5)
29
+ model = prepare_model(nn.Linear(1, 1), context)
30
+ optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
31
+ x = torch.tensor([[1.0], [2.0]]) if rank == 0 else torch.tensor([[3.0], [4.0]])
32
+ y = 2 * x
33
+ optimizer.zero_grad()
34
+ loss = nn.functional.mse_loss(model(x), y)
35
+ loss.backward()
36
+ optimizer.step()
37
+ queue.put(
38
+ (
39
+ rank,
40
+ model.module.weight.detach().cpu().item(),
41
+ model.module.bias.detach().cpu().item(),
42
+ )
43
+ )
44
+ finally:
45
+ finalize(context)
46
+
47
+
48
+ @pytest.mark.skipif(
49
+ not torch.distributed.is_available(), reason="torch.distributed unavailable"
50
+ )
51
+ def test_two_rank_cpu_ddp_matches_single_process_update():
52
+ queue = mp.get_context("spawn").Queue()
53
+ # Both workers must share one rendezvous port.
54
+ port = _port()
55
+ processes = [
56
+ mp.get_context("spawn").Process(target=_worker, args=(rank, port, queue))
57
+ for rank in range(2)
58
+ ]
59
+ for process in processes:
60
+ process.start()
61
+ values = [queue.get(timeout=30) for _ in processes]
62
+ for process in processes:
63
+ process.join(timeout=30)
64
+ assert process.exitcode == 0
65
+ assert values[0][1] == values[1][1]
66
+ assert values[0][2] == values[1][2]
67
+
68
+ torch.manual_seed(5)
69
+ reference = nn.Linear(1, 1)
70
+ optimizer = torch.optim.SGD(reference.parameters(), lr=0.1)
71
+ x = torch.arange(1.0, 5.0).reshape(-1, 1)
72
+ optimizer.zero_grad()
73
+ nn.functional.mse_loss(reference(x), 2 * x).backward()
74
+ optimizer.step()
75
+ assert torch.allclose(torch.tensor(values[0][1]), reference.weight.squeeze())
76
+ assert torch.allclose(torch.tensor(values[0][2]), reference.bias.squeeze())