Composite Tensors in Files
Purpose
A composite tensor — a data-less head plus an ordered set of member
tensors — is stored in a Hurray file as its head followed by each member's descriptor and
data, written contiguously and in order. Every tensor — the head and each member — gets
its own footer-index entry, so all are individually addressable by name. Membership is
recovered from the head's member_count plus file-offset adjacency: the members are the
tensors written immediately after the head (ADR-027 § Binding; docs/spec/file-format.md).
hurray-io provides a matched pair:
FileWriter::write_composite— validates the whole group up front (member count, partition exact-cover, overlay ordering) viahurray-core'sCompositeValidator, then writes the head and members. Nested composites are written recursively.FileReader::read_composite— reassembles a head with its members (recursively for nesting) and validates the group, returning aFileComposite.
Writing a composite to a file
Each node carries a name because every tensor gets its own index entry:
use hurray_core::{
layout::{CompositeLayout, CompositionRule},
ElementType, LayoutDescriptor, Shape, TensorDescriptor,
};
use hurray_io::file::{FileCompositeNode, FileWriter};
async fn run(left: TensorDescriptor, right: TensorDescriptor,
left_data: Vec<u8>, right_data: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
let head = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
LayoutDescriptor::Composite(CompositeLayout::new(CompositionRule::Partition, 2)?),
vec![], None, None, None, None,
)?;
let left_buffers: [&[u8]; 1] = [left_data.as_slice()];
let right_buffers: [&[u8]; 1] = [right_data.as_slice()];
let members = vec![
FileCompositeNode::Tensor { name: "weight.left", descriptor: &left, buffers: &left_buffers },
FileCompositeNode::Tensor { name: "weight.right", descriptor: &right, buffers: &right_buffers },
];
let file = tokio::fs::File::create("model.hrry").await?;
let mut writer = FileWriter::new(file).await?;
writer.write_composite("weight", &head, &members).await?; // validated before any tensor is written
writer.finish(vec![]).await?;
Ok(())
}
import hurray
# A composite is one named entry, like any other tensor.
hurray.save("model.hrry", {"weight": weight, "bias": bias})
/* The same preorder array the stream surface takes, with the names filled in: a file
gives every tensor its own index entry, so each node needs one. */
HurrayCompositeNode nodes[3] = {
{ .kind = HURRAY_ITEM_COMPOSITE, .name = "weight", .name_len = 6,
.descriptor = head, .member_count = 2 },
{ .kind = HURRAY_ITEM_TENSOR, .name = "weight.left", .name_len = 11,
.descriptor = left, .payloads = &left_slice, .payload_count = 1 },
{ .kind = HURRAY_ITEM_TENSOR, .name = "weight.right", .name_len = 12,
.descriptor = right, .payloads = &right_slice, .payload_count = 1 },
};
HurrayStatus s = hurray_file_writer_write_composite(writer, nodes, 3);
/* A group spans several index entries, so a failure part-way through leaves a head with
fewer members after it than it declares — not a file any reader may open. The writer is
poisoned rather than left to write a clean index over it. */
if (s < 0) return -1;
hurray_file_writer_finish(writer, NULL, 0);
Every tensor in a file gets an index entry, head and member alike, so a composite's
members are named "{head}.{index}" — weight.0, weight.1. Those names are an artifact
of the container rather than of the composite, so Python generates them for you.
Reading a composite from a file
read_composite takes the head's name and returns the reassembled group. Members remain
individually readable by name with read_tensor:
use hurray_io::file::{FileItem, FileReader};
async fn run(file: tokio::fs::File) -> Result<(), Box<dyn std::error::Error>> {
let mut reader = FileReader::open(file).await?;
// Whole composite, grouped and validated:
let composite = reader.read_composite("weight").await?;
println!("head {:?}, {} member(s)", composite.head.shape.dims(), composite.members.len());
for member in &composite.members {
match member {
FileItem::Tensor(t) => println!(" member {}: {} buffer(s)", t.name, t.buffers.len()),
FileItem::Composite(c) => println!(" nested composite {}", c.name), // recurses
}
}
// Or just one member, by name:
let left = reader.read_tensor("weight.left").await?;
Ok(())
}
import hurray
loaded = hurray.load("model.hrry")
loaded["weight"] # a hurray.Composite
loaded["weight"].members # its tiles, in write order
# The head's name is the only top-level entry: on the wire the members belong
# to it, so they do not also come back on their own. Asking still works:
tile = hurray.load("model.hrry", names=["weight.0"])["weight.0"]
HurrayItem *item = NULL;
HurrayStatus s = hurray_file_reader_read_composite(reader, "weight", 6, &item);
if (s == HURRAY_ERR_NOT_COMPOSITE) { /* the name is in the index, but it is a tensor */ }
if (s == HURRAY_ERR_NOT_FOUND) { /* no such name */ }
if (s < 0) return -1;
uint64_t count = 0;
hurray_item_node_count(item, &count);
for (uint64_t i = 0; i < count; i++) {
HurrayItemNode node = {0};
hurray_item_node(item, i, &node);
/* Named, unlike on a stream: every tensor of the group has its own index entry.
The name is UTF-8 and NOT NUL-terminated, so name_len is not optional. */
printf("%.*s: %u member(s)\n", (int)node.name_len, (const char *)node.name,
node.member_count);
}
hurray_item_destroy(&item); /* one call for the whole subtree */
/* Or just one member, by name — a composite's members stay individually addressable. */
HurrayTensor *left = NULL;
hurray_file_reader_read_tensor(reader, "weight.left", 11, &left);
The C walk is the same loop the stream page shows: nodes in
preorder, nesting in the member_count column. One shape for both surfaces, because a
composite read out of a file is the same tree as one read off a wire.
Recovery is independent of index sort order
The file writer's sorted_index option sorts the index array by name for binary search,
but the tensors' positions in the file are unchanged. read_composite recovers membership
by descriptor offset, so it returns the members in file (write) order regardless of how
the index is sorted.
Nested composites and the depth guard
A member may itself be a composite; read_composite reassembles the tree recursively. The
recursion is bounded by FileReader::with_max_composite_depth (default 64) to guard against
a maliciously deep composite.
Errors
Error::NotAComposite— the named tensor exists but its head is not a composite. In C this isHURRAY_ERR_NOT_COMPOSITE, its own status for the reasonHURRAY_ERR_NOT_FOUNDhas one: it is a question the caller asked wrongly, not a defect in the file.Error::TornComposite— fewer tensors follow the head than itsmember_countdeclares.Error::CompositeNestingTooDeep— nesting exceeded the configured maximum.Error::Core— composite validation failed (member-count mismatch, partition coverage, overlay ordering).
Runnable example
cargo run --example composite_file --features tokio -p hurray-io
python hurray-python/examples/composites.py
cargo run --example composite_vtable -p hurray-ffi
See hurray-io/examples/composite_file.rs, hurray-python/examples/composites.py, and
hurray-ffi/examples/composite_vtable.rs for the full programs. The last one writes the
same composite to a stream and to a file through the C entry points, which is the shortest
way to see what the two surfaces share and where they differ.