[Feature] Single-file zip archives for memory-mapped TensorDicts - #1735
Merged
Conversation
Teach the memmap save/load path to target a single-file archive: a standard zip whose entries mirror the memmap directory layout, with tensor payloads stored uncompressed and 64-byte aligned so the archive can be memory-mapped once and every leaf exposed as a zero-copy view. - save()/dumps()/memmap() write an archive when the prefix ends in .tdz or archive=True is passed; optional per-entry compression. - load_memmap() detects archive files, mmaps them and traverses the entry tree through a Path-like shim so all existing loaders (TensorDict, lazy stacks, tensorclass, non-tensor data, njt) work unchanged; new subpath= argument loads a nested subtree only, for directories and archives alike. - pack_memmap()/unpack_memmap()/is_memmap_archive() convert between the two representations; plain unzip/zip -0 work as well. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
load_memmap(path, mode="r+") maps the archive MAP_SHARED so in-place writes to the leaves propagate to the file, restoring directory semantics for a single file. r+ refuses layouts that cannot write through (compressed, misaligned or nested-tensor entries) instead of silently diverting writes to copies. refresh_archive_checksums() re-stamps the per-entry CRC-32s after in-place writes so modified archives keep working with zip tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Barplots comparing memmap directories, consolidated files and zip archives on save, lazy open, full read and artifact copy across three layouts, added to the saving docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Archive saving no longer copies the data twice: memmap_like stages a metadata-only directory (meta.json files plus empty sparse payloads, zero data I/O) that yields the exact archive layout, and tensor bytes are streamed straight from the source tensordict into the zip. Archive saves now match directory saves for large leaves (137ms vs 689ms per GB before). Nested-tensor tensordicts fall back to full staging. memmap_like accepts a .tdz target (or archive=True) and creates a preallocated zero-filled archive returned loaded with mode="r+", i.e. a single-file writable dataset buffer; the expanded-tensordict preallocation idiom works unchanged. Also ships benchmarks/scripts/serialization_formats_bench.py, which reproduces the format-comparison sweep and the docs figure, and refreshes the figure and PR-facing numbers with the new writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The serialization benchmark now covers torch.save and safetensors next to the three tensordict representations, both measured on their fastest paths: flat tensor dicts (dotted keys), loaded with torch.load(mmap=True, weights_only=True) and mmap-backed load_file respectively, with the nested structure rebuilt on load. The docs figure is regenerated with the five formats and the saving docs now state the exact command to reproduce it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--num-threads adds a multithreaded save measurement for the formats that support it (memmap directory, consolidated, archive). Document in memmap() that the archive payload is written sequentially and num_threads only parallelizes the metadata-only staging step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
load_memmap(path, num_threads=N) inflates the deflate entries of a compressed archive in parallel (pread at the known payload offsets + raw zlib inflate, which releases the GIL), scoped to the requested subpath. Measured ~4x end-to-end at 8 threads (512MB, 2000 leaves: 952ms -> 239ms). Uncompressed archives and directories ignore the argument; other compression methods keep the sequential path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The _SubTensorDict memmap round-trip fix on main makes the archive round trip work for sub-tensordicts as well; drop the skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The direct archive writer paired source tensors with staged entries by iteration order, but memmap_like with num_threads>1 inserts staged leaves in thread completion order, scrambling the pairing and writing tensor bytes under the wrong entry names. Stage sequentially (the staging is metadata-only, threads add nothing there) and add a size-consistency check in the packer so any future pairing mismatch fails loudly at write time instead of corrupting the archive. Add a threaded-save regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the static memmap_formats_benchmark.png with a second figure in the serialization_speed sphinx-gallery tutorial: the on-disk formats (memmap directory, consolidated file, tdz archive, torch.save, safetensors) compared on save/open/copy, 32 repetitions, median bars with interquartile-range error bars, rendered on the machine building the docs. Bump the threading figure to 32 repetitions as well and add safetensors to the docs requirements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At doc-build payload sizes, in-memory consolidation only benefits from num_threads on large leaves; with many small leaves the per-chunk overhead outweighs the gain. Phrase the tutorial commentary accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Description
This PR upgrades the memory-mapped serialization story so that a saved TensorDict can travel as a single file instead of a tree of small files -- without introducing a new format. A memmap archive is a standard zip whose entries mirror the memmap directory layout byte-for-byte (
meta.jsonper level, one*.memmappayload per leaf). It is not a third serialization scheme next to memmap directories and consolidation: it is the memmap directory format, in one file.unzip archive.tdzyields a directory thatload_memmap()accepts today, andzip -0 -rover an existing memmap directory yields a loadable archive.Motivation
A memmap directory is ideal to work with locally (per-leaf files, in-place writable, partially loadable), but shipping it means copying thousands of small files -- slow on NFS/S3 and easy to corrupt with a partial copy. The consolidated format is a single file but is opaque and disconnected from the memmap tooling. This PR keeps the directory semantics and makes the copyable artifact one file.
The design follows the same playbook as
torch.save(uncompressed zip64, aligned records,mmap=Trueloading) and HuggingFace safetensors (parse small metadata, mmap the file, expose tensors as offset views). Compared to safetensors, the archive natively supports nesting, lazy stacks, tensorclasses, non-tensor data and nested tensors, because the payload schema is the existing memmap one.How it works
Write path (
save/dumps/memmapwith a.tdzsuffix orarchive=True):Tensor data is written exactly once (single pass, plus a cheap CRC-32), so archive saving is on par with a directory save for large leaves (see benchmarks). The staging directory only ever holds metadata and sparse files. Tensordicts containing nested tensors fall back to full staging (
memmap_likedoes not support them).Alignment is achieved with a private zip "extra" block, so the file remains a perfectly standard zip.
compression="deflate"|"bzip2"|"lzma"is available as an explicit opt-in for archival storage (uint8 images, masks and index tensors compress well; float weights barely do). Compressed archives load withload_memmap(path, num_threads=N)inflating the deflate entries in parallel (pread at known offsets + raw zlib, GIL released): ~4x end-to-end at 8 threads (512MB / 2000 leaves: 952ms -> 239ms).Read path (
load_memmapon a file):The key implementation move is the
_ArchivePathshim: it implements the smallPathsurface the existing loaders use (/,exists,iterdir,is_dir,open,with_suffix), so the recursive_load_memmapimplementations ofTensorDict,LazyStackedTensorDictand tensorclasses traverse an archive exactly as they traverse a directory -- no per-class loader duplication. Only the leaf materialization dispatches: real files still produceMemoryMappedTensor.from_filename, archive entries produce views into the shared mapping (the same mechanismfrom_consolidateduses).Loading is lazy twice over: only the requested
subpathsubtree is parsed, and below that, mmap only faults in the pages of leaves that are actually accessed.device="meta"works unchanged. Misaligned or compressed entries (e.g. foreignzip -rarchives) transparently fall back to a copying read instead of a view.Partial loading.
subpathaccepts aNestedKey(arbitrary nesting normalized as usual) or a"/"-separated string path; it resolves the nestedmeta.jsoninside the archive and loads only that subtree. The same argument works for directory prefixes.Write-through loading. By default an archive-loaded tensordict behaves like
from_consolidated(): leaves are copy-on-write views and in-place writes stay in memory.mode="r+"maps the file shared instead, restoring directory semantics (t.add_(1)reaches the file). Because zip stores a CRC-32 per entry, in-place writes leave checksums stale:load_memmapnever verifies them, andrefresh_archive_checksums(path)re-stamps them (local headers, central directory, data descriptors) before the archive is handed to tools that do verify (unzip,unpack_memmap).mode="r+"refuses layouts where write-through is impossible -- compressed, misaligned (foreign zips) or nested-tensor entries -- rather than silently diverting some writes to copies.Preallocated writable archives.
memmap_likeaccepts a.tdztarget (orarchive=True) and creates a zero-filled archive, returned loaded withmode="r+": a single-file dataset buffer. The expanded-tensordict preallocation idiom works unchanged (datum.expand(1_000_000).memmap_like("data.tdz")) since the writer streams from the staging layout, not from materialized data.Semantics and limitations
mode="r+"(or through amemmap_likebuffer), but no appending, reshaping or dtype changes after the fact (inherent to any single-file design).mode="r+"/memmap_likearchives.Tests, docs, benchmarks
test/tensordict/test_methods.py: round-trip across allTestTensorDictsfixtures, plus aTestMemmapArchiveclass covering payload alignment, zero-copy/single-storage, copy-on-write vsmode="r+"write-through, checksum refresh,memmap_likearchives (incl. expanded/stride-0 sources), foreign unaligned zips, pack/unpack round trip, compression, subpath (dir and archive, string and nested-key forms), meta-device loading, nested tensors,load_memmap_, pickling and error paths.docs/source/saving.rstgains a "Single-file memmap archives" section with a benchmark figure;pack_memmap/unpack_memmap/is_memmap_archive/refresh_archive_checksumsare referenced indocs/source/reference/td.rst.benchmarks/common/memmap_benchmarks_test.pygains archive save/load benchmarks;benchmarks/scripts/serialization_formats_bench.pyreproduces the format-comparison sweep and the docs figure.Found in passing (pre-existing, not addressed here):
_SubTensorDictmemmap round trips are lossy for directories and archives alike (wrong batch size, broken idx on reload), andload_memmap's cross-class dispatch drops thedeviceargument.Performance
Measured on an Apple-silicon laptop (APFS SSD, warm page cache, min over reps;
flat= 8 large leaves,small= 2000 leaves in 100 nested groups).torch.saveand safetensors are measured on their fastest paths -- flat tensor dicts (dotted keys), loaded withtorch.load(mmap=True, weights_only=True)/ mmap-backedload_file, nesting rebuilt on load. Reproduce withpython benchmarks/scripts/serialization_formats_bench.py --plot fig.png.Takeaways: opening is lazy and size-independent for every format and scales with entry count -- the archive sits between the directory (one file open per leaf) and the flat single-blob formats (one metadata parse). Bulk read throughput is bandwidth-bound and near-identical everywhere. Archive saving matches a directory save for large leaves; many-small layouts pay a per-entry zip overhead.
torch.saveis the slowest writer at scale (~5x). safetensors is the fastest flat-tensor format, as expected from its minimalism (no structure, no checksums, no writability) -- what tdz buys for its overhead is native nesting/lazy stacks/tensorclass/non-tensor/NJT support, zip-standard tooling, directory interconvertibility, subpath loading and in-place writability. Copying is where all single-file formats beat the directory (13x on the many-small layout locally, more on network filesystems). Multithreaded saves (num_threads, measurable with--num-threads) speed up directory saves of large flat leaves (~1.6x at 8 threads here) but not archives: the zip payload is written sequentially andnum_threadsonly parallelizes the metadata-only staging. A parallel archive writer (precomputed STORED offsets + pwrite) is possible if GPU-resident or network-filesystem saves ever need it.🤖 Generated with Claude Code