EarthSci Toolkit - Rust Implementation
Rust implementation of the EarthSciML Abstract Syntax Tree Format (ESM).
Features
- Core: Parse, serialize, pretty-print, substitute, validate (schema + structural)
- Analysis: Unit checking, stoichiometric matrices, structural validation, component graphs
- Simulation: diffsol-backed ODE integration plus a vectorized array/PDE runtime
- CLI Tool:
esmcommand-line interface for validation, conversion, analysis, and simulation - WASM: WebAssembly compilation for in-browser loading, validation, and 0-D simulation
- Conformance: Adapter binaries for the cross-language conformance harness
Installation
As a Library
Add this to your Cargo.toml:
[]
= "0.3"
As a CLI Tool
(The cli feature is part of the default feature set.)
For WASM
Usage
Library
use ;
To simulate, build a problem and solve it:
use ;
use Path;
CLI
# Validate an ESM file
# Convert to compact JSON
# Pretty print expressions (unicode, latex, or ascii)
# Run a simulation
# Write GRIDDED output: dimension-labeled, row-major [time, ...spatial]
# arrays with CF coordinates, plus the named observed fields alongside the
# state (repeat --observed for several).
# Analyze structure / complexity / coupling
# Batch-validate a directory of fixtures
# Show file information
Run esm --help for the full command list.
Examples
Runnable examples live in examples/ (cargo run --example <name>):
roundtrip_expression (also driven by the property-corpus conformance
script), pde_conformance, canonical_expand, segmented_refresh_solve,
and unit_validation.
Building
Library and CLI
WASM
Development
Run tests:
Run tests with all features:
Run benchmarks (the bench target is gated behind the benchmarks feature, so
a plain cargo bench builds nothing):
Format code:
Lint:
# Every feature EXCEPT `xla`, which is what CI lints -- `--all-features`
# would pull in `xla` and fail without a fetched `xla_extension` release.
Cargo features
default:cli,solvecli: theesmcommand-line binary (requires clap)solve: the diffsol ODE solver behindsolve/init/solve_to_completion(building anEsmProblemnever needs it)wasm: WebAssembly bindings (requires wasm-bindgen)parallel/simd/zero_copy/custom_alloc/performance: opt-in experimental performance utilities in theperformancemodule (benchmark support; not used by the core simulate paths)benchmarks: enables the criterion bench targetconformance-adapters: the cross-language conformance adapter binariesesio: the EarthSciIO data-provider bridgexla: the compiled right-hand-side backend (see below)
Join-gate tuning (ESS_* environment variables)
An aggregate carrying a join clause (CONFORMANCE_SPEC.md §5.5.6 / §5.5.8)
drives its contraction from a candidate pair index instead of walking the full
product. Five variables tune that, read once per thread on first use. They
change cost, never an answer — every on clause is also lowered into the
node's filter, so switching any of this off widens the walk and the filter
still decides. That is what the differential tests assert, arm against arm.
| variable | default | effect |
|---|---|---|
ESS_JOIN_GATE_DISABLE |
unset | 1 turns the driver off entirely: every gated aggregate walks the untouched full product. The "before" arm of the gate benchmarks. |
ESS_JOIN_GATE_STATS |
unset | 1 reports each node's gates, and each gate the planner declined, on stderr. |
ESS_GATE_CACHE_PAIRS |
4000000 |
Resident-pair budget for each gate-index cache, counted in pair-equivalents of 16 B — roughly 64 MB per cache. 0 retains nothing beyond what a live gate holds, so every evaluation rebuilds. |
ESS_GATE_PLAN_RATIO |
4 |
How far a gate may overshoot the pair-space it could narrow before the planner declines to build it. A gate is weighed only against what SIBLING gates left reachable, so one whose symbols nothing else touches is never declined. |
ESS_GATE_PLAN_FLOOR |
1000000 |
A gate matching fewer pairs than this is never declined, whatever the ratio says. The reachable-space estimate is an upper bound, and being wrong about a small gate costs more than it saves. |
Everything else spelled ESS_* in this crate (the tape, vectorizer and CSE
switches) is an undocumented escape hatch for bisecting a suspected codegen
bug, not a supported knob.
Compiled right-hand side (xla feature)
simulate_array::tape compiles a model's observed + RHS rules into a flat
instruction program. With the non-default xla feature that program can
also be emitted as an XLA computation
rhs(u: f64[N], p: f64[M], t: f64[]) -> du: f64[N]
and run through PJRT (earthsci_ast::xla_runtime), instead of being
interpreted by the slab executor.
A caller reaches it by NAME. esm_problem with compiler: Some(Compiler::Xla) builds an EsmProblem whose right-hand side is that executable
(API_SPEC §5.8), and solve on it integrates through XLA:
let prob = esm_problem?;
assert_eq!;
let sol = solve?;
Which passes go through XLA, and which do not:
| Pass | Under compiler = Xla |
|---|---|
the right-hand side f(u, p, t) |
the emitted XLA executable |
| the finite-difference Jacobian an implicit solve differences | the same executable, twice per Jacobian call |
| constants and static observeds materialized at construction | the tape the emitter was built from |
| the per-segment seed, and the build-inspection snapshot | the same tape |
| the observeds reported at output times | the same tape |
The emitted program's only output is du, which is why the observed passes
are not in it. They are served from the same lowered form rather than from
the whole-array overlay beneath it, so there is one evaluator and not two —
and a rule that did not lower to the tape is refused before the emitter is
reached, so the refusal names the rule and its cadence tier.
Four properties are deliberate:
- No fallback. A model carrying any rule the emitter cannot lower is a
hard error naming the rule and the reason, never a partly-interpreted run.
The
compiled_rhsconformance tier records such a model as a named refusal, and undercompiler = Xlait is acompiler_refused_ruleat construction. AnEsmProblemthat namesxlaand carries no compiled program refuses too; the tape is never run under another compiler's name. - Availability is a fact about the build, not the document. Without the
xlafeature, or without an XLA runtime this process can start,esm_problemanswersSimulateError::CompilerUnavailablenaming what to build — before it looks at the document. - Numerical, not bitwise, agreement. XLA's
exp/log/poware not Rust's libm, and XLA'sreducedoes not pin a summation order. The tolerance classes oftests/conformance/compiled_rhs/README.md(one right-hand side) andtests/conformance/compiler_agreement/README.md(a whole run) are the contract. Rust isbindings_requiredforxlain the latter, on a build that has the feature and the extension. - One compile per
EsmProblem. The executable is emitted and compiled at construction and shared by every segment and every Jacobian call; nothing recompiles per step.
Setup
The feature links a prebuilt xla_extension release (144 MB for CPU), which
this repository does not vendor. Fetch it once, outside the checkout:
The script verifies a pinned SHA-256 before unpacking and prints the exact
export line for the directory it chose.
Building also needs a C++ toolchain and libclang for the xla crate's
bindgen step:
# directory holding libclang.so
build.rs bakes $XLA_EXTENSION_DIR/lib into this crate's binaries, cdylib,
unit and integration tests, examples and benches as an rpath, so none of those has to carry
LD_LIBRARY_PATH at run time (readelf -d <binary> | grep RUNPATH to check).
The one exception is doctests: rustdoc links its own test binary and a build
script's link arguments do not reach it, so with the feature on that binary
fails to load the extension. Either skip the doc step or give it the path:
LD_LIBRARY_PATH="/lib"
A default build neither links the extension nor needs any of this:
cargo check --all-targets with default features stays green on a machine
that has never heard of XLA.
Running on a GPU
EARTHSCI_XLA_PLATFORM=gpu (or =cuda) selects a GPU PJRT client;
EARTHSCI_XLA_GPU_MEMORY_FRACTION (default 0.75) and
EARTHSCI_XLA_GPU_PREALLOCATE=1 tune its allocator. CPU is the default.
A GPU run needs two things beyond a CPU one, and they fail in different places:
-
the CUDA extension.
scripts/fetch-xla-extension.sh --variant cuda12, then rebuild —build.rsbakes the rpath at BUILD time, so pointingXLA_EXTENSION_DIRat the CUDA tree after the fact changes nothing. With the CPU extension,EARTHSCI_XLA_PLATFORM=gpufails insidexla_runtime::client()with a message that says which case it looks like. -
the CUDA shared libraries the CUDA extension hard-links. cuDNN, NCCL, nvshmem, cuBLAS, cuFFT, cuSPARSE, nvJitLink, the CUDA runtime and NVRTC are
DT_NEEDEDentries, notdlopencalls, so the dynamic loader wants all of them beforemainruns whether or not the program uses any. A stock node with a driver and a toolkit does not have them. A missing one is therefore NOT a Rust error — it iserror while loading shared libraries: libcudnn.so.9and no output at all.scripts/setup-xla-gpu-libs.sh --prefix <dir outside the repo>builds a directory that satisfies every one of them from pinned NVIDIA wheels, plus theptxasXLA needs to assemble its kernels (without which every GPU compilation fails with "No PTX compilation provider is available"), and prints the lines to export:# -> LD_LIBRARY_PATH, XLA_FLAGS=--xla_gpu_cuda_data_dir=..., EARTHSCI_XLA_PLATFORM=gpuThe script also builds a stub for
nvshmem_transport_ibrc.so.3: the extension names that soname and the nvshmem wheel ships its transports at ABI 6. Nothing calls into the transport on ONE node, so a stub satisfies the loader; it would be wrong for a multi-node nvshmem collective, and the generated file says so.
On a batch system the job needs a GPU, real-disk scratch, and the exports above — generically:
#SBATCH --gres=gpu:1
#SBATCH --mem=24000
# a RAM-backed /tmp is an OOM kill
tests/xla_compiled_rhs.rs has a GPU arm that runs the whole compiled_rhs
tier when EARTHSCI_XLA_PLATFORM=gpu is set and skips with a message
otherwise. It checks the client's platform NAME before any fixture, so a build
that silently fell back to CPU fails rather than passing.
earthsci-xla-device-probe (a xla-feature binary) reports what the client
picked and what a device costs: devices, largest, residency <fixture.esm> [iters], multidevice.
Device-resident state
CompiledRhs::eval(u, p, t) is a pure function of host data: it uploads the
state and parameters, runs, and downloads the derivative. That is right for
the conformance tier, which evaluates unrelated probe states, and wrong for a
solver, which evaluates a state it just produced — there the two transfers per
call move the same numbers off the device and straight back on.
CompiledRhs::on_device(u, p) returns a DeviceRhs that splits the transfer
from the evaluation:
let rhs = compile?; // once per model
let mut dev = rhs.on_device?; // one upload
for k in 0..steps
let u = dev.state_to_host?; // one download
eval_at(t)runs against the buffers already on the device and leaves the derivative there; nothing crosses to the host.du_to_host()/state_to_host()are the transfers, made explicit — call them when you want the numbers, not once per step.du_device()hands the resident derivative to another device computation.set_state(u)re-uploads from the host, for a caller that is not stepping.euler_step(t, dt)doesu <- u + dt * f(u, p, t)through a second tiny compiled program, so the loop above has no transfer in it at all. It is there to make the residency usable, not because explicit Euler is a good integrator: a real solver would fuse its stage into the emitted program.
t is re-uploaded every call — it is one scalar, and keeping it resident
would cost another compiled program to advance it.
Multi-device: not available through xla 0.4.4
Sharding a model's state across several GPUs is not reachable from this
crate version, and the gap is in the crate's C++ shim (xla_rs/xla_rs.cc),
not in XLA:
compileconstructsCompileOptions options;and passes it unchanged, so every executable is built withnum_replicas = 1,num_partitions = 1,use_spmd_partitioning = falseand the default device assignment. None of those is settable from Rust.executeandexecute_bboth callexe->Execute({input_buffer_ptrs}, options)— one argument group, i.e. one replica. A replicated executable could not be fed even if one could be built.XlaBuilder::SetSharding/OpShardingare not wrapped, so the emitter cannot annotate operations for SPMD partitioning.
What IS reachable: PjRtClient::devices / addressable_devices,
buffer_from_host_buffer(.., Some(&device)) to place a buffer on a chosen
device, and PjRtBuffer::copy_to_device. earthsci-xla-device-probe multidevice prints what each of these does on the machine you run it on.
Until the shim grows the three items above, use one process per GPU
(CUDA_VISIBLE_DEVICES) for independent work.
License
MIT
Contributing
Please see the main repository for contribution guidelines.