Skip to content

Commit 78803c7

Browse files
drakeyuZihan Yu
andauthored
Fix kokkos ghost-row energy bug, sync KOKKOS source tree, add GPU CI job (#815)
pair_matgl/kk (and pair_matgl) folded periodic neighbors onto duplicate ghost rows with unit_shifts always zero, which TensorNet's message-passing silently drops messages into since each physical atom needs exactly one consistent row. Fold every edge back onto its local row via atom->map() and recover an explicit integer unit_shifts instead, fixing a ~30-42 eV energy gap against the native matgl/ASE calculator. Also: - lammps/src/KOKKOS now matches the working build tree exactly (virial sign fix, LayoutRight fix for edge_index/unit_shifts views, correct GPU device selection, multi-rank guard) -- previously only the build repo's KOKKOS-MATGL/ dir had these fixes. - README implementation notes updated to describe the actual algorithm instead of the old (buggy) ghost-position description. - Added a build_pair_matgl_kokkos CI job mirroring the existing CPU job, for when a self-hosted CUDA runner is registered against this repo. Co-authored-by: Zihan Yu <drakeyu@login38.chn.perlmutter.nersc.gov>
1 parent bc225e0 commit 78803c7

6 files changed

Lines changed: 518 additions & 41 deletions

File tree

.github/workflows/lammps-build.yml

Lines changed: 285 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ name: LAMMPS pair_matgl build
99
# * gcc/g++/cmake/python3.
1010
# If any of those are missing the workflow falls back to downloading them.
1111
#
12-
# Kokkos / GPU variant is *not* exercised here — GitHub-hosted runners have
13-
# no GPU. Phase-3 hardware testing is delegated to a self-hosted CUDA
14-
# runner (TODO).
12+
# A second job, build_pair_matgl_kokkos, exercises the Kokkos/GPU variant
13+
# (pair_style matgl/kk). It needs a self-hosted runner labeled
14+
# [self-hosted, gpu, cuda] with an NVIDIA driver + nvcc on PATH — there is
15+
# no such runner registered against this repo today, so the job will queue
16+
# forever until one exists. Verified manually on a single-GPU NERSC node;
17+
# not yet exercised by this workflow.
1518

1619
on:
1720
push:
@@ -318,3 +321,282 @@ jobs:
318321
lammps_build/CMakeFiles/CMakeOutput.log
319322
lammps_build/CMakeFiles/CMakeError.log
320323
if-no-files-found: ignore
324+
325+
build_pair_matgl_kokkos:
326+
name: pair_matgl/kk (GPU, Kokkos)
327+
# Requires a self-hosted runner with an NVIDIA GPU, driver, and nvcc on
328+
# PATH, labeled to match below. No such runner is registered against
329+
# this repo yet, so this job will queue indefinitely until one exists.
330+
# `timeout-minutes` bounds that queue time instead of hanging forever.
331+
runs-on: [self-hosted, gpu, cuda]
332+
timeout-minutes: 60
333+
334+
env:
335+
LIBTORCH_VERSION: "2.5.1"
336+
# libtorch CUDA wheel tag (e.g. cu118, cu121, cu124) — must match the
337+
# CUDA toolkit installed on the runner. Override via repo/org variable
338+
# CUDA_TAG if the runner ships a different CUDA version.
339+
CUDA_TAG: ${{ vars.CUDA_TAG || 'cu121' }}
340+
# Kokkos GPU architecture flag (see Kokkos_ARCH_* options) — must match
341+
# the runner's GPU. Override via repo/org variable KOKKOS_ARCH.
342+
KOKKOS_ARCH: ${{ vars.KOKKOS_ARCH || 'AMPERE80' }}
343+
344+
steps:
345+
- name: Checkout matgl
346+
uses: actions/checkout@v4
347+
with:
348+
path: matgl
349+
350+
- name: Probe runner for CUDA + LAMMPS source
351+
id: probe
352+
shell: bash
353+
run: |
354+
set -e
355+
if ! command -v nvidia-smi >/dev/null 2>&1; then
356+
echo "No NVIDIA driver found on this runner (nvidia-smi missing)" >&2
357+
exit 1
358+
fi
359+
nvidia-smi
360+
for cand in /opt/lammps /usr/local/src/lammps /lammps "$HOME/lammps"; do
361+
if [ -f "$cand/cmake/CMakeLists.txt" ]; then
362+
echo "lammps_src=$cand" >> "$GITHUB_OUTPUT"
363+
echo "Found LAMMPS source at: $cand"
364+
break
365+
fi
366+
done
367+
for cand in /opt/libtorch /usr/local/libtorch /libtorch "$HOME/libtorch"; do
368+
if [ -f "$cand/share/cmake/Torch/TorchConfig.cmake" ]; then
369+
echo "libtorch=$cand" >> "$GITHUB_OUTPUT"
370+
echo "Found libtorch at: $cand"
371+
break
372+
fi
373+
done
374+
375+
- name: Cache CUDA libtorch (fallback)
376+
if: steps.probe.outputs.libtorch == ''
377+
id: cache-libtorch
378+
uses: actions/cache@v4
379+
with:
380+
path: libtorch
381+
key: libtorch-${{ env.LIBTORCH_VERSION }}-${{ env.CUDA_TAG }}-cxx11abi
382+
383+
- name: Download CUDA libtorch (fallback)
384+
if: steps.probe.outputs.libtorch == '' && steps.cache-libtorch.outputs.cache-hit != 'true'
385+
shell: bash
386+
run: |
387+
curl -L -o libtorch.zip \
388+
"https://download.pytorch.org/libtorch/${CUDA_TAG}/libtorch-cxx11-abi-shared-with-deps-${LIBTORCH_VERSION}%2B${CUDA_TAG}.zip"
389+
unzip -q libtorch.zip
390+
rm libtorch.zip
391+
392+
- name: Resolve libtorch path
393+
id: libtorch
394+
shell: bash
395+
run: |
396+
if [ -n "${{ steps.probe.outputs.libtorch }}" ]; then
397+
echo "path=${{ steps.probe.outputs.libtorch }}" >> "$GITHUB_OUTPUT"
398+
else
399+
echo "path=${GITHUB_WORKSPACE}/libtorch" >> "$GITHUB_OUTPUT"
400+
fi
401+
402+
- name: Clone LAMMPS (fallback)
403+
# Vanilla LAMMPS source ships lib/kokkos/ (incl. bin/nvcc_wrapper)
404+
# in-tree — no separate Kokkos checkout needed.
405+
if: steps.probe.outputs.lammps_src == ''
406+
shell: bash
407+
run: |
408+
git clone --depth 1 --branch develop \
409+
https://github.com/lammps/lammps.git lammps_src
410+
411+
- name: Resolve LAMMPS source path
412+
id: lammps
413+
shell: bash
414+
run: |
415+
if [ -n "${{ steps.probe.outputs.lammps_src }}" ]; then
416+
echo "path=${{ steps.probe.outputs.lammps_src }}" >> "$GITHUB_OUTPUT"
417+
else
418+
echo "path=${GITHUB_WORKSPACE}/lammps_src" >> "$GITHUB_OUTPUT"
419+
fi
420+
421+
- name: Install Python deps (uv) for parity reference
422+
shell: bash
423+
run: |
424+
curl -LsSf https://astral.sh/uv/install.sh | sh
425+
export PATH="$HOME/.local/bin:$PATH"
426+
cd matgl
427+
uv venv --python 3.12
428+
uv pip install -e .
429+
uv pip install pytest
430+
431+
- name: Export tiny LAMMPS-loadable model + Python reference
432+
# Same fixture as the CPU job: a 4-atom Mo-S cell, tiny untrained
433+
# TensorNet. Export runs on CPU — no GPU needed to build the
434+
# TorchScript artifact, only to evaluate it inside LAMMPS later.
435+
shell: bash
436+
run: |
437+
export PATH="$HOME/.local/bin:$PATH"
438+
cd matgl
439+
mkdir -p ../lammps_artifacts
440+
uv run python - <<'PY'
441+
import json
442+
import numpy as np
443+
import torch
444+
from pymatgen.core import Lattice, Structure
445+
from pymatgen.optimization.neighbors import find_points_in_spheres
446+
from matgl.apps._pes_pyg import Potential
447+
from matgl.ext._lammps import LAMMPSMatGLModel
448+
from matgl.models._tensornet_pyg import TensorNet
449+
450+
torch.manual_seed(0)
451+
m = TensorNet(
452+
element_types=("Mo", "S"),
453+
is_intensive=False,
454+
units=16, nblocks=1, num_rbf=8,
455+
cutoff=4.0, use_warp=False, rbf_type="Gaussian",
456+
)
457+
p = Potential(model=m, calc_forces=True, calc_stresses=True)
458+
p.eval()
459+
w = LAMMPSMatGLModel(potential=p, dtype=torch.float32)
460+
w.eval()
461+
torch.jit.script(w).save("../lammps_artifacts/model.pt")
462+
463+
struct = Structure(
464+
Lattice.cubic(4.5),
465+
["Mo", "S", "Mo", "S"],
466+
[[0, 0, 0], [0.5, 0.5, 0.5], [0.5, 0, 0.25], [0, 0.5, 0.75]],
467+
)
468+
src, dst, images, dist = find_points_in_spheres(
469+
struct.cart_coords, struct.cart_coords, r=4.0,
470+
pbc=np.array([1, 1, 1], dtype=np.int64),
471+
lattice=np.array(struct.lattice.matrix), tol=1e-8,
472+
)
473+
keep = (src != dst) | (dist > 1e-8)
474+
src, dst, images = src[keep], dst[keep], images[keep]
475+
pos = torch.tensor(struct.cart_coords, dtype=torch.float32)
476+
eidx = torch.tensor(np.stack([src, dst]), dtype=torch.long)
477+
ushifts = torch.tensor(images, dtype=torch.long)
478+
cell = torch.tensor(np.array(struct.lattice.matrix), dtype=torch.float32)
479+
z = torch.tensor([s.specie.Z for s in struct], dtype=torch.long)
480+
local = torch.ones(len(struct), dtype=torch.bool)
481+
out = w(pos, eidx, ushifts, cell, z, local, True)
482+
ref = {
483+
"energy": float(out["total_energy_local"].item()),
484+
"forces": out["forces"].detach().tolist(),
485+
}
486+
with open("../lammps_artifacts/reference.json", "w") as fh:
487+
json.dump(ref, fh, indent=2)
488+
print(json.dumps(ref, indent=2))
489+
PY
490+
491+
- name: Drop ML-MATGL-KOKKOS sources into LAMMPS src/KOKKOS/
492+
# src/KOKKOS/ is a *standard* LAMMPS package directory, always
493+
# scanned for PairStyle(...) macros when PKG_KOKKOS=ON — unlike
494+
# ML-MATGL, it doesn't need RegisterStyles() wired into
495+
# CMakeLists.txt by hand (that's only needed on the hand-patched
496+
# NERSC checkout this package was developed against). Dropping the
497+
# .cpp/.h straight in mirrors the CPU job's src/-root trick.
498+
shell: bash
499+
env:
500+
LAMMPS_SRC: ${{ steps.lammps.outputs.path }}
501+
run: |
502+
cp matgl/lammps/src/ML-MATGL/pair_matgl.cpp "${LAMMPS_SRC}/src/"
503+
cp matgl/lammps/src/ML-MATGL/pair_matgl.h "${LAMMPS_SRC}/src/"
504+
cp matgl/lammps/src/KOKKOS/pair_matgl_kokkos.cpp "${LAMMPS_SRC}/src/KOKKOS/"
505+
cp matgl/lammps/src/KOKKOS/pair_matgl_kokkos.h "${LAMMPS_SRC}/src/KOKKOS/"
506+
if ! grep -q ML-MATGL-KOKKOS-CI.cmake "${LAMMPS_SRC}/cmake/CMakeLists.txt"; then
507+
echo "include(${GITHUB_WORKSPACE}/matgl/lammps/cmake/ML-MATGL-KOKKOS-CI.cmake)" \
508+
>> "${LAMMPS_SRC}/cmake/CMakeLists.txt"
509+
fi
510+
# CI-only cmake fragment: just link Torch. The base pair_matgl.cpp
511+
# in src/ also needs Torch linked (same as the CPU job); the
512+
# Kokkos sources in src/KOKKOS/ are picked up automatically by
513+
# LAMMPS' own KOKKOS package glob once PKG_KOKKOS=ON.
514+
cat > matgl/lammps/cmake/ML-MATGL-KOKKOS-CI.cmake <<'CM'
515+
find_package(Torch REQUIRED)
516+
target_compile_features(lammps PRIVATE cxx_std_17)
517+
target_link_libraries(lammps PRIVATE ${TORCH_LIBRARIES})
518+
if(DEFINED TORCH_CXX_FLAGS)
519+
set_property(TARGET lammps APPEND_STRING
520+
PROPERTY COMPILE_FLAGS " ${TORCH_CXX_FLAGS}")
521+
endif()
522+
message(STATUS "ML-MATGL-KOKKOS (CI): linked against TORCH_LIBRARIES=${TORCH_LIBRARIES}")
523+
CM
524+
525+
- name: Configure LAMMPS (Kokkos/CUDA)
526+
shell: bash
527+
env:
528+
LAMMPS_SRC: ${{ steps.lammps.outputs.path }}
529+
LIBTORCH: ${{ steps.libtorch.outputs.path }}
530+
run: |
531+
BUILD_DIR="${GITHUB_WORKSPACE}/lammps_build"
532+
rm -rf "${BUILD_DIR}"
533+
cmake -B "${BUILD_DIR}" -S "${LAMMPS_SRC}/cmake" \
534+
-G Ninja \
535+
-D PKG_KOKKOS=ON \
536+
-D Kokkos_ENABLE_CUDA=ON \
537+
-D Kokkos_ENABLE_CUDA_LAMBDA=ON \
538+
-D Kokkos_ARCH_${KOKKOS_ARCH}=ON \
539+
-D CMAKE_CXX_COMPILER="${LAMMPS_SRC}/lib/kokkos/bin/nvcc_wrapper" \
540+
-D CMAKE_PREFIX_PATH="${LIBTORCH}" \
541+
-D MKL_INCLUDE_DIR=/usr/include \
542+
-D CMAKE_BUILD_TYPE=Release \
543+
-D BUILD_MPI=OFF
544+
545+
- name: Build LAMMPS
546+
shell: bash
547+
run: |
548+
cmake --build "${GITHUB_WORKSPACE}/lammps_build" -j 2
549+
550+
- name: Run pair_matgl/kk single-point deck on GPU
551+
# Same in.matgl_si deck as the CPU job, unmodified — `-sf kk`
552+
# dispatches `pair_style matgl` to `matgl/kk` automatically.
553+
shell: bash
554+
env:
555+
LIBTORCH: ${{ steps.libtorch.outputs.path }}
556+
run: |
557+
export LD_LIBRARY_PATH="${LIBTORCH}/lib:${LD_LIBRARY_PATH:-}"
558+
cp lammps_artifacts/model.pt matgl/lammps/tests/model.pt
559+
cd matgl/lammps/tests
560+
"${GITHUB_WORKSPACE}/lammps_build/lmp" -k on g 1 -sf kk -in in.matgl_si | tee log.lammps
561+
562+
- name: Diff LAMMPS energy against Python reference
563+
shell: bash
564+
run: |
565+
export PATH="$HOME/.local/bin:$PATH"
566+
cd matgl
567+
uv run python - <<'PY'
568+
import json
569+
import sys
570+
571+
ref = json.load(open("../lammps_artifacts/reference.json"))
572+
log = open("lammps/tests/log.lammps").read()
573+
# thermo_style for the test deck is "step pe fx fy fz pxx pyy pzz".
574+
# With ``run 0`` LAMMPS prints one numeric row; pull its second column.
575+
pe = None
576+
for line in log.splitlines():
577+
cols = line.split()
578+
if len(cols) >= 2 and cols[0].lstrip("-").isdigit():
579+
try:
580+
pe = float(cols[1])
581+
except ValueError:
582+
continue
583+
assert pe is not None, "Could not find PotEng row in log.lammps"
584+
ref_e = ref["energy"]
585+
diff = abs(pe - ref_e)
586+
print(f"LAMMPS PotEng = {pe!r}, Python ref = {ref_e!r}, diff = {diff:.3e}")
587+
if diff > 1e-3:
588+
print("ENERGY MISMATCH!", file=sys.stderr)
589+
sys.exit(1)
590+
PY
591+
592+
- name: Upload artifacts on failure
593+
if: failure()
594+
uses: actions/upload-artifact@v4
595+
with:
596+
name: lammps-kokkos-debug
597+
path: |
598+
matgl/lammps/tests/log.lammps
599+
lammps_artifacts/
600+
lammps_build/CMakeFiles/CMakeOutput.log
601+
lammps_build/CMakeFiles/CMakeError.log
602+
if-no-files-found: ignore

lammps/README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,23 @@ stresses (when nonzero) within `1e-3 GPa`.
183183
- The pair style requests a **full neighbor list with ghost atoms**
184184
(`REQ_FULL | REQ_GHOST`). The model expects edge indices that span both
185185
owned and ghost atoms.
186-
- Bond vectors are computed from LAMMPS' already-imaged ghost positions,
187-
so `unit_shifts` is always zero — the strain-based stress trick still
188-
works because the strain is applied to all atomic positions (owned and
189-
ghost) on the model side.
186+
- Every edge is folded back onto the *local* row of the atom it represents
187+
(via `atom->map(atom->tag[j])`) rather than pointing at the ghost row
188+
directly. TensorNet's message-passing layers need one consistent row
189+
per physical atom — a ghost row never propagates outgoing messages back
190+
to the atom it duplicates. The periodic image is recovered explicitly as
191+
an integer `unit_shifts` (the ghost/local position difference,
192+
transformed through the box's inverse deformation matrix and rounded to
193+
the nearest integer), rather than relying on LAMMPS' already-imaged
194+
ghost positions with `unit_shifts = 0`. Single-rank only: ghost atoms
195+
can be owned by a different MPI rank, so there is no local row to fold
196+
onto in a multi-rank run.
190197
- Forces are accumulated for **all** atoms (owned + ghost). LAMMPS' usual
191198
`comm->reverse_comm` step then sums ghost contributions back to the
192199
rank that owns each atom. This requires `newton on`.
193-
- Virials are written into the global `virial[6]` array directly. We set
200+
- Virials are written into the global `virial[6]` array directly as
201+
`virial -= va` (the model returns `virials = dE/dstrain = -W`, while
202+
LAMMPS' convention is `W = sum_i r_i ⊗ f_i`). We set
194203
`no_virial_fdotr_compute = 1` in the constructor so LAMMPS doesn't
195204
recompute the virial from forces.
196205

0 commit comments

Comments
 (0)