fix(storage): cache open HDF5 read handles for multi-bag bag files#786
Open
pmrv wants to merge 1 commit into
Open
fix(storage): cache open HDF5 read handles for multi-bag bag files#786pmrv wants to merge 1 commit into
pmrv wants to merge 1 commit into
Conversation
The multi-bag layout introduced in PR #746 made every contains() and list() pay a full HDF5 file open/close (~120 us), regressing contains 8.6x against the per-key layout (240 us vs 28 us, #625). Add a process-wide cache of open read-only h5py handles, keyed by absolute path: a WeakValueDictionary index plus a bounded strong-ref MRU that keeps the most recently used handles alive between operations. Cached handles are validated against an (inode, mtime_ns, size) stat signature on every acquisition, so bags rewritten by other processes or other storage instances are detected and reopened. Handles are opened with locking=False so a long-lived cached reader cannot make writes from other processes fail. Within a process, HDF5 rejects any open whose mode or locking flags differ from a live handle's, so every operation that opens the bag file itself - put, get, evict, and rebag - closes the cached handle first and holds a per-bag in-process lock across the operation; readers hold the same lock while using the handle, so a writer can never close it mid-read. The cache is process-wide rather than per-instance because a handle cached by one instance must be closable by any other instance addressing the same file. contains(hit) drops from 240 us back to the pre-regression ~29 us. evict is unchanged: it genuinely writes, and the filelock acquisition plus HDF5 write open/close are structural for a durable per-key delete inside a shared file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DWRGyuEoH3qXNSQbSXddp
Contributor
Benchmark ResultsBaseline: 071d395 (cached) DeltaSignificant changes (|Δ| > 10%): 161 — 79 other rows hiddenValue Storage
Integration
Call Storage
Full resultsCall Storagecalls
Digest
Integrationcompute_heavy
data_heavy
lightweight
Value Storagenested_structures
numpy_arrays
small_strings
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the
BagOfHoldingH5Filemulti-bag regression flagged in #625: since PR #746, everycontains()(and each bag inlist()) paid a full HDF5 file open/close, ~8.6× slower than the per-key layout (240 µs vs 28 µs).What changed
bagofholding_file.pygains a process-wide cache of open read-onlyh5py.Filehandles (_BagHandleCache), keyed by absolute bag-file path:WeakValueDictionaryindexes the open handles; a bounded strong-ref MRU (_MAX_OPEN_BAGS = 32) keeps the most recently used ones alive between operations. A handle evicted from the MRU is never closed eagerly — the strong reference is dropped and the interpreter closes the file once the last in-flight user releases it.(inode, mtime_ns, size)stat signature, so bags rewritten by other processes or other storage instances are detected and reopened. One stat (~1 µs) replaces one HDF5 open (~120 µs).locking=False: a long-lived reader holding HDF5's shared OS lock would otherwise make every write from another process fail for as long as it lives. Cross-process write mutual exclusion was always provided by thefilelocksidecar locks, not by HDF5's locking, so nothing is lost.put/_to_file,get/_from_file,_evict,rebag(H5Bag uses default flags) — runs inside_bag_writer(key): it closes the cached handle and holds a per-bag in-process lock across the operation. Readers (_contains,list) hold the same lock while using the handle, so a writer can never close a handle mid-read.WeakKeyDictionarypattern used for lock tables would leave two non-equal instances on the same root able to brick each other's writes.locking=Falsefalls back to a default-flag open on h5py < 3.5 / HDF5 < 1.12.1.Numbers
contains(hit)contains(miss)evictevictis deliberately untouched: it genuinely mutates the bag, so it must pay thefilelockacquisition (~100 µs) plus an HDF5 write open/del/close (~130 µs+). That cost is structural for a durable per-key delete inside a shared file; caching write handles across operations would hold HDF5's exclusive state hostage against every other process. If evict throughput ever matters, the realistic lever is batching (one write open per bag for N evictions, e.g. aevict_manyon the storage or transaction-style deferral), not handle caching.list()— remaining cost and optionslist()now reuses cached handles, so a warm repeatedlist()costs ~0 opens for up to_MAX_OPEN_BAGSbags. But a coldlist()over a root with many bags still opens every file once, and roots with more than 32 bags will churn the MRU. Options, roughly in order of effort:list()on a defaultprefix_length=2root is up to 256 opens (~30 ms worst case).list()on an unchanged root free even beyond the MRU bound (key lists survive handle eviction). Doesn't help the truly cold first call.list()one file read, but adds cost and a consistency obligation to every write, plus a repair story for crashes between bag write and index write. This is the only option that fixes the cold path, and the only one with real correctness surface.prefix_length=0already gives O(readdir)list()today for workloads that are list-heavy and don't hit the many-small-files problem multi-bagging exists to solve — worth remembering before building 3.Happy to follow up with 2 (small) or 3 (needs design agreement) in a separate PR.
Behavioral notes
h5py.File(path)(default flags) in the same process while a cached handle is live will now get the flag-mismatchOSError; go through the storage API, or open withlocking=False.st_mtime_ns: on filesystems with coarse mtime granularity, an external rewrite that changes neither size nor inode within the same timestamp tick could go briefly unnoticed bycontains. Same-process writes are exact (explicit invalidation); cross-process reads were already best-effort._evictitself closes the handle under the bag lock before unlinking, so the backend's own deletes are safe; CI is Linux-only).Testing
contains/list(open counting), invalidation on sibling-keyput,getafter a warmcontains(the flag-mismatch trap), evict with a warm cache including file removal, writes from a second non-equal instance on the same root, external-process writes being neither blocked nor masked (subprocess test — also pinslocking=False), and the MRU bound.ty check src/clean.🤖 Generated with Claude Code
https://claude.ai/code/session_014DWRGyuEoH3qXNSQbSXddp
Generated by Claude Code