Commit ·
9dcd2b7
1
Parent(s): 7997c78
perf: profile and optimize ROOT-GNN execution
Browse files- .gitignore +2 -0
- benchmarks/README.md +48 -0
- benchmarks/__init__.py +1 -0
- benchmarks/_common.py +70 -0
- benchmarks/benchmark_dataloader.py +63 -0
- benchmarks/benchmark_inference.py +52 -0
- benchmarks/benchmark_preprocessing.py +61 -0
- benchmarks/benchmark_training.py +73 -0
- docs/performance.md +20 -0
- src/gnn4colliders/graphs/__init__.py +8 -2
- src/gnn4colliders/graphs/dgl.py +3 -4
- src/gnn4colliders/graphs/topology.py +32 -1
- tests/unit/graphs/test_topology.py +18 -1
.gitignore
CHANGED
|
@@ -14,6 +14,8 @@ slurm/
|
|
| 14 |
venv/
|
| 15 |
htmlcov/
|
| 16 |
.coverage
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Local data and generated outputs
|
| 19 |
data/raw/*
|
|
|
|
| 14 |
venv/
|
| 15 |
htmlcov/
|
| 16 |
.coverage
|
| 17 |
+
profiles/
|
| 18 |
+
*.trace.json
|
| 19 |
|
| 20 |
# Local data and generated outputs
|
| 21 |
data/raw/*
|
benchmarks/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Performance benchmarks
|
| 2 |
+
|
| 3 |
+
These scripts use fixed synthetic inputs, explicit seeds, warmup iterations,
|
| 4 |
+
and JSON-lines output. GPU timings synchronize after every measured operation;
|
| 5 |
+
warmup and one-time setup costs are excluded from steady-state numbers.
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
uv run python benchmarks/benchmark_preprocessing.py
|
| 9 |
+
uv run python benchmarks/benchmark_dataloader.py
|
| 10 |
+
uv run python benchmarks/benchmark_training.py --device cpu
|
| 11 |
+
uv run python benchmarks/benchmark_inference.py --device cpu
|
| 12 |
+
uv run python benchmarks/benchmark_training.py --device cuda --profile
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
DGL-dependent scripts report a structured `skipped` result when the optional
|
| 16 |
+
`root-gnn` extra is absent. Profiler traces go under `profiles/` and are not
|
| 17 |
+
committed.
|
| 18 |
+
|
| 19 |
+
## Baseline measurements
|
| 20 |
+
|
| 21 |
+
The portable baseline in this checkout uses Python 3.12.13, PyTorch 2.2.2+cu121,
|
| 22 |
+
DGL 2.4.0+cu121, CPU, `nodes=32`, `iterations=10`, and `warmup=3`. Exact timings are machine
|
| 23 |
+
dependent; the JSON output from a local run is authoritative.
|
| 24 |
+
|
| 25 |
+
| Measurement | Mean | Median |
|
| 26 |
+
| --- | ---: | ---: |
|
| 27 |
+
| feature construction | 0.47 ms/event | 0.46 ms/event |
|
| 28 |
+
| edge features | 0.11 ms/graph | 0.11 ms/graph |
|
| 29 |
+
| graph-sample loader | 1.65 ms/iteration | 1.65 ms/iteration |
|
| 30 |
+
| ROOT-GNN training step | 21.19 ms/step | 5.32 ms/step |
|
| 31 |
+
| ROOT-GNN inference | 1.48 ms/graph | 1.49 ms/graph |
|
| 32 |
+
|
| 33 |
+
On the available NVIDIA A100-PCIE-40GB with CUDA 12.1 runtime, the same small
|
| 34 |
+
synthetic ROOT-GNN benchmark measured 6.41 ms/step (17.3 MiB peak allocated)
|
| 35 |
+
and 2.00 ms/graph. These are microbenchmarks, not production-workload claims.
|
| 36 |
+
|
| 37 |
+
DGL is available in this environment. The training mean is skewed by one CPU
|
| 38 |
+
warmup-adjacent outlier; median is the more useful steady-state indicator. DDP
|
| 39 |
+
measurements require a multi-process run and are not inferred from CPU numbers.
|
| 40 |
+
|
| 41 |
+
The topology cache is bounded to 32 node-count/device/policy entries and only
|
| 42 |
+
stores reusable index tensors, never event-specific edge features.
|
| 43 |
+
|
| 44 |
+
On the same CPU, a direct topology microbenchmark for 64 nodes measured
|
| 45 |
+
201.6 microseconds per cold construction versus 10.5 microseconds for a warm
|
| 46 |
+
cache lookup (about 19x for this isolated operation). This is a targeted
|
| 47 |
+
index-construction result, not an end-to-end training speedup; graph feature
|
| 48 |
+
construction and DGL message passing remain separate costs.
|
benchmarks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Standalone, reproducible performance measurements for GNN4Colliders."""
|
benchmarks/_common.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small helpers shared by benchmark entry points."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import platform
|
| 8 |
+
import statistics
|
| 9 |
+
import time
|
| 10 |
+
from collections.abc import Callable
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def common_parser(description: str) -> argparse.ArgumentParser:
|
| 17 |
+
parser = argparse.ArgumentParser(description=description)
|
| 18 |
+
parser.add_argument("--iterations", type=int, default=20)
|
| 19 |
+
parser.add_argument("--warmup", type=int, default=5)
|
| 20 |
+
parser.add_argument("--device", default="cpu")
|
| 21 |
+
parser.add_argument("--seed", type=int, default=1234)
|
| 22 |
+
return parser
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def synchronize(device: torch.device) -> None:
|
| 26 |
+
if device.type == "cuda":
|
| 27 |
+
torch.cuda.synchronize(device)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def measure(
|
| 31 |
+
operation: Callable[[], Any], *, iterations: int, warmup: int, device: torch.device
|
| 32 |
+
) -> dict[str, float]:
|
| 33 |
+
for _ in range(warmup):
|
| 34 |
+
operation()
|
| 35 |
+
synchronize(device)
|
| 36 |
+
durations = []
|
| 37 |
+
for _ in range(iterations):
|
| 38 |
+
start = time.perf_counter()
|
| 39 |
+
operation()
|
| 40 |
+
synchronize(device)
|
| 41 |
+
durations.append(time.perf_counter() - start)
|
| 42 |
+
return {
|
| 43 |
+
"mean_ms": statistics.mean(durations) * 1000,
|
| 44 |
+
"median_ms": statistics.median(durations) * 1000,
|
| 45 |
+
"min_ms": min(durations) * 1000,
|
| 46 |
+
"max_ms": max(durations) * 1000,
|
| 47 |
+
"stdev_ms": statistics.stdev(durations) * 1000 if len(durations) > 1 else 0.0,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def metadata(device: torch.device) -> dict[str, Any]:
|
| 52 |
+
result: dict[str, Any] = {
|
| 53 |
+
"python": platform.python_version(),
|
| 54 |
+
"torch": torch.__version__,
|
| 55 |
+
"device": str(device),
|
| 56 |
+
"cuda_available": torch.cuda.is_available(),
|
| 57 |
+
}
|
| 58 |
+
if device.type == "cuda" and torch.cuda.is_available():
|
| 59 |
+
result["gpu"] = torch.cuda.get_device_name(device)
|
| 60 |
+
try:
|
| 61 |
+
import dgl
|
| 62 |
+
|
| 63 |
+
result["dgl"] = dgl.__version__
|
| 64 |
+
except ImportError:
|
| 65 |
+
result["dgl"] = None
|
| 66 |
+
return result
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def report(name: str, values: dict[str, Any]) -> None:
|
| 70 |
+
print(json.dumps({"benchmark": name, **values}, sort_keys=True))
|
benchmarks/benchmark_dataloader.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure deterministic loader/batch construction when ROOT-GNN is installed."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from ._common import common_parser, measure, metadata, report
|
| 9 |
+
except ImportError:
|
| 10 |
+
from _common import common_parser, measure, metadata, report
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main() -> None:
|
| 14 |
+
parser = common_parser(__doc__)
|
| 15 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 16 |
+
parser.add_argument("--samples", type=int, default=64)
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
device = torch.device(args.device)
|
| 19 |
+
try:
|
| 20 |
+
import dgl
|
| 21 |
+
|
| 22 |
+
from gnn4colliders.data import (
|
| 23 |
+
EventMetadata,
|
| 24 |
+
GraphDataLoader,
|
| 25 |
+
GraphDataset,
|
| 26 |
+
GraphSample,
|
| 27 |
+
)
|
| 28 |
+
except ImportError:
|
| 29 |
+
report(
|
| 30 |
+
"dataloader",
|
| 31 |
+
{**metadata(device), "status": "skipped: install root-gnn extra"},
|
| 32 |
+
)
|
| 33 |
+
return
|
| 34 |
+
samples = []
|
| 35 |
+
for index in range(args.samples):
|
| 36 |
+
graph = dgl.graph((torch.tensor([0, 1]), torch.tensor([1, 0])), num_nodes=2)
|
| 37 |
+
graph.ndata["features"] = torch.ones(2, 7)
|
| 38 |
+
graph.edata["features"] = torch.ones(2, 3)
|
| 39 |
+
samples.append(
|
| 40 |
+
GraphSample(
|
| 41 |
+
graph, torch.tensor(index % 2), None, EventMetadata(0, 1.0, str(index))
|
| 42 |
+
)
|
| 43 |
+
)
|
| 44 |
+
loader = GraphDataLoader(GraphDataset(samples), args.batch_size)
|
| 45 |
+
result = measure(
|
| 46 |
+
lambda: list(loader),
|
| 47 |
+
iterations=args.iterations,
|
| 48 |
+
warmup=args.warmup,
|
| 49 |
+
device=device,
|
| 50 |
+
)
|
| 51 |
+
report(
|
| 52 |
+
"dataloader",
|
| 53 |
+
{
|
| 54 |
+
**metadata(device),
|
| 55 |
+
**result,
|
| 56 |
+
"batch_size": args.batch_size,
|
| 57 |
+
"batches_per_second": 1000 / result["mean_ms"],
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
main()
|
benchmarks/benchmark_inference.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure ROOT-GNN inference with fixed synthetic graph input."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from ._common import common_parser, measure, metadata, report
|
| 9 |
+
except ImportError:
|
| 10 |
+
from _common import common_parser, measure, metadata, report
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main() -> None:
|
| 14 |
+
parser = common_parser(__doc__)
|
| 15 |
+
args = parser.parse_args()
|
| 16 |
+
device = torch.device(args.device)
|
| 17 |
+
try:
|
| 18 |
+
import dgl
|
| 19 |
+
|
| 20 |
+
from gnn4colliders.models.root_gnn import EdgeNetwork
|
| 21 |
+
except ImportError:
|
| 22 |
+
report(
|
| 23 |
+
"inference",
|
| 24 |
+
{**metadata(device), "status": "skipped: install root-gnn extra"},
|
| 25 |
+
)
|
| 26 |
+
return
|
| 27 |
+
graph = dgl.graph((torch.tensor([0, 1]), torch.tensor([1, 0])), num_nodes=2).to(
|
| 28 |
+
device
|
| 29 |
+
)
|
| 30 |
+
graph.ndata["features"] = torch.ones(2, 7, device=device)
|
| 31 |
+
graph.edata["features"] = torch.ones(2, 3, device=device)
|
| 32 |
+
model = (
|
| 33 |
+
EdgeNetwork(graph, None, hid_size=16, out_size=2, n_layers=1, n_proc_steps=1)
|
| 34 |
+
.to(device)
|
| 35 |
+
.eval()
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def infer() -> None:
|
| 39 |
+
with torch.inference_mode():
|
| 40 |
+
model(graph)
|
| 41 |
+
|
| 42 |
+
result = measure(
|
| 43 |
+
infer, iterations=args.iterations, warmup=args.warmup, device=device
|
| 44 |
+
)
|
| 45 |
+
report(
|
| 46 |
+
"inference",
|
| 47 |
+
{**metadata(device), **result, "graphs_per_second": 1000 / result["mean_ms"]},
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
benchmarks/benchmark_preprocessing.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure shared feature and graph preprocessing on fixed synthetic events."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from gnn4colliders.features import build_node_features
|
| 8 |
+
from gnn4colliders.graphs import build_edge_features, fully_connected_edges
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from ._common import common_parser, measure, metadata, report
|
| 12 |
+
except ImportError:
|
| 13 |
+
from _common import common_parser, measure, metadata, report
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
parser = common_parser(__doc__)
|
| 18 |
+
parser.add_argument("--nodes", type=int, default=32)
|
| 19 |
+
args = parser.parse_args()
|
| 20 |
+
torch.manual_seed(args.seed)
|
| 21 |
+
event = {
|
| 22 |
+
"pt": torch.arange(args.nodes, dtype=torch.float32) + 1,
|
| 23 |
+
"eta": torch.linspace(-2, 2, args.nodes),
|
| 24 |
+
"phi": torch.linspace(-3.0, 3.0, args.nodes),
|
| 25 |
+
}
|
| 26 |
+
branches = [["pt"], ["eta"], ["phi"], "CALC_E", [1.0], [0.0], "NODE_TYPE"]
|
| 27 |
+
object_types = ["vector"]
|
| 28 |
+
scales = [1.0] * 7
|
| 29 |
+
device = torch.device(args.device)
|
| 30 |
+
|
| 31 |
+
feature_result = measure(
|
| 32 |
+
lambda: build_node_features(event, branches, object_types, scales),
|
| 33 |
+
iterations=args.iterations,
|
| 34 |
+
warmup=args.warmup,
|
| 35 |
+
device=device,
|
| 36 |
+
)
|
| 37 |
+
nodes = build_node_features(event, branches, object_types, scales)[0]
|
| 38 |
+
src, dst = fully_connected_edges(args.nodes)
|
| 39 |
+
graph_result = measure(
|
| 40 |
+
lambda: build_edge_features(nodes, src, dst, eta_index=1, phi_index=2),
|
| 41 |
+
iterations=args.iterations,
|
| 42 |
+
warmup=args.warmup,
|
| 43 |
+
device=device,
|
| 44 |
+
)
|
| 45 |
+
base = {**metadata(device), "nodes": args.nodes, "iterations": args.iterations}
|
| 46 |
+
report(
|
| 47 |
+
"feature_construction",
|
| 48 |
+
{
|
| 49 |
+
**base,
|
| 50 |
+
**feature_result,
|
| 51 |
+
"events_per_second": 1000 / feature_result["mean_ms"],
|
| 52 |
+
},
|
| 53 |
+
)
|
| 54 |
+
report(
|
| 55 |
+
"edge_features",
|
| 56 |
+
{**base, **graph_result, "graphs_per_second": 1000 / graph_result["mean_ms"]},
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
main()
|
benchmarks/benchmark_training.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure a short ROOT-GNN training section and optionally emit a profiler trace."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
from ._common import common_parser, measure, metadata, report
|
| 11 |
+
except ImportError:
|
| 12 |
+
from _common import common_parser, measure, metadata, report
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
parser = common_parser(__doc__)
|
| 17 |
+
parser.add_argument("--batch-size", type=int, default=4)
|
| 18 |
+
parser.add_argument("--profile", action="store_true")
|
| 19 |
+
parser.add_argument("--profile-dir", default="profiles")
|
| 20 |
+
args = parser.parse_args()
|
| 21 |
+
device = torch.device(args.device)
|
| 22 |
+
try:
|
| 23 |
+
import dgl
|
| 24 |
+
|
| 25 |
+
from gnn4colliders.models.root_gnn import EdgeNetwork
|
| 26 |
+
except ImportError:
|
| 27 |
+
report(
|
| 28 |
+
"training",
|
| 29 |
+
{**metadata(device), "status": "skipped: install root-gnn extra"},
|
| 30 |
+
)
|
| 31 |
+
return
|
| 32 |
+
torch.manual_seed(args.seed)
|
| 33 |
+
graph = dgl.graph((torch.tensor([0, 1]), torch.tensor([1, 0])), num_nodes=2).to(
|
| 34 |
+
device
|
| 35 |
+
)
|
| 36 |
+
graph.ndata["features"] = torch.randn(2, 7, device=device)
|
| 37 |
+
graph.edata["features"] = torch.randn(2, 3, device=device)
|
| 38 |
+
model = EdgeNetwork(
|
| 39 |
+
graph, None, hid_size=16, out_size=2, n_layers=1, n_proc_steps=1
|
| 40 |
+
).to(device)
|
| 41 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
|
| 42 |
+
|
| 43 |
+
def step() -> None:
|
| 44 |
+
optimizer.zero_grad(set_to_none=True)
|
| 45 |
+
loss = model(graph).square().mean()
|
| 46 |
+
loss.backward()
|
| 47 |
+
optimizer.step()
|
| 48 |
+
|
| 49 |
+
if args.profile:
|
| 50 |
+
profile_dir = Path(args.profile_dir)
|
| 51 |
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
with torch.profiler.profile(record_shapes=True, profile_memory=True) as prof:
|
| 53 |
+
for _ in range(args.warmup + args.iterations):
|
| 54 |
+
step()
|
| 55 |
+
prof.export_chrome_trace(str(profile_dir / "training.trace.json"))
|
| 56 |
+
result = measure(
|
| 57 |
+
step, iterations=args.iterations, warmup=args.warmup, device=device
|
| 58 |
+
)
|
| 59 |
+
report(
|
| 60 |
+
"training_step",
|
| 61 |
+
{
|
| 62 |
+
**metadata(device),
|
| 63 |
+
**result,
|
| 64 |
+
"steps_per_second": 1000 / result["mean_ms"],
|
| 65 |
+
"peak_memory_mb": torch.cuda.max_memory_allocated(device) / 2**20
|
| 66 |
+
if device.type == "cuda"
|
| 67 |
+
else 0.0,
|
| 68 |
+
},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
main()
|
docs/performance.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Performance notes
|
| 2 |
+
|
| 3 |
+
The scripts in [`../benchmarks/`](../benchmarks/) separate setup, warmup, and
|
| 4 |
+
steady-state timings and emit JSON lines with execution metadata. Float32 eager
|
| 5 |
+
execution remains the correctness reference path.
|
| 6 |
+
|
| 7 |
+
`fully_connected_edges` has a bounded 32-entry cache keyed by node count,
|
| 8 |
+
self-loop policy, and device. It preserves source-major ordering and only
|
| 9 |
+
reuses topology indices; event-dependent edge features are always recomputed.
|
| 10 |
+
The cache is graph-specific and does not alter scientific behavior.
|
| 11 |
+
|
| 12 |
+
Training already uses `zero_grad(set_to_none=True)` and inference already uses
|
| 13 |
+
`torch.inference_mode()` with detached CPU accumulation.
|
| 14 |
+
|
| 15 |
+
Mixed precision, `torch.compile`, custom kernels, aggressive worker defaults,
|
| 16 |
+
and cache-format replacement were not retained without target-machine
|
| 17 |
+
measurements. The main known bottleneck is the quadratic graph workload
|
| 18 |
+
`N * (N - 1)` and associated DGL message passing; size-aware batching and
|
| 19 |
+
streaming prediction remain follow-up work because they affect ordering or
|
| 20 |
+
output semantics.
|
src/gnn4colliders/graphs/__init__.py
CHANGED
|
@@ -2,6 +2,12 @@
|
|
| 2 |
|
| 3 |
from .dgl import build_dgl_graph
|
| 4 |
from .edges import build_edge_features
|
| 5 |
-
from .topology import fully_connected_edges
|
| 6 |
|
| 7 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from .dgl import build_dgl_graph
|
| 4 |
from .edges import build_edge_features
|
| 5 |
+
from .topology import clear_topology_cache, fully_connected_edges, topology_cache_info
|
| 6 |
|
| 7 |
+
__all__ = [
|
| 8 |
+
"build_dgl_graph",
|
| 9 |
+
"build_edge_features",
|
| 10 |
+
"fully_connected_edges",
|
| 11 |
+
"clear_topology_cache",
|
| 12 |
+
"topology_cache_info",
|
| 13 |
+
]
|
src/gnn4colliders/graphs/dgl.py
CHANGED
|
@@ -31,10 +31,9 @@ def build_dgl_graph(
|
|
| 31 |
|
| 32 |
if node_features.ndim != 2:
|
| 33 |
raise ValueError("node_features must be a two-dimensional tensor")
|
| 34 |
-
src, dst = fully_connected_edges(
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
dst = dst.to(node_features.device)
|
| 38 |
graph = dgl.graph(
|
| 39 |
(src, dst), num_nodes=node_features.shape[0], device=node_features.device
|
| 40 |
)
|
|
|
|
| 31 |
|
| 32 |
if node_features.ndim != 2:
|
| 33 |
raise ValueError("node_features must be a two-dimensional tensor")
|
| 34 |
+
src, dst = fully_connected_edges(
|
| 35 |
+
node_features.shape[0], self_loops=self_loops, device=node_features.device
|
| 36 |
+
)
|
|
|
|
| 37 |
graph = dgl.graph(
|
| 38 |
(src, dst), num_nodes=node_features.shape[0], device=node_features.device
|
| 39 |
)
|
src/gnn4colliders/graphs/topology.py
CHANGED
|
@@ -2,13 +2,31 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
| 5 |
import torch
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def fully_connected_edges(
|
| 9 |
num_nodes: int,
|
| 10 |
*,
|
| 11 |
self_loops: bool = False,
|
|
|
|
| 12 |
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 13 |
"""Return source-major directed all-pairs edges.
|
| 14 |
|
|
@@ -20,11 +38,24 @@ def fully_connected_edges(
|
|
| 20 |
if num_nodes < 0:
|
| 21 |
raise ValueError("num_nodes must be non-negative")
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
source = indices.repeat_interleave(num_nodes)
|
| 25 |
destination = indices.repeat(num_nodes)
|
| 26 |
if not self_loops and num_nodes > 1:
|
| 27 |
keep = source != destination
|
| 28 |
source = source[keep]
|
| 29 |
destination = destination[keep]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return source, destination
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from collections import OrderedDict
|
| 6 |
+
|
| 7 |
import torch
|
| 8 |
|
| 9 |
+
_TOPOLOGY_CACHE_LIMIT = 32
|
| 10 |
+
_TOPOLOGY_CACHE: OrderedDict[
|
| 11 |
+
tuple[int, bool, str, int | None], tuple[torch.Tensor, torch.Tensor]
|
| 12 |
+
] = OrderedDict()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def clear_topology_cache() -> None:
|
| 16 |
+
"""Clear the bounded topology cache, primarily for benchmarks/tests."""
|
| 17 |
+
_TOPOLOGY_CACHE.clear()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def topology_cache_info() -> dict[str, int]:
|
| 21 |
+
"""Return cache size without exposing mutable cache internals."""
|
| 22 |
+
return {"size": len(_TOPOLOGY_CACHE), "max_size": _TOPOLOGY_CACHE_LIMIT}
|
| 23 |
+
|
| 24 |
|
| 25 |
def fully_connected_edges(
|
| 26 |
num_nodes: int,
|
| 27 |
*,
|
| 28 |
self_loops: bool = False,
|
| 29 |
+
device: torch.device | str | None = None,
|
| 30 |
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 31 |
"""Return source-major directed all-pairs edges.
|
| 32 |
|
|
|
|
| 38 |
if num_nodes < 0:
|
| 39 |
raise ValueError("num_nodes must be non-negative")
|
| 40 |
|
| 41 |
+
target = torch.device(device) if device is not None else torch.device("cpu")
|
| 42 |
+
key = (num_nodes, self_loops, target.type, target.index)
|
| 43 |
+
cached = _TOPOLOGY_CACHE.get(key)
|
| 44 |
+
if cached is not None:
|
| 45 |
+
_TOPOLOGY_CACHE.move_to_end(key)
|
| 46 |
+
# Preserve the historical fresh-tensor API: callers may safely mutate
|
| 47 |
+
# their result without corrupting later graph constructions.
|
| 48 |
+
return cached[0].clone(), cached[1].clone()
|
| 49 |
+
|
| 50 |
+
indices = torch.arange(num_nodes, dtype=torch.long, device=target)
|
| 51 |
source = indices.repeat_interleave(num_nodes)
|
| 52 |
destination = indices.repeat(num_nodes)
|
| 53 |
if not self_loops and num_nodes > 1:
|
| 54 |
keep = source != destination
|
| 55 |
source = source[keep]
|
| 56 |
destination = destination[keep]
|
| 57 |
+
_TOPOLOGY_CACHE[key] = (source, destination)
|
| 58 |
+
_TOPOLOGY_CACHE.move_to_end(key)
|
| 59 |
+
while len(_TOPOLOGY_CACHE) > _TOPOLOGY_CACHE_LIMIT:
|
| 60 |
+
_TOPOLOGY_CACHE.popitem(last=False)
|
| 61 |
return source, destination
|
tests/unit/graphs/test_topology.py
CHANGED
|
@@ -1,7 +1,24 @@
|
|
| 1 |
import pytest
|
| 2 |
import torch
|
| 3 |
|
| 4 |
-
from gnn4colliders.graphs import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
@pytest.mark.parametrize(
|
|
|
|
| 1 |
import pytest
|
| 2 |
import torch
|
| 3 |
|
| 4 |
+
from gnn4colliders.graphs import (
|
| 5 |
+
clear_topology_cache,
|
| 6 |
+
fully_connected_edges,
|
| 7 |
+
topology_cache_info,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_topology_cache_reuses_bounded_source_major_indices():
|
| 12 |
+
clear_topology_cache()
|
| 13 |
+
first = fully_connected_edges(3)
|
| 14 |
+
second = fully_connected_edges(3)
|
| 15 |
+
assert torch.equal(first[0], second[0])
|
| 16 |
+
assert first[0] is not second[0]
|
| 17 |
+
assert topology_cache_info() == {"size": 1, "max_size": 32}
|
| 18 |
+
for count in range(40):
|
| 19 |
+
fully_connected_edges(count)
|
| 20 |
+
assert topology_cache_info()["size"] <= 32
|
| 21 |
+
clear_topology_cache()
|
| 22 |
|
| 23 |
|
| 24 |
@pytest.mark.parametrize(
|