Skip to content

Commit efdb905

Browse files
committed
Enable compiler-managed CUDA graphs for UMA inference
Torch compile can capture UMA compute regions, but internal neighbor generation changes the exact edge shape from one frame to the next. Those shape changes prevent reliable CUDA graph reuse even when the atom count is fixed. Expose reduce-overhead as an inference compile mode and mark prediction boundaries for CUDA graph trees. For internal NVIDIA v3 graphs, generate the neighbor list before model execution and pad its edges to a configurable bucket. Padded edges are moved beyond the cutoff so they contribute zero energy, force, and stress while keeping the compiled input shape stable. Neighbor construction remains outside capture because the released NVIDIA implementation allocates temporary buffers. The internal path is limited to CUDA, graph version 3, and non-distributed inference. External graphs can use reduce-overhead without padding. Test Plan: ``` PYTHONPATH="$PWD/src" pytest -q -s tests/core/graph/test_padded_nvidia_graph.py tests/core/models/uma/test_padded_edges.py tests/core/units/mlip_unit/test_inference_settings.py PYTHONPATH="$PWD/src" pytest -q -s tests/core/units/mlip_unit/test_predict.py::test_reduce_overhead_internal_graph_predict pre-commit run --files docs/core/common_tasks/ase_calculator.md src/fairchem/core/graph/padded_nvidia_graph.py src/fairchem/core/models/uma/escn_md.py src/fairchem/core/units/mlip_unit/api/inference.py src/fairchem/core/units/mlip_unit/predict.py tests/core/graph/test_padded_nvidia_graph.py tests/core/models/uma/test_padded_edges.py tests/core/units/mlip_unit/test_inference_settings.py tests/core/units/mlip_unit/test_predict.py ```
1 parent 802979b commit efdb905

9 files changed

Lines changed: 383 additions & 9 deletions

File tree

docs/core/common_tasks/ase_calculator.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ The advanced user might quickly see that **default**, **batch**, and **turbo** m
9292
| activation_checkpointing | this uses a custom chunked activation checkpointing algorithm and allows significant savings in memory for a small inference speed penalty. If you are predicting on systems >1000 atoms, we recommend keeping this on. However, if you want the absolute fastest inference possible for small systems, you can turn this off |
9393
| merge_mole | This is useful in long rollout applications where the system composition stays constant. By pre-merge the MoLE weights, we can save both memory and compute. |
9494
| compile | This uses torch.compile to significantly speed up computation. Due to the way pytorch traces the internal graph, it requires a long compile time during the first iteration and can even recompile anytime it detected a significant change in input dimensions. It is not recommended if you are computing frequently on very different atomic systems. |
95+
| compile_mode | Set to `"reduce-overhead"` to use compiler-managed CUDA graphs. With internal graph generation this requires version 3, a CUDA device, and no graph parallelism. It is intended for repeated evaluations of similarly sized systems. |
9596
| external_graph_gen | Only use this if you want to use an external graph generator. This should be rarely used except for development |
9697
| internal_graph_gen_version | currently we support v2[default], an internal implementation that is better suited for parallelism and v3 the neighborlist from Nvidia Alchemi library which is faster for single gpu operations. |
98+
| internal_graph_edge_bucket_size | Number of edges per padded bucket when `compile_mode="reduce-overhead"` uses internal graph version 3. Larger buckets reduce shape changes but perform more work on masked edges. Default 1024. |
9799
| edge_chunk_size | Experimental. Used for padding edge sizes. This helps reduce re-compilations from torch compile, default to None |
98100
| use_quaternion_wigner | enable quaternion-based Wigner D matrix computation. If false we fall back to euler-angle based rotations. default True. |
99101
| base_precision_dtype | governs the main precision type of the computation, default to FP32, FP64 is also supported |
@@ -109,8 +111,9 @@ settings = InferenceSettings(
109111
activation_checkpointing=False,
110112
merge_mole=True,
111113
compile=True,
114+
compile_mode="reduce-overhead",
112115
external_graph_gen=False,
113-
internal_graph_gen_version=2,
116+
internal_graph_gen_version=3,
114117
)
115118
116119
predictor = pretrained_mlip.get_predict_unit(
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Bucketed NVIDIA neighbor graphs for compiled inference."""
2+
3+
from __future__ import annotations
4+
5+
import torch
6+
import torch.nn.functional as F
7+
8+
from fairchem.core.common import gp_utils
9+
from fairchem.core.graph.radius_graph_pbc_nvidia import radius_graph_pbc_nvidia
10+
11+
12+
class PaddedNvidiaGraphGenerator:
13+
"""Generate v3 neighbor graphs and replace data's edges with a padded bucket."""
14+
15+
def __init__(self, settings, backbone):
16+
if gp_utils.initialized():
17+
raise ValueError(
18+
"padded NVIDIA graph generation does not support graph parallelism"
19+
)
20+
if not getattr(backbone, "supports_padded_edges", False):
21+
raise ValueError(
22+
f"{type(backbone).__name__} does not support padded neighbor edges"
23+
)
24+
if torch.device(next(backbone.parameters()).device).type != "cuda":
25+
raise ValueError("padded NVIDIA graph generation requires CUDA")
26+
self.cutoff = float(backbone.cutoff)
27+
self.max_neighbors = int(backbone.max_neighbors)
28+
self.enforce_max_neighbors_strictly = bool(
29+
backbone.enforce_max_neighbors_strictly
30+
)
31+
self.edge_bucket_size = settings.internal_graph_edge_bucket_size
32+
33+
def generate(self, data):
34+
edge_index, cell_offsets, neighbors = radius_graph_pbc_nvidia(
35+
data,
36+
self.cutoff,
37+
self.max_neighbors,
38+
self.enforce_max_neighbors_strictly,
39+
pbc=data.pbc,
40+
)
41+
num_edges = edge_index.shape[1]
42+
edge_capacity = (
43+
(num_edges + self.edge_bucket_size - 1) // self.edge_bucket_size
44+
) * self.edge_bucket_size
45+
pad = edge_capacity - num_edges
46+
47+
data.edge_index = F.pad(edge_index, (0, pad))
48+
data.cell_offsets = F.pad(cell_offsets.to(data.pos.dtype), (0, 0, 0, pad))
49+
data.nedges = neighbors.clone()
50+
data.nedges[-1] += pad
51+
data.edge_valid_mask = (
52+
torch.arange(edge_capacity, device=edge_index.device) < num_edges
53+
)
54+
return data

src/fairchem/core/models/uma/escn_md.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ def resolve_dataset_mapping(
278278

279279
@registry.register_model("escnmd_backbone")
280280
class eSCNMDBackbone(nn.Module, MOLEInterface):
281+
supports_padded_edges = True
282+
281283
def __init__(
282284
self,
283285
max_num_elements: int = 100,
@@ -663,10 +665,12 @@ def _generate_graph(self, data_dict):
663665

664666
# Compute shifts from cell offsets
665667
if len(data_dict["natoms"]) == 1:
666-
# Single system: use matmul (compile-friendly, no data-dependent ops)
667-
shifts = data_dict["cell_offsets"].to(
668-
data_dict["cell"].dtype
669-
) @ data_dict["cell"].squeeze(0)
668+
offsets = data_dict["cell_offsets"].to(data_dict["cell"].dtype)
669+
if "edge_valid_mask" in data_dict:
670+
cell_per_edge = data_dict["cell"].expand(offsets.shape[0], -1, -1)
671+
shifts = torch.bmm(offsets.unsqueeze(1), cell_per_edge).squeeze(1)
672+
else:
673+
shifts = offsets @ data_dict["cell"].squeeze(0)
670674
else:
671675
# Batched: need repeat_interleave for variable edges per system
672676
cell_per_edge = data_dict["cell"].repeat_interleave(
@@ -684,6 +688,13 @@ def _generate_graph(self, data_dict):
684688
- data_dict["pos"][data_dict["edge_index"][1]]
685689
+ shifts
686690
) # [n_edges, 3]
691+
edge_valid_mask = data_dict.get("edge_valid_mask", None)
692+
if edge_valid_mask is not None:
693+
padding_vec = edge_distance_vec.new_zeros(3)
694+
padding_vec[0].fill_(self.cutoff + 1.0)
695+
edge_distance_vec = torch.where(
696+
edge_valid_mask.unsqueeze(1), edge_distance_vec, padding_vec
697+
)
687698
# pylint: disable=E1102
688699
edge_distance = torch.linalg.norm(
689700
edge_distance_vec, dim=-1, keepdim=False
@@ -952,7 +963,14 @@ def build_inference_settings(cls, settings: InferenceSettings) -> dict:
952963
if settings.edge_chunk_size is not None:
953964
overrides["edge_chunk_size"] = settings.edge_chunk_size
954965
if settings.external_graph_gen is not None:
955-
overrides["otf_graph"] = not settings.external_graph_gen
966+
padded_internal_graph = (
967+
settings.compile_mode == "reduce-overhead"
968+
and not settings.external_graph_gen
969+
and settings.internal_graph_gen_version == 3
970+
)
971+
overrides["otf_graph"] = not (
972+
settings.external_graph_gen or padded_internal_graph
973+
)
956974
if settings.internal_graph_gen_version is not None:
957975
overrides["radius_pbc_version"] = settings.internal_graph_gen_version
958976
if settings.use_quaternion_wigner is not None:

src/fairchem/core/units/mlip_unit/api/inference.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,30 @@ class InferenceSettings:
201201
# VRAM but allow bigger systems; reduce if you run into OOM errors.
202202
max_atoms: int | None = None
203203

204+
# Optional torch.compile mode. "reduce-overhead" enables Inductor CUDA graphs.
205+
compile_mode: str | None = None
206+
207+
# Edge bucket used by padded NVIDIA internal graph generation.
208+
internal_graph_edge_bucket_size: int = 1024
209+
204210
def __post_init__(self):
211+
if self.compile_mode not in (None, "reduce-overhead"):
212+
raise ValueError("compile_mode must be None or 'reduce-overhead'")
213+
if self.compile_mode is not None and not self.compile:
214+
raise ValueError("compile_mode requires compile=True")
215+
if (
216+
type(self.internal_graph_edge_bucket_size) is not int
217+
or self.internal_graph_edge_bucket_size < 1
218+
):
219+
raise ValueError("internal_graph_edge_bucket_size must be positive")
220+
if (
221+
self.compile_mode == "reduce-overhead"
222+
and not self.external_graph_gen
223+
and self.internal_graph_gen_version != 3
224+
):
225+
raise ValueError(
226+
"internal reduce-overhead requires internal_graph_gen_version=3"
227+
)
205228
if isinstance(self.base_precision_dtype, str):
206229
self.base_precision_dtype = getattr(torch, self.base_precision_dtype)
207230
assert (

src/fairchem/core/units/mlip_unit/predict.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939
from fairchem.core.components.batch_server import get_app_handle_with_retry
4040
from fairchem.core.datasets.atomic_data import AtomicData, warn_if_upcasting
41+
from fairchem.core.graph.padded_nvidia_graph import PaddedNvidiaGraphGenerator
4142
from fairchem.core.models.uma.nn.execution_backends import (
4243
ExecutionMode,
4344
maybe_update_settings_backend,
@@ -150,6 +151,7 @@ def __init__(
150151
self.lazy_model_intialized = False
151152
self.assert_on_nans = assert_on_nans
152153
self._warned_upcast = False
154+
self._padded_graph_generator = None
153155

154156
if self.model.module.backbone.regress_config.direct_forces:
155157
logging.warning(
@@ -448,6 +450,8 @@ def _fall_back_from_fast_path(
448450
# 1. change flags
449451
self.inference_settings.merge_mole = False
450452
self.inference_settings.compile = False
453+
self.inference_settings.compile_mode = None
454+
self._padded_graph_generator = None
451455
if self.inference_settings.execution_mode == ExecutionMode.UMAS_FAST_GPU:
452456
self.inference_settings.execution_mode = ExecutionMode.GENERAL
453457
self.lazy_model_intialized = False
@@ -493,9 +497,17 @@ def predict(
493497
if single_atom_result is not None:
494498
return single_atom_result
495499

500+
if self.inference_settings.compile_mode == "reduce-overhead":
501+
torch.compiler.cudagraph_mark_step_begin()
502+
503+
use_padded_internal_graph = (
504+
self.inference_settings.compile_mode == "reduce-overhead"
505+
and not self.inference_settings.external_graph_gen
506+
)
507+
496508
# Regular model prediction path
497509
# this needs to be .clone() to avoid issues with graph parallel modifying this data with MOLE
498-
data_device = data.to(self.device).clone()
510+
data_device = data.clone().to(self.device)
499511

500512
dtype = self.inference_settings.base_precision_dtype
501513
if not self._warned_upcast:
@@ -505,6 +517,12 @@ def predict(
505517
data_device[key] = val.to(dtype)
506518

507519
backbone = self.model.module.backbone
520+
if use_padded_internal_graph:
521+
if self._padded_graph_generator is None:
522+
self._padded_graph_generator = PaddedNvidiaGraphGenerator(
523+
self.inference_settings, backbone
524+
)
525+
data_device = self._padded_graph_generator.generate(data_device)
508526
_prepare_inference_gradients(backbone, data_device)
509527

510528
# Model handles any per-prediction checks (e.g., MOLE consistency)
@@ -514,13 +532,28 @@ def predict(
514532
self._fall_back_from_fast_path(error)
515533
self._lazy_init(data)
516534
self.model.module.on_predict_check(data_device)
517-
518535
return self._run_inference(data_device, undo_element_references)
519536

520537
def _lazy_init(self, data: AtomicData) -> None:
521538
"""
522539
Lazy initialization on first predict call.
523540
"""
541+
if (
542+
self.inference_settings.compile_mode == "reduce-overhead"
543+
and not self.inference_settings.external_graph_gen
544+
):
545+
if gp_utils.initialized():
546+
raise ValueError(
547+
"internal reduce-overhead does not support graph parallelism"
548+
)
549+
if not getattr(self.model.module.backbone, "supports_padded_edges", False):
550+
raise ValueError(
551+
"internal reduce-overhead is not supported by "
552+
f"{type(self.model.module.backbone).__name__}"
553+
)
554+
if torch.device(self._requested_device).type != "cuda":
555+
raise ValueError("internal reduce-overhead requires CUDA")
556+
524557
# Model handles its own preparation (MOLE merge, eval mode, etc.)
525558
self.model.module.prepare_for_inference(data, self.inference_settings)
526559
# Inference differentiates outputs with respect to inputs, not weights.
@@ -539,7 +572,11 @@ def _lazy_init(self, data: AtomicData) -> None:
539572
# The model's scalars are fixed at inference, so this skips dynamo's
540573
# TensorifyScalarRestartAnalysis retrace during compile.
541574
torch._dynamo.config.specialize_float = True
542-
self.model = torch.compile(self.model, dynamic=True)
575+
self.model = torch.compile(
576+
self.model,
577+
dynamic=True,
578+
mode=self.inference_settings.compile_mode,
579+
)
543580

544581
self.lazy_model_intialized = True
545582

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Tests for bucketed NVIDIA neighbor graphs."""
2+
3+
from __future__ import annotations
4+
5+
from types import SimpleNamespace
6+
7+
import torch
8+
9+
import fairchem.core.graph.padded_nvidia_graph as padded_graph
10+
from fairchem.core.graph.padded_nvidia_graph import PaddedNvidiaGraphGenerator
11+
12+
13+
class _GraphData(SimpleNamespace):
14+
def clone(self):
15+
return _GraphData(
16+
**{
17+
key: value.clone() if torch.is_tensor(value) else value
18+
for key, value in vars(self).items()
19+
}
20+
)
21+
22+
23+
def _generator(bucket_size=4):
24+
generator = object.__new__(PaddedNvidiaGraphGenerator)
25+
generator.cutoff = 5.0
26+
generator.max_neighbors = 32
27+
generator.enforce_max_neighbors_strictly = False
28+
generator.edge_bucket_size = bucket_size
29+
return generator
30+
31+
32+
def test_generate_pads_edges_to_bucket(monkeypatch):
33+
edge_index = torch.tensor([[1, 0, 2], [0, 1, 1]])
34+
cell_offsets = torch.arange(9, dtype=torch.float64).reshape(3, 3)
35+
neighbors = torch.tensor([2, 1])
36+
37+
def graph(*args, **kwargs):
38+
return edge_index, cell_offsets, neighbors
39+
40+
monkeypatch.setattr(padded_graph, "radius_graph_pbc_nvidia", graph)
41+
data = _GraphData(
42+
pos=torch.empty(3, 3, dtype=torch.float32),
43+
pbc=torch.ones(2, 3, dtype=torch.bool),
44+
)
45+
46+
result = _generator().generate(data)
47+
48+
assert result is data
49+
torch.testing.assert_close(result.edge_index[:, :3], edge_index)
50+
torch.testing.assert_close(
51+
result.edge_index[:, 3], torch.zeros(2, dtype=torch.long)
52+
)
53+
torch.testing.assert_close(result.cell_offsets[:3], cell_offsets.float())
54+
torch.testing.assert_close(result.cell_offsets[3], torch.zeros(3))
55+
torch.testing.assert_close(result.nedges, torch.tensor([2, 2]))
56+
torch.testing.assert_close(
57+
result.edge_valid_mask, torch.tensor([True, True, True, False])
58+
)
59+
60+
61+
def test_generate_keeps_exact_bucket_shape(monkeypatch):
62+
edge_index = torch.tensor([[1, 0, 2, 1], [0, 1, 1, 2]])
63+
cell_offsets = torch.zeros(4, 3)
64+
neighbors = torch.tensor([4])
65+
monkeypatch.setattr(
66+
padded_graph,
67+
"radius_graph_pbc_nvidia",
68+
lambda *args, **kwargs: (edge_index, cell_offsets, neighbors),
69+
)
70+
data = _GraphData(pos=torch.empty(3, 3), pbc=torch.ones(1, 3, dtype=torch.bool))
71+
72+
result = _generator().generate(data)
73+
74+
torch.testing.assert_close(result.edge_index, edge_index)
75+
torch.testing.assert_close(result.nedges, neighbors)
76+
assert result.edge_valid_mask.all()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Tests for padded neighbor edges in UMA."""
2+
3+
from __future__ import annotations
4+
5+
import torch
6+
7+
from fairchem.core.models.uma.escn_md import eSCNMDBackbone
8+
9+
10+
def test_precomputed_padding_is_moved_beyond_cutoff():
11+
backbone = object.__new__(eSCNMDBackbone)
12+
torch.nn.Module.__init__(backbone)
13+
backbone.cutoff = 5.0
14+
backbone.otf_graph = False
15+
data = {
16+
"pos": torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]),
17+
"cell": torch.eye(3).unsqueeze(0),
18+
"natoms": torch.tensor([2]),
19+
"edge_index": torch.tensor([[0, 0], [1, 0]]),
20+
"cell_offsets": torch.zeros(2, 3),
21+
"edge_valid_mask": torch.tensor([True, False]),
22+
}
23+
24+
graph = backbone._generate_graph(data)
25+
26+
torch.testing.assert_close(graph["edge_distance"][0], torch.tensor(1.0))
27+
torch.testing.assert_close(graph["edge_distance"][1], torch.tensor(6.0))
28+
torch.testing.assert_close(data["scatter_target"], torch.tensor([1, 0]))
29+
30+
31+
def test_precomputed_periodic_shifts_match_internal_graph_math():
32+
backbone = object.__new__(eSCNMDBackbone)
33+
torch.nn.Module.__init__(backbone)
34+
backbone.cutoff = 5.0
35+
backbone.otf_graph = False
36+
data = {
37+
"pos": torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]),
38+
"cell": torch.diag(torch.tensor([3.0, 4.0, 5.0])).unsqueeze(0),
39+
"natoms": torch.tensor([2]),
40+
"edge_index": torch.tensor([[0], [1]]),
41+
"cell_offsets": torch.tensor([[1.0, 0.0, 0.0]]),
42+
}
43+
44+
graph = backbone._generate_graph(data)
45+
46+
torch.testing.assert_close(
47+
graph["edge_distance_vec"], torch.tensor([[2.0, 0.0, 0.0]])
48+
)
49+
torch.testing.assert_close(graph["edge_distance"], torch.tensor([2.0]))

0 commit comments

Comments
 (0)