Skip to content

Native code export for LAMMPS and Python/ASE - #309

Merged
jameskermode merged 132 commits into
mainfrom
lammps-export
Oct 2, 2026
Merged

jameskermode merged 132 commits into
mainfrom
lammps-export

Conversation

@jameskermode

@jameskermode jameskermode commented Dec 9, 2025 •

Copy link
Copy Markdown
Collaborator

Summary

Infrastructure for exporting fitted ETACE potentials to standalone native libraries with
Julia 1.12's juliac --trim=safe, plus the consumers: a LAMMPS pair_style ace plugin and
a pip-installable Python/ASE package. Compiled libraries need no Julia at runtime.

Performance

The acceptance gate for this work was ≤ 1.2x pair_style pace recursive per core, in
exact :polynomial mode, on both reference models. Measured on the same core in the same
session:

Model pair_style ace (µs/site) vs pace recursive
Cantor (CrMnFeCoNi) 58.1 0.89x
TiAl (order 4) 94.0 0.51x

Both are faster than pace recursive outright, not merely inside the gate. The route there,
the rejected alternatives, and the measurement methodology are written up in
export/bench/FINDINGS_parity.md.

What's in it

Export pipeline

  • Export ETACE models to trim-compatible Julia, compiled to a shared library (~3 MB) with a
    bundled Julia runtime (~20 MB)
  • ace1_model models are converted via ETModels.convert2et() before export
  • Multi-species models, including the pair term, and per-atom virials

LAMMPS

  • pair_style ace plugin loading the compiled library directly
  • LAMMPS native neighbour lists; MPI domain decomposition and OpenMP threading

Python/ASE (ase-ace)

Calculator Backend Threading Startup Julia required
ACELibraryCalculator Compiled .so Single Instant No
ACEJuliaCalculator JuliaCall Multi 10-30s Managed by juliapkg

Julia and its five Julia packages are declared in exactly one file,
src/ase_ace/juliapkg.json, and installed on first use; no separate Julia install is needed.

Removed since this PR was opened

  • :hermite_spline radial export and the splinify-for-export path it was the only
    consumer of. It could not survive --trim, and splinify() leaves no recurrence to emit.
    The mode now raises rather than silently misbehaving.
  • The minimal-export path, which nothing used.
  • The socket backend (ACECalculator, ASE SocketIOCalculator + IPICalculator.jl over
    i-PI). Its documented reason to exist was multi-threaded Julia, but ACEJuliaCalculator
    threads identically — both reach the same Folds.sum(..., ThreadedEx()) inside
    ACEpotentials — and the two agreed to 8e-9 relative on the same cell and model before it
    was dropped. It had also never worked on any host whose localhost resolves to IPv6,
    since ASE's SocketServer binds AF_INET only.
  • ACEregistry from CI and the docs: everything resolves from General now, and the
    registry's stale entries break resolution on Julia 1.13+.

Test plan

  • Exported energies, forces and virials match Julia to the gates in export/test/
  • Forces checked against a finite difference; stress against -dE/dV
  • LAMMPS plugin builds and runs, serial and under MPI
  • Parity gate met on both reference models (above)
  • ase-ace: packaging (wheel + sdist, installed into a throwaway venv), the JuliaCall
    calculator, the library calculator, and a clean-environment check that the declared
    Julia packages are sufficient
  • CI green on every workflow

Requirements

  • Export: Julia 1.12+ with juliac support
  • LAMMPS: any version with plugin support
  • Python: 3.9+, ASE; matscipy for ACELibraryCalculator, juliacall for
    ACEJuliaCalculator

@tjjarvinen

Copy link
Copy Markdown
Collaborator

I am not sure are you aware that you can already use ACEpotentials with ASE and LAMMPS.

It is done with IPICalculator.jl and current used by e.g. DFTK for ASE. I have also used ACEpotentials with ASE extensively, so it is well tested.

Using the i-PI socket server protocol is the approach preferred by Ask Hjorth Larsen the ASE maintainer. This was discussed with him during Julia MolSSI Workshop last year and other occasions afterwards. The implementation at IPICalculator.jl is meant to serve all AtomsCalculators compatible calculators in JuliaMolSim ecosystem by providing i-PI protocol suport and general ASE interface.

LAMMPS supports Molssi Driver Interface (MDI) in server mode https://docs.lammps.org/mdi.html, which is compatible with i-PI protocol, with the exception of not having support for Unix pipe. You can use ACEpotentials with IPICalculators in LAMMPS this way. Although I have to admit that the LAMMPS documentation is not clear at all. I have not tested ACEpotentials in LAMMPS this way, but I expect it to work

Thus, I propose that instead of using resources to implement our own ASE and LAMMPS binary interfaces - this PR - we use the existing i-PI protocol interface to connect to ASE and LAMMPS, and make documentation entries for them. This the easiest way to get ASE and LAMMPS interface going and reduces maintenance burden for ACEpotentials.

@jameskermode
jameskermode force-pushed the lammps-export branch 2 times, most recently from 493ab77 to 5fd03c8 Compare December 10, 2025 12:40
@jameskermode

Copy link
Copy Markdown
Collaborator Author

Thanks for letting me know, I was aware of the IPICalculator.jl support but not that it could be used with LAMMPS. Nonetheless, I think native code export I am pursuing here is a good path to explore because it allows MPI-parallel domain decomposition. This should be much more scalable than socket communications for very large systems. ASE is then a fringe benefit that used the same native code export - and also benefits from OpenMP parallelisation that wouldn't be easy to do with IPICalculator. Initial tests give ca. 5x speed up on 8 threads even for small systems. Moreover, so far I only spent a day or so on it and it's nearly working.

@tjjarvinen

tjjarvinen commented Dec 10, 2025 •

Copy link
Copy Markdown
Collaborator

MPI is a valid point. Although there is the executor keyword that allows you to perform distributed calculation already in Julia

# start Julia in parallel and then use DistributedEx() instead of the default ThreadedEx()
energy_forces_virial(system, ace_calc;  executor=DistributedEx(), ntasks=nworkers() )

I am not sure what the performance is with it, but you could compare it to the MPI implementation. (DistributedEx() might not be exported in ACEpotentials so you need to get it from Folds.jl or Transducers.jl)

Also, I am really curious how did you manage get 5x performance from OpenMP? The default Julia version is multi threaded and scales well with number of threads when neighbourlist calculation time is ignored.

@jameskermode

Copy link
Copy Markdown
Collaborator Author

It would indeed be interesting to compare LAMMPS MPI performance with Julia executor, but my experience in general on HPC machines is that MPI has been heavily vendor optimisated and in comparison's Julia distributed calculations are typically not competitive. This is likely to be particularly true for LAMMPS.

Standard Julia threads can't be used with --trim=safe native code, so I added OpenMP on the loop over atoms outside the Julia call. 5x on 8 cores isn't great but this was only 128 atom system and this is including the neighbourlist overhead. I was intending it for use either from Python, or within multilevel MPI/OpenMP parallelism from LAMMPS, just to give a bit of extra speedup and make full use of available hardware threads.

@jameskermode

Copy link
Copy Markdown
Collaborator Author

Another advantage of this approach: it looks like we can bundle the Julia runtime libraries, meaning no Julia installation required for end users.

@tjjarvinen

Copy link
Copy Markdown
Collaborator

MPI should be faster when communication limited, if computation limited then MPI and Julia should give about the same results. The point here would be get an idea of when the situation changes to communication limited case.

The point of including Julia runtime is also good. It should make ACEpotentials easier to use when only compute is needed.

  • Standard Julia for small system where multi threading inside a single node - sockets with Unix pipe is same as using MPI on local node
  • Large system with MPI using several nodes (might even be valid to use MPI in Julia too at some point)
  • Easy to use binary package for compute only

I should also work on to finish the new neigbourlist package. It would be faster than the current one, is multithreaded and can be made to work with MPI.

@cortner

cortner commented Dec 15, 2025

Copy link
Copy Markdown
Member

@jameskermode -- this is entirely outside of what I want to have any control over and would be very happy to merge this so we can proceed with the other PRs.

jameskermode and others added 2 commits January 6, 2026 10:03
This commit adds comprehensive infrastructure for exporting ACE models
to standalone shared libraries for use with LAMMPS and Python.

## Export System (`export/`)
- Code generation approach for trim-safe compilation with juliac
- Hermite spline radial basis for machine-precision accuracy
- C interface for energy, forces, and virial calculations
- Python/ASE calculator interfaces (library and JuliaCall-based)
- LAMMPS plugin with OpenMP parallelization

## Benchmark Infrastructure (`benchmark/`)
- TiAl ACE and ETACE model deployments for performance testing
- MPI and hybrid MPI+OpenMP scaling benchmarks
- Comparison with ML-PACE (ACEpotentials v0.6.x export)
- Performance analysis and results

## Key Features
- Self-contained exports without Julia runtime at evaluation time
- Support for single and multi-species systems
- Batch API with threading for high performance
- Comprehensive test suite

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add comprehensive end-to-end tutorial for fitting ETACE models
  and exporting to LAMMPS (export/examples/etace_lammps_tutorial.jl)
- Update export/README.md with:
  - ACE vs ETACE comparison table and guidance
  - Radial basis export options (hermite_spline vs polynomial)
  - Splinification workflow notes
- Integrate tutorial into Literate.jl documentation system
- Add tutorial to docs/src/tutorials/index.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@jameskermode jameskermode changed the title WIP native code ASE and LAMMPS export Native code export for LAMMPS and Python/ASE Jan 6, 2026
jameskermode and others added 2 commits January 6, 2026 10:52
The export CI workflow was lost during the rebase onto upstream main.
This restores the comprehensive CI that tests:
- ETACE model export and compilation
- Python calculator integration (library + JuliaCall)
- LAMMPS plugin (serial and MPI)
- ase-ace package tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update runtests.jl to only include ETACE-based tests:
  - etace: ETACE polynomial export tests
  - hermite: Hermite spline export tests
  - multispecies: Multi-species model tests
  - python, lammps, mpi: Integration tests

- Update export-ci.yml:
  - Rename test-export to test-etace-export
  - Run etace, hermite, multispecies tests
  - Add juliac compilation step to produce library artifact
  - Add ACE registry for dependency resolution

- Remove outdated test files that used ACEModel (not ETACE):
  - test_export.jl
  - test_c_interface_minimal.jl
  - test_minimal_export.jl
  - test_portable_python.jl

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
jameskermode and others added 14 commits January 6, 2026 11:13
The exported polynomial evaluation was using an incorrect 3-term recurrence
that did not match P4ML's OrthPolyBasis1D3T implementation.

Bug: The export code had:
  P[1] = 1.0
  P[2] = (A[1]*y + B[1]) * P[1]
  P[n+1] = (A[n]*y + B[n]) * P[n] + C[n] * P[n-1]

Fix: Now matches P4ML exactly:
  P[1] = A[1]  (typically 1/sqrt(2) for orthonormal Legendre)
  P[2] = A[2] * y + B[2]
  P[n] = (A[n] * y + B[n]) * P[n-1] + C[n] * P[n-2]  for n >= 3

This caused exported models to produce completely wrong energies/forces
(e.g., 0.4 eV vs -13.7 eV). Now all ETACE, hermite, and multispecies
export tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Allows manual triggering of the export CI workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The juliac feature is experimental and may not be available in all
Julia installations. The important ETACE export tests (43/43 passing)
are the core functionality to verify.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The juliac native compiler is experimental and not available in standard
Julia builds. This change:
- Detects if juliac.jl exists before attempting compilation
- Sets an output variable indicating if library was built
- Makes downstream jobs (test-python, test-lammps-serial, test-mpi,
  ase-ace library/julia tests) conditional on the library being built
- Jobs will be skipped (not failed) when juliac is unavailable

The core ETACE export tests (43 tests) will always run and verify the
export functionality works correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add export/src/kernels/ with reusable trim-safe kernel functions:
  - polynomials.jl: Generic 3-term recurrence polynomial evaluation
  - transforms.jl: Agnesi and normalized transforms with derivatives
  - envelopes.jl: Quartic, polynomial envelope functions
  - hermite.jl: Hermite cubic spline interpolation

- Add radial_basis_v2.jl with data-table approach:
  - Uses tuple of NamedTuples for transform parameters
  - Single generic kernel functions instead of per-pair generation
  - ~18-29% reduction for multi-species models

- Update export_ace_model.jl:
  - Add code_style parameter (:compact default, :expanded legacy)
  - Default to compact data-table generation

- Fix test_multispecies.jl: Add missing TEST_DIR constants

- Fix etace_lammps_tutorial.jl: Simplify println block to avoid
  Literate.jl parsing LAMMPS commands as Julia code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Delete ace_c_interface_minimal.jl (465 lines, zero test coverage)
- Delete export_ace_model_minimal.jl (368 lines, zero test coverage)
- Delete kernels/ directory (492 lines, unused from previous refactor)
- Delete radial_basis_v2.jl (272 lines, merged into main file)
- Remove code_style parameter (only :compact was tested)
- Replace legacy per-pair radial basis generation with data-table approach

Total reduction: ~1650 lines removed across 9 files
Before: ~4054 lines across 7 files
After: 2896 lines across 4 files

All 36 ETACE export tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add _emit_species_dispatch() and _emit_species_dispatch_multi() helpers
to consolidate repeated patterns for species-based conditional code generation.

Replaced 12 instances of duplicated for-loop patterns with helper calls:
- E0 lookup patterns (6 instances)
- Weight contraction patterns (3 instances)
- E0 addition patterns (3 instances)

Net reduction: 28 lines, improved maintainability through DRY principle.
All 36 ETACE export tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Break the 1937-line export_ace_model.jl into 4 focused files:
- export_ace_model.jl (436 lines): Main entry points and coordination
- write_radial.jl (575 lines): Radial basis writing functions
- write_evaluation.jl (573 lines): Evaluation function generators
- write_c_interface.jl (368 lines): C interface and main entry point

Each file has a clear responsibility, making the codebase easier to
navigate and maintain. All 36 ETACE export tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Documentation fixes:
- Use #jl prefix for code blocks that use deploy_dir variable
- Add proper markdown code fences for bash/LAMMPS examples
- Prevents Literate.jl from executing blocks in separate scopes

CI fixes:
- Remove continue-on-error from juliac compilation step
- juliac compilation is core functionality, not optional
- Fail hard if juliac.jl not found or compilation fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The bundled juliac.jl script is not available in standard Julia 1.12
installations from GitHub Actions. Use JuliaC.jl package instead
which provides the same functionality as an installable package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
JuliaC.jl requires the full workflow:
- ImageRecipe: configure compilation settings
- LinkRecipe: configure linking to produce final .so
- compile_products(): generate intermediate object files
- link_products(): link to create final shared library

The previous code only called compile_products() which produces
intermediate files, not the final linked shared library.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The CI was using `ls test_etace_*.jl | head -1` which alphabetically
selected test_etace_energy.jl, but that model only has energy functions.

Changed to explicitly use test_etace_model.jl which has all C interface
functions (energy, forces, virial) required by LAMMPS, Python, and
ase-ace tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
jameskermode and others added 7 commits September 21, 2026 08:51
…ver fired

Building a working juliapkg environment on a developer machine for the first time
turned up four things.  Two are documentation, two are defects.

README, the Julia version you actually get.  juliacall declares OpenSSL_jll as
"<=python", and juliapkg turns that into an extra *Julia* version bound so the two
OpenSSLs cannot clash in one process.  A Python linked against OpenSSL 3.0 -- which
includes every CPython `uv` downloads -- yields `1 - 1.11`, which intersects our
`~1.11, ~1.12` down to `~1.11`.  So you get Julia 1.11 even with 1.12 installed and
on PATH.  The README said only that the Julia on PATH "is not necessarily" the one
used; it never said why, and this is the reason you will actually hit.

