Commit ·
13fd35c
1
Parent(s): ee44c00
feat: add distributed training execution
Browse files- docs/perlmutter.md +58 -0
- src/gnn4colliders/config/application.py +150 -55
- src/gnn4colliders/distributed/__init__.py +19 -0
- src/gnn4colliders/distributed/collectives.py +52 -0
- src/gnn4colliders/distributed/context.py +103 -0
- src/gnn4colliders/distributed/model.py +20 -0
- src/gnn4colliders/distributed/sampler.py +47 -0
- tests/unit/distributed/__init__.py +0 -0
- tests/unit/distributed/test_ddp_cpu.py +76 -0
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(
|
|
|
|
|
|
|
| 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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
optimizer=trainer.optimizer,
|
| 147 |
scheduler=trainer.scheduler,
|
| 148 |
early_stopping=trainer.early_stopping,
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 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 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())
|