Summary
DPA4/SeZM .pt2 models exhibit a 3–6× throughput degradation when called from the C++ LAMMPS interface (DeepPotPTExpt::compute) compared to the Python interface (DeepEval._eval_model / ASE DP calculator), despite both paths executing the same AOTInductor-compiled graph from the same .pt2 archive.
At 65,536 atoms on an A800 GPU:
- Python (ASE): ~56 ms/step → 1.17M atom·step/s
- C++ (LAMMPS): ~507 ms/step → 130K atom·step/s (Pair component: ~350 ms)
- Gap: ~6.2× at large atom counts
For comparison, DPA1 (se_atten_v2, 0.52M params, .pth TorchScript) runs at ~128K atom·step/s in LAMMPS — meaning DPA4 with 115× fewer parameters (4,490 params) achieves essentially the same LAMMPS speed as DPA1, despite being 25× faster in Python/ASE.
Reproduction
Environment:
- deepmd-kit:
0.1.dev76+g9e08fcb0f (outisli branch)
- PyTorch: 2.7+ with CUDA 12.8
- LAMMPS: 22 Jul 2025 Update 2
- GPU: NVIDIA A800-SXM4-80GB
Model: DPA4 SeZM, n_blocks=1, channels=8, sel=181, lmax=2 (4,490 parameters total)
Benchmark protocol: Fe BCC, 20 warmup + 100 production NVE steps, atom counts from 250 to 101,306.
LAMMPS timing breakdown (from timer output, 100 production steps):
| n_atoms |
Loop (s) |
Pair (s) |
Neigh (s) |
Pair−Neigh (s) |
ASE (ms/step) |
C++ model / Python model |
| 250 |
0.27 |
0.19 |
0.08 |
0.11 |
3.5 |
0.3× |
| 2,000 |
1.15 |
0.61 |
0.52 |
0.09 |
4.9 |
0.2× |
| 9,826 |
5.75 |
3.42 |
2.27 |
1.15 |
11.1 |
1.0× |
| 65,536 |
50.71 |
35.05 |
15.31 |
19.74 |
56.2 |
3.5× |
| 101,306 |
77.88 |
54.73 |
22.66 |
32.07 |
88.4 |
3.6× |
The Pair−Neigh column isolates the model inference + data marshalling time inside pair_deepmd. At small atom counts, C++ is actually faster than Python (JIT warmup amortized). At large atom counts, C++ is 3.5× slower — the gap grows with system size.
Key comparison with DPA1:
| n_atoms=65,536 |
Params |
LAMMPS Pair−Neigh (ms/step) |
DPA1 .pth (TorchScript) |
515,844 |
197 |
DPA4 .pt2 (AOTInductor) |
4,490 |
197 |
The two models have identical C++ inference time despite 115× parameter difference. The DPA4 .pt2 AOTI graph is not delivering the expected speedup over TorchScript in the C++ call path.
Root cause analysis
We traced the full call chain in both paths:
Python path (DeepEval._eval_model, deep_eval.py):
numpy → torch.tensor(device=cuda) # direct to GPU
→ VesinNeighborList.build() # GPU O(N) cell list (vesin.torch)
→ edge_schema_from_extended() # GPU tensor ops
→ self._pt2_runner(*model_inputs) # AOTI graph (all inputs already on GPU)
→ .detach().cpu().numpy() # single D2H at end
C++ path (DeepPotPTExpt::compute, DeepPotPTExpt.cc):
std::vector<double> coord → from_blob().clone().to(device) # H2D #1
std::vector<int64_t> atype → from_blob().clone().to(device) # H2D #2
LAMMPS InputNlist → nlist_data.copy_from_nlist() # CPU copy
→ shuffle_exclude_empty() # CPU remap
→ mapping rebuild (O(nall) loop) # CPU
→ from_blob().clone().to(device) # H2D #3
→ createEdgeTensors() # CPU O(N×nnei) ← BOTTLENECK
→ 4× from_blob().clone().to(device) # H2D #4-7
loader->run(inputs) # AOTI graph (same as Python)
output["energy"].to(kCPU) # D2H #1 (implicit sync)
output["extended_force"].to(kCPU) # D2H #2
output["virial"].to(kCPU) # D2H #3
select_map() # CPU remap
Three identified bottlenecks:
1. createEdgeTensors() runs entirely on CPU (~60-80ms at 65K atoms)
At 65,536 atoms × ~120 neighbors = ~7.8M edges, commonPT.h:183-330 iterates all neighbor pairs in C++ loops to compute edge indices and vectors, then copies to GPU with 4 separate .clone().to(device) calls.
In contrast, the Python path uses vesin.torch which builds the neighbor/edge list directly on GPU with O(N) cell-list algorithm.
2. Multiple CPU↔GPU synchronization points (~30-50ms)
The C++ path has ~7 .clone().to(device) H2D transfers and ~3-5 .to(torch::kCPU) D2H transfers, each causing an implicit cudaStreamSynchronize. The Python path keeps all data GPU-resident with a single D2H at the end.
3. CPU-side data marshalling (~10-20ms)
select_real_atoms_coord, std::vector<double> type conversions, nlist_data.shuffle_exclude_empty(), mapping rebuild loop, and select_map for output remapping — all serial CPU work.
NOT contributing factors:
- CUDA Graphs: Disabled in both paths (
triton.cudagraphs: False in compile options)
- Different compiled kernels: Same
.pt2 archive, same AOTI compiled graph
DP_TRITON_INFER: Baked at trace time, not a runtime difference
- Model architecture: Both paths call the same
forward_common_lower_exportable
Proposed optimizations
-
GPU neighbor list / edge construction for C++: Use vesin C++ API with CUDA backend (or a custom CUDA kernel) to replace the CPU createEdgeTensors(). This would eliminate the dominant ~60-80ms bottleneck.
-
Pinned memory + async transfers: Use CUDA pinned memory for from_blob and cudaMemcpyAsync to overlap H2D transfers with GPU computation.
-
Batch output D2H: Instead of 3-5 separate .to(kCPU) calls (each triggering sync), concatenate outputs into a single contiguous GPU tensor and do one D2H transfer.
-
CUDA Graphs for inference: The .pt2 inference path has no autograd (the triton.cudagraphs: False rationale about stale autograd metadata doesn't apply). Wrapping loader->run() in a CUDA graph capture would eliminate per-kernel launch overhead.
-
Cache GPU tensors across steps: When ago > 0 (neighbor list unchanged), avoid re-creating coord/atype tensors from scratch — update them in-place on GPU.
Impact
This performance gap means DPA4's architectural efficiency advantage (compact SO(3)-equivariant representation) is completely invisible in LAMMPS production MD simulations. A DPA4 model with 4,490 params runs at the same speed as a DPA1 model with 515,844 params, making the model compression meaningless for the most common deployment scenario.
Related to #5574
Summary
DPA4/SeZM
.pt2models exhibit a 3–6× throughput degradation when called from the C++ LAMMPS interface (DeepPotPTExpt::compute) compared to the Python interface (DeepEval._eval_model/ ASEDPcalculator), despite both paths executing the same AOTInductor-compiled graph from the same.pt2archive.At 65,536 atoms on an A800 GPU:
For comparison, DPA1 (se_atten_v2, 0.52M params,
.pthTorchScript) runs at ~128K atom·step/s in LAMMPS — meaning DPA4 with 115× fewer parameters (4,490 params) achieves essentially the same LAMMPS speed as DPA1, despite being 25× faster in Python/ASE.Reproduction
Environment:
0.1.dev76+g9e08fcb0f(outisli branch)Model: DPA4 SeZM,
n_blocks=1, channels=8, sel=181, lmax=2(4,490 parameters total)Benchmark protocol: Fe BCC, 20 warmup + 100 production NVE steps, atom counts from 250 to 101,306.
LAMMPS timing breakdown (from
timeroutput, 100 production steps):The
Pair−Neighcolumn isolates the model inference + data marshalling time insidepair_deepmd. At small atom counts, C++ is actually faster than Python (JIT warmup amortized). At large atom counts, C++ is 3.5× slower — the gap grows with system size.Key comparison with DPA1:
.pth(TorchScript).pt2(AOTInductor)The two models have identical C++ inference time despite 115× parameter difference. The DPA4 .pt2 AOTI graph is not delivering the expected speedup over TorchScript in the C++ call path.
Root cause analysis
We traced the full call chain in both paths:
Python path (
DeepEval._eval_model,deep_eval.py):C++ path (
DeepPotPTExpt::compute,DeepPotPTExpt.cc):Three identified bottlenecks:
1.
createEdgeTensors()runs entirely on CPU (~60-80ms at 65K atoms)At 65,536 atoms × ~120 neighbors = ~7.8M edges,
commonPT.h:183-330iterates all neighbor pairs in C++ loops to compute edge indices and vectors, then copies to GPU with 4 separate.clone().to(device)calls.In contrast, the Python path uses
vesin.torchwhich builds the neighbor/edge list directly on GPU with O(N) cell-list algorithm.2. Multiple CPU↔GPU synchronization points (~30-50ms)
The C++ path has ~7
.clone().to(device)H2D transfers and ~3-5.to(torch::kCPU)D2H transfers, each causing an implicitcudaStreamSynchronize. The Python path keeps all data GPU-resident with a single D2H at the end.3. CPU-side data marshalling (~10-20ms)
select_real_atoms_coord,std::vector<double>type conversions,nlist_data.shuffle_exclude_empty(), mapping rebuild loop, andselect_mapfor output remapping — all serial CPU work.NOT contributing factors:
triton.cudagraphs: Falsein compile options).pt2archive, same AOTI compiled graphDP_TRITON_INFER: Baked at trace time, not a runtime differenceforward_common_lower_exportableProposed optimizations
GPU neighbor list / edge construction for C++: Use
vesinC++ API with CUDA backend (or a custom CUDA kernel) to replace the CPUcreateEdgeTensors(). This would eliminate the dominant ~60-80ms bottleneck.Pinned memory + async transfers: Use CUDA pinned memory for
from_blobandcudaMemcpyAsyncto overlap H2D transfers with GPU computation.Batch output D2H: Instead of 3-5 separate
.to(kCPU)calls (each triggering sync), concatenate outputs into a single contiguous GPU tensor and do one D2H transfer.CUDA Graphs for inference: The
.pt2inference path has no autograd (thetriton.cudagraphs: Falserationale about stale autograd metadata doesn't apply). Wrappingloader->run()in a CUDA graph capture would eliminate per-kernel launch overhead.Cache GPU tensors across steps: When
ago > 0(neighbor list unchanged), avoid re-creating coord/atype tensors from scratch — update them in-place on GPU.Impact
This performance gap means DPA4's architectural efficiency advantage (compact SO(3)-equivariant representation) is completely invisible in LAMMPS production MD simulations. A DPA4 model with 4,490 params runs at the same speed as a DPA1 model with 515,844 params, making the model compression meaningless for the most common deployment scenario.
Related to #5574