Handles¶
Every read or write inside the engine goes through h5py.File, and every
h5py.File open sits behind the MPILock. Opening the file is the
most expensive operation an engine method can do. This page explains the
discipline that keeps it cheap: open one handle, then reuse it for as long as
possible.
The handles_handles decorator¶
Any method on a Resource that needs an HDF5 handle is decorated and
takes a handle keyword argument:
@handles_handles("r")
def load_morphologies(self, handle=HANDLED):
...
On each call the decorator resolves a handle in this order:
Explicit handle. If the caller passed a non-None
handle=, the body runs with that handle. No lock, no open.Ambient handle. Otherwise it checks the per-engine handle
ContextVar, set by an enclosingread_scope()/write_scope()block or by any outer@handles_handlescall that already opened one. If a compatible handle is open it is reused. (A write handle satisfies both read and write requests; a read handle satisfies only reads.)Fresh handle. Otherwise it acquires the matching mpilock (
r-> read lock,a-> write lock), opens the file, registers the handle on the ContextVar so nested decorated calls inherit it, runs the body, then tears down.
Step 2 is the whole performance story: nested decorated calls reuse the open handle automatically, with no manual plumbing.
Automatic reuse, and the explicit override¶
Because step 3 registers the handle on the ContextVar, a decorated method that calls other decorated methods on the same engine shares its handle with them for free:
@handles_handles("r")
def load_morphologies(self, handle=HANDLED):
# `_get_morphology_loaders` is itself @handles_handles("r"); it finds the
# ambient handle on the ContextVar and reuses it. No lock, no open.
loaders = self._get_morphology_loaders()
...
Passing handle=handle explicitly still works and takes precedence over the
ambient lookup. Use it when you hold a handle that is not on the ContextVar
(for example one received as an argument), or to be explicit at a hot call site.
A non-decorated helper that needs to take part in reuse (for example a mixin
like ChunkLoader.get_loaded_chunks) accepts a handle= keyword and
passes it on to the decorated calls it makes.
The ContextVar propagates across threads and asyncio tasks that go through
contextvars.copy_context(), including the path the BSB job pool takes
(Job.run uses ctx.run on the worker thread). It does not
propagate across MPI ranks: each rank has its own handle and its own lock.
Batching with read_scope and write_scope¶
The ambient lookup only helps once a handle is open. To batch many top-level decorated calls behind a single lock + open, wrap them in a scope:
with engine.read_scope():
for chunk in chunks:
ps.load_positions() # every call reuses one handle
cs.get_chunk_stats()
with engine.write_scope():
for chunk, data in batch:
ps.append_data(chunk, data)
read_scope() opens one read handle and holds it for
the block; write_scope() opens one write handle.
Every decorated call inside reuses it. Storage exposes the
same two methods, which delegate to the engine, so callers above the engine
layer use storage.read_scope() without holding an engine reference.
Without a scope, a loop of N top-level reads pays N lock acquires and N file opens. Inside a read scope it pays one of each.
PromotedHandleWarning¶
A write operation (@handles_handles("a")) called inside a read scope is
legal: mpilock promotes the held read lock to a write for that one call, then
returns to the read state. The promotion is safe but briefly serializes every
reader and writer across the cluster, so it is usually a refactor target (move
the write outside the read scope). The engine emits
PromotedHandleWarning to flag it. If the write is
genuinely small, one-off, and cannot be moved, pass promote_from_read=True
at the call site to silence the warning.
UnusedWriteScopeWarning¶
A write_scope() block that exits without any
@handles_handles("a") operation running inside held the cluster-wide write
lock for nothing, blocking other writers. The engine emits
UnusedWriteScopeWarning on exit. The fix is to use
read_scope() if the block only reads, or to drop the
scope and let individual decorated calls open their own short-lived handles.
The cost of not batching¶
Each redundant open costs:
one MPI-collective lock acquire (microseconds in isolation, much more under reader contention from other ranks),
one
h5py.File()construction (slow on Lustre and other network filesystems),one
hdf5.file.openOTel span if the SDK is active.
On a typical HPC run, an unbatched loop that visits N morphologies turns into ~N * 200 ms of pure overhead, with no signal in the work itself, and the cost grows with cluster size because every other rank’s lock acquires queue behind ours.