README, the cost of update=True.  It asks juliapkg for the newest compatible Julia
*exactly*, so juliaup's 1.11.7 stops qualifying the moment 1.11.9 exists and juliapkg
downloads a second Julia (~385 MB) into <prefix>/julia_env/pyjuliapkg.  Measured, not
inferred: with the documented default, find_julia() returns
~/.julia/juliaup/julia-1.11.7+0.x64.linux.gnu/bin/julia and downloads nothing.

A skip guard that never fired.  test_init_missing_library sat behind

    try:    from ase_ace import ACELibraryCalculator
    except ImportError:    pytest.skip("matscipy not installed")

but that import SUCCEEDS without matscipy -- the class is exported unconditionally
and raises ImportError from __init__ instead.  So the guard was dead code and the
test failed with that ImportError rather than skipping.  It now guards on
HAS_MATSCIPY, the actual signal.  Checked both ways so this is not a failing test
converted into a silent one: with matscipy installed the test PASSES (the
FileNotFoundError assertion is genuine), without it the test skips.

The wrong extra name.  The file told the reader to `pip install ase-ace[library]`
twice.  The declared extra is `lib`; README and __init__.py already said so.

Why none of this was caught before: no developer machine had a working ase-ace
environment.  The venv had neither juliacall nor juliapkg, its editable install
still pointed at the pre-move path, and a stale wheel in ~/.local contributed a
juliapkg.json -- ACEfit and julia "=1.11.5" -- to every resolve on the host.  That
last one is what masked the ACEfit break until CI found it.

Verified: ase-ace suite 37 passed, 52 skipped, 0 failed against a real Julia 1.11.9
environment with all ten packages; packaging gate 26 passed, 6 skipped.  The eight
declared packages were loaded for the first time on this host (ALL_EIGHT_LOAD_OK,
ACEpotentials 0.10.1), and benchmark_interfaces.py's ACEpotentials.JSON fix was
executed rather than reasoned about -- with bare `using JSON` confirmed to still
fail as ArgumentError: Package JSON not found in current path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ixture

tests/fixtures/test_model.json was tracked AND rewritten by every CI run, which is
two problems in one.  The committed copy was never the model under test -- the
"Create test model" step fitted a fresh Si model over the top of it before any test
ran -- so 126 KB of JSON sat in the repository pretending to be an input.  And
following the documented CI steps on a developer machine dirtied the working tree,
which is exactly what happened while verifying the juliapkg setup.

The file was never meant to be tracked: tests/fixtures/.gitignore has listed
`test_model.json` under "Generated test fixtures - not tracked in git" all along,
and .gitkeep is there to hold the directory open.  It was added before that rule
existed and git kept tracking it, as git does.

Nothing reads the committed copy.  ACE_TEST_MODEL is the only thing that selects a
model (tests/test_julia_calculator.py:37, skipping when it is unset or missing), and
conftest's TEST_MODEL_PATH is a default *output* for create_test_model(), not an
input.  test_julia_environment.py re-runs the fitting path in a clean environment
rather than depending on the file.

CI now fits into $RUNNER_TEMP and points ACE_TEST_MODEL at it, so no step writes
inside the checkout.  The Julia side takes the destination from the environment --
`save_model(model, ENV["ACE_TEST_MODEL"])` -- rather than threading a path through
a shell double-quoted `python3 -c` into a triple-quoted seval block, where the
escaping is three levels deep and easy to get wrong.

Verified: packaging and Julia-environment gates 30 passed, 6 skipped with the file
gone; the 6 are the pre-existing ACE_TEST_PACKAGING=1 tier-2 builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The socket backend had never run.  tests/test_calculator.py and test_threading.py
hung indefinitely rather than failing, so nothing reported it, and six separate
defects were stacked behind one another -- each only reachable once the previous
was fixed.  All six are below, with what each was verified against.

1. ace_driver.jl read `meta.elements` from `load_model`.  load_model returns
   (model, parsed_json): the second value is the whole file, and D["meta"] is the
   `meta` kwarg of save_model, an empty Dict unless a caller passed one.  Species
   and cutoff live in D["model_spec"], which is guaranteed present here -- reaching
   that line means make_model(D["model_spec"]) already succeeded.  Confirmed by a
   corrupt model file: `KeyError: key "model_spec" not found`, from load_model.

2. create_template_system passed a 3x3 Matrix to AtomsBase.periodic_system, which
   takes a vector of lattice vectors.  MethodError; the driver never reached the
   socket.

3. JuliaACEServer.start(timeout=60.0) documented "Maximum time to wait for server
   to be ready" and "Raises RuntimeError if the server fails to start or connect
   within timeout", then ignored `timeout` entirely: time.sleep(0.5), one poll(),
   return.  Julia needs ~50s to load ACEpotentials before it reaches the socket, so
   start() reported success on a process that had barely begun and every later
   failure became a stall at the first calculation.  It now waits, and raises with
   the driver's captured output.  Verified: raises at 5.8s with timeout=5, and at
   30.1s on a corrupt model carrying the Julia error text.

   Two latent problems fell out of this.  stdout=PIPE and stderr=PIPE with nobody
   reading deadlocks any driver that logs enough to fill the 64 KiB pipe buffer
   (--verbose sets JULIA_DEBUG); both pipes are now drained by threads.  And
   get_output() called communicate() and returned ("", "") while the process was
   alive -- precisely when a caller diagnosing a stall needs it.

4. --host defaulted to "localhost".  ASE's SocketServer is socket.socket(AF_INET)
   bound to ('', port) -- IPv4 only -- and on any host whose resolver returns ::1
   first, Julia dialled IPv6 and got ECONNREFUSED.  This alone made the backend
   unusable on most modern Linux.  Now 127.0.0.1.

5. The driver's template was built from the model's element LIST, i.e. one atom per
   element type, but IPICalculator takes species and masses from that template and
   asserts on every POSDATA that the incoming position count matches -- the i-PI
   protocol transmits positions and cell only, never species, so it cannot do
   otherwise.  A one-species model therefore had a 1-atom template and every real
   system failed with "received amount of position data does no match the atomic
   symbol data".  The caller knows the composition, so it now passes --species, and
   ACECalculator restarts the driver when the composition changes, since i-PI
   cannot renegotiate a template.  Verified: 2-atom cell -326.402816 eV, 16-atom
   supercell -2611.222527 eV across a restart, ratio 8.0000.

6. Introduced while fixing 3, and caught by its own negative test.  The readiness
   marker ("Connecting to") is logged immediately BEFORE connect(), and Julia JIT
   compiles run_driver in between -- measured at several seconds -- so a driver
   whose connection is refused is still alive well after the marker.  A timed
   survival window is wrong in kind, not just in value: it reported ready on a
   driver that died moments later.  Readiness is now observed where the truth is,
   on our own listening socket: a completed connection makes it select()-readable,
   and peeking does not consume it, so ASE still accepts it normally.  Verified:
   the IPv6 symptom reproduced deliberately now raises "Julia driver exited before
   connecting" in 55s instead of stalling.

Also one test defect, unreachable until the above.  test_energy_reasonable asserted
abs(E_per_atom) < 100, which assumes a COHESIVE energy; an ACE total energy carries
the one-body reference E0, about -160 eV/atom for Si.  It would have failed on any
correct model.  Not widened to make it pass: ACEJuliaCalculator returns
-326.402818497 eV for the same cell against the socket backend's -326.402815849 eV,
agreeing to 8e-9 relative -- the 2.6e-06 eV residual is the i-PI atomic-unit round
trip -- and the juliacall suite asserts no magnitude bound at all.  The bound now
catches what it meant to: a blown-up model or a dropped reference.

Verified: every ase-ace test file exits 0.  test_calculator 21 passed (was an
indefinite hang), test_threading passed (likewise), julia_calculator 27 passed,
julia_environment 4 passed, packaging 26 passed, library 3 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting the socket driver costs ~50s -- Julia loads ACEpotentials before it
reaches the socket -- and `ace_calculator` was function-scoped, so every test that
asked for a calculator paid it again.  test_threading was worse: its fixtures were
function-scoped AND parametrised over four thread counts, so each of its two tests
started four drivers, eight in all.

`ace_calculator` is now session-scoped and the threading fixtures module-scoped,
which gives one driver per thread count rather than one per (test, thread count).

Safe because nothing shares mutable state through them: the tests hand the
calculator to atoms.calc and read results, the lifecycle tests (close, context
manager, missing model) build their own ACECalculator rather than taking the
fixture, and a test computing a different composition is fine now that the
calculator restarts its driver when the composition changes.  The widening is legal
because test_model_path and julia_available were already session-scoped -- pytest
forbids a broader fixture depending on a narrower one.

Measured on the full suite, every file still exiting 0:

  test_calculator.py   697s -> 473s
  test_threading.py    688s -> 466s
  TOTAL               1629s -> 1185s   (-27%)

Less than a count of fixture uses predicts, and worth knowing why: the
composition-change restart re-pays startup inside the shared fixture, because
test_energy_scales_with_size computes a 16-atom supercell after a 2-atom cell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ACECalculator ran Julia in a subprocess and spoke i-PI to it over a socket, via
ASE's SocketIOCalculator and IPICalculator.jl.  It is gone, along with
ace_driver.jl, server.py's JuliaACEServer, tests/test_calculator.py and
tests/test_threading.py.

Its documented reason to exist was multi-threaded Julia.  That does not
distinguish it: ACEJuliaCalculator sets JULIA_NUM_THREADS too, and both reach the
same parallelism inside ACEpotentials, which chunks the atom loop with
Folds.sum(index_chunks(domain; n = ntasks), ThreadedEx()).  Measured here on a
512-atom Si cell: 334ms at one thread, 195ms at two, 110ms at four, 94ms at eight.
The speedup is real and both backends get it; only one of them needs 153 lines of
driver and 554 of subprocess management to do so.

The backends were also shown to agree numerically before this removal: for the same
cell and model, juliacall gave -326.402818497 eV against the socket backend's
-326.402815849 eV, 8e-9 relative, the residual being the i-PI atomic-unit round
trip.  Nothing is lost by dropping one of them.

What remains of the socket backend's reasons is process isolation -- a Julia crash
not taking Python with it, and no juliacall/PythonCall embedding.  That did not pay
for itself: the preceding commit had to fix six defects before the backend ran at
all, one of which (--host "localhost" resolving to ::1 against ASE's AF_INET bind)
made it unusable on most modern Linux since it was written.  The fixes are in
history if it is ever wanted back.

server.py is TRIMMED, not deleted.  It carries the shared juliapkg plumbing --
julia_env, resolve_julia_env, get_julia_assets_path, juliapkg_environment_error --
that julia_calculator.py and utils.py both import; deleting the file would have
taken the JuliaCall backend with it.  What went is JuliaACEServer, find_free_port
and five imports nothing else used, leaving 232 lines of environment resolution.
The module keeps its name so this stays a deletion rather than a rename touching
every import; renaming it to julia_env is a reasonable follow-up.

The declaration drops from eight Julia packages to five: ArgParse, IPICalculator
and UnitfulAtomic were there for ace_driver.jl, which parsed a command line, spoke
i-PI and built a Unitful template.  The derived `using` scan caught a consequence of
that shrink which is easy to miss -- benchmark_interfaces.py does `using
UnitfulAtomic` in an embedded Julia block -- now reached through
ACEpotentials.UnitfulAtomic, the route that file already takes for JSON.

Also: the three examples/ scripts moved to ACEJuliaCalculator, which needed more
than a rename -- it takes (model_path, num_threads) only, has no timeout=, and is
not a context manager, so the `with` blocks became plain assignments; the README's
socket sections, parameter table, unix-socket guidance and i-PI references are gone;
and the CI step that ran tests/test_calculator.py's import/utility classes now runs
the library suite instead.

Verified: every remaining test file exits 0 -- descriptors, julia_calculator (111s),
julia_environment (164s), library_calculator, packaging.  The suite drops from 1185s
to 279s.  ACECalculator is no longer importable; the shared plumbing still is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module managed the socket backend's Julia subprocess, which is why it was
called server.py.  That backend is gone and what remains is the juliapkg plumbing
every Julia-backed path uses -- julia_env(), resolve_julia_env(),
get_julia_assets_path(), juliapkg_environment_error() -- so the name described
nothing.  Renamed with git mv, so history follows the file; 20 references across
seven files updated.

Two things this was not just find-and-replace for.

The module and one of its functions now share a name.  `from ase_ace.julia_env
import julia_env` is fine, but binding that function at package level would shadow
the module, so __init__ imports neither and the docstring says why.  The one place
that imports the module as an object (tests/test_packaging.py, which monkeypatches
julia_env onto it) binds it as `julia_env_mod` rather than shadowing the function
in its own scope.

And the socket removal had left a dead comment behind: cutting from `_READY_MARKER
= ...` to the next function orphaned the five-line explanation ABOVE it, which sat
immediately before get_julia_assets_path describing an i-PI readiness signal that
no longer exists.  Removed, along with two docstrings still naming ace_driver.jl
and "both Julia-backed calculators".

Verified: ase_ace.server is no longer importable, ase_ace.julia_env is, and the
suite is unchanged -- packaging, descriptors, library_calculator, julia_calculator
(27 passed, 3 skipped) and julia_environment all exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of every tracked .md and example on this branch against the claims the
socket removal invalidated.  Eight real defects; three apparent ones deliberately
left alone.

Fixed:

* export/README.md, eight places: the directory tree still listed calculator.py and
  ace_driver.jl, the backend table still had an ACECalculator row, the text still
  said "three ASE-compatible calculators", the threading advice still offered the
  socket protocol as option 2, and an install note still said `ACECalculator` needs
  no extra.  Plural phrasing about "both Julia-backed calculators" corrected
  throughout -- there is one.

* export/ase-ace/README.md line 5 still said "three".  I cut the socket sections
  from that file and missed its own header.

* src/ase_ace/base.py listed ACECalculator in a CURRENT description of the backends
  a subclass may be, which is the worst place for this to be wrong.

* src/ase_ace/library_calculator.py described itself as an alternative to "running
  Julia via sockets"; the alternative is JuliaCall now.

* src/ase_ace/julia_calculator.py claimed in the present tense that "the socket
  backend asks juliapkg too".

* tests/test_packaging.py's module docstring was left self-contradictory by my own
  earlier edit: "Two of the three calculators (ACEJuliaCalculator, JuliaCall, and
  ACELibraryCalculator)" named two, then said only one of them was affected.

* src/ase_ace/julia_env.py named ACECalculator when explaining why the bypass rule
  is asymmetric, and counted "the three modules" that take those arguments.

* export/python/examples/example_ase.py did `from ase_ace import
  ACELibraryCalculator as ACECalculator`.  It worked, but aliasing to a name the
  package no longer exports is a trap for whoever reads it next.

Left alone, having checked:

* The top-level README's `registry add .../ACEregistry` is under "Quick Start for
  v0.9.x", where it is historically correct; the v0.10.x section above it already
  says `add ACEpotentials` with no registry.

* export/bench/FINDINGS_parity.md records the "Unix Sockets (Faster for Local)"
  heading as an unmeasured comparative claim.  That is a dated report of what was
  found then; editing it would falsify a record, and the heading itself is gone.

* tests/test_packaging.py fixtures that write a placeholder ace_driver.jl are
  recreating the PRE-fold layout on purpose, to prove it is rejected.

Verified: the examples' constructor keywords checked against the live signatures of
ACEJuliaCalculator and ACELibraryCalculator -- all four use only keywords that exist,
which is the check that would have caught the `timeout=` the socket removal left
behind.  packaging, descriptors, library_calculator and julia_calculator all exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jameskermode

Copy link
Copy Markdown
Collaborator Author

@Alexsp32 Thanks for your contributions here. With some AI assitance I've improved performance of LAMMPS export on CPU and now has parity with ML-PACE. Do you want to test again before I merge? I would then plan to tidy up documentation, tutorials etc and do a release, and publish the ase-ace package on PyPI.

