zTensor
.zt is a container format for tensor data. Tensors sit on page boundaries,
each one carries a digest, and a manifest describes them. The parts of the
format that are frozen are kept separate from the parts expected to grow.
The crate that reads .zt also reads .safetensors, .gguf, .npz, PyTorch
.pt, HDF5 and ONNX, projecting each into the same object model so that one
piece of code handles all of them. It writes a single format, canonical .zt.
use ztensor::{Source, Writer};
// Any supported format, detected from the file itself.
let src = ztensor_compat::open("model.safetensors")?;
for t in src.tensors() {
println!("{}: {:?} {}", t.name(), t.shape(), t.layout());
}
// Out: a canonical .zt, aligned and digest-carrying.
let mut w = Writer::create("model.zt")?;
w.ingest(&src)?;
w.finish()?;
File layout
Eight magic bytes, then the tensor blobs at aligned offsets, then the manifest, then a 40-byte footer. The footer holds the manifest's offset and length, so opening a file means seeking to the end, reading 40 bytes, and reading the manifest. That gives the byte range of every tensor without touching the weights, whether the file is 100 MB or 100 GB.
Existing formats
The formats in common use were each built around a different priority, and serving a large model off disk runs into what they set aside.
safetensors packs tensors back to back, so a tensor rarely starts on a page
boundary and cannot be mapped, registered or evicted without touching its
neighbours. GGUF encodes each quantization scheme into the container, which
makes every new scheme a format change. Reading a .pt file executes whatever
code the pickle stream contains. None of them carry per-tensor digests, so a
corrupt byte surfaces as a wrong answer.
Comparison
.zt |
.safetensors |
.gguf |
.pt (pickle) |
.npz |
.onnx |
.h5 |
|
|---|---|---|---|---|---|---|---|
| Zero-copy read | ✓ | ✓ | ✓ | ~² | ~² | ||
| Page-aligned tensors | ✓ | ||||||
| Per-tensor digest | ✓ | ||||||
| Safe (no code execution) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
| Streaming / append | ✓ | ~³ | ✓ | ||||
| Sparse tensors | ✓ | ✓ | |||||
| Per-tensor compression | ✓ | ✗¹ | ✓ | ||||
| Extensible types | ✓ | N/A | ✓ | ✓ | |||
| Byte-reproducible | ✓ |
¹ .npz compresses the zip archive, not each tensor. ² Partial: needs the
right alignment, or uncompressed data. ³ Zip append, not through the standard
API.
Blank cells are not a criticism. safetensors was built as a safe replacement
for pickle and does that job well; alignment and digests were not among its
goals. .zt was designed later, with those properties as requirements from
the start. Formats has the per-format detail.
Design choices
A canonical .zt file places every tensor on a 64 KiB boundary and records an
XXH3 digest for each tensor and for the manifest. Quantization schemes are
named by a registry of versioned profiles rather than built into the
container, so adding one is a registry entry and not a format revision. The
manifest is CBOR against a fixed schema, so reading a file decodes data and
calls nothing.
Alignment matters because an operating system maps, protects and evicts memory a page at a time. Two tensors sharing a page cannot be handled separately: mapping one maps the other, and releasing one either releases its neighbour or does nothing. Placement costs padding, and Benchmarks measures that cost against the tensor sizes where it starts to matter.
The three layers
The format separates the parts that are frozen from the parts that are expected to change.
A manifest entry gives a tensor's location as a byte range in L0, and its meaning as a profile name in L2. Adding a profile to L2 does not change L1 or L0. What separates the three is how long each is meant to last:
| Layer | Contents | Contract |
|---|---|---|
| L0: Container | Magic, 40-byte footer, aligned blob heap, byte order | Frozen |
| L1: Manifest | Deterministic-CBOR schema | Gated by the footer's version integer |
| L2: Vocabulary | Layouts, logical types, encodings, digests | Expected to change; registry-managed |
Unregistered names
zTensor checks an L2 name against its registry and passes it through without
acting on it. Working out that zt.quant_group/1 with an f4_e2m1 payload
and f8_e8m0 scales dequantizes to bf16 belongs to the framework.
An unregistered name is an error. Guessing would mean decoding under the wrong scheme, and the result of that has the right shape and dtype, so nothing downstream can tell it from the right answer.
See the format specification for the normative rules and profiles for the registry.
Sharding
A part in a manifest may carry a shard field naming another file, and a part
without one lives in the file whose manifest it is in. Sharding is that field
plus a table at the top of the manifest saying what each name refers to, which
is why a model that fits in one file contains no trace of the mechanism.
The files themselves are not special. Every one of them is an ordinary .zt
container with the same magic, the same aligned blobs, and the same 40-byte
footer. A shard differs in one respect: it has no manifest of its own (spec
§7.2), so it makes no claim about what its bytes are or where they start. The
root's manifest makes those claims for all of them.
A table row records a shard's size and digest, not a path. Finding the file is left to the reader, which can go by name, by content address, or by scanning a directory for something of that size and digest. Renaming or moving the files therefore cannot break the model, and a different file under the same name is caught at open.
Overlays
An overlay is the same mechanism pointed at a model someone else published. A LoRA registers the base model as a shard and stores only its own deltas, addressing the base weights where they already are instead of copying them.
Performance
Llama 3.2 1B shapes (~2.8 GB), Linux, i9-13900K, NVMe SSD, median of 5 runs. Benchmarks has the method, the charts and the analysis.
Reading
One mmap-backed API across every format, compared against each format's own library.
| Source format | zTensor | zTensor (zc off) | Reference impl. |
|---|---|---|---|
| .zt | 2.27 GB/s | 0.96 GB/s | n/a |
| .safetensors | 2.47 GB/s | 1.00 GB/s | 1.57 GB/s / 1.59 GB/s† (safetensors) |
| .pt | 2.29 GB/s | 0.83 GB/s | 1.60 GB/s (torch) |
| .npz | 2.33 GB/s | 0.94 GB/s | 0.80 GB/s (numpy) |
| .gguf | 2.37 GB/s | 0.92 GB/s | 1.57 GB/s / 2.52 GB/s† (gguf) |
| .onnx | 2.30 GB/s | 0.82 GB/s | 0.81 GB/s (onnx) |
| .h5 | 2.36 GB/s | 0.95 GB/s | 1.47 GB/s (h5py) |
ONNX measured at 1 GB (protobuf caps a message at 2 GB). †Native zero-copy
where available (GGUF mmap, SafeTensors safe_open).
Writing
Each format written by its own reference implementation, three workloads at 512 MB: Large (few big matrices), Mixed (realistic model shapes), Small (many 10 KB parameters).
| Format | Large | Mixed | Small |
|---|---|---|---|
| ztensor | 3.29 GB/s | 3.62 GB/s | 0.80 GB/s |
| safetensors | 5.18 GB/s | 6.27 GB/s | 2.62 GB/s |
| pickle | 5.91 GB/s | 6.03 GB/s | 2.86 GB/s |
| npz | 1.10 GB/s | 1.15 GB/s | 0.54 GB/s |
| gguf | 4.78 GB/s | 6.25 GB/s | 1.30 GB/s |
| onnx | 0.29 GB/s | 0.30 GB/s | 0.35 GB/s |
| hdf5 | 6.13 GB/s | 5.96 GB/s | 0.28 GB/s |
zTensor is slower here because a canonical write does more: it hashes every byte, pads to 64 KiB and shares blobs between identical tensors. Those costs land once, when the artifact is written, and not on the loads afterwards.
Two identities
Hashing the whole file tells you whether you have this exact artifact. The answer changes when the same tensors are re-aligned or split across files. Canonical form exists to pin that down, and it is single-file for the same reason.
The content digest is computed from the manifest alone and leaves layout out of the input. The same tensors give the same value whether they sit in one file or fifty shards, packed at 4 KiB or 64 KiB, raw or compressed. Computing it costs one manifest read, whatever the model weighs.
zt id model.zt # sha256:4811d499...
zt verify model.zt --canonical
Capabilities
A projected .npz cannot offer what a canonical .zt can, so the API reports
per part what will work:
| Operation | What it gives you | Where it works |
|---|---|---|
bytes() |
decoded bytes, saying whether they were borrowed or copied | every format |
map() |
a borrow, or an error | mapped raw parts |
locate() |
the exact range of one file, for your own I/O | raw parts |
verify() |
a digest check | .zt |
evict() |
drops these pages without touching a neighbour's | page-exclusive parts |
caps() answers all five questions at once, before you commit to a plan.
map() returns an error where it cannot borrow, so a loader that meant to be
zero-copy finds out at that call. When a foreign checkpoint does not support
what you need, ingest converts it to a .zt that does.
Testing
The parsers here read files from wherever a model came from, so the contract is explicit: hostile input yields an error, never a panic, an unbounded allocation, or a fabricated tensor. Three things hold them to it. A 76-file conformance corpus is regenerated from code and diffed in CI. Fuzz targets cover the container, the CBOR codec and all six foreign parsers. A test file holds the reproducer behind every hardening fix. Formats has the detail, and platform support says which systems this is run on.
Install
Crates
cargo add ztensor # the format
cargo add ztensor-compat # foreign-format projections
cargo install ztensor-cli # the `zt` command
pip install ztensor # Python bindings
Command line
zt ls model.gguf # inspect anything
zt convert model.gguf model.zt # canonical, verifiable output
zt verify model.zt --deep # structure + digests + shards
zt diff a.safetensors b.zt # compare across formats