Skip to main content

nvalchemi-distributed

How to run domain-decomposed (multi-GPU) MLIP simulations with DomainParallel — choose between the halo and graph-partition strategies, author a distribution_spec so a bring-your-own model runs under domain decomposition, and write a custom dynamics integrator that stays correct across ranks.

Quellinformationen

Repository
NVIDIA/nvalchemi-toolkit
Letzte Quellaktivität
6. Oktober 2026 um 05:51
Erkannte Sprache von SKILL.md
Englisch
Sterne
191
Forks
42

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
nvalchemi-distributed
description
How to run domain-decomposed (multi-GPU) MLIP simulations with DomainParallel — choose between the halo and graph-partition strategies, author a distribution_spec so a bring-your-own model runs under domain decomposition, and write a custom dynamics integrator that stays correct across ranks.
# nvalchemi Distributed (Domain Decomposition) ## Overview Domain decomposition (DD) splits *one* atomic system across several GPUs so a simulation that doesn't fit — or doesn't run fast enough — on a single card can scale out. The **same** model wrapper, hooks, and integrators you use single-process run unchanged; you add one wrapper around each. - {class}`~nvalchemi.distributed.DomainParallel` wraps any {class}`~nvalchemi.dynamics.base.BaseDynamics` integrator/optimizer and drives it across the mesh. - Under the hood it wraps the model in a `DistributedModel`, which reads the model's `distribution_spec` to know how to shard/gather each field. ```python from nvalchemi.distributed import ( DistributedManager, DomainConfig, DomainParallel, HookScope, ) ``` Launch with **torchrun** (or SLURM): DD is one process per GPU. ```bash torchrun --nproc_per_node=4 my_distributed_md.py ``` There are four things you may need to do. Pick the section you need: 1. **Run a shipped model under DD** → §1 2. **Choose halo vs graph-partition** → §2 3. **Make your own model run under DD** (author a `distribution_spec`) → §3 4. **Write a custom integrator that stays correct under DD** → §4 --- ## 1. Run a domain-decomposed model Bootstrap the process group + mesh with `DistributedManager`, wrap the model as usual, then wrap the integrator in `DomainParallel`. Build the full system on rank 0, `partition()` it, and `run()`. ```python import torch from nvalchemi.data import AtomicData, Batch from nvalchemi.distributed import ( DistributedManager, DomainConfig, DomainParallel, HookScope, ) from nvalchemi.dynamics import NVTLangevin, HostMemory from nvalchemi.dynamics.hooks import SnapshotHook from nvalchemi.dynamics.base import DynamicsStage from nvalchemi.hooks import NeighborListHook from nvalchemi.models.mace import MACEWrapper # 1. Bootstrap (reads RANK / WORLD_SIZE / LOCAL_RANK from torchrun). DistributedManager.initialize() dm = DistributedManager() mesh = dm.initialize_mesh(mesh_shape=(dm.world_size,), mesh_dim_names=("domain",)) device = torch.device(dm.device) # 2. Wrap the model — identical to single-process. wrapper = MACEWrapper.from_checkpoint("medium-mpa-0", device=device).eval() # 3. Build the inner integrator (its NeighborListHook is an INNER hook). integrator = NVTLangevin( model=wrapper, dt=1.0, temperature=300.0, friction=0.01, n_steps=200, hooks=[NeighborListHook(wrapper.model_config.neighbor_config, skin=0.5, stage=DynamicsStage.BEFORE_COMPUTE)], ) # 4. Trajectory snapshot: gather to rank 0 (an OUTER hook). snapshot = SnapshotHook(sink=HostMemory(capacity=201), frequency=10) snapshot.scope = HookScope.RANK_ZERO # 5. Wrap + run. cutoff = wrapper.cutoff makes the halo width exact. domain_cfg = DomainConfig(cutoff=float(wrapper.cutoff), skin=0.5, mesh=mesh) with DomainParallel(dynamics=integrator, config=domain_cfg, n_steps=200, hooks=[snapshot]) as dynamics: full_batch = build_full_system(device) if dm.rank == 0 else None owned = dynamics.partition(full_batch) # returns THIS rank's owned atoms dynamics.run(owned) DistributedManager.cleanup() ``` Key points: - `partition()` takes the full system on **rank 0** (`None` elsewhere) and returns each rank's owned `Batch`. `run()` loops `step()` for `n_steps`. - `with DomainParallel(...)` makes teardown exception-safe; keep the process-group lifecycle (`initialize` / `cleanup`) at launcher scope. - **The initial system** (`build_full_system`, built on rank 0) is a `Batch` whose per-atom fields must include everything the integrator reads. For MD that means `positions`, `atomic_numbers`, `atomic_masses`, `cell`, `pbc`, **and `velocities`**. Masses/positions/cell/pbc are `AtomicData(...)` constructor args, but **velocities are attached separately** — `data.add_node_property("velocities", v)` — then `Batch.from_data_list([data])`. `NVTLangevin` / `NVE` read `batch.velocities` + `batch.atomic_masses` and crash at step 0 if they're absent: ```python data = AtomicData(positions=pos, atomic_numbers=z, atomic_masses=m, cell=box.unsqueeze(0), pbc=pbc) # (masses in the ctor) data.add_node_property("velocities", v) # velocities added after full = Batch.from_data_list([data], device=device) ``` - **Hook placement:** neighbor-list / compute hooks go on the **inner** integrator (they fire on the padded compute view); trajectory/logging hooks go on the **outer** `DomainParallel` with `HookScope.RANK_ZERO` (gather to rank 0) or `HookScope.GLOBAL` (gather to every rank). Runnable references: `examples/distributed/03_mace_nvt_distributed.py` (NVT), `06_mace_npt_distributed.py` (NPT), `07_fire_nvt_dd.py` (FIRE + 2-D pipeline×DD). --- ## 2. Choose the strategy: halo vs graph-partition Two strategies ship, selected with `DomainConfig.strategy` ({class}`~nvalchemi.distributed.config.StrategyKind`). The default is halo. | | **Halo** (default) | **Graph-partition** | |---|---|---| | Split by | space (spatial domains) | atom index (balanced node blocks) | | Each rank holds | owned atoms + a ghost halo | the **full geometry** (replicated) + owns a node slice | | Best for | models with a bounded cutoff you can hand a padded view (MACE, NequIP, LJ, Ewald, PME) | models that build their own neighbour list inside `forward` (UMA / eSCN-family) | | Comms | one halo exchange per step | per-layer feature all-gather + reduce-scatter | | Cell / migration | cell load-bearing; atoms migrate | cell is a plain input; no migration | ```python from nvalchemi.distributed.config import StrategyKind # Halo (default): cfg = DomainConfig(cutoff=wrapper.cutoff, skin=0.5, mesh=mesh) # Graph-partition: cfg = DomainConfig(cutoff=wrapper.cutoff, skin=0.5, mesh=mesh, strategy=StrategyKind.GRAPH_PARTITION) ``` **Rule of thumb:** use **halo** for a short-range MPNN with a real cutoff (it scales to large N as a capacity play); use **graph-partition** for a model that rebuilds its own graph and can't take a pre-padded view, or to fit a single system past one GPU's memory. **Shipped presets** (from `nvalchemi.distributed.spec`) — a wrapper declares one of these as its `distribution_spec` (§3): | Preset | Strategy | |---|---| | `SPEC_MPNN_HALO` | halo — scatter-heavy MPNNs (MACE, NequIP, generic) **and any differentiable, autograd-force model** (incl. a BYO pure-PyTorch pair potential) | | `SPEC_LJ_HALO` | halo — the *shipped* Lennard-Jones wrapper's **opaque Warp kernel** (carries `OpAdapter`s) | | `SPEC_UMA_HALO` | halo — UMA/eSCN (local scatter) | | `SPEC_EWALD_HALO` / `SPEC_PME_HALO` | halo — long-range electrostatics | | `SPEC_DFTD3_HALO` | halo — DFTD3 dispersion | | `SPEC_MPNN_GP` | graph-partition — MPNNs under node partition | Note: `SPEC_LJ_HALO` is for the shipped LJ's opaque kernel, **not** a BYO pair potential — if you write a differentiable pair potential in plain PyTorch, its forces come from autograd and the correct preset is `SPEC_MPNN_HALO`. **Compiled runs:** add `compile=True` to the `DomainConfig`. The forward is fixed-shape (padded to per-rank caps), so after a short warm-up the trajectory runs recompile-free — MD-ready. Supported by the MACE (incl. cuEquivariance), AIMNet2, and UMA wrappers. --- ## 3. Add a `distribution_spec` (bring your own model) To make an arbitrary wrapped model (see `nvalchemi-model-wrapping`) run under DD, give it a `distribution_spec` that returns an {class}`~nvalchemi.distributed.spec.MLIPSpec`. The framework reads it — the wrapper's `forward` stays free of any distributed code. Four steps: **(1) Wrap** your model with `BaseModelMixin` as usual. **(2) Declare a spec.** If your model is a scatter-heavy MPNN with autograd forces, return a preset: ```python from nvalchemi.distributed.spec import SPEC_MPNN_HALO class MyWrapper(nn.Module, BaseModelMixin): def distribution_spec(self, strategy=None): return SPEC_MPNN_HALO ``` **Getting the wrapper right under halo.** For an autograd/scatter model that returns `SPEC_MPNN_HALO`, four things in your `forward` / `adapt_input` decide correctness — miss them and you get *silently wrong* energies, not a crash: - **Emit per-atom energies over ALL local atoms (owned + ghost).** Return an `(n_local,)` atomic-energy tensor and let the framework reduce ghost rows; don't pre-slice to owned. - **Rebind in-place scatters:** write `ae = ae.scatter_add_(0, recv, e_edge)` and use the *returned* tensor — under DD the dispatch returns a new, cross-rank halo-corrected tensor; the pre-rebind value is wrong. - **Filter neighbor-list sentinels in `adapt_input`:** halo padding can leave receiver indices ≥ `n_atoms`; drop them with `valid = (edge_index[0] < n) & (edge_index[1] < n)`. - **Periodic systems:** compute displacements with the minimum-image convention from the cell (`frac = rij @ inv_cell; frac -= frac.round(); rij = frac @ cell`). It is a no-op on halo ghost edges (the ghost already sits at its image) and correct single-process, so no explicit shift vectors are needed. Your wrapper's `ModelConfig` should declare forces as autograd — `autograd_outputs=frozenset({"forces"})`, `autograd_inputs=frozenset({"positions"})`, `neighbor_config=NeighborConfig(cutoff=…, format=NeighborListFormat.COO)`. See `nvalchemi-model-wrapping` and `examples/distributed/04_byo_pytorch_mpnn.py` for the full wrapper. If the model calls a **custom op** (a Warp/Triton kernel, a fused scatter) the tracer can't see through, build the spec explicitly and declare per-op behaviour with an adapter: ```python import torch from nvalchemi.distributed.ops import HaloStoragePolicy, ScatterOutputs from nvalchemi.distributed.spec import ( DistributionSpec, MLIPSpec, OpAdapter, OutputKind, ) def _build_spec(): return MLIPSpec( distribution=DistributionSpec( policy=HaloStoragePolicy(), # halo layout custom_ops=( # one OpAdapter per opaque kernel OpAdapter( op=torch.ops.mypkg.my_energy_op.default, arg_transforms={}, # inputs already halo-padded output_transforms={0: ScatterOutputs()}, # halo-correct output 0 ), ), ), output_kinds={"energy": OutputKind.PER_GRAPH, "forces": OutputKind.PER_NODE}, ) ``` - **`policy`** — storage layout: `HaloStoragePolicy` (halo) or `GraphParallelPolicy` (node-partition). `output_kinds` tags each named output's shape (`PER_NODE`, `PER_GRAPH`, `GLOBAL`) so consolidation knows whether to halo-reverse, all-reduce, or pass it through. - **Adapters** declare how a black-box op distributes. `OpAdapter(op=, arg_transforms={i: …}, output_transforms={i: …})` goes in `DistributionSpec.custom_ops`; `MethodAdapter` (swap a third-party module method) and `PythonAdapter` / `JitAdapter` (replace a helper) go in `third_party_helpers`. Transforms — `ScatterOutputs` / `GatherInputs` / `SliceOwned` / `AllReduceSum` — say what to do per arg/output. **(3) Validate.** `trace_and_validate` runs the model under a simulated 2-rank mesh, checks force-equivalence vs single-process, and — when a bare op needs an adapter — tells you which one to add: ```python from nvalchemi.distributed.validate import trace_and_validate report = trace_and_validate(MyWrapper(model), world_size=2) assert report.ok, report.next_action # e.g. "add OpAdapter(..., ScatterOutputs())" ``` **(4) Persist.** `MLIPSpec.save(path)` / `MLIPSpec.load(path)` round-trip the spec so production runs skip re-discovery. Full walkthroughs: `examples/distributed/04_byo_pytorch_mpnn.py` (autograd MPNN, no custom op) and `05_byo_graph_transformer.py` (a custom-op kernel needing an `OpAdapter`). Deep reference: the *Bring Your Own Model* user guide. --- ## 4. Bring your own dynamics under DD A custom {class}`~nvalchemi.dynamics.base.BaseDynamics` integrator (see `nvalchemi-dynamics-implementation`) runs under `DomainParallel` **unchanged as long as every operation is per-atom.** The one rule: > Under DD each rank's `pre_update` / `post_update` sees **only its owned > atoms**. Per-atom math is correct as-is; any **global** reduction is not. **Safe locally (no change needed):** position/velocity integration, per-atom Langevin friction + noise, applying forces. `NVE` and `NVTLangevin` are exact under DD for exactly this reason. Reading `batch.energy` is also fine — the forward already reduces + replicates it globally. **Must be globalized (a shard `.sum()` / `.max()` / `.dot()` is WRONG):** total kinetic energy, temperature, degrees of freedom, FIRE dot-products (`v·f`, `v·v`, `f·f`), a global convergence test, barostat pressure. For the shipped ensembles this is automatic: Nosé–Hoover, `NPT`, `NPH`, and `FIRE` declare their global quantities as intent and `DomainParallel`'s coordinator reduces them — **prefer reusing them.** `FIRE` is deprecated; it is the only relaxation optimizer that works under `DomainParallel`, because `FIRE2` does not declare its reductions yet, so use `FIRE` there until `FIRE2` gains support. For a genuinely custom global scalar, compute it in a `HookScope.GLOBAL` hook, which sees the full gathered system on every rank: ```python from nvalchemi.dynamics.base import DynamicsStage from nvalchemi.distributed import HookScope class GlobalKineticEnergyHook: stage = DynamicsStage.BEFORE_STEP scope = HookScope.GLOBAL # gather the FULL system to every rank first frequency = 1 def __init__(self, integrator): self._integrator = integrator def __call__(self, ctx, stage): b = ctx.batch # full system, identical on every rank self._integrator.global_ke = 0.5 * ( b.atomic_masses * (b.velocities**2).sum(-1) ).sum() # Register on the OUTER DomainParallel; the integrator reads self.global_ke. dynamics = DomainParallel(dynamics=my_integrator, config=cfg, n_steps=n,
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen