Skip to content

feat(torch.compile): PET FX compilation - #1029

Open
HaoZeke wants to merge 12 commits into
metatensor:mainfrom
HaoZeke:petPartialCompile
Open

feat(torch.compile): PET FX compilation#1029
HaoZeke wants to merge 12 commits into
metatensor:mainfrom
HaoZeke:petPartialCompile

Conversation

@HaoZeke

@HaoZeke HaoZeke commented Feb 2, 2026

Copy link
Copy Markdown
Member

Summary

Full-graph FX compilation for PET training. Reworked from per-submodule compilation to the PhACE pattern (#921), and based off of the much cleaner to read UPET thing @sirmarcel has :)

make_fx(tracing_mode="symbolic") traces the entire PET forward pass (including force/stress via autograd.grad(create_graph=False)) into a single FX graph, then torch.compile(dynamic=True, fullgraph=True) compiles it. Single backward through the compiled graph, no double backward, SDPA (Scaled Dot-Product Attention) everywhere.

Key changes

  • _forward_from_batch(): Pure-tensor input/output forward method on PET, bypassing metatensor TensorMap construction to make the model traceable.
  • neighbor_atom_indices in systems_to_batch: Maps each edge slot to its neighbor atom index for force scattering in the compiled path.
  • modules/compile.py: compile_pet_model() entry point. Wraps PET in _PETBatchForward, traces with make_fx, compiles with torch.compile(dynamic=True, fullgraph=True).
  • _wrap_compiled_output(): Converts compiled function outputs back to Dict[str, TensorMap] for the loss function. Handles energy, forces, stress, and non-energy targets. Fixed device mismatch for GPU training.
  • training.compile option in documentation/hypers (default: false).
  • SiLU decomposition: Replaces nn.SiLU with x * sigmoid(x) to avoid silu_backward nodes that break inductor.

What gets compiled into one graph

  • Edge distance computation (differentiable)
  • All GNN (Graph Neural Network) layers (CartesianTransformer blocks with SDPA)
  • Feature extraction and prediction heads
  • Per-atom to per-structure energy aggregation (scatter_add)
  • Force computation: autograd.grad + chain-rule scatter
  • Stress computation: virial via einsum + scatter

Adaptive cutoff support

Works with num_neighbors_adaptive out of the box. Adaptive cutoffs cause max_edges_per_node (the second dimension of NEF (Node-Edge-Feature) tensors) to vary per batch, which typically breaks torch.compile. The NEF format reshapes edges into padded 2D arrays of shape [n_atoms, max_neighbors, ...], and the adaptive cutoff computation (data-dependent edge filtering) happens outside the FX graph in systems_to_batch(). The traced graph receives pre-filtered NEF tensors with varying but deterministic shapes. dynamic=True generates shape-generic kernels that handle the varying max_edges_per_node without recompilation.

Key workarounds that make this work:

  • use_duck_shape = False during make_fx tracing prevents FX from aliasing n_atoms and max_edges_per_node symbolic dimensions when they happen to have the same concrete value in the tracing sample
  • scatter_add everywhere (never index_add_) avoids an inductor miscompilation bug with dynamic indices that produces silent numerical corruption (documented in UPET fx_compile.py)
  • NamedMemberAccessor instead of functional_call for parameter swapping during tracing

Benchmark Results

Benchmarks run via pet_bench/bench_compile.py in the pixi_envs repo, using the CUDA pixi environment:

# From pixi_envs/orgs/metatensor/metatrain/
pixi run -e cuda bench-compile            # both configs
pixi run -e cuda bench-compile-fixed      # fixed cutoff only
pixi run -e cuda bench-compile-adaptive   # adaptive cutoff only

Model: Default PET (2.9M params, 2 GNN layers, 8 heads, d_pet=128)
Dataset: qm9_reduced_100 (100 structures, batch_size=8, energy+forces+stress)

CUDA (RTX 4070 Ti SUPER, PyTorch 2.10.0+cu128)

Fixed Cutoff

Compilation Overhead
Phase Time
FX trace (make_fx) 6.4 s
First call (Triton codegen) 3.4 s
Total 9.8 s
Step Timing (50 steps after warmup)
Metric Eager Compiled Change
Median 24.9 ms 16.3 ms -35%
Mean 25.1 ms 15.2 ms -39%
Min / Max 24.4 / 26.9 ms 11.9 / 17.4 ms
Summary
Speedup 1.53x
Compile overhead 9.8 s (one-time)
Break-even ~1138 steps (~95 epochs)

Adaptive Cutoff (k=16)

With num_neighbors_adaptive=16, each atom keeps only its 16 nearest neighbors. The NEF second dimension (max_edges_per_node) varies per batch, exercising dynamic=True.

Compilation Overhead
Phase Time
FX trace (make_fx) 5.2 s
First call (Triton codegen) 1.9 s
Total 7.1 s
Step Timing (50 steps after warmup)
Metric Eager Compiled Change
Median 26.1 ms 16.4 ms -37%
Mean 26.0 ms 16.4 ms -37%
Min / Max 21.0 / 30.1 ms 16.2 / 17.5 ms
Summary
Speedup 1.59x
Compile overhead 7.1 s (one-time)
Break-even ~729 steps (~61 epochs)

Fixed vs Adaptive Comparison

Metric Fixed Adaptive (k=16)
Speedup 1.53x 1.59x
Compile overhead 9.8 s 7.1 s
Break-even (epochs) 95 61
Compiled step variance 2.0 ms std 0.3 ms std

Adaptive cutoff compiles faster and gives higher speedup with more stable step times.

CPU (PyTorch 2.9.1)

Compilation Overhead
Phase Time
FX trace (make_fx) 5.5 s
First call (C++ codegen) 16.1 s
Total 21.6 s
Step Timing (50 steps after warmup)
Metric Eager Compiled Change
Median 49.5 ms 40.0 ms -19%
Mean 50.5 ms 42.1 ms -17%
Min / Max 31.9 / 66.8 ms 25.4 / 62.5 ms
Summary
Speedup 1.24x
Compile overhead 21.6 s (one-time)
Break-even ~2274 steps (~190 epochs)

Contributor checklist

  • Tests updated (for new features and bugfixes)?
  • Documentation updated (for new features)?
  • Issue referenced (for PRs that solve an issue)?

Maintainer/Reviewer checklist

  • CHANGELOG updated with public API or any other important changes?
  • GPU tests passed (maintainer comment: "cscs-ci run")?

Comment thread src/metatrain/pet/model.py Outdated
Comment thread src/metatrain/pet/model.py Outdated
@HaoZeke
HaoZeke force-pushed the petPartialCompile branch 4 times, most recently from 355110f to b810276 Compare February 16, 2026 21:25
@HaoZeke

HaoZeke commented Feb 16, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke
HaoZeke marked this pull request as ready for review February 16, 2026 21:45
@HaoZeke
HaoZeke requested a review from abmazitov as a code owner February 16, 2026 21:45
@HaoZeke

HaoZeke commented Feb 16, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke
HaoZeke requested a review from Luthaf February 16, 2026 21:49
@HaoZeke HaoZeke changed the title feat(torch.compile): partial PET application feat(torch.compile): PET FX compilation Feb 16, 2026
@HaoZeke

HaoZeke commented Feb 17, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke
HaoZeke force-pushed the petPartialCompile branch 2 times, most recently from 654394e to a209073 Compare February 23, 2026 14:05
@HaoZeke

HaoZeke commented Feb 23, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke

HaoZeke commented Feb 27, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke

HaoZeke commented Feb 27, 2026

Copy link
Copy Markdown
Member Author

cc @JonathanSchmidt1, any stress/beta testing would be amazing

@HaoZeke
HaoZeke force-pushed the petPartialCompile branch from ddfb374 to bd14821 Compare March 2, 2026 01:00
@HaoZeke

HaoZeke commented Mar 2, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@HaoZeke
HaoZeke force-pushed the petPartialCompile branch from c519724 to 2d3d773 Compare March 2, 2026 02:31
@HaoZeke

HaoZeke commented Mar 2, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

@JonathanSchmidt1

Copy link
Copy Markdown
Contributor

cc @JonathanSchmidt1, any stress/beta testing would be amazing

I have been using the branch and it seems fine so far (but only on a new ML task so no comparison to the previous version yet).

JonathanSchmidt1 added a commit to JonathanSchmidt1/metatrain that referenced this pull request Mar 6, 2026
- Integrate full-graph FX compilation (compile branch PR metatensor#1029):
  _wrap_compiled_output, systems_to_batch, compile_pet_model,
  training.compile hyper, ._orig_mod stripping for best model
- Resolve trainer.py: compiled forward path sits inside our
  existing BF16 autocast / step-level logging / timing structure
- Resolve checkpoints: both max_atoms_per_batch and compile=False
  added to trainer_update_v12_v13
- Resolve llpr/checkpoints: upstream v3→v4 (inv_covariance→cholesky)
  as v3_v4, our edge_embedder→edge_linear migration promoted to
  v4_v5; bump LLPR model __checkpoint_version__ to 5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@JonathanSchmidt1

JonathanSchmidt1 commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

@HaoZeke Have you experimented with making it work with DDP?
edit: I played a bit with compiled ddp in https://github.com/JonathanSchmidt1/metatrain/tree/max-atom-sampler
First tests seem to work though I had to set the triton/inductor cache to be in scratch instead of /tmp

@HaoZeke

HaoZeke commented Mar 11, 2026

Copy link
Copy Markdown
Member Author

@HaoZeke Have you experimented with making it work with DDP? edit: I played a bit with compiled ddp in https://github.com/JonathanSchmidt1/metatrain/tree/max-atom-sampler First tests seem to work though I had to set the triton/inductor cache to be in scratch instead of /tmp

very cool, I haven't checked it out yet myself, but if you're building off of this maybe we should push to have this in main a bit faster

@HaoZeke
HaoZeke force-pushed the petPartialCompile branch from 2d3d773 to 9b55a7d Compare March 11, 2026 13:06
@HaoZeke
HaoZeke force-pushed the petPartialCompile branch 8 times, most recently from a0292de to 1974484 Compare March 15, 2026 06:14
HaoZeke added 11 commits March 16, 2026 19:09
- systems_to_batch now returns neighbor_atom_indices, mapping each
  edge slot to its neighbor atom index (used for force scattering
  in the compiled path).
- New _forward_from_batch method on PET: pure-tensor input/output
  forward pass that bypasses metatensor TensorMap construction,
  making the model traceable by make_fx / torch.compile.

Entire-Checkpoint: e96ffeb8691a
Trace the entire PET forward pass (including force/stress computation
via autograd.grad) into a single FX graph using make_fx, then compile
with torch.compile(dynamic=True, fullgraph=True).

Benefits:
- Maximum kernel fusion (Triton on GPU, C++ on CPU)
- Zero compiled/eager boundary crossings
- Always uses scaled_dot_product_attention (SDPA)
- Single backward pass (no double backward)
- ~1.65x speedup on RTX 4070 Ti SUPER (24.9ms -> 15.1ms/step)

New module: modules/compile.py with compile_pet_model() entry point.
New trainer option: training.compile (default false).
Fixes device mismatch in _wrap_compiled_output for GPU training.

Entire-Checkpoint: e96ffeb8691a
- CartesianTransformer compile tests (SDPA, manual attention, backward,
  non-first layer, PostLN variants)
- Numerical equivalence test: forward vs _forward_from_batch
- TestTrainingCompile: standard training tests with compile=True
Entire-Checkpoint: e96ffeb8691a
Add test_forward_from_batch_adaptive verifying _forward_from_batch
matches forward() with num_neighbors_adaptive=16, and
TestTrainingCompileAdaptive running the standard training test suite
with both compile=True and adaptive cutoffs enabled.

Adaptive cutoffs cause max_edges_per_node (the 2nd dimension of NEF
tensors) to vary per batch, which exercises dynamic=True more
aggressively than fixed cutoffs and validates that the symbolic
tracing handles this dimension correctly.

Entire-Checkpoint: e4127d1674a5
Two tests verifying that FX-compiled backward produces identical
parameter gradients as the eager path:

- test_compiled_vs_eager_backward: energy-only, atol=1e-5
- test_compiled_vs_eager_backward_with_forces: energy+forces with
  the eager path replicating compile.py force logic using
  create_graph=True, atol=1e-4 for the different backward paths

Both tests create two identical PET models from the same seed,
run forward+backward on the same batch, and compare every
parameter gradient tensor.
Slice read_targets to match the 5-system subset, fixing the [5, 100]
sample count mismatch in Dataset.from_dict.

Add use_manual_attention kwarg to _forward_from_batch (defaults False,
so compiled path unchanged) for eager callers needing create_graph=True.

Skip test_compiled_vs_eager_backward_with_forces on CPU since
torch.compile AOT autograd requires SDPA double backward (CUDA only).
Force/stress training through the FX-compiled graph requires second
derivatives of backward ops. PyTorch 2.9's fused RMSNorm and SDPA
backward kernels lack second derivative implementations.

Add DecomposedRMSNorm (primitive ops only) alongside the existing
DecomposedSiLU. Route compiled force/stress paths through manual
attention instead of SDPA. Both decompositions use only primitive ops
whose second derivatives are always available.

Also suppress inductor-internal UserWarnings (TF32 suggestion, online
softmax split) that pytest's filterwarnings=["error"] promotes to
failures.

Tested: 122 passed, 18 skipped on cosmolab (RTX 4070 Ti SUPER, PyTorch
2.9.1+cu128).
DDP registers post-accumulate-grad hooks on parameter tensors at
init time. These hooks fire whenever gradients accumulate, regardless
of whether model.forward() was called. The compiled path passes
raw_model.parameters() to the traced function; gradients flow back
to those same tensors and DDP syncs them automatically.

Changes:
- Remove the compile+distributed disable guard in trainer
- Compile on raw_model (model.module when DDP-wrapped)
- Use raw_model.parameters() in compiled forward calls
- Require max_atoms_per_batch for compile+distributed (prevents
  recompilation storms from variable batch shapes across ranks)
- Add MaxAtomBatchSampler and MaxAtomDistributedBatchSampler
  (greedy bin-packing, CSR storage for fork-safe DataLoader)
- Add get_num_atoms() to DiskDataset and MemmapDataset
- Introduce raw_model helper replacing model.module patterns
- Warn about TORCHINDUCTOR_CACHE_DIR for HPC clusters
Two new tests:
- test_compiled_vs_eager_training_weights: trains 3 steps with
  both eager and compiled paths, verifies weights agree within
  atol=5e-3 (cross-path float divergence from operator decomposition)
- test_compiled_training_deterministic: same compiled path run
  twice with thread pinning, verifies weights agree within 1e-6
  (Triton kernel reduction non-determinism floor)

Both pin torch.set_num_threads(1) per
rgoswami.me/snippets/pytorch-deterministic-regression/ to eliminate
scatter_add_ thread scheduling non-determinism. Per-step loss
agreement is also checked (< 1e-4).
…p, recompilation tests

New tests:
- test_compiled_ddp_vs_single_training_weights: 3-step DDP+compile
  vs single-GPU compile weight comparison
- test_compiled_ddp_checkpoint_roundtrip: save/load checkpoint from
  compiled training, verify output equivalence
- test_no_recompilation_on_same_batch_shape: verify torch._dynamo
  does not recompile for consistent batch shapes
ruff format on 4 files. Relax test_compiled_training_deterministic
tolerance from 1e-6 to 1e-3: CPU inductor (C++/OpenMP) has higher
float accumulation variance than GPU Triton kernels.
@HaoZeke
HaoZeke force-pushed the petPartialCompile branch from 1974484 to 764b34e Compare March 16, 2026 18:10
- Add strict=True to zip() calls (B905)
- Rename unused loop variables (B007)
- Add assert for max_atoms narrowing (mypy)
- Add type: ignore for None sentinel lists (mypy)
- Add param/return docstrings to new functions (pydoclint)
@HaoZeke

HaoZeke commented Mar 20, 2026

Copy link
Copy Markdown
Member Author

cscs-ci run

1 similar comment
@abmazitov

Copy link
Copy Markdown
Contributor

cscs-ci run

@sirmarcel

Copy link
Copy Markdown
Contributor

I've been looking into this a little bit with Claude, are you sure that the gradients are correctly flowing through the adaptive cutoff procedure?

@sirmarcel

Copy link
Copy Markdown
Contributor

Claude's take:

What's happening. systems_to_batch computes cutoff_factors from edge_distances (which derives from positions), then the compiled FX graph receives cutoff_factors as a plain tensor input. The graph's force computation is (dE_dR,) = autograd.grad(energy.sum(), edge_vectors, create_graph=False), which differentiates only w.r.t. edge_vectorscutoff_factors is held fixed. The hand-coded chain rule outside the graph (the center/neighbor scatter that maps dE/d(edge_vectors) to dE/d(positions)) likewise has no path through cutoff_factors. So the gradient contribution positions → edge_distances → cutoff_factors → energy is silently dropped.

Note that this is not a .detach(); nothing severs the chain. The chain simply never exists, because positions.requires_grad is False everywhere systems_to_batch is called from the compiled path (the requires_grad_(True) call at trainer.py:681 is on c_edge_vectors after systems_to_batch has already produced cutoff_factors). For comparison, the eager pet.forward(systems, ...) path works correctly because MetatomicCalculator sets positions.requires_grad_(True) before calling the model (see metatomic/torch/ase_calculator.py:520).

@JonathanSchmidt1

Copy link
Copy Markdown
Contributor

Claude's take:

What's happening. systems_to_batch computes cutoff_factors from edge_distances (which derives from positions), then the compiled FX graph receives cutoff_factors as a plain tensor input. The graph's force computation is (dE_dR,) = autograd.grad(energy.sum(), edge_vectors, create_graph=False), which differentiates only w.r.t. edge_vectorscutoff_factors is held fixed. The hand-coded chain rule outside the graph (the center/neighbor scatter that maps dE/d(edge_vectors) to dE/d(positions)) likewise has no path through cutoff_factors. So the gradient contribution positions → edge_distances → cutoff_factors → energy is silently dropped.

Note that this is not a .detach(); nothing severs the chain. The chain simply never exists, because positions.requires_grad is False everywhere systems_to_batch is called from the compiled path (the requires_grad_(True) call at trainer.py:681 is on c_edge_vectors after systems_to_batch has already produced cutoff_factors). For comparison, the eager pet.forward(systems, ...) path works correctly because MetatomicCalculator sets positions.requires_grad_(True) before calling the model (see metatomic/torch/ase_calculator.py:520).

Stupid question do we not have any tests for the forces where this should show up?

@sirmarcel

Copy link
Copy Markdown
Contributor

@JonathanSchmidt1 Claude
Why the regression tests miss it: test_forward_from_batch only checks per-atom energy match, and test_compiled_vs_eager_backward_with_forces builds its "eager" reference by replicating compile.py's force formula via _forward_from_batch with create_graph=True. Both sides drop the same gradient term, so they agree perfectly. There is currently no test of the form "bare pet.forward(systems, outputs) with positions.requires_grad_(True) — i.e. the path every existing checkpoint and UPETCalculator evaluation goes through — agrees with the compiled path on position gradients.

@sirmarcel

Copy link
Copy Markdown
Contributor

We caught this because I forced testing directly against the uPET calculator.

@JonathanSchmidt1

Copy link
Copy Markdown
Contributor

Thank you for the reply @sirmarcel . We definitely need some regression tests against fixed numbers ...

@abmazitov

abmazitov commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

I skimmed through the code quickly and there are a few things I don't understand as for now.

First, what's the logic behind having two separate forward functions? It looks to me like we have a plain forward which is unchanged, and a secondary _forward_from_batch which is supposed to be compiled, right? Shouldn't we call _forward_from_batch inside forward? Which one of those two should be exposed during model export then? If we export the non-scripted forward function, then does it mean we only torch.compile for training, not for inference?

When I thought about this problem, I had the following structure in my head:

class _PETBatchForward(torch.nn.Module):
    def __init__(self, hypers: ModelHypers, *args, **kwargs):
        ...
    def forward(self, *inputs: List[torch.Tensor]):
        # Implementation of all the GNN steps,
        # i.e. node features / edge_features calculation,
        # and finally - calculating the atomic_predictions
        results: Dict[str, Dict[str, torch.Tensor]] = {}
        # Filling in results
        return results

class PET:
    def __init__(self, hypers: ModelHypers, *args, **kwargs):
        self._core = _PETBatchForward(hypers, *args, **kwargs)
        ...
    def forward(self, systems: List[System], outputs: Dict[str, ModelOutput):
        inputs = systems_to_batch(systems, *args, **kwargs)
        results = self._core(inputs)
        return_dict = ... # convert results to TensorMaps
        return return_dict

So essentially we keep the same PET class as a part of the API, but internally run the torch-compiled core for pure-torch part of the evaluation. This looks quite natural and allows to, for example, get access to the pure-torch part of PET from the outside, which can be very useful for people not familiar with metatomic world and trying to first understand how the core works. Is there any particular reason behind a currently chosen design, and not what (as an example) I have suggested above?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants