ho22joshua commited on
Commit
9dcd2b7
·
1 Parent(s): 7997c78

perf: profile and optimize ROOT-GNN execution

Browse files
.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__ = ["build_dgl_graph", "build_edge_features", "fully_connected_edges"]
 
 
 
 
 
 
 
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(node_features.shape[0], self_loops=self_loops)
35
- if node_features.device.type != "cpu":
36
- src = src.to(node_features.device)
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
- indices = torch.arange(num_nodes, dtype=torch.long)
 
 
 
 
 
 
 
 
 
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 fully_connected_edges
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(