Random Access Without a Runtime
Purpose
A Hurray file is read by range, not by feeding bytes forward. The trailer sits at
file_size - 40, the footer index is located from it, and tensors are found by name — so
a reader seeks. hurray-io keeps that logic in a sans-io core, FileReaderCore, which
names the bytes it wants and is told how many arrived. Nothing inside it reads, waits, or
owns a file (ADR-041 §§ 1, 7).
That matters for three reasons:
- No runtime is required. The core compiles with
--no-default-features, so a consumer that has no executor — a C program, a synchronous Python call — pays for none. - The payload is copied once. The core allocates each buffer at the alignment its handle declares and the transport writes straight into it; what comes back out is that same memory, not a copy of it (§§ 2, 5).
- An interrupted read costs nothing. The read position advances only when a byte count is reported, and every request is absolute, so a driver that abandons a transfer is handed the same range again.
FileReader and FileWriter are thin drivers over the cores and remain the ordinary way
to read and write files from async Rust; reach for the cores when you need the bytes to
come from somewhere they do not know about. From C, that driver is already written:
HurrayFileReader and HurrayFileWriter drive the same cores over the transport vtable,
which is what the C tabs below use.
The protocol
Three calls, in a loop:
| Call | Meaning |
|---|---|
step() | What the core needs next: a Range { offset, len }, or Ready, Descriptor, Tensor, Composite |
input_dst() | The memory that range must be read into — exactly len bytes, at a stable address |
report_filled(n) | How many bytes actually arrived. A short read is normal; 0 means the file is truncated |
step() never blocks, which is what lets the same core sit behind a blocking C callback
and an event loop alike (§ 4).
A C caller never writes that loop. It supplies two callbacks — read_at(ctx, offset, dst, len, &filled) and size(ctx, &size) — and hurray_file_reader_* runs the protocol
inside them. The callbacks are allowed to block, which is the point of them; nothing
inside Hurray does.
Reading a file by range
The driver below is the whole bridge between the core and std::io — a seek, a read, and
a count:
use std::io::{Read, Seek, SeekFrom};
use hurray_io::file::{FileReaderCore, FileStep};
/// Answers every range the core asks for, and returns the step that ended the job.
fn drive<R: Read + Seek>(
core: &mut FileReaderCore,
source: &mut R,
) -> hurray_io::Result<FileStep> {
loop {
match core.step()? {
FileStep::Range { offset, len } => {
source.seek(SeekFrom::Start(offset))?;
// `read`, not `read_exact`: a bulk helper that fails cannot say how many
// bytes it moved, and that count is the read position.
let n = source.read(&mut core.input_dst()[..len])?;
core.report_filled(n)?;
}
other => return Ok(other),
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::{FileWriteOp, FileWriterCore};
use hurray_io::stream::WriteStep;
fn drain(mut op: FileWriteOp<'_>, sink: &mut Vec<u8>) -> hurray_io::Result<()> {
while let WriteStep::Output { .. } = op.step()? {
let src = op.output_src();
sink.extend_from_slice(src);
let n = src.len();
op.report_written(n)?;
}
Ok(())
}
let handle = BufferHandle::new(
256, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let descriptor = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)?;
let data: Vec<u8> = (0..=255u8).collect();
let buffers: [&[u8]; 1] = [&data];
let mut bytes = Vec::new();
let mut writer = FileWriterCore::new();
drain(writer.header()?, &mut bytes)?;
drain(writer.tensor("embeddings", &descriptor, &buffers)?, &mut bytes)?;
drain(writer.finish(vec![])?, &mut bytes)?;
let mut source = std::io::Cursor::new(bytes);
let size = source.seek(SeekFrom::End(0))?;
let mut core = FileReaderCore::new(size);
// Opening is four ranges at most: header, trailer, index, KV section.
assert_eq!(drive(&mut core, &mut source)?, FileStep::Ready);
println!("{} tensor(s)", core.index().len());
// A descriptor on its own — one range, and not a byte of payload.
core.begin_descriptor("embeddings")?;
assert_eq!(drive(&mut core, &mut source)?, FileStep::Descriptor);
let descriptor = core.take_descriptor().expect("a descriptor is ready");
println!("{:?} {:?}", descriptor.element_type, descriptor.shape.dims());
// The whole tensor — the descriptor, then one range per buffer.
core.begin_tensor("embeddings")?;
assert_eq!(drive(&mut core, &mut source)?, FileStep::Tensor);
let tensor = core.take_tensor().expect("a tensor is ready");
// The buffer carries the alignment its handle declared, so an aligned SIMD load on it is
// sound. This is the guarantee, not a property of the allocator that happened to hold.
let declared = tensor.descriptor.buffers[0].alignment() as usize;
assert_eq!(tensor.buffers[0].as_ptr() as usize % declared, 0);
assert_eq!(tensor.buffers[0].as_ref(), &data[..]);
Ok(())
}
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "hurray.h"
/* One absolute range. A positional read keeps no cursor, which is why re-issuing a
failed request reads the same bytes rather than resuming somewhere else. */
static HurrayStatus file_read_at(void *ctx, uint64_t offset, uint8_t *dst, size_t len,
size_t *out_filled) {
ssize_t n = pread(*(int *)ctx, dst, len, (off_t)offset);
if (n < 0) { *out_filled = 0; return HURRAY_ERR_TRANSPORT; } /* moved nothing */
*out_filled = (size_t)n; /* short is normal; 0 means the file is truncated */
return HURRAY_OK;
}
/* Asked once, at open: a container is located from its end. */
static HurrayStatus file_size(void *ctx, uint64_t *out_size) {
struct stat st;
if (fstat(*(int *)ctx, &st) != 0) { return HURRAY_ERR_TRANSPORT; }
*out_size = (uint64_t)st.st_size;
return HURRAY_OK;
}
int fd = open("model.hrry", O_RDONLY);
HurrayTransport transport = { .ctx = &fd, .read_at = file_read_at, .size = file_size };
HurrayFileReader *reader = NULL;
if (hurray_file_reader_new(&transport, &reader) != HURRAY_OK) return -1;
/* The names come from the footer index, so listing them reads no tensor data. */
uint64_t count = 0;
hurray_file_reader_tensor_count(reader, &count);
for (uint64_t i = 0; i < count; i++) {
const uint8_t *name = NULL;
size_t name_len = 0;
hurray_file_reader_tensor_name(reader, i, &name, &name_len); /* borrowed, no NUL */
printf("%.*s\n", (int)name_len, (const char *)name);
}
/* One tensor by name; the bytes of every other tensor stay on disk. */
const char *want = "embeddings";
HurrayTensor *tensor = NULL;
HurrayStatus s = hurray_file_reader_read_tensor(
reader, (const uint8_t *)want, strlen(want), &tensor);
if (s == HURRAY_ERR_NOT_FOUND) {
/* The file is fine — that name is simply not in it, and the reader stays usable. */
} else if (s == HURRAY_OK) {
HurrayBufferList *buffers = NULL; /* borrowed — do not destroy */
hurray_tensor_buffers(tensor, &buffers);
/* ... hand the buffers to your kernels ... */
hurray_tensor_destroy(&tensor); /* one call frees descriptor and buffers */
}
hurray_file_reader_destroy(&reader);
close(fd);
import numpy as np
import hurray
tensor = hurray.from_numpy(np.arange(64, dtype=np.float32))
hurray.save("model.hrry", {"embeddings": tensor}, kv={"producer": "cookbook"})
# The synchronous Python surface drives the same core, with the runtime detached for the
# duration of the call.
loaded = hurray.load("model.hrry")
assert np.array_equal(np.from_dlpack(loaded["embeddings"]), np.arange(64, dtype=np.float32))
assert hurray.load_kv("model.hrry")["producer"] == "cookbook"
Writing a file in one forward pass
The write side needs no ranges at all: a file is written forwards, and the footer index is accumulated as tensors go out and emitted last. What the core will not do is assemble your payloads — it offers each buffer in place, one stretch at a time, and holds no pointer into it once the item is written (§ 3):
use hurray_io::file::{FileWriteOp, FileWriterCore};
use hurray_io::stream::WriteStep;
/// Writes every byte of one submitted item. The sink may take fewer bytes than offered.
fn drain<W: std::io::Write>(
mut op: FileWriteOp<'_>,
sink: &mut W,
) -> hurray_io::Result<()> {
loop {
match op.step()? {
WriteStep::Output { .. } => match sink.write(op.output_src())? {
// A zero-length write is never progress: a closed sink is its own signal.
0 => return Err(op.report_sink_closed()),
n => op.report_written(n)?,
},
WriteStep::ItemWritten => return Ok(()),
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::KvValue;
let handle = BufferHandle::new(
256, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let descriptor = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)?;
let payload: Vec<u8> = (0..=255u8).collect();
// Bound, not a temporary: the operation borrows the payloads until the item is written.
let buffers: [&[u8]; 1] = [&payload];
let mut file = Vec::new();
let mut core = FileWriterCore::new();
drain(core.header()?, &mut file)?;
drain(core.tensor("embeddings", &descriptor, &buffers)?, &mut file)?;
drain(
core.finish(vec![("producer".into(), KvValue::String("cookbook".into()))])?,
&mut file,
)?;
assert_eq!(&file[0..8], b"HRRYFILE");
assert_eq!(&file[file.len() - 4..], b"HRRY");
Ok(())
}
#include "hurray.h"
static HurrayStatus file_write(void *ctx, const uint8_t *src, size_t len,
size_t *out_written) {
ssize_t n = write(*(int *)ctx, src, len);
if (n < 0) { *out_written = 0; return HURRAY_ERR_TRANSPORT; }
*out_written = (size_t)n; /* never 0 on success: that means the sink is closed */
return HURRAY_OK;
}
int fd = open("model.hrry", O_WRONLY | O_CREAT | O_TRUNC, 0644);
/* A writer needs no range reads: it emits one forward pass. */
HurrayTransport transport = { .ctx = &fd, .write = file_write };
HurrayFileWriter *writer = NULL;
/* NULL options: 4096-byte data alignment, index in write order. The 64-byte header
goes out during this call. */
if (hurray_file_writer_new(&transport, NULL, &writer) != HURRAY_OK) return -1;
const char *name = "embeddings";
HurrayIoSlice payload = { .ptr = data, .len = data_len };
if (hurray_file_writer_write_tensor(writer, (const uint8_t *)name, strlen(name),
descriptor, &payload, 1) != HURRAY_OK) {
/* A failed file write ends the file: the writer is poisoned, and the recovery is
to write the file again from the start, not to retry this tensor. */
hurray_file_writer_destroy(&writer);
return -1;
}
/* KV values are borrowed, and the same struct a reader hands back — so copying one
file's metadata into another needs no transcription. */
const char *producer = "cookbook";
HurrayKvEntry kv[1] = {{
.key = (const uint8_t *)"producer",
.key_len = 8,
.value = { .kind = HURRAY_KV_STRING,
.data = (const uint8_t *)producer,
.data_len = 8 },
}};
/* Until this returns, the bytes on disk are not a container: the index is written here. */
hurray_file_writer_finish(writer, kv, 1);
hurray_file_writer_destroy(&writer);
close(fd);
import numpy as np
import hurray
# `save` is the same forward pass: header, tensors, KV, index, trailer.
hurray.save(
"two.hrry",
{
"embeddings": hurray.from_numpy(np.arange(64, dtype=np.float32)),
"bias": hurray.from_numpy(np.zeros(16, dtype=np.float32)),
},
kv={"producer": "cookbook", "layers": 32},
)
names = list(hurray.load("two.hrry"))
assert names == ["embeddings", "bias"]
Composites: validated first, then flattened
A composite is written as its head followed by each member, contiguously, and recovered on
read from the head's member_count plus file-offset adjacency. flatten_composite
validates the whole tree — member counts, partition exact-cover, overlay ordering, at every
level — and returns the order its tensors go on disk, so a torn group never reaches the
sink. The nesting is walked with an explicit stack rather than by recursing, which is what
lets a state machine do it at all (§ 10). The C surface over this is
Composite Tensors in a File —
hurray_file_writer_write_composite and hurray_file_reader_read_composite, over the same
vtable:
use hurray_io::file::{flatten_composite, FileCompositeNode, FileWriterCore};
fn run(
head: hurray_core::TensorDescriptor,
members: &[FileCompositeNode<'_>],
core: &mut FileWriterCore,
file: &mut Vec<u8>,
) -> hurray_io::Result<()> {
fn drain(_: hurray_io::file::FileWriteOp<'_>, _: &mut Vec<u8>) -> hurray_io::Result<()> { Ok(()) }
for node in flatten_composite("weight", &head, members)? {
// head first, then "weight.left", "weight.right", … depth first
drain(core.tensor(node.name, node.descriptor, node.buffers)?, file)?;
}
Ok(())
}
Reading one back is a single job: the core walks the index in file order and rebuilds the tree, member counts validated as each level closes.
use hurray_io::file::{FileItem, FileReaderCore, FileStep};
fn run(core: &mut FileReaderCore, source: &mut std::io::Cursor<Vec<u8>>) -> hurray_io::Result<()> {
fn drive(_: &mut FileReaderCore, _: &mut std::io::Cursor<Vec<u8>>) -> hurray_io::Result<FileStep> {
Ok(FileStep::Composite)
}
core.begin_composite("weight")?;
assert_eq!(drive(core, source)?, FileStep::Composite);
let composite = core.take_composite().expect("a composite is ready");
for member in &composite.members {
match member {
FileItem::Tensor(t) => println!("{} — {} buffer(s)", t.name, t.buffers.len()),
FileItem::Composite(c) => println!("{} — nested, {} member(s)", c.name, c.members.len()),
}
}
Ok(())
}
What a failure costs
The file core distinguishes two kinds of failure, and the difference is the whole point of addressing bytes rather than consuming them:
- A failed job leaves the core usable. A missing name, a descriptor that will not
decode, a torn composite — the next
begin_descriptor/begin_tensor/begin_compositestarts from the index again, not from wherever the file pointer stopped. - A failed open is terminal. Bad magic, an unsupported container version, a trailer
whose index overruns it, a CRC mismatch: there is no index to start from, so
step()reportsCoreFailedfrom then on.
There is no "unusable" state for a lost byte count here, unlike the stream core. A count
that never arrives simply means the next step() re-offers the same absolute range, and
re-filling the destination from the start of that range is idempotent.
The write side splits differently, on a line worth knowing before you build a retry around it: what matters is whether the item emitted a byte, not whether the call failed.
- A write that fails before its first byte emitted nothing, so there is nothing to un-send. The item's name was not consumed, the core is unchanged, and resubmitting the same item produces a file byte-identical to one written with no failure at all.
- A write that fails after its first byte is terminal. The file's tensor region is a
sequence of complete tensors, and a forward-only writer has no way back to the item's
start — there is no seek, and the C vtable has no truncate. The core is poisoned and
finishrefuses, rather than writing a clean index over a region that does not conform.
finish is an item under the same rule, which makes it the retry most worth having: the
data is already out and only the index is missing, so a footer that failed before its
first byte can simply be written again.
use std::io::Write;
use hurray_io::file::{FileWriteOp, FileWriterCore};
use hurray_io::stream::WriteStep;
fn drain<W: Write>(mut op: FileWriteOp<'_>, sink: &mut W) -> hurray_io::Result<()> {
loop {
match op.step()? {
WriteStep::Output { .. } => match sink.write(op.output_src())? {
0 => return Err(op.report_sink_closed()),
n => op.report_written(n)?,
},
WriteStep::ItemWritten => return Ok(()),
}
}
}
/// A sink that fails its first write, then accepts everything.
struct FailsOnce {
bytes: Vec<u8>,
failed: bool,
}
impl Write for FailsOnce {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if !self.failed {
self.failed = true;
return Err(std::io::Error::other("simulated"));
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
let handle = BufferHandle::new(
256, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let descriptor = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)?;
let payload = vec![0u8; 256];
let buffers: [&[u8]; 1] = [&payload];
let mut sink = FailsOnce { bytes: Vec::new(), failed: false };
let mut core = FileWriterCore::new();
// The header's first write is the one that fails, so nothing reached the sink.
assert!(drain(core.header()?, &mut sink).is_err());
assert!(!core.is_unusable());
// Nothing was emitted, so every name is still free and the file is completable.
drain(core.header()?, &mut sink)?;
drain(core.tensor("weight", &descriptor, &buffers)?, &mut sink)?;
drain(core.finish(Vec::new())?, &mut sink)?;
assert_eq!(&sink.bytes[0..8], b"HRRYFILE");
assert_eq!(&sink.bytes[sink.bytes.len() - 4..], b"HRRY");
Ok(())
}
/* Same split, through the vtable. A failure before the tensor's first byte returns the
write callback's own status and the writer stays usable; one after it returns
HURRAY_ERR_SINK_CLOSED and every later call fails. */
HurrayStatus st = hurray_file_writer_write_tensor(
writer, "weight", 6, descriptor, payloads, 1);
if (st == HURRAY_ERR_SINK_CLOSED) {
/* Torn: this file cannot be completed. Start a new one. */
} else if (st != HURRAY_OK) {
/* Nothing was emitted — retry the same name. */
st = hurray_file_writer_write_tensor(
writer, "weight", 6, descriptor, payloads, 1);
}
import os
import hurray
weight = hurray.zeros((8, 8), dtype=hurray.float32)
# `hurray.save` is all-or-nothing: it owns the file it creates, so there is no partially
# written state for a caller to inspect or resume. If it raises, delete and write again.
try:
hurray.save("weights.hrry", {"weight": weight})
except OSError:
if os.path.exists("weights.hrry"):
os.remove("weights.hrry")
hurray.save("weights.hrry", {"weight": weight})
assert hurray.load("weights.hrry")["weight"].shape == (8, 8)
Runnable example
hurray-io/examples/sans_io_file.rs writes a file with two tensors, a partition composite
and KV metadata, then reads it back through the core — all over std::io, with no runtime
linked:
cargo run --example sans_io_file
hurray-ffi/examples/file_vtable.rs does the same thing through the C entry points, over
a real file on disk reached only through callbacks:
cargo run --example file_vtable -p hurray-ffi
See also
- Layer 6: File Format — the container's bytes, and the async
FileReader/FileWriter - Composite Tensors in Files — membership, naming, and validation
- Streaming from C — the same core/driver split over the C transport vtable, for streams
- Python: File I/O —
save,load,load_kv