On GPU, I've struggled with some Reactant.jl compulation bugs (one has just been fixed, so I'll try again at some point). The most promising approach at the moment for fast evaluation of ACEpotentials.jl-fitted models is to port the forward model to JAX, see ace-jax repo for an early-stage prototype if you are interested. This has parity with ML-PACE on Kokkos (although I should note MACE with cuequivariance can still be faster than large basis-set linear ACE models, work in progress on that.)

ACEJuliaCalculator(num_threads=2) exits 139 -- SIGSEGV, no traceback, no message the
caller can act on.  num_threads=1 returns an energy from the same model in the same
process.  juliacall does warn:

    Julia was started with multiple threads but multithreading support is
    experimental in JuliaCall.  It is recommended to restart Python with the
    environment variable PYTHON_JULIACALL_HANDLE_SIGNALS=yes set, otherwise you
    may experience segfaults or other crashes.

but that reaches stderr from a process already doomed, and "restart Python" is not
something a caller several frames down can do.  _init_julia now sets the variable
before importing juliacall, in the same window it already uses for
JULIA_NUM_THREADS.  With it set before the import, the warning does not fire either:
the condition is removed rather than muted.

Deliberately conditional, and deliberately setdefault.  The variable is not free --
Julia takes over SIGINT, so Ctrl-C stops raising KeyboardInterrupt -- and
single-threaded callers do not have the crash, so they should not pay for it.
Measured: at num_threads=1 the variable is still unset after a successful
calculation; at num_threads=2 it is "yes" and the energy matches single-threaded
exactly (-326.40281849663245 both ways).  setdefault so an explicit caller choice,
including "no", still wins.  If something else imported juliacall first the guard
cannot help, so that case gets a RuntimeWarning naming the problem instead of a
segfault later.

This bit the test suite, not just users.  Every green run of the ase-ace suite so
far happened to inherit the variable from whoever invoked pytest; a run without it
segfaulted in the middle of test_julia_calculator.py, which I initially mistook for
damage from an unrelated rename.  The whole suite now passes with the variable unset
-- packaging, descriptors, library_calculator, julia_calculator (29 passed) and
julia_environment, 277s total.

Two tests, both checked by mutation.  Disabling the guard (`if False:`) fails both:
the offline one because the thread-count condition is gone -- which would mean
forcing the variable on single-threaded callers and taking away their Ctrl-C -- and
the subprocess one because two-threaded juliacall segfaults again.  The subprocess
strips PYTHON_JULIACALL_HANDLE_SIGNALS from the environment on purpose, so the test
does not depend on how the suite was invoked, which is exactly what hid this.

The mutation run also corrected the failure message: subprocess reports a segfault as
-11 and a shell as 139, and the message named only 139.  It now names both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jameskermode
jameskermode marked this pull request as ready for review September 22, 2026 09:02
@Alexsp32

Copy link
Copy Markdown
Collaborator

Thanks for the update, @jameskermode. I’m happy to test this again, but won’t get around to it until next week probably. If you’re happy to wait until then, I’ll give the changes a try.

@jameskermode

Copy link
Copy Markdown
Collaborator Author

@Alexsp32 Next week is fine - given I opened this PR last Dec another week is nothing, and would be good to have a second data point before merging (although this doesn't affect the standard Julia evaluatin paths so isn't a risky merge)

Two tests that fail on b1477f6:

* test/etmodels/test_etpair_ace1.jl: ETModels.convertpair on an ace1_model
  pair term (ACE1_PolyEnvelope1sR, per species pair), against the ACEModel
  pair energy/forces/virial at 1e-12, plus an asymmetric (ordered-pair)
  envelope variant and a by-name refusal of an unsupported envelope.
  Fails on b1477f6 with `AssertionError: env1 isa PolyEnvelope1sR`.

* export/test/test_ace1_export.jl (new `ace1` group): exports the
  unsplinified twin of a 3-species ace1_model (Ytype = :spherical) and gates
  the in-process module at 1e-12 against the ET stack and the ACEModel.
  On b1477f6 the many-body-only export is SILENTLY wrong (1.35 eV/atom,
  17.9 eV/A) because the generator always emits solid harmonics; the stack
  cases cannot be converted; unsupported angular bases (racah normalisation,
  complex harmonics) are exported without complaint.

test/etmodels/ace1_exact_twin.jl builds the exact twin (ace1_model minus its
two splinify() calls) for both tests and checks its basis is ace1_model's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jameskermode and others added 6 commits October 1, 2026 12:02
convertpair only accepted the species-independent PolyEnvelope1sR, so the
pair term of any ace1_model failed to convert (AssertionError).  ace1_model's
pair envelope is ACE1_PolyEnvelope1sR with (rcut, r0) per species pair:

    env(r) = s^-p - sc^-p + p sc^(-p-1) (s - sc),  s = r/r0,  sc = rcut/r0,
    env(r) = 0 for r > rcut.

Add a _convert_pair_envelope method for it that looks (rcut, r0) up per
ORDERED pair (ET.catcat2idx(zlist, z0, z1), i.e. envelopes[iz0, jz], as
LearnableRnlrzzBasis does).  The envelope function is a named callable,
ACE1PairEnvelopeFn, so the exporter can recognise it by dispatch.  An
unsupported envelope type now raises an error naming it instead of a bare
AssertionError.  The PolyEnvelope1sR path is unchanged.

test/etmodels/test_etpair_ace1.jl: 18/18 pass (ACEModel pair E/F/V to
<= 2.5e-15, also with an asymmetric envelope; transposing the lookup makes
that case fail at 4.6e-2 eV/atom).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… envelope

Two exporter gaps for ace1_model-derived ETACE stacks:

1. SILENTLY WRONG RESULTS.  _write_spherical_harmonics always emitted solid
   harmonics Z_lm(r), but ace1_model uses real spherical harmonics
   Y_lm(r) = Z_lm(r/|r|).  The export loaded, ran and was wrong with no
   error (1.35 eV/atom, 17.9 eV/A on the new ace1 test model).
   The angular basis is now classified by dispatch on its type
   (_angular_basis_kind: RealSCWrapper{SolidHarmonics} -> :solid,
   RealSCWrapper{SphericalHarmonics} -> :spherical).  :spherical emits the
   solid recurrence as _eval_zlm/_eval_zlm_ed and wraps it as SpheriCart
   does (Z(R/r); gradient dZ/r projected onto the tangent plane).  Anything
   else -- a complex basis, a non-L2 (e.g. :racah) normalisation -- is
   REFUSED before any file is written, so a mismatch cannot be silent again.

2. ACE1 pair envelope.  _write_pair_basis only knew PolyEnvelope1sR (a
   scalar PAIR_ENV_RCUT).  It now recognises ETModels.ACE1PairEnvelopeFn by
   dispatch and emits PAIR_ENV_RC / PAIR_ENV_R0 per ORDERED pair, with the
   pair kernel's envelope lookup moved after k = pair_idx(iz0, jz).  An
   unrecognised envelope is refused by name (it used to be a FieldError).

The solid-harmonics / PolyEnvelope1sR output is BYTE-IDENTICAL to b1477f6's
(12 exports compared: the two NZ=3 multispecies models and a Si model with a
pair term, for_library false/true, aa_products :flat/:dag), so existing
build stamps and compiled libraries stay valid.

ace1 group: 60/60 in-process (1e-12 gates measure <= 1.9e-14 eV/A against
both the ET stack and the ACEModel); juliac --trim=safe compiles the export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ace1 group also writes build/ace1_lib.jl (for_library = true).  When
build/libace_ace1.so exists and its build stamp matches, every held-out site
is evaluated through the library's C API (python3/ctypes subprocess, as the
other library gates do) and compared with the in-process module at 1e-12;
otherwise the check is a visible @test_skip, or a failure under
ACE_REQUIRE_ACE1_LIB=1.

export-ci.yml: run and require `ace1` with the other in-process groups;
add a step after the library compile that compiles ace1_lib.jl with JuliaC
(--trim=safe, generic CPU) and re-runs the group with the library check
required; trigger the workflow on src/et_models/** and the shared twin
helper too, since the exporter depends on the conversion.

Measured here: library vs module 0.0 / 0.0 / 0.0 (bitwise) over 96 sites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
export/README.md:
* Quick Start passed an ace1_model ACEPotential straight to build_deployment,
  which has no such method (export_ace_model takes the converted ETACE
  StackedCalculator or an ETACEPotential).  It now converts with
  convert2et_full first.
* New "Supported models" table: solid AND spherical harmonics (L2), the
  PolyEnvelope1sR and ACE1_PolyEnvelope1sR pair envelopes, one-body E0s; and
  what is refused (splinified models, complex / non-L2 harmonics, unknown
  pair envelopes, ZBL), with the silent spherical-harmonics bug named.
* New "Models built with ace1_model": export through the unsplinified exact
  twin (test/etmodels/ace1_exact_twin.jl), and what copying splinified-fit
  weights into the twin does and does not reproduce.
* Known limitation 3 points at it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Model force-evaluation fix)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rd library only

The compiled-library tier ran its ctypes driver under the runner's system
python3, which has no numpy, so the CI step errored (ModuleNotFoundError)
before comparing anything. struct/array/ctypes do the same job with nothing
to install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Alexsp32

Alexsp32 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

I've tried working through the new export workflow and found the following issues so far:

  • export/scripts/build_deployment.jl Step 2/6 fails on Julia v.1.13 due to the Juliac.jl file no longer being in the expected path.
  • export/scripts/build_deployment.jl Step 3/6 fails sometimes on Julia v1.12, this is due to ldd sometimes failing on network file systems. (I think this is worth documenting since people are likely to compile in an HPC environment they want to use)

I'll push a fix for both as soon as it's working for me. Maybe you could check it doesn't break anything on your end?

@jameskermode

Copy link
Copy Markdown
Collaborator Author

thanks @Alexsp32, will gladly check your fix. I've not tested on 1.13, can add that to CI matrix.

@Alexsp32

Alexsp32 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

That suddenly turned way more complicated than I'd liked. I've bumped the unit tests to include Julia v1.13 now, but if anything uses the ACE registry, those might fail due to ACEsuit/ACEregistry#11.

@jameskermode

Copy link
Copy Markdown
Collaborator Author

@Alexsp32 Julia 1.13 looks good, so if you're happy I'll go ahead and merge and tag a new relase?

@Alexsp32

Alexsp32 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Sounds good, thanks for taking care of it!

@jameskermode
jameskermode merged commit 1aa7731 into main Oct 2, 2026
22 checks passed
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.

4 participants