Heavy trajectory infrastructure#
This page describes the internal architecture for out-of-core (chunked) trajectory processing. Read it if you want to:
add heavy-mode support to a new form,
implement a new analysis function that supports the heavy path,
write a custom
Reducer, orunderstand the execution flow for debugging.
The canonical design document is devguide/SCALABILITY.md.
Architecture overview#
The heavy path is built around three components:
ChunkedExecutor
│
├── form.StructuresIterator ← reads the trajectory in chunks
│
└── Reducer(s) ← accumulate partial results per chunk
ChunkedExecutor owns the orchestration loop. It does not contain any scientific logic. It:
estimates the memory footprint and decides eager vs. heavy;
validates that the form supports the requested attributes in heavy mode;
initialises each reducer (possibly creating a
PersistentResultHandlefor large outputs);iterates chunks from the form’s
StructuresIterator;feeds each chunk to all reducers;
saves checkpoints at configurable intervals;
emits SMonitor telemetry at each step.
Chunk processing is fail-fast. Exceptions from the iterator, chunk normalization, or a reducer propagate to the caller, and the executor does not finalize or return partial results. There is no implicit corrupt-frame skipping policy.
The Reducer ABC#
Every heavy-mode analysis function defines a private Reducer subclass.
The base class is molsysmt._private.execution.Reducer.
Mandatory interface#
from molsysmt._private.execution import Reducer
class MyReducer(Reducer):
def initialize(self, metadata: dict) -> None:
"""Called once before the chunk loop. Reset all accumulation state here."""
def consume(self, chunk: dict) -> None:
"""Called once per chunk. chunk is read-only. Copy before mutating."""
def finalize(self):
"""Called once after all chunks. Return the final result."""
metadata keys (passed to initialize)#
Key |
Type |
Description |
|---|---|---|
|
int |
atoms selected (after atom_indices filtering) |
|
int |
frames to be processed by this executor |
|
int |
total frames in the source system |
|
int |
number of chunks that will be delivered |
|
str |
name of the calling operation |
|
str |
form name string |
|
array or ‘all’ |
atom selection |
|
array or None |
frame selection |
|
PersistentResultHandle or absent |
present only when |
chunk keys (passed to consume)#
Key |
Type |
Description |
|---|---|---|
|
np.ndarray float64, nm |
shape |
|
np.ndarray float64, nm or |
shape |
|
np.ndarray float64, ps or |
shape |
|
np.ndarray int or |
global frame indices |
Optional extensions#
Override these to enable additional features:
def estimate_output_shape(self, metadata: dict):
"""
Return the shape (tuple) of the final output array.
ChunkedExecutor will call check_disk_budget(), create a PersistentResultHandle,
and pass it as metadata['output_handle'] to initialize().
Return None (default) to always accumulate in RAM.
"""
return None
def checkpoint(self) -> dict | None:
"""
Return a serializable state dict (will be pickled by ChunkedExecutor).
Return None (default) if checkpointing is not supported.
"""
return None
def restore(self, state: dict) -> None:
"""
Restore accumulated state from a checkpoint dict.
Called before the chunk loop when restore_from is set.
initialize() is NOT called when restoring — state comes from here.
Raises NotImplementedError by default.
"""
raise NotImplementedError(...)
def merge(self, other: Reducer) -> None:
"""
Merge another reducer's partial state into self (earlier frames first).
Used for parallel reduction: run N executors on N trajectory segments,
then merge in temporal order.
Raises NotImplementedError by default.
"""
raise NotImplementedError(...)
Minimal example#
import numpy as np
from molsysmt._private.execution import Reducer
class MeanCoordinatesReducer(Reducer):
"""Computes the per-atom mean position over all frames."""
def initialize(self, metadata):
self._sum = np.zeros((metadata['n_atoms'], 3), dtype=np.float64)
self._count = 0
def consume(self, chunk):
coords = np.array(chunk['coordinates'], dtype=np.float64) # writable copy
self._sum += coords.sum(axis=0)
self._count += coords.shape[0]
def finalize(self):
return self._sum / self._count
def checkpoint(self):
return {'sum': self._sum.tolist(), 'count': self._count}
def restore(self, state):
self._sum = np.array(state['sum'], dtype=np.float64)
self._count = state['count']
def merge(self, other):
self._sum += other._sum
self._count += other._count
ChunkedExecutor usage#
from molsysmt._private.execution import ChunkedExecutor
executor = ChunkedExecutor(
molecular_system=molsys,
form='molsysmt.H5MSMFileHandler',
operation='my_operation',
reducer=my_reducer, # single reducer → execute() returns one result
# reducers=[r1, r2], # multiple reducers → execute() returns list
atom_indices=atom_indices,
structure_indices=None, # None means 'all'
chunk_size=500,
heavy_mode='force', # 'auto' | 'force' | 'off'
attributes=['coordinates'], # attributes to request per chunk (see below)
# -- checkpoint / resume --
checkpoint_interval=10, # save checkpoint every 10 chunks (0 = disabled)
checkpoint_path='/tmp/ckpts/', # directory or file path
restore_from=None, # path to a .pkl checkpoint to resume from
# -- user-controlled output path --
output_path=None, # if set, PersistentResultHandle writes here (not deleted on cleanup)
)
result = executor.execute()
The attributes parameter#
attributes controls which data fields are requested from the StructuresIterator per chunk.
The default is ['coordinates']. Add 'box' when the operation needs periodic boundary
conditions (PBC), and 'time' when the reducer needs timestamps:
# Distances with PBC: need box vectors per chunk
executor = ChunkedExecutor(..., attributes=['coordinates', 'box'])
# Custom reducer that also needs frame timestamps
executor = ChunkedExecutor(..., attributes=['coordinates', 'time'])
execute() validates that every requested attribute appears as True in the form’s
_heavy_support dict before starting. If any attribute is unsupported,
UnsupportedHeavyOperationError is raised immediately.
Multi-reducer (read-once, analyze-many)#
Pass reducers=[r1, r2, ...] to run multiple analyses in a single trajectory pass.
execute() returns a list of results in the same order:
executor = ChunkedExecutor(
...,
reducers=[center_reducer, rmsd_reducer],
)
center_result, rmsd_result = executor.execute()
Checkpoint / resume#
# First run — save a checkpoint every 20 chunks
executor = ChunkedExecutor(
...,
checkpoint_interval=20,
checkpoint_path='/tmp/run1/',
)
result = executor.execute()
# If interrupted, resume from the last checkpoint
import os, glob
last_ckpt = sorted(glob.glob('/tmp/run1/*.pkl'))[-1]
executor2 = ChunkedExecutor(
...,
restore_from=last_ckpt,
)
result = executor2.execute()
The reducer’s restore() is called before the chunk loop. initialize() is skipped.
The executor automatically computes which frames were already processed and skips them.
User-controlled output path#
When output_path is set, the PersistentResultHandle created for a large-output reducer writes
to that path instead of a system temporary file. The file is not deleted by cleanup(),
giving the caller full lifecycle control:
executor = ChunkedExecutor(
...,
output_path='/scratch/distances.dat',
)
result = executor.execute()
# result is a PersistentResultHandle backed by /scratch/distances.dat
# The file persists after result.cleanup() or when the handle goes out of scope.
This is forwarded directly to PersistentResultHandle(shape, path=output_path).
Parallel reduction via merge()#
# Process two halves independently
r1 = MyReducer()
ChunkedExecutor(..., reducer=r1, structure_indices=list(range(0, 2500))).execute()
r2 = MyReducer()
ChunkedExecutor(..., reducer=r2, structure_indices=list(range(2500, 5000))).execute()
# Merge in temporal order (earlier frames first)
r1.merge(r2)
result = r1.finalize()
Adding heavy support to a form#
Two things are required:
1. Declare _heavy_support#
In the form’s __init__.py, add a module-level dict listing every attribute the form’s
StructuresIterator can deliver in a chunk:
_heavy_support = {
'coordinates': True,
'box': True, # set True only if the iterator can yield box vectors per chunk
# 'time': True, # add if time is available
}
ChunkedExecutor.execute() reads this dict before starting. If a requested attribute is not
listed as True, it raises UnsupportedHeavyOperationError.
box support matters for PBC-aware operations (e.g. get_distances(pbc=True)). All current
heavy-mode forms (file:h5msm, molsysmt.H5MSMFileHandler, file:xtc) declare both
'coordinates' and 'box' as True.
2. Implement StructuresIterator#
The form module must export a StructuresIterator class that is a context manager and a
Python iterator. Minimum signature:
class StructuresIterator:
def __init__(self, molecular_system, atom_indices='all',
structure_indices=None, chunk=1,
output_type='dictionary', skip_digestion=True,
coordinates=True, box=False, time=False, **kwargs):
...
def __iter__(self):
return self
def __next__(self) -> dict:
"""
Return one chunk as a dict with keys:
'coordinates', 'box', 'time', 'structure_id'
where 'coordinates' is a puw quantity (nm) and 'box' is nm.
Raise StopIteration when exhausted.
"""
...
def __enter__(self):
return self
def __exit__(self, *args):
# Close resources owned by this iterator.
# Do NOT close a molecular system passed in from outside.
...
ChunkedExecutor calls _build_chunk() on the raw dict returned by __next__() to
strip units and produce a canonical float64 chunk.
SMonitor event codes#
Code |
Name |
Trigger |
|---|---|---|
|
|
heavy mode activated |
|
|
footprint within budget |
|
|
after each successful chunk |
|
|
chunk I/O > 5 s |
|
|
RSS > |
|
|
form/attribute not supported |
|
|
disk budget exceeded |
All codes are registered in molsysmt/_private/smonitor/catalog.py.
ETA computation. The eta_s field in ChunkProcessed is computed via an exponential moving
average (EMA, α = 0.3) of per-chunk elapsed times, so it adapts continuously as I/O speed changes
over the run rather than relying only on the first-chunk measurement.
Memory pressure. MemoryPressureWarning is emitted after each chunk when
psutil.Process().memory_info().rss > molsysmt.configure.memory_pressure_threshold × max_ram_usage.
The check is silently skipped if psutil is not installed. Threshold is configurable:
molsysmt.configure.memory_pressure_threshold (default 0.80).
Relevant source files#
File |
Role |
|---|---|
|
Orchestration loop |
|
|
|
Footprint estimate, decide_mode, check_disk_budget |
|
|
|
|
|
|
|
|
|
Reference |
|
File-path iterator (delegates to handler, owns lifecycle) |
|
XTC iterator (delegates to |
|
All heavy-mode tests |