Guide
Modules
The crate's modules are the spec's layers, so a section of the specification and the code that implements it have the same address:
| Module | What is in it |
|---|---|
ztensor::format |
L0 and L1, frozen: the magic, the footer, the alignment floor, the manifest schema, its CBOR mapping, and the rules that decide conformance and canonical form. Nothing here opens a file. |
ztensor::vocab |
L2, open: layouts, logical types and encodings, which another crate can extend. |
ztensor::read |
Opening .zt and getting at bytes. |
ztensor::write |
Producing .zt. |
ztensor::provide |
For a crate projecting a foreign format into a Source. Reading a checkpoint needs nothing from it. |
The crate root re-exports the names a consumer actually uses, and only those:
Source, Tensor, Part, Writer, DType and about a dozen more. The
format constants, the shard resolvers, the free functions that take a path
instead of a source (read::shard_identity, read::manifest_of,
read::canonical_violations) and the projection machinery keep their module
paths, so what you see first is what you are likely to want.
Reading
open detects the format and returns a [Source]. The same type comes back
for a .zt file, a sharded model, a foreign checkpoint, or a snapshot spread
over several files.
let src = ztensor_compat::open("model.gguf")?;
for t in src.tensors() {
println!("{}: {:?} {}", t.name(), t.shape(), t.layout());
}
let t = src.tensor("blk.0.attn_q.weight")?;
let data = t.data()?; // the part that holds the bytes
let bytes = data.bytes()?; // the best available; says which it gave
let view = data.map()?; // a borrow, or an error
let at = data.locate()?; // (store, offset, len) for your own read
Parts and bytes
A dense tensor keeps all of its bytes in one part, "data". A quantized one
spreads them over that part and its scales. data() is a step rather than a
shorthand because which part is being addressed is something a caller has to
keep track of, and t.parts() lists the rest.
verify() is the one byte-level operation that stays on the tensor, because
it covers every part. A quantized tensor whose scales had rotted should not
pass on the strength of its payload alone.
Three byte accessors
There are three methods because callers want three different things.
bytes() is for code that just wants the data and does not want to write the
branch; it hands back a Cow, borrowed when the file allowed it. map() is
for code that needs a borrow and should fail if it cannot have one, so it
errors rather than copying. locate() is for code doing its own I/O with
io_uring, cuFile or a staged host-to-device copy, which needs the address
rather than the bytes.
Capabilities
Ask what a part supports before relying on it:
let caps = src.tensor("blk.0.attn_q.weight")?.data()?.caps();
if caps.evict {
// its pages are its own: releasing them cannot disturb a neighbour
}
Each Caps field is named after the method it gates, and is computed from
that method's own precondition. The report and the behaviour therefore stay
in sync.
Metadata only
When you only need the metadata, for example in a planner deciding what to load, open the file without mapping it:
let src = ztensor::Source::options().map(false).open("model.zt")?;
let at = src.tensor("blk.0.attn_q.weight")?.data()?.locate()?;
Writing
Only .zt is written, and canonical form is the default: 64 KiB placement,
digests on every part, byte-identical parts sharing one blob.
use ztensor::{DType, Writer};
let mut w = Writer::create("model.zt")?;
// canonical form requires ascending name order
w.add("a.weight", [4096u64, 4096], DType::BF16, &weights)?;
w.add("b.bias", [4096u64], DType::F32, &bias)?;
w.finish()?;
Two canonical writes of the same tensors produce byte-identical files, so a canonical file's hash is a stable identity for that artifact. For an identity that survives being re-aligned or split, see the content digest below.
Multi-part objects
add is a shorthand for object, which builds everything else: several
parts, a layout profile, or attributes.
w.object("q", |o| {
o.shape([4096u64, 4096])
.layout("zt.quant_group/1")
.attr("group_size", 128u64)
.part("data", |p| p.dtype(DType::U8).bytes(&payload))
.part("scales", |p| p.dtype(DType::U8).logical("f8_e8m0").bytes(&scales))
})?;
Each part is described inside its own scope, so .dtype() before there is a
part to apply it to is a compile error and not something found out when the
object is added. The cost is that a description borrows its
bytes, so a payload built inline (.bytes(&encode(x))) has to be bound to a
local first.
Leaving canonical form
For files that are not for distribution, turn it off to insert in any order, encode parts, or reference other files:
let mut w = Writer::options().canonical(false).create("scratch.zt")?;
Placement is not part of what you give up. A non-canonical writer still puts blobs on 64 KiB boundaries, since that is what buys per-tensor page exclusivity, and a sharded model wants it as much as any other file does. Ask for something else when the model is many small tensors and a page of padding each accounts for most of the file:
let mut w = Writer::options().canonical(false).align(4096).create("tiny.zt")?;
Asking for an alignment while leaving canonical form on is an error, and the message says which of the two to change.
Appending
append adds objects and shards to a .zt that already exists, without
rewriting the blobs in it:
let mut w = ztensor::Writer::append("model.zt")?;
w.add("extra.weight", [1024u64], DType::F32, &data)?;
w.finish()?;
Everything new goes past the old end of the file, so the cost is the size of what you add. Adding a 4 KiB tensor to a 512 MiB file takes about 55 µs, against 303 ms to rewrite the file. The alignment the file already uses is carried forward, so a 64 KiB file stays one.
This is not atomic. From the first byte written until finish puts a footer
at the new end, the footer is not at EOF and no reader will open the file. The
original bytes are all still there, so a crashed append is undone by
truncating back to the old length. Use publish when the file is worth more
than the rewrite.
Publishing
To publish where readers are watching, let the writer do the dance:
let mut w = Writer::publish("model.zt")?; // writes beside it, renames at the end
Nothing appears at the path until finish, and a writer dropped without
finishing leaves nothing behind.
Canonical check
Canonical form is the recommended distribution format. A file carries no mark saying it is one, because all six rules in the spec are decidable from the bytes.
zt verify model.zt --canonical
model.zt: ok. 3 part(s) digest-verified, 0 without digests
canonical form: no
rule 2: "t"/"data" is at offset 4096, which is not a multiple of 65536
A non-canonical file is still fully conforming. This check answers a different question from whether the file is valid.
Content digest
A file's hash identifies the file. Two files holding the same tensors have different hashes if they were aligned or split differently. The content digest identifies the model:
zt id model.zt
It is computed from the manifest alone, so it costs nothing on a 100 GB checkpoint, and it is the same whether the model is one file or fifty.
let digest = ztensor::read::manifest_of("model.zt")?
.unwrap()
.content_digest(ztensor::DigestAlgorithm::Sha256)?;
Converting
let src = ztensor_compat::open("model.pt")?;
let mut w = Writer::create("model.zt")?;
w.ingest(&src)?;
w.finish()?;
This is the upgrade path. The foreign file has no digests and arbitrary
alignment; the result has digests and 64 KiB placement. Layouts are preserved:
a GGUF q8_0 tensor stays a gguf.q8_0/1 object with its block bytes intact,
so nothing is dequantized along the way.
Verifying
let src = ztensor::Source::open("model.zt")?;
src.tensor("a.weight")?.verify()?; // digest + logical-type content rules
src.verify_shards()?; // whole-file digests of every shard
verify returns Verified::Digest when it checked a digest and
Verified::NoDigest when the part carries none. A mismatch comes back as
Err(Reject { rule: Digest, .. }), since the file has failed a rule.
Verification stages
Structure and the manifest hash are checked when the file is opened. Per-part
digests are checked when you ask for them, and whole-shard digests only in
verify_shards. Hashing 100 GB on every load would be too slow to be
practical, so the expensive checks are opt-in.
Sharding
A multi-file model is one root .zt whose manifest addresses blobs in other
.zt files. Those files are containers like any other; what makes them shards
is that they carry no manifest, so the root's is the only description of what
their bytes are.
The root's table gives each shard a name, but what it records is size and digest. Resolution goes from that identity to a path, which is why renaming the files does not break the model or quietly change it.
let model = ztensor::Source::open("model.zt")?; // positional resolver
Writing the shards
A shard is an ordinary .zt. Write one, then ask it for its identity:
let mut w = Writer::create("model-00001.zt")?;
w.add("blk.0.attn_q.weight", [4096u64, 4096], DType::BF16, &weights)?;
w.finish()?;
The root records that identity and points its tensors at the shard:
let id = ztensor::read::shard_identity("model-00001.zt", DigestAlgorithm::Sha256)?;
let mut root = Writer::options().canonical(false).create("model.zt")?;
root.add_shard("00001", &id)?;
for (name, object) in &ztensor::read::manifest_of("model-00001.zt")?.unwrap().objects {
root.link(name, object, "00001")?;
}
root.finish()?;
Linking through a shard that was never registered is an error, so the two cannot drift apart.
The format also allows a shard with no manifest of its own, holding nothing
but bytes (spec §7.2). zTensor reads those, because other tools write them,
but it does not produce them and you should not want one: a shard without a
manifest cannot say which of its bytes are occupied, so nothing can prove a
tensor has its pages to itself, and evict is refused. It carries no
per-part digests either, so there is nothing to verify. A shard that is a
normal .zt keeps both.
Finding the shards
A resolver turns a shard name into a file. It can also ignore the name:
// Match on size and digest instead, which still works after a rename.
let model = ztensor::Source::options()
.resolver(ztensor::read::DirectoryResolver::scan("checkpoint/")?)
.open("checkpoint/model.zt")?;
Overlays
Because a part may name a shard, a file can reference another model's blobs. An overlay uses this: a LoRA stores only its deltas and points at the base model's tensors.
let base = ztensor::Source::open("base.zt")?;
let object = base.provenance().as_root().unwrap().object("base.weight")?.clone();
let mut w = ztensor::Writer::options().canonical(false).align(4096).create("lora.zt")?;
w.add_shard("base", &ztensor::read::shard_identity("base.zt", DigestAlgorithm::Xxh3)?)?;
w.link("base.weight", &object, "base")?;
w.add("base.weight.lora_a", [64u64], DType::F32, &delta)?;
w.finish()?;
link reads the byte range out of the shard's own manifest. A data shard
(spec §7.2) carries no manifest to read, so state the range yourself:
w.object("t", |o| {
o.shape([8192u64]).part("data", |p| {
p.dtype(DType::U8)
.digest(format!("xxh3:{hex}"))
.external("00001", offset..offset + length)
})
})?;
digest is only for parts like this one. Everything the writer writes it
hashes as it goes, and a digest supplied for those could only agree with the
computed one or be wrong.
Signing a sharded model
A root records each shard's size and digest, so a root whose digests are
sha256 commits to every shard byte. One signature over the root then covers
the whole model.
let id = ztensor::read::shard_identity("model-00001.zt", DigestAlgorithm::Sha256)?;
w.add_shard("00001", &id)?;
Unrelated files
A sharded safetensors snapshot is a different case. The files were not written for each other, and opening them together makes a weaker claim:
let src = ztensor_compat::open_all(&paths)?; // one name space, nothing verified
The only thing tying the set together is the caller's list, so there is
nothing to verify it against. What open_all does check is that no tensor
name appears in two files.
Provenance
The difference between a root, a data shard and a projection is what can be verified, so the API gives it as one answer:
match src.provenance() {
Provenance::Root(manifest) => { /* the file states its own structure */ }
Provenance::DataShard => { /* bytes only; it claims nothing */ }
Provenance::Projection => { /* whoever opened it made the claim */ }
}
src.provenance().as_root() is the shorthand for code that already knows it
opened a .zt root and only wants the manifest.
Streaming weights
For canonical files, each tensor owns its pages, so they can be released individually:
let src = ztensor::Source::open("model.zt")?;
let t = src.tensor("blk.0.attn_q.weight")?;
t.prefetch()?; // MADV_WILLNEED
// ... upload to the device ...
t.evict()?; // MADV_DONTNEED, exact range
evict refuses when a part shares a page with another blob, so it will not
drop a neighbour's cache. It is present on every platform and refused where
there is no way to drop page cache, so if caps.evict { t.evict()? } compiles
everywhere.
Streaming a large tensor
stream declares an object by part lengths and hands back a Sink. The
Sink is a token, not a borrow of the writer, so a producer driven from
outside (one copying weights off a device, say) can hold both in one
structure.
let mut sink = w.stream("w", |o| {
o.shape([n]).part("data", |p| p.dtype(DType::F16).length(nbytes))
})?;
for chunk in chunks { sink.write(&mut w, chunk)?; }
sink.close(&mut w)?;
When the bytes come from somewhere that already speaks io::Write, borrow the
pair together instead, and the compiler is what stops the writer being used for
anything else while the object is open:
std::io::copy(&mut reader, &mut sink.attach(&mut w))?;
Python
import ztensor, torch
with ztensor.open("model.safetensors") as src:
for name in src: # a mapping, so iteration gives names
print(name, src[name].shape)
for t in src.values(): # the tensors themselves
print(t.name, t.dtype, t.caps.map)
t = src["layer.weight"]
w = torch.from_dlpack(t) # zero-copy; DLPack can say bfloat16
t.location # (path, offset, nbytes) for your own I/O
t["scales"] # parts are tensors too
ztensor.convert("model.safetensors", "model.zt")
ztensor.verify("model.zt", deep=True) # Verification(checked=..., undigested=...)
A tensor with one part answers about that part directly. A tensor with
several does not choose one for you, so t.tobytes() on a CSR tensor is an
error naming indices, indptr and values instead of a guess.
Writing has the same three shapes as Rust: add for a dense tensor,
add_object for one with several parts, and stream for one too large to
hold in memory.
The bindings link against no framework: tensors export DLPack and the buffer
protocol, so numpy, torch and jax all read them without this package knowing
any of them exist. ztensor.numpy is a safetensors-shaped shim for migrating
existing code.