Skip to main content

nvalchemi-dynamics-api

How to configure and run dynamics simulations, compose multi-stage pipelines (FusedStage, DistributedPipeline), use inflight batching, and manage data sinks. Use when writing any simulation script — molecular dynamics (NVE/NVT), structure relaxation or geometry optimization (e.g. FIRE2), equation-of-state or adsorption scans — or orchestrating many structures through a batched GPU pipeline.

Source facts

Repository
NVIDIA/nvalchemi-toolkit
Last source activity
October 6, 2026 at 05:51
Detected SKILL.md language
English
Stars
202
Forks
46

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
nvalchemi-dynamics-api
description
How to configure and run dynamics simulations, compose multi-stage pipelines (FusedStage, DistributedPipeline), use inflight batching, and manage data sinks. Use when writing any simulation script — molecular dynamics (NVE/NVT), structure relaxation or geometry optimization (e.g. FIRE2), equation-of-state or adsorption scans — or orchestrating many structures through a batched GPU pipeline.
# nvalchemi Dynamics API ## Overview The dynamics API provides tools to discover available dynamics classes, configure them, and scale simulations up (single GPU) and out (multi-rank pipelines). ```python from nvalchemi.dynamics import ( BaseDynamics, DemoDynamics, FusedStage, DistributedPipeline, ConvergenceHook, Hook, DynamicsStage, SizeAwareSampler, DataSink, GPUBuffer, HostMemory, ZarrData, hooks, ) ``` --- ## Available dynamics classes | Class | Description | |-------|-------------| | `BaseDynamics` | Abstract base — subclass to create integrators | | `DemoDynamics` | Velocity Verlet reference implementation (testing only) | To find all dynamics classes in a codebase, search for subclasses of `BaseDynamics`. --- ## Configuring a dynamics run ### Basic setup ```python from nvalchemi.dynamics import DemoDynamics, ConvergenceHook from nvalchemi.models.demo import DemoModelWrapper from nvalchemi.data import AtomicData, Batch import torch model = DemoModelWrapper() dynamics = DemoDynamics( model=model, n_steps=1000, # total steps for run() dt=0.5, # timestep (fs) ) # Create batch with required state data = AtomicData( atomic_numbers=torch.tensor([6, 8, 1], dtype=torch.long), positions=torch.randn(3, 3), ) batch = Batch.from_data_list([data]) batch.forces = torch.zeros(3, 3) batch.energy = torch.zeros(1, 1) result = dynamics.run(batch) ``` ### With convergence detection ```python dynamics = DemoDynamics( model=model, n_steps=10000, dt=0.5, convergence_hook=ConvergenceHook( criteria=[ {"key": "fmax", "threshold": 0.05, "reduce_op": "max"}, {"key": "energy_change", "threshold": 1e-6}, ], frequency=1, ), ) ``` Shorthand for force-based convergence: ```python dynamics = DemoDynamics( model=model, n_steps=10000, dt=0.5, convergence_hook=ConvergenceHook.from_fmax(threshold=0.05), ) ``` ### With hooks ```python from nvalchemi.dynamics.hooks import MaxForceClampHook, LoggingHook dynamics = DemoDynamics( model=model, n_steps=1000, dt=0.5, hooks=[ MaxForceClampHook(max_force=10.0), LoggingHook(backend="csv", log_path="md_log.csv", frequency=100), ], ) ``` --- ## Scaling up: FusedStage (single GPU, multiple stages) `FusedStage` composes multiple dynamics stages on one GPU with a **single shared model forward pass per step**. Samples migrate between stages via convergence. ### Composition with `+` operator ```python # Stage 0: geometry optimization opt = DemoDynamics( model=model, n_steps=100, dt=1.0, convergence_hook=ConvergenceHook.from_fmax(0.05), ) # Stage 1: production MD md = DemoDynamics(model=model, n_steps=500, dt=0.5) # Compose — auto-registers convergence hook to migrate status 0 → 1 fused = opt + md result = fused.run(batch) # runs until all samples reach exit_status ``` ### Constructor (explicit) ```python fused = FusedStage( sub_stages=[(0, opt), (1, md)], entry_status=0, # initial sample status exit_status=2, # auto-set to len(sub_stages) if -1 compile_step=False, # enable torch.compile compile_kwargs=None, # kwargs for torch.compile reprime_on_entry=None, # status codes needing a fresh force compute on entry ) ``` If a sub-stage needs freshly computed forces before it starts integrating (e.g. its `AFTER_COMPUTE` hooks differ from the stage the sample came from), pass its status code in `reprime_on_entry`. Newly entering samples skip one `pre_update`/`post_update` cycle while the shared compute and that stage's `AFTER_COMPUTE` hooks refresh their forces, then integrate normally the following iteration. This is separate from the initial batch-wide force prime and does not affect samples already in that status. ### With torch.compile ```python fused = (opt + md) fused.compile(fullgraph=True, mode="reduce-overhead") with fused: # lazy compilation on context entry result = fused.run(batch) ``` ### CUDA streams: `with` vs bare `run()` Prefer to use context manager: ```python with dynamics: dynamics.run(batch) ``` As best pattern, even without explicit CUDA stream usage, as it in turn supports `compile(mode="reduce-overhead")` Calling `dynamics.run(batch)` without context manager will reuse the current stream. ### How it works 1. Each sample has a `status` field (integer) 2. Each sub-stage processes only samples matching its status code 3. When a sub-stage's `ConvergenceHook` fires, converged samples' status increments 4. Samples at `exit_status` are graduated (no longer updated) 5. `run()` loops until all samples reach `exit_status` or sampler is exhausted ### Chaining more stages ```python fused_3 = opt + md + analysis # 3 sub-stages: status 0, 1, 2 fused_3 = fused + extra_stage # append to existing FusedStage ``` --- ## Scaling out: DistributedPipeline (multi-rank) `DistributedPipeline` chains dynamics stages across multiple ranks using `torch.distributed`. Each rank runs one stage; converged samples are sent to the next rank. This is pipeline parallelism for dynamics, distinct from data-parallel multi-GPU *training* (DDP). ### Composition with `|` operator ```python opt_stage = DemoDynamics(model=model, n_steps=100, dt=1.0) # rank 0 md_stage = DemoDynamics(model=model, n_steps=500, dt=0.5) # rank 1 pipeline = opt_stage | md_stage with pipeline: # initializes torch.distributed + setup pipeline.run() ``` ### Constructor (explicit) ```python pipeline = DistributedPipeline( stages={0: opt_stage, 1: md_stage}, synchronized=False, # True for debugging (adds barriers) ) ``` ### Communication modes Control how inter-rank buffers synchronize: ```python stage = DemoDynamics( model=model, n_steps=100, dt=0.5, comm_mode="async_recv", # default: deferred blocking # comm_mode="sync", # immediate blocking (debugging) # comm_mode="fully_async", # maximum overlap ) ``` The default `comm_mode` is `"async_recv"`. The three modes differ in when blocking occurs: - `"sync"`: `irecv` completes inline in `_prestep_sync_buffers`; simplest and good for debugging. - `"async_recv"`: `irecv` is posted in `_prestep_sync_buffers`, but `wait()` is deferred to `_complete_pending_recv` for communication overlap. - `"fully_async"`: send and receive are both deferred for maximum overlap. Pending sends from the prior step are drained at the start of the next `_prestep_sync_buffers`. ### Pre-allocated buffers For high-throughput pipelines, pre-allocate send/recv buffers: ```python from nvalchemi.dynamics.base import BufferConfig buffer_cfg = BufferConfig( num_systems=100, # max graphs num_nodes=5000, # total node capacity num_edges=20000, # total edge capacity ) stage = DemoDynamics( model=model, n_steps=100, dt=0.5, buffer_config=buffer_cfg, ) ``` Buffers are **lazily initialized** on the first step using the first concrete batch as a template for attribute keys, dtypes, and shapes. This means the first step has slightly more overhead. Adjacent stages must use identical `BufferConfig` values. This is validated in `DistributedPipeline.setup()`. --- ## Buffer semantics and communication ### Three buffer layers The dynamics framework manages data flow through three layers: | Layer | Location | Purpose | |-------|----------|---------| | **Active batch** | `_CommunicationMixin.active_batch` | Working set being integrated | | **Communication buffers** | `send_buffer` / `recv_buffer` | Pre-allocated `Batch.empty()` for zero-copy inter-rank transfer | | **Overflow sinks** | `DataSink` list (priority-ordered) | Staging when active batch is full | ### Communication protocol (DistributedPipeline) Each pipeline step follows a four-phase protocol: 1. `_prestep_sync_buffers()` zeros the send buffer and posts `irecv` from the prior rank. 2. `_complete_pending_recv()` waits on deferred receive, routes into the active batch, and drains overflow sinks. 3. `step()` runs dynamics integration. 4. `_poststep_sync_buffers(converged_indices)` extracts converged samples into the send buffer and sends them to the next rank. **Deadlock prevention:** when no samples converge, an empty send buffer is still sent so the downstream `irecv` completes. ### Back-pressure When `send_buffer` has limited capacity (via `BufferConfig`): - Only `min(converged_count, remaining_capacity)` samples are extracted - Excess converged samples remain in the active batch as **no-ops**. Their positions and velocities are saved before the integrator and restored after it runs. - Without `BufferConfig`, all converged samples are sent without constraints (backward compatible). ### Buffer lifecycle: put/defrag/zero ```python # Pre-allocated buffer created via Batch.empty() buffer = Batch.empty(num_systems=100, num_nodes=5000, num_edges=20000, template=batch) # Copy selected graphs into buffer (Warp GPU kernels, float32 only) mask = converged_mask # bool tensor, True = copy this graph buffer.put(src_batch, mask) # Remove copied graphs from source in-place src_batch.defrag() # Reset buffer for reuse (preserves allocated memory) buffer.zero() ``` **Important:** `Batch.put()` uses Warp GPU kernels that only handle float32 attributes. Adjacent pipeline stages must have identical `BufferConfig` values. ### Data routing methods | Method | Purpose | |--------|---------| | `_recv_to_batch(incoming)` | Route received data through recv buffer into active batch | | `_buffer_to_batch(incoming)` | Append to active batch, overflow to sinks if full | | `_batch_to_buffer(mask)` | Copy graduated samples into send buffer, defrag active batch | | `_overflow_to_sinks(batch)` | Write to first non-full sink in priority order | | `_drain_sinks_to_batch()` | Pull from sinks back into active batch when room available | --- ## Inflight batching with SizeAwareSampler For streaming workflows, `SizeAwareSampler` manages dataset access with bin-packing for size-matched batching. As samples converge and leave the batch, new samples are pulled from the dataset. ```python from nvalchemi.dynamics import SizeAwareSampler sampler = SizeAwareSampler( dataset=dataset, # must have __len__, __getitem__, get_metadata max_atoms=1000, # max total atoms per batch (None = auto from GPU) max_edges=5000, # max total edges per batch max_batch_size=32, # max graphs per batch bin_width=10, # group samples by atom count bins shuffle=False, max_gpu_memory_fraction=0.8, # for auto max_atoms estimation ) # Build initial batch via greedy bin-packing batch = sampler.build_initial_batch() # Request a replacement sample that fits constraints replacement = sampler.request_replacement(num_atoms=50, num_edges=200) # Check if all samples consumed sampler.exhausted # bool ``` ### How inflight replacement works (`_refill_check`) When `refill_frequency` triggers (every N steps), `_refill_check()`: 1. Identifies graduated graphs (`status >= exit_status`) 2. Writes graduated graphs to sinks 3. Extracts remaining graphs via `Batch.index_select` 4. Requests replacements from sampler (one per graduated slot, matching atom/edge budget) 5. Appends replacements via `Batch.append` 6. Rebuilds `status` (replacements get `0`) and `fmax` (replacements get `inf`) tensors This produces a **new** `Batch` object, not an in-place mutation. It returns `None` when the sampler is exhausted and no active samples remain. ### With FusedStage ```python opt = DemoDynamics( model=model, n_steps=100, dt=1.0, sampler=sampler, refill_frequency=1, # check for replacements every N steps convergence_hook=ConvergenceHook.from_fmax(0.05), ) md = DemoDynamics(model=model, n_steps=500, dt=0.5) fused = opt + md with fused: result = fused.run() # no batch arg — built from sampler ``` ### With DistributedPipeline (first stage only) ```python first_stage = DemoDynamics( model=model, n_steps=100, dt=1.0, sampler=sampler, refill_frequency=1, ) pipeline = first_stage | second_stage with pipeline: pipeline.run() # first stage auto-refills from sampler ``` --- ## Data sinks Sinks store graduated/snapshot data. Used by `SnapshotHook` and the communication layer. ```python from nvalchemi.dynamics import GPUBuffer, HostMemory, ZarrData ``` ### DataSink interface ```python class DataSink(ABC): def write(self, batch: Batch, mask: Tensor | None = None) -> None: ... def read(self) -> Batch: ... def zero(self) -> None: ... # clear contents def drain(self) -> Batch: ... # read() + zero() def is_full(self) -> bool: ... # len(self) >= capacity def __len__(self) -> int: ... @property def capacity(self) -> int: ... ``` ### Implementations
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub