Hurray Format Specification
Version: 0.1.0-draft
Hurray is a language-agnostic, zero-copy runtime interchange format for multi-dimensional tensor data, optimized for the memory layout diversity, quantization schemes, and access patterns of modern AI/ML inference pipelines and scientific arrays.
Scope and Goals
- Define a binary tensor descriptor encoding that is language- and runtime-agnostic.
- Enable zero-copy buffer sharing across runtimes, processes, and devices.
- Support the full range of quantization schemes used in modern inference.
- Be streamable (streaming format): a reader MUST be able to start processing tensor data without buffering the entire input, and a writer MUST be able to emit tensor data incrementally without buffering the entire output. Tensor descriptors always precede their data buffers; the format is self-delimiting; back-references are not permitted. File format writers operate in a single forward pass and append a footer index at the end.
- Extensible and evolvable: extension points are stable across 1.x; new named values go through the spec amendment process; backward and forward-additive compatibility is guaranteed within major version
1.x. See Versioning § Evolvability Contract. - Use the standard numeric dtype vocabulary (shared by NumPy and the Python Array API Standard) for Tier 1 element types, enabling zero-copy interoperability without dtype translation. This is an interop convenience, not an Array API conformance claim. See Python Bindings for binding-level requirements.
- Serve as the storage foundation of an array database engine: the file format, tiled/blocked layout, Morton and Hilbert curve layouts, and footer index are designed to be compatible with sub-array queries, tile-skipping, and range-based retrieval. A concrete target use case is an embeddable SQL/MDA query engine (ISO 9075-15) backed by Hurray buffers, with zero-copy handoff from query results to an ML inference pipeline. Spec decisions that would foreclose chunk-based access, spatial locality, dimension-range indexing, or SQL/MDA interoperability MUST be evaluated against this use case before being adopted.
RFC 2119 Notice
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in these documents are to be interpreted as described in RFC 2119.
Versioning
This specification follows semantic versioning. A reader MUST reject a tensor descriptor whose major version field exceeds the reader's supported major version.
Table of Contents
| Section | Description |
|---|---|
| Element Types | Numeric element type system: type tags, bit widths, encoding, sub-byte packing |
| Data Model | Element type system, shape and dimension model |
| Quantization | Quantization scheme registry: descriptor header, scheme tag space, partial-block policy, buffer placement rules |
| Per-Tensor Affine | Per-tensor affine quantization — scheme tag 0x01 (Tier 1) |
| Per-Channel Affine | Per-channel (per-axis) affine quantization — scheme tag 0x02 (Tier 1) |
| Per-Block Affine | Per-block affine quantization — scheme tag 0x03 (Tier 1) |
| NF4 (NormalFloat4) | NF4 (NormalFloat4) block quantization — scheme tag 0x04 (Tier 2) |
| MXFP (OCP Microscaling) | MXFP (OCP Microscaling) block quantization — scheme tag 0x05 (Tier 2) |
| Memory Layout | Layout taxonomy, common fields, element address computation, alignment, sharding, buffer table |
| Row-Major (C Order) | Row-major (C order) layout — tag 0x01 |
| Column-Major (Fortran Order) | Column-major (Fortran order) layout — tag 0x02 |
| Strided | Strided layout with negative/zero stride support — tag 0x03 |
| Tiled / Blocked | Tiled / blocked layout with recursive nesting — tag 0x04 |
| Morton (Z-Order Curve) | Morton (Z-order curve) layout — tag 0x05 |
| COO (Coordinate) | COO (Coordinate) sparse layout — tag 0x06 |
| CSR (Compressed Sparse Row) | CSR (Compressed Sparse Row) sparse layout — tag 0x07 |
| CSC (Compressed Sparse Column) | CSC (Compressed Sparse Column) sparse layout — tag 0x08 (also known as CCS) |
| CSF (Compressed Sparse Fiber) | CSF (Compressed Sparse Fiber) sparse layout — tag 0x09 |
| Block-Paged | Block-paged (PagedAttention KV cache) layout — tag 0x0A |
| Composite / Virtual Tensor | Composite / Virtual tensor (head + members) — tag 0x0B |
| Hilbert Curve | Hilbert curve layout — tag 0x40 |
| Buffer Protocol | Zero-copy semantics, alignment, device memory |
| Metadata | Tensor descriptor binary encoding |
| Interchange | Streaming IPC format: in-process, IPC, cross-machine network transport |
| File Format | File format: random-access container with named tensors, footer index, KV metadata |
| Versioning | Format version field and compatibility policy |
| References | Normative references |
Data Model — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines the logical data model of a Hurray tensor: the abstract description of its shape and element structure, independent of how it is stored in memory or encoded on the wire. All other sections of the specification operate on the model defined here.
Tensor
A tensor is a multi-dimensional array of elements, all of the same element type. A tensor is characterised by:
- a rank — the number of dimensions,
- a shape — the size of each dimension,
- an element type — defined in
element-types.md.
The element type and shape fully determine the logical content of the tensor.
How the elements are mapped to memory is defined separately by the tensor's
memory layout (memory-layout.md).
Rank
The rank of a tensor is a non-negative integer that counts the number of
dimensions. It is encoded as a uint32 in the tensor descriptor (metadata.md).
- A rank-0 tensor is a scalar: it contains exactly one element and has no shape array.
- A rank-1 tensor is a vector.
- A rank-2 tensor is a matrix.
- Higher ranks are used for batched inputs, activation maps, and weight tensors in neural networks.
The maximum rank is 64. A writer MUST NOT emit a descriptor with rank > 64.
A reader MUST reject a descriptor with rank > 64. A conforming implementation
MUST support tensors of rank 0 through 64 inclusive (see
docs/adr/ADR-008-normative-rank-cap-64.md).
Note (non-normative): The
uint32rank field can encode values above 64, but those values are reserved and will be rejected. The cap matches PyTorch's MAX_DIMS = 64 and bounds the shape-array size at 512 bytes (64 × 8), enabling stack allocation on the descriptor-parsing hot path.
Shape
The shape of a tensor is an ordered sequence of rank dimension sizes,
indexed from 0. Dimension 0 is the outermost (slowest-varying) dimension
in row-major order; dimension rank − 1 is the innermost (fastest-varying).
Each dimension size is encoded as a uint64. A dimension size:
- MUST be greater than or equal to
0. - A size of
0denotes an empty dimension: the tensor contains no elements along that axis. The total element count of a tensor with any zero-size dimension is0. - The value
0xFFFFFFFFFFFFFFFF(UINT64_MAX) is the dynamic dimension sentinel — see § Dynamic Dimensions below. - Any other value in the range
[1, 0xFFFFFFFFFFFFFFFE]is a static dimension size.
For a scalar tensor (rank = 0), the shape is the empty sequence; no shape
bytes are present in the descriptor.
Total Element Count
For a tensor with no dynamic dimensions, the total number of logical elements is:
element_count = product(shape[i] for i in 0 .. rank-1)
This product is 1 for a scalar. For a tensor with one or more zero-size
dimensions, element_count = 0.
A reader MUST NOT compute element_count for a tensor that has any dynamic
dimension without first resolving all dynamic dimensions to concrete values.
Dynamic Dimensions
A dimension whose size is 0xFFFFFFFFFFFFFFFF is dynamic: its concrete
value is not known at descriptor-write time and MUST be supplied by the reader
or the interchange protocol before the tensor's data buffer can be safely
accessed.
Rules for dynamic dimensions:
- A reader MUST NOT compute buffer sizes, strides, or element counts for a tensor containing a dynamic dimension without first resolving it to a concrete value.
- A writer that sets a dimension to
0xFFFFFFFFFFFFFFFFMUST ensure the interchange channel provides a mechanism to communicate the resolved value before the data buffer is transferred (seeinterchange.md). - A quantization scheme that requires a statically known dimension size along
its quantization axis (
axis) MUST reject a descriptor whoseshape[axis]is0xFFFFFFFFFFFFFFFF. The specific constraint is documented per scheme inquantization/. - Shard descriptors MUST NOT use dynamic dimensions (see
metadata.md§ Shard Section).
Note (non-normative): Dynamic dimensions are intended for streaming and just-in-time dispatch scenarios, where a model producer does not know the batch size or sequence length at graph-compilation time. They are not a substitute for shape polymorphism at the type level.
Element Type
The element type of a tensor is identified by a uint8 type_tag defined
in element-types.md. All elements in a tensor share the same type tag.
The element type describes the storage representation of each element in
the data buffer. When a tensor is quantized (HAS_QUANTIZATION flag set), the
storage type is an integer or float8 type and the quantization descriptor
defines the mapping to real-valued elements. The storage type is always
orthogonal to the quantization scheme: type_tag never encodes quantization
semantics.
Scalar Tensors
A scalar tensor has rank = 0. Its shape is empty. It contains exactly one
element. The data buffer size is sizeof(element_type) bytes (or a partial
byte for sub-byte types; see element-types.md § Sub-Byte Packing).
A scalar tensor MUST NOT carry a strided, tiled, or sparse layout descriptor;
only row-major (0x01) is permitted for scalars.
Note (non-normative): Scalar tensors arise as outputs of reduction operations (e.g., loss values) and as single-element configuration parameters.
Empty Tensors
A tensor is empty if element_count = 0. This occurs when at least one
dimension has size 0. An empty tensor is valid; its data buffer has size 0
bytes.
An empty tensor MUST still carry a complete, valid descriptor with a correct element type, layout tag, buffer table, and any applicable quantization descriptor. A reader MUST accept an empty tensor without treating it as an error. A zero-length data buffer MAY be represented with a null pointer; the 64-byte alignment requirement does not apply to a zero-length buffer.
Note (non-normative): The decision to permit empty tensors is recorded in
docs/adr/ADR-007-permit-empty-tensors.md. The primary motivation is round-trip fidelity with PyTorch, NumPy, JAX, Apache Arrow, and DLPack, all of which permit zero-size dimensions.
Relationship to Other Sections
element-types.mddefines thetype_tagvalues, sub-byte packing rules, and the Tier 1 / Tier 2 type classification.memory-layout.mddefines how the logical index space described by the shape is mapped to a linear buffer: strides, block shapes, sparse indices.metadata.mddefines the binary encoding of rank, shape, and all other descriptor fields on the wire.quantization.mddefines how storage elements are mapped to real-valued elements whenHAS_QUANTIZATIONis set.interchange.mddefines how dynamic dimensions are resolved during runtime tensor exchange.
Open Questions
All open questions in this section are resolved. See docs/adr/ADR-007-permit-empty-tensors.md (OQ-2) and docs/adr/ADR-008-normative-rank-cap-64.md (OQ-1).
Element Types
Status: Draft
Scope
This section defines the complete numeric element type system for the Hurray tensor format, including type identifiers, bit widths, encoding rules, and sub-byte packing conventions.
Note (non-normative): Hurray is a strictly numeric tensor format. String types, structured types, and arbitrary user-defined element types are out of scope. The type system is designed to cover the full range of numeric precisions encountered in modern AI/ML inference, from float64 down to 2-bit quantized integers.
Normative Requirements
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Byte Order
All multi-byte fields in the Hurray format — including element data, descriptor fields, and metadata — MUST be encoded in little-endian byte order (least significant byte at the lowest address). The format does not define a big-endian encoding and contains no endianness indicator field.
Implementations running on big-endian host architectures MUST convert to little-endian on write and from little-endian on read. The wire format is always little-endian regardless of host byte order.
Note (non-normative): This matches the choice made by Apache Arrow, DLPack, and SafeTensors. All hardware platforms targeted by AI/ML inference workloads (x86, ARM, RISC-V, NVIDIA/AMD GPUs, Apple Silicon) are little-endian. A fixed byte order eliminates the need for byte-swap detection and preserves zero-copy semantics between any two conforming implementations.
Type Identifier Encoding
Every element type is identified by a type tag, encoded as a uint8 value in the binary tensor descriptor. The type tag space is partitioned as follows:
| Range | Allocation |
|---|---|
0x00 | Reserved (invalid) |
0x01 -- 0x3F | Tier 1 core types |
0x40 -- 0x7F | Tier 2 extended types |
0x80 -- 0xEF | Reserved for future specification versions |
0xF0 -- 0xFE | Reserved for implementation-private extensions |
0xFF | Reserved (invalid) |
A conforming reader MUST reject a tensor descriptor containing a type tag of 0x00 or 0xFF.
A conforming reader MUST reject a tensor descriptor containing a type tag it does not recognize, unless the reader is operating in an explicitly configured permissive mode. In permissive mode, the reader MAY accept the descriptor but MUST NOT attempt to interpret the tensor data buffer.
Implementations MUST NOT assign semantics to type tags in the range 0x80 -- 0xEF; these are reserved for future versions of this specification.
Implementations MAY use type tags in the range 0xF0 -- 0xFE for private extensions. Tensors using private extension type tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band.
Tier 1 -- Core Types
All conforming implementations MUST support every Tier 1 type. A conforming implementation MUST be able to read a tensor descriptor for any Tier 1 type and correctly interpret its metadata (shape, strides, buffer layout). Whether the implementation can perform computation on every Tier 1 type is outside the scope of this specification.
Floating-Point Types
| Type | Tag | Bit Width | Description |
|---|---|---|---|
float16 | 0x01 | 16 | IEEE 754 binary16 |
bfloat16 | 0x02 | 16 | Brain floating point |
float32 | 0x03 | 32 | IEEE 754 binary32 |
float64 | 0x04 | 64 | IEEE 754 binary64 |
float16 (0x01)
float16 is the IEEE 754 binary16 format: 1 sign bit, 5 exponent bits, 10 significand (mantissa) bits. Total width is 16 bits (2 bytes). The two bytes MUST be stored in little-endian order (least significant byte first).
All IEEE 754 binary16 bit patterns are valid, including positive and negative zero, infinities, and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange; they MUST NOT canonicalize NaN payloads or flush subnormals to zero.
bfloat16 (0x02)
bfloat16 uses 1 sign bit, 8 exponent bits, and 7 significand (mantissa) bits. Total width is 16 bits (2 bytes). The two bytes MUST be stored in little-endian order.
Note (non-normative):
bfloat16has the same exponent range asfloat32but with reduced mantissa precision. It is widely used in machine learning training and inference.
All 16-bit patterns are valid bfloat16 values, including infinities and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.
float32 (0x03)
float32 is the IEEE 754 binary32 format: 1 sign bit, 8 exponent bits, 23 significand bits. Total width is 32 bits (4 bytes). The four bytes MUST be stored in little-endian order.
All IEEE 754 binary32 bit patterns are valid. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.
float64 (0x04)
float64 is the IEEE 754 binary64 format: 1 sign bit, 11 exponent bits, 52 significand bits. Total width is 64 bits (8 bytes). The eight bytes MUST be stored in little-endian order.
All IEEE 754 binary64 bit patterns are valid. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.
Integer Types
| Type | Tag | Bit Width | Signed | Range |
|---|---|---|---|---|
int8 | 0x10 | 8 | yes | -128 to 127 |
uint8 | 0x11 | 8 | no | 0 to 255 |
int16 | 0x12 | 16 | yes | -32768 to 32767 |
uint16 | 0x13 | 16 | no | 0 to 65535 |
int32 | 0x14 | 32 | yes | -2147483648 to 2147483647 |
uint32 | 0x15 | 32 | no | 0 to 4294967295 |
int64 | 0x16 | 64 | yes | -2^63 to 2^63 - 1 |
uint64 | 0x17 | 64 | no | 0 to 2^64 - 1 |
All signed integer types use two's complement representation.
All multi-byte integer types MUST be stored in little-endian byte order.
int8 and uint8 occupy exactly one byte; byte order is not applicable.
All bit patterns within the specified width are valid for both signed and unsigned integer types. There are no trap representations.
Boolean Type
| Type | Tag | Bit Width | Description |
|---|---|---|---|
bool | 0x20 | 1 | Boolean, packed 8 per byte |
A bool element represents a logical true or false value. Each boolean occupies a single bit. Booleans are packed 8 per byte using LSB-first (least significant bit first) order.
Packing rule: logical element at index i within a group of 8 is stored in bit (i % 8) of byte floor(i / 8). Bit 0 is the least significant bit of the byte.
- A bit value of
0x1represents true. - A bit value of
0x0represents false.
When the total number of boolean elements is not a multiple of 8, the remaining high-order bits in the final byte MUST be set to 0x0.
Example: A 1-D boolean tensor with shape [5] and values [true, false, true, true, false] is stored as a single byte: 0x0D (binary 00001101). Bits 5, 6, and 7 are padding and MUST be zero.
Note (non-normative): This packing convention is identical to the one used by Apache Arrow for boolean arrays.
Tier 2 -- Extended Types
Tier 2 types are OPTIONAL. Conforming implementations MAY support any subset of Tier 2 types, including none. Implementations that do not support a given Tier 2 type MUST still reject (or, in permissive mode, skip) descriptors using that type tag according to the rules in the Type Identifier Encoding section.
Float8 Variants
| Type | Tag | Bit Width | Format | Description |
|---|---|---|---|---|
float8_e4m3 | 0x40 | 8 | 1-4-3 | IEEE-style: 1 sign, 4 exponent, 3 mantissa |
float8_e5m2 | 0x41 | 8 | 1-5-2 | IEEE-style: 1 sign, 5 exponent, 2 mantissa |
float8_e8m0 | 0x42 | 8 | 0-8-0 | Exponent-only: 8 exponent bits, no sign, no mantissa |
float8_e4m3 and float8_e5m2 follow the OCP (Open Compute Project) 8-bit Floating Point Specification (OFP8). Each occupies exactly 1 byte; byte order is not applicable.
float8_e4m3 (0x40): 1 sign bit, 4 exponent bits, 3 mantissa bits. Exponent bias is 7. NaN is represented by the bit patterns 0x7F and 0xFF (all exponent and mantissa bits set). There are no infinity representations.
float8_e5m2 (0x41): 1 sign bit, 5 exponent bits, 2 mantissa bits. Exponent bias is 15. This format supports infinities (0x7C and 0xFC) and NaN values (exponent all ones, mantissa non-zero).
float8_e8m0 (0x42): 8 exponent bits, no sign bit, no mantissa bits. This is a power-of-two scale factor format. The value is 2^(bits - 127) where bits is the unsigned 8-bit integer in the range [0x01, 0xFE]. The bit patterns 0x00 and 0xFF are reserved (NaN per OCP MX v1.0 § 5.6) and MUST NOT be used as data values. A reader encountering 0x00 or 0xFF in a float8_e8m0 buffer MUST treat the containing descriptor as invalid.
Note (non-normative):
float8_e8m0is primarily used as a scale factor type in microscaling (MX) quantization formats, not as a general-purpose element type.
Implementations MUST preserve all float8 bit patterns exactly during zero-copy interchange.
Sub-Byte Floating-Point Types
| Type | Tag | Bit Width | Format | Description |
|---|---|---|---|---|
float4_e2m1 | 0x43 | 4 | 1-2-1 | OCP MX: 1 sign, 2 exponent, 1 mantissa |
float6_e2m3 | 0x44 | 6 | 1-2-3 | OCP MX: 1 sign, 2 exponent, 3 mantissa |
float6_e3m2 | 0x45 | 6 | 1-3-2 | OCP MX: 1 sign, 3 exponent, 2 mantissa |
float4_e2m1 (0x43): 1 sign bit, 2 exponent bits, 1 mantissa bit. Exponent bias is 1. This is the MXFP4 format defined in the OCP Microscaling (MX) Specification. Each element occupies 4 bits.
float4_e2m1 uses the same LSB-first 4-bit packing as int4 (see § Sub-Byte Integer Types § 4-bit packing). When the total element count is odd, the high nibble of the final byte MUST be set to 0x0.
The complete value map, per OCP MX v1.0 § 5.2, is:
| Bit pattern | Value |
|---|---|
0x0 (0b0000) | +0.0 |
0x8 (0b1000) | -0.0 |
0x1–0x3 | positive subnormals: (mantissa / 2) * 2^(1-bias) = 0.5, 1.0 |
0x9–0xB | negative subnormals |
0x4–0x7 | positive normals: (1 + mantissa / 2) * 2^(exponent - bias) |
0xC–0xF | negative normals |
Maximum representable value: 1.5 * 2^2 = 6.0. There are no infinity or NaN representations; values outside [-6.0, 6.0] MUST be clamped by hardware.
Implementations MUST preserve all float4_e2m1 bit patterns exactly during zero-copy interchange.
Note (non-normative):
float4_e2m1has native Tensor Core support on NVIDIA Blackwell (B100/B200) and is used in production quantized LLM inference. It is typically paired withfloat8_e8m0block scales under the MX quantization scheme (seequantization.md).
float6_e2m3 (0x44) and float6_e3m2 (0x45): Both are OCP MX Specification 6-bit floating-point formats. Each element occupies 6 bits.
float6_e2m3: 1 sign bit, 2 exponent bits, 3 mantissa bits. Exponent bias is 1. Maximum representable value: (1 + 7/8) * 2^(3-1) = 3.75. Zero is represented by all-zero exponent and mantissa (sign-preserving). No infinity or NaN representations; out-of-range values MUST be clamped. Subnormals: exponent all-zero, mantissa non-zero → value = (mantissa/8) * 2^(1-bias). Normative bit-pattern → value mapping per OCP MX v1.0 § 5.3.
float6_e3m2: 1 sign bit, 3 exponent bits, 2 mantissa bits. Exponent bias is 3. Maximum representable value: (1 + 3/4) * 2^(7-3) = 28.0. Zero is represented by all-zero exponent and mantissa (sign-preserving). No infinity or NaN representations; out-of-range values MUST be clamped. Subnormals: exponent all-zero, mantissa non-zero → value = (mantissa/4) * 2^(1-bias). Normative bit-pattern → value mapping per OCP MX v1.0 § 5.4.
6-bit packing
Four 6-bit elements are packed into 3 bytes using LSB-first order across byte boundaries. Given four elements at logical indices 4k, 4k+1, 4k+2, 4k+3 stored in bytes B0, B1, B2:
| Element | Bits occupied |
|---|---|
4k+0 | bits [5:0] of B0 |
4k+1 | bits [7:6] of B0, bits [3:0] of B1 |
4k+2 | bits [7:4] of B1, bits [1:0] of B2 |
4k+3 | bits [7:2] of B2 |
When the total number of elements is not a multiple of 4, the unused high-order bits in the final group of 3 bytes MUST be set to 0x0.
Buffer size in bytes for N elements: ceil(N * 6 / 8) = ceil(N / 4) * 3.
Worked example. Four 6-bit elements with bit patterns 0b000001, 0b000010, 0b000100, 0b001000 (logical indices 0, 1, 2, 3) pack into bytes B0, B1, B2 as follows.
| Source | Bits taken | Destination | Resulting byte bits |
|---|---|---|---|
element 0 = 0b000001 | all 6 bits | B0[5:0] | B0[5:0] = 000001 |
element 1 = 0b000010 | bits [1:0] = 0b10 | B0[7:6] | B0[7:6] = 10 |
element 1 = 0b000010 | bits [5:2] = 0b0000 | B1[3:0] | B1[3:0] = 0000 |
element 2 = 0b000100 | bits [3:0] = 0b0100 | B1[7:4] | B1[7:4] = 0100 |
element 2 = 0b000100 | bits [5:4] = 0b00 | B2[1:0] | B2[1:0] = 00 |
element 3 = 0b001000 | all 6 bits | B2[7:2] | B2[7:2] = 001000 |
Assembling each byte (MSB on the left):
B0 = 10_000001 = 0x81
B1 = 0100_0000 = 0x40
B2 = 001000_00 = 0x20
The 3-byte packed group on the wire is therefore 0x81 0x40 0x20.
Implementations MUST preserve all float6_e2m3 and float6_e3m2 bit patterns exactly during zero-copy interchange.
Note (non-normative):
float6_e2m3andfloat6_e3m2are defined in the OCP MX specification and intended for use with MX block quantization (seequantization.md). Hardware adoption is currently limited compared to MXFP4.
Extended Floating-Point Types
| Type | Tag | Bit Width | Description |
|---|---|---|---|
float128 | 0x46 | 128 | IEEE 754 binary128 (quad precision) |
float128 (0x46): 1 sign bit, 15 exponent bits, 112 significand bits. Exponent bias is 16383. Total width is 128 bits (16 bytes). The sixteen bytes MUST be stored in little-endian order.
All IEEE 754 binary128 bit patterns are valid, including positive and negative zero, infinities, and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.
Note (non-normative):
float128is rarely used in ML inference. It is included for high-precision scientific computing workloads (e.g., physics simulations, climate modelling) that may share tensor data with inference pipelines via the array database use case (Core Property 10).
Sub-Byte Integer Types
| Type | Tag | Bit Width | Signed | Range |
|---|---|---|---|---|
int4 | 0x48 | 4 | yes | -8 to 7 |
uint4 | 0x49 | 4 | no | 0 to 15 |
int2 | 0x4A | 2 | yes | -2 to 1 |
uint2 | 0x4B | 2 | no | 0 to 3 |
Sub-byte integer types are packed into bytes using LSB-first order, analogous to the boolean packing rule.
4-bit packing
Two 4-bit elements are packed per byte. The element at even logical index 2k occupies bits [3:0] (the low nibble) of byte k. The element at odd logical index 2k+1 occupies bits [7:4] (the high nibble) of byte k.
int4 values use two's complement representation within 4 bits. The valid bit patterns are 0x0 through 0xF, representing values -8 (0x8) through 7 (0x7).
uint4 values are unsigned. The valid bit patterns are 0x0 through 0xF, representing values 0 through 15.
When the total number of 4-bit elements is odd, the high nibble of the final byte MUST be set to 0x0.
Example: A 1-D uint4 tensor with shape [3] and values [5, 12, 3] is stored as two bytes. Byte 0: element 0 in low nibble, element 1 in high nibble = 0xC5. Byte 1: element 2 in low nibble, padding in high nibble = 0x03.
2-bit packing
Four 2-bit elements are packed per byte. The element at logical index 4k + j (where 0 <= j < 4) occupies bits [2j+1 : 2j] of byte k.
| Position in byte | Logical index offset | Bits |
|---|---|---|
| 0 | 4k + 0 | [1:0] |
| 1 | 4k + 1 | [3:2] |
| 2 | 4k + 2 | [5:4] |
| 3 | 4k + 3 | [7:6] |
int2 values use two's complement representation within 2 bits. The valid bit patterns are 0b00 (0), 0b01 (1), 0b10 (-2), 0b11 (-1).
uint2 values are unsigned. The valid bit patterns are 0b00 (0), 0b01 (1), 0b10 (2), 0b11 (3).
When the total number of 2-bit elements is not a multiple of 4, the unused high-order bits in the final byte MUST be set to 0x0.
Example: A 1-D uint2 tensor with shape [3] and values [3, 1, 2] is stored as one byte. Element 0 in bits [1:0] = 0b11, element 1 in bits [3:2] = 0b01, element 2 in bits [5:4] = 0b10, padding in bits [7:6] = 0b00. Result: 0x27 (binary 00100111).
Complex Types
| Type | Tag | Bit Width | Description |
|---|---|---|---|
complex64 | 0x50 | 64 | Two float32 values (real, imaginary) |
complex128 | 0x51 | 128 | Two float64 values (real, imaginary) |
A complex64 element consists of two consecutive float32 values: the real part followed by the imaginary part. Total width is 64 bits (8 bytes). Each constituent float32 MUST be stored in little-endian order.
A complex128 element consists of two consecutive float64 values: the real part followed by the imaginary part. Total width is 128 bits (16 bytes). Each constituent float64 MUST be stored in little-endian order.
Note (non-normative): Complex types are included for signal processing and scientific computing workloads that may share tensor data with ML inference pipelines. They are not commonly needed in LLM inference.
Type Properties Summary
The following table summarizes all defined types and their properties.
| Type | Tag | Tier | Bit Width | Byte Width | Sub-Byte | Alignment (bytes) |
|---|---|---|---|---|---|---|
float16 | 0x01 | 1 | 16 | 2 | no | 2 |
bfloat16 | 0x02 | 1 | 16 | 2 | no | 2 |
float32 | 0x03 | 1 | 32 | 4 | no | 4 |
float64 | 0x04 | 1 | 64 | 8 | no | 8 |
int8 | 0x10 | 1 | 8 | 1 | no | 1 |
uint8 | 0x11 | 1 | 8 | 1 | no | 1 |
int16 | 0x12 | 1 | 16 | 2 | no | 2 |
uint16 | 0x13 | 1 | 16 | 2 | no | 2 |
int32 | 0x14 | 1 | 32 | 4 | no | 4 |
uint32 | 0x15 | 1 | 32 | 4 | no | 4 |
int64 | 0x16 | 1 | 64 | 8 | no | 8 |
uint64 | 0x17 | 1 | 64 | 8 | no | 8 |
bool | 0x20 | 1 | 1 | n/a | yes | 1 |
float8_e4m3 | 0x40 | 2 | 8 | 1 | no | 1 |
float8_e5m2 | 0x41 | 2 | 8 | 1 | no | 1 |
float8_e8m0 | 0x42 | 2 | 8 | 1 | no | 1 |
float4_e2m1 | 0x43 | 2 | 4 | n/a | yes | 1 |
float6_e2m3 | 0x44 | 2 | 6 | n/a | yes | 1 |
float6_e3m2 | 0x45 | 2 | 6 | n/a | yes | 1 |
float128 | 0x46 | 2 | 128 | 16 | no | 16 |
int4 | 0x48 | 2 | 4 | n/a | yes | 1 |
uint4 | 0x49 | 2 | 4 | n/a | yes | 1 |
int2 | 0x4A | 2 | 2 | n/a | yes | 1 |
uint2 | 0x4B | 2 | 2 | n/a | yes | 1 |
complex64 | 0x50 | 2 | 64 | 8 | no | 4 |
complex128 | 0x51 | 2 | 128 | 16 | no | 8 |
The Alignment column specifies the minimum alignment requirement of the natural element type. This is the minimum alignment of an individual element within a contiguous buffer; the buffer itself has a separate, stricter alignment requirement (see buffer-protocol.md).
For sub-byte types, the alignment refers to the packed byte granularity: data for sub-byte types MUST start at a byte boundary within the buffer.
Tag 0x47 is reserved for future assignment by this specification. Implementations MUST NOT use tag 0x47. Private extensions MUST NOT assign tag 0x47.
Note (non-normative): Tag
0x47does not appear in the table because it is intentionally reserved. It is held for a future Tier 2 type whose assignment will be defined in a later revision.
Note (non-normative): The alignment column for complex types reflects the natural alignment of the constituent floating-point element:
complex64lists alignment4(perfloat32half) andcomplex128lists alignment8(perfloat64half). This matches the natural alignment of the constituent element type. Consumers loading a full complex value as a single 128-bit SIMD register must account for the fact that only the buffer-level alignment (64 bytes minimum, seebuffer-protocol.md) guarantees register-width alignment, not the element-level alignment.
Buffer Size Calculation
For whole-byte types with bit width W >= 8, the minimum buffer size in bytes for a contiguous tensor with N total elements is:
buffer_size = N * (W / 8)
For sub-byte types with bit width B < 8, the general minimum buffer size in bytes for a contiguous tensor with N total elements is:
buffer_size = ceil(N * B / 8)
where ceil denotes the ceiling function (rounding up to the next integer).
For sub-byte bit widths B ∈ {1, 2, 4} (i.e. bool, int2/uint2, int4/uint4/float4_e2m1), 8 / B is an integer packing factor P and the formula reduces to the equivalent expression ceil(N / P). For B = 6 (float6_e2m3 and float6_e3m2), the general form ceil(N * 6 / 8) = ceil(N / 4) * 3 MUST be used, because four 6-bit elements pack into three bytes rather than into a whole number of elements per byte; see § Sub-Byte Floating-Point Types § 6-bit packing.
These formulas apply to contiguous (dense) layouts. For strided layouts, the buffer size depends on the strides; see memory-layout.md.
Interaction with Other Sections
- Quantization (
quantization.md): Quantized tensors use an element type from this section as their storage type and attach a quantization descriptor that defines the dequantization mapping. The storage type tag in the tensor descriptor always refers to a type defined here. - Memory Layout (
memory-layout.md): Stride semantics for sub-byte types require special treatment. Strides are expressed in logical elements; the packing rules in this section define how logical element indices map to bit positions within the buffer. - Metadata (
metadata.md): The type tag defined here is stored as auint8field in the binary tensor descriptor.
Relationship to the standard numeric vocabulary
Note (non-normative): The Tier 1 numeric types —
bool,int8,uint8,int16,uint16,int32,uint32,int64,uint64,float16,bfloat16,float32,float64,complex64,complex128— use the standard numeric dtype vocabulary shared by NumPy and the Python Array API Standard (data-apis.org/array-api). This alignment is intentional and is purely an interop convenience: a Hurray tensor carrying a Tier 1 element type maps to the corresponding NumPy dtype without translation when handed to the ecosystem. It does not imply that a Hurray tensor is an Array API array (see Python Bindings and ADR-029).
Note (non-normative): Tier 2 types (
float8_e4m3,float8_e5m2,float8_e8m0,float4_e2m1,float6_e2m3,float6_e3m2,float128,int4,uint4,int2,uint2) and quantized tensor types (seequantization.md) have no standard NumPy dtype and are exposed ashurray-namespaced dtype objects in the Python bindings. Requirements for the Python bindings are defined in Python Bindings.
Open Questions
[OQ-1]:
ShouldResolved:float8_e4m3follow the OCP OFP8 convention (no infinities, two NaN values) or the IEEE 754 draft for binary8 (which may differ)?float8_e4m3normatively follows OCP OFP8 (no infinities, two NaN bit patterns, exponent bias 7), matching production hardware (NVIDIA H100/H200, AMD MI300). If IEEE 754 binary8 diverges when finalized, a separate type tag will be assigned rather than redefining this one.
[OQ-2]:
Should the specification define aResolved:float128(IEEE 754 binary128) type?float128is added as a Tier 2 type with tag0x46. Note: the originally proposed tag0x05falls in the Tier 1 range (0x01–0x3F) and was corrected to0x46(Tier 2 range0x40–0x7F). Rationale: high-precision scientific computing workloads sharing tensor data with inference pipelines via the array database use case (Core Property 10).
[OQ-3]:
Should the private extension range (Resolved by ADR-001: Private extension type tags MUST carry an inline descriptor encoding at minimum the bit width, packing, and floating-point parameters (sign/exponent/mantissa widths, exponent bias, NaN/Inf flags). See0xF0--0xFE) require implementations to include a type descriptor (name, bit width) in the tensor metadata so that readers can at least compute buffer sizes for unknown types?docs/adr/ADR-001-private-extension-type-descriptors.md. The descriptor binary encoding will be defined inmetadata.md.
[OQ-4]:
ShouldResolved:float4_e2m1(MXFP4) be added as a Tier 2 type?float4_e2m1is added as Tier 2 with tag0x43. Packing follows the LSB-firstint4convention (two elements per byte). Rationale: native Tensor Core support on NVIDIA Blackwell and production use in quantized LLM inference.
[OQ-5]:
ShouldResolved: Both are added as Tier 2 with tagsfloat6_e2m3andfloat6_e3m2be added as Tier 2 types?0x44and0x45respectively. Packing: 4 elements per 3 bytes, LSB-first across byte boundaries. Buffer size:ceil(N / 4) * 3bytes.
Quantization — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines the binary encoding of the quantization descriptor: the
opaque payload that appears inside the Quantization Section of a tensor descriptor
(see metadata.md § Quantization Section). It specifies the set of supported
quantization schemes, the wire format for each scheme's parameters, and the
normative dequantization formula that a reader MUST apply to recover real-valued
elements from the quantized storage buffer.
A Hurray quantized tensor has two parts:
- A storage type — an integer or float8 type tag defined in
element-types.mdthat describes how each element is encoded in the tensor's data buffer. - A quantization descriptor — the subject of this file, which describes how to map those storage values to real-valued elements.
Note (non-normative): The tensor descriptor's
type_tagfield always refers to the storage type. Hurray does not allocate separate type tags for quantized formats; a tensor is quantized if and only if theHAS_QUANTIZATIONflag (bit 0 offlags) is set in the tensor descriptor. This separation keeps the type system orthogonal to the quantization scheme space.
Relationship to Other Sections
metadata.mddefines the Quantization Section framing. The section is present if and only if theHAS_QUANTIZATIONflag (bit 0) is set in the tensor descriptor. It consists of auint32quantization_lengthprefix followed byquantization_lengthbytes of payload. This file defines the contents of those payload bytes.element-types.mddefines the storage type tags (type_tagvalues) that may appear in a quantized tensor descriptor. Each scheme below lists the set of storage types it accepts.memory-layout.mddefines the buffer table: an ordered list of buffer handles indexed from0. Several schemes store their per-block scale and zero-point arrays in buffers other than buffer 0; those schemes reference those buffers by index.
Descriptor Header
Every quantization descriptor begins with a fixed 4-byte header.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | Identifies the quantization scheme. Values are assigned below. |
| 1 | scheme_version | uint8 | Version of the scheme-specific encoding. Current value for all schemes defined here: 0x01. |
| 2 | flags | uint16 | Scheme-specific flags bitmask. Reserved bits MUST be 0. |
All multi-byte fields in the quantization descriptor MUST be encoded in little-endian byte order.
Immediately following the 4-byte header, scheme-specific fields are encoded.
The total length of the descriptor (header plus scheme-specific fields, plus any
trailing padding chosen by the writer) MUST equal the quantization_length
prefix defined in metadata.md.
A reader MUST dispatch on scheme_tag after reading the first byte. A reader
that does not recognise scheme_tag MUST reject the tensor descriptor, unless
operating in permissive mode. In permissive mode, the reader MAY skip past the
descriptor using the quantization_length prefix but MUST NOT dereference the
tensor data buffer.
A reader MUST reject a descriptor whose scheme_version exceeds the highest
version defined in this specification for the given scheme_tag.
A reader MUST reject a descriptor with any reserved flags bit set.
A reader MUST NOT read beyond quantization_length bytes when parsing the
descriptor.
Scheme Tag Space
| Range | Allocation |
|---|---|
0x00 | Reserved (invalid) |
0x01 – 0x3F | Tier 1 schemes (MUST be supported by conforming implementations that advertise quantization support) |
0x40 – 0x5F | Tier 2 schemes (OPTIONAL) |
0x60 – 0x7F | Reserved for future nested/composite schemes — implementations MUST NOT assign tags in this range |
0x80 – 0xEF | Reserved for future specification versions |
0xF0 – 0xFE | Implementation-private extension schemes |
0xFF | Reserved (invalid) |
A reader MUST reject a descriptor whose scheme_tag is 0x00 or 0xFF.
Implementations MUST NOT assign semantics to scheme tags in the range
0x60–0x7F; these are reserved for future nested/composite quantization
schemes defined by this specification.
Tags in the range 0x80 – 0xEF MUST NOT be used by any implementation; they
are reserved for future specification versions.
Tags in the range 0xF0 – 0xFE MAY be used by implementations for private
schemes. Tensors using private scheme tags MUST NOT be exchanged between
independent implementations unless both parties have agreed on the semantics
out of band.
Note (non-normative): The three block-quantization schemes defined in this specification have different
block_sizelower bounds, reflecting their distinct design constraints:
- Per-block-affine (Tier 1,
0x03):block_size ≥ 2. Any positive block size with even packing makes algebraic sense; the lower bound exists only to forbid degenerate single-element blocks.- NF4 (Tier 2,
0x04):block_size ≥ 8. Below 8 elements per block the 16-point NF4 information content provides no statistical benefit over a plain low-bit linear quantization, so the format is not meaningful at smaller block sizes.- MXFP (Tier 2,
0x05):block_size ≥ 16. Tier 2 microscaling formats require hardware Tensor Core support; 16 is the minimum block size for which any production silicon (NVIDIA Blackwell) currently implements MX compute.
Assigned Scheme Tags
| Scheme | Tag | Tier |
|---|---|---|
| Per-Tensor Affine | 0x01 | 1 |
| Per-Channel Affine | 0x02 | 1 |
| Per-Block Affine | 0x03 | 1 |
| NF4 (NormalFloat4) | 0x04 | 2 |
| MXFP (OCP Microscaling) | 0x05 | 2 |
Partial Block Policy
The block-quantization schemes defined in this specification differ in whether
they permit a partial trailing block — a final block along axis containing
fewer than block_size valid logical elements. The active scheme's policy
applies; readers implementing multiple schemes MUST apply the partial-block
rule of the active scheme.
- Per-block-affine (
0x03): a partial trailing block is permitted. The storage buffer MUST still allocate space for a full block ofblock_sizeelements; the unused trailing storage elements MUST be zero bytes and a reader MUST ignore them when dequantizing. The scale (and zero-point) arrays MUST contain one entry per block, including the partial final block. Seequantization/per-block-affine.md§ Padding for the normative encoding. - NF4 (
0x04): a partial trailing block is permitted under the same rules as per-block-affine, since NF4 derives its block index computation from per-block-affine. Seequantization/per-block-affine.md§ Padding (NF4 uses identical padding rules). - MXFP (
0x05): a partial trailing block is NOT permitted.shape[axis]MUST be a positive multiple ofblock_size. This restriction is normative under the OCP MX v1.0 specification: microscaling hardware assumes exact block alignment along the quantized axis. A reader MUST reject an MXFP descriptor whoseshape[axis]is not a positive multiple ofblock_size. Seequantization/mxfp.md§ Validity Constraints.
A reader that supports multiple schemes MUST dispatch on scheme_tag and
apply the corresponding partial-block rule. A reader MUST NOT cross-apply the
permissive per-block-affine padding rule to MXFP, nor reject a partial final
block of a per-block-affine or NF4 tensor on the basis of the MXFP exact-
multiple constraint.
Zero-Point Convention
The affine schemes (0x01, 0x02, 0x03) dequantize by subtracting
zero_point from the storage value. The stored zero point is the value
subtracted: Hurray defines no implicit bias, and the descriptor carries no field
selecting an alternative convention.
A writer converting a tensor from an external quantization toolchain MUST normalize that toolchain's zero-point values to this convention before placing them in a Hurray buffer.
Note (non-normative): Some external toolchains store a biased zero point and remove the bias in the loader rather than recording it in the file. GPTQ is the widely deployed case: its packed zero-point plane has conventionally held
zero_point - 1. A descriptor built from un-normalized values is structurally valid — it satisfies every validity constraint of its scheme, and a reader has no way to detect the discrepancy — but dequantizes every affected element off by one scale step. Hurray records the zero point, not the convention that produced it, so this normalization is the writer's responsibility and cannot be recovered downstream.
Buffer Table Placement Rules
Every scheme that references a buffer (per-channel, per-block, NF4, MXFP) adds entries to the tensor descriptor's buffer table beyond the buffers used by the layout itself. The following rules apply across all quantization schemes:
- The tensor data buffer always occupies a buffer table index determined by
the layout (typically
0for dense layouts). Quantization-parameter buffers MUST occupy distinct indices. - A quantization-parameter buffer MUST NOT be shared with the tensor data buffer. A reader MUST reject a descriptor that violates this rule.
- Two quantization-parameter buffers (e.g., scale and zero-point) MAY reside
in the same buffer table entry if and only if the entry's
byte_sizeaccommodates both arrays laid out end-to-end, and both arrays start at byte offset0. Since each parameter descriptor field specifies its own*_buffer_index, this case is expressed by writing the same index into both fields; readers MUST then interpret the buffer as the concatenation[scales | zero_points]. Writers SHOULD prefer distinct buffers for clarity; sharing is permitted only as an optimization. - A quantization-parameter buffer's
device_tag(seemetadata.mdbuffer handle format) MUST match the tensor data buffer'sdevice_tag. A reader MUST reject a descriptor that violates this rule.
Note (non-normative): The device-colocation rule ensures that quantized tensor kernels can dereference both the data and the quantization parameters without triggering cross-device transfers. A writer that needs to materialize quantization parameters on a different device must emit a separate tensor.
Extension Schemes (0xF0 – 0xFE)
Implementations MAY define private quantization schemes using scheme tags in
0xF0 – 0xFE. The binary encoding of an extension scheme descriptor is
unconstrained beyond the fixed 4-byte header; the writer and reader MUST agree
on the payload format out of band.
Note (non-normative): Extension schemes are the mechanism for implementation-specific or experimental quantization formats (e.g., GPTQ, AWQ group quantization with per-group permutations, or hardware-vendor- specific packings). Schemes that prove broadly useful are candidates for assignment in the Tier 1 or Tier 2 range through a specification revision.
What sends these formats here is the per-group permutation — GPTQ activation-order grouping reorders the quantized axis, which per-block affine cannot express. The permutation-free subset is a different case: a group-quantized tensor whose groups are contiguous along one axis is representable under per-block affine (
0x03) by choosing the appropriate storage type, block size, and flags, and does not need an extension scheme. See § Zero-Point Convention for the normalization such a conversion requires.
Version Compatibility
Adding a new scheme tag MUST be accompanied by a minor version increment of the
overall format (version_minor in the fixed header; see metadata.md). A
reader at an earlier minor version will encounter an unrecognised scheme_tag
and reject the descriptor per the rules above, which is the intended behaviour.
Adding a new field to an existing scheme (or repurposing a reserved byte) MUST
be accompanied by a scheme_version increment for that scheme. A reader MUST
compare scheme_version against the highest version it supports for the given
scheme_tag and reject the descriptor if the version is newer than supported.
Removing a scheme or changing the dequantization formula for an existing
(scheme_tag, scheme_version) pair is a backward-incompatible change and MUST
be accompanied by a major version increment.
Open Questions
Double / nested quantization (deferred). This version does not define a normative
encoding for double quantization (quantizing the scale buffer itself, as in bitsandbytes
QLoRA "nested" quantization). The scheme-tag range 0x60–0x7F is reserved for a future
nested/composite scheme (see § Scheme Tag Space). A conforming implementation MAY express
double quantization today by representing the scale tensor as a separate quantized tensor
using existing scheme tags, with the relationship conveyed by application-layer convention.
Note (non-normative): A recursive or two-level scale descriptor is deferred until the base quantization encoding has been validated through implementation; double quantization is primarily a weight-storage optimization rather than a runtime interchange primitive. The intended future occupant of
0x60–0x7Fis a nested-scale scheme compatible with bitsandbytes-style double quantization (NF4 data with quantizedfloat8scales and afloat32super-scale), to be specified in a future revision once implementation experience is available.
[OQ-2]:
Should MXFPResolved:block_sizebe fixed at 32 or parameterised?block_sizeis parameterised. The field already exists in the binary encoding; the constraint is: MUST be a power of two in[16, 2048]. The lower bound of 16 excludes values with no hardware Tensor Core support. The OCP MX v1.0 canonical value of32is documented as the default. Rationale: avoids scheme tag proliferation for what is effectively one scheme with a size variation; future OCP revisions and hardware variants can use different block sizes under the same scheme tag.
[OQ-3]:
Should per-channel affine support lower-precision scale types?Resolved: Per-channel scales remain locked tofloat32(scale_type_tag = 0x03). Rationale: storage saving is negligible (~8–16 KB per layer) while accuracy cost is real for precision-critical per-channel weight quantization. Ascale_type_tagfield (+ 3 reserved bytes) has been added to the per-channel affine binary encoding at offset 16 to allow future relaxation without a wire-format break.
[OQ-4]:
Should the descriptor include an explicitResolved:num_blocksfield?num_blocksremains derived:num_blocks = shape[axis] / block_size. Rationale: the value is fully determined by fields already present in the descriptor; an explicit field would be redundant and introduce a new mismatch failure mode.
[OQ-5]:
Should the NF4 lookup table be duplicated into a normative reference appendix?Resolved: The table remains inline inquantization/nf4.md. Rationale: duplication would risk the two copies diverging; a single source of truth is safer. Spec audits (spec-checker) are the guard against accidental mutation.
Per-Tensor Affine Quantization — Hurray Format Specification
Scheme tag: 0x01 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
A single scale and zero_point pair applies to every element of the tensor.
This scheme covers both asymmetric quantization (arbitrary zero_point) and
symmetric quantization (zero_point = 0).
The scale and zero-point are stored inline in the quantization descriptor; no additional buffer table entries are required beyond the tensor data buffer.
Binary Encoding
Total descriptor length: 16 bytes.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | MUST be 0x01. |
| 1 | scheme_version | uint8 | MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility. |
| 2 | flags | uint16 | MUST be 0x0000. No flags are defined for this scheme. |
| 4 | scale | float32 | Dequantization scale. MUST be a finite, non-zero value. |
| 8 | zero_point | int32 | Quantization zero point. For symmetric quantization, MUST be 0x00000000. |
| 12 | _reserved | uint8[4] | MUST be 0x00. |
All multi-byte fields MUST be encoded in little-endian byte order.
Dequantization Formula
For each storage element q:
x_real = scale * (q - zero_point)
The subtraction q - zero_point is performed in signed 32-bit integer
arithmetic. The result is then cast to float32 and multiplied by scale in
float32 arithmetic. The real-valued element type produced by dequantization
is float32. A consumer MAY further convert to float64 or to a
lower-precision float type; such conversion is out of scope for this
specification.
zero_point is subtracted exactly as stored. See quantization.md
§ Zero-Point Convention for the normalization a writer MUST apply when
converting from a toolchain that stores a biased zero point.
Validity Constraints
scaleMUST NOT be zero, NaN, or infinity. A reader MUST reject a descriptor that violates this constraint.zero_pointMUST lie within the representable range of the storage type. For example, for auint8storage type,zero_pointMUST be in[0, 255]. A reader MUST reject a descriptor that violates this constraint.- The
_reservedbytes MUST be0x00. A reader MUST reject a descriptor that violates this constraint.
Valid Storage Types
The storage type (type_tag in the tensor descriptor) MUST be one of:
int8(0x10),uint8(0x11)int16(0x12),uint16(0x13)int32(0x14),uint32(0x15)int4(0x48),uint4(0x49)int2(0x4A),uint2(0x4B)
A reader MUST reject a descriptor whose storage type is not in this list.
Per-Channel Affine Quantization — Hurray Format Specification
Scheme tag: 0x02 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
One scale and zero_point pair per slice along a specified axis. The scale
and zero-point arrays are stored in separate buffers listed in the tensor
descriptor's buffer table.
Note (non-normative): Because dense-layout descriptors require
buffer_count = 0x01, a per-channel-affine quantized dense tensor requiresbuffer_countto be at least 2 (symmetric case) or 3 (asymmetric case). This is the mechanism by which quantization schemes extend the buffer table beyond the dense-layout minimum.
Binary Encoding
Total descriptor length: 20 bytes.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | MUST be 0x02. |
| 1 | scheme_version | uint8 | MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility. |
| 2 | flags | uint16 | Scheme-specific flags (see below). Reserved bits MUST be 0. |
| 4 | axis | uint32 | Index of the quantized axis. MUST be strictly less than rank. |
| 8 | scale_buffer_index | uint32 | Index in the buffer table of the buffer holding the scale array. |
| 12 | zero_point_buffer_index | uint32 | Index in the buffer table of the buffer holding the zero_point array. |
| 16 | scale_type_tag | uint8 | Storage type of the scale values. MUST be 0x03 (float32) in this version of the specification. Reserved for future lower-precision scale types. |
| 17 | _reserved | uint8[3] | MUST be 0x00. |
All multi-byte fields MUST be encoded in little-endian byte order.
Flags bits:
| Bit | Name | Meaning |
|---|---|---|
| 0 | SYMMETRIC | If set, the zero_point array is implicitly all zeros; zero_point_buffer_index MUST be 0xFFFFFFFF. |
| 1–15 | (reserved) | MUST be 0. |
Referenced Buffers
The scale buffer MUST contain exactly shape[axis] consecutive float32
values in little-endian byte order, starting at byte offset 0 within the
referenced buffer. Its byte size MUST be exactly shape[axis] * 4.
The zero_point buffer — present only if the SYMMETRIC flag is not set —
MUST contain exactly shape[axis] consecutive int32 values in little-endian
byte order, starting at byte offset 0 within the referenced buffer. Its byte
size MUST be exactly shape[axis] * 4.
Note (non-normative): The
zero_pointbuffer usesint32regardless of the storage type's bit width (e.g.int4orint2). This simplifies alignment and avoids sub-byte zero-point packing. The Validity Constraints section enforces that zero-point values lie within the representable range of the storage type, so the wider container does not introduce additional degrees of freedom on the wire.
A reader MUST reject a descriptor whose scale_buffer_index or (when the
SYMMETRIC flag is not set) zero_point_buffer_index is greater than or equal
to buffer_count in the buffer table.
A reader MUST reject a descriptor whose scale_buffer_index or
zero_point_buffer_index equals the buffer index used by the layout for tensor
data (typically 0 for dense layouts).
Dequantization Formula
For a storage element q at logical index [i_0, i_1, ..., i_{rank-1}]:
c = i_axis
x_real = scale[c] * (q - zero_point[c])
If the SYMMETRIC flag is set, zero_point[c] is treated as 0 for all c.
zero_point[c] is subtracted exactly as stored. See quantization.md
§ Zero-Point Convention for the normalization a writer MUST apply when
converting from a toolchain that stores a biased zero point.
Validity Constraints
axisMUST satisfyaxis < rank.shape[axis]MUST NOT equal0xFFFFFFFFFFFFFFFF(the dynamic dimension sentinel): per-channel quantization requires a statically known channel count.scale_type_tagMUST be0x03whenscheme_version = 0x01. Future scheme versions MAY define additional values. A reader MUST reject ascheme_version = 0x01descriptor with any other value.- The
_reservedbytes MUST be0x00whenscheme_version = 0x01. A reader MUST reject ascheme_version = 0x01descriptor with any non-zero reserved byte. - Every element of the
scalearray MUST be a finite, non-zerofloat32value. - Every element of the
zero_pointarray (when present) MUST lie within the representable range of the storage type.
A reader MAY defer the per-element validity check on the scale and zero-point arrays to the first dequantization attempt, but MUST perform the axis and shape checks before accepting the descriptor.
Valid Storage Types
The storage type (type_tag in the tensor descriptor) MUST be one of:
int8(0x10),uint8(0x11)int16(0x12),uint16(0x13)int32(0x14),uint32(0x15)int4(0x48),uint4(0x49)int2(0x4A),uint2(0x4B)
A reader MUST reject a descriptor whose storage type is not in this list.
Worked Example
A rank-2 int8-stored weight tensor with shape [768, 1024], per-channel
affine quantization along axis 0 (asymmetric), no statistics or shard sections.
The tensor descriptor's buffer table carries three buffers:
- Buffer 0 — tensor data,
768 * 1024 = 786432bytes,int8storage. - Buffer 1 — scale array,
768 * 4 = 3072bytes,float32. - Buffer 2 — zero-point array,
768 * 4 = 3072bytes,int32.
Quantization descriptor bytes (20 total):
Offset Value (hex) Field
------ ---------------------------- -----
0 02 scheme_tag = 0x02 (per-channel affine)
1 01 scheme_version = 1
2 00 00 flags = 0x0000 (asymmetric)
4 00 00 00 00 axis = 0
8 01 00 00 00 scale_buffer_index = 1
12 02 00 00 00 zero_point_buffer_index = 2
16 03 scale_type_tag = 0x03 (float32)
17 00 00 00 _reserved = 0x00
The quantization_length prefix in the tensor descriptor's Quantization
Section would be 0x00000014 (20).
Dequantization of element q at logical position [c, k]:
x_real = scale[c] * (q - zero_point[c])
Per-Block Affine Quantization — Hurray Format Specification
Scheme tag: 0x03 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The tensor is divided into fixed-size, contiguous blocks along a specified axis.
Each block carries its own scale (and optionally zero_point). This scheme
covers the single-level GGUF block-quantized formats — those carrying one
scale, and optionally one zero point, per block (e.g., Q8_0, Q4_0, Q4_1) —
at the descriptor level; those layouts are representable by choosing the
appropriate storage type, block size, and flags.
The GGUF K-quant family (Q2_K–Q6_K) is not expressible under this
scheme. Those formats apply a second, super-block scaling level: the per-block
scales are themselves quantized and scaled by a shared factor, which this
scheme's single scale array cannot represent. See quantization.md § Open
Questions, where nested and two-level scale descriptors are deferred to the
reserved 0x60–0x7F tag range.
Binary Encoding
Total descriptor length: 24 bytes.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | MUST be 0x03. |
| 1 | scheme_version | uint8 | MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility. |
| 2 | flags | uint16 | Scheme-specific flags (see below). Reserved bits MUST be 0. |
| 4 | axis | uint32 | Index of the axis along which the tensor is divided into blocks. MUST be strictly less than rank. |
| 8 | block_size | uint32 | Number of logical elements per block along axis. MUST be a power of two and MUST be greater than or equal to 2. |
| 12 | scale_buffer_index | uint32 | Index in the buffer table of the buffer holding the scale array. |
| 16 | zero_point_buffer_index | uint32 | Index in the buffer table of the buffer holding the zero_point array. Ignored when the SYMMETRIC flag is set; writers SHOULD set it to 0xFFFFFFFF in that case. |
| 20 | scale_type_tag | uint8 | Storage type of the scale values. MUST be 0x01 (float16), 0x02 (bfloat16), or 0x03 (float32). |
| 21 | _reserved | uint8[3] | MUST be 0x00. |
All multi-byte fields MUST be encoded in little-endian byte order.
Flags bits:
| Bit | Name | Meaning |
|---|---|---|
| 0 | SYMMETRIC | If set, the zero_point array is implicitly all zeros; zero_point_buffer_index MUST be 0xFFFFFFFF. |
| 1–15 | (reserved) | MUST be 0. |
Block Layout
Let S = shape[axis] (resolved; MUST NOT be the dynamic dimension sentinel) and
K = block_size. The number of blocks along axis is:
num_blocks_per_axis = ceil(S / K)
The total number of blocks across the whole tensor is:
num_blocks = num_blocks_per_axis * product(shape[j] for j != axis)
Block index b at a tensor position [i_0, ..., i_{rank-1}] is computed as
follows. Let outer be the linear index formed from all dimensions except
axis using row-major order over those dimensions. Then:
b = outer * num_blocks_per_axis + floor(i_axis / K)
Note (non-normative): This mapping preserves the tensor's row-major traversal order along non-quantized dimensions, which matches GGUF's linear layout for 2-D weight matrices. Writers targeting column-major traversal SHOULD use a column-major tensor layout rather than altering the block mapping.
Padding
If S is not a multiple of K, the final block along axis for each outer
position contains only S mod K valid elements. The storage buffer MUST still
allocate space for a full block of K elements; the unused trailing elements
within the final block MUST be set to 0x00 bytes by the writer. A reader MUST
ignore these padding elements when dequantizing.
The scale (and zero-point) arrays MUST contain one entry per block, including the partial final block.
Referenced Buffers
The scale buffer MUST contain exactly num_blocks consecutive values of
scale_type_tag in little-endian byte order, starting at byte offset 0. Its
byte size MUST be exactly num_blocks * sizeof(scale_type_tag).
The zero_point buffer — present only if the SYMMETRIC flag is not set —
MUST contain exactly num_blocks consecutive int32 values in little-endian
byte order, starting at byte offset 0. Its byte size MUST be exactly
num_blocks * 4.
A reader MUST reject a descriptor whose scale_buffer_index or (when
applicable) zero_point_buffer_index is greater than or equal to
buffer_count.
Dequantization Formula
Let b be the block index for a storage element q at logical position
[i_0, ..., i_{rank-1}], computed as above. Let s = scale[b] and, if the
SYMMETRIC flag is set, z = 0, else z = zero_point[b].
x_real = s * (q - z)
The multiplication is performed in float32 arithmetic. If scale_type_tag is
float16 or bfloat16, s MUST be widened to float32 losslessly before the
multiplication.
z is subtracted exactly as stored. See quantization.md § Zero-Point
Convention for the normalization a writer MUST apply when converting from a
toolchain that stores a biased zero point — the case that arises when a
group-quantized GPTQ or AWQ tensor without activation-order permutation is
mapped onto this scheme.
Validity Constraints
axisMUST satisfyaxis < rank.block_sizeMUST be a power of two and MUST be greater than or equal to2.- When
shape[axis]is greater than0,block_sizeMUST be less than or equal toshape[axis]. A reader MUST reject a descriptor whoseblock_sizeexceeds a non-zeroshape[axis]. - When
shape[axis]equals0(an empty quantization axis, per ADR-007), the upper-bound check is waived. Theblock_sizefield MUST still be a power of two greater than or equal to2, but its value has no effect on buffer sizing:num_blocksevaluates to0, and the scale and zero-point buffers MUST have byte size0(their pointers MAY be null perbuffer-protocol.md). shape[axis]MUST NOT equal0xFFFFFFFFFFFFFFFF.- Every
scalevalue MUST be a finite, non-zero value. scale_type_tagMUST be one of0x01,0x02,0x03.
Note (non-normative): Permitting
block_sizeto exceedshape[axis]when the axis is empty preserves the producer's declared quantization granularity across shape changes (for example, a filter that selects zero rows from an otherwise per-block-quantized weight tensor). The descriptor remains structurally valid and round-trippable; no blocks are materialized.
Note (non-normative): The case
block_size = shape[axis]— a single block covering the entire quantized axis — is intentionally permitted. It is a degenerate but valid configuration that produces semantics equivalent to per-tensor affine quantization along that axis (one sharedscale/zero_pointpair per outer position). Readers MUST handle it identically to any otherblock_sizevalue; there is no separate code path.
Valid Storage Types
The storage type (type_tag in the tensor descriptor) MUST be one of:
int8(0x10),uint8(0x11)int4(0x48),uint4(0x49)int2(0x4A),uint2(0x4B)
A reader MUST reject a descriptor whose storage type is not in this list.
Note (non-normative): Wider integer storage types (
int16and above) are not permitted for per-block affine because block quantization is only beneficial at low bit widths. A writer wishing to apply per-block scaling to wider storage types should use per-channel affine (0x02) instead.
NF4 (NormalFloat4) Quantization — Hurray Format Specification
Scheme tag: 0x04 | Tier: 2
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
A non-linear 4-bit quantization scheme introduced by the QLoRA paper. Each
storage code in [0, 15] decodes to one of 16 fixed real-valued levels chosen
to be information-theoretically optimal for weights drawn from a standard
normal distribution. Each block carries a single absmax scale; there is no
per-element zero point.
The block index computation follows the same rule as Per-Block Affine
(see quantization/per-block-affine.md § Block Layout).
Binary Encoding
Total descriptor length: 16 bytes.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | MUST be 0x04. |
| 1 | scheme_version | uint8 | MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility. |
| 2 | flags | uint16 | MUST be 0x0000. No flags are defined for this scheme. |
| 4 | axis | uint32 | Index of the axis along which the tensor is divided into blocks. MUST be strictly less than rank. |
| 8 | block_size | uint32 | Number of logical elements per block along axis. MUST be a power of two; RECOMMENDED values are 64 (bitsandbytes default) or 128. |
| 12 | scale_buffer_index | uint32 | Index in the buffer table of the buffer holding the per-block absmax scales. |
All multi-byte fields MUST be encoded in little-endian byte order.
Lookup Table
The 16 NF4 levels are fixed by this specification and MUST NOT be altered by
readers or writers. Indexed by the unsigned 4-bit storage code q:
q | nf4[q] |
|---|---|
| 0 | -1.0 |
| 1 | -0.6961928009986877 |
| 2 | -0.5250730514526367 |
| 3 | -0.39491748809814453 |
| 4 | -0.28444138169288635 |
| 5 | -0.18477343022823334 |
| 6 | -0.09105003625154495 |
| 7 | 0.0 |
| 8 | 0.07958029955625534 |
| 9 | 0.16093020141124725 |
| 10 | 0.24611230194568634 |
| 11 | 0.33791524171829224 |
| 12 | 0.44070982933044434 |
| 13 | 0.5626170039176941 |
| 14 | 0.7229568362236023 |
| 15 | 1.0 |
The table values are given here as float32 decimal expansions of the exact
levels from the QLoRA reference implementation. Implementations MUST use these
float32 values verbatim; deriving the table from first principles at runtime
is NOT RECOMMENDED and MUST match these values bit-for-bit if attempted.
Referenced Buffer
The scale buffer MUST contain exactly num_blocks consecutive float32
absmax values in little-endian byte order, starting at byte offset 0 within
the referenced buffer. num_blocks is computed identically to the Per-Block
Affine scheme. Its byte size MUST be exactly num_blocks * 4.
Dequantization Formula
Let b be the block index for a storage element q at logical position
[i_0, ..., i_{rank-1}] (computed as in Per-Block Affine). Let s = scale[b].
x_real = s * nf4[q]
The multiplication is performed in float32 arithmetic.
Validity Constraints
axisMUST satisfyaxis < rank.block_sizeMUST be a power of two and MUST be greater than or equal to8.- When
shape[axis]is greater than0,block_sizeMUST be less than or equal toshape[axis]. A reader MUST reject a descriptor whoseblock_sizeexceeds a non-zeroshape[axis]. - When
shape[axis]equals0(an empty quantization axis, per ADR-007), the upper-bound check is waived. Theblock_sizefield MUST still be a power of two greater than or equal to8, but its value has no effect on buffer sizing:num_blocksevaluates to0, and theabsmaxscale buffer MUST have byte size0. shape[axis]MUST NOT equal0xFFFFFFFFFFFFFFFF.- Every
scalevalue MUST be a finite, non-negativefloat32. scale_buffer_indexMUST be a valid index into the buffer table and MUST NOT refer to the tensor data buffer.
Note (non-normative): The minimum block size of 8 reflects the information-theoretic minimum for NF4: below 8 elements per block, the 16-point quantization grid provides no statistical benefit over a lower-bit format.
Valid Storage Types
The storage type (type_tag in the tensor descriptor) MUST be uint4 (0x49).
A reader MUST reject an NF4 descriptor whose storage type is not uint4.
Note (non-normative): NF4 storage codes are conceptually unsigned (they index into a signed-valued lookup table);
uint4is the correct storage type. The packing order follows the standarduint4rule fromelement-types.md: element2kin the low nibble of bytek, element2k+1in the high nibble.
MXFP (OCP Microscaling) Quantization — Hurray Format Specification
Scheme tag: 0x05 | Tier: 2
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
A block quantization format standardized by the Open Compute Project
Microscaling specification (OCP MX v1.0). A contiguous block of elements along
a chosen axis shares a single float8_e8m0 exponent-only scale. Each element
within the block is stored as one of several supported narrow numeric types.
This is the format used by NVIDIA Blackwell Tensor Cores for MXFP8/MXFP6/MXFP4
compute.
The block size is a descriptor field (block_size). The OCP MX v1.0 canonical
value is 32; other power-of-two values are permitted by this specification to
accommodate future OCP revisions and hardware variants.
The block index computation follows the same rule as Per-Block Affine
(see quantization/per-block-affine.md § Block Layout), with the
descriptor-specified block_size.
Binary Encoding
Total descriptor length: 16 bytes.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | scheme_tag | uint8 | MUST be 0x05. |
| 1 | scheme_version | uint8 | MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility. |
| 2 | flags | uint16 | MUST be 0x0000. No flags are defined for this scheme. |
| 4 | axis | uint32 | Index of the axis along which the tensor is divided into microscaling blocks. MUST be strictly less than rank. |
| 8 | block_size | uint32 | Number of logical elements per microscaling block along axis. MUST be a power of two in the range [16, 2048]. The OCP MX v1.0 canonical value is 32. |
| 12 | scale_buffer_index | uint32 | Index in the buffer table of the buffer holding the per-block float8_e8m0 scales. |
All multi-byte fields MUST be encoded in little-endian byte order.
Referenced Buffer
The scale buffer MUST contain exactly num_blocks consecutive
float8_e8m0 values, one byte each, starting at byte offset 0 within the
referenced buffer. Its byte size MUST be exactly num_blocks bytes.
num_blocks is computed identically to the Per-Block Affine scheme using the
descriptor-specified block_size, with the additional MXFP constraint that
shape[axis] is a positive multiple of block_size (no partial trailing
block — see Validity Constraints below). The full count across the whole
tensor is:
num_blocks = (shape[axis] / block_size) * product(shape[j] for j ≠ axis)
(exact division — shape[axis] MUST be a positive multiple of block_size;
see Validity Constraints). See quantization/per-block-affine.md § Block
Layout for the derivation; MXFP differs only in disallowing partial trailing
blocks.
The bit patterns 0x00 and 0xFF in any scale byte are reserved (NaN per OCP MX v1.0 § 5.6; see element-types.md § float8_e8m0) and MUST NOT appear in the scale buffer. A reader encountering 0x00 or 0xFF in the scale buffer MUST treat the descriptor as invalid.
Dequantization Formula
Let b be the block index for a storage element at logical position
[i_0, ..., i_{rank-1}]. Let e = scale[b] be the float8_e8m0 byte. The
shared exponent scale is:
s = 2^(e - 127)
If the storage type is a float type (float8_e4m3, float8_e5m2, float4_e2m1, float6_e2m3, float6_e3m2):
x_real = s * float_value_of(q)
where float_value_of(q) is the real number represented by the storage element
q interpreted according to element-types.md and the OCP MX v1.0 value maps
for the respective type.
If the storage type is an integer type (int8, int4):
x_real = s * int_value_of(q)
where int_value_of(q) is the signed integer value of q. This MXFP integer
form is equivalent to per-block symmetric affine quantization with a
power-of-two scale and no zero point.
All arithmetic is performed in float32 (or higher) precision; the shared
exponent s is itself exactly representable in float32 for every valid
float8_e8m0 bit pattern except 0xFF (which is prohibited above).
Validity Constraints
axisMUST satisfyaxis < rank.block_sizeMUST be a power of two in the range[16, 2048]. A reader MUST reject a descriptor whoseblock_sizeis not a power of two, is less than16, or exceeds2048. Values below 16 have no hardware Tensor Core support and are not valid under any OCP MX revision.shape[axis]MUST NOT equal0xFFFFFFFFFFFFFFFF.shape[axis]MUST be a positive multiple ofblock_size. Unlike Per-Block Affine and NF4, MXFP does NOT permit partial trailing blocks; all blocks MUST be full. A reader MUST reject a descriptor whoseshape[axis]is not a positive multiple ofblock_size.scale_buffer_indexMUST be a valid index into the buffer table and MUST NOT refer to the tensor data buffer.
Valid Storage Types
The storage type (type_tag in the tensor descriptor) MUST be one of:
float8_e4m3(0x40) — MXFP8float8_e5m2(0x41) — MXFP8float4_e2m1(0x43) — MXFP4float6_e2m3(0x44) — MXFP6float6_e3m2(0x45) — MXFP6int8(0x10) — MXINT8int4(0x48) — MXINT4 / MXFP4 integer-valued surrogate
A reader MUST reject an MXFP descriptor whose storage type is not in this list.
Memory Layout -- Hurray Format Specification
Status: Draft
Scope
This section defines how tensor elements are arranged in memory. It specifies the addressing model that maps a tensor's logical index space to byte positions within a data buffer. Hurray supports a range of memory layouts — from simple contiguous arrangements to tiled, space-filling-curve, sparse, and composite (virtual) layouts — to accommodate the diverse access patterns required by modern AI/ML inference pipelines.
Note (non-normative): The unifying mathematical concept behind all Hurray dense layouts is the subpaving: a finite collection of non-overlapping boxes (rectangular regions) that together tile a tensor's index space. A contiguous row-major tensor is a trivial subpaving (one box). A tiled tensor is a regular subpaving. See Subpaving (Wikipedia).
Normative Requirements
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Layout Taxonomy
Every tensor descriptor MUST include a layout tag (uint8) that identifies the
memory layout of the tensor's data. The tag space is partitioned as follows:
| Range | Allocation |
|---|---|
0x00 | Reserved (invalid) |
0x01 – 0x3F | Core named layouts (Tier 1) |
0x40 – 0x7F | Extended named layouts (Tier 2) |
0x80 – 0xEF | Reserved for future specification versions |
0xF0 – 0xFE | Implementation-private extension layouts |
0xFF | Reserved (invalid) |
A conforming reader MUST reject a tensor descriptor containing a layout tag of 0x00
or 0xFF.
A conforming reader MUST reject a tensor descriptor containing a layout tag it does not recognise, unless operating in permissive mode. In permissive mode, the reader MAY accept the descriptor but MUST NOT dereference or interpret the tensor data buffer.
Named Layout Tags
| Layout | Tag | Tier | Type |
|---|---|---|---|
| Row-Major (C Order) | 0x01 | 1 | Dense |
| Column-Major (Fortran Order) | 0x02 | 1 | Dense |
| Strided | 0x03 | 1 | Dense |
| Tiled / Blocked | 0x04 | 1 | Dense |
| Morton (Z-Order Curve) | 0x05 | 1 | Dense |
| COO (Coordinate) | 0x06 | 1 | Sparse |
| CSR (Compressed Sparse Row) | 0x07 | 1 | Sparse |
| CSC (Compressed Sparse Column) | 0x08 | 1 | Sparse |
| CSF (Compressed Sparse Fiber) | 0x09 | 1 | Sparse |
| Block-Paged | 0x0A | 1 | Indirect |
| Composite / Virtual Tensor | 0x0B | 1 | Virtual |
| Hilbert Curve | 0x40 | 2 | Dense |
The Type column classifies each layout's addressing model:
- Dense — every logical element exists and maps to a physical position by an affine stride formula.
- Sparse — only stored (non-zero) elements are materialised; unstored coordinates are implicitly zero.
- Indirect — every logical element exists (no implicit zeros), but the mapping from a logical index to a physical position is non-affine and resolved through an index structure (e.g. a block table) rather than an affine stride formula.
- Virtual — the descriptor owns no data buffers; it presents a logical view assembled
from a set of member tensors (the head of a composite; see
layouts/composite.md).
Note (non-normative): Rows marked "(reserved — planned)" name a tag that is earmarked for a layout whose spec section does not yet exist. A reader treats such a tag exactly as it treats any unrecognised tag: it MUST reject a descriptor bearing that tag unless operating in permissive mode, in which case it MUST NOT dereference the tensor data buffer (see the unrecognised-tag rule above). The reservation only records intent so the tag is not reassigned before the layout is specified.
Writers choose the layout. Hurray imposes no requirement on which layout a writer selects; any layout from the table above (or from the extension range, by prior agreement) is valid.
Common Fields
All layouts share the following fields in the tensor descriptor. These fields are defined once here; individual layout files specify additional layout-specific fields.
Rank and Shape
rank(uint32): the number of dimensions.0denotes a scalar tensor.shape(uint64[rank]): the size of each dimension. Each value MUST be greater than or equal to 0. A size of 0 indicates an empty tensor.
Note (non-normative): Zero-size dimensions are valid for placeholder tensors or empty batches. A tensor with shape
[3, 0, 5]has zero total elements.
The value 0xFFFFFFFFFFFFFFFF (UINT64_MAX) is the dynamic dimension sentinel:
the dimension's size is not statically known and MUST be resolved before use.
byte_offset
byte_offset(uint64): offset in bytes from the start of buffer 0 to the element at logical index[0, 0, ..., 0]. MUST be ≤ the buffer's byte size.
For sub-byte types (bool, int4, uint4, int2, uint2), byte_offset MUST
point to a byte boundary.
For sparse layouts (COO, CSR, CSC, CSF, and future sparse tags) and indirect
layouts (block-paged, and future indirect tags), the concept of a "first element at
a fixed offset" does not apply: the first logical element is located through an index
structure, not at a fixed offset. For these tensors, byte_offset MUST be set to
0x0000000000000000.
For the virtual layout (composite head, tag 0x0B), there is no data buffer at all;
byte_offset MUST be set to 0x0000000000000000. See layouts/composite.md.
Element Address Computation
Whole-Byte Types
For element types with bit width ≥ 8, the byte address of the element at linear
offset offset (as computed by the layout-specific addressing formula) is:
byte_address = byte_offset + offset * (bit_width / 8)
Sub-Byte Types
For sub-byte types (bool, int4, uint4, int2, uint2), strides are expressed
in logical elements. Given a linear element offset offset:
- Packing factor
P: elements per byte (8 forbool, 2 for 4-bit, 4 for 2-bit). - Bit width
B: bits per element (1, 4, or 2). - Byte index:
byte_offset + floor(offset / P). - Bit position within byte:
(offset mod P) * B(counting from LSB).
For strided layouts with sub-byte types, strides are in logical elements; the implementation MUST compute the linear element offset using the strides, then apply the packing formula above.
Note (non-normative): Sub-byte types are almost always used with contiguous layouts. Strided sub-byte tensors are supported for completeness but require bit-level manipulation per element. Writers SHOULD prefer contiguous layouts for sub-byte data.
Note (non-normative): The 6-bit types (
float6_e2m3,float6_e3m2) pack 4 elements per 3 bytes (seeelement-types.md§ Buffer Size Calculation). However, because their bit width (6) does not divide evenly into 8 bits, the standard sub-byte bit-addressing model (where bit position within a byte is well-defined) does not apply cleanly. Elements are always addressed at the group level (3 bytes per group of 4 elements); individual element extraction within a group is defined by the bit layout inelement-types.md§ Encoding but is not generalised here.
Accordingly, 6-bit types (float6_e2m3, float6_e3m2) MUST be accessed at the group
level — one group is 4 elements packed into 3 bytes — and an implementation MUST NOT apply
the single-element byte/bit-addressing model of § Sub-Byte Types to them. Single-element
extraction within a group is defined normatively by element-types.md § Encoding and is
intentionally not generalised into a layout addressing formula here; that encoding is
sufficient for implementors.
Alignment
- The data buffer MUST be aligned to at least 64 bytes regardless of layout or element type.
- For tiled layouts, each tile's data SHOULD start at a naturally aligned boundary. Writers SHOULD insert inter-tile padding when needed; tile stride values MUST account for any padding.
- For sub-byte types,
byte_offsetMUST be byte-aligned. - Page-aligned buffers (typically 4096 bytes) SHOULD be used when the tensor is shared
across processes or with GPU devices. See
buffer-protocol.md.
Buffer Table
Every tensor descriptor contains a buffer table: an ordered list of buffer handles.
The buffer table is encoded as a uint8 count followed by that many buffer handle
entries, as defined in metadata.md.
For dense layouts (tags 0x01–0x05, 0x40), the buffer table MUST contain at least one entry. Non-quantized dense tensors MUST have exactly buffer_count = 0x01. Quantized dense tensors MUST have buffer_count = 0x01 plus the number of quantization-parameter buffers required by the active scheme (see quantization.md § Buffer Table Placement Rules).
For sparse layouts (tags 0x06, 0x07, 0x08, 0x09, and future sparse tags),
the buffer table MUST contain the number of entries specified by that layout's
individual spec file. Each buffer holds a distinct component array (values, indices,
pointers). Most sparse layouts have a fixed buffer count; CSF (0x09) is the
exception, with a rank-dependent count of 2 × rank + 1 (see layouts/csf.md).
For indirect layouts (tag 0x0A, and future indirect tags), the buffer table
MUST contain at least three entries (buffer_count >= 3): a values buffer plus the
index/pointer buffers that resolve the logical-to-physical mapping. For block-paged
(0x0A) these are buffer 0 = page_pool, buffer 1 = block_table, and buffer 2 =
seq_ptr. When the tensor is quantized, the quantization-parameter buffers follow at
indices 3 and up, per quantization.md § Buffer Table Placement Rules. See that
layout's individual spec file for the exact buffer table.
For the virtual layout (composite head, tag 0x0B), the buffer table MUST be empty
(buffer_count = 0x00): the head owns no data and supplies its logical view through its
member tensors. See layouts/composite.md.
Splittability and Sharding
A tensor MAY be described as a shard: a rectangular sub-region of a larger logical
tensor. A shard carries a shard descriptor in the tensor descriptor (see
metadata.md § Shard Section) indicating its position within the parent index space.
Shard descriptor fields:
parent_shape(uint64[rank]): shape of the logical parent tensor.shard_offset(uint64[rank]): starting index of this shard within the parent along each dimension.
The constraint shard_offset[k] + shape[k] <= parent_shape[k] MUST hold for every
dimension k.
Note (non-normative): Sharding always produces rectangular sub-regions (hyperrectangles), which covers all practical partitioning patterns in ML inference (batch splitting, tensor parallelism, pipeline stages). Protocol-level splitting of tensor data across stream frames is a separate concern covered in
interchange.md.
Extension Layouts
Layout tags in the range 0xF0–0xFE are reserved for implementation-private
extension layouts. Tensors using these tags MUST NOT be exchanged between independent
implementations unless both parties have agreed on the layout semantics out of band.
An extension layout descriptor MUST include:
extension_layout_id(uint64): implementation-defined unique identifier.extension_data(byte sequence): opaque layout-specific metadata, preceded by auint32byte-length field.
Note (non-normative): Extension layouts are the mechanism for hardware-specific panel/pack formats used between BLAS pipeline stages. A client advertises an extension layout tag with opaque hardware metadata during capability negotiation (see
interchange.md); the server transcodes and packs accordingly.
Custom Layouts
Any layout expressible as a composition of the named primitives — strides, tiling,
space-filling curves — using a composite partition (layouts/composite.md) or recursive
tiling is representable without the extension mechanism. Truly opaque custom layouts MUST
use extension tags (0xF0–0xFE) with an out-of-band semantic agreement.
Interaction with Other Sections
- Element Types (
element-types.md): defines bit widths, packing rules, and natural alignment for each element type. - Quantization (
quantization.md): block-quantized layouts interact with the memory layout tiling structure. - Buffer Protocol (
buffer-protocol.md): defines buffer ownership, alignment requirements, and device memory semantics. - Metadata (
metadata.md): defines the binary encoding of all layout-specific fields in the tensor descriptor. - Interchange (
interchange.md): defines how tensor descriptors and data buffers are transmitted. Protocol-level data splitting is distinct from logical sharding.
Row-Major (C Order) Layout — Hurray Format Specification
Layout tag: 0x01 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
In row-major layout, elements are stored with the last dimension varying fastest. Strides are implicit and MUST NOT be present in the descriptor for this layout tag.
Implicit Strides
strides[rank - 1] = 1
strides[i] = shape[i + 1] * strides[i + 1] for i = rank - 2, ..., 0
All strides are in logical elements.
Element Address
The linear element offset of element [i_0, i_1, ..., i_{r-1}] in a row-major
tensor of rank r is:
offset = sum(i_k * strides[k] for k = 0, ..., r - 1)
The byte address is computed from the element offset using the rules in
memory-layout.md § Element Address Computation.
Buffer Size
For a contiguous row-major tensor, the minimum buffer size is
num_elements * element_byte_width for whole-byte types, or
ceil(num_elements / packing_factor) for sub-byte types, where num_elements is
the product of all dimension sizes.
Additional Descriptor Fields
None. This layout has no layout-specific fields in the tensor descriptor.
Example
A rank-2 tensor with shape [3, 4] has implicit strides [4, 1].
Element [1, 2] is at linear offset 1 * 4 + 2 * 1 = 6.
Column-Major (Fortran Order) Layout — Hurray Format Specification
Layout tag: 0x02 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
In column-major layout, elements are stored with the first dimension varying fastest. Strides are implicit and MUST NOT be present in the descriptor for this layout tag.
Implicit Strides
strides[0] = 1
strides[i] = shape[i - 1] * strides[i - 1] for i = 1, ..., rank - 1
All strides are in logical elements.
Element Address
The linear element offset of element [i_0, i_1, ..., i_{r-1}] is computed
identically to row-major using the column-major strides above.
The byte address is computed from the element offset using the rules in
memory-layout.md § Element Address Computation.
Buffer Size
Identical to row-major: num_elements * element_byte_width for whole-byte types,
or ceil(num_elements / packing_factor) for sub-byte types.
Additional Descriptor Fields
None. This layout has no layout-specific fields in the tensor descriptor.
Example
A rank-2 tensor with shape [3, 4] has implicit strides [1, 3].
Element [1, 2] is at linear offset 1 * 1 + 2 * 3 = 7.
Strided Layout — Hurray Format Specification
Layout tag: 0x03 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The strided layout generalises both row-major and column-major by allowing an arbitrary stride value per dimension. It is the most general of the simple (non-tiled, non-curve) layouts.
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
strides | int64[rank] | Stride of each dimension in logical elements. |
Stride Semantics
- A positive stride advances forward through the buffer.
- A negative stride advances backward. A negative stride on dimension
kreverses that dimension: logical index 0 maps to the highest physical offset along that axis. - A zero stride on dimension
kmeans all indices alongkmap to the same physical element — a broadcast (virtual) dimension. Data is not physically replicated.
Negative and zero strides are valid. A conforming implementation MUST support them.
Element Address
The linear element offset of element [i_0, i_1, ..., i_{r-1}] is:
offset = sum(i_k * strides[k] for k = 0, ..., r - 1)
When negative strides are present, the offset may be negative relative to the base
address at byte_offset. The byte_offset field MUST be set such that element
[0, 0, ..., 0] is addressable within the buffer. The physical address of every
valid element MUST lie within the buffer's bounds.
Buffer Size
The minimum buffer size must cover every addressable element:
max_offset = sum(max(0, strides[k] * (shape[k] - 1)) for all k)
min_offset = sum(min(0, strides[k] * (shape[k] - 1)) for all k)
range_elements = max_offset - min_offset + 1
Buffer size in bytes depends on the element type (see memory-layout.md
§ Element Address Computation).
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar) tensors. See
data-model.md § Scalar Tensors.
Contiguity
A strided tensor is dense (contiguous with no gaps) if and only if the absolute values of its strides form a permutation of the row-major strides for the same shape. Implementations SHOULD NOT assume density without verifying this condition.
Example
A rank-2 tensor with shape [3, 4] and strides [-4, 1] represents a row-major
matrix with the row order reversed. byte_offset points to what would be element
[2, 0] in a non-reversed matrix. Element [2, 3] is at offset
2 * (-4) + 3 * 1 = -5. The buffer MUST be large enough to cover the full range
from offset -8 to 0 (9 elements).
Tiled / Blocked Layout — Hurray Format Specification
Layout tag: 0x04 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
A tiled layout partitions the tensor's index space into uniform rectangular tiles (blocks). This is a regular subpaving: all tiles have the same shape and tile the index space without overlap or gap (with possible padding at the boundaries).
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
tile_shape | uint64[rank] | Tile size along each dimension. Every value MUST be greater than 0. |
outer_layout | uint8 | Layout tag for tile-grid ordering. MUST be 0x01, 0x02, or 0x03. |
inner_layout | uint8 | Layout tag for element ordering within each tile. MUST be 0x01, 0x02, 0x03, or 0x04 (recursive tiling). |
_reserved | uint8[2] | MUST be 0x00. |
If outer_layout is 0x03 (strided):
| Field | Type | Description |
|---|---|---|
outer_strides | int64[rank] | Outer strides in units of tiles (not elements). |
If inner_layout is 0x03 (strided):
| Field | Type | Description |
|---|---|---|
inner_strides | int64[rank] | Inner strides in logical elements within a tile. |
If inner_layout is 0x04 (recursive tiling), the tiled layout-specific fields are
encoded recursively beginning with tile_shape. A reader MUST enforce a maximum
recursion depth (RECOMMENDED: 8 levels) and MUST reject descriptors that exceed it.
Element Address
To locate element [i_0, i_1, ..., i_{r-1}]:
- Compute the tile index:
t_k = floor(i_k / tile_shape[k])for each dimension. - Compute the intra-tile offset:
e_k = i_k mod tile_shape[k]for each dimension. - Compute the linear tile number using the outer layout applied to
[t_0, ..., t_{r-1}]. Multiply bytotal_tile_elementsto get the byte offset to the tile start. - Compute the intra-tile linear offset using the inner layout applied to
[e_0, ..., e_{r-1}]. - Final linear element offset = (3) + (4).
Boundary Padding
When a dimension size is not evenly divisible by the tile size, partial tiles exist at
the boundary. The buffer MUST contain storage for full tiles, including padding.
Padding element values are undefined; readers MUST NOT access elements whose logical
index exceeds shape.
Number of tiles per dimension:
num_tiles[k] = ceil(shape[k] / tile_shape[k])
Total buffer elements:
total_tile_elements = product(tile_shape[k] for all k)
total_tiles = product(num_tiles[k] for all k)
buffer_elements = total_tiles * total_tile_elements
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar) tensors. See
data-model.md § Scalar Tensors.
Recursive Tiling
Note (non-normative): Recursive tiling is useful for hierarchical blocking in GEMM kernels (e.g., 128×128 L2 tiles subdivided into 32×32 L1 tiles). It is also the natural expression of cache-oblivious recursive blocking.
Example
Rank-2 tensor with shape [6, 8], tile_shape = [2, 4], outer_layout = 0x01
(row-major), inner_layout = 0x01 (row-major).
- Tile grid shape:
[3, 2]. Outer strides (implicit):[2, 1]in tiles. - Each tile: 8 elements. Inner strides (implicit):
[4, 1]in elements. - Element
[3, 5]: tile[1, 1], intra-tile[1, 1]. Tile linear index =1 * 2 + 1 = 3. Tile start =3 * 8 = 24. Intra-tile offset =1 * 4 + 1 = 5. Final offset =29.
Morton (Z-Order Curve) Layout — Hurray Format Specification
Layout tag: 0x05 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The Morton layout stores elements by interleaving the bits of their dimension indices, producing a linear order with good spatial locality for multi-dimensional access patterns.
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
morton_bits | uint32[rank] | Number of bits used per dimension in the Morton encoding. Each value MUST be greater than 0. |
Dimension Size Constraints
For each dimension k, shape[k] MUST satisfy shape[k] <= 2^morton_bits[k].
Morton Index Computation
For element [i_0, i_1, ..., i_{r-1}], the Morton code is computed by interleaving
bits in round-robin order, starting from the least significant bit of dimension 0:
morton_code = 0
for bit_position b = 0, 1, 2, ...:
for dimension d = 0, 1, ..., rank - 1:
if b < morton_bits[d]:
morton_code |= ((i_d >> b) & 1) << (b * rank + d)
The element at Morton code m is stored at linear offset m in the buffer.
Buffer Size
The buffer MUST hold 2^(sum(morton_bits[k] for all k)) elements.
Note (non-normative): For non-power-of-two dimension sizes,
morton_bits[k]SHOULD be set toceil(log2(shape[k]))to minimise padding. The worst-case padding factor per dimension is strictly less than 2×. Writers for whom padding waste is unacceptable SHOULD prefer a tiled or row-major layout.
Example
Rank-2 tensor with shape [4, 4], morton_bits = [2, 2].
Element [2, 3]: i_0 = 2 = 0b10, i_1 = 3 = 0b11.
Interleaved (LSB first, dim 0 then dim 1): bit 0 of i_0=0, bit 0 of i_1=1,
bit 1 of i_0=1, bit 1 of i_1=1. Morton code = 0b1110 = 14.
Element [2, 3] is stored at linear offset 14.
Hilbert Curve Layout — Hurray Format Specification
Layout tag: 0x40 | Tier: 2
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The Hilbert curve layout stores elements according to a Hilbert space-filling curve, which provides better locality than the Morton curve: consecutive Hilbert indices always correspond to physically adjacent elements (L∞ distance = 1 in index space), with no large jumps. This benefits access patterns that traverse multi-dimensional regions with spatial coherence (e.g., image convolution, volumetric sampling, point-cloud processing), at the cost of a more expensive index computation than Morton.
Note (non-normative): The Hilbert curve is particularly effective for 2D and 3D spatial tensors where Morton's large jumps at quadrant boundaries cause cache misses. For 1D access patterns or cases where index-computation cost dominates, row-major or Morton layouts are preferable.
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
hilbert_order | uint32 | Order of the Hilbert curve. MUST be greater than 0. Each tensor dimension MUST equal 2^hilbert_order. |
hilbert_rank | uint32 | Number of curve dimensions. MUST equal the tensor's rank. MUST be greater than or equal to 2. |
Validity Constraints
A conforming reader MUST reject a Hilbert-curve descriptor that violates any of the following constraints:
shape[k] = 2^hilbert_orderfor everykin[0, hilbert_rank). All tensor dimensions MUST be a power of two equal to2^hilbert_order.hilbert_rankMUST equal the tensor'srank.hilbert_rankMUST be greater than or equal to2.hilbert_orderMUST be greater than0.
This layout MUST NOT be used for rank-0 (scalar) tensors (constraints 2 and 3
together exclude rank 0). See data-model.md § Scalar Tensors.
Buffer Size
The buffer MUST hold exactly 2^(hilbert_rank * hilbert_order) elements.
Normative Index Mapping
The normative index mapping is the algorithm defined by Skilling (2004) (see
references.md). Conforming implementations MUST use this algorithm. All arithmetic
is integer arithmetic; ^ denotes bitwise XOR; array indexing is zero-based.
Let r = hilbert_rank, p = hilbert_order. Coordinates X[0..r-1] are each in
[0, 2^p). Hilbert index h is in [0, 2^(r*p)).
Bit packing: bit (b * r + (r - 1 - d)) of h holds bit b of X[d],
for b = 0, ..., p-1 and d = 0, ..., r-1.
CoordsToHilbert(X[0..r-1], r, p) → h:
M = 1 << (p - 1)
Q = M
while Q > 1:
P = Q - 1
for i = 0 to r-1:
if X[i] & Q:
X[0] ^= P
else:
t = (X[0] ^ X[i]) & P
X[0] ^= t; X[i] ^= t
Q >>= 1
for i = 1 to r-1:
X[i] ^= X[i-1]
t = 0; Q = M
while Q > 1:
if X[r-1] & Q: t ^= Q - 1
Q >>= 1
for i = 0 to r-1:
X[i] ^= t
h = 0
for b = 0 to p-1:
for d = 0 to r-1:
h |= ((X[d] >> b) & 1) << (b * r + (r - 1 - d))
return h
HilbertToCoords(h, r, p) → X[0..r-1]:
X = [0] * r
for b = 0 to p-1:
for d = 0 to r-1:
X[d] |= ((h >> (b * r + (r - 1 - d))) & 1) << b
t = X[r-1] >> 1
for i = r-1 downto 1:
X[i] ^= X[i-1]
X[0] ^= t
Q = 2
while Q != (1 << p):
P = Q - 1
for i = r-1 downto 0:
if X[i] & Q:
X[0] ^= P
else:
t = (X[0] ^ X[i]) & P
X[0] ^= t; X[i] ^= t
Q <<= 1
return X
Conformance Check
Selected index mappings for r = 2, p = 2 (shape [4, 4]):
h | X[0] | X[1] | h | X[0] | X[1] | |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 8 | 2 | 2 | |
| 1 | 1 | 0 | 9 | 2 | 3 | |
| 2 | 1 | 1 | 10 | 3 | 3 | |
| 3 | 0 | 1 | 11 | 3 | 2 | |
| 4 | 0 | 2 | 12 | 3 | 1 | |
| 5 | 0 | 3 | 13 | 2 | 1 | |
| 6 | 1 | 3 | 14 | 2 | 0 | |
| 7 | 1 | 2 | 15 | 3 | 0 |
Consecutive entries differ by exactly 1 in exactly one coordinate. Implementations SHOULD validate against this table as a conformance check.
Note (non-normative): The MUST-level normative reference for the index mapping is the
CoordsToHilbert/HilbertToCoordsalgorithm defined above. This table is provided as a SHOULD-level conformance aid; if a discrepancy is ever observed between table and algorithm, the algorithm output prevails.
COO (Coordinate) Sparse Layout — Hurray Format Specification
Layout tag: 0x06 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The COO (Coordinate) format stores a sparse tensor as a list of explicitly enumerated non-zero elements. Each non-zero is described by its logical index tuple and its value. COO is the most general sparse format: it imposes no ordering requirement on the non-zeros and supports arbitrary rank.
Note (non-normative): COO is simple to construct (append-only) and easy to convert to other formats. It is less efficient for row-wise random access than CSR, but is well-suited for assembly (e.g., accumulating contributions from finite-element computations) and as an interchange format between sparse kernels.
Buffer Table
A COO tensor descriptor MUST have buffer_count = 2 in the buffer table.
| Buffer index | Name | Element type | Length | Description |
|---|---|---|---|---|
| 0 | values | tensor element type | nnz elements | Non-zero element values, in storage order. |
| 1 | indices | uint64 | nnz × rank elements | Logical index tuples of the non-zeros, stored in row-major order: indices[i * rank + d] is the coordinate of the i-th non-zero along dimension d. |
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor. |
is_sorted | uint8 | 0x01 if the non-zeros are sorted in lexicographic index order (dimension 0 major); 0x00 otherwise. |
_reserved | uint8[7] | MUST be 0x00. |
Storage Order
Non-zeros are stored at positions 0 through nnz - 1 in values and indices.
The i-th non-zero has:
- Value:
values[i] - Logical index:
[indices[i * rank + 0], indices[i * rank + 1], ..., indices[i * rank + (rank-1)]]
If is_sorted = 0x01, the non-zeros MUST appear in lexicographic index order
(i.e., sorted by dimension 0 first, then dimension 1, etc.). A conforming reader MAY
use binary search for element lookup when is_sorted = 0x01.
If is_sorted = 0x00, no ordering guarantee is made. Readers MUST perform a linear
scan to locate a specific element.
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar) tensors. See
data-model.md § Scalar Tensors.
A conforming writer MUST ensure:
- Every stored index tuple is within bounds:
0 <= indices[i * rank + d] < shape[d]for alliin[0, nnz)and alldin[0, rank). - No two stored entries share the same index tuple (no duplicate coordinates).
- If
is_sorted = 0x01, entries are in strictly increasing lexicographic order (no ties, since duplicate coordinates are forbidden).
A conforming reader SHOULD validate constraints (1) and (2) and MUST reject descriptors that violate them, unless operating in permissive mode.
Buffer Size
valuesbuffer:nnz * element_byte_widthbytes (orceil(nnz / packing_factor)for sub-byte types).indicesbuffer:nnz * rank * 8bytes (uint64elements).
Both buffers MUST satisfy the alignment requirements in buffer-protocol.md.
Note (non-normative): The
byte_offsetfield in the common descriptor header is not meaningful for sparse layouts — there is no single "first element" at a fixed offset. For COO tensors,byte_offsetMUST be set to0x0000000000000000.
Element Lookup
To retrieve the value at logical index idx[0..rank-1]:
- If
is_sorted = 0x01, perform a binary search on theindicesbuffer (comparing fullrank-tuples in lexicographic order) to find a matching entry. - If
is_sorted = 0x00, perform a linear scan over allnnzentries. - If a match is found at position
i, returnvalues[i]. - If no match is found, the element is implicitly zero (or the zero value of the element type).
Interaction with Statistics Section
When the HAS_STATISTICS flag is set, the nnz field in the statistics section
MUST match the nnz field in the COO descriptor, which is authoritative. The sparsity_ratio SHOULD be
computed as 1.0 - (nnz / total_elements) where total_elements is the product of
all shape values.
Example
Rank-2 sparse tensor with shape [4, 4], element type float32, 3 non-zeros:
Non-zeros: (0, 1) = 1.5, (2, 0) = -0.5, (3, 3) = 2.0
nnz = 3, is_sorted = 0x01
values (buffer 0, 12 bytes): [1.5, -0.5, 2.0] as float32 LE
indices (buffer 1, 3×2×8 = 48 bytes, uint64 LE):
entry 0: [0, 1]
entry 1: [2, 0]
entry 2: [3, 3]
CSR (Compressed Sparse Row) Layout — Hurray Format Specification
Layout tag: 0x07 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The CSR (Compressed Sparse Row) format stores a sparse matrix (rank-2 tensor) by compressing the row structure: each row is represented by a contiguous slice of a flat non-zero array, with a separate pointer array indicating where each row begins. CSR is the most widely-used sparse matrix format in scientific computing and machine learning (e.g., sparse attention, graph neural networks, sparse weight matrices).
Note (non-normative): CSR is defined only for rank-2 tensors in this version of the specification. Generalisation to arbitrary rank (Compressed Sparse Fiber, CSF) is specified in CSF (Compressed Sparse Fiber).
A conforming implementation MUST reject a CSR descriptor whose rank is not 2.
Buffer Table
A CSR tensor descriptor MUST have buffer_count = 3 in the buffer table.
| Buffer index | Name | Element type | Length | Description |
|---|---|---|---|---|
| 0 | values | tensor element type | nnz elements | Non-zero values in row-major order (all non-zeros of row 0 first, then row 1, etc.). |
| 1 | col_indices | uint64 | nnz elements | Column index of each non-zero. col_indices[i] is the column of values[i]. |
| 2 | row_ptr | uint64 | nrows + 1 elements | Row pointer array. row_ptr[i] is the index into values / col_indices of the first non-zero in row i. row_ptr[nrows] = nnz. |
where nrows = shape[0].
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix. |
_reserved | uint8[8] | MUST be 0x00. |
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar) tensors. See
data-model.md § Scalar Tensors. CSR is further restricted to rank 2 (see
§ Description).
Storage Invariants
A conforming writer MUST ensure:
row_ptr[0] = 0androw_ptr[nrows] = nnz.row_ptris non-decreasing:row_ptr[i] <= row_ptr[i+1]for alli.- Within each row
i, the non-zeros incol_indices[row_ptr[i]..row_ptr[i+1])MUST be sorted in strictly increasing order (no duplicate column indices per row). - All column indices are within bounds:
0 <= col_indices[j] < shape[1]for allj.
A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.
Buffer Size
valuesbuffer:nnz * element_byte_widthbytes (orceil(nnz / packing_factor)for sub-byte types).col_indicesbuffer:nnz * 8bytes (uint64elements).row_ptrbuffer:(nrows + 1) * 8bytes (uint64elements), wherenrows = shape[0].
All buffers MUST satisfy the alignment requirements in buffer-protocol.md.
For CSR tensors, byte_offset MUST be set to 0x0000000000000000.
Note (non-normative): The
byte_offsetfield in the common descriptor header is not meaningful for CSR — there is no single "first element" at a fixed offset.
Element Lookup
To retrieve the value at row r, column c:
- The non-zeros of row
roccupy positionsrow_ptr[r]throughrow_ptr[r+1] - 1invaluesandcol_indices. - Perform a binary search on
col_indices[row_ptr[r]..row_ptr[r+1])for valuec. - If found at position
j, returnvalues[j]. - If not found, the element is implicitly zero.
Binary search is valid because column indices within each row are sorted (invariant 3).
Row Iteration
To iterate over all non-zeros in row r:
for j = row_ptr[r] to row_ptr[r+1] - 1:
col = col_indices[j]
val = values[j]
Interaction with Statistics Section
When the HAS_STATISTICS flag is set, the nnz field in the statistics section
MUST match the nnz field in the CSR descriptor, which is authoritative. The sparsity_ratio SHOULD be
1.0 - (nnz / (shape[0] * shape[1])).
Example
Rank-2 sparse matrix with shape [4, 5], element type float32:
Dense representation:
row 0: [1.0, 0, 0, 2.0, 0 ]
row 1: [ 0, 0, 3.0, 0, 0 ]
row 2: [ 0, 4.0, 0, 0, 5.0]
row 3: [ 0, 0, 0, 0, 0 ]
nnz = 5
values (buffer 0): [1.0, 2.0, 3.0, 4.0, 5.0]
col_indices (buffer 1): [0, 3, 2, 1, 4 ]
row_ptr (buffer 2): [0, 2, 3, 5, 5 ]
row_ptr[3] = row_ptr[4] = 5 because row 3 has no non-zeros.
CSC (Compressed Sparse Column) Layout — Hurray Format Specification
Layout tag: 0x08 | Tier: 1
Also known as: CCS (Compressed Column Storage)
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The CSC (Compressed Sparse Column) format is the column analog of CSR. It stores a sparse matrix (rank-2 tensor) by compressing the column structure: each column is represented by a contiguous slice of a flat non-zero array, with a separate pointer array indicating where each column begins.
CSC is preferred when the workload requires efficient column access or column iteration — e.g., sparse matrix-vector products with column-major dense vectors, column pivoting in sparse direct solvers, and graph algorithms that traverse out-edges (adjacency stored column-wise).
Note (non-normative): CSC is defined only for rank-2 tensors in this version of the specification, consistent with CSR; generalisation to arbitrary rank is provided by the Compressed Sparse Fiber (CSF) layout (see CSF (Compressed Sparse Fiber)). The format is also known as Compressed Column Storage (CCS) in some communities (e.g., MATLAB, some LAPACK interfaces). The two names refer to the same format.
A conforming implementation MUST reject a CSC descriptor whose rank is not 2.
Buffer Table
A CSC tensor descriptor MUST have buffer_count = 3 in the buffer table.
| Buffer index | Name | Element type | Length | Description |
|---|---|---|---|---|
| 0 | values | tensor element type | nnz elements | Non-zero values in column-major order (all non-zeros of column 0 first, then column 1, etc.). |
| 1 | row_indices | uint64 | nnz elements | Row index of each non-zero. row_indices[i] is the row of values[i]. |
| 2 | col_ptr | uint64 | ncols + 1 elements | Column pointer array. col_ptr[j] is the index into values / row_indices of the first non-zero in column j. col_ptr[ncols] = nnz. |
where ncols = shape[1].
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix. |
_reserved | uint8[8] | MUST be 0x00. |
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar) tensors. See
data-model.md § Scalar Tensors. CSC is further restricted to rank 2 (see
§ Description).
Storage Invariants
A conforming writer MUST ensure:
col_ptr[0] = 0andcol_ptr[ncols] = nnz.col_ptris non-decreasing:col_ptr[j] <= col_ptr[j+1]for allj.- Within each column
j, the non-zeros inrow_indices[col_ptr[j]..col_ptr[j+1])MUST be sorted in strictly increasing order (no duplicate row indices per column). - All row indices are within bounds:
0 <= row_indices[i] < shape[0]for alli.
A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.
Buffer Size
valuesbuffer:nnz * element_byte_widthbytes (orceil(nnz / packing_factor)for sub-byte types).row_indicesbuffer:nnz * 8bytes (uint64elements).col_ptrbuffer:(ncols + 1) * 8bytes (uint64elements), wherencols = shape[1].
All buffers MUST satisfy the alignment requirements in buffer-protocol.md.
Note (non-normative): As with CSR and COO, the
byte_offsetfield in the common descriptor header is not meaningful for CSC. For CSC tensors,byte_offsetMUST be set to0x0000000000000000.
Element Lookup
To retrieve the value at row r, column c:
- The non-zeros of column
coccupy positionscol_ptr[c]throughcol_ptr[c+1] - 1invaluesandrow_indices. - Perform a binary search on
row_indices[col_ptr[c]..col_ptr[c+1])for valuer. - If found at position
i, returnvalues[i]. - If not found, the element is implicitly zero.
Binary search is valid because row indices within each column are sorted (invariant 3).
Column Iteration
To iterate over all non-zeros in column c:
for i = col_ptr[c] to col_ptr[c+1] - 1:
row = row_indices[i]
val = values[i]
Relationship to CSR
CSC is the transpose of CSR: the CSC representation of matrix A is equivalent to
the CSR representation of A^T with shape swapped. Implementations that support
both formats MAY convert between them by transposing the pointer and index arrays.
Interaction with Statistics Section
When the HAS_STATISTICS flag is set, the nnz field in the statistics section
MUST match the nnz field in the CSC descriptor, which is authoritative. The sparsity_ratio SHOULD be
1.0 - (nnz / (shape[0] * shape[1])).
Example
The same matrix as in the CSR example (shape [4, 5], element type float32):
Dense representation:
row 0: [1.0, 0, 0, 2.0, 0 ]
row 1: [ 0, 0, 3.0, 0, 0 ]
row 2: [ 0, 4.0, 0, 0, 5.0]
row 3: [ 0, 0, 0, 0, 0 ]
nnz = 5
values (buffer 0): [1.0, 4.0, 3.0, 2.0, 5.0]
row_indices (buffer 1): [0, 2, 1, 0, 2 ]
col_ptr (buffer 2): [0, 1, 2, 3, 4, 5]
col_ptr[j+1] - col_ptr[j] = number of non-zeros in column j:
col 0: 1 (value 1.0 at row 0), col 1: 1 (value 4.0 at row 2),
col 2: 1 (value 3.0 at row 1), col 3: 1 (value 2.0 at row 0),
col 4: 1 (value 5.0 at row 2).
CSF (Compressed Sparse Fiber) Layout — Hurray Format Specification
Layout tag: 0x09 | Tier: 1
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The CSF (Compressed Sparse Fiber) format is the rank-N generalisation of CSR/CSC. It
stores a sparse tensor as a tree of rank levels, where each level compresses one
mode (logical dimension) with a (pos, crd) pair: a pos pointer array delimiting
each parent's children, and a crd coordinate array naming those children. A single
values array holds the non-zero values at the leaves. CSF is the natural higher-rank
complement to COO for structured sparsity — sparse attention masks, sparse
activations, and higher-order tensor factorisations.
Every level of a CSF tree is compressed: there is no per-mode dense/compressed distinction in this version of the specification. Dense-outer rank-2 cases are covered by CSR/CSC.
Note (non-normative): CSF is defined only for rank ≥ 3 tensors in this version. Rank-2 sparse matrices are served by CSR (CSR (Compressed Sparse Row)) and CSC (CSC (Compressed Sparse Column)), whose dense outer pointer array is more compact and more interop-canonical. COO (COO (Coordinate)) (which supports any rank) and CSR/CSC are preferable for rank ≤ 2. A future revision could admit dense levels via a
mode_formatfield; a reader of this version rejecting such a descriptor is intended versioning behaviour.
A conforming implementation MUST reject a CSF descriptor whose rank is less than 3.
Mode Ordering
A CSF descriptor carries a mode_order: uint32[rank] field, a permutation of the
integers 0..rank-1. mode_order[L] is the logical dimension stored at tree level
L, where level 0 is the outermost level and level rank-1 is the leaf level
directly above values. The bounding size for level L — the upper bound on every
coordinate stored at that level — is shape[mode_order[L]].
mode_order affects storage traversal only; it does not change the tensor's logical
shape.
A conforming reader MUST honour any valid mode_order permutation for lookup and
iteration. A conforming reader MUST NOT reject a CSF descriptor merely because
mode_order is non-identity. A conforming reader MUST reject a descriptor whose
mode_order is not a valid permutation of 0..rank-1 (e.g. contains a duplicate or an
out-of-range value), unless operating in permissive mode.
When a writer has no access-pattern preference, the identity ordering
[0, 1, ..., rank-1] (row-major, outer-to-inner) SHOULD be used as the default.
Note (non-normative):
mode_orderis a performance knob: it lets a writer match the tree's nesting to its access pattern. The identity ordering is preferred as the default because it is reproducible across writers and keeps the outermost (slowest- varying) logical dimension at the top of the tree, which is cache-friendly for row-major access.
Buffer Table
A CSF tensor descriptor MUST have buffer_count = 2 * rank + 1 in the buffer table
(plus any quantization-parameter buffers, which follow at indices 2 * rank + 1 and
up).
| Buffer index | Name | Element type | Length | Description |
|---|---|---|---|---|
| 0 | values | tensor element type | nnz elements | Non-zero values, ordered by tree traversal (leaf order). values[p] is the value reached by descending to leaf position p. |
2L + 1 | pos_L | uint64 | n_{L-1} + 1 elements (2 for level 0) | Level-L pointer array. pos_L[k] and pos_L[k+1] delimit the children of parent k in crd_L. |
2L + 2 | crd_L | uint64 | n_L elements (nnz for the leaf level) | Level-L coordinate array. crd_L[i] is a coordinate along logical dimension mode_order[L]. |
where n_L is the number of nodes stored at level L, n_{-1} = 1 (a single virtual
root), and n_{rank-1} = nnz. The top-level pointer array pos_0 always has length 2
and equals [0, n_0].
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor. |
mode_order | uint32[rank] | Permutation of 0..rank-1; mode_order[L] is the logical dimension stored at level L. See § Mode Ordering. |
_reserved | uint8[8] | MUST be 0x00. |
Validity Constraints
This layout MUST NOT be used for rank-0 (scalar), rank-1, or rank-2 tensors. CSF
requires rank >= 3 (see § Description); rank-1 and rank-2 cases are served by COO,
CSR, and CSC. Rank remains capped at 64 by data-model.md.
Storage Invariants
A conforming writer MUST ensure, for every level L in 0..rank-1:
pos_L[0] = 0.pos_Lis non-decreasing:pos_L[k] <= pos_L[k+1]for allk.- The terminal
posentry equals the level's stored count:pos_0[1] = n_0,pos_L[n_{L-1}] = n_L, andn_{rank-1} = nnz. - Within each parent slice
crd_L[pos_L[k]..pos_L[k+1]), the coordinates MUST be sorted in strictly increasing order (no duplicate siblings). - All coordinates are within bounds:
0 <= crd_L[i] < shape[mode_order[L]]for alli.
In addition, mode_order MUST be a valid permutation of 0..rank-1.
For an empty tensor (nnz = 0), pos_0 MUST be [0, 0], values and every crd_L
MUST have length 0, and for each level L >= 1 pos_L MUST be [0] (length 1, since
n_{L-1} = 0).
A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.
Buffer Size
valuesbuffer:nnz * element_byte_widthbytes (orceil(nnz / packing_factor)for sub-byte types).pos_Lbuffer:(n_{L-1} + 1) * 8bytes (uint64elements);pos_0is always2 * 8 = 16bytes.crd_Lbuffer:n_L * 8bytes (uint64elements); the leafcrd_{rank-1}isnnz * 8bytes.
All buffers MUST satisfy the alignment requirements in buffer-protocol.md.
For CSF tensors, byte_offset MUST be set to 0x0000000000000000.
Note (non-normative): As with COO, CSR, and CSC, the
byte_offsetfield in the common descriptor header is not meaningful for CSF — there is no single "first element" at a fixed offset; the first stored element is located by descending the tree.
Element Lookup
To retrieve the value at logical index idx = [idx[0], ..., idx[rank-1]]:
- Permute the query into storage order: the coordinate sought at level
Lisq_L = idx[mode_order[L]]. - Initialise the parent position
p = 0(the virtual root). - For each level
Lfrom0torank-1:- The children of the current parent occupy the slice
crd_L[pos_L[p] .. pos_L[p+1]). - Perform a binary search on that slice for
q_L. - If the binary search finds
q_Lat relative offsetkwithin the slice (so thatcrd_L[pos_L[p] + k] == q_L), setp = pos_L[p] + k— the absolute index intocrd_L— and continue to the next level. - If not found, the element is implicitly zero; stop.
- The children of the current parent occupy the slice
- After the leaf level (
L = rank-1),pis the leaf position; returnvalues[p].
Binary search is valid at each level because sibling coordinates within a parent slice are sorted (invariant 4).
Iteration
To iterate over all non-zeros, descend the tree in depth-first, leaf order. The
following sketch accumulates the storage-order coordinate tuple c[0..rank-1]; the
logical index is recovered by placing c[L] at dimension mode_order[L]:
def visit(L, p):
for i = pos_L[p] to pos_L[p+1] - 1:
c[L] = crd_L[i]
if L == rank - 1:
emit(c, values[i]) # i is the leaf position
else:
visit(L + 1, i)
visit(0, 0)
Relationship to CSR / CSC / COO
CSF generalises CSR/CSC to arbitrary rank but does not replace them. For rank-2 tensors, writers SHOULD prefer CSR (CSR (Compressed Sparse Row)) or CSC (CSC (Compressed Sparse Column)), whose dense outer pointer array is more compact and more interop-canonical; for rank-1 and rank-2 coordinate lists, writers SHOULD prefer COO (COO (Coordinate)). CSF MUST NOT be used below rank 3.
A rank-2 CSF tree with mode_order = [0, 1] is structurally analogous to CSR (pos_0
the trivial root pointer, pos_1 the row pointer, crd_1 the column indices), and
with mode_order = [1, 0] to CSC — but CSF stores an explicit top-level pos_0/crd_0
pair that CSR/CSC fold into their dense outer array, so the layouts are not
byte-identical.
Quantization Compatibility
The values buffer (buffer index 0) holds the quantized leaf values, exactly as for
COO and CSR. Quantization schemes decorate the leaves: per-tensor, per-channel,
per-block, NF4, and MXFP compose as for COO/CSR, following the placement rules in
quantization.md. Quantization-parameter buffers occupy buffer indices 2 * rank + 1
and up. All quantization-parameter buffers MUST carry the same device_tag as the
values buffer.
Sharding
A CSF tensor MUST NOT carry a shard descriptor in this version of the specification.
Sharding a CSF tree (the interaction between a shard's shard_offset and the
per-level pos/crd structure) is deferred to a future revision.
Interaction with Statistics Section
When the HAS_STATISTICS flag is set, the nnz field in the statistics section
MUST match the nnz field in the CSF descriptor, which is authoritative. The sparsity_ratio SHOULD be
1.0 - (nnz / total_elements), where total_elements is the product of all shape
entries.
Example
Rank-3 sparse tensor with shape [2, 3, 4], element type float32,
mode_order = [0, 1, 2] (identity), 4 non-zeros:
Non-zeros (logical index -> value):
(0, 0, 1) -> 1.0
(0, 2, 3) -> 2.0
(1, 1, 0) -> 3.0
(1, 1, 2) -> 4.0
nnz = 4
buffer_count = 2 * 3 + 1 = 7
Level 0 (dimension 0, bound 2):
pos_0 (buffer 1): [0, 2]
crd_0 (buffer 2): [0, 1] # two non-empty slices: i=0, i=1
Level 1 (dimension 1, bound 3):
pos_1 (buffer 3): [0, 2, 3] # parent 0 has 2 children, parent 1 has 1
crd_1 (buffer 4): [0, 2, 1] # i=0: j=0,2 ; i=1: j=1
Level 2 (dimension 2, bound 4):
pos_2 (buffer 5): [0, 1, 2, 4] # leaf delimiters
crd_2 (buffer 6): [1, 3, 0, 2] # the k coordinates
values (buffer 0): [1.0, 2.0, 3.0, 4.0]
Lookup of (1, 1, 2):
q = [1, 1, 2] # mode_order is identity
Level 0: parent p = 0, slice crd_0[pos_0[0]..pos_0[1]) = crd_0[0..2) = [0, 1]
search 1 -> relative offset k = 1; p = pos_0[0] + 1 = 0 + 1 = 1
Level 1: slice crd_1[pos_1[1]..pos_1[2]) = crd_1[2..3) = [1]
search 1 -> relative offset k = 0; p = pos_1[1] + 0 = 2 + 0 = 2
Level 2: slice crd_2[pos_2[2]..pos_2[3]) = crd_2[2..4) = [0, 2]
search 2 -> relative offset k = 1; p = pos_2[2] + 1 = 2 + 1 = 3
leaf position p = 3 -> values[3] = 4.0
Block-Paged Layout — Hurray Format Specification
Status: Draft
Layout tag: 0x0A | Tier: 1 | Type: Indirect
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
The block-paged format stores a tensor whose paged axis is divided into fixed-size
pages drawn from a shared page pool, with a block table mapping each logical
page position to a physical page ID. It is the interchange form of a PagedAttention KV
cache: one page pool plus a ragged set of per-sequence block tables, addressed CSR-style
by a seq_ptr offset array.
Block-paged is an indirect-dense layout: every logical element along the paged
axis exists (there are no implicit zeros, unlike sparse layouts), but the mapping from a
logical index to a physical buffer position is resolved through the block table rather
than by an affine stride formula. A block-paged descriptor describes a static snapshot
of one whole batch for one {kv_role, layer_index} pair. It carries no live allocator
state.
Prefix sharing across sequences is expressed as static structure: two block-table entries MAY name the same physical page ID. The aliasing is internal to the block table; it creates no shared buffer ownership (see § Prefix Sharing).
Note (non-normative): Block-paged is defined only for rank-3 tensors in this version of the specification. Generalisation to other ranks is left to a future revision. The whole transformer KV cache (
[layers, 2, ...]) is transmitted as a stream of descriptors, one per key/value role per layer (see ADR-024).
A conforming implementation MUST reject a block-paged descriptor whose rank is not 3.
Note (non-normative): "Page" and "block" refer to the same fixed-size physical unit. "Page" names the storage slot in the
page_pool; "block" names the entry in the index structure (block_table) that maps a logical page position to a physical page ID. This dual naming matches the vLLM convention (page pool, block table).
Logical Shape
The logical shape is [total_tokens, num_heads, head_dim], where total_tokens is the
sum of the per-sequence token counts across the batch. The ragged per-sequence structure
is carried by seq_ptr (see § Buffer Table), not by the shape, which remains a
hyperrectangle. The paged axis is the axis subdivided into pages; in this version it
MUST be axis 0 (paged_axis = 0).
Buffer Table
A block-paged tensor descriptor MUST have buffer_count >= 3 in the buffer table.
Buffers 0–2 are always present; buffers 3 and up are present only when the
HAS_QUANTIZATION flag is set, per the placement rules in quantization.md.
| Buffer index | Name | Element type | Length | Description |
|---|---|---|---|---|
| 0 | page_pool | tensor storage type | num_pages * page_size * num_heads * head_dim elements | Flat pool of fixed-size pages. Page p occupies the contiguous slice [p * page_size * num_heads * head_dim, (p + 1) * page_size * num_heads * head_dim). |
| 1 | block_table | uint32 or uint64 (per block_table_index_type) | total_logical_pages elements | Concatenation of every sequence's page-ID list, in sequence order. block_table[k] is the physical page ID of the k-th logical page across the batch. |
| 2 | seq_ptr | uint32 or uint64 (per block_table_index_type) | num_seqs + 1 elements | Offset array into block_table. Sequence s owns block_table[seq_ptr[s]..seq_ptr[s+1]). seq_ptr[0] = 0 and seq_ptr[num_seqs] = total_logical_pages. |
| 3 | scales | quantization scale type | num_pages * page_size elements (per-token) | Present only when HAS_QUANTIZATION is set. Paged through the same block_table as the values. |
| 4 | zero_points | quantization zero-point type | same length as scales | Present only for an asymmetric scheme that uses a separate zero-point buffer (see quantization.md § Buffer Table Placement Rules). |
where total_logical_pages = seq_ptr[num_seqs].
Note (non-normative): Buffers 1 (
block_table) and 2 (seq_ptr) share the same element type, selected byblock_table_index_type.
Note (non-normative): Empty batch (
num_seqs = 0):block_table(buffer 1) is empty (byte_size = 0, and MAY be a null pointer perbuffer-protocol.md), andseq_ptr(buffer 2) contains exactly one element,seq_ptr[0] = 0.
Additional Descriptor Fields
| Field | Type | Description |
|---|---|---|
page_size | uint32 | Tokens per page. MUST be >= 1. Common values are 16 and 32. |
num_pages | uint64 | Number of physical pages in page_pool. |
paged_axis | uint32 | The axis subdivided into pages. MUST be 0 in this version. |
num_seqs | uint32 | Number of sequences in the batch. MAY be 0 for an empty batch. |
kv_role | uint8 | 0x00 = key, 0x01 = value, 0x02 = fused / non-KV generic. |
layer_index | uint32 | Transformer layer index. 0xFFFFFFFF indicates the tensor is not layer-scoped. |
block_table_index_type | uint8 | 0x00 = uint32 (default), 0x01 = uint64. Governs the element type of buffers 1 and 2. |
_reserved | uint8[6] | MUST be 0x00. Readers MUST reject a descriptor with any non-zero reserved byte. |
All multi-byte fields in the block-paged descriptor MUST be little-endian.
Note (non-normative): A
uint32block table addresses up to0xFFFFFFFF(4294967295) pages. Larger pools MUST useuint64(block_table_index_type = 0x01).
byte_offset
For block-paged tensors, byte_offset MUST be set to 0x0000000000000000.
Note (non-normative): The
byte_offsetfield in the common descriptor header is not meaningful for block-paged — logical position 0 is located via the block table, not at a fixed offset, exactly as for sparse layouts.
Storage Invariants
A conforming writer MUST ensure:
seq_ptr[0] = 0andseq_ptr[num_seqs] = total_logical_pages.seq_ptris non-decreasing:seq_ptr[s] <= seq_ptr[s+1]for alls.- All physical page IDs are within bounds:
0 <= block_table[k] < num_pagesfor allk. - The
page_poolbuffer size isnum_pages * page_size * num_heads * head_dim * element_byte_widthbytes (or the sub-byte equivalent,ceil(num_pages * page_size * num_heads * head_dim / packing_factor), wherepacking_factoris as defined inmemory-layout.md§ Sub-Byte Types). - When
HAS_QUANTIZATIONis set,scales(andzero_points, when present) are indexed by the sameblock_table, with one entry per page slot (num_pages * page_sizeentries). paged_axis = 0.
A conforming reader SHOULD validate invariants (1)–(3) and (6) and MUST reject descriptors that violate them, unless operating in permissive mode. A reader is NOT required to validate that aliased page IDs carry identical content: aliasing is expressible, not validated (see § Prefix Sharing).
Partial trailing page. A sequence's token count need not be a multiple of
page_size; the slots in its final page beyond the valid token count are undefined.
A reader MUST NOT read past a sequence's valid token count (bounded by seq_ptr and the
sequence's token count). A writer SHOULD zero those unused slots when transferring across
a trust or tenant boundary.
Element Lookup
To retrieve the value at token t of sequence s, head h, dimension d:
page_in_seq = t / page_size (integer division)
offset_in_page = t mod page_size
phys_page = block_table[seq_ptr[s] + page_in_seq]
flat = ((phys_page * page_size + offset_in_page) * num_heads + h) * head_dim + d
value = page_pool[flat]
When the tensor is quantized, dequantize value using
scales[phys_page * page_size + offset_in_page] (and the corresponding zero_points
entry for an asymmetric scheme), per the active scheme in quantization.md.
Prefix Sharing
Two sequences share a prefix when their block-table slices name the same physical page IDs for the shared leading positions. For example, with
seq 0 block-table slice: [12, 5, 7, 9]
seq 1 block-table slice: [12, 5, 7, 3]
sequences 0 and 1 share physical pages 12, 5, and 7. The aliasing is internal to the
block_table buffer: it creates no shared buffer ownership and no wire-level reference
count (see ADR-024 and ADR-009). The engine-internal copy-on-write reference counts that
govern live page lifetime are out of scope; a block-paged descriptor is a static
snapshot.
Quantization Compatibility
Block-paged tensors MAY be quantized using the schemes defined in quantization.md. The
quantization-parameter buffers occupy buffer indices 3 and up, per quantization.md
§ Buffer Table Placement Rules. The following rules govern how those schemes compose with
the paged structure.
Per-page-slot parameters
For a quantized block-paged tensor, scales (and zero-points, when present) are stored
per page slot: the scale array has exactly num_pages * page_size entries, and it is
paged through the same block_table as the values. The scale for the element at
physical page p, slot i lives at scales[p * page_size + i]. This is the indexing
already given in § Element Lookup: a reader dequantizes using
scales[phys_page * page_size + offset_in_page] (and the corresponding zero_points
entry for an asymmetric scheme). Zero-point parameters, when carried in a separate buffer,
follow the same per-page-slot layout.
Note (non-normative): Per-page co-location is required so that a shared (aliased) page carries its own scales. Because two sequences may name the same physical page ID in their
block_tableslices (see § Prefix Sharing), storing scales per logical token would force a shared page's leading tokens to be dequantized with different scales depending on the aliasing sequence, breaking numerical coherence of the prefix. Paging the scales through the sameblock_tableas the values keeps every aliased reference to a page numerically identical.
Scale-buffer size
The standard per-block-affine scale-buffer-size formula in quantization.md (which yields
shape[axis] / block_size scale entries) does NOT apply to block-paged. A reader MUST
compute the scale-buffer size as num_pages * page_size entries from the page structure,
because scales are stored per physical page slot rather than per logical token. This is
what allows an aliased/shared page to carry its own scales and keeps prefix sharing
numerically coherent.
Per-scheme composition
- Per-tensor schemes (
scheme_tag = 0x01, seequantization.md) compose normally: one scale (and zero-point) applies to the whole tensor. - Per-channel schemes (
scheme_tag = 0x02) compose normally when the quantization axis isnum_heads(axis 1) orhead_dim(axis 2). The per-channel parameters are indexed by channel along that axis and are independent of the paged structure. - Per-block-affine (
scheme_tag = 0x03) composes only under the constraint that the quantization descriptor'saxisfield MUST be0(the paged / token axis) andblock_sizeMUST equalpage_size. Under this constraint the per-block parameters coincide exactly with the per-page-slot parameters described above. A reader MUST reject a block-paged descriptor usingscheme_tag = 0x03whoseaxisfield is not0, or whoseblock_sizeis not equal topage_size.
A writer MUST NOT quantize the paged / token axis (axis 0) with the per-channel scheme
(scheme_tag = 0x02); paged-axis quantization MUST use per-block-affine under the
block_size == page_size constraint above, so that scales remain per-page-slot and
aliasing stays coherent.
Sharding
A shard descriptor (see memory-layout.md § Splittability and Sharding) MUST NOT be
applied to a block-paged tensor in this version of the specification. A reader MUST reject
a block-paged descriptor that also carries a shard descriptor.
Note (non-normative): Tensor-parallel / multi-GPU sharding of a block-paged KV cache (for example, splitting along the
num_headsaxis) is deferred to a future revision. Before sharding can be permitted, the interaction between a shard descriptor'sshard_offsetand the absoluteseq_ptroffsets into theblock_tablemust be resolved, sinceseq_ptrindexes the whole-batch block table rather than a shard-local slice. The normative rule for this version is stated above: a reader MUST reject a block-paged descriptor that also carries a shard descriptor.
Alignment
All buffers MUST satisfy the alignment requirements in buffer-protocol.md (at least
64 bytes; page-aligned for GPU, IPC, or RDMA). All buffers in a block-paged descriptor
MUST share the same device_tag and memory_class.
Framework Compliance
Note (non-normative): There is no formal PagedAttention specification. This layout is designed to be faithful to the dominant inference frameworks, principally vLLM.
In vLLM's FlashAttention / FlashInfer backend, the per-layer KV cache is stored as one tensor per key and per value with shape
[num_blocks, block_size, num_kv_heads, head_dim], and the defaultblock_sizeis 16. The block table is a per-sequence list of physical block numbers, and sequences may share physical blocks (copy-on-write prefix sharing). These map onto Hurray as follows:
vLLM concept Hurray block-paged num_blocksnum_pagesblock_size(default 16)page_sizenum_kv_headsnum_headshead_dimhead_dimper-K / per-V tensor one descriptor per kv_roleperlayer_indexper-sequence block table block_tableslice delimited byseq_ptrshared physical block aliased page ID in block_tableHurray encodes the portable interchange layout (
[num_blocks, block_size, num_kv_heads, head_dim]per role), not a kernel-internal reshaped layout. In particular, the olderpaged_attention_v1key-cache reshape[num_blocks, num_kv_heads, head_dim/x, block_size, x]is an engine implementation detail and is out of scope for this layout. A producer that stores its cache in a kernel-internal layout MUST transpose to the interchange layout before emitting a block-paged descriptor.
Example
A KV cache for layer 3, key role, with 2 sequences, page_size = 4, num_pages = 5,
num_heads = 2, head_dim = 8, element type float16. Sequence 0 has 6 tokens,
sequence 1 has 3 tokens, and sequence 1 reuses page 0 (a shared prefix):
num_seqs = 2
seq_ptr (buffer 2): [0, 2, 3]
block_table (buffer 1): [0, 1, 0]
page_pool (buffer 0): 5 * 4 * 2 * 8 = 320 float16 values
kv_role = 0x00 (key)
layer_index = 3
page_size = 4
num_pages = 5
paged_axis = 0
block_table_index_type = 0x00 (uint32)
Sequence 0 occupies block_table[0..2) = [0, 1] (6 tokens span 2 pages: page 0 holds
tokens 0–3, page 1 holds tokens 4–5, with slots 6–7 of page 1 undefined). Sequence 1
occupies block_table[2..3) = [0] (3 tokens in page 0, slot 3 undefined), aliasing
page 0 — the shared prefix.
Composite / Virtual Tensor — Hurray Format Specification
Layout tag: 0x0B | Tier: 1 | Type: Virtual
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Description
A composite tensor is a head descriptor plus an ordered set of member
tensors, combined under a declared composition rule. The head presents a single
logical view — one shape and one type_tag — while the members supply the actual
data, each as a complete, ordinary tensor descriptor with its own layout, buffers,
quantization, statistics, and device placement.
The head is a virtual (data-less) descriptor: it owns no buffers. This is a new
addressing category, Virtual, alongside Dense, Sparse, and Indirect (see
memory-layout.md § Layout Taxonomy). A composite unifies three previously distinct
capabilities under one primitive:
- Partition — a logical tensor whose index space is an exact, non-overlapping tiling of heterogeneous regions, each a full member tensor (see § Composition Semantics › Partition for the coverage and non-overlap constraints).
- Overlay — a base tensor spanning the whole index space plus scattered corrections at shared indices (SpQR / KVQuant outlier quantization).
- Group — several independent tensors delivered together under one logical identity (multi-output inference; weight collections).
Note (non-normative): A member is an ordinary
TensorDescriptor. Everything a region needs — per-member layout, buffers, quantization, statistics, device tags — comes from the existing descriptor machinery, so the composite adds no per-member wire format of its own beyond the small Composite Member section (§ Composite Member Section). The crux of the unification is that a region is a shard is a member.
Note (non-normative): This version specifies only the v1.0-accepted scope: partition, group, and sealed overlay. Versioned / open overlay — an appendable, time-travel-capable overlay identified by an open-composite
member_countsentinel and a per-member version field — is deferred to a future ADR (see ADR-027 § Status). Wire space is reserved for it (themember_countsentinel0xFFFFFFFFin § Head Layout-Specific Fields, and the reserved padding in § Composite Member Section) so the future feature is an additive change, not a re-layout. Those reserved encodings are not usable in v1.0; the normative rejection rules for them appear in § Head Layout-Specific Fields and § Composite Member Section below.
Head Descriptor
The head is an ordinary tensor descriptor (see metadata.md) with layout_tag = 0x0B.
It carries the composite's logical shape (shape) and logical element type
(type_tag) — the view the composite presents to a consumer.
The head owns no data:
buffer_countMUST be0x00.byte_offsetMUST be0x0000000000000000.
A reader MUST reject a head descriptor whose buffer_count is not 0x00, or whose
byte_offset is not 0x0000000000000000.
The head MUST NOT set the HAS_COMPOSITE_MEMBER flag (bit 4); that flag applies to
members only (§ Composite Member Section). Because the head owns no data buffer, it MUST
NOT set the HAS_QUANTIZATION flag (there is no stored data to dequantize). When a head is
itself a member of an enclosing partition or overlay composite (a nested composite; see
§ Binding), it MUST carry a shard section exactly as any other member does.
A strict reader MUST reject an unrecognised layout_tag = 0x0B. A permissive reader MAY
read the head's shape and type_tag but MUST NOT dereference tensor data through the
head (there is none).
Head Layout-Specific Fields
Immediately following the head's byte_offset (per metadata.md § Layout-Specific
Fields), the composite head encodes the following fields. All multi-byte fields are
little-endian.
| Field | Type | Description |
|---|---|---|
composition_rule | uint8 | 0x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF are reserved; 0xF0–0xFE are implementation-private; 0xFF is invalid. |
combine_op | uint8 | Overlay only: 0x01 replace (last-wins), 0x02 add. MUST be 0x00 for partition (0x01) and group (0x03). |
_reserved | uint8[2] | MUST be 0x00. |
member_count | uint32 | Number of member tensors that immediately follow the head. MUST be a definite count for every composition rule. |
A reader MUST reject a head whose composition_rule is 0x00, 0xFF, or in the reserved
range 0x04–0xEF. A reader that does not recognise an implementation-private
composition_rule in 0xF0–0xFE MUST reject the head unless operating in permissive
mode with an out-of-band agreement.
A reader MUST reject a head whose combine_op is not 0x00 when composition_rule is
0x01 (partition) or 0x03 (group). When composition_rule is 0x02 (overlay), a
reader MUST reject a head whose combine_op is not 0x01 or 0x02.
A reader MUST reject a head whose _reserved bytes are not all 0x00.
member_count sentinel — RESERVED. The value 0xFFFFFFFF denotes an open
composite and is RESERVED for a future ADR (versioned / open overlay). A strict v1.0
reader MUST reject a head whose member_count is 0xFFFFFFFF. All v1.0 composites —
partition, group, and overlay — use a definite member_count.
Note (non-normative):
member_count = 0x00000000(a head with no members) is a definite count of zero. It is syntactically valid but carries no data view; writers are not expected to emit it. Validation for each rule at "the Nth member" is trivially satisfied at N = 0 except where a rule requires a base member (overlay), which such a head cannot supply — see § Validation.
Members
A member is a complete, ordinary TensorDescriptor with its own layout_tag (dense,
sparse, indirect, or a nested composite), its own buffer table, and its own optional
quantization, statistics, and device tags.
Shard section
For partition (0x01) and overlay (0x02) composites, every member MUST carry a
shard section (HAS_SHARD flag set; see metadata.md § Shard Section, and ADR-004). Its
parent_shape MUST equal the head's logical shape, and its shard_offset together with
the member's own shape define the member's box in the head's index space:
member covers, along dimension k, the half-open range
[ shard_offset[k], shard_offset[k] + shape[k] )
For group (0x03) composites, members MAY omit the shard section (group members have
no spatial relationship to the head; see § Composition Semantics).
Element type across members
For partition and overlay, each member MAY declare its own stored element type and its
own quantization scheme, but each member's decoded value type MUST equal the head's
type_tag. Dequantization already yields a canonical real-valued view, so no new
machinery is required.
Note (non-normative): SpQR example — head
type_tag = float16; the base member isint4with per-block-affine quantization decoding tofloat16; an outlier correction member is afloat16COO sparse tensor. The overlay combine is evaluated infloat16(the head's type). Composite versioning would change values over a fixed index space; shape evolution is out of scope.
For group composites, the head's shape and type_tag are advisory and members MAY
differ arbitrarily (see § Composition Semantics).
Composite Member Section
Overlay members carry a Composite Member section, a new optional descriptor
section gated by descriptor flag bit 4, HAS_COMPOSITE_MEMBER, defined in
metadata.md § Composite Member Section and appended after the Extension Type section.
Its single v1.0 field is member_role:
member_role | Meaning |
|---|---|
0x00 | correction |
0x01 | base |
0x02–0xFF | RESERVED (see § Deferred below) |
Partition and group members MUST NOT carry a Composite Member section (they MUST NOT set
HAS_COMPOSITE_MEMBER). An overlay member MUST carry one.
Note (non-normative): v1.0 carries
member_roleonly. A sealed overlay's precedence is plain stream / emission order (§ Composition Semantics), so no explicit version field is needed. The section's reserved padding leaves room for a future ADR to add amember_versionfield additively without reallocating the section.
Binding
A head with a definite member_count = N binds the next N self-delimiting tensors in
stream / file write order as its members. This is a forward promise — the head
precedes its members, which precede their data — not a back-reference. It introduces no
name namespace and is streamable for both readers and writers.
- In-process: the head handle plus an array of N member handles (no wire concern).
- IPC / network streaming: the framing and "close" rules are defined in
interchange.md§ Composite Tensor Streaming. - File: the head + members occupy consecutive index entries in the tensor region; the
recovery rule is defined in
file-format.md§ Composite Tensors.
Nested composites are permitted: a member MAY itself be a head with layout_tag = 0x0B,
parsed pre-order. A reader MUST enforce a maximum composite nesting depth of 8 levels and
MUST reject a descriptor that exceeds it (the same recursion-depth discipline used for
nested Tiled layouts, metadata.md § Layout-Specific Fields › Tiled / Blocked).
Note (non-normative): Plain sharding (members without a head; see
interchange.md§ Parallel Transfers) is the status quo. The head upgrades an ephemeral shard set into a persistent, composition-typed collection. Explicit member identifiers for out-of-order random access are not defined in this version (see § Deferred).
Composition Semantics
Partition (0x01)
Members' shard boxes MUST exactly cover the head's index space with no overlap. Each
logical index belongs to exactly one member, so the composite view is zero-copy: value
lookup at logical index idx is (1) select the member whose box contains idx,
(2) compute the local index idx - shard_offset, (3) apply that member's own addressing.
Coverage constraint. The union of all member boxes MUST exactly cover every element in
the head's index space: for every valid index [i_0, i_1, ..., i_{r-1}] (where
0 <= i_k < shape[k] for all k, shape being the head's shape), there MUST be
exactly one member whose box — [shard_offset[k], shard_offset[k] + shape[k]) along each
dimension k (§ Members › Shard section) — contains that index.
Non-overlap constraint. Two members' boxes A and B overlap if, for every
dimension k:
A.shard_offset[k] < B.shard_offset[k] + B.shape[k]
AND
B.shard_offset[k] < A.shard_offset[k] + A.shape[k]
Member boxes MUST NOT overlap. A conforming writer MUST produce a partition composite whose members satisfy both constraints. A conforming reader SHOULD validate them and MUST reject a violating composite unless in permissive mode (§ Validation).
Overlay (0x02, v1.0: sealed only)
One member is the base (member_role = 0x01): it MUST be the first member and its
box MUST span the whole index space (its shard shard_offset is all-zero and its shape
equals the head's shape). The remaining members are corrections (member_role = 0x00) whose boxes MAY overlap one another and the base.
Precedence is emission / stream order — later wins. The writer emits the base first, then corrections in the order they take effect. A single-pass reader applies members as they arrive; no version field or reordering is required.
Reads apply the base, then all corrections in emission order, under combine_op,
evaluated in the head's type_tag:
combine_op = 0x01(replace): within a correction's box, the topmost (latest-emitted) member covering an index wins; outside every correction's box, the base shows through.combine_op = 0x02(add): the value at an index is the base value plus the sum of all covering corrections' values at that index.
A correction's box replaces or adds within its box only; outside it, lower-precedence members (down to the base) show through. Per-member storage is zero-copy, but the merged logical view is computed by the consumer — a sealed overlay is not zero-copy at the composite level.
Note (non-normative): Replace serves region overwrites; add serves residual / outlier overlays (SpQR / KVQuant). A v1.0 overlay is a complete snapshot: definite
member_count, not appendable without rewriting the head. Logical delete (reverting a region to base, or masking it) is out of scope for v1.0; a region is reverted by writing a new correction that carries the desired values. A data-less tombstone member kind is reserved (see § Deferred).
Group (0x03)
Members are independent tensors under one head identity, with no spatial semantics and
no ordering semantics. The head's shape and type_tag are advisory; members MAY
differ arbitrarily in rank, shape, element type, layout, and device. This occupies the
grouping gap (see ADR-010) using forward adjacency, not naming.
Validation
Validation is cross-member and stateful but bounded: because every v1.0 composition
rule uses a definite member_count = N, a reader accumulates state only up to N, reaches
one verdict at the Nth member, and is done.
Per-member checks (immediate)
On each member, a reader MUST verify:
- For partition and overlay: the member carries a shard section whose
parent_shapeequals the head'sshape, and whose box is in bounds (shard_offset[k] + shape[k] <=the head'sshape[k]for every dimensionk). - The member's decoded value type equals the head's
type_tag(§ Members). combine_opis legal for the composition rule (§ Head Layout-Specific Fields).- For overlay: the member carries a Composite Member section with a valid
member_role(0x00or0x01). The first overlay member MUST be the base (member_role = 0x01) and MUST span the index space (all-zeroshard_offset,shapeequal to the head'sshape). Every subsequent overlay member MUST havemember_role = 0x00. - For partition and group: the member MUST NOT set
HAS_COMPOSITE_MEMBER.
A per-member violation MUST cause rejection of the whole composite.
Close-time checks (at the Nth member)
- Partition: on receiving the Nth member, run the exact-cover and non-overlap checks (§ Composition Semantics › Partition) over the N boxes. A gap or an overlap MUST cause rejection.
- Sealed overlay: close at the Nth member. The base-span is already checked at the
first member (per-member check 4); overlap between corrections is legal; there is no
exact-cover requirement. A reader MUST reject an overlay head (
composition_rule = 0x02) withmember_count = 0x00000000: overlay requires a base member (§ Composition Semantics), which a zero-member composite cannot supply. - Group: close at the Nth member. There is no coverage or overlap check (members MAY differ arbitrarily).
Torn composite
A torn composite — the stream or file ends before all N members have arrived, for any composition rule — is incomplete. A strict reader MUST reject it. A permissive reader MAY expose the arrived members as independent shard tensors but MUST NOT present the composite as complete.
Deferred
Note (non-normative): The following are reserved for a future ADR (versioned / open overlay and related work) and are not part of v1.0: the
0xFFFFFFFFopen-compositemember_countsentinel and its append-oriented membership rules; themember_versionfield (the Composite Member section's reserved padding holds room for it); time-travel reads; file append with footer regeneration; a tombstone member kind (member_role = 0x02, data-less, revert-to-base-within-box); explicit member identifiers for out-of-order random access; wall-clock timestamp versioning; an optional inline single-frame compaction of a partition composite (a future revival of this idea would need a fresh layout tag allocated from the reserved range0x0C–0x3F; the old subpaving tag0x06is permanently reassigned to COO and is not available for it); and heterogeneous per-member device placement. See ADR-027 § Status and § Consequences.
Example
A sealed overlay (SpQR-style) with a float16 logical view of shape [4096, 4096],
combine_op = 0x01 (replace), one base and one correction:
Head (layout_tag = 0x0B):
shape = [4096, 4096]
type_tag = float16
buffer_count = 0x00
byte_offset = 0x0000000000000000
composition_rule = 0x02 (overlay)
combine_op = 0x01 (replace)
member_count = 2
Member 0 (base):
shape = [4096, 4096]
type_tag = int4 (stored), decodes to float16
layout_tag = 0x01 (row-major), per-block-affine quantization
HAS_SHARD: parent_shape = [4096, 4096], shard_offset = [0, 0]
HAS_COMPOSITE_MEMBER: member_role = 0x01 (base)
Member 1 (correction):
shape = [4096, 4096]
type_tag = float16
layout_tag = 0x06 (COO sparse: scattered outliers)
HAS_SHARD: parent_shape = [4096, 4096], shard_offset = [0, 0]
HAS_COMPOSITE_MEMBER: member_role = 0x00 (correction)
The merged logical view is: for each index, the correction's outlier value if present, otherwise the dequantized base value. The reader computes this merge; it is not zero-copy at the composite level.
A partition composite with a float32 logical view of shape [8, 8], split into two
[8, 4] members:
Head: shape = [8, 8], type_tag = float32, buffer_count = 0,
composition_rule = 0x01 (partition), combine_op = 0x00, member_count = 2
Member 0: shape = [8, 4], HAS_SHARD parent_shape = [8, 8], shard_offset = [0, 0]
Member 1: shape = [8, 4], HAS_SHARD parent_shape = [8, 8], shard_offset = [0, 4]
The two boxes exactly cover [8, 8] with no overlap; element [3, 6] resolves to member 1
at local index [3, 2]. The view is zero-copy.
Buffer Protocol — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines the buffer protocol: the rules governing how tensor data buffers and quantization-parameter buffers are represented, aligned, located on a device, owned, and released. It is the normative reference for all other sections that reference buffer handles, device tags, or alignment requirements.
The buffer protocol is independent of the interchange transport (in-process,
IPC, or cross-machine); interchange.md defines the transport-level framing.
Buffer Handle
A buffer handle is the unit by which a tensor descriptor references a
contiguous region of memory. Each handle appears as a 16-byte entry in the
buffer table of the tensor descriptor (see metadata.md § Buffer Table).
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | byte_size | uint64 | Size of the buffer in bytes (little-endian). 0 denotes an empty buffer. |
| 8 | alignment | uint32 | Minimum alignment of the buffer's base address in bytes (little-endian). MUST be a power of two; MUST be at least 64 for non-empty buffers (byte_size > 0); any power-of-two value (including 1) is valid for empty buffers (byte_size == 0). See § Empty Buffers. |
| 12 | device_tag | uint8 | Device where this buffer resides. See § Device Tags. |
| 13 | sync_mode | uint8 | Producer-side synchronisation mechanism in effect. See § Stream and Event Synchronisation. |
| 14 | memory_class | uint8 | Memory access class. See § Memory Class. |
| 15 | _reserved | uint8 | MUST be 0x00. Readers MUST reject a descriptor with non-zero reserved bytes. |
All multi-byte fields MUST be encoded in little-endian byte order.
Note (non-normative): The buffer handle in the descriptor is a declaration of a buffer's properties, not a pointer. The actual pointer (or shared-memory handle, or RDMA registration) is communicated out-of-band via the interchange protocol or, for in-process use, via the C ABI defined in
docs/impl/c-ffi.md.
Alignment
Minimum Alignment
The base address of every non-empty buffer MUST be aligned to at least 64 bytes. This ensures compatibility with all current SIMD instruction sets (AVX-512, NEON, SVE) without requiring per-operation alignment negotiation.
A writer MUST set alignment to the actual alignment it guarantees, which MUST
be a power of two and MUST be at least 64. A writer MAY set alignment to a
larger value (e.g., a page boundary of 4096 or 65536) to communicate a
stronger guarantee.
A reader MAY rely on the declared alignment for SIMD loads. A reader MUST NOT
rely on alignment stronger than what is declared in the alignment field.
Page Alignment for GPU and IPC
Buffers shared across process boundaries (IPC) or placed in device memory (GPU)
SHOULD be aligned to the host page size, which is typically 4096 bytes.
Writers targeting GPU or IPC transport MUST set alignment to at least 4096.
Buffers intended for RDMA transfer SHOULD be aligned to the RDMA provider's
minimum pinnable unit, which is typically 4096 bytes. Writers targeting RDMA
MUST set alignment to at least 4096.
Empty Buffers
A buffer with byte_size = 0 is an empty buffer. The 64-byte minimum
alignment requirement does not apply to an empty buffer: there are no addressable
bytes to align. A writer MAY set alignment to any power-of-two value (including
0x00000001) for an empty buffer. A reader MUST NOT dereference the pointer of
an empty buffer.
In C ABI contexts, an empty buffer MAY be represented by a null pointer. A non-null pointer for an empty buffer is also valid; readers MUST handle both.
Device Tags
The device_tag field identifies the memory space in which the buffer resides.
| Value | Device |
|---|---|
0x00 | CPU host memory |
0x01 | CUDA device memory |
0x02 | ROCm device memory |
0x03 | Metal device memory (Apple Silicon unified memory) |
0x04 | Vulkan device memory |
0x05 | WebGPU device memory |
0x06 | Qualcomm Hexagon (HVX/HMX) memory |
0x07 | Intel Level Zero / oneAPI device memory |
0x08 | OpenCL device memory |
0x09–0xEF | Reserved for future specification versions |
0xF0–0xFE | Implementation-private device types |
0xFF | Reserved (invalid) |
A reader MUST reject a buffer handle whose device_tag is 0xFF.
Tags in the range 0x09–0xEF MUST NOT be used by any implementation; they
are reserved for future specification versions.
Tags in the range 0xF0–0xFE MAY be used by implementations for
private or experimental device types. Descriptors carrying private device tags
MUST NOT be exchanged between independent implementations unless both parties
have agreed on the semantics out of band.
Per-Device Memory Model
This subsection specifies, for each named device tag, the allocation context the buffer's base address refers to and any alignment requirement that applies in addition to the global 64-byte SIMD minimum defined in § Alignment. Where no device-specific alignment is required, only the 64-byte minimum (and the page-alignment SHOULD for GPU/IPC) applies.
0x00 CPU host memory
Buffers reside in the producer's process-addressable host memory, allocated by
any standard host allocator (e.g., malloc, mmap, jemalloc, the system page
allocator). No alignment requirement above the 64-byte SIMD minimum applies.
Buffers shared via IPC SHOULD be page-aligned per § Page Alignment for GPU and
IPC.
0x01 CUDA device memory
Buffers reside in CUDA device memory, typically allocated by cudaMalloc,
cuMemAlloc, or cuMemCreate / cuMemMap on the device identified by the
producer's CUDA context. The base address is a CUDA device pointer and MUST NOT
be dereferenced from the host. Buffers intended for cross-process sharing or
GPUDirect RDMA SHOULD be page-aligned (typically 4096 bytes); the CUDA driver
returns allocations aligned to at least 256 bytes, which already satisfies the
64-byte SIMD minimum.
0x02 ROCm device memory
Buffers reside in AMD GPU device memory allocated through the HIP / ROCr runtime
(e.g., hipMalloc, hsa_amd_memory_pool_allocate). The base address is a
device pointer and MUST NOT be dereferenced from the host. The 64-byte minimum
applies; page alignment SHOULD be used for IPC and RDMA per the global rules.
0x03 Metal device memory
Buffers reside in a Metal MTLBuffer whose storage mode is shared (Apple
Silicon unified memory) or private. On Apple Silicon, unified memory permits
host access to shared-mode buffers; private-mode buffers MUST NOT be
dereferenced from the host. The base address conveyed via this tag is the
buffer's contents pointer (for shared/managed storage) or the GPU resource
handle (for private storage); the consumer MUST agree out of band on which
storage mode the producer used. The 64-byte minimum applies.
0x04 Vulkan device memory
Buffers reside in a VkDeviceMemory allocation on the producer's Vulkan logical
device, typically backing a VkBuffer. The base address is the result of
vkMapMemory for host-visible memory, or an opaque device handle that MUST NOT
be dereferenced from the host for device-local memory. Cross-process sharing
requires an external memory handle (VkExternalMemoryHandleTypeFlagBits)
exchanged out of band. The 64-byte minimum applies; producers SHOULD honour
the device's nonCoherentAtomSize and minMemoryMapAlignment properties when
applicable.
0x05 WebGPU device memory
Buffers reside in a WebGPU GPUBuffer allocated against the producer's
GPUDevice. The base address is a host pointer only when the buffer was
mapped via mapAsync with MAP_READ or MAP_WRITE; otherwise it is an
opaque GPU resource handle that MUST NOT be dereferenced from the host. WebGPU
imposes a 4-byte minimum for buffer offsets and copy sizes; the 64-byte SIMD
minimum subsumes this. Cross-process sharing of WebGPU buffers is not defined
by the W3C specification; implementations MUST exchange GPU resource handles
out of band.
0x06 Qualcomm Hexagon (HVX/HMX) memory
Buffers reside in memory allocated via the Qualcomm AI Engine Direct (QNN)
SDK or the Hexagon SDK's rpcmem / FastRPC allocator, accessible to the
Hexagon DSP / NPU via the QNN HTP backend. The base address is a host pointer
into the rpcmem region that the DSP can also address through its IOMMU. HVX
operations are most efficient on 128-byte-aligned addresses; producers SHOULD
align buffers to at least 128 bytes when targeting Hexagon, which exceeds the
64-byte SIMD minimum.
0x07 Intel Level Zero / oneAPI device memory
Buffers reside in memory allocated via the Intel Level Zero API
(zeMemAllocDevice, zeMemAllocHost, zeMemAllocShared) on a Level Zero
device, or equivalently via SYCL Unified Shared Memory (sycl::malloc_device,
sycl::malloc_shared). The base address may or may not be host-dereferenceable
depending on the allocation kind; consumers MUST agree on the kind out of band
or query it via zeMemGetAllocProperties. The 64-byte minimum applies; the
Level Zero spec returns allocations aligned to at least 64 bytes by default.
0x08 OpenCL device memory
Buffers reside in an OpenCL cl_mem allocation on the producer's OpenCL
context and device. The base address is a host pointer only when the buffer
was created with CL_MEM_USE_HOST_PTR / CL_MEM_ALLOC_HOST_PTR and is
currently mapped via clEnqueueMapBuffer; otherwise it is an opaque device
handle and MUST NOT be dereferenced from the host. The 64-byte minimum applies;
producers SHOULD honour the device's CL_DEVICE_MEM_BASE_ADDR_ALIGN property
when allocating sub-buffers.
0xF0–0xFE Implementation-private device types
Buffers carrying a private device tag MUST be exchanged only between peers that have agreed on the allocation context, alignment requirements, host-versus-device addressability, and synchronisation rules out of band. The format specification makes no statement about the memory model of private tags beyond the global buffer protocol invariants in this section.
Note (non-normative): The mapping between Hurray device tags and DLPack
DLDeviceTypeconstants is normative for Python bindings only and lives indocs/impl/python-bindings.md§ Device Tag Mapping (Hurray ↔ DLPack). It is intentionally not duplicated here, since translation is the binding layer's responsibility, not a property of the buffer protocol.
Device Colocation
All buffers referenced by a single tensor descriptor (data buffer + all
quantization-parameter buffers) MUST share the same device_tag AND the same
memory_class. A reader MUST reject a descriptor whose buffers carry different
device_tag or memory_class values.
For TENSOR_PUT transfers (see interchange.md), the client unilaterally
declares the destination device_tag in the descriptor; the server MAY reject
the transfer with DEVICE_UNAVAILABLE but MUST NOT silently place buffers on a
different device.
Note (non-normative): Device colocation ensures that quantized tensor kernels can dereference both the data and the quantization parameters without triggering cross-device transfers. A writer that needs quantization parameters on a different device must emit a separate tensor descriptor.
When buffer handles are exchanged across machines, the device selection rules
in interchange.md § Device Negotiation govern which device tag is valid for a
given transfer.
Memory Class
The memory_class field identifies how a buffer is accessible — specifically,
whether it can be read without copying by more than one compute unit simultaneously.
memory_class is orthogonal to device_tag: the device tag names the allocator
or hardware domain; the memory class names the access semantics within that domain.
Memory Class Values
| Value | Name | Semantics |
|---|---|---|
0x00 | STANDARD | Device-exclusive memory. Only the primary compute unit of the tagged device can access this buffer without a copy. This is the default for all device types and is the correct value for pre-ADR-020 descriptors whose _reserved[0] byte is 0x00. |
0x01 | HOST_PINNED | CPU-accessible, device-mapped. The CPU can read and write at native cache speed. The device can access the buffer over its interconnect (PCIe, NVLink) without an explicit copy, but at reduced bandwidth compared to device-local memory. No hardware-managed coherency between CPU and device caches. |
0x02 | UNIFIED | Hardware-managed unified or coherent memory. Both CPU and device can access this buffer at any time; the hardware (driver or MMU) ensures coherency. Physical pages may migrate. |
0x03 | PEER | Peer-to-peer device memory. Directly accessible by a specific set of peer accelerators agreed out of band (NVLink, xGMI, PCIe BAR mapping). Not CPU-accessible without a copy. The set of peers is communicated via the interchange protocol, not this field. |
0x04–0xEF | (reserved) | Reserved for future specification versions. Readers MUST reject a buffer handle with a memory_class in this range. |
0xF0–0xFE | (private) | Implementation-private memory classes. Valid only when paired with a private device_tag (0xF0–0xFE). Semantics are agreed out of band. A reader that does not recognise the private class MUST reject the handle unless the semantics have been agreed out of band. |
0xFF | (invalid) | Reserved. Readers MUST reject a buffer handle whose memory_class is 0xFF. |
A reader MUST reject a buffer handle whose memory_class value is in the range
0x04–0xEF or equals 0xFF.
Per-Device Validity
Not every (device_tag, memory_class) combination is meaningful. The following
table defines the valid combinations. A reader MUST reject a buffer handle whose
(device_tag, memory_class) pair is not listed as valid for the declared device.
Private device tags (0xF0–0xFE) MAY be paired with any private memory class
(0xF0–0xFE) or STANDARD (0x00); semantics are out of band.
| Device | STANDARD | HOST_PINNED | UNIFIED | PEER |
|---|---|---|---|---|
CPU (0x00) | ✓ heap / malloc | ✓ page-locked for GPU DMA | ✓ CPU side of a unified address space | ✗ |
CUDA (0x01) | ✓ cudaMalloc | ✓ cudaMallocHost | ✓ cudaMallocManaged | ✓ NVLink / PCIe P2P |
ROCm (0x02) | ✓ hipMalloc | ✓ hipHostMalloc | ✓ hipMallocManaged (hw-dependent) | ✓ xGMI / PCIe |
Metal (0x03) | ✓ MTLStorageModePrivate | ✓ MTLStorageModeManaged (discrete GPU only) | ✓ MTLStorageModeShared (Apple Silicon) | ✗ |
Vulkan (0x04) | ✓ DEVICE_LOCAL | ✓ HOST_VISIBLE | ✓ DEVICE_LOCAL|HOST_VISIBLE (integrated GPU) | ✓ via external memory extension |
WebGPU (0x05) | ✓ | ✗ | ✗ | ✗ |
Hexagon (0x06) | ✓ VTCM / DDR | ✓ FastRPC shared | ✓ FastRPC coherent | ✗ |
Level Zero (0x07) | ✓ zeMemAllocDevice | ✓ zeMemAllocHost | ✓ zeMemAllocShared | ✓ |
OpenCL (0x08) | ✓ device cl_mem | ✓ CL_MEM_ALLOC_HOST_PTR | ✓ SVM (clSVMAlloc, OpenCL 2.0+) | ✗ |
Note (non-normative): Metal
HOST_PINNED(MTLStorageModeManaged) is deprecated and unavailable on Apple Silicon. Producers targeting Apple Silicon MUST useUNIFIED(MTLStorageModeShared) instead. TheHOST_PINNEDvalue remains defined for discrete Metal GPU configurations.
Note (non-normative): ROCm
UNIFIEDrequires hardware support for Heterogeneous Memory Management (HMM). Producers MUST verify hardware support before tagging a bufferUNIFIED; consumers MAY fall back to a copy-based path ifUNIFIEDis declared but the consumer's runtime does not support HMM on the current device.
Backward Compatibility
Existing descriptors that encode 0x00 in what was previously the first byte of
_reserved[2] are implicitly STANDARD (memory_class = 0x00) — the most
conservative and correct semantics for any pre-ADR-020 allocation. No existing
producer or consumer is broken by this reassignment.
Readers compiled before this amendment will encounter a non-zero memory_class
byte and reject it at the _reserved byte check. This is the intended fail-safe:
a consumer that does not understand the memory class MUST NOT silently treat a
UNIFIED buffer as STANDARD, as doing so would yield incorrect synchronisation.
Buffer Ownership and Lifetime
Ownership Model
At any instant, exactly one entity — the owner — is responsible for the buffer's memory. Ownership may be transferred between a producer and a consumer as part of the interchange protocol, but it is never shared: concurrent read/write access to the same buffer by multiple owners is a protocol error.
In-Process
In in-process exchange, the producer creates the buffer and holds ownership until the consumer signals that it has retained a reference (via the release callback mechanism described below). The consumer then owns the buffer for the duration of its use and MUST release it exactly once when done.
IPC
In IPC exchange via shared memory, the producer creates and owns the shared memory segment. The consumer maps the segment into its own address space. The producer MUST NOT unmap or destroy the segment until all consumers have unmapped it. The IPC channel MUST convey a release signal so that the producer knows when it may reclaim the segment.
Cross-Machine
In cross-machine exchange, the sender owns the source buffer and the receiver
owns the destination buffer. There is no shared buffer; data is copied (or
RDMA-written) from sender to receiver. See interchange.md for framing details.
Release Callback
For in-process and IPC exchange, the buffer handle is augmented at the ABI level
with a release callback: a function pointer that the consumer calls exactly
once when it has finished using the buffer. The release callback is not encoded
in the binary descriptor; it is supplied by the producer at handoff time via the
C ABI (see docs/impl/c-ffi.md).
A consumer MUST call the release callback exactly once. A consumer MUST NOT access the buffer after calling the release callback. A producer's release callback MUST be safe to call from any thread.
Reference Counting
Reference counting is an implementation detail — not a normative contract
(see docs/adr/ADR-009-release-callback-not-normative-refcount.md). A producer
that wishes to support multiple simultaneous consumers of the same buffer MUST
implement reference counting internally. Each consumer receives a separate buffer
handle whose release callback decrements the internal count; the actual
deallocation occurs only when the count reaches zero. Consumers are unaware of
this; they call their release callback exactly once as the normative contract
requires.
Note (non-normative): This is the same model used by DLPack's
DLManagedTensor.deleter. It keeps the ABI surface minimal and allows each language binding to use its own lifetime management idiom (Python GC, RustArc, etc.) without bridging to a C reference count.
Stream and Event Synchronisation
The sync_mode field at offset 13 of the buffer handle declares how the
producer has ordered its device-side writes with respect to the moment of
handoff. The release callback (see § Release Callback)
governs the end of consumer access; sync_mode governs the start.
The two contracts are independent and apply symmetrically.
sync_mode Values
| Value | Name | Meaning |
|---|---|---|
0x00 | SYNC_PRODUCER_SYNCED | The producer has issued a host-side wait on the device stream(s) that wrote the buffer, ensuring all preceding device-side writes have completed before handoff. The consumer MAY access the buffer immediately on any stream. |
0x01 | SYNC_EVENT | The producer has recorded a device event on the stream(s) that wrote the buffer. The consumer MUST retrieve the producer's event handle via the C ABI (see docs/impl/c-ffi.md) and MUST issue a device-stream-wait on it on every stream that will access the buffer before enqueuing any work that touches the buffer. The consumer MUST release the event handle exactly once via the event-release callback defined in docs/impl/c-ffi.md. |
0x02 | SYNC_CONSUMER_STREAM | The consumer declared its target stream at handoff time via the C ABI; the producer has issued a device-side ordering dependency from its writing stream(s) onto the consumer's declared stream(s). The consumer MAY access the buffer on the stream(s) it declared, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait. |
0x03–0xFE | (reserved) | Reserved for future specification versions. Readers MUST reject a buffer handle whose sync_mode is in this range. |
0xFF | (invalid) | Reserved. Readers MUST reject a buffer handle whose sync_mode is 0xFF. |
A reader MUST reject a buffer handle whose sync_mode value is not one of the
values defined for the format version it implements.
Producer Requirement
A producer of a non-CPU buffer (device_tag != 0x00) MUST ensure that, at the
instant ownership of the buffer is transferred to the consumer, all device-side
writes enqueued by the producer that affect the buffer's bytes have reached a
point at which a properly-synchronised consumer access on the same device will
observe them. The producer MUST satisfy this requirement by exactly one of the
three mechanisms enumerated above, and the chosen mechanism MUST be declared in
the buffer handle's sync_mode field.
For a CPU buffer (device_tag == 0x00), sync_mode MUST be
SYNC_PRODUCER_SYNCED (0x00). When concurrent host-side writes exist, the
producer MUST additionally issue a host memory fence (a release-store or
equivalent) before handoff so that all preceding host writes are visible to the
consumer's subsequent loads.
Consumer Requirement
A consumer that has received a buffer handle MUST inspect the sync_mode field
and apply the matching rule before accessing the buffer's bytes:
- If
sync_mode == SYNC_PRODUCER_SYNCED, the consumer MAY access the buffer immediately on any stream. - If
sync_mode == SYNC_EVENT, the consumer MUST retrieve the producer's event handle from the C ABI handoff structure and MUST issue a device-stream-wait on it on every stream that will access the buffer before enqueuing any work that touches the buffer. The consumer MUST release the event handle exactly once via the event-release callback. - If
sync_mode == SYNC_CONSUMER_STREAM, the consumer MAY access the buffer on the stream(s) it declared at handoff time, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait.
A consumer that does not recognise the declared sync_mode value MUST reject
the descriptor.
Per-Transport Constraints
The set of sync_mode values that are valid depends on the interchange
transport (see interchange.md):
- In-process. All three
sync_modevalues are valid. Event and stream handles are exchanged out of band via the C ABI; both parties share the same driver context. - IPC (same machine, different processes).
SYNC_PRODUCER_SYNCEDis always valid.SYNC_EVENTis valid only if the device supports IPC-exportable events (e.g., CUDAcudaIpcEventHandle_t, ROCmhipIpcEventHandle_t).SYNC_CONSUMER_STREAMis valid only if the device supports IPC-exportable streams. When neitherSYNC_EVENTnorSYNC_CONSUMER_STREAMis available on the underlying device, the producer MUST useSYNC_PRODUCER_SYNCEDor fall back to a host-staged copy. - Cross-machine (network transport).
sync_modeMUST beSYNC_PRODUCER_SYNCEDfor every buffer handle transmitted over a network transport.SYNC_EVENTandSYNC_CONSUMER_STREAMare FORBIDDEN across machines because device event and stream handles are not valid in a different driver context on a different host. A receiver MUST reject a cross-machineTENSOR_DESCRIPTORwhose buffer handle declares any other mode. Seeinterchange.md§ RDMA Data Plane for howTENSOR_DATA_ENDserves as the cross-machine equivalent ofSYNC_PRODUCER_SYNCED.
Relationship to the Release Callback
The sync_mode contract governs the start of consumer access; the release
callback (see § Release Callback) governs the end of
consumer access. The two are independent normative contracts that occupy
symmetric positions at the bookends of the consumer's hold.
The event-release callback used in SYNC_EVENT mode is separate from the
buffer-release callback. A consumer in SYNC_EVENT mode therefore makes two
release calls per buffer: one for the event handle, called after the consumer
has issued its stream-wait (typically immediately after handoff), and one for
the buffer, called after all device work on the buffer is complete. Conflating
the two would force the producer to keep the event alive for the buffer's
entire lifetime, defeating the purpose of using events instead of full stream
synchronisation.
A consumer that has issued device work using the buffer MUST NOT call the buffer-release callback until that device work has completed on the device. The consumer is free to satisfy this by host-side waiting, by recording its own completion event and waiting on it, or by deferring the release callback to a completion callback registered on its stream.
C ABI Note
The opaque event handle (for SYNC_EVENT) and the consumer stream handle (for
SYNC_CONSUMER_STREAM) are NOT carried in the binary descriptor. They are
exchanged out of band via the C ABI. See docs/impl/c-ffi.md for the per-mode
handoff payload definitions and the ABI-side cross-check that the payload
provided at handoff time matches the sync_mode declared in the descriptor.
Note (non-normative):
sync_modeis a declaration of a buffer's synchronisation properties; the synchronisation handle is a transport detail, not a buffer property. Putting the discriminant in the binary descriptor lets a static inspector (hurray-inspect) surface the synchronisation contract for any buffer without reaching into the C ABI layer.
Zero-Copy Invariants
The buffer protocol is designed to preserve zero-copy access across language and runtime boundaries. The following invariants MUST hold at all times:
- No implicit copies. Neither the producer nor the consumer MAY copy the
buffer contents as part of the handoff. Copies are only permitted when
explicitly requested by the interchange protocol (e.g., layout transcoding
in response to a
TENSOR_REQUESTthat specifies a different layout tag). - No in-place mutation after handoff. Once a producer has handed off a buffer to a consumer, the producer MUST NOT modify the buffer's contents. Mutating a buffer that is held by a consumer is a protocol error.
- Quantization parameter buffers are immutable. Scale and zero-point buffers MUST NOT be modified after the tensor descriptor is emitted. They are part of the tensor's logical value and MUST be treated as read-only by all consumers.
- Pointer stability. The base address of a buffer MUST NOT change for the duration of the consumer's hold. Buffer defragmentation or garbage collection that moves the buffer is the producer's responsibility to prevent while any consumer holds a reference.
Relationship to Other Sections
metadata.mddefines the binary encoding of buffer handles in the buffer table. Thealignmentanddevice_tagfields are declared there; this file defines the normative rules they must satisfy.data-model.mddefines empty tensors (zero-size dimensions). This file specifies the corresponding empty-buffer rules (null pointer allowed, alignment waived, no dereference).quantization.mddefines quantization-parameter buffers. This file's device-colocation and immutability rules apply to those buffers.interchange.mddefines in-process, IPC, and cross-machine transport. This file's alignment and ownership rules are prerequisites for all three transport modes.docs/impl/c-ffi.mddefines the C ABI for buffer handle handoff, including the release callback signature.
Open Questions
All open questions in this section are resolved. See
docs/adr/ADR-009-release-callback-not-normative-refcount.md (OQ-1) and
docs/adr/ADR-020-memory-class-field.md (memory class field, device colocation
extension, and supported_memory_classes interchange advertisement).
Metadata — Hurray Format Specification
Status: Draft
Scope
This section defines the binary encoding of the tensor descriptor: the self-describing header that precedes every tensor data buffer in the Hurray format. The tensor descriptor encodes all information required to interpret a tensor: its element type, rank, shape, memory layout, buffer table, and optional quantization and shard annotations.
Note (non-normative): The tensor descriptor is designed to be self-delimiting: a receiver can determine its total byte length from the first 10 bytes (the
descriptor_lengthfield occupies bytes 6–9) and MAY skip the descriptor entirely without parsing any layout-specific fields. Skipping past the whole descriptor is what only requires the first 10 bytes; locating or skipping a particular section within the descriptor (e.g. the buffer table or the quantization section) requires reading through the preceding fields. This property is essential for streaming readers.
Normative Requirements
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Overall Descriptor Structure
A tensor descriptor consists of a fixed header, followed by variable-length core fields, followed by layout-specific fields, followed by a buffer table, followed by zero or more optional sections selected by the flags field.
[Fixed Header] 20 bytes
[shape] 8 × rank bytes
[byte_offset] 8 bytes
[Layout-specific] variable (layout-dependent)
[Buffer Table] variable
[Quantization] variable, present if HAS_QUANTIZATION flag is set
[Shard] variable, present if HAS_SHARD flag is set
[Statistics] 72 bytes, present if HAS_STATISTICS flag is set
[Extension Type] 20 bytes, present if HAS_EXTENSION_TYPE flag is set
[Composite Member] 16 bytes, present if HAS_COMPOSITE_MEMBER flag is set
All multi-byte fields MUST be encoded in little-endian byte order (least significant byte at the lowest address).
Fixed Header
The fixed header occupies the first 20 bytes of every tensor descriptor.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | magic | bytes[4] | Magic bytes: 0x48 0x52 0x52 0x59 (ASCII "HRRY"). |
| 4 | version_major | uint8 | Major format version. Current value: 0x01. |
| 5 | version_minor | uint8 | Minor format version. Current value: 0x00. |
| 6 | descriptor_length | uint32 | Total length of the tensor descriptor in bytes, including the fixed header. A reader MUST use this field to advance past the descriptor without parsing all fields. |
| 10 | flags | uint32 | Descriptor flags bitmask (see Flags). |
| 14 | type_tag | uint8 | Element type tag (see element-types.md). |
| 15 | layout_tag | uint8 | Memory layout tag (see memory-layout.md). See data-model.md § Scalar Tensors for layout restrictions that apply when rank = 0. |
| 16 | rank | uint32 | Number of dimensions. A rank of 0 denotes a scalar tensor. |
A reader MUST reject a descriptor whose magic field is not 0x48 0x52 0x52 0x59.
A reader MUST reject a descriptor whose version_major exceeds the reader's
supported major version.
A reader MUST reject a descriptor whose descriptor_length is less than 20 (the
minimum valid descriptor size).
A reader MUST NOT read beyond descriptor_length bytes when parsing a descriptor.
Flags
| Bit | Name | Meaning |
|---|---|---|
| 0 | HAS_QUANTIZATION | A quantization descriptor section is present (see Quantization Section). |
| 1 | HAS_SHARD | A shard descriptor section is present (see Shard Section). |
| 2 | HAS_EXTENSION_TYPE | An extension type descriptor section is present. MUST be set if and only if type_tag is in the range 0xF0–0xFE (see Extension Type Section). |
| 3 | HAS_STATISTICS | A statistics section is present (see Statistics Section). |
| 4 | HAS_COMPOSITE_MEMBER | A composite member section is present (see Composite Member Section). Set on the members of an overlay composite; see layouts/composite.md. |
| 5–31 | (reserved) | MUST be 0. A reader MUST reject a descriptor with any reserved flag bit set. |
Core Variable Fields
Immediately following the fixed header:
shape
uint64[rank] — the size of each dimension, in ascending dimension order (dimension
0 first). Each value MUST be greater than or equal to 0. A dimension size of 0
indicates an empty tensor.
The value 0xFFFFFFFFFFFFFFFF (UINT64_MAX) is the dynamic dimension sentinel:
it indicates that the dimension's size is not statically known. A reader MUST NOT
compute buffer sizes, strides, or element counts for a dimension carrying this
sentinel without first resolving it to a concrete value.
For a scalar tensor (rank = 0), this field is absent (zero bytes).
byte_offset
uint64 — the byte offset from the start of buffer 0 in the buffer table to the
element at logical index [0, 0, ..., 0]. MUST be less than or equal to the byte
size of buffer 0.
For sub-byte types (bool, int4, uint4, int2, uint2), byte_offset MUST
point to a byte boundary. The first element begins at bit 0 of that byte.
Layout-Specific Fields
Immediately following byte_offset, the layout-specific fields for the layout
identified by layout_tag are encoded. If the layout has no additional fields, this
section is absent.
Row-Major (0x01) and Column-Major (0x02)
No additional fields. Strides are implicit and computed as defined in
memory-layout.md.
Strided (0x03)
| Field | Type | Description |
|---|---|---|
strides | int64[rank] | Stride of each dimension in logical elements. |
Tiled / Blocked (0x04)
| Field | Type | Description |
|---|---|---|
tile_shape | uint64[rank] | Tile size along each dimension. Every value MUST be greater than 0. |
outer_layout | uint8 | Layout tag for tile-grid ordering. MUST be 0x01, 0x02, or 0x03. |
inner_layout | uint8 | Layout tag for element ordering within each tile. MUST be 0x01, 0x02, 0x03, or 0x04 (recursive tiling). |
_reserved | uint8[2] | MUST be 0x00. |
If outer_layout is 0x03 (strided):
| Field | Type | Description |
|---|---|---|
outer_strides | int64[rank] | Outer strides in units of tiles (not elements). |
If inner_layout is 0x03 (strided):
| Field | Type | Description |
|---|---|---|
inner_strides | int64[rank] | Inner strides in logical elements within a tile. |
If inner_layout is 0x04 (recursive tiling), the tiled layout-specific fields are
encoded recursively at this point, beginning with tile_shape.
A reader MUST enforce a maximum recursion depth for nested tiled descriptors. The RECOMMENDED limit is 8 levels. A reader MUST reject a descriptor that exceeds its configured recursion limit.
Morton (Z-Order Curve) (0x05)
| Field | Type | Description |
|---|---|---|
morton_bits | uint32[rank] | Number of bits used per dimension in the Morton encoding. Each value MUST be greater than 0. |
COO (0x06)
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor. |
is_sorted | uint8 | 0x01 if the non-zeros are sorted in lexicographic index order (dimension 0 major); 0x00 otherwise. |
_reserved | uint8[7] | MUST be 0x00. |
See layouts/coo.md for buffer table composition, storage order, and validity
constraints.
CSR (0x07)
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix. |
_reserved | uint8[8] | MUST be 0x00. |
See layouts/csr.md for buffer table composition, storage invariants, and
rank-2 restriction.
CSC (0x08)
| Field | Type | Description |
|---|---|---|
nnz | uint64 | Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix. |
_reserved | uint8[8] | MUST be 0x00. |
See layouts/csc.md for buffer table composition, storage invariants, and
rank-2 restriction.
Hilbert Curve (0x40)
| Field | Type | Description |
|---|---|---|
hilbert_order | uint32 | Order of the Hilbert curve. MUST be greater than 0. |
hilbert_rank | uint32 | Number of curve dimensions. MUST equal rank. MUST be greater than or equal to 2. |
Composite / Virtual (0x0B)
The composite head is a virtual (data-less) descriptor. Its layout-specific fields encode the composition rule. All multi-byte fields are little-endian.
| Field | Type | Description |
|---|---|---|
composition_rule | uint8 | 0x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF reserved; 0xF0–0xFE private; 0xFF invalid. |
combine_op | uint8 | Overlay only: 0x01 replace, 0x02 add. MUST be 0x00 for partition and group. |
_reserved | uint8[2] | MUST be 0x00. |
member_count | uint32 | Number of member tensors that immediately follow the head. MUST be a definite count. The value 0xFFFFFFFF (open composite) is RESERVED; a strict reader MUST reject it. |
A composite head MUST have buffer_count = 0x00 and byte_offset = 0x0000000000000000.
See layouts/composite.md for members, binding, composition semantics, and validation.
Extension Layouts (0xF0–0xFE)
| Field | Type | Description |
|---|---|---|
extension_layout_id | uint64 | Implementation-defined layout identifier. |
extension_data_length | uint32 | Byte length of the opaque metadata that follows. |
extension_data | bytes[extension_data_length] | Opaque layout-specific metadata. |
A reader that does not recognise extension_layout_id MUST reject the descriptor,
unless operating in permissive mode.
Buffer Table
Immediately following the layout-specific fields, the buffer table is encoded.
| Field | Type | Description |
|---|---|---|
buffer_count | uint8 | Number of buffer handles. MUST be at least 1 for every layout except the composite head (layout_tag = 0x0B), for which it MUST be exactly 0x00 (a composite head owns no data; see layouts/composite.md). For dense layout tags (0x01–0x05, 0x40) without quantization, MUST be exactly 0x01. For quantized dense tensors, MUST equal 0x01 plus the number of quantization-parameter buffers required by the active scheme (see quantization.md § Buffer Table Placement Rules). |
The maximum value is 255, imposed by the uint8 wire type. This limit applies
to the sum of data and quantization-parameter buffers. Implementations that
require more than 255 buffers MUST use multiple tensor descriptors.
Followed by buffer_count buffer handles, each encoded as 16 bytes:
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | byte_size | uint64 | Size of the buffer in bytes. |
| 8 | alignment | uint32 | Minimum buffer alignment in bytes. MUST be a power of two and MUST be at least 64. |
| 12 | device_tag | uint8 | Device where this buffer resides (see buffer-protocol.md and Device Tags in interchange.md). |
| 13 | _reserved | uint8[3] | MUST be 0x00. |
The _reserved bytes MUST be 0x00. A conforming reader in strict mode MUST
reject a descriptor containing any buffer handle whose _reserved bytes are not
all 0x00.
Note (non-normative): For sparse layout tags (COO
0x06, CSR0x07, CSC0x08, CSF0x09),buffer_countexceeds 1 — each entry holds a distinct component array (values, indices, pointers). For CSF the count is rank-dependent,2·rank + 1(onevaluesbuffer plus apos/crdpair per level). For quantized dense tensors, quantization-parameter buffers (scales, zero-points) extend the buffer table beyond the layout baseline. The layout-defined minimum is always0x01for dense layouts; quantization schemes append their parameter buffers on top.
Quantization Section
Present if and only if the HAS_QUANTIZATION flag (bit 0) is set.
| Field | Type | Description |
|---|---|---|
quantization_length | uint32 | Byte length of the quantization descriptor that follows. |
quantization_descriptor | bytes[quantization_length] | Binary encoding of the quantization descriptor, as defined in quantization.md. |
A reader that encounters HAS_QUANTIZATION but does not support quantized types MUST
reject the descriptor unless operating in permissive mode.
Shard Section
Present if and only if the HAS_SHARD flag (bit 1) is set.
| Field | Type | Description |
|---|---|---|
parent_shape | uint64[rank] | Shape of the logical parent tensor. MUST have the same rank as the tensor. |
shard_offset | uint64[rank] | Starting index of this shard within the parent tensor along each dimension. |
The constraint shard_offset[k] + shape[k] <= parent_shape[k] MUST hold for every
dimension k. A reader MUST reject a shard descriptor that violates this constraint.
Statistics Section
Present if and only if the HAS_STATISTICS flag (bit 3) is set.
The statistics section is a fixed-size 72-byte block. All statistics are advisory: they reflect the tensor data at write time. A reader MUST NOT rely on any statistic for correctness; statistics MAY be used only as optimization hints (algorithm selection, memory pre-allocation, routing decisions).
Note (non-normative): A streaming writer that has not processed the entire tensor buffer before emitting the descriptor (e.g., a pipeline stage forwarding data on the fly) MUST omit the statistics section (
HAS_STATISTICSnot set) rather than emitting invalid statistics. A writer that knows only a subset of statistics (e.g.,nnzis known from a sparse format butvalue_meanwas not computed) MUST mark the unknown fields as not valid incomputed_mask.
computed_mask
The computed_mask field (first 4 bytes of the section) is a bitmask indicating
which statistics fields contain valid data. A reader MUST check the relevant bit
before using any field. Fields whose bit is not set MUST be treated as unknown,
regardless of their encoded value.
| Bit | Name | Covers |
|---|---|---|
| 0 | NNZ_VALID | nnz |
| 1 | SPARSITY_VALID | sparsity_ratio |
| 2 | VALUE_RANGE_VALID | value_min, value_max, value_abs_max |
| 3 | VALUE_STATS_VALID | value_mean, value_stddev |
| 4 | NM_SPARSITY_VALID | nm_n, nm_m |
| 5 | NAN_INF_VALID | has_nan, has_inf |
| 6–31 | (reserved) | MUST be 0. |
A conforming reader MUST reject a descriptor whose computed_mask has any
reserved bit set (any bit greater than or equal to 6, given the six defined
statistics fields above).
Field Encoding
The 72-byte statistics block is encoded as follows. All multi-byte fields are little-endian.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | computed_mask | uint32 | Validity bitmask (see above). |
| 4 | _reserved | uint32 | MUST be 0x00000000. |
| 8 | nnz | uint64 | Number of non-zero elements. Valid when NNZ_VALID is set. |
| 16 | sparsity_ratio | float64 | Fraction of zero elements: (total_elements - nnz) / total_elements. Range [0.0, 1.0]. Valid when SPARSITY_VALID is set. |
| 24 | value_min | float64 | Minimum element value, dequantized to float64. Valid when VALUE_RANGE_VALID is set. |
| 32 | value_max | float64 | Maximum element value, dequantized to float64. Valid when VALUE_RANGE_VALID is set. |
| 40 | value_abs_max | float64 | Maximum absolute element value (max(abs(value_min), abs(value_max))). Key input for symmetric quantization range calibration. Valid when VALUE_RANGE_VALID is set. |
| 48 | value_mean | float64 | Arithmetic mean of all elements, dequantized to float64. Valid when VALUE_STATS_VALID is set. |
| 56 | value_stddev | float64 | Population standard deviation of all elements, dequantized to float64. MUST be greater than or equal to 0.0. Valid when VALUE_STATS_VALID is set. |
| 64 | nm_n | uint8 | N in the N:M structured sparsity pattern (e.g., 2 for 2:4 sparsity). 0x00 if not applicable. Valid when NM_SPARSITY_VALID is set. |
| 65 | nm_m | uint8 | M in the N:M structured sparsity pattern (e.g., 4 for 2:4 sparsity). MUST satisfy nm_n <= nm_m. 0x00 if not applicable. Valid when NM_SPARSITY_VALID is set. |
| 66 | has_nan | uint8 | 0x01 if at least one NaN element is present; 0x00 if no NaN was found. Valid when NAN_INF_VALID is set. |
| 67 | has_inf | uint8 | 0x01 if at least one positive or negative infinity is present; 0x00 otherwise. Valid when NAN_INF_VALID is set. |
| 68 | _reserved2 | uint8[4] | MUST be 0x00. |
Note (non-normative):
value_min,value_max,value_abs_max,value_mean, andvalue_stddevare always expressed infloat64regardless of the tensor's element type. For quantized tensors, these values reflect dequantized (real-valued) statistics, not the raw quantized storage values. Forbooltypes, the statistics are defined over the integer domain{0, 1}.
Note (non-normative): N:M structured sparsity is particularly relevant for NVIDIA Ampere/Ada/Hopper Tensor Cores, which provide hardware-accelerated 2:4 sparsity (2 non-zeros in every group of 4 consecutive elements). Declaring the N:M pattern in the descriptor lets a receiver select the sparse kernel path without scanning the buffer.
Extension Type Section
Present if and only if the HAS_EXTENSION_TYPE flag (bit 2) is set. This flag MUST
be set whenever type_tag is in the range 0xF0–0xFE, and MUST NOT be set
otherwise.
Per ADR-001, extension type tags MUST carry an inline descriptor providing at minimum the bit width and packing parameters required to compute buffer sizes.
The extension type descriptor is 20 bytes:
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | bit_width | uint32 | Bit width of one element. MUST be greater than 0. |
| 4 | packing_factor | uint8 | Number of elements packed per byte. MUST be exactly 1 when bit_width is greater than or equal to 8. When bit_width is less than 8, bit_width MUST be one of 1, 2, or 4, and packing_factor MUST equal 8 / bit_width (that is, 8, 4, or 2 respectively). All other sub-byte widths — including but not limited to 3, 5, 6, and 7 bits — MUST NOT be encoded as extension types. A reader MUST reject an extension type descriptor that violates these constraints. |
| 5 | is_float | uint8 | 0x01 if floating-point, 0x00 if integer. |
| 6 | is_signed | uint8 | 0x01 if signed integer. MUST be 0x00 for float types. |
| 7 | sign_bits | uint8 | Number of sign bits (for float types). MUST be 0 or 1. MUST be 0x00 for integer types. |
| 8 | exponent_bits | uint8 | Number of exponent bits (for float types). MUST be 0x00 for integer types. |
| 9 | mantissa_bits | uint8 | Number of mantissa bits (for float types). MUST be 0x00 for integer types. |
| 10 | _reserved | uint8[2] | MUST be 0x00. |
| 12 | exponent_bias | uint32 | Exponent bias (for float types). MUST be 0x00000000 for integer types. |
| 16 | has_nan | uint8 | 0x01 if NaN is representable (float types only). |
| 17 | has_inf | uint8 | 0x01 if infinity is representable (float types only). |
| 18 | _reserved2 | uint8[2] | MUST be 0x00. |
Note (non-normative): The sign of a float extension type is carried by
sign_bits;is_signedapplies to integer types only. Requiringis_signedto be0x00for float types does not mean float extension types are unsigned — it means a reader has exactly one field to consult. A signed float setsis_float = 0x01andsign_bits = 0x01; an unsigned float, such as a private analogue of the exponent-onlyfloat8_e8m0(0x42), setsis_float = 0x01andsign_bits = 0x00.
Sub-byte element widths that are not a power of two (notably 6-bit) are reserved to the built-in type tag space. Implementors requiring an interchange-portable non-power-of-two sub-byte type MUST request a built-in tag assignment through the specification governance process rather than encoding the type in the private extension range. The extension descriptor's whole-byte and power-of-two sub-byte width restriction ensures that buffer-size computation remains a single integer formula (ceil(N / packing_factor) for sub-byte, N * (bit_width / 8) for whole-byte) without rational arithmetic.
Note (non-normative): The 6-bit
float6_e2m3(0x44) andfloat6_e3m2(0x45) types are built-in (Tier 2) and use a dedicated 4-elements-per-3-bytes packing defined inelement-types.md. Their packing rule is not expressible as8 / bit_widthand is therefore not delegable to the generic extension descriptor. The extension descriptor is designed for private, implementation-defined types whose layout fits the simple "elements per byte" model; richer packings remain the prerogative of the standardized type system.
A reader MUST use bit_width and packing_factor to compute buffer sizes for
tensors with extension type tags, even if it does not interpret the numeric semantics
of the type.
Composite Member Section
Present if and only if the HAS_COMPOSITE_MEMBER flag (bit 4) is set. It appears after
the Extension Type section. The section is a fixed-size 16-byte block. All multi-byte
fields are little-endian.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | member_role | uint8 | 0x00 correction, 0x01 base. 0x02–0xFF RESERVED (a future tombstone kind; see layouts/composite.md § Deferred). |
| 1 | _reserved | uint8[15] | MUST be 0x00. |
A reader MUST reject a Composite Member section whose member_role is not 0x00 or
0x01, or whose _reserved bytes are not all 0x00.
This section is carried by the members of an overlay composite (composition_rule = 0x02). Partition and group members MUST NOT set HAS_COMPOSITE_MEMBER. The full
semantics — base-vs-correction roles, precedence, and the combine operation — are defined
in layouts/composite.md.
Note (non-normative): v1.0 uses
member_roleonly. The 15 reserved bytes hold room for a futuremember_versionfield (versioned overlay), added additively without reallocating the section. Seelayouts/composite.md§ Deferred.
Version Compatibility
A reader MUST reject a descriptor whose version_major exceeds the reader's
supported major version.
A reader encountering a version_minor greater than its supported minor version
SHOULD accept the descriptor but MUST NOT interpret fields beyond what its supported
minor version defines. The descriptor_length field allows the reader to skip the
descriptor entirely if desired.
Note (non-normative): Minor version increments add optional fields or new flag bits. A reader built against version 1.0 will correctly skip a 1.1 descriptor by consuming exactly
descriptor_lengthbytes, because all reserved flag bits were required to be 0 in 1.0. Major version increments signal backward-incompatible changes; a reader MUST NOT attempt to parse a descriptor with an unsupported major version.
Worked Example
A rank-2 float32 tensor with shape [3, 4] in row-major layout, one buffer of
192 bytes aligned to 64 bytes on CPU, no optional sections:
Offset Value (hex) Field
------ ---------------------------- -----
0 48 52 52 59 magic = "HRRY"
4 01 version_major = 1
5 00 version_minor = 0
6 3D 00 00 00 descriptor_length = 61
10 00 00 00 00 flags = 0x00000000 (no optional sections)
14 03 type_tag = 0x03 (float32)
15 01 layout_tag = 0x01 (row-major)
16 02 00 00 00 rank = 2
20 03 00 00 00 00 00 00 00 shape[0] = 3
28 04 00 00 00 00 00 00 00 shape[1] = 4
36 00 00 00 00 00 00 00 00 byte_offset = 0
(no layout-specific fields for row-major)
44 01 buffer_count = 1
45 C0 00 00 00 00 00 00 00 buffer[0].byte_size = 192
53 40 00 00 00 buffer[0].alignment = 64
57 00 buffer[0].device_tag = 0x00 (CPU)
58 00 00 00 buffer[0]._reserved
Total: 61 bytes. Fixed header (20) + shape (16) + byte_offset (8) + buffer table (1 + 16) = 61.
Interaction with Other Sections
- Element Types (
element-types.md): definestype_tagvalues and the bit-width, packing, and alignment properties used during buffer size computation. - Memory Layout (
memory-layout.md): defineslayout_tagvalues and the layout-specific fields encoded in this descriptor. - Quantization (
quantization.md): defines the binary format of thequantization_descriptorpayload in the quantization section. - Buffer Protocol (
buffer-protocol.md): defines buffer ownership, device memory semantics, and release callback conventions referenced by the buffer table entries. - Interchange (
interchange.md): the tensor descriptor defined here is transmitted verbatim inTENSOR_DESCRIPTORandTENSOR_PUTmessage payloads, followed by transport-specific fields (total_data_bytes,shard_index,total_shards).
Open Questions
[OQ-1]:
Should the descriptor include a CRC-32 checksum field?Resolved: No checksum in the descriptor. Integrity is delegated to the transport/storage layer (TCP, TLS, ECC, ZFS). Adding 4 bytes and a full-pass CRC on every descriptor would penalise in-process and IPC interchange where corruption is not a realistic threat. If file-level integrity is needed, it belongs in the file format footer (seefile-format.mdOQ-3).
[OQ-2]:
The binary encoding of the quantization descriptor is deferred toResolved: The encoding is fully defined inquantization.md.quantization.md: a fixed 4-byte header (scheme_tag,scheme_version,flags) followed by a per-scheme payload. Complete byte-level layouts are specified inquantization/per-tensor-affine.md,quantization/per-channel-affine.md,quantization/per-block-affine.md,quantization/nf4.md, andquantization/mxfp.md. Thequantization_lengthprefix inmetadata.mdallows readers to skip unrecognised schemes safely.
Interchange — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines how Hurray tensors are exchanged between producers and consumers. Three interchange modes are in scope:
| Mode | Description |
|---|---|
| In-process | Shared memory within a single address space; zero-copy by pointer passing |
| IPC | Cross-process on a single machine; shared memory segments or Unix domain sockets |
| Network transport | Cross-machine over a network interface; client-server streaming protocol |
This section focuses primarily on the network transport mode, as it is the most complex and the most relevant to distributed inference pipelines. In-process and IPC modes are covered in In-Process and IPC.
A Hurray stream MAY contain zero or more tensors. Back-to-back concatenation of
self-delimiting descriptor+data pairs is the canonical multi-tensor encoding: a
reader processes one tensor at a time, advancing to the next descriptor after the
current tensor's data is consumed. No container header, index, or tensor names are
defined at this level (see docs/adr/ADR-010-multi-tensor-collections-deferred.md).
Note (non-normative): The design of the Hurray network transport protocol draws inspiration from Apache Arrow Flight, but differs in several key respects: it is tensor-focused (not columnar), it supports layout and device negotiation, it defines on-the-fly transcoding, and its data plane is designed for large buffer transfers where gRPC framing overhead is impractical.
In-Process and IPC
In-Process
Within a single address space, tensor interchange is accomplished by passing a tensor
descriptor (see metadata.md) and a buffer handle (see buffer-protocol.md) by
value. No serialization is required. The buffer handle carries a release callback (see buffer-protocol.md § Buffer Ownership and Lifetime and ADR-009); the receiver MUST retain the handle for the duration of its use and call the release callback exactly once when done.
IPC
For cross-process interchange on a single machine, two mechanisms are supported:
- Shared memory: the producer maps a shared memory segment and places the data
buffer there. The tensor descriptor is transmitted over any IPC channel (pipe, Unix
domain socket, etc.). The
byte_offsetfield in the descriptor identifies the buffer's position within the shared segment. - Unix domain socket streaming: the producer serializes the tensor using the network transport framing defined below, transmitted over a Unix domain socket. This is slower than shared memory but requires no shared-memory setup.
In both cases, buffer alignment requirements from buffer-protocol.md apply.
Network Transport Protocol
Overview
The Hurray network transport protocol is a client-server streaming protocol for transferring tensor data over a reliable, ordered byte stream (e.g., TCP). It consists of:
- A control plane: session establishment, capability negotiation, tensor requests, and error signalling. Uses message framing defined in this section.
- A data plane: tensor descriptor and buffer transmission. Uses the same message framing, but data frame payloads MAY be transferred over a separate high-throughput channel (e.g., RDMA) by prior agreement during session establishment.
The protocol is half-duplex per stream: within a single stream, the client sends a request and the server responds with a sequence of messages. Multiple streams MAY be multiplexed over a single connection.
Note (non-normative): The
stream_idfield is an opaque per-stream identifier. Implementations MAY use one TCP connection per stream, or MAY multiplex multiple streams over a single connection by demultiplexing onstream_id. Messages are self-framing (fixed-size header +payload_lengthbytes), so a receiver can always advance to the next message regardless ofstream_id. Normative multiplexing rules are not defined in this version of the spec.
Message Framing
Every message on the wire consists of a message header followed by a payload. All multi-byte fields are little-endian.
Message header (12 bytes):
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | message_type | uint32 | Message type tag (see Message Types) |
| 4 | stream_id | uint32 | Stream identifier. 0x00000000 is reserved. |
| 8 | payload_length | uint32 | Length of the payload in bytes, not including the header. |
The payload follows immediately after the header. A receiver MUST read exactly
payload_length bytes as the payload. A receiver MUST reject messages whose
payload_length exceeds the receiver's configured maximum message size, and MUST
send an ERROR message in response.
Message Types
| Tag | Name | Direction | Description |
|---|---|---|---|
0x00000001 | CLIENT_HELLO | Client → Server | Session initiation and capability advertisement |
0x00000002 | SERVER_HELLO | Server → Client | Session acceptance and capability advertisement |
0x00000003 | TENSOR_REQUEST | Client → Server | Request a tensor by key, with layout and device preferences |
0x00000004 | TENSOR_DESCRIPTOR | Server → Client | Tensor descriptor, precedes data frames |
0x00000005 | TENSOR_DATA | Server → Client | Tensor data frame (partial or complete) |
0x00000006 | TENSOR_DATA_END | Server → Client | Signals that all data frames for a tensor have been sent |
0x00000007 | TENSOR_PUT | Client → Server | Push a tensor to the server (descriptor + data) |
0x00000008 | TENSOR_PUT_ACK | Server → Client | Acknowledges receipt of a pushed tensor |
0x00000009 | ERROR | Either | Error response; terminates the stream |
0x0000000A | PING | Either | Keepalive request |
0x0000000B | PONG | Either | Keepalive response |
0x0000000C | RDMA_REGISTER | Either | RDMA memory region registration: shares rkey and remote address |
0x0000000D | RDMA_READY | Either | Acknowledges RDMA_REGISTER; signals readiness for the RDMA transfer |
0x000000F0–0x000000FE | (private extension) | — | Reserved for implementation-private extensions |
0x000000FF | (invalid) | — | Reserved; MUST NOT be used |
A receiver that encounters an unrecognised message_type MUST send an ERROR
message and close the stream.
Message type values in the range 0x000000F0–0x000000FE are reserved for
implementation-private extensions. Implementations MAY use these values for
private extension message types; messages using private extension types MUST
NOT be sent to a peer that has not agreed to the extension out of band. The
value 0x000000FF is reserved and MUST NOT be used.
Session Establishment
CLIENT_HELLO Payload
| Field | Type | Description |
|---|---|---|
protocol_version | uint32 | Protocol version. Current version: 0x00000001. |
max_message_size | uint32 | Maximum payload size (bytes) the client will accept. |
capability_flags | uint64 | Bitmask of client capabilities (see Capability Flags). |
supported_layouts | layout_entry[layout_count] | Layout entries the client can consume, in preference order (most preferred first). Preceded by a uint16 count field. Each entry is encoded as defined in Layout Entry Encoding. |
supported_devices | uint8[device_count] | Device tags the client can accept (see Device Tags). Preceded by a uint16 count field. |
supported_memory_classes | uint8[class_count] | Memory class values the client can accept (see buffer-protocol.md § Memory Class). Preceded by a uint16 count field. If absent or empty, the client is assumed to support STANDARD (0x00) only. |
SERVER_HELLO Payload
| Field | Type | Description |
|---|---|---|
protocol_version | uint32 | Protocol version the server will use. MUST be <= the client's version. |
max_message_size | uint32 | Maximum payload size (bytes) the server will accept. |
capability_flags | uint64 | Bitmask of server capabilities. |
supported_layouts | layout_entry[layout_count] | Layout entries the server can produce (possibly via transcoding). Preceded by a uint16 count field. Each entry is encoded as defined in Layout Entry Encoding. |
supported_devices | uint8[device_count] | Device tags the server can target. Preceded by a uint16 count field. |
supported_memory_classes | uint8[class_count] | Memory class values the server can produce. Preceded by a uint16 count field. If absent or empty, the server is assumed to support STANDARD (0x00) only. |
Note (non-normative):
supported_memory_classesadvertises which memory access classes a peer can produce or consume across all devices. A server that supports CUDAUNIFIED(cudaMallocManaged) and CPUSTANDARDwould list[0x00, 0x02]. A client that cannot handleUNIFIEDbuffers lists only[0x00]; the server MUST NOT send aUNIFIEDbuffer to such a client. Memory class negotiation follows the same fallback logic as device negotiation: the server SHOULD prefer the client's advertised classes and MUST report the actualmemory_classin theTENSOR_DESCRIPTORbuffer handle.
A client MUST send a CLIENT_HELLO as the first message on every new connection.
The server MUST respond with a SERVER_HELLO before any other message. If the server
cannot satisfy the minimum requirements (e.g., protocol version mismatch), it MUST
respond with an ERROR message instead and close the connection.
Capability Flags
| Bit | Name | Meaning |
|---|---|---|
| 0 | TRANSCODING | Sender can transcode tensors to a requested layout on the fly |
| 1 | PARALLEL_STREAMS | Sender supports multi-stream parallel shard transfer |
| 2 | RDMA_DATA_PLANE | Sender supports RDMA for the data plane |
| 3 | RDMA_GPUDIRECT | Sender supports GPUDirect-style writes into a receiver-registered destination memory region. MUST imply RDMA_DATA_PLANE (bit 2). |
| 4–63 | (reserved) | MUST be 0 |
Device Tags
The canonical definition of device tags lives in
buffer-protocol.md§ Device Tags. The table below is reproduced for transport-protocol convenience and MUST stay consistent withbuffer-protocol.md.
| Tag | Device |
|---|---|
0x00 | CPU host memory |
0x01 | CUDA device memory |
0x02 | ROCm device memory |
0x03 | Metal device memory (Apple Silicon unified memory) |
0x04 | Vulkan device memory |
0x05 | WebGPU device memory |
0x06 | Qualcomm Hexagon (HVX/HMX) memory |
0x07 | Intel Level Zero / oneAPI device memory |
0x08 | OpenCL device memory |
0x09–0xEF | Reserved for future specification versions |
0xF0–0xFE | Implementation-private device types |
0xFF | Reserved (invalid) |
Layout Entry Encoding
Layout lists appear in CLIENT_HELLO, SERVER_HELLO, and TENSOR_REQUEST. Each list
is preceded by a uint16 count, followed by that many layout entries. A layout
entry is variable-length and encoded as follows:
layout_tag(uint8): the layout tag as defined inmemory-layout.md.- If
layout_tagis in the extension range (0xF0–0xFE):ext_metadata_length(uint16): byte length of the opaque metadata that follows. MAY be0.ext_metadata(byte sequence): opaque hardware- or implementation-specific metadata ofext_metadata_lengthbytes.
- If
layout_tagis not in the extension range, the entry consists of the singlelayout_tagbyte only. No length or metadata fields follow.
A reader MUST skip any extension entry whose ext_metadata it does not understand,
using ext_metadata_length to advance past it. A reader MUST NOT reject a layout list
solely because it contains unrecognised extension entries.
Note (non-normative): The primary use case for extension layout entries in negotiation is hardware-specific panel/pack formats for BLAS kernels. A client advertising such a format would include an extension tag with opaque metadata encoding its hardware parameters (e.g. panel width, register block dimensions, SIMD width, cache line size). The server either recognises the profile and transcodes accordingly, or skips the entry and falls back to the next preference. The packed buffer travels to the client and is handed directly to the BLAS kernel — it is never forwarded or reinterpreted by generic tensor code.
Layout Negotiation
Request
When sending a TENSOR_REQUEST, the client specifies its layout preferences. The
server MUST honor the negotiation rules below.
TENSOR_REQUEST Payload
| Field | Type | Description |
|---|---|---|
tensor_key | utf8 string | Identifier of the requested tensor. Encoded as a uint32 byte length followed by UTF-8 bytes. |
preferred_layouts | layout_entry[layout_count] | Layout entries in preference order (most preferred first). Preceded by a uint16 count field. 0 count means no preference. Each entry is encoded as defined in Layout Entry Encoding. |
preferred_device | uint8 | Preferred device tag for the response buffer. |
min_alignment | uint32 | Minimum buffer alignment (bytes) the client requires. MUST be a power of two. |
request_flags | uint32 | Bitmask of request flags (see below). |
Request flags:
| Bit | Name | Meaning |
|---|---|---|
| 0 | ALLOW_TRANSCODE | Client permits the server to transcode to a preferred layout |
| 1 | PARALLEL_OK | Client supports receiving the tensor as multiple parallel shards |
| 2–31 | (reserved) | MUST be 0 |
Server Layout Selection
Upon receiving a TENSOR_REQUEST, the server MUST select a layout for the response
according to the following rules, in order:
- If the client supplied a non-empty
preferred_layoutslist, the server MUST iterate through the list in order and select the first layout tag that satisfies one of: a. The tensor is already stored in that layout (no transcoding needed), or b.ALLOW_TRANSCODEis set and the server hasTRANSCODINGcapability for that layout tag. - If no preferred layout can be satisfied (list exhausted or empty), the server MUST serve the tensor in its native stored layout.
The server MUST indicate the chosen layout in the TENSOR_DESCRIPTOR message. The
client MUST be prepared to receive any layout that appeared in the server's
supported_layouts advertisement, even if it was not among the client's preferences.
On-the-Fly Transcoding
When the server transcodes a tensor to satisfy a layout preference, the transcoding MUST be element-preserving: the logical tensor (same rank, same shape, same element values at every index) MUST be identical before and after transcoding. Only the memory layout differs.
A server MUST NOT transcode if doing so would require materialising a buffer larger than the server's configured transcoding memory limit. In that case the server MUST fall through to the next preferred layout or the native layout.
Note (non-normative): Transcoding is inherently a memory and compute cost on the server side. Servers SHOULD document their transcoding capabilities and limits. Clients SHOULD list lightweight layouts (e.g. row-major
0x01) later in their preference list as a fallback, rather than requiring the server to transcode into a complex layout first.
Device Negotiation
The preferred_device field of TENSOR_REQUEST drives device selection for the
response buffer. Device selection is distinct from layout negotiation: device
selection has no ordered preference list on the wire (a single tag is supplied),
and the rules below define the server's response when the preferred device cannot
be served.
Server Device Selection
Upon receiving a TENSOR_REQUEST, the server MUST select a placement device for
the response buffer according to the following rules, in order:
- If
preferred_deviceappears in the server'ssupported_deviceslist (fromSERVER_HELLO) and the server has resources to satisfy it at request time, the server MUST place the response buffer onpreferred_device. - If
preferred_device == 0x00(CPU) and the server cannot satisfy CPU, the server MUST sendERRORwitherror_code = DEVICE_UNAVAILABLEand close the stream. CPU is the universal fallback; if a server cannot satisfy CPU, no silent fallback is meaningful. - If the preferred device is a non-CPU device the server cannot serve, the
server MUST send
ERRORwitherror_code = DEVICE_UNAVAILABLEand close the stream. The server MUST NOT silently fall back to a different device. - Exception to rule 3: if
preferred_devicewas advertised by the server inSERVER_HELLObut is transiently unavailable (e.g., out of device memory), the server MAY fall back to CPU (0x00) if and only if the client also advertised CPU in itsCLIENT_HELLO supported_deviceslist. This is the only permitted silent fallback. - The server MUST report the actual placement device in the
device_tagfield of every buffer handle inTENSOR_DESCRIPTOR. Perbuffer-protocol.md§ Device Colocation, all buffers of a single tensor MUST share the samedevice_tag. - A client that receives a
TENSOR_DESCRIPTORwhose bufferdevice_tagdiffers from itspreferred_deviceMUST be prepared to either accept the placement (if the tag is in itssupported_deviceslist) or close the stream withERROR.
Note (non-normative): The "no silent fallback" design is motivated by the performance characteristics of inference workloads: a model running on CUDA that silently lands on CPU produces correct output but performance collapses catastrophically (often by orders of magnitude). The narrow CPU-fallback exception in rule 4 is provided only as a graceful degradation path for clients that explicitly opt in by advertising CPU in their
supported_deviceslist.
Note (non-normative): Clients that want graceful CPU fallback should advertise CPU (
0x00) inCLIENT_HELLO supported_devices. TheDEVICE_UNAVAILABLEerror gives the client enough information to retry with a differentpreferred_deviceif it has alternatives available.
Streaming: Tensor Descriptor and Data Frames
Ordering Invariant
For every tensor transferred, the server MUST send messages in the following order:
TENSOR_DESCRIPTOR
TENSOR_DATA (zero or more frames)
TENSOR_DATA_END
A tensor whose total_data_bytes is greater than 0 MUST send one or more TENSOR_DATA
frames. A tensor whose total_data_bytes is 0 — an empty tensor (ADR-007) or a composite
head, which owns no data — MUST send zero TENSOR_DATA frames: the server sends
TENSOR_DESCRIPTOR immediately followed by TENSOR_DATA_END. A receiver MUST accept this
frame-free sequence when total_data_bytes = 0.
A receiver MUST NOT attempt to interpret data frames before receiving the
TENSOR_DESCRIPTOR. This invariant holds for each shard in a parallel transfer.
TENSOR_DESCRIPTOR Payload
The payload is a serialized tensor descriptor as defined in metadata.md, followed by
the following transport-specific fields:
| Field | Type | Description |
|---|---|---|
total_data_bytes | uint64 | Total number of bytes that will follow in TENSOR_DATA frames for this tensor (or shard). |
shard_index | uint32 | Index of this shard in a parallel transfer. 0 for non-parallel transfers. |
total_shards | uint32 | Total number of shards in a parallel transfer. 1 for non-parallel transfers. |
The sync_mode field of each buffer handle inside TENSOR_DESCRIPTOR declares
the producer's synchronisation guarantee for that buffer. Consumers MUST observe
the sync_mode value and apply the matching rule per buffer-protocol.md
§ Stream and Event Synchronisation before accessing the buffer's bytes.
TENSOR_DATA Payload
| Field | Type | Description |
|---|---|---|
byte_offset_in_buffer | uint64 | Byte offset within the tensor's data buffer where this frame's bytes begin. |
data | byte sequence | Raw tensor data bytes. Length is payload_length minus 8 (the byte_offset_in_buffer field). |
Data frames for a single tensor MUST be sent in ascending byte_offset_in_buffer
order with no gaps and no overlaps. The sum of all data frame lengths MUST equal the
total_data_bytes declared in the TENSOR_DESCRIPTOR.
TENSOR_DATA_END Payload
The TENSOR_DATA_END message has an empty payload (payload_length = 0). It signals
that all data frames for the current tensor (or shard) have been sent on this stream.
Composite Tensor Streaming
A composite tensor (head + members; see layouts/composite.md) is streamed as a
forward-adjacency sequence with no back-reference. The head is an ordinary tensor
descriptor with layout_tag = 0x0B and buffer_count = 0, so it is sent as a
TENSOR_DESCRIPTOR message immediately followed by a TENSOR_DATA_END (an empty data
plane — the head owns no buffers). The head's member_count = N binds the next N
self-delimiting tensors on the stream, in order, as its members:
TENSOR_DESCRIPTOR (head, layout_tag = 0x0B, member_count = N)
TENSOR_DATA_END (head has no data buffers)
TENSOR_DESCRIPTOR (member 0)
TENSOR_DATA (zero or more frames)
TENSOR_DATA_END
...
TENSOR_DESCRIPTOR (member N-1)
TENSOR_DATA (zero or more frames)
TENSOR_DATA_END (composite "close": the Nth member's TENSOR_DATA_END)
The head's TENSOR_DESCRIPTOR MUST precede its members' descriptors, which MUST precede
their data — a forward promise, never a back-reference. The composite closes on the
Nth member's TENSOR_DATA_END; at that point a receiver runs the close-time validation
for the composition rule (layouts/composite.md § Validation).
A member that is itself a composite head (a nested composite) recursively binds its own
members before the enclosing composite's member count advances; the sequence is parsed
pre-order, subject to the depth limit in layouts/composite.md § Binding.
A receiver MUST reject a composite whose per-member checks fail (send ERROR and close
the stream). A torn composite — the stream ends before all N members' TENSOR_DATA_END
messages arrive — is incomplete: a strict receiver MUST reject it; a permissive receiver
MAY expose the arrived members as independent shard tensors but MUST NOT present the
composite as complete (layouts/composite.md § Validation).
Note (non-normative): The head carries no data, so its
total_data_bytesin theTENSOR_DESCRIPTORtransport fields is0and noTENSOR_DATAframe is sent for it. Binding is purely positional (member count + stream order); no tensor names or member identifiers are introduced.
Note (non-normative): A composite head therefore sends zero
TENSOR_DATAframes —TENSOR_DESCRIPTORimmediately followed byTENSOR_DATA_END— as does any tensor whosetotal_data_bytesis0. This is governed by the general rule in § Ordering Invariant ("zero or more frames"); the head is not a special case.
Parallel Transfers
Overview
A tensor MAY be transferred as a set of shards delivered simultaneously over
multiple independent streams (e.g., multiple TCP connections or RDMA queue pairs).
Each shard is a rectangular sub-region of the logical tensor, described by the shard
descriptor mechanism defined in memory-layout.md (fields parent_shape and
shard_offset).
The client indicates willingness to receive parallel shards by setting the
PARALLEL_OK flag in the TENSOR_REQUEST. The server indicates parallel transfer
support via the PARALLEL_STREAMS capability flag in SERVER_HELLO.
Parallel Transfer Flow
- The client sends a single
TENSOR_REQUESTwithPARALLEL_OKset. - The server selects a sharding strategy (number of shards, shard boundaries) and
responds on N separate streams, one per shard. Each stream carries an
independent
TENSOR_DESCRIPTOR→TENSOR_DATA→TENSOR_DATA_ENDsequence. - Each
TENSOR_DESCRIPTORpayload MUST include:- A tensor descriptor with a shard descriptor (
parent_shape,shard_offset, and the shard's ownshape) embedded as defined inmetadata.md. shard_indexandtotal_shardsfields in the transport header.
- A tensor descriptor with a shard descriptor (
- The client reassembles the logical tensor by placing each shard at the position
indicated by its
shard_offsetwithin a buffer sized forparent_shape.
Shard Consistency
All shards of a single tensor MUST share the same:
parent_shape- element type
- layout tag (outer layout; the shard's inner layout MAY vary in future extensions)
The union of all shard bounding boxes (defined by shard_offset and shape) MUST
exactly cover the full parent_shape without overlap, satisfying the coverage and
non-overlap constraints from memory-layout.md.
A client MUST validate shard consistency upon receiving all TENSOR_DESCRIPTOR
messages. A client MUST reject a parallel transfer where any shard descriptor
violates these constraints.
Note (non-normative): Sharding along the batch dimension (dimension 0) is the simplest and most common case — each shard is a contiguous slice of rows. The protocol does not restrict sharding to any particular dimension or sharding scheme.
RDMA Data Plane
Overview
When both client and server advertise the RDMA_DATA_PLANE capability flag during
session establishment, the data plane for individual tensor transfers MAY use RDMA
rather than TCP TENSOR_DATA frames. The control plane (TCP) is still used for all
session management, descriptor exchange, and completion signalling.
Note (non-normative): The RDMA data plane bypasses TCP framing for the tensor buffer itself. For GB-scale tensors this eliminates CPU copies and TCP serialisation overhead, achieving near-line-rate GPU-to-GPU transfer via GPUDirect RDMA. The underlying RDMA operations are performed by an RDMA library such as UCX (
ucp_put_nb/ucp_get_nb) or libibverbs (ibv_post_send). The Hurray protocol specifies the handshake messages; it does not mandate a specific RDMA library.
Handshake Flow (Server → Client Tensor Transfer)
When both parties have advertised RDMA_DATA_PLANE, the server MAY substitute the
TENSOR_DATA / TENSOR_DATA_END sequence with an RDMA handshake. The client MUST
be prepared to handle either path.
Client Server
| |
|--- TENSOR_REQUEST ----------->|
|<-- TENSOR_DESCRIPTOR ---------|
|<-- RDMA_REGISTER -------------| (server registers source buffer, shares rkey + addr)
|--- RDMA_REGISTER ------------>| (client registers destination buffer; only if RDMA_GPUDIRECT)
|--- RDMA_READY --------------->| (client is ready; RDMA operation may begin)
| |
| [RDMA Write executes outside the TCP control plane]
| |
|<-- TENSOR_DATA_END -----------| (server signals that buffer is ready on client side)
The client's RDMA_REGISTER step is OPTIONAL and only occurs when both peers
advertised the RDMA_GPUDIRECT capability flag in their respective HELLO
messages. See GPUDirect Destination Registration.
The server MUST send TENSOR_DESCRIPTOR before RDMA_REGISTER. The RDMA_REGISTER
message MUST be sent on the same stream as the corresponding TENSOR_DESCRIPTOR.
RDMA_REGISTER Payload
The RDMA_REGISTER message is sent in one or two roles per transfer:
- The source-buffer owner sends
RDMA_REGISTERto declare its source memory region. This is the server forTENSOR_REQUESTtransfers and the client forTENSOR_PUTtransfers. - When both peers advertised the
RDMA_GPUDIRECTcapability flag, the destination-buffer owner MAY also sendRDMA_REGISTERto declare a pre-pinned destination memory region. This is the client forTENSOR_REQUESTtransfers and the server forTENSOR_PUTtransfers. See GPUDirect Destination Registration.
The payload table is identical in both roles; the role is determined by the sender and the position of the message in the handshake sequence.
| Field | Type | Description |
|---|---|---|
remote_addr | uint64 | Virtual address of the registered memory region on the sender's side. Little-endian. |
length | uint64 | Size of the memory region in bytes. MUST equal total_data_bytes from the preceding TENSOR_DESCRIPTOR. Little-endian. |
rkey | byte sequence | Opaque RDMA memory key. Encoded as a uint32 byte-length prefix followed by that many bytes. The format is RDMA-library-specific (e.g., a UCX packed rkey blob, or a 4-byte IB verbs rkey). |
A receiver that cannot complete RDMA setup (e.g., memory pinning failed, no RDMA
hardware available on the required path) MUST respond with an ERROR message instead
of RDMA_READY. The source-buffer owner MUST then fall back to transmitting the
tensor via TENSOR_DATA frames on the control plane.
GPUDirect Destination Registration
When both peers advertised the RDMA_GPUDIRECT capability flag in their
respective HELLO messages, the destination-buffer owner (the receiver) MAY
pre-register a destination memory region and share its rkey with the sender
via a second RDMA_REGISTER message. This enables the sender to write directly
into the receiver's device memory (e.g., GPU memory), eliminating an
intermediate host-to-device copy.
- The receiver MUST send its
RDMA_REGISTER(destination) after the sender'sRDMA_REGISTER(source) and beforeRDMA_READY, if it intends to use GPUDirect. - The
lengthfield of the destinationRDMA_REGISTERMUST equaltotal_data_bytesfrom the precedingTENSOR_DESCRIPTOR. - The receiver MUST register a buffer whose device type is consistent with the
device_tagreported inTENSOR_DESCRIPTOR. If the server fell back to a different device per Device Negotiation, the receiver MUST NOT send a destinationRDMA_REGISTERfor a buffer on the originally requested device. The receiver MUST either accept the fallback and proceed without GPUDirect, or close the stream withERROR. - If the receiver does NOT send
RDMA_REGISTER, the sender MUST use its own buffer management strategy for the RDMA destination — typically landing data in the receiver's host memory via the RDMA library's managed staging area. - A sender that receives a destination
RDMA_REGISTERbut cannot perform a GPUDirect write (e.g., topology mismatch) MUST sendERRORinstead ofRDMA_READY.
Note (non-normative): GPUDirect requires the NIC and GPU to share a PCIe root complex (or to be NVLink-connected). The protocol cannot validate this topology; the receiver is responsible for ensuring it before advertising
RDMA_GPUDIRECTand before sending a destinationRDMA_REGISTER.
RDMA_READY Payload
The RDMA_READY message has an empty payload (payload_length = 0). It signals
that the receiver has processed RDMA_REGISTER and is ready for the RDMA transfer
to begin.
Completion
After the RDMA operation completes on the sender's side, the sender MUST send
TENSOR_DATA_END over the control plane. The receiver MUST NOT read from the
transferred buffer before receiving TENSOR_DATA_END.
For cross-machine transport, the sender MUST set
sync_mode = SYNC_PRODUCER_SYNCED (0x00) in every buffer handle within the
transmitted TENSOR_DESCRIPTOR. TENSOR_DATA_END is the cross-machine
equivalent of SYNC_PRODUCER_SYNCED per buffer-protocol.md § Stream and
Event Synchronisation: the receiver MUST NOT read from the transferred buffer
before receiving TENSOR_DATA_END, and upon receipt of TENSOR_DATA_END the
buffer is considered producer-synced. The sync_mode values SYNC_EVENT
(0x01) and SYNC_CONSUMER_STREAM (0x02) are FORBIDDEN on cross-machine
transports because device event and stream handles are not valid in a
different driver context on a different host. A receiver MUST reject a
cross-machine TENSOR_DESCRIPTOR whose buffer handle declares any other mode.
Note (non-normative): RDMA Write completion on the sender does not imply that the receiver has observed the data without an explicit memory fence or signal.
TENSOR_DATA_ENDserves as that authoritative "buffer is ready" signal. The sender MUST ensure the RDMA operation has completed (e.g., via a completion queue event) before sendingTENSOR_DATA_END.
TENSOR_PUT with RDMA
For TENSOR_PUT (client → server), roles are reversed relative to the
server-to-client flow: the client is the source-buffer owner and the server is
the receiver.
Client Server
| |
|--- TENSOR_PUT --------------->| (tensor key + descriptor)
|--- RDMA_REGISTER ------------>| (client registers source buffer, shares rkey + addr)
|<-- RDMA_REGISTER -------------| (server registers destination buffer; only if RDMA_GPUDIRECT)
|<-- RDMA_READY ----------------| (server is ready; RDMA operation may begin)
| |
| [RDMA Write executes outside the TCP control plane]
| |
|--- TENSOR_DATA_END ---------->| (client signals data placed)
|<-- TENSOR_PUT_ACK ------------| (server confirmed receipt)
The server's RDMA_REGISTER (destination) step is OPTIONAL and only occurs
when both peers advertised the RDMA_GPUDIRECT capability flag in their
respective HELLO messages.
In TENSOR_PUT, the client unilaterally declares the destination device_tag
in the descriptor; the server has no negotiation opportunity (there is no
analogue of preferred_device for PUT transfers). If the server cannot accept
the tensor on the declared device, it MUST send ERROR with
error_code = DEVICE_UNAVAILABLE before any RDMA exchange and close the stream.
The rules in GPUDirect Destination Registration
apply symmetrically with roles inverted: the server is the destination-buffer
owner, and its destination RDMA_REGISTER declares a region whose device type
MUST match the device_tag declared by the client in the descriptor.
If the client (source-buffer owner) cannot perform a GPUDirect write after
receiving the server's destination RDMA_REGISTER (e.g., topology mismatch),
the client MUST send ERROR and close the stream before TENSOR_DATA_END.
TENSOR_PUT
Overview
TENSOR_PUT allows a client to push a tensor to the server. The protocol defines
the wire exchange only — the server-side storage model (lifetime, eviction, collision
handling) is an implementation concern and is intentionally out of scope.
TENSOR_PUT Flow
Client Server
|--- TENSOR_PUT --------------->| (tensor key + descriptor)
|--- TENSOR_DATA (one or more)->| (data frames)
|--- TENSOR_DATA_END ---------->|
|<-- TENSOR_PUT_ACK ------------| (server confirmed receipt)
With the RDMA data plane, TENSOR_DATA frames are replaced by the RDMA handshake
described in TENSOR_PUT with RDMA.
TENSOR_PUT Payload
| Field | Type | Description |
|---|---|---|
tensor_key | utf8 string | Identifier for the pushed tensor. Encoded as a uint32 byte length followed by UTF-8 bytes. |
descriptor | byte sequence | Serialized tensor descriptor as defined in metadata.md. Encoded as a uint32 byte length followed by the descriptor bytes. |
total_data_bytes | uint64 | Total number of bytes that will follow in TENSOR_DATA frames. |
TENSOR_PUT_ACK Payload
The TENSOR_PUT_ACK message has an empty payload (payload_length = 0). It
signals that the server has received and accepted the complete tensor buffer. It
does not imply anything about how the server stores, forwards, or uses the tensor.
If the server cannot accept the tensor for any reason (e.g., policy rejection, resource
exhaustion), it MUST send an ERROR message instead of TENSOR_PUT_ACK and close
the stream.
Note (non-normative): Server-side storage semantics — including tensor lifetime, eviction policy, and key collision handling — are deliberately unspecified. A server implementation is free to store the tensor for the session, forward it immediately to another peer, or discard it after use. The protocol's role is delivery confirmation, not storage coordination.
Error Handling
ERROR Payload
| Field | Type | Description |
|---|---|---|
error_code | uint32 | Error code (see below). |
message | utf8 string | Human-readable error description. uint32 length prefix followed by UTF-8 bytes. |
| Code | Name | Meaning |
|---|---|---|
0x00000001 | PROTOCOL_VERSION_MISMATCH | Incompatible protocol versions |
0x00000002 | UNKNOWN_TENSOR | Requested tensor key not found |
0x00000003 | LAYOUT_UNAVAILABLE | No acceptable layout could be served |
0x00000004 | TRANSCODE_LIMIT_EXCEEDED | Transcoding refused: buffer too large |
0x00000005 | DEVICE_UNAVAILABLE | Requested device not available |
0x00000006 | INVALID_MESSAGE | Malformed message received |
0x00000007 | MESSAGE_TOO_LARGE | payload_length exceeds receiver's limit |
0x00000008 | SHARD_MISMATCH | Parallel shard descriptors are inconsistent |
0x000000F0–0x000000FE | (implementation-defined) |
Upon sending or receiving an ERROR message, both parties MUST close the affected
stream. The connection MAY remain open for other streams.
Open Questions Summary
[OQ-1]:
Endianness negotiation: should the transport protocol allow a client to request big-endian wire encoding?Resolved: No endianness negotiation. The wire format is always little-endian; big-endian clients MUST byte-swap on receipt. Rationale: all AI/ML inference hardware targeted by Hurray is little-endian; negotiation would add protocol complexity with zero practical benefit. Consistent with Arrow, DLPack, and SafeTensors.
[OQ-2]:
RDMA data plane handshake.Resolved:RDMA_REGISTER(0x0000000C) andRDMA_READY(0x0000000D) message types are now defined. The party owning the source buffer registers its memory region and sendsRDMA_REGISTER(rkey + remote address + length) over the control plane; the peer responds withRDMA_READY; the RDMA operation executes out-of-band;TENSOR_DATA_ENDis sent over the control plane as the authoritative completion signal. See RDMA Data Plane.
[OQ-3]:
Multiplexing scheme.Resolved: Thestream_idfield is defined as an opaque per-stream identifier. Implementations MAY multiplex multiple streams over a single TCP connection usingstream_idfor demultiplexing, but the protocol does not mandate a normative multiplexing scheme. Each stream MAY equivalently run on its own connection. Normative multiplexing rules are deferred to a future revision once the format is stable.
[OQ-4]:
Resolved: Server-side storage model is explicitly out of scope.TENSOR_PUTsemantics.TENSOR_PUT_ACKmeans "received and accepted"; it carries no implication about persistence, lifetime, or collision handling. Those are implementation concerns. The server sendsERRORto reject a PUT for any reason. See TENSOR_PUT.
[OQ-5]:
Device negotiation and GPUDirect RDMA destination registration.Resolved: A normative Device Negotiation section defines server device selection rules. GPUDirect destination registration uses bidirectionalRDMA_REGISTERgated by the newRDMA_GPUDIRECTcapability flag (bit 3). Seedocs/adr/ADR-011-server-device-selection.md(server device selection) anddocs/adr/ADR-012-gpudirect-rdma.md(GPUDirect RDMA).
Interaction with Other Sections
- Memory Layout (
memory-layout.md): defines the layout tag space used in capability advertisement and layout negotiation. Shard descriptors (parent_shape,shard_offset) are the logical basis for parallel transfers. - Metadata (
metadata.md): defines the binary encoding of the tensor descriptor transmitted inTENSOR_DESCRIPTORmessages. - Buffer Protocol (
buffer-protocol.md): defines buffer alignment requirements that the client expresses viamin_alignmentinTENSOR_REQUEST, and device memory semantics relevant to thepreferred_devicefield. - Element Types (
element-types.md): defines the little-endian wire encoding of tensor element data, which is the data transmitted inTENSOR_DATAframes.
File Format — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines the Hurray file format: a random-access container for one or
more named tensors, intended for on-disk model storage, distribution, and mmap-based
zero-copy loading. It is the companion to the streaming IPC format defined in
interchange.md.
The two formats are complementary, not competing:
| Streaming format | File format | |
|---|---|---|
| Transport | Socket, pipe, RDMA, IPC | Seekable file or mmap-able region |
| Tensor count | Unbounded, unknown upfront | Fixed, enumerable via index |
| Tensor names | None | Required, unique UTF-8 |
| Random access | No | Yes — seek to any tensor by name |
| Writer constraint | Single-pass, no seek required | Single-pass, sequential write + footer |
| Reader start | Immediately (streaming) | After file is complete (index in footer) |
| Prior art | Arrow IPC stream, gRPC | SafeTensors, GGUF, Arrow IPC file |
Both formats share the same tensor descriptor encoding defined in metadata.md.
The file format adds a container layer; it does not redefine how individual tensors
are described.
See docs/adr/ADR-011-file-format-random-access-container.md for the design decisions.
File Layout
A Hurray file has the following structure, in order:
[ File header ] 64 bytes, fixed
[ Padding ] 0x00 bytes, to first_descriptor_offset
[ Tensor region ] repeated: descriptor → padding → data buffer(s) → padding
[ KV metadata section ] optional, located by trailer
[ Index section ] located by trailer
[ Trailer ] 40 bytes, fixed, at file_size - 40
All multi-byte fields MUST be encoded in little-endian byte order.
File Header
The file header occupies the first 64 bytes of the file.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | magic | uint8[8] | MUST be 0x48 0x52 0x52 0x59 0x46 0x49 0x4C 0x45 (ASCII HRRYFILE). |
| 8 | container_version_major | uint8 | Container format major version. Current: 0x01. |
| 9 | container_version_minor | uint8 | Container format minor version. Current: 0x00. |
| 10 | _reserved | uint8[2] | MUST be 0x00. |
| 12 | file_flags | uint32 | Bitmask of file-level flags. See § File Flags. |
| 16 | data_buffer_alignment | uint32 | Alignment (bytes) applied to all tensor data buffers within the file. MUST be a power of two and MUST be at least 4096. MUST NOT exceed 2097152 (2 MiB). |
| 20 | first_descriptor_offset | uint64 | Absolute byte offset of the first tensor descriptor. MUST be >= 64. Typically 64 (immediately after the header). |
| 28 | tensor_count_hint | uint64 | Number of tensors in the file, if known at write time. Writers that do not know this upfront MUST set this field to 0xFFFFFFFFFFFFFFFF. Readers MUST NOT rely on this field for correctness; use the index entry count instead. |
| 36 | _reserved_header | uint8[28] | MUST be 0x00. Reserved for future use. |
Total: 64 bytes.
A reader MUST reject a file whose magic does not equal HRRYFILE. A reader MUST
reject a file whose container_version_major exceeds the highest major version the
reader supports.
File Flags
| Bit | Name | Meaning |
|---|---|---|
| 0 | HAS_KV_METADATA | KV metadata section is present; kv_offset and kv_length in the trailer are non-zero. |
| 1 | SORTED_INDEX | Index entries are sorted by UTF-8 byte order of name, enabling binary search. A writer MUST NOT set this flag unless the index is actually sorted. |
| 2 | HAS_INDEX_CRC32C | The index_crc32c field in the trailer carries a valid CRC-32C of the index section bytes. When this flag is set, a reader MUST verify the CRC and MUST reject the file if it does not match. Writers SHOULD always set this flag and populate index_crc32c. When this flag is not set, the index_crc32c field MUST be 0x00000000 and readers MUST NOT perform checksum verification. |
| 3–31 | (reserved) | MUST be 0. A reader MUST reject a file with reserved flag bits set. |
Tensor Region
Following the file header (and any padding to first_descriptor_offset), tensors are
written sequentially. Each tensor occupies the following region:
[ Tensor descriptor ] as defined in metadata.md (begins with HRRY magic)
[ Padding ] 0x00 bytes to align next data buffer to data_buffer_alignment
[ Data buffer 0 ] tensor data, byte_size bytes
[ Padding ] 0x00 bytes to align next item to data_buffer_alignment
[ Data buffer 1 ] (if buffer_count > 1)
[ Padding ]
...
[ Padding ] 0x00 bytes to align next descriptor to 8 bytes
Descriptor Placement
Each tensor descriptor MUST begin at a byte offset that is a multiple of 8. The
first descriptor begins at first_descriptor_offset (which MUST be 8-byte aligned).
Subsequent descriptors begin at the next 8-byte-aligned offset after the previous
tensor's last data buffer (including padding).
Data Buffer Placement
The first data buffer of a tensor MUST begin at a byte offset that is a multiple of
data_buffer_alignment. If the tensor descriptor ends at a byte offset that is not
a multiple of data_buffer_alignment, the writer MUST insert 0x00 padding bytes
to reach the next data_buffer_alignment boundary.
If a tensor has multiple data buffers (e.g., dense data + quantization parameter
buffers), each buffer MUST begin at a data_buffer_alignment-aligned offset. Padding
bytes between buffers MUST be 0x00.
Empty Tensors
An empty tensor (any dimension size 0) has data buffer byte size 0. Its data
buffer region occupies 0 bytes. The writer MUST still insert sufficient padding
after the descriptor to satisfy alignment requirements for the next descriptor.
KV Metadata Section
The KV metadata section, when present (HAS_KV_METADATA flag set), is located by
the trailer's kv_offset and kv_length fields. It MUST be aligned to an 8-byte
boundary.
KV Section Encoding
| Field | Type | Description |
|---|---|---|
kv_count | uint32 | Number of key-value entries. |
kv_entries[kv_count] | (variable) | Sequentially encoded KV entries. |
Each KV entry is encoded as:
| Field | Type | Description |
|---|---|---|
key_length | uint16 | Length of the key in bytes. MUST be at least 1. |
key | uint8[key_length] | UTF-8 key bytes, no null terminator. |
value_tag | uint8 | Type tag for the value (see § KV Value Types). |
value | (variable) | Value payload, encoded per value_tag. |
Keys MUST be unique within the KV section (case-sensitive, byte-exact comparison). A reader MUST reject a file with duplicate KV keys. Keys MUST be valid UTF-8.
KV Value Types
| Tag | Type | Payload encoding |
|---|---|---|
0x01 | utf8 string | uint32 byte length, then UTF-8 bytes. |
0x02 | int64 | 8 bytes, little-endian. |
0x03 | uint64 | 8 bytes, little-endian. |
0x04 | float64 | 8 bytes, little-endian IEEE 754 binary64. |
0x05 | bool | 1 byte. 0x00 = false, 0x01 = true. All other values MUST be rejected. |
0x06 | byte sequence | uint32 byte length, then opaque bytes. |
0x07 | array | uint8 element type tag (MUST be 0x01–0x06), then uint32 element count, then element payloads concatenated. |
0x08–0xEF | (reserved) | MUST NOT be used. A reader MUST reject a file containing a reserved value tag. |
0xF0–0xFE | (extension) | Implementation-private. MUST NOT appear in files exchanged between independent implementations unless agreed out of band. |
0xFF | (invalid) | MUST NOT be used. |
Note (non-normative): The KV section is intended for model-level metadata: architecture name, quantization configuration identifier, tokenizer vocabulary size, etc. It is not a substitute for per-tensor metadata; per-tensor fields belong in the tensor descriptor.
Index Section
The index section is located by the trailer's index_offset and index_length
fields. It MUST be aligned to an 8-byte boundary.
Index Encoding
| Field | Type | Description |
|---|---|---|
index_entry_count | uint64 | Number of index entries. MUST equal the number of tensors in the file. |
index_entries[index_entry_count] | (variable) | Sequentially encoded index entries. |
Each index entry is encoded as:
| Field | Type | Description |
|---|---|---|
name_length | uint16 | Length of the tensor name in bytes. MUST be at least 1. |
name | uint8[name_length] | UTF-8 tensor name bytes, no null terminator. |
descriptor_offset | uint64 | Absolute byte offset of the tensor descriptor from the start of the file. |
descriptor_length | uint32 | Length of the tensor descriptor in bytes. MUST equal the descriptor's own internal descriptor_length field; a reader MUST reject a file where they disagree. |
data_offset | uint64 | Absolute byte offset of the first data buffer from the start of the file. When buffer_count is 0 (a composite head; see § Composite Tensors) the tensor has no data buffer, and this field MUST be descriptor_offset + descriptor_length — the offset the empty data region begins at. |
data_length | uint64 | Total byte length of all data buffers for this tensor (sum of all buffer byte_size values, plus inter-buffer padding within the tensor's data region). MUST be 0 when buffer_count is 0. |
flags | uint32 | Reserved for future use. MUST be 0x00000000. |
Index entry names MUST be unique within the index (case-sensitive, byte-exact).
Index entries are written in tensor write order by default. If the SORTED_INDEX
file flag is set, entries MUST be sorted by UTF-8 byte order of name (strict
byte comparison, no Unicode normalisation).
Note (non-normative): The
descriptor_lengthfield in the index duplicates the descriptor's own internal length to enable fast enumeration: a reader that wants to list all tensor names, shapes, and dtypes can parse the index without touching any descriptor byte, at O(index size) cost instead of O(file size).
Note (non-normative):
data_lengthcovers the full data region including inter-buffer padding. A reader that wants to mmap the entire data region of a tensor can usedata_offsetanddata_lengthwithout parsing the buffer table.
Composite Tensors
A composite tensor (head + members; see layouts/composite.md) is written as a head
descriptor followed by its member_count = N members, all as consecutive tensors in
the tensor region, in that order. The head is an ordinary tensor with layout_tag = 0x0B
and buffer_count = 0, so its data region occupies 0 bytes (its data_length in the
index is 0); each member is written as an ordinary tensor with its own descriptor and
data buffers. Every tensor — head and members alike — gets its own index entry.
No data_buffer_alignment padding after a head. § Data Buffer Placement's alignment
rule applies to a tensor's data buffer(s); a buffer_count = 0 head has none — unlike an
empty tensor (§ Empty Tensors), which still has one buffer of byte_size = 0 and is
therefore still aligned as a buffer. A writer MUST NOT insert data_buffer_alignment
padding after a composite head's descriptor. The next descriptor (the head's first
member) follows at the ordinary 8-byte descriptor-aligned offset (§ Descriptor
Placement). The head's index entry records data_offset = descriptor_offset + descriptor_length and data_length = 0 (§ Index Section).
Membership is recovered from the head's member_count plus descriptor-offset order:
the members are the next N tensors, ordered by ascending descriptor_offset, following
the head. This recovery rule uses descriptor_offset order (write order in the tensor
region), which is preserved regardless of whether the SORTED_INDEX file flag is set.
Setting SORTED_INDEX reorders only the index entries (by tensor name, for binary
search); it does not move tensors within the tensor region, so descriptor_offset order
still recovers head→member adjacency.
The composite closes at the Nth member. A reader MUST run the close-time validation for
the composition rule (layouts/composite.md § Validation) once all N members are located.
A file in which fewer than N members follow a head (a torn composite) is invalid: a strict
reader MUST reject it; a permissive reader MAY treat the arrived members as independent
shard tensors but MUST NOT present the composite as complete.
Nested composites are permitted: a member MAY itself be a head, whose own members are the
immediately following tensors (pre-order), subject to the depth limit in
layouts/composite.md § Binding.
Note (non-normative): Because membership is positional, composite members are not required to carry distinguishing names beyond the file format's per-tensor uniqueness rule. A reader that only wants the merged logical view resolves it from the head plus its N following members; a reader indexing by name still sees each member as an addressable tensor.
Trailer
The trailer occupies the last 40 bytes of the file (bytes file_size - 40 through
file_size - 1).
| Offset from trailer start | Field | Type | Description |
|---|---|---|---|
| 0 | index_offset | uint64 | Absolute byte offset of the index section from the start of the file. |
| 8 | index_length | uint64 | Byte length of the index section. |
| 16 | kv_offset | uint64 | Absolute byte offset of the KV metadata section. 0 if no KV section. |
| 24 | kv_length | uint32 | Byte length of the KV metadata section. 0 if no KV section. |
| 28 | index_crc32c | uint32 | CRC-32C of the index section bytes (the index_length bytes starting at index_offset). Valid only when the HAS_INDEX_CRC32C file flag (bit 2) is set. MUST be 0x00000000 when the flag is not set. |
| 32 | _reserved | uint8[4] | MUST be 0x00. Reserved for future trailer fields. |
| 36 | trailer_magic | uint8[4] | MUST be 0x48 0x52 0x52 0x59 (ASCII HRRY). |
Total: 40 bytes.
Note (non-normative):
kv_lengthisuint32(4-byte), capping the KV metadata section at approximately 4 GiB. This is intentional: KV metadata holds model-level annotations (architecture, tokenizer config, quantization settings) and is not expected to exceed this limit in practice. Tensor data, which can be arbitrarily large, is addressed byuint64offsets in the index.
A reader locates the trailer by seeking to file_size - 40. It MUST verify
trailer_magic before trusting any other trailer field.
A reader MUST check the HAS_INDEX_CRC32C file flag before interpreting index_crc32c. If the flag is set, the reader MUST verify the CRC-32C against the index section bytes and MUST reject the file on mismatch. If the flag is not set, index_crc32c MUST be 0x00000000; a reader that finds a non-zero value with the flag unset MUST reject the file.
A reader MUST reject a file whose index_offset + index_length extends into the
trailer (i.e., index_offset + index_length > file_size - 40).
Reader Protocol
Random-Access (Seek-Capable) Reader
- Read bytes 0–7. MUST equal
HRRYFILE. - Read bytes 8–63 (remainder of file header). Check
container_version_major. - Seek to
file_size - 40. Read trailer. Verifytrailer_magic. IfHAS_INDEX_CRC32Cis set infile_flags, recordindex_crc32cfor verification after step 4. - Seek to
index_offset. Readindex_lengthbytes. Parse index. - Optionally seek to
kv_offset. Read KV metadata. - For each requested tensor: seek to
descriptor_offset, parse descriptor; seek todata_offset, mmap or readdata_lengthbytes.
Sequential Reader (No Seek)
A reader that cannot seek MAY consume the file sequentially. Such a reader MUST track the running byte offset from the start of the file at every step and use offset arithmetic — never inspection of data buffer content — to determine section boundaries.
- Read and verify the 64-byte file header. Set the running offset to
64. - Read padding bytes until the running offset equals
first_descriptor_offset. - For each tensor in the tensor region:
a. Read the tensor descriptor's first 10 bytes to obtain
descriptor_length(bytes 6–9), then read the remainingdescriptor_length - 10bytes of the descriptor. Advance the running offset bydescriptor_length. b. Read padding bytes until the running offset is a multiple ofdata_buffer_alignment(the value declared in the file header). c. For each entry in the descriptor's buffer table, readbyte_sizebytes of data and then read padding bytes until the running offset is a multiple ofdata_buffer_alignment. Advance the running offset by the buffer size and the padding. d. Read padding bytes until the running offset is a multiple of8(alignment for the next descriptor). e. The reader has now reached the start of either the next tensor descriptor, the KV metadata section, or the index section. - To determine whether step (3) should be repeated, the reader cannot inspect
data content (a tensor's data buffer may legitimately contain the byte
sequence
0x48 0x52 0x52 0x59, and the KV / index sections do not begin withHRRY). The end of the tensor region MUST be detected by one of:- Trailer probe (preferred): a sequential reader that has buffered the
entire file in memory after the fact MAY locate the trailer at
file_size - 40and useindex_offset(andkv_offsetif present) to determine the tensor region's end offset. - Tensor count hint: if the file header's
tensor_count_hintis not0xFFFFFFFFFFFFFFFF, the reader MAY iterate exactly that many tensors. The reader MUST then verify that the running offset corresponds to a declared section boundary. - EOF: the reader MAY consume bytes until end-of-file and treat the terminal 40 bytes as the trailer; it MUST NOT treat that 40 bytes as a tensor region.
- Trailer probe (preferred): a sequential reader that has buffered the
entire file in memory after the fact MAY locate the trailer at
A sequential reader cannot perform random access and cannot look up tensors by name without reading the entire file in order. This mode is OPTIONAL to implement and is significantly more constrained than the random-access reader defined above; conforming implementations SHOULD prefer the random-access path.
Writer Protocol
A conforming streaming writer produces a valid Hurray file in a single forward pass:
- Write the file header. Set
tensor_count_hintto0xFFFFFFFFFFFFFFFFif the count is not known. - For each tensor, in any order:
a. Record the current byte offset as
descriptor_offset. b. Write the tensor descriptor. c. If the tensor declares at least one data buffer, insert0x00padding to the nextdata_buffer_alignmentboundary. A tensor withbuffer_count = 0(a composite head; see § Composite Tensors) has no data buffer to align, and the writer MUST NOT insert this padding. An empty tensor (§ Empty Tensors) declares one buffer ofbyte_size = 0and is padded here like any other. d. Record the current byte offset asdata_offset. e. Write each data buffer, inserting0x00padding between buffers to place each one at adata_buffer_alignmentboundary. Nodata_buffer_alignmentpadding follows the last buffer. f. Insert0x00padding to the next 8-byte boundary, so the next descriptor begins on its own boundary (§ Descriptor Placement). g. Recorddescriptor_lengthanddata_lengthfor the index.data_lengthspansdata_offsetthrough the last byte of the last data buffer and excludes step f's padding; it is0whenbuffer_count = 0. - Write the KV metadata section (if any). Record its offset and length.
- Write the index section. Record its offset and length.
- Compute CRC-32C over the index section bytes. Set
HAS_INDEX_CRC32Cinfile_flags. Write the 40-byte trailer withindex_crc32cpopulated.
The writer MUST NOT seek backward at any point. All offset information is tracked
in memory as a list of (name, descriptor_offset, descriptor_length, data_offset, data_length) tuples, which is the only in-memory state required beyond the tensors
themselves.
Alignment and Padding Summary
| Item | Required alignment | Padding fill |
|---|---|---|
| File header | n/a (byte 0) | — |
| First tensor descriptor | first_descriptor_offset (≥ 8-byte aligned) | 0x00 |
| Subsequent tensor descriptors | 8-byte | 0x00 |
| Data buffers (all) | data_buffer_alignment (≥ 4096) | 0x00 |
| KV metadata section | 8-byte | 0x00 |
| Index section | 8-byte | 0x00 |
| Trailer | last 40 bytes of file | — |
Relationship to Other Sections
metadata.mddefines the tensor descriptor encoding used verbatim within tensor regions. Thedescriptor_lengthfield in the index caches the value of the descriptor's own internal length field.interchange.mddefines the streaming IPC format. The two formats are complementary: file magicHRRYFILEdistinguishes the file format from a stream that begins with anHRRYdescriptor.buffer-protocol.mddefines alignment rules. The file format'sdata_buffer_alignment(minimum 4096 bytes) enables zero-copy mmap of tensor data. The same device-tag and ownership rules apply to mmapped buffers.versioning.mddefines descriptor versioning. Container versioning (container_version_major/container_version_minor) is independent of descriptor versioning.
Open Questions
[OQ-1]:
Should single-tensor files be required to use a specific tensor name?Resolved: No required name. Tensor names are always meaningful and left to the producer. For the array database use case (Core Property 10), mandating a generic name like"data"would erase the semantic identity of the tensor. Readers that need a single-tensor API SHOULD use the sole entry in the footer index without reference to its name.
[OQ-2]:
Should a futureResolved: Strict UTF-8 byte order is sufficient. Tensor names in practice are ASCII identifiers; NFC normalisation adds implementation complexity with no practical benefit for the target use case. A new flag can be defined if a future use case genuinely requires Unicode-aware sorting.SORTED_INDEX_NFCflag be defined for NFC-normalised or case-folded comparisons?
[OQ-3]:
Should the trailer carry a CRC-32C of the footer index for integrity verification?Resolved: Added. Theindex_crc32cfield (uint32, offset 28 in the trailer) carries the CRC-32C of the index section bytes. Writers SHOULD always populate it; a value of0x00000000signals "not computed" and readers MAY accept without verification. The trailer grows from 32 to 40 bytes accordingly. Relatedmetadata.mdOQ-1 (descriptor checksum) was resolved as "no" — file-at-rest integrity is a stronger argument than in-flight descriptor integrity.
Versioning — Hurray Format Specification
Status: Draft
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
Scope
This section defines the versioning model for the Hurray format and the compatibility policy that conforming readers and writers MUST follow.
The Hurray format carries three independent version axes:
- Descriptor version — the format version of the tensor descriptor itself.
- Container version — the format version of the file-level container.
- Quantization scheme version — the version of an individual quantization scheme's wire encoding.
The three axes evolve independently: a single major version of the descriptor may coexist with multiple major versions of the container, and quantization schemes may version their parameter encodings without affecting either of the other two axes.
Note (non-normative): Decoupling these axes avoids forcing a global version bump every time a single component evolves. A new scheme tag, for example, only requires a descriptor minor version increment — it does not require the container or any other scheme to change versions.
Version Axes
Descriptor Version (version_major / version_minor)
The descriptor version describes the wire encoding of the tensor descriptor as
defined in metadata.md.
- Location: tensor descriptor fixed header, byte
4(version_major,uint8) and byte5(version_minor,uint8). Seemetadata.md§ Fixed Header. - Current values:
version_major = 0x01,version_minor = 0x00(i.e., descriptor format version1.0).
A change to the descriptor encoding falls into one of three classes (see § Change Classification).
A version_minor increment is REQUIRED when any of the following changes are
made to the descriptor encoding:
- A new optional flag bit is defined in the descriptor
flagsfield. - A new optional descriptor section is defined (gated by a new flag bit).
- A new optional trailing field is appended to an existing section without changing the offset of any field defined in the previous minor version.
- A new
type_tag,layout_tag, ordevice_tagvalue is allocated within the existing tag space. - A new quantization scheme tag is allocated within the existing scheme tag space.
A version_major increment is REQUIRED when any of the following changes are
made:
- An existing field's offset, type, or semantics changes.
- An existing flag bit's meaning changes.
- An existing tag value's meaning changes.
- A previously OPTIONAL field becomes REQUIRED, or vice versa.
- A previously valid encoding is forbidden.
- The minimum descriptor length grows.
Reader behaviour for the descriptor version is defined in
§ Compatibility Matrix. The normative reader rules are
established in metadata.md § Fixed Header and § Version Compatibility; this
section does not duplicate them, it only classifies the changes that justify
each rule.
Note (non-normative): During the pre-
1.0draft period the descriptor, container, and quantization-scheme version values remain fixed at their initial1.0values; all features added while the specification is in draft accumulate into that initial1.0. The minor- and major-increment rules in this section govern only changes made AFTER the first stable (non-draft) release is published. Adding a new layout, scheme, or tag during the draft period therefore does not bumpversion_minor. This does not weaken the post-release increment rules, which apply in full once1.0is published.
Container Version (container_version_major / container_version_minor)
The container version describes the wire encoding of the Hurray file format as
defined in file-format.md.
- Location: file header, byte
8(container_version_major,uint8) and byte9(container_version_minor,uint8). Seefile-format.md§ File Header. - Current values:
container_version_major = 0x01,container_version_minor = 0x00(i.e., container format version1.0).
The container version is independent of the descriptor version. A file at
container version 1.0 MAY contain tensor descriptors at descriptor version
1.0, 1.1, or any later compatible descriptor version. A reader MUST NOT
infer the descriptor version from the container version, or vice versa.
A container_version_minor increment is REQUIRED when any of the following
changes are made to the container encoding:
- A new optional file-level flag bit is defined in
file_flags. - A new optional KV value tag is allocated within the existing KV value tag space.
- A new optional trailing field is appended to the file header, the trailer, or an index entry without changing the offset of any field defined in the previous minor version.
A container_version_major increment is REQUIRED when any of the following
changes are made:
- An existing field's offset, type, or semantics changes in the file header, index, or trailer.
- The trailer length changes.
- An existing flag bit's meaning changes.
- The data buffer alignment minimum or maximum bounds change.
- A previously OPTIONAL section becomes REQUIRED, or vice versa.
The normative reader rules for the container version are established in
file-format.md § File Header.
Quantization Scheme Version (scheme_version)
The quantization scheme version describes the wire encoding of an individual
quantization scheme's parameter payload as defined in quantization.md.
- Location: quantization descriptor header, byte
1(scheme_version,uint8). Seequantization.md§ Descriptor Header. - Current values:
scheme_version = 0x01for every scheme defined inquantization.mdand its sub-files.
Each (scheme_tag, scheme_version) pair is independently versioned.
Incrementing the version of one scheme does not affect any other scheme.
A scheme_version increment is REQUIRED when any of the following changes are
made to a scheme's parameter encoding:
- A new field is added to the scheme's payload.
- A reserved byte or flag bit is repurposed.
- The interpretation of an existing field changes.
- The dequantization formula changes (this is also a backward-incompatible change at the descriptor level — see below).
Adding a brand-new scheme does not increment any existing scheme's
scheme_version; it allocates a new scheme_tag and starts that tag at
scheme_version = 0x01. Allocating the new scheme_tag requires a descriptor
minor version increment, as established in quantization.md § Version
Compatibility.
The normative reader rule for the scheme version is established in
quantization.md § Descriptor Header: a reader MUST reject a descriptor whose
scheme_version exceeds the highest version defined in this specification for
the given scheme_tag.
Note (non-normative): Removing a scheme or changing the dequantization formula for an existing
(scheme_tag, scheme_version)pair is a backward-incompatible change to the descriptor encoding and therefore requires a descriptorversion_majorincrement, not merely ascheme_versionbump. Thescheme_versionfield exists to support backward-compatible additions to a scheme's parameter encoding within a single descriptor major version.
Change Classification
Changes to any of the three version axes fall into one of three classes.
| Class | Wire-format effect | Required version bump |
|---|---|---|
| MAJOR | Backward-incompatible. A reader built for the previous major version cannot correctly parse data written at the new major version. | Major version increment on the affected axis. |
| MINOR | Backward-compatible addition. A reader built for the previous minor version can still parse data written at the new minor version (ignoring new optional content). | Minor version increment on the affected axis. |
| PATCH | No wire-format change. Documentation clarifications, editorial fixes, examples, and test vector additions. | No version increment. |
Examples of MAJOR changes
- Removing a field, flag bit, or tag value.
- Changing the offset, type, or semantics of an existing field.
- Adding a new mandatory field that all writers MUST emit and all readers MUST parse.
- Changing the dequantization formula for an existing
(scheme_tag, scheme_version)pair. - Changing the meaning of an existing flag bit.
- Changing the encoding of magic bytes, the descriptor length field, or any field a reader is required to consult before parsing the rest of a structure.
Examples of MINOR changes
- Adding a new optional flag bit to a flags field.
- Adding a new optional section gated by a new flag bit.
- Allocating a new
type_tag,layout_tag,device_tag, KV value tag, or quantizationscheme_tagwithin the existing tag space. - Adding a new optional trailing field to an existing section without disturbing the offsets of fields defined in the previous minor version.
Minor amendments MUST comply with the Evolvability Contract § Spec Amendment Rules (in particular S6 — no fixed-offset additions) and the defaults table in § Defaults for Appended Trailing Fields (S4).
Examples of PATCH changes
- Clarifying ambiguous wording.
- Adding non-normative notes or worked examples.
- Adding test vectors.
- Fixing typos that do not affect interpretation.
Compatibility Matrix
Let R denote the highest version a reader supports on a given axis, and W
denote the version recorded in the data being read on the same axis. The
compatibility rules apply identically to all three axes (descriptor, container,
and quantization scheme).
| Relationship | Reader behaviour |
|---|---|
R.major < W.major | MUST reject the data. |
R.major > W.major | MUST reject the data, unless this specification defines an explicit migration path for the older major version. No such migration path is defined for any axis at version 1.x. |
R.major == W.major, R.minor < W.minor | MUST parse all fields defined at R.minor. MUST ignore any optional fields, flag bits, sections, or tag values that are not defined at R.minor. MUST use the relevant length prefix (descriptor_length, quantization_length, or section length in the file index) to skip unknown trailing content. |
R.major == W.major, R.minor >= W.minor | Normal read. All fields written at W.minor are defined at R.minor or earlier. |
A reader that encounters a flag bit, tag value, or scheme not defined at its supported minor version MUST treat the affected feature as unknown:
- For an unknown descriptor flag bit: a reader MUST reject the descriptor,
because flag bits in descriptor minor version
1.0are required to be0for all reserved positions, so an unknown flag bit at1.x > 0indicates the writer used a feature the reader does not understand. - For an unknown type tag (outside the extension range
0xF0–0xFE): a reader MUST reject the descriptor. - For an unknown layout tag (outside the extension range): a reader MUST reject the descriptor.
- For an unknown quantization scheme tag: a reader MUST reject the
descriptor unless operating in permissive mode, as defined in
quantization.md§ Descriptor Header. - For an unknown KV value tag: a reader MUST reject the file, as defined
in
file-format.md§ KV Value Types. - For an unknown file flag bit: a reader MUST reject the file, as defined
in
file-format.md§ File Flags.
Note (non-normative): The asymmetry between "unknown trailing content MUST be ignored" and "unknown flag bits / tags MUST be rejected" is intentional. Trailing content is opt-in by definition: it can only be reached via a length prefix the reader already trusts. Unknown flag bits or tags signal that the writer relied on a feature whose semantics are unknowable to the reader, which would lead to silent misinterpretation of the data buffer.
Evolvability Contract
The Evolvability Contract states the stability guarantees the Hurray format
makes to implementors and downstream tools across the lifetime of major version
1.x, and defines the rules that make the format safe to change over time:
which compatibility a reader can rely on when it encounters data from a different
minor version, and which spec-amendment moves are admissible at each step.
It is the normative counterpart to the "Format Evolvability" property listed in
README § Core Properties. The guarantee period begins at
descriptor and container version 1.0; pre-1.0 drafts are explicitly
excluded (see § Out of Scope below).
The per-tag-space mechanics — reserved-range layouts, extension-range boundaries, and tag allocation tables — are normatively defined in the per-section files. This section does not restate those rules; it cross- references them:
- Element type tag space and extension range: see Element Types § Type Tag Space.
- Layout tag space, extension range, and per-layout reserved bytes: see
Memory Layout § Layout Tag Space and the per-layout
files under
docs/spec/layouts/. - Device tag space and extension range: see Buffer Protocol § Device Tag Space.
- Quantization scheme tag space, scheme reserved ranges, and permissive-mode parsing: see Quantization § Scheme Tag Space and § Descriptor Header.
- KV value tag space and file flag bits: see File Format § KV Value Types and § File Flags.
Stability Commitments
For the lifetime of major version 1.x, this specification commits to the
following invariants. Conforming readers, writers, and downstream tools MAY
rely on every one of them.
- Reserved tag ranges are stable. Reserved tag ranges defined by this
specification — across every public tag space (element type, layout,
device, quantization scheme, KV value, and flag bits) — MUST NOT be
repurposed, narrowed, or removed within major version
1.x. A reserved range allocated at1.0MUST remain reserved with the same boundaries and the same intended use class throughout1.x. - Implementation-private ranges remain implementation-private. The
implementation-private ranges
0xF0–0xFEfor element type tags, layout tags, and device tags MUST remain implementation-private for the lifetime of major version1.x. This specification MUST NOT allocate any named public value into a private range, and a future minor revision MUST NOT reclaim a private range for public allocation. Equivalent implementation-private ranges defined for other tag spaces in their per- section files (e.g., the quantization scheme private range inquantization.md) are subject to the same guarantee. - Reserved flag bits remain available for feature gating. Reserved flag
bits in the tensor descriptor header
flagsfield, the file headerfile_flagsfield, and every per-section flag field defined in this specification MUST remain available for backward-compatible feature gating throughout major version1.x. A reserved flag bit MUST NOT be removed or have its reserved status withdrawn within1.x; when allocated, it MUST be allocated as an optional feature gated by a minor version increment, per § Change Classification. - Every variable-length section is length-prefixed. Every variable-length
section in the tensor descriptor and the file format MUST carry a length
prefix that allows an older reader to skip unknown trailing content
without rejecting the structure. A future minor revision of the descriptor
or container MUST NOT introduce a variable-length section that lacks such
a length prefix. The applicable prefixes (
descriptor_length,quantization_length, file index entry section lengths, and any equivalent fields defined in future minor revisions) MUST continue to bound exactly the bytes whose interpretation may change. - Permissive-mode parsing is preserved. A reader MUST be able to parse
the tensor descriptor's shape and buffer table even when it cannot
interpret an unknown layout tag or an unknown quantization scheme tag.
This specification MUST NOT, within major version
1.x, introduce a change that requires interpreting a layout tag or a quantization scheme tag in order to recover the shape, rank, element type, or buffer table. The exact behaviour of permissive mode for each tag space is defined in its per-section file (see Quantization § Descriptor Header and Memory Layout § Layout Tag Space). - The three version axes evolve independently. The descriptor,
container, and per-quantization-scheme versions MUST evolve independently.
Adding a new feature on one axis MUST NOT force a version bump on the
others. A descriptor minor increment MUST NOT require a container minor
increment; a scheme-version bump for a single
(scheme_tag, scheme_version)pair MUST NOT require any change to the descriptor or container version; a container minor increment MUST NOT require any change to the descriptor or to any scheme version. This commitment complements § Version Axes, which establishes the axes themselves. - Public tag allocation goes through the spec amendment process. Any new public tag value — including a new element type tag, layout tag, device tag, quantization scheme tag, KV value tag, or named flag bit — MUST be added by a spec amendment that increments the appropriate minor version (per § Change Classification). New public named values MUST NOT be added by implementations independently of the specification. Implementations that need a private value MUST use the appropriate implementation-private range and remain subject to commitment (2) above.
Note (non-normative): Together, commitments (1)–(7) form the stable "extension surface" that downstream array databases, runtime registries, language bindings, and compatibility-testing harnesses can build against. A new tag allocated at descriptor
1.5is guaranteed to retain its meaning, encoding, and tag-space neighbourhood through descriptor1.99.
Out of Scope
The Evolvability Contract is a finite guarantee. The following properties are deliberately not guaranteed by this specification, and conforming readers, writers, and tools MUST NOT rely on them:
- Forward compatibility across major versions is out of scope. This
contract applies within major version
1.xonly. A reader MUST reject data whose major version on any axis exceeds the reader's supported major version, per § Compatibility Matrix. The guarantees in § Stability Commitments do not transfer to major version2.xor beyond; a future major version MAY revise tag spaces, reserved ranges, flag bits, and length-prefix conventions without preserving1.xsemantics. - Interpretation of unknown content is out of scope. Permissive-mode parsing allows a reader to extract shape, rank, element type, and buffer table when a layout tag or scheme tag is unknown. It does not authorise the reader to interpret the associated data buffer. A reader that does not understand the layout tag or quantization scheme tag of a tensor MUST NOT attempt to dereference, dequantize, or otherwise interpret the data buffer; doing so would constitute silent misinterpretation, which this contract explicitly forbids.
- Interoperability of implementation-private tags is out of scope. Tag
values in any implementation-private range (e.g.,
0xF0–0xFEfor element type, layout, and device tags, and equivalent ranges in other tag spaces) MUST NOT be exchanged between independent implementations without an out-of-band agreement on their meaning. Encountering a private-range value from an unknown source MUST be treated as an unknown tag under the rules in § Compatibility Matrix; permissive-mode parsing remains available where defined, but no semantic interoperability is implied. - A runtime plugin or codec mechanism is out of scope. This specification provides no registered plugin interface, no dynamic codec loader, and no implementation-supplied extension descriptor. New element types, layouts, devices, quantization schemes, and KV value tags MUST be added by a spec amendment under commitment (7); they MUST NOT be added by a runtime registration call, a sidecar manifest, or any implementation- private mechanism.
- User-defined non-numeric element types are out of scope. The element type extension range defined in Element Types exists only for new numeric encodings (integer, floating-point, and numerically-equivalent storage types). It MUST NOT be used to encode strings, structured records, opaque blobs, references to other tensors, or any other non-numeric content. Carrying non-numeric data over Hurray is the responsibility of the KV metadata section in File Format, not of the element type system.
- Back-compatibility of pre-
1.0drafts is out of scope. The Evolvability Contract begins at descriptor and container version1.0. Pre-1.0draft versions of this specification MAY have used different tag allocations, reserved ranges, or wire encodings, and conforming1.xreaders and writers MUST NOT assume any compatibility with them. A reader encountering data that claims a pre-1.0version SHOULD reject it; it MAY accept it only if the reader was explicitly configured to consume draft data and has applied an implementation-defined migration.
Note (non-normative): The boundary between "what we promise" and "what we deliberately do not promise" is what makes the Evolvability Contract usable. Without the out-of-scope list, downstream tooling could infer guarantees that the format cannot actually defend — for example, assuming that a private-range tag from one runtime is meaningful in another, or that permissive-mode parsing implies safe buffer access. Calling out non-commitments is part of the contract.
Compatibility Direction
-
BACKWARD (CD1): A reader at minor
MMUST correctly parse data written at any minorN ∈ {0, …, M}on the same major version on the relevant axis. -
FORWARD_ADDITIVE (CD2): A reader at minor
Mreading data written at minorN > Mon the same major version MUST correctly parse every field defined at minorM— the fixed header, the buffer table, and every length-prefixed section whose gating flag bit is defined at minorM, including trailing bytes of those sections up to the prefix length. The reader MUST reject the data if it encounters any flag bit or public tag value not defined at minorM, except within the permissive-mode exceptions for layout tags and quantization scheme tags defined in Memory Layout § Layout Tag Space and Quantization § Descriptor Header.Note (non-normative): The asymmetry between rejecting new flag-gated sections and skipping additive trailing bytes is intentional. An unknown flag bit may gate a new section whose presence changes the semantics of the data buffer; the reader has no safe way to ignore such a gate. Additive trailing bytes inside an already-known length-prefixed section, by contrast, extend a structure whose framing the reader already understands and can step past using the existing length prefix.
-
CD3: A reader supporting major
Kon a given axis MUST reject data whose major version on that axis isK + 1or higher. -
CD4: Cross-major reading is not automatic. A
K+1-major reader is not required to readK-major data; it MAY do so only via the migration specification required by S5 below.
Writer Rules
- W3: A writer MUST NOT emit a deprecated public tag value or flag bit when a non-deprecated equivalent exists.
- W4: When a writer appends an optional trailing field to an
existing length-prefixed section under a
version_minorincrement, the writer MUST emit that field at the documented offset and MUST update the enclosing length prefix accordingly. A writer MUST NOT emit a partial trailing field.
Note (non-normative): Rules W1 and W2 are the writer requirements stated in § Writer Requirements below. W3 and W4 extend them with the evolution-specific constraints needed by the Evolvability Contract.
Reader Rules
- R3: A deprecated public tag value MUST be treated as semantically equivalent to its non-deprecated definition. Deprecation MUST NOT change a value's wire semantics.
- R4: When a reader at minor
Mencounters a length-prefixed section shorter than the section length defined for minorM(i.e., data written at some minorN < M), the reader MUST treat every field beyond the data's section length as carrying its documented default from the defaults table in § Defaults for Appended Trailing Fields below.
Note (non-normative): Rules R1 and R2 are the reader behaviours stated in § Compatibility Matrix above. R3 and R4 extend them with the evolution-specific constraints needed by the Evolvability Contract.
Spec Amendment Rules
- S1: New public tag values MUST be allocated from the documented public reserved range of their tag space (see § Stability Commitments, items 1 and 7).
- S2 (Anti-rebind): An allocated public tag value MUST NOT be rebound to a different meaning within the same major version, even after deprecation.
- S3 (Deprecation convention): A deprecated tag table entry MUST be marked "deprecated since 1.N" and SHOULD point to a replacement. Deprecation is a writer-facing signal only — see R3 for reader obligations.
- S4 (Defaults for trailing fields): Any new optional trailing field appended to an existing section MUST have a normatively documented default in the same minor revision. The default MUST be recorded in § Defaults for Appended Trailing Fields below.
- S5 (Migration commitment): A future major version MUST be accompanied by a normative migration specification mapping the prior major version's encoding onto the new major version's encoding for every field, flag bit, and tag value that survives the transition.
- S6 (No fixed-offset additions): A new field in a minor revision MUST be gated by a flag bit, a tag value, or a length-prefixed trailing extension. A minor amendment MUST NOT allocate a new field at a fixed offset that an older reader would parse as part of an existing structure.
Defaults for Appended Trailing Fields
The following table records the default value that a reader at the prior minor version conceptually sees for each trailing field appended under a minor bump. This table is normative for R4. It MUST be updated as part of any spec amendment that adds a trailing field.
| Section | Field | Introduced in | Default for prior-minor readers |
|---|---|---|---|
(empty — no trailing fields have been appended at 1.0) |
Note (non-normative): The table is currently empty because no optional trailing fields have been appended to any section at descriptor version
1.0. The first such amendment MUST add its entry here.
Anti-Patterns
Note (non-normative): This sub-section is non-normative commentary; the operative prohibition lives in the bullet items themselves, which use MUST NOT to bind future spec amendments to the choice made here.
- Per-field numeric tagging (Protobuf, Thrift): imposing a tag and
a length-or-type word on every field defeats fixed-offset zero-copy
reads and forces a per-field decode loop even for readers that need
only a small subset of fields. This approach MUST NOT be adopted as
the descriptor's encoding strategy within major version
1.x. - vtables (FlatBuffers): adding a per-object vtable indirection
breaks single-pass streamability (the vtable is referenced by an
offset that may point backward relative to the object) and adds
extra cache-line traffic per field access. This approach MUST NOT
be adopted as the descriptor's encoding strategy within major
version
1.x. - Hurray's evolvability mechanism, by contrast, is the flag-bit + length-prefix model: new sections are gated by flag bits and framed by length prefixes; additive trailing fields live behind the enclosing length prefix; tag spaces grow only within their documented reserved ranges. This combination preserves fixed-offset zero-copy reads for every field defined at the reader's minor version while still admitting backward-compatible extension.
Worked Example
Note (non-normative): This sub-section is illustrative. The hypothetical
bias_correctionfield described below is not part of the format at descriptor version1.0; no MXFP trailing field has been appended at1.0. The example shows how W4, R4, and S4 work together when a future minor revision appends a trailing field.
Worked Example: MXFP Scheme Evolution Across Minor Versions
Suppose a future descriptor version 1.1 appends an optional
bias_correction field (float32, 4 bytes) to the MXFP quantization
scheme payload (see MXFP (OCP Microscaling) §
Binary Encoding) immediately after the existing scale_buffer_index
field, extending the MXFP descriptor from 16 bytes to 20 bytes. The
following steps illustrate the contract:
- Spec amendment (S4). The
1.1amendment recordsbias_correctionin the defaults table in § Defaults for Appended Trailing Fields with default value0.0(IEEE 754float32zero). 1.1writer (W4). The writer emits the full 20-byte MXFP payload includingbias_correction, and sets the enclosingquantization_lengthprefix to20(plus any further trailing bytes added by an even later minor revision the writer participates in).1.0reader against1.1data (CD2, R4). The reader seesquantization_length ≥ 20but parses only the first 16 bytes defined at1.0. The trailing 4 bytes are skipped via the length prefix; the reader synthesisesbias_correction = 0.0per the defaults table, even though it never actually reads the bytes (a1.0reader does not knowbias_correctionexists, so the synthesised default is only observable to a downstream1.1consumer that subsequently reparses the data).1.1reader against1.1data. The reader parses all 20 bytes, includingbias_correction, and interprets the field directly.1.1reader against1.0data (CD1, R4). The reader seesquantization_length = 16(the1.0MXFP length). Every field beyond the data's section length is treated as carrying its documented default; the reader synthesisesbias_correction = 0.0per the defaults table and proceeds with dequantization as if the writer had emitted the default explicitly. No descriptor rejection occurs.
This example does not introduce any normative requirement that is not already stated by W4, R4, and S4 — it only illustrates how those rules compose.
Writer Requirements
A conforming writer:
- MUST set
version_majorandversion_minorto the highest descriptor version whose features it actually uses. A writer that only uses descriptor1.0features MUST emitversion_major = 0x01,version_minor = 0x00, even if the writer's implementation is aware of higher minor versions. - MUST set
container_version_majorandcontainer_version_minorto the highest container version whose features it actually uses, under the same rule. - MUST set
scheme_versionto the lowest scheme version whose features it actually uses for each emitted quantization descriptor. - MUST NOT set any reserved flag bit, reserved tag value, or reserved byte unless this specification has defined the corresponding feature.
Note (non-normative): Writers SHOULD emit the lowest version number compatible with the features they use, to maximise the population of readers that can consume the output. A writer that knows it only emits descriptor
1.0features SHOULD emit1.0, not1.5, even if the writer's library supports both.
Version Registry
Current values for every version field defined in this specification:
| Axis | Field | Location | Current value |
|---|---|---|---|
| Descriptor | version_major | tensor descriptor, byte 4 | 0x01 |
| Descriptor | version_minor | tensor descriptor, byte 5 | 0x00 |
| Container | container_version_major | file header, byte 8 | 0x01 |
| Container | container_version_minor | file header, byte 9 | 0x00 |
| Quantization scheme | scheme_version (per-tensor affine, scheme_tag = 0x01) | quantization descriptor, byte 1 | 0x01 |
| Quantization scheme | scheme_version (per-channel affine, scheme_tag = 0x02) | quantization descriptor, byte 1 | 0x01 |
| Quantization scheme | scheme_version (per-block affine, scheme_tag = 0x03) | quantization descriptor, byte 1 | 0x01 |
| Quantization scheme | scheme_version (NF4, scheme_tag = 0x04) | quantization descriptor, byte 1 | 0x01 |
| Quantization scheme | scheme_version (MXFP, scheme_tag = 0x05) | quantization descriptor, byte 1 | 0x01 |
Note (non-normative): The full set of allocated
scheme_tagvalues is maintained inquantization.md§ Scheme Tag Space and its sub-files. This registry tracks only the version of each scheme's wire encoding, not the tag allocation itself. When a new scheme is added toquantization.md, a corresponding row MUST be added to this table.
Relationship to Other Sections
metadata.mdestablishes the normative reader rules for the descriptor version and the structure of the descriptor that the version describes.file-format.mdestablishes the normative reader rules for the container version and the structure of the file that the version describes.quantization.mdestablishes the normative reader rules for the quantization scheme version and defines which scheme tags exist.element-types.md,memory-layout.md, and the per-layout files underlayouts/define the tag spaces (type tags, layout tags) whose allocation is gated by the descriptor version policy stated in this section.
References — Hurray Format Specification
Status: Draft
Normative References
The following documents are referenced normatively by one or more sections of the Hurray format specification. Conforming implementations MUST comply with the relevant portions of these documents as cited.
[RFC2119]
Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
https://www.rfc-editor.org/rfc/rfc2119
Used by: All sections. Normative keywords (MUST, SHOULD, MAY, etc.)
are interpreted as defined in this document.
[IEEE754]
IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019, July 2019.
https://ieeexplore.ieee.org/document/8766229
Used by: element-types.md — float16 (binary16), float32 (binary32),
float64 (binary64) bit-pattern definitions and NaN/infinity handling rules.
[OFP8]
Open Compute Project, "OCP 8-bit Floating Point Specification (OFP8)", Version 1.0, September 2023.
Used by: element-types.md — float8_e4m3 and float8_e5m2 bit-pattern
definitions. quantization/mxfp.md — float8_e8m0 scale factor encoding and
the MXFP8 element type definitions.
[OCPMX]
Open Compute Project, "OCP Microscaling Formats (MX) Specification", Version 1.0, 2023.
https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf
Used by: quantization/mxfp.md — MXFP block size (32), float8_e8m0
shared exponent scale encoding, and valid MXFP element types.
[QLoRA]
Dettmers, T., Pagnoni, A., Rodola, G., and Zettlemoyer, L., "QLoRA: Efficient Finetuning of Quantized LLMs", arXiv:2305.14314, May 2023.
https://arxiv.org/abs/2305.14314
Used by: quantization/nf4.md — the NF4 lookup table values and the NF4
dequantization formula are taken from the reference implementation accompanying
this paper.
[SKILLING2004]
Skilling, J., "Programming the Hilbert Curve", AIP Conference Proceedings, Volume 707, pp. 381–387, 2004.
https://doi.org/10.1063/1.1751381
Used by: layouts/hilbert.md — the normative CoordsToHilbert and
HilbertToCoords algorithms are the algorithms defined in this paper.
Informative References
The following documents are referenced for context or as prior art. They are not normative; conforming implementations need not comply with them.
[DLPACK]
DLPack: Open In-Memory Tensor Structure.
https://github.com/dmlc/dlpack
Referenced by: buffer-protocol.md — the release callback (single deleter)
model is inspired by DLManagedTensor.deleter. docs/impl/python-bindings.md —
the __dlpack__ / __dlpack_device__ protocol for Python zero-copy interop.
[ARROW]
Apache Arrow: A cross-language development platform for in-memory data.
Referenced by: buffer-protocol.md and interchange.md — IPC framing and
buffer protocol design reference. docs/prior-art.md § 4.2.1.
[ARROWFLIGHT]
Apache Arrow Flight: A framework for high-performance data services.
https://arrow.apache.org/docs/format/Flight.html
Referenced by: interchange.md — streaming RPC model reference.
docs/prior-art.md § 4.2.2.
[SAFETENSORS]
Hugging Face SafeTensors: Safe serialization for tensors.
https://github.com/huggingface/safetensors
Referenced by: docs/prior-art.md § 4.3.1.
[GGUF]
GGUF: GPT-Generated Unified Format (llama.cpp).
https://github.com/ggerganov/ggml/blob/master/docs/gguf.md
Referenced by: quantization/per-block-affine.md — per-block affine
quantization covers the GGUF block quantization family (Q4_0, Q4_1, Q8_0).
docs/prior-art.md § 4.3.2.
[ONNX]
Open Neural Network Exchange (ONNX): An open standard for machine learning interoperability.
Referenced by: element-types.md — type system breadth reference.
data-model.md — zero-size dimension policy comparison.
[ZARR]
Zarr: Chunked, compressed, N-dimensional arrays.
Referenced by: docs/prior-art.md § 4.3.3 — chunk/shard layout reference.
[ARRAYAPI]
Consortium for Python Data API Standards, "Python Array API Standard".
https://data-apis.org/array-api
Referenced by: element-types.md — Tier 1 type vocabulary alignment with
the Python Array API Standard.
[UCX]
Unified Communication X (UCX): An optimized communication layer.
Referenced by: interchange.md — RDMA data plane, UCX packed rkey blob as
one possible RDMA key encoding.
[BITSANDBYTES]
Dettmers, T. et al., bitsandbytes: 8-bit optimizers and quantization.
https://github.com/TimDettmers/bitsandbytes
Referenced by: quantization/nf4.md — block_size = 64 is the bitsandbytes
default for NF4 quantization.
Hurray Implementation Requirements
This directory contains requirements for implementations of the Hurray format.
These requirements are distinct from the format specification (docs/spec/):
docs/spec/ | docs/impl/ | |
|---|---|---|
| Scope | What the binary encoding and protocol must look like | What a conforming implementation must provide |
| Language | Language-agnostic | Specific to each implementation layer |
| Audience | Anyone writing a Hurray reader/writer | Authors of the Hurray implementations themselves |
The format spec is the source of truth for the wire format. These documents define the API contracts, binding conventions, compliance criteria, and quality requirements that the reference implementation and language bindings must satisfy.
Implementations in Scope
| Implementation | Language | Crate / Module |
|---|---|---|
| Reference implementation | Rust | hurray-core, hurray-io |
| C FFI layer | C (via Rust) | hurray-ffi |
| Python bindings | Python (via PyO3) | hurray-python |
Documents
| Document | Description |
|---|---|
| Compliance | Conformance levels, mandatory vs optional feature support, test surface |
| Implementation Status | Which of those features each implementation actually provides — generated from the crates, the Python module, and the C header |
| Rust Reference | Requirements for hurray-core and hurray-io |
| C FFI | C ABI layer requirements: opaque handles, function table, panic safety |
| Python Bindings | Python codec + zero-copy bridge: DLPack, NumPy/PyTorch interop, native Hurray buffer protocol |
Compliance Requirements — Hurray Implementation Requirements
Overview
A conforming Hurray implementation is one that correctly reads and writes the
binary format as defined in docs/spec/. This document defines conformance levels,
the mandatory and optional feature surface, and the test requirements that determine
whether an implementation is conforming.
Conformance Levels
Level 1 — Reader
A Level 1 conforming implementation can read any valid Hurray tensor descriptor and associated data buffer for all Tier 1 element types and all Tier 1 named layouts. It does not need to produce output.
Mandatory:
- Parse all fixed-header fields (magic, version, flags, type_tag, layout_tag, rank).
- Reject descriptors with invalid magic, unsupported major version, or set reserved flag bits.
- Correctly interpret shape, byte_offset, and layout-specific fields for all Tier 1 layouts (
0x01–0x09). - Read and validate the buffer table (count, byte_size, alignment, device_tag).
- Skip optional sections using
descriptor_lengthwhen flags are not understood. - Return an error for unrecognised Tier 1 layout or type tags (unless in permissive mode).
Optional:
- Tier 2 element types (
float8_e4m3,float8_e5m2,float8_e8m0,complex64,complex128, sub-byte types). - Tier 2 layouts (
hilbert, tag0x40). - Quantization section (
HAS_QUANTIZATION). - Statistics section (
HAS_STATISTICS). - Extension type and layout tags.
- Permissive mode.
Level 2 — Writer
A Level 2 conforming implementation can write valid Hurray tensor descriptors and data buffers. Level 2 implies Level 1.
Mandatory (in addition to Level 1):
- Emit a correctly structured fixed header (magic
0x48 0x52 0x52 0x59, current version0x01 0x00). - Compute and emit a correct
descriptor_length. - Emit all mandatory fields for the chosen layout tag.
- Emit a buffer table with correct
byte_sizeandalignment(minimum 64 bytes). - Set
byte_offset = 0for sparse layouts (COO, CSR, CSC, CSF). - Set reserved flag bits to
0.
Level 3 — Network Transport
A Level 3 conforming implementation supports the Hurray network transport protocol
as defined in docs/spec/interchange.md. Level 3 implies Level 2.
Mandatory:
- Implement
CLIENT_HELLO/SERVER_HELLOsession establishment. - Implement
TENSOR_REQUEST/TENSOR_DESCRIPTOR/TENSOR_DATA/TENSOR_DATA_END. - Implement
ERROR,PING,PONG. - Correctly handle
descriptor_lengthto delimit descriptors in the stream.
Optional:
- Layout negotiation (
supported_layouts,ALLOW_TRANSCODE). - On-the-fly transcoding (
TRANSCODINGcapability flag). - Parallel shard transfers (
PARALLEL_STREAMS,PARALLEL_OK). - RDMA data plane (
RDMA_DATA_PLANE,RDMA_REGISTER,RDMA_READY). TENSOR_PUT/TENSOR_PUT_ACK.
Mandatory Element Type Support
All conforming implementations MUST support Tier 1 element types:
| Type | Tag |
|---|---|
float16 | 0x01 |
bfloat16 | 0x02 |
float32 | 0x03 |
float64 | 0x04 |
int8 | 0x10 |
uint8 | 0x11 |
int16 | 0x12 |
uint16 | 0x13 |
int32 | 0x14 |
uint32 | 0x15 |
int64 | 0x16 |
uint64 | 0x17 |
bool | 0x20 |
"Support" means: correctly read the descriptor, compute element addresses and buffer sizes, and preserve element bit patterns exactly during zero-copy interchange. An implementation is not required to perform arithmetic on every supported type.
Mandatory Layout Support
All conforming implementations MUST support Tier 1 layouts (0x01–0x0B, the full
core named-layout range) for reading descriptors:
| Layout | Tag |
|---|---|
| Row-major | 0x01 |
| Column-major | 0x02 |
| Strided | 0x03 |
| Tiled / Blocked | 0x04 |
| Morton | 0x05 |
| COO | 0x06 |
| CSR | 0x07 |
| CSC | 0x08 |
| CSF | 0x09 |
| Block-paged | 0x0A |
| Composite / Virtual | 0x0B |
Test Requirements
A conforming implementation MUST pass a test suite that covers:
- Descriptor parsing: valid descriptors for all mandatory type × layout combinations.
- Rejection cases: invalid magic, unsupported major version, reserved flag bits set, out-of-bounds
byte_offset, invalid shard offset,rank = 65(descriptor MUST be rejected perdata-model.md§ Rank, ADR-008). - Round-trip: write a tensor descriptor, read it back, verify all fields are identical.
- Buffer size: verify computed buffer sizes match expected values for all mandatory types and layouts.
- Zero-copy invariant: verify that bit patterns are preserved exactly after a round-trip (no NaN canonicalization, no subnormal flushing).
- Sparse invariant validation: for COO, CSR, CSC — verify that constraint violations are rejected. For CSF — verify that violations of per-level
posmonotonicity,crdsortedness within each parent slice, andmode_orderpermutation validity are rejected.
Empty Tensor Round-Trip Vectors
Per ADR-007 (permit empty tensors), a conforming implementation MUST round-trip the following empty-tensor descriptors without loss of information:
- A rank-1 tensor with shape
[0](empty vector). - A rank-3 tensor with shape
[3, 0, 5](zero-size middle dimension;element_count = 0). - A rank-2 CSR sparse matrix with
shape = [4, 5]andnnz = 0(no stored non-zeros;row_ptr = [0, 0, 0, 0, 0],valuesandcol_indicesbuffers havebyte_size = 0).
For each empty-tensor vector: writer emits the descriptor with a complete buffer table (zero-size buffers permitted), reader accepts the descriptor without error, all descriptor fields match exactly after round-trip.
The reference Rust implementation provides the canonical test suite. Language binding
test suites SHOULD mirror the reference suite and additionally test language-specific
interoperability (see python-bindings.md, c-ffi.md).
Implementation Status
Which spec features each Hurray implementation actually provides. Rows are format features, taken from Compliance; columns are implementations.
| Implementation | Covers |
|---|---|
hurray-core + hurray-io | Rust reference implementation |
hurray-python | Python bindings (PyO3) |
hurray-ffi | C ABI surface |
This page is generated, never hand-written. Tag coverage is read back out of the decoders one byte at a time, each capability is proven by an encode → decode round-trip, the Python column is introspected from the compiled module, and the C column is the set of symbols the generated header exports. CI regenerates the page and fails if it differs from the committed copy, so a feature an implementation gains — or loses — cannot pass unreported.
What no probe can detect carries a declared status and a justification, listed under the table it appears in.
Legend
- ✅ Implemented
- ◐ Partly implemented — see the note
- ❌ Not implemented
- ➖ Not this layer's to implement — see the note
- 📄 Specified, implemented nowhere — see the note
Adding a third-party implementation is a column, not a redesign: append an [[implementations]] block to website/coverage-matrix.toml with default = "no", then override that per section or per row as the implementation covers more. No row changes shape.
Conformance Levels
The three levels defined in Compliance. Level 2 implies Level 1, and Level 3 implies Level 2.
| Feature | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|
| Level 1 — Reader | ✅ | ✅ | ✅ |
| Level 2 — Writer | ✅ | ✅ | ❌ |
| Level 3 — Network transport | 📄 (a) | 📄 (a) | 📄 (a) |
- (a) No crate implements any part of the Level 3 transport: none of the message types, capability flags, or the RDMA data plane appear anywhere in
hurray-core,hurray-io,hurray-ffi,hurray-python, orhurray-inspect.hurray-ioimplements the stream framing and the file container — Levels 1 and 2 — and stops there. The protocol is specified so that an implementation has something to build against; scheduling one is tracked separately.
Element Types
Tier 1 types are mandatory for every conforming implementation; Tier 2 types are optional.
Extension types (0xF0–0xFE) are listed under Optional Descriptor
Sections, since the type tag and its describing section
are one feature on the wire.
| Feature | Tag | Tier | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|---|---|
float16 | 0x01 | 1 | ✅ | ✅ | ➖ |
bfloat16 | 0x02 | 1 | ✅ | ✅ | ➖ |
float32 | 0x03 | 1 | ✅ | ✅ | ➖ |
float64 | 0x04 | 1 | ✅ | ✅ | ➖ |
int8 | 0x10 | 1 | ✅ | ✅ | ➖ |
uint8 | 0x11 | 1 | ✅ | ✅ | ➖ |
int16 | 0x12 | 1 | ✅ | ✅ | ➖ |
uint16 | 0x13 | 1 | ✅ | ✅ | ➖ |
int32 | 0x14 | 1 | ✅ | ✅ | ➖ |
uint32 | 0x15 | 1 | ✅ | ✅ | ➖ |
int64 | 0x16 | 1 | ✅ | ✅ | ➖ |
uint64 | 0x17 | 1 | ✅ | ✅ | ➖ |
bool | 0x20 | 1 | ✅ | ✅ | ➖ |
float8_e4m3 | 0x40 | 2 | ✅ | ✅ | ➖ |
float8_e5m2 | 0x41 | 2 | ✅ | ✅ | ➖ |
float8_e8m0 | 0x42 | 2 | ✅ | ✅ | ➖ |
float4_e2m1 | 0x43 | 2 | ✅ | ✅ | ➖ |
float6_e2m3 | 0x44 | 2 | ✅ | ✅ | ➖ |
float6_e3m2 | 0x45 | 2 | ✅ | ✅ | ➖ |
float128 | 0x46 | 2 | ✅ | ✅ | ➖ |
int4 | 0x48 | 2 | ✅ | ✅ | ➖ |
uint4 | 0x49 | 2 | ✅ | ✅ | ➖ |
int2 | 0x4A | 2 | ✅ | ✅ | ➖ |
uint2 | 0x4B | 2 | ✅ | ✅ | ➖ |
complex64 | 0x50 | 2 | ✅ | ✅ | ➖ |
complex128 | 0x51 | 2 | ✅ | ✅ | ➖ |
hurray-ffi— The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag,hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change tohurray-ffi.
Memory Layouts
Tier 1 layouts (0x01–0x0B) are mandatory for reading; Hilbert (0x40) is Tier 2.
| Feature | Tag | Tier | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|---|---|
| Row-major | 0x01 | 1 | ✅ | ✅ | ➖ |
| Column-major | 0x02 | 1 | ✅ | ✅ | ➖ |
| Strided | 0x03 | 1 | ✅ | ✅ | ➖ |
| Tiled / blocked | 0x04 | 1 | ✅ | ✅ | ➖ |
| Morton (Z-order) | 0x05 | 1 | ✅ | ✅ | ➖ |
| COO | 0x06 | 1 | ✅ | ✅ | ➖ |
| CSR | 0x07 | 1 | ✅ | ✅ | ➖ |
| CSC | 0x08 | 1 | ✅ | ✅ | ➖ |
| CSF | 0x09 | 1 | ✅ | ✅ | ➖ |
| Block-paged | 0x0A | 1 | ✅ | ✅ | ➖ |
| Composite / virtual | 0x0B | 1 | ✅ | ✅ | ➖ |
| Hilbert curve | 0x40 | 2 | ✅ | ✅ | ➖ |
hurray-ffi— The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag,hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change tohurray-ffi.
Quantization Schemes
Carried in the optional quantization section. Support means encoding and decoding the scheme descriptor; performing the dequantization arithmetic is a compute concern and outside the format's scope.
| Feature | Tag | Tier | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|---|---|
| Per-tensor affine | 0x01 | 1 | ✅ | ✅ | ➖ |
| Per-channel affine | 0x02 | 1 | ✅ | ✅ | ➖ |
| Per-block affine | 0x03 | 1 | ✅ | ✅ | ➖ |
| NF4 (NormalFloat4) | 0x04 | 2 | ✅ | ✅ | ➖ |
| MXFP (OCP Microscaling) | 0x05 | 2 | ✅ | ✅ | ➖ |
hurray-ffi— The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag,hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change tohurray-ffi.
Optional Descriptor Sections
Sections a descriptor may carry beyond the fixed header, each gated by a flag bit. A
reader that does not understand one skips it using descriptor_length, so none of these
is required at any conformance level.
| Feature | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|
| Quantization | ✅ | ✅ | ✅ |
| Shard | ✅ | ✅ | ✅ |
| Statistics | ✅ | ✅ | ✅ |
Extension type (0xF0–0xFE) | ✅ | ✅ | ✅ |
| Composite member | ✅ | ✅ | ✅ |
Streaming and File Interchange
The two framings that share the descriptor encoding: the self-delimiting stream format and
the HRRYFILE container. Rows are framings, not driver styles — one core puts the same bytes
on the wire whether it is driven by hurray-io's tokio shell, by hurray.StreamReader,
by hurray.aio.StreamReader, or by a C caller's own loop over the blocking transport
vtable (ADR-041 § 8). Which drivers an implementation offers is in its own guide; for
Python that is Python Bindings.
| Feature | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|
| Stream — write | ✅ | ✅ | ✅ |
| Stream — read | ✅ | ✅ | ✅ |
| Stream — composite tensors | ✅ | ✅ | ✅ |
| File — write | ✅ | ✅ | ✅ |
| File — read | ✅ | ✅ | ✅ |
| File — KV metadata section | ✅ | ✅ | ✅ |
| File — composite tensors | ✅ | ✅ | ✅ |
Network Transport (Level 3)
The cross-machine protocol in Interchange. Specified in full, implemented nowhere — see the note below the table.
| Feature | hurray-core + hurray-io | hurray-python | hurray-ffi |
|---|---|---|---|
Session establishment (CLIENT_HELLO / SERVER_HELLO) | 📄 | 📄 | 📄 |
Tensor transfer (TENSOR_REQUEST / TENSOR_DESCRIPTOR / TENSOR_DATA / TENSOR_DATA_END) | 📄 | 📄 | 📄 |
Control messages (ERROR / PING / PONG) | 📄 | 📄 | 📄 |
Client push (TENSOR_PUT / TENSOR_PUT_ACK) | 📄 | 📄 | 📄 |
Layout negotiation (supported_layouts, ALLOW_TRANSCODE) | 📄 | 📄 | 📄 |
On-the-fly transcoding (TRANSCODING) | 📄 | 📄 | 📄 |
Parallel shard transfers (PARALLEL_STREAMS, PARALLEL_OK) | 📄 | 📄 | 📄 |
RDMA data plane (RDMA_DATA_PLANE, RDMA_REGISTER, RDMA_READY) | 📄 | 📄 | 📄 |
- Every column — No crate implements any part of the Level 3 transport: none of the message types, capability flags, or the RDMA data plane appear anywhere in
hurray-core,hurray-io,hurray-ffi,hurray-python, orhurray-inspect.hurray-ioimplements the stream framing and the file container — Levels 1 and 2 — and stops there. The protocol is specified so that an implementation has something to build against; scheduling one is tracked separately.
Rust Reference Implementation Requirements — Hurray Implementation Requirements
Overview
The Rust reference implementation is the canonical implementation of the Hurray format. It is authoritative: when the spec is ambiguous, the reference implementation defines the correct behaviour. When the implementation deviates from the spec, the implementation is wrong.
The implementation is split across three crates:
| Crate | Responsibility |
|---|---|
hurray-core | Format types, tensor descriptor, buffer handle, quantization descriptors. No I/O, no async. |
hurray-io | Async streaming read/write, file format support. Depends on hurray-core and tokio. |
hurray-ffi | C ABI layer. See c-ffi.md. |
hurray-core
Type System
- MUST define a
TensorDescriptortype that encodes all fields fromdocs/spec/metadata.md. - MUST define an
ElementTypeenum covering all Tier 1 and Tier 2 type tags. - MUST define a
LayoutTagenum covering all named layout tags. - MUST define layout-specific descriptor types for each named layout (e.g.,
StridedLayout,TiledLayout). - MUST define a
BufferHandletype carryingbyte_size,alignment,device_tag, and a release callback (seec-ffi.md). - MUST define an
Errorenum viathiserror. Nounwrap()orexpect()in library code.
Serialization
- MUST implement binary serialization of
TensorDescriptorto the wire format defined indocs/spec/metadata.md. - MUST implement binary deserialization with full validation (magic, version, flag bits, bounds checks, sparse invariants).
- Serialization MUST be
no_std-compatible when theallocfeature is enabled. - A
serdefeature gate MUST provideserde::Serialize/serde::DeserializeforTensorDescriptor(JSON/CBOR interchange for tooling, not the wire format).
Buffer Safety
unsafecode MUST be isolated in dedicated modules.- Every
unsafeblock MUST have a// SAFETY:comment explaining the invariant that makes the code sound. - Buffer aliasing across runtimes MUST be mediated through the
BufferHandlereference count and release callback.
Correctness
cargo clippy -- -D warningsMUST pass.- All public items MUST have
///doc comments with at least one example. - Test coverage for the public API MUST be ≥ 80%.
hurray-io
Core and Drivers
The streaming and file logic MUST be split into a sans-io core — a state machine over bytes, holding no file, socket, or runtime — and thin drivers that move bytes between a transport and that core. The async API below is one such driver; a blocking C ABI and a Python event loop are others, and all of them MUST share the one core rather than each growing its own bridge (ADR-041 § 1).
The following are requirements on any implementation of the core, not internal details:
- The core supplies the read destination and MUST NOT accept payload bytes a driver placed elsewhere. The guarantee is that no copy of a payload happens after the transport writes into that destination (§ 2). This is not end-to-end zero copy, which only mmap or shared memory gives; the defect it rules out is a second, tensor-sized copy.
- The write path is not the same requirement mirrored. The core MUST accept borrowed, non-contiguous payload slices and MUST NOT assemble them. The caller keeps each slice valid until the core reports that item written, and the core MUST expose that point and MUST NOT retain the pointer afterwards (§ 3).
- Progress is explicit and never blocks. One request is outstanding at a time, and every transfer reports a byte count. A short read is not end of input — only a zero-byte read is, and then only at an item boundary; mid-item it is an error. A zero-length write is never progress: a closed sink is a distinct signal (§ 4).
- Alignment is the alignment the buffer handle declares, not the 64-byte minimum and not the allocator's default, and MUST hold across multi-buffer and composite paths (§ 5).
- Resumability requires a known position. A transport operation and its byte-count report MUST form one uninterruptible step, so cancellation is observable only at a step boundary. If a count is ever lost the core MUST enter an explicitly named unusable state rather than diverge silently, and a partially filled destination MUST never be observable (§ 6).
- A writer core MUST commit at completion. A submitted item MUST NOT mutate any state the core carries past the operation — the name it consumes, the index entry it earns, the bytes it stages — until the core reports that item written. An operation abandoned before its first byte therefore leaves the core unchanged, and resubmitting the same item under the same name MUST succeed. Once the first byte is accepted the core MUST become unusable and MUST retire that state only when the item completes, and it MUST NOT depend on a driver reporting the abandonment, because a driver that errors and drops the operation reports nothing (§ 6b).
- Composite nesting MUST be an explicit stack, not call recursion, so a composite can survive being suspended mid-member (§ 10).
The two access patterns the format has get two request vocabularies, and a core MUST admit both (§ 7):
- A sequential core is fed forwards: it asks for the next
nbytes. This serves stream read and write, and the file writer's single forward pass. - A random-access core is asked by range: it names the bytes
[offset, offset + len)it needs and is told how many arrived. This serves the file reader, which seeks — the trailer atfile_size - 40, the index located from it, tensors found by name — and therefore cannot be driven by a byte feed at all.
A random-access core MUST NOT need the unusable state of § 6, and MUST NOT be given one: because every request is absolute and the read position advances only when a count is reported, a transfer whose count is lost is recovered by re-issuing the same range, and re-filling the destination from the start of that range is idempotent. For the same reason, a job that fails (a name not in the index, a descriptor that will not decode, a torn composite) MUST leave the core usable — only a failure during the open sequence, where there is no index to start again from, is terminal.
Streaming Read
- MUST implement an async tensor descriptor reader that reads exactly
descriptor_lengthbytes before emitting a parsedTensorDescriptor. - MUST implement an async data frame reader that yields data in chunks without buffering the entire tensor.
- A reader MUST be able to start processing tensor data without buffering the entire input (streamable principle).
Streaming Write
- MUST implement an async tensor descriptor writer that emits the descriptor before any data bytes.
- MUST implement an async data frame writer that emits data incrementally.
- A writer MUST be able to emit tensors one at a time without buffering the entire output.
Async Runtime
- MUST use
tokioas the async runtime. - MUST NOT mix
rayonthread pool calls directly in async contexts. CPU-bound operations MUST usetokio::task::spawn_blocking. - All async functions MUST be
Send + 'staticto support multi-threaded tokio runtimes.
Streaming Format
- MUST support reading and writing the streaming IPC format defined in
docs/spec/interchange.md: a sequence of zero or more tensor descriptors + data buffers, terminated by an end-of-stream marker. - The streaming format MUST be self-delimiting:
descriptor_lengthallows a reader to advance past any descriptor without full parsing. - Back-references and end-of-file indexes are forbidden in the streaming format (streamable principle).
File Format
- MUST support reading and writing the Hurray file format defined in
docs/spec/file-format.md. The file format is a single-pass writable, random-access readable container for one or more named tensors. - A writer MUST emit the file in a single forward pass (no seek-back), producing the structure:
HRRYFILEmagic + 64-byte file header + zero or more tensor regions (each tensor descriptor followed by its data buffer(s) with appropriate padding) + optional KV metadata section + index section + 40-byte trailer atfile_size - 40. - The writer MUST track per-tensor
(name, descriptor_offset, descriptor_length, data_offset, data_length)tuples in memory and emit them in the index section after all tensor regions are written. - A random-access reader MUST locate the trailer by seeking to
file_size - 40, verifytrailer_magic(ASCIIHRRY), and useindex_offset/index_lengthfrom the trailer to locate the index section. - When the
HAS_INDEX_CRC32Cfile flag is set, the reader MUST verify the CRC-32C of the index section and reject the file on mismatch. - The implementation MUST support mmap-based zero-copy loading of tensor data buffers when the file's
data_buffer_alignmentmatches or exceeds the host page size. - A reader MUST reject a
data_buffer_alignmentthat is not a power of two, rather than carrying it into the padding arithmetic:0is a divisor there, and a crafted header MUST NOT be able to panic a parser. - The reader MUST NOT read a padding byte it was not asked for. Each request covers exactly one section, descriptor, or buffer; inter-buffer padding is skipped by the next request's offset, never consumed.
Code Quality
- No
unwrap()orexpect()in library code. All errors propagate with?. - Feature flags:
serde(serialization support),tokio(async I/O, enabled inhurray-io). - MSRV (minimum supported Rust version): tracked in
Cargo.tomland enforced in CI. - All changes to
hurray-coreandhurray-ioMUST be reviewed by therust-revieweragent before merge.
C FFI Layer Requirements — Hurray Implementation Requirements
Overview
The hurray-ffi crate exposes a stable C ABI that allows non-Rust runtimes to
consume and produce Hurray tensors without depending on Rust tooling. It is the
foundation for all non-Python language bindings.
ABI Stability
- All public symbols MUST use the
#[no_mangle]attribute andextern "C"linkage. - The ABI MUST be declared stable across patch versions and SHOULD be stable across minor versions. Breaking ABI changes require a major version bump.
- All struct layouts exposed across the FFI boundary MUST be
#[repr(C)]. - Enums exposed across the FFI boundary MUST be
#[repr(u8)]or#[repr(i32)]as appropriate, never#[repr(Rust)].
C ABI Version
The C ABI carries a single uint32 version identifier exposed via a constant
HURRAY_C_ABI_VERSION and a runtime accessor hurray_c_abi_version(). The
current version is 10:
| Version | Changes |
|---|---|
1 | Initial C ABI: opaque handles, buffer release callbacks, panic-safe error returns. |
2 | Per-mode buffer handoff sync payloads (SYNC_PRODUCER_SYNCED, SYNC_EVENT, SYNC_CONSUMER_STREAM) and the event-release callback. See Buffer Handoff Synchronisation. |
3 | HurrayBufferList for multi-buffer tensors, and the native protocol capsule carried a list rather than a single HurrayBuffer (ADR-030). Narrowed at version 10, where a capsule carries the tensor handle that owns the list. See Buffer Lists. |
4 | A capsule's descriptor bytes and the producing build's ABI version became readable from any language, through a separate context handle, HurrayTensorContext, beside the capsule's buffer list (ADR-034). Folded into HurrayTensor at version 10, which deletes it. See Tensor Handle. |
5 | Layout-payload field reads, so a consumer can interpret every Tier 1 layout it can decode (ADR-039). See Descriptor Field Reads. |
6 | The field identifier space grew to cover all five optional descriptor sections, which renamed the three ADR-039 accessors and added two (ADR-040). See Descriptor Field Reads. |
7 | HurrayReader and HurrayWriter became real handles over a transport vtable, and HurrayTensor pairs a descriptor with its buffers (ADR-041). See Streaming Over a Transport Vtable. |
8 | The transport vtable grew read_at and size, and HurrayFileReader / HurrayFileWriter read and write the HRRYFILE container (ADR-041 § 8). See File Containers Over a Transport Vtable. |
9 | Composites cross the stream and file surfaces whole: hurray_reader_next_item, hurray_file_reader_read_composite, hurray_writer_write_composite, hurray_file_writer_write_composite, and the HurrayItem handle (ADR-041 step 6). See Composites. |
10 | HurrayTensor became the whole tensor — the ABI version and an opaque owner moved onto it, hurray_tensor_new constructs one, and the separate context handle is deleted. A native-protocol capsule carries one HurrayTensor* and no context (ADR-043). See Tensor Handle. |
A consumer of the C ABI MUST query hurray_c_abi_version() before invoking any
function whose contract changed in a later version. A consumer compiled against
version 1 of the ABI that links against a runtime providing version 2 will
receive buffer handles whose sync_mode is SYNC_PRODUCER_SYNCED (0x00) by
default, which is the safe fallback: a version-1 consumer that does not
inspect sync_mode will still observe the strongest synchronisation guarantee
and will not race against the producer's device writes.
Opaque Handles
All Hurray objects crossing the FFI boundary MUST be represented as opaque pointer handles. Callers MUST NOT dereference or inspect the pointed-to memory directly.
| Handle type | Represents |
|---|---|
HurrayDescriptor* | A parsed tensor descriptor |
HurrayBuffer* | A buffer handle (data + metadata) |
HurrayBufferList* | An ordered, owning collection of buffer handles |
HurrayTensor* | A tensor: a descriptor and its buffers, owned together. What a stream or file read produces, and what a native-protocol capsule carries |
HurrayReader* | A blocking stream reader over a caller-supplied transport |
HurrayWriter* | A blocking stream writer over a caller-supplied transport |
Each handle is obtained from a hurray_*_create function and MUST be released by
the corresponding hurray_*_destroy function. Double-free and use-after-free are
undefined behaviour on the caller side; the implementation MUST detect them in debug
builds (e.g., via a poisoned sentinel).
Buffer Lists
A tensor whose descriptor references more than one buffer — per-channel / NF4 /
MXFP quantization, sparse layouts, block-paged, composite — needs all of its
buffers to travel together. HurrayBufferList is the ordered collection that
holds them (ADR-030), and the thing a producer accumulates them into before
handing the finished list to hurray_tensor_new, which takes ownership of it
(see Tensor Handle). The list builds; the tensor is the
finished product.
| Function | Contract |
|---|---|
hurray_buffer_list_new(capacity, out_list) | Creates an empty list. capacity is a hint. |
hurray_buffer_list_push(list, buffer) | Appends buffer, transferring ownership to the list on success. On failure ownership stays with the caller. |
hurray_buffer_list_len(list, out_len) | Number of buffers in the list. |
hurray_buffer_list_get(list, index, out_buffer) | Borrows the handle at index. Returns HURRAY_ERR_INDEX_OUT_OF_BOUNDS if index >= len. |
hurray_buffer_list_destroy(list) | Destroys the list and every handle it owns, then writes null through list. |
The following rules are normative.
- A list owns every handle pushed into it. A handle obtained from
hurray_buffer_list_getis borrowed: the caller MUST NOT callhurray_buffer_destroyon it. It remains valid until the list is destroyed. - Buffers MUST be pushed in descriptor buffer-table order: element
iof the list is buffer indexiof the descriptor. hurray_buffer_list_destroytakes a pointer to the caller's handle variable and MUST write null through it, so the caller's variable is observably dead and a repeated destroy is a no-op. This is the sound half of the "release marks the structure released" discipline: the list allocation is freed, so only memory the caller owns can carry the marker.- Destroying a list MUST destroy every owned handle exactly once, and MUST null each owned slot as it goes, so that a release callback which panics or re-enters cannot cause a double free.
Tensor Handle
A tensor is a descriptor and the buffers that descriptor describes. HurrayTensor is
that pair as one owned handle (ADR-043), and every path that produces a tensor
produces it: a stream read, a file read, and a native-protocol capsule, which carries
one HurrayTensor* and nothing else.
Before ADR-043 a capsule carried its buffers as its pointer and everything else — the encoded descriptor, the producing build's ABI version, the owner reference — in a separate context handle. Nothing enforced the pairing: two independent fetches returned two unrelated handles, so a consumer that read one without the other got either bytes it could not interpret or a description of bytes it did not have. Teardown was two calls in a required order. Pairing the two halves inside one handle makes both problems structural rather than documentary.
typedef struct HurrayTensor HurrayTensor;
typedef void (*HurrayOwnerReleaseFn)(void *owner);
HurrayStatus hurray_tensor_new(uint32_t abi_version,
const uint8_t *descriptor_bytes,
uint64_t descriptor_len,
struct HurrayBufferList *buffers, /* ownership transferred */
void *owner,
HurrayOwnerReleaseFn owner_release,
struct HurrayTensor **out_tensor);
HurrayStatus hurray_tensor_abi_version(const struct HurrayTensor *tensor, uint32_t *out);
HurrayStatus hurray_tensor_descriptor(const struct HurrayTensor *tensor,
struct HurrayDescriptor **out_descriptor);
HurrayStatus hurray_tensor_buffers(const struct HurrayTensor *tensor,
struct HurrayBufferList **out_buffers);
HurrayStatus hurray_tensor_destroy(struct HurrayTensor **tensor);
Construction
hurray_tensor_newMUST decodedescriptor_bytesduring the call and MUST store only the decoded form. A producer therefore cannot export a tensor whose descriptor does not parse: bytes that fail to decode MUST be rejected with the status the decode failed with, and MUST NOT produce a handle.- A null
descriptor_bytes, or adescriptor_lenof0, MUST be rejected withHURRAY_ERR_NULL_POINTER. A tensor with no descriptor is a carrier for bytes nothing can interpret, and eager decoding rules it out in any case. - The encoded bytes MUST NOT be retained and are not reachable from C. Every descriptor
field is read through the field accessors of
Descriptor Field Reads on the borrowed
HurrayDescriptor*, which is the one shape the capsule path and the read path share — a tensor from a stream or file read has no descriptor bytes to offer, because the decode happens out of a staging buffer the reader drops. buffersMUST be a list built withhurray_buffer_list_newandhurray_buffer_list_push(see Buffer Lists). On success its ownership transfers to the tensor, and the caller MUST NOT thereafter push into it, destroy it, or use the handle at all. On failure ownership stays with the caller, who MUST destroy it.abi_versionis what the producing build'sHURRAY_C_ABI_VERSIONconstant says. A tensor produced by a stream or file read MUST report this library'sHURRAY_C_ABI_VERSIONand MUST carry no owner; one produced byhurray_tensor_newMUST report what its producer passed.ownerandowner_releaseare opaque and MUST NOT be interpreted by this crate. They exist so a producer can keep whatever owns the tensor's memory alive for the handle's lifetime —hurray-pythonparks a Python object reference there, which is how a Python type is kept out of the C ABI entirely. Both MAY be null, which is a tensor with no owner to release.
Reading
hurray_tensor_abi_versionMUST be callable on aHurrayTensor*produced by any version of this ABI, and a consumer MUST call it before any other accessor and before trusting anything else about the handle. Its signature MUST NOT change. Every other accessor MAY assume the version has been checked.- That ordering is what makes the handle extensible, and what makes the capsule's name a one-time matter rather than a counter: fields added in a later ABI version are reached only through accessors added in that version, and a consumer that checked the version knows which ones exist, so additive growth never invalidates a dereference.
hurray_tensor_descriptorandhurray_tensor_buffersreturn borrowed pointers, owned by the tensor and valid until it is destroyed. A caller MUST NOT callhurray_descriptor_destroyorhurray_buffer_list_destroyon them, and MUST NOT destroy either half individually — the disciplinehurray_buffer_list_getalready follows.- Element
iof the borrowed list is buffer indexiof the descriptor's buffer table, so every buffer index appearing in a quantization, layout, or composite-member descriptor indexes it directly.
Teardown
hurray_tensor_destroyMUST release the descriptor, every buffer the list owns, and the owner reference — invokingowner_release(owner)exactly once — and MUST then write null through the caller's pointer, so the caller's variable is observably dead.- One call ends the whole tensor. There is no teardown order for a caller to get wrong and no second call to omit on an error path.
- A consumer MUST keep the handle alive for as long as it reads the buffers. Taking the halves out and destroying the carrier is use-after-free: the halves are borrows.
Descriptor Field Reads
Decoding a descriptor yields its fixed header — rank, element type tag, layout tag, byte offset, buffer table. Everything with internal structure behind that header is read separately: the layout payload, and the five optional sections (quantization, shard, statistics, extension type, composite member).
An implementation MUST expose every layout-specific field of the Tier 1 layouts
(0x01–0x09), because Compliance Level 1 requires a reader to correctly interpret
those fields and a consumer that cannot read them cannot address an element. The
optional sections are not required at any conformance level, but an implementation that
decodes one SHOULD expose its fields: a section a caller cannot read is a section the
producer wrote for nothing.
Fields are named by a HurrayDescriptorField identifier and read through five typed
accessors (ADR-039, ADR-040):
| Accessor | Reads |
|---|---|
hurray_descriptor_field_u64 | every unsigned or boolean scalar |
hurray_descriptor_field_i64 | signed scalars: per-tensor affine zero_point |
hurray_descriptor_field_f64 | real scalars: per-tensor affine scale, the float64 statistics |
hurray_descriptor_field_u64_array | tile_shape, morton_bits, mode_order, parent_shape, shard_offset |
hurray_descriptor_field_i64_array | strides, outer_strides, inner_strides |
Field Identifiers
HurrayDescriptorField MUST be a uint32 with named constants, and MUST NOT be a C
enum: a caller may pass any integer, and an out-of-range value materialised as a Rust
enum is undefined behaviour.
The identifier encoding is:
field_id = (range << 16) | (discriminant << 8) | field_index
| Range | Sub-object | Discriminant byte |
|---|---|---|
0x0001 | layout payload | layout tag |
0x0002 | quantization | scheme tag; 0x00 addresses the section itself |
0x0003 | statistics | 0x00 |
0x0004 | shard | 0x00 |
0x0005 | extension type | 0x00 |
0x0006 | composite member | 0x00 |
The embedded discriminant makes applicability a comparison rather than a table lookup,
and the layout range inherits the layout tag space's reserved and private ranges.
0x0000_0000 MUST NOT be a valid identifier, so that a zeroed variable is rejected
rather than read as the first field.
Quantization is the only sub-object with an inner sum type, so it is the only section
that uses the discriminant byte. 0x0002_0000 MUST read the quantization scheme tag
itself, which a caller MUST read before selecting any scheme-specific identifier. The
remaining sections are flat and carry 0x00 in that byte.
Allocation follows the anti-rebind rule in ADR-019: a retired identifier MUST NOT be reused for a different field.
Field Semantics
- Every scalar field in the format is unsigned or boolean but two, so almost all of
them widen losslessly into
uint64. A boolean MUST read as0or1, and a wire tag byte MUST read as its documented value. - A signed scalar MUST be read through the signed accessor, and a real scalar through
the real accessor. A
zero_pointis routinely negative, and an unsigned read would report it as a value near2^64. - Stride fields MUST be read through the signed array accessor: a negative stride reverses a dimension and a zero stride broadcasts one, both of which an unsigned read would misreport.
- Array accessors MUST use the capacity/length in-out protocol that
hurray_descriptor_shapeuses:*out_lencarries the caller's capacity in and the true length out, and a null or insufficient buffer returnsHURRAY_ERR_BUFFER_TOO_SMALLwith the true length written. - An absent optional field MUST return
HURRAY_ERR_FIELD_NOT_PRESENTand MUST NOT write to the output: absence is a state this format expresses through a cleared flag bit, not a value, and reporting it as a zero would be indistinguishable from data. This covers an absent section, an unset optional field, and a symmetric quantization scheme's zero-point buffer index. - A statistics field whose guard bit in
computed_maskis clear MUST returnHURRAY_ERR_FIELD_NOT_PRESENT. The statistics fields are physically present whatever the mask says, so an implementation that returned the stored bytes would let a caller read an uncomputed statistic by omitting the mask check the spec requires.0x0003_0000MUST read the raw mask, for a caller that branches on it directly. - A field identifier that does not belong to the descriptor's layout or quantization
scheme MUST return
HURRAY_ERR_FIELD_NOT_APPLICABLE. An identifier outside any allocated range, or passed to an accessor of the wrong type, MUST return the same code. - Every accessor takes a
level, which selects the tile to read in a nested tiled layout.levelMUST be0for every layout that does not nest and for every section field, and a non-zero value MUST be rejected withHURRAY_ERR_INDEX_OUT_OF_BOUNDSrather than ignored.
Streaming Over a Transport Vtable
A reader and a writer are blocking and take their bytes from callbacks the caller supplies at construction. The implementation MUST NOT open a path or own a file descriptor on the caller's behalf, and MUST NOT link an async runtime into the C library: a vtable composes with a socket, a pipe, a memory span, or a caller's own object, and file descriptors are not portable across the platforms this ABI targets.
typedef HurrayStatus (*HurrayTransportReadFn)(
void* ctx, uint8_t* dst, size_t len, size_t* out_filled);
typedef HurrayStatus (*HurrayTransportWriteFn)(
void* ctx, const uint8_t* src, size_t len, size_t* out_written);
typedef void (*HurrayTransportReleaseFn)(void* ctx);
typedef HurrayStatus (*HurrayTransportReadAtFn)(
void* ctx, uint64_t offset, uint8_t* dst, size_t len, size_t* out_filled);
typedef HurrayStatus (*HurrayTransportSizeFn)(void* ctx, uint64_t* out_size);
typedef struct HurrayTransport {
void* ctx;
HurrayTransportReadFn read; /* required by a stream reader */
HurrayTransportWriteFn write; /* required by any writer */
HurrayTransportReleaseFn release; /* optional */
HurrayTransportReadAtFn read_at; /* required by a file reader */
HurrayTransportSizeFn size; /* required by a file reader */
} HurrayTransport;
One struct serves both surfaces, and a caller populates only the callbacks its transport
has. read_at and size are appended rather than grouped beside read so that a caller
using positional initialisation gets null for them instead of a release callback where a
positional read is expected.
The struct is copied at construction; ctx MUST stay valid until the owning handle is
destroyed, at which point release is invoked exactly once.
Transport Rules
These are ABI surface, and none of them may be left to inference.
- Partial transfers are legal. A callback MUST report the number of bytes it moved. Fewer than requested is a short transfer: not an error, and not end of input.
- Zero means different things in each direction. A read reporting
0withHURRAY_OKis end of input. A write reporting0withHURRAY_OKis not completion — it means the sink will accept nothing more, and is reported asHURRAY_ERR_SINK_CLOSEDwhen an item is in flight. - EOF, error, and closed are three distinct statuses, never one.
- A failing callback MUST have moved no bytes and MUST write
0through its count out-parameter. A transport that partially succeeds and then fails MUST report the partial count withHURRAY_OKand surface the failure on the next call — what POSIXreadandwritealready do. This rule is what makes a failed call recoverable: the handle's stream position still matches the transport's, so the call MAY be retried and will resume rather than restart. - A range request is absolute.
read_atMUST read from the offset it is given and MUST NOT rely on, or be relied upon to advance, any cursor of its own; a short range read is re-requested at the offset that follows. Because the request carries its own position, a failedread_atis always retryable — re-issuing it reads the same bytes. sizeis read once, when a file reader is created. A container is located from its end, so its size is a precondition of opening it and never something a read discovers:read_atreporting0means the file is shorter than its own structure claims, which isHURRAY_ERR_UNEXPECTED_EOFand not an end-of-input signal.- No reentrancy. A callback MUST NOT call any Hurray function on the handle that invoked it.
- The context pointer belongs to the caller. The implementation MUST NOT free it;
releaseis the only teardown hook. - One handle, one thread at a time. The absence of an internal runtime makes this a rule rather than a threading model.
Reader and Writer Contracts
| Function | Contract |
|---|---|
hurray_reader_new(transport, options, out_reader) | transport->read MUST be non-null. options MAY be NULL for the documented defaults. |
hurray_reader_next_tensor(reader, out_tensor) | Returns HURRAY_OK with a new HurrayTensor*, HURRAY_END_OF_STREAM at a clean tensor boundary, HURRAY_ERR_UNEXPECTED_EOF if input ended inside a descriptor or buffer, or the read callback's own status. A composite head arrives as the buffer-less tensor it is on the wire. |
hurray_reader_next_item(reader, out_item) | The same bytes, assembled: returns a new HurrayItem* holding one tensor or one whole composite. HURRAY_END_OF_STREAM only at an item boundary — input that ends with a head still owed members is HURRAY_ERR_INVALID_STREAM, not a clean end. See Composites. |
hurray_reader_destroy(&reader) | Destroys the reader, releases the transport context, writes NULL through the argument. Tensors already produced are unaffected. |
hurray_writer_new(transport, options, out_writer) | transport->write MUST be non-null. options MAY be NULL. |
hurray_writer_write_tensor(writer, descriptor, payloads, count) | payloads MUST have one HurrayIoSlice per buffer in the descriptor's buffer table, in that order, each of the declared byte_size. The tensor is validated before any byte reaches the sink. A composite head is refused: a head alone is not a tensor. |
hurray_writer_write_composite(writer, nodes, count) | One composite, as the preorder node array of Composites. nodes[0] MUST be a head. The whole tree is validated before any byte reaches the sink. |
hurray_writer_destroy(&writer) | Destroys the writer and releases the transport context. There is nothing to flush. |
A writer whose sink failed after the first byte of a tensor MUST be poisoned: a truncated item is on the wire, a stream cannot be un-sent, and a fresh writer over the same connection is not a recovery. A writer whose sink failed before the first byte MUST stay usable.
Readers MUST allocate every buffer at the alignment its buffer handle declares, and the transport MUST write into that allocation directly: a consumer is entitled to rely on the declared alignment for SIMD loads, and no copy of a payload may happen after the transport writes it.
A read produces the handle of Tensor Handle: a HurrayTensor owning
its descriptor and its buffers, reporting this library's HURRAY_C_ABI_VERSION and
carrying no owner. hurray_tensor_descriptor and hurray_tensor_buffers hand back
borrowed handles, valid until hurray_tensor_destroy, which releases both halves in
one call so the teardown order is not the caller's problem.
Composites
A composite is a tensor or a composite head with members, and C has no sum type to receive that in. It therefore crosses this boundary as the sequence the format itself carries: a head followed by its members, in preorder, with every node declaring how many of the nodes after it are its direct members. Nesting is in that one column, and the array is structurally acyclic — a pointer tree crossing an ABI is not.
Reads produce an opaque HurrayItem; writes consume an array of HurrayCompositeNode.
Both use the same two kind tags, which are not wire values: the wire has no
tensor-or-composite tag, it has a composite layout. The tags exist so that walking an
item needs no descriptor decode, and because a head with member_count == 0 — which the
layout permits — would otherwise be indistinguishable from a tensor.
#define HURRAY_ITEM_TENSOR 0
#define HURRAY_ITEM_COMPOSITE 1
typedef struct HurrayItemNode { /* read: borrowed from the HurrayItem */
uint8_t kind;
const uint8_t* name; /* NULL on a stream; UTF-8, no NUL, in a file */
size_t name_len;
HurrayTensor* tensor; /* borrowed; a head's buffer list is empty */
uint32_t member_count;
} HurrayItemNode;
typedef struct HurrayCompositeNode { /* write: borrowed from the caller */
uint8_t kind;
const uint8_t* name; /* required in a file; ignored on a stream */
size_t name_len;
const HurrayDescriptor* descriptor;
const HurrayIoSlice* payloads; /* ignored for a head: it owns no data */
size_t payload_count;
uint32_t member_count;
} HurrayCompositeNode;
| Function | Contract |
|---|---|
hurray_item_kind(item, out_kind) | The kind of node 0, for a caller that only needs to know whether a composite arrived. |
hurray_item_node_count(item, out_count) | Total nodes, root and every nested member included. 1 for a plain tensor. |
hurray_item_node(item, index, out_node) | Copies node index out in preorder. Its pointers borrow from the item. HURRAY_ERR_INDEX_OUT_OF_BOUNDS past the end. |
hurray_item_destroy(&item) | Destroys the item, every tensor and name it owns, and writes NULL. |
Implementations MUST order the nodes so that a node's direct members begin at the next
index and each is followed immediately by its own subtree, and MUST set member_count from
the members actually present — a count that disagrees with its head is rejected by
validation, not reported here.
A node's tensor is borrowed: it MUST NOT be passed to hurray_tensor_destroy, which
hurray_item_destroy does for every node in one call.
A write MUST refuse, before any byte reaches the sink:
- a
countof0, or anodes[0]that is not a head —HURRAY_ERR_NOT_COMPOSITE, the caller asking for a composite where there is none; - member counts that do not describe the array, whether a head declares members the array
does not carry or nodes trail after the root's subtree completes —
HURRAY_ERR_INDEX_OUT_OF_BOUNDS, a count that does not fit what it indexes; - a tree that is not a valid composite at any level, member count, partition exact-cover
and overlay base-first ordering included —
HURRAY_ERR_INVALID_LAYOUT, the composite layout's contract with its members being what was broken.
A stream composite is one item: the failure contract above therefore applies to the whole group, and a sink that stops accepting bytes part-way through it poisons the writer like any other torn item.
File Containers Over a Transport Vtable
A HRRYFILE container is addressed, not consumed: its trailer sits at size - 40,
its footer index is located from there, and a tensor is read by name without the bytes
between being touched. A file reader therefore asks for absolute ranges through read_at
and learns the file's length through size, while a file writer emits one forward pass
through write alone and never seeks back.
Reader and Writer Contracts
| Function | Contract |
|---|---|
hurray_file_reader_new(transport, out_reader) | transport->read_at and transport->size MUST both be non-null. Reads the header, trailer, index and KV section. A failed open is terminal: there is no index to start from, so no handle is produced and the transport context is released. |
hurray_file_reader_tensor_count(reader, out_count) | Number of footer-index entries. Every tensor is individually addressable, composite heads and members included. |
hurray_file_reader_tensor_name(reader, index, out_name, out_len) | Borrows entry index's name as UTF-8 bytes, not NUL-terminated, valid until the reader is destroyed. HURRAY_ERR_INDEX_OUT_OF_BOUNDS past the end. |
hurray_file_reader_read_descriptor(reader, name, name_len, out_descriptor) | Decodes one descriptor and reads no buffer data. The descriptor handle is owned by the caller. |
hurray_file_reader_read_tensor(reader, name, name_len, out_tensor) | Reads a descriptor and every buffer, as an owned HurrayTensor*. HURRAY_ERR_NOT_FOUND if the name is not in the index. A composite head reads back as the buffer-less tensor it is in the file. |
hurray_file_reader_read_composite(reader, name, name_len, out_item) | Reads the composite headed by name — its members and theirs — as an owned HurrayItem* (Composites). HURRAY_ERR_NOT_FOUND for a name not in the index; HURRAY_ERR_NOT_COMPOSITE for a name whose descriptor is not a head, refused at descriptor time before any data region is read. |
hurray_file_reader_kv_count(reader, out_count) / hurray_file_reader_kv_entry(reader, index, out_entry) | The file-level KV section, in file order. An entry's pointers borrow from the reader. |
hurray_file_reader_destroy(&reader) | Destroys the reader, releases the transport context, writes NULL. Tensors already produced are unaffected; borrowed name and KV pointers are not. |
hurray_file_writer_new(transport, options, out_writer) | transport->write MUST be non-null. Writes the 64-byte header during the call. options MAY be NULL for 4096-byte data alignment and an index in write order. |
hurray_file_writer_write_tensor(writer, name, name_len, descriptor, payloads, count) | As the stream writer's, plus the name that becomes the footer-index entry: non-empty, at most 65 535 bytes, unique within the file. |
hurray_file_writer_write_composite(writer, nodes, count) | One composite, as the same preorder node array, written head first then each member in order. Every node's name is REQUIRED: a reader recovers membership from the head's member_count and the file order of the entries after it, so each tensor needs an entry to be found by. |
hurray_file_writer_finish(writer, kv, kv_count) | Writes the KV section, the footer index and the trailer. kv MAY be NULL with a count of 0. Until this returns HURRAY_OK the bytes are not a container. Calling it twice after it returned HURRAY_OK MUST fail; a footer that failed before its first byte MAY be retried. |
hurray_file_writer_destroy(&writer) | Destroys the writer and releases the transport context. Any flush or close belongs to the transport's release callback. |
The file writer's failure contract is the stream writer's. The rule stated above for
hurray_writer_write_tensor is normative for both surfaces: a write that fails after
the first byte of its item MUST poison the writer, and one that fails before it MUST
leave the writer usable, the item's name unconsumed, and the file completable. The status
distinguishes the two — HURRAY_ERR_SINK_CLOSED after, the callback's own status before.
hurray_file_writer_finish is an item under the same rule, which makes it the one retry
worth having here: the data is already on the sink and only the index is missing, so a
footer that failed before its first byte MAY be written again.
KV Metadata
One tagged value type serves reads and writes, so a KV section read from one file can be handed straight to another file's writer without being transcribed:
#define HURRAY_KV_STRING 0x01
#define HURRAY_KV_INT64 0x02
#define HURRAY_KV_UINT64 0x03
#define HURRAY_KV_FLOAT64 0x04
#define HURRAY_KV_BOOL 0x05
#define HURRAY_KV_BYTES 0x06
#define HURRAY_KV_ARRAY 0x07
typedef struct HurrayKvValue {
uint8_t kind;
bool bool_value;
int64_t i64_value;
uint64_t u64_value;
double f64_value;
const uint8_t* data; /* STRING (UTF-8, no NUL) or BYTES */
size_t data_len;
const struct HurrayKvValue* items; /* ARRAY */
size_t item_count;
} HurrayKvValue;
typedef struct HurrayKvEntry {
const uint8_t* key; /* UTF-8, non-empty, no NUL terminator */
size_t key_len;
HurrayKvValue value;
} HurrayKvEntry;
The kind values are the KV section's own wire tags, not a second numbering. Fields the
tag does not select MUST be zero on a read and are ignored on a write. An array's elements
MUST be scalars — the format forbids an array of arrays, and a nested one MUST be refused
with HURRAY_ERR_INVALID_FILE rather than flattened.
File Composites
The node array of Composites, with the names filled in. One difference from a stream matters and it is not cosmetic: a file composite is several items, one index entry per tensor, not one item.
So a group that begins and does not finish leaves a head with fewer members after it than
it declares, which file-format.md § Composite Tensors calls invalid, and no footer can
rescue it. Implementations MUST therefore poison the writer when a composite group begins
and does not complete — including when the defect is a member's name rather than a
transport failure, since the head is already on the sink by then. Only a failure on the
head itself, the first node, leaves the writer usable and the file completable.
Panic Safety
Rust panics MUST NOT propagate across the FFI boundary. Every extern "C" function
that calls Rust code MUST wrap the call in std::panic::catch_unwind. If a panic is
caught, the function MUST:
- Log or store the panic message (implementation-defined).
- Return a well-defined error code (e.g.,
HURRAY_ERR_INTERNAL_PANIC). - Leave no partially-constructed state visible to the caller.
Error Handling
All fallible FFI functions MUST return an error code of type HurrayStatus
(int32). The value 0 (HURRAY_OK) indicates success. All other values indicate
errors.
typedef int32_t HurrayStatus;
#define HURRAY_OK 0
#define HURRAY_ERR_INVALID_MAGIC -1
#define HURRAY_ERR_VERSION_MISMATCH -2
#define HURRAY_ERR_INVALID_LAYOUT -3
#define HURRAY_ERR_INVALID_TYPE -4
#define HURRAY_ERR_BUFFER_TOO_SMALL -5
#define HURRAY_ERR_NULL_POINTER -6
#define HURRAY_ERR_INTERNAL_PANIC -7
#define HURRAY_ERR_INVALID_SYNC_MODE -8
#define HURRAY_ERR_SYNC_MODE_MISMATCH -9
#define HURRAY_ERR_INTERNAL -10
#define HURRAY_ERR_INDEX_OUT_OF_BOUNDS -11
#define HURRAY_ERR_FIELD_NOT_PRESENT -12
#define HURRAY_ERR_FIELD_NOT_APPLICABLE -13
#define HURRAY_ERR_TRANSPORT -14
#define HURRAY_ERR_SINK_CLOSED -15
#define HURRAY_ERR_UNEXPECTED_EOF -16
#define HURRAY_ERR_CORE_UNUSABLE -17
#define HURRAY_ERR_LIMIT_EXCEEDED -18
#define HURRAY_ERR_INVALID_STREAM -19
#define HURRAY_ERR_NOT_FOUND -20
#define HURRAY_ERR_INVALID_FILE -21
#define HURRAY_ERR_NOT_COMPOSITE -22
#define HURRAY_END_OF_STREAM 1
Functions MUST return HURRAY_ERR_NULL_POINTER for any required pointer argument
that is NULL, without invoking undefined behaviour.
Positive status values report a normal outcome that is not plain success.
HURRAY_END_OF_STREAM is the only one defined: a stream ending is not a failure, so
consumers MUST test for errors with status < 0 rather than status != HURRAY_OK.
Buffer Release Callbacks
Buffer handles carry a release callback to support zero-copy buffer sharing with
non-Rust runtimes. When hurray-ffi wraps an externally-owned buffer, the caller
provides a release function and a context pointer:
typedef void (*HurrayReleaseCallback)(void* buffer, void* context);
HurrayStatus hurray_buffer_from_ptr(
void* data,
uint64_t byte_size,
uint32_t alignment,
uint8_t device_tag,
HurrayReleaseCallback release,
void* release_context,
HurrayBuffer** out_handle
);
The release callback MUST be called exactly once when the buffer's reference count reaches zero. The implementation MUST NOT call the release callback from a destructor that runs on a foreign thread without the caller's consent.
Thread Safety
- All handles MUST be safe to use from a single thread at a time (i.e.,
Sendbut notSyncin Rust terms). - Concurrent access to the same handle from multiple threads is undefined behaviour unless documented otherwise.
- Reference counting for shared buffer handles MUST be performed with atomic
operations (
std::sync::atomic).
Naming Conventions
All public symbols MUST be prefixed with hurray_. Type names use Hurray prefix
with PascalCase. Error codes use HURRAY_ERR_ prefix with SCREAMING_SNAKE_CASE.
Header Generation
A C header file (hurray.h) MUST be generated from the Rust source using cbindgen
as part of the build process. The generated header MUST be checked into the repository
and kept in sync with the Rust source. CI MUST fail if the generated header differs
from the committed one.
Buffer Handoff Synchronisation
The buffer protocol's sync_mode field (see docs/spec/buffer-protocol.md
§ Stream and Event Synchronisation) declares one of three producer-side
synchronisation mechanisms in the binary descriptor. The C ABI carries the
corresponding payload out of band; the discriminant itself is read from the
buffer handle's binary descriptor and is NOT duplicated in the ABI struct.
The ABI layer MUST cross-check the sync_mode declared in the descriptor
against the payload provided at handoff time. A mismatch is a producer-side
bug; the ABI MUST reject the handoff with HURRAY_ERR_SYNC_MODE_MISMATCH
before returning a buffer handle to the consumer.
Per-Mode Payloads
sync_mode = SYNC_PRODUCER_SYNCED (0x00)
No additional fields beyond the buffer pointer, byte size, alignment, device tag, release callback, and release context defined in Buffer Release Callbacks.
The producer MUST have issued a host-side wait on the device stream(s) that
wrote the buffer before calling the handoff function. For CPU buffers
(device_tag == 0x00), the producer MUST have issued a host memory fence if
concurrent host-side writes exist.
sync_mode = SYNC_EVENT (0x01)
The handoff struct carries an opaque event handle and an event-release callback in addition to the buffer fields:
| Field | Type | Description |
|---|---|---|
sync_handle | opaque pointer (void*) | Device-vendor-specific event handle (e.g., cudaEvent_t, hipEvent_t, MTLSharedEvent, VkSemaphore). Opaque to the ABI layer. |
sync_handle_device_tag | uint8 | Device tag identifying the event handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not. |
event_release_fn | void (*)(void* sync_handle, void* context) | Callback the consumer calls exactly once after issuing its stream-wait. Thread-safe. |
event_release_context | opaque pointer (void*) | Context pointer passed to event_release_fn. |
The producer MUST record the event on the writing stream(s) before calling the handoff function. The producer MUST NOT defer event recording past the handoff; doing so would allow the consumer to issue a stream-wait on an unrecorded event and deadlock.
The consumer MUST issue a device-stream-wait on sync_handle on every stream
that will access the buffer before enqueuing any work that touches the
buffer's bytes. The consumer MUST call event_release_fn(sync_handle, event_release_context) exactly once after all stream-waits have been issued
(typically immediately after handoff). event_release_fn MUST be safe to call
from any thread.
The event-release callback is separate from the buffer-release callback
defined in Buffer Release Callbacks: a consumer
in SYNC_EVENT mode makes two release calls per buffer, with independent
lifetimes.
sync_mode = SYNC_CONSUMER_STREAM (0x02)
At handoff request time, the consumer supplies an opaque stream handle to the producer:
| Field | Type | Description |
|---|---|---|
consumer_stream | opaque pointer (void*) | Device-vendor-specific stream handle (e.g., cudaStream_t, hipStream_t, id<MTLCommandQueue>). Opaque to the ABI layer. Supplied by the consumer at handoff request time. |
consumer_stream_device_tag | uint8 | Device tag identifying the stream handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not. |
The producer MUST issue a device-side ordering dependency from its writing
stream(s) onto consumer_stream before the handoff function returns a buffer
handle. The producer-side stream is not blocked; the ordering is established
on the device.
The consumer MAY access the buffer on consumer_stream after handoff returns,
but MUST NOT access the buffer on any other stream until it has issued an
inter-stream wait on that other stream.
SYNC_CONSUMER_STREAM does not introduce a second release callback: there is
no event handle whose lifetime must be managed.
ABI Cross-Check
For every buffer handed off through the C ABI, the implementation MUST:
- Read the
sync_modebyte at offset 13 of the buffer handle binary descriptor. - Verify that the payload provided by the producer at handoff time matches
the declared
sync_mode:SYNC_PRODUCER_SYNCEDMUST NOT carry async_handleorconsumer_stream.SYNC_EVENTMUST carry a non-NULLsync_handle, anevent_release_fn, and async_handle_device_tagequal to the buffer'sdevice_tag.SYNC_CONSUMER_STREAMMUST carry a non-NULLconsumer_streamand aconsumer_stream_device_tagequal to the buffer'sdevice_tag.
- Reject any reserved
sync_modevalue (0x03–0xFE) and the invalid value0xFFwithHURRAY_ERR_INVALID_SYNC_MODE. - Reject a mismatch between descriptor and payload with
HURRAY_ERR_SYNC_MODE_MISMATCH.
Backward Compatibility for Pre-Version-2 Consumers
A consumer compiled against C ABI version 1 that does not inspect sync_mode
will receive buffers whose sync_mode == SYNC_PRODUCER_SYNCED by default. This
is the safe fallback: the strongest synchronisation guarantee, no per-mode
payload required, and behaviour identical to the version-1 contract. A
producer that wishes to interoperate with version-1 consumers MUST set
sync_mode = SYNC_PRODUCER_SYNCED for every buffer it hands off and MUST
issue the corresponding host-side wait before handoff.
Python Bindings Requirements — Hurray Implementation Requirements
Overview
The hurray-python package is the Python face of the Hurray interchange format:
the library a Python program uses to produce Hurray tensors, consume them, and
hand them off to the surrounding array ecosystem without copying. It is built on
PyO3 and is a codec and zero-copy bridge — not a numerical or compute library, and
not an implementation of the Python Array API Standard (see ADR-029). Its two goals
are:
- Produce / consume Hurray tensors — construct tensors and serialize them to the Hurray format, and parse Hurray data into usable Python objects, including the types the ecosystem cannot represent natively (Tier 2, quantized, sparse, composite).
- Zero-copy interop — share buffers with NumPy, PyTorch, JAX, and CuPy without copying, via DLPack, the NumPy array protocols, and the native Hurray buffer protocol.
These interop protocols are standalone — DLPack in particular is an independent
specification, not part of the Array API (a consumer's from_dlpack works on any
object exposing __dlpack__, with no Array API namespace involved). hurray-python
therefore does not claim, and MUST NOT advertise, Array API conformance; it is
Array-API-interoperable (a Tier 1 tensor handed to NumPy/PyTorch becomes a real
array those ecosystems can compute on), not Array-API-implementing.
Rationale
Note (non-normative): This section explains why
hurray-pythonexists and the incentives that shape its surface. It is motivation, not a requirement.
hurray-python is the Python face of the Hurray format — the library a Python
program uses to produce Hurray tensors (serialize its data into the format),
consume them (load Hurray data into usable Python objects), and hand them off
to the surrounding array ecosystem without copying. It is a codec and an
interchange bridge, in the same spirit as the Python packages for other data
formats: not a numerical or compute library, and not a place where array math
lives. Its reason to exist is simply that Python is where the machine-learning
ecosystem lives, and a format with no ergonomic Python entry point would never be
adopted there.
Three incentives drive its design:
-
Reach the ecosystem on day one. The array ecosystem already shares tensors through a widely implemented zero-copy handoff. By speaking that same handoff,
hurray-pythoninteroperates with NumPy, PyTorch, JAX, and CuPy immediately, for the common case (ordinary dense tensors of the standard element types), with no per-library adapters to write. This is why Hurray tensors are made to feel like ordinary arrays in that ecosystem rather than opaque blobs. -
Preserve full fidelity between Hurray-aware components. The common ecosystem handoff can only describe the ordinary dense case. Everything that makes Hurray worth having — compressed and quantized data, sparse and other specialized layouts, richer element types, and the metadata that travels with a tensor — falls outside what that handoff can carry. So
hurray-pythonalso offers a native Hurray interchange path that preserves the tensor in full, for sharing between components that both understand Hurray. -
Bridge the gap while adoption grows. In an ideal end state, producer and consumer libraries would understand Hurray natively, the same way they understand the common handoff today. Until then,
hurray-pythonis the adapter that speaks both sides: it ingests tensors from the existing ecosystem and emits Hurray, and vice versa. This bridging role is deliberately temporary in ambition — it recedes naturally as more of the ecosystem adopts Hurray directly.
Finally, hurray-python offers a handful of direct convenience methods for the
most common partners (NumPy, PyTorch) even though the generic zero-copy handoff
already exists. This is a deliberate, adoption-minded choice: a new format is
accepted or rejected on how much friction it adds, so a one-call, discoverable path
matters. These methods also cover the cases the generic handoff cannot express, so
that no data is silently left behind.
Tensor Surface
hurray-python MUST expose a hurray.Tensor class with an inspection and interop
surface. This surface describes the tensor and hands it off zero-copy; it does not
include array computation (elementwise math, reductions, linear algebra, indexing, or
operators) — those belong to the framework the buffer is handed to. hurray.Tensor
MUST NOT implement __array_namespace__, and the hurray module is not an Array API
namespace (see ADR-029).
Inspection and interop methods
| Method | Requirement |
|---|---|
__dlpack__(stream=None) | MUST return a DLPack capsule for zero-copy buffer sharing. See DLPack Interoperability for stream semantics. |
__dlpack_device__() | MUST return a (DLDeviceType, device_id) tuple. device_id is passed as runtime metadata at Tensor construction time and defaults to 0. See DLPack Interoperability. |
__hurray__(stream=None) | MUST return a native Hurray capsule (full-fidelity, all dtypes). See Native Interchange Protocol. |
dtype | MUST return the tensor's hurray.dtype.* object. |
shape | MUST return a Tuple[Optional[int], ...]. Each element is an int for a known dimension, or None for a dynamic (unknown) dimension. |
ndim | MUST return the number of dimensions. |
size | MUST return Optional[int]: the total number of elements, or None if one or more dimensions are dynamic (unknown). |
device | MUST return a device object consistent with __dlpack_device__. |
T | MUST return a transposed view without copying for rank-2 tensors. MUST raise ValueError if the tensor is not rank-2. |
Dtype surface
hurray.Dtype MUST expose, in addition to name, bit_width, tier, and the
is_float / is_integer / is_signed / is_sub_byte predicates:
| Member | Requirement |
|---|---|
tag | MUST return the type's normative wire tag (element-types.md § Type Tags). |
element_alignment | MUST return the natural alignment of one element in bytes. Sub-byte types MUST report 1. |
Dtype.from_tag(tag) | MUST return the type for a wire tag, and MUST raise hurray.InvalidDescriptorError for the permanently invalid sentinels (0x00, 0xFF) and for tags reserved by this version. A tag in the private-extension range (0xF0–0xFE) is not an error; it MUST resolve to an extension type that preserves the tag. |
Dtype.from_tag and Dtype.from_name MUST return the singleton for the type, not a new
object: the class documents hurray.float32 is hurray.dtype.float32, and a lookup that
returned an equal-but-not-identical object would break that quietly.
Because every private extension type shares the name "extension", repr MUST include
the tag, which is the only thing distinguishing two of them.
Buffer sizing
The module MUST expose hurray.buffer_size_bytes(dtype, count) -> int, applying the
packing rules of memory-layout.md § Sub-byte packing. count * dtype.bit_width // 8 is
not a substitute: it is wrong for every sub-byte type.
Dynamic dimensions
A dynamic dimension MUST be spelled None in a shape passed to hurray.Tensor, matching
what Tensor.shape returns for one, so that a shape read from a tensor can be passed
back to build an equal descriptor (ADR-032 § 4). Implementations MUST NOT also expose a
sentinel constant for it.
Functions that allocate a buffer or validate one against the shape — zeros, ones,
empty, full, sparse_coo, Composite — MUST refuse a dynamic dimension with
hurray.InvalidDescriptorError, naming the call and the offending index.
A tensor with a dynamic dimension MUST NOT render its buffer as data in repr or str:
the wire sentinel reaches NumPy as -1, which means "infer this extent", so an
unresolved dimension would print as an empty tensor.
Tier 1 dtype interop correspondence
Note (non-normative): Tier 1 element types use the standard numeric vocabulary, so a Tier 1
hurray.Tensormaps to a NumPy dtype without translation when handed to the ecosystem. This correspondence is an interop detail, not an Array API claim.
| Hurray type | NumPy dtype (for interop) |
|---|---|
bool | numpy.bool_ (via __array__; not representable over DLPack — see below) |
int8 | numpy.int8 |
uint8 | numpy.uint8 |
int16 | numpy.int16 |
uint16 | numpy.uint16 |
int32 | numpy.int32 |
uint32 | numpy.uint32 |
int64 | numpy.int64 |
uint64 | numpy.uint64 |
float16 | numpy.float16 |
bfloat16 | no native NumPy dtype (e.g. ml_dtypes.bfloat16); crosses via DLPack to PyTorch/JAX |
float32 | numpy.float32 |
float64 | numpy.float64 |
complex64 | numpy.complex64 |
complex128 | numpy.complex128 |
Tier 2 and quantized types
For Tier 2 element types (sub-byte integers, float8 variants) and quantized
types, hurray-python MUST expose a hurray-namespaced dtype object (e.g.,
hurray.dtype.int4, hurray.dtype.float8_e4m3). These have no standard NumPy dtype
and cross between Hurray-aware components via the native protocol or save/load.
Tensors with Tier 2 / quantized dtypes MAY still implement __dlpack__ where DLPack
supports the element type. For element types outside the DLPack type enum, __dlpack__
MUST raise the Python built-in BufferError.
DLPack Interoperability
hurray-python MUST support DLPack for all element types that DLPack defines:
float16, bfloat16, float32, float64, int8, uint8, int16, uint16,
int32, uint32, int64, uint64, complex64, complex128, bool.
__dlpack__()MUST return a PyCapsule named"dltensor_versioned"conforming to the DLPack specification (v1.0 or later).__dlpack__()MUST raise the Python built-inBufferErrorfor any element type not in the DLPack type enum (e.g.,int4,float8variants, quantized types).BufferErroris the conventional signal used across the ecosystem (NumPy, PyTorch) for a tensor that cannot be represented in DLPack.- The DLPack capsule MUST reference the original buffer without copying. The buffer's reference count MUST be incremented when the capsule is created and decremented when the capsule is consumed or deleted.
Stream parameter semantics
The stream parameter of __dlpack__(stream=None) maps to the tensor's SyncMode:
stream value | Requirement |
|---|---|
None | The tensor MUST have SyncMode::ProducerSynced. The buffer is already fully written; no synchronisation is required by the consumer. |
-1 | The binding layer MUST perform a device-level synchronisation (equivalent to cudaDeviceSynchronize on CUDA) before returning the capsule. |
| Positive integer (stream handle) | If the tensor is ProducerSynced, the buffer is already ready; the stream argument MUST be ignored. Tensors with SyncMode::Event or SyncMode::ConsumerStream are out of scope for the initial Layer 8a implementation; the binding MUST raise BufferError for these modes. |
Private device tags and memory classes
0xF0–0xFE is the private range in both spaces: an agreement between one producer and
one consumer, which the spec gives no name.
hurray.Device'skindandmemory_classMUST each accept a name or a wire byte. The names cover what the spec assigns; the byte is how a private value is reached, since there is no name to pass.Device.tagandDevice.memory_class_tagMUST expose the wire bytes, andDevice.is_privateMUST report whether the device tag is in the private range.kindandmemory_classMUST report"private"for every value in the range — that is what the spec calls them — soreprMUST include the tag. Without it two different vendor devices print identically, which is a reader unable to tell what it is holding.- A reserved or permanently-invalid byte MUST be refused rather than accepted as a private one: a later version of the format may assign it, and a descriptor built on a guess would then mean something else.
The same rule governs private element types, whose repr carries the tag for the same
reason (§ Dtype surface).
Device ID
DLPack requires a device_id integer (e.g., GPU index) that is not stored in the
Hurray wire format. The device_id MUST be passed as runtime metadata when
constructing a hurray.Tensor and defaults to 0 (the first device of that type).
__dlpack_device__() MUST return this runtime device_id.
__dlpack_device__()MUST return the correct(DLDeviceType, device_id)pair according to the Device Tag Mapping (Hurray ↔ DLPack) table below.
Device and Memory Class Mapping (Hurray → DLPack)
The correct DLPack DLDeviceType for a Hurray buffer is determined by the
combination of device_tag and memory_class. DLPack encodes what Hurray
separates into two orthogonal fields as a single flat enum (e.g., kDLCUDAHost,
kDLCUDAManaged). The binding layer is responsible for the translation; neither
raw Hurray values NOR raw DLPack integers are stored in the other system's fields.
See ADR-020 for the rationale behind the two-field design.
Full mapping table
Hurray device_tag | Hurray memory_class | DLPack DLDeviceType | DLPack int |
|---|---|---|---|
0x00 CPU | STANDARD | kDLCPU | 1 |
0x00 CPU | HOST_PINNED | kDLCPU | 1 |
0x00 CPU | UNIFIED | kDLCPU | 1 |
0x01 CUDA | STANDARD | kDLCUDA | 2 |
0x01 CUDA | HOST_PINNED | kDLCUDAHost | 3 |
0x01 CUDA | UNIFIED | kDLCUDAManaged | 13 |
0x01 CUDA | PEER | — | raise hurray.UnsupportedError |
0x02 ROCm | STANDARD | kDLROCM | 10 |
0x02 ROCm | HOST_PINNED | kDLROCMHost | 11 |
0x02 ROCm | UNIFIED | — | raise hurray.UnsupportedError |
0x02 ROCm | PEER | — | raise hurray.UnsupportedError |
0x03 Metal | STANDARD | kDLMetal | 8 |
0x03 Metal | HOST_PINNED | kDLMetal | 8 |
0x03 Metal | UNIFIED | kDLMetal | 8 |
0x04 Vulkan | STANDARD | kDLVulkan | 7 |
0x04 Vulkan | HOST_PINNED | kDLVulkan | 7 |
0x04 Vulkan | UNIFIED | kDLVulkan | 7 |
0x04 Vulkan | PEER | — | raise hurray.UnsupportedError |
0x05 WebGPU | STANDARD | kDLWebGPU | 15 |
0x06 Hexagon | STANDARD | kDLHexagon | 16 |
0x06 Hexagon | HOST_PINNED | kDLHexagon | 16 |
0x06 Hexagon | UNIFIED | kDLHexagon | 16 |
0x07 Level Zero | STANDARD | kDLOneAPI | 14 |
0x07 Level Zero | HOST_PINNED | kDLOneAPI | 14 |
0x07 Level Zero | UNIFIED | kDLOneAPI | 14 |
0x07 Level Zero | PEER | — | raise hurray.UnsupportedError |
0x08 OpenCL | STANDARD | kDLOpenCL | 4 |
0x08 OpenCL | HOST_PINNED | kDLOpenCL | 4 |
0x08 OpenCL | UNIFIED | kDLOpenCL | 4 |
0xF0–0xFE | any | — | raise hurray.UnsupportedError |
Combinations not listed above (e.g., WebGPU + HOST_PINNED, which is invalid per
the per-device validity table in ADR-020) MUST NOT occur in a conforming
descriptor; if encountered, the binding layer SHOULD raise hurray.UnsupportedError.
Notes on the mapping:
- Hurray's
device_tagvalues are intentionally distinct from DLPack'sDLDeviceTypeintegers (ADR-016). Translation is the binding layer's responsibility — Hurray tag values MUST NOT be passed directly to DLPack consumers, and DLPack integers MUST NOT be stored in a Hurray buffer handle. - When implementing
__dlpack_device__, the binding layer MUST derive theDLDeviceTypefrom the(device_tag, memory_class)pair using the table above. It MUST NOT return the raw Hurraydevice_tagvalue. - CPU
HOST_PINNEDandUNIFIEDboth map tokDLCPUbecause DLPack does not distinguish host-pinned from ordinary host memory from the CPU's perspective. Thememory_classfield carries this information for consumers that need it. - Metal
STANDARD,HOST_PINNED, andUNIFIEDall map tokDLMetal(8) because DLPack does not distinguish Metal storage modes. Consumers that need to distinguish them MUST read the Hurraymemory_classfield directly. Note:HOST_PINNED(StorageManaged) is deprecated on Apple Silicon; see ADR-020. - ROCm
UNIFIEDhas no DLPack equivalent (kDLROCMManageddoes not exist in DLPack v1.0). The binding MUST raisehurray.UnsupportedError. PEERmemory has no DLPack equivalent for any device type. The binding MUST raisehurray.UnsupportedErrorfor anyPEERbuffer exposed via DLPack.- For implementation-private device tags (
0xF0–0xFE), the binding layer MUST NOT fabricate a DLPack mapping. It MUST raisehurray.UnsupportedErrorunless the consumer has explicitly agreed on a private mapping out of band. - For combinations in notes 5, 6, and 7 where
hurray.UnsupportedErroris raised, Hurray-aware consumers SHOULD use the native interchange protocol (__hurray__) instead. See Native Interchange Protocol.
Native Interchange Protocol
hurray-python MUST expose a native interchange protocol for
hurray-to-hurray zero-copy transfers covering (device_tag, memory_class)
combinations that DLPack v1.0 cannot represent (see ADR-023).
hurray.Tensor.__hurray__(stream=None) -> PyCapsule— MUST return a PyCapsule named"hurray_tensor_handle"wrapping aHurrayTensorpointer fromhurray-ffi(ADR-043). Available for all dtypes (Tier 1, Tier 2, quantized).hurray.from_hurray(obj, /) -> hurray.Tensor— MUST accept any object whose__hurray__returns a valid capsule and reconstruct ahurray.Tensorthat owns the transferred buffers.
Multi-buffer tensors
A tensor whose descriptor references more than one buffer — per-channel / NF4 / MXFP quantization, sparse layouts, block-paged, composite — MUST carry every buffer in a single capsule (ADR-030).
- The capsule pointer MUST be a
HurrayTensorowning the descriptor and oneHurrayBufferper buffer, reached throughhurray_tensor_buffers(ADR-043). A single-buffer tensor is theN = 1case, not a separate path. - Element
iof that buffer list MUST be the buffer at indexiof the descriptor's buffer table. Buffer indices appearing in quantization descriptors (scale_buffer_index,zero_point_buffer_index), layout descriptors, and composite members index the list directly. - A producer MUST NOT emit a capsule whose buffer count differs from the descriptor's buffer count, and a consumer MUST reject such a capsule rather than construct a tensor whose buffer indices do not resolve.
hurray.TensorMUST expose the descriptor's optional sections for reading:quantization(returning the scheme class, orNone),statistics,shard, andbuffer_count. The quantization getter MUST return an object of the same class the constructor accepts, so an inspected scheme can be reused to build another tensor without conversion.- A sparse-layout
hurray.TensorMUST carry its component buffers through this protocol, with its values and index buffers in descriptor order. A separate__hurray_sparse_buffer__protocol MUST NOT be introduced: sparse is the multi-buffer case, not a distinct one. hurray.save()MUST write every buffer of a tensor, andhurray.load()MUST accept multi-buffer tensors, rejecting any whose buffer count disagrees with its descriptor.
Capsule lifetime
The PyCapsule lifetime rules MUST match DLPack discipline:
- Capsule name on creation:
"hurray_tensor_handle". - Capsule name after consumption:
"used_hurray_tensor_handle". The consumer MUST rename the capsule before taking ownership, exactly as DLPack consumers rename"dltensor"to"used_dltensor". - Capsule destructor: if the capsule is destroyed while still named
"hurray_tensor_handle", the destructor MUST callhurray_tensor_destroyon the wrapped pointer, which releases the descriptor, every buffer the tensor owns, and the owner reference. If the capsule has been consumed (renamed), the consumer MUST callhurray_tensor_destroyexactly once. That one call replaces the two-call teardown the pointer/context pair required, and has no ordering requirement. - The consumer MUST keep the
HurrayTensoralive for as long as it reads the buffers: the descriptor and the buffer list obtained fromhurray_tensor_descriptorandhurray_tensor_buffers, and the handles obtained fromhurray_buffer_list_get, are borrowed and MUST NOT be destroyed individually. - The source
Tensor's Python reference count MUST be incremented when the capsule is created and decremented in both the destructor and consume paths. See Buffer Lifetime and Ownership.
stream parameter
__hurray__(stream=None) uses the same stream semantics as __dlpack__:
see Stream parameter semantics.
One handle, no capsule context
There is no capsule context. The capsule carries one pointer — a HurrayTensor
from hurray-ffi (ADR-043) — and the producer MUST NOT set a context on it. The
descriptor and the producing build's HURRAY_C_ABI_VERSION are reached through that
handle's accessors, so the pairing is structural: a consumer that has the pointer has
the description of the bytes it points at, in any language, with no structure private
to hurray-python in the way.
A producer MUST build the handle so that it owns the descriptor and the finished
HurrayBufferList before the capsule exists. A producer reaching the ABI from C does
that with hurray_tensor_new, which decodes the encoded descriptor it is given, so a
capsule whose descriptor does not parse is refused where it is created rather than where
it is consumed. hurray-python itself holds a decoded TensorDescriptor already and
hands that to hurray-ffi's Rust constructor instead: encoding it only for
hurray_tensor_new to decode it straight back would buy validation a hurray.Tensor
has by construction (ADR-043, Consequences).
The strong reference that keeps the source tensor alive MUST travel as the handle's
opaque owner, released through its owner_release callback, so that no Python type
reaches the C ABI.
ABI versioning
hurray.from_hurray MUST read the version with hurray_tensor_abi_version before
any other accessor and before trusting anything else about the handle; a mismatch MUST
raise hurray.UnsupportedError. See
C FFI § Tensor Handle for why that ordering is normative.
Discovery
Consumers MUST discover support by probing
hasattr(obj, '__hurray__'). There is no separate capability flag on the
hurray namespace.
Descriptor Encoding
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
The descriptor is the format's central artifact. hurray-python MUST be able to produce
and consume it, or a Python program cannot carry a Hurray tensor in a container of its own
nor read one that arrived out of band.
hurray.Descriptor
A Descriptor is what a tensor declares, without its data. It MUST expose dtype,
shape, ndim, size, layout, buffer_handles, buffer_count, device,
quantization, shard, statistics, byte_offset, version, and encoded_len, and
MUST agree with the tensor it came from on every one of them.
It MUST NOT be constructible from Python: a constructor would duplicate hurray.Tensor's
entire parameter list to build the half of it that carries no data. Descriptors come from
Tensor.descriptor, from Composite.descriptor (the head — the one descriptor that is
only a descriptor), or from Descriptor.decode.
decode MUST return a Descriptor and MUST NOT return a Tensor: a decoded descriptor
has no buffers, and a Tensor holding none would be the same class of false statement
ADR-037 removed from alignment.
Encoding
Descriptor.encode()MUST produce the binary descriptor exactly as it appears on the wire, andDescriptor.decode(bytes)MUST recover an equal descriptor from it.decodeMUST accept trailing bytes and ignore them. The descriptor carries its own length; in a stream what follows it is the data it describes.encoded_lenMUST equallen(encode()).
Standalone quantization sections
Each quantization class MUST expose encode(), and the module MUST expose
hurray.decode_quantization(bytes) returning whichever class the section's scheme tag
names — a caller reads the bytes without knowing in advance which scheme wrote them.
The classes MUST compare by value, like every other descriptor value object in this
binding. A class carrying a floating-point field MUST NOT define __hash__: NaN would
break the hash/equality contract, and being unhashable is the correct outcome rather than
an omission.
Buffer Lifetime and Ownership
Zero-copy interop requires that the source object's buffer remains valid for the entire lifetime of any object that holds a pointer to it. The binding layer MUST enforce the following rules:
-
hurray.from_numpy(array)→hurray.Tensor: TheTensorMUST hold a strong Python reference to the sourcendarrayfor its own lifetime. ThendarrayMUST NOT be garbage-collected while theTensoris alive. -
hurray.Tensor.__array__()→ndarray: The returnedndarrayMUST reference the sourceTensoras itsbaseobject (via NumPy'sbaseattribute or an equivalent mechanism), so that theTensoris kept alive for as long as thendarrayholds the buffer. -
hurray.Tensor.__dlpack__()→ capsule: The DLPack capsule destructor MUST decrement theTensor's Python reference count when the capsule is consumed or deleted. The reference count MUST be incremented when the capsule is created. This ensures theTensor(and therefore its buffer) is not freed while a DLPack consumer holds the capsule.
The same rules apply to the component Tensor views a sparse-layout tensor hands out.
Buffer Handles
This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
The binding MUST expose one hurray.BufferHandle per entry of a tensor's buffer table
(ADR-037).
The class
hurray.BufferHandle MUST be immutable and MUST expose, as read-only properties:
| Property | Type | Meaning |
|---|---|---|
byte_size | int | The buffer's declared size in bytes |
alignment | int | The alignment the buffer's base address satisfies |
sync_mode | str | "producer_synced", "event", or "consumer_stream" |
device | hurray.Device | The device and memory class the buffer lives in |
is_empty | bool | Whether byte_size is zero |
It MUST compare and hash by value, and MUST NOT be constructible from Python: every field is settled by the buffers a tensor already holds, so there is nothing a caller could supply.
A BufferHandle MUST NOT hold a reference to its tensor or to any buffer. In a zero-copy
format a metadata accessor that extends buffer lifetime is a defect: collecting handles
across a stream MUST pin nothing.
Tensor.buffer_handles
hurray.Tensor.buffer_handles MUST be a tuple with one handle per buffer, in descriptor
order, so len(t.buffer_handles) == t.buffer_count. It MUST be available for every layout
and every device, including tensors whose bytes cannot be handed out.
Tensor.buffer(i) is unchanged and remains the byte-view accessor. The two are separate
deliberately: on a device tensor the metadata question has an answer where the byte
question does not.
BufferHandle.device MUST be the same object as Tensor.device, such that
t.buffer_handles[i].device is t.device holds. buffer-protocol.md § Device Colocation
requires every buffer of one descriptor to share a device and memory class, so there is
exactly one to report.
Alignment is measured
A descriptor emitted by the binding MUST declare, for each buffer, an alignment its base address actually satisfies:
- An empty buffer MUST declare
1. - An owned buffer MUST be allocated to at least
MIN_BUFFER_ALIGNMENTand declare what it allocated. - A borrowed buffer MUST have its base address measured, and MUST declare the largest
power of two that address satisfies, capped at
PAGE_ALIGNMENT. A stronger true declaration is legal and useful to IPC and RDMA consumers.
The binding MUST NOT declare an alignment it has not established. A consumer issues aligned loads on the strength of this field.
copy on ingest
Because buffer-protocol.md § Alignment sets a 64-byte floor that NumPy does not promise,
every ingest entry point — from_numpy, from_torch, from_scipy, sparse_coo,
from_dlpack, asarray — MUST accept a keyword-only copy: bool | None = None:
copy | Required behaviour |
|---|---|
None | Share the source when its address meets the floor; otherwise copy into an allocation that does |
False | Never copy; raise hurray.CopyRequiredError naming the alignment the source actually has |
True | Always copy |
For a multi-buffer source the decision MUST be made per buffer: SciPy's .data,
.indices and .indptr are three allocations with three addresses.
The aligned allocator
hurray-python MUST expose hurray.aligned_allocator(), a context manager that installs
a 64-byte-aligned NumPy data-memory handler (NEP 49) for the duration of a block, so that
arrays allocated inside it need no copy on ingest.
- It MUST restore the previously installed handler on exit, including when the block raises, and MUST support nesting.
- It MUST NOT be offered as a module-level install. The handler is thread-local, so a process-wide "install once" would silently fail to cover arrays allocated on other threads.
- It MUST raise
hurray.UnsupportedErroron NumPy older than 1.22, which predates NEP 49.
Implementations MUST document that a thread started inside the block does not inherit the policy.
sync_mode
sync_mode MUST be read-only, and no constructor MAY accept it. "event" asserts that a
device event exists for a consumer to wait on; no Python API supplies one, so a settable
field could only author a contract nothing could honour. Everything the binding constructs
is therefore producer_synced.
A tensor holding any buffer whose sync_mode is not producer_synced MUST refuse the
paths that hand out its bytes — buffer(i), the component views (.values, .indices,
.indptr), __array__, to_torch — with hurray.UnsupportedError naming the mode.
__dlpack__ MUST raise the built-in BufferError instead, as
Stream parameter semantics already requires.
__hurray__ and StreamWriter.write MUST continue to relay such tensors unchanged:
relaying a declaration is not reading a byte.
Alignment and the round-trip obligation
alignment is exempt from the round-trip obligation that governs layout,
quantization, statistics and shard (ADR-032 § 4). Alignment describes an address, and
a rebuild that copies bytes has a different address; a tensor that arrived declaring 4096
MAY honestly declare 64 once rebuilt. Implementations MUST NOT add a settable
alignment= to force equality.
Constants
The module MUST expose hurray.MIN_BUFFER_ALIGNMENT and hurray.PAGE_ALIGNMENT,
mirroring hurray-core.
NumPy Interoperability
For CPU tensors with Tier 1 element types, hurray-python MUST support:
hurray.Tensor.__array__()— MUST return a NumPyndarraybacked by the same buffer (zero-copy for C-contiguous / row-major tensors; a copy is acceptable for other layouts if NumPy cannot represent them natively). See Buffer Lifetime and Ownership.hurray.from_numpy(array)— MUST create ahurray.Tensorthat shares the NumPy array's buffer without copying, for C-contiguous arrays. See Buffer Lifetime and Ownership.
For tensors with Tier 2 / quantized element types, hurray.Tensor.__array__() MUST
raise hurray.UnsupportedError. NumPy has no dtype for int4, float8 variants, or
quantized/scaled types; returning an ndarray is structurally impossible. Callers that
need the raw bytes SHOULD use the native protocol or DLPack directly.
PyTorch Interoperability
For CPU and CUDA tensors, hurray-python MUST support zero-copy conversion via
DLPack:
hurray.from_torch(tensor)— MUST calltensor.__dlpack__()and wrap the result in ahurray.Tensorwithout copying.hurray.Tensor.to_torch()— MUST callself.__dlpack__()and construct atorch.Tensorviatorch.utils.dlpack.from_dlpackwithout copying.
Layouts and Sparse Tensor Support
hurray-python MUST expose exactly one tensor class, hurray.Tensor, for every
layout (ADR-031). There MUST NOT be a separate class per layout family: a sparse
tensor is a hurray.Tensor whose layout happens to be COO, CSR, or CSC, matching the
format, where sparse is a layout_tag inside the ordinary tensor descriptor.
.layout— MUST return ahurray.Layoutobject, not a string (ADR-032). See § Layout Descriptor Classes below..values— ahurray.Tensorview over the values buffer..indices(COO) or.col_indices/.row_ptr(CSR) or.row_indices/.col_ptr(CSC) —hurray.Tensorviews over the index buffers..nnz— the stored non-zero count, for layouts that track one..to_scipy()— MUST convert a CSR or CSC tensor to the correspondingscipy.sparsematrix type without copying where scipy's memory layout is compatible.hurray.from_scipy(matrix)— MUST wrap ascipy.sparsematrix as ahurray.Tensorwithout copying.
Accessors that do not apply to a tensor's layout MUST raise AttributeError, so that
hasattr reports whether a tensor actually supports them (ADR-031 § 2, extending
design decision D10). They MUST NOT raise hurray.UnsupportedError, which would make
hasattr return True for every accessor on every tensor.
Protocols that require a densely addressable element buffer — __dlpack__,
__array__, __array_interface__, to_torch — MUST reject non-dense layouts, naming
the layout in the error message.
hurray.Tensor MUST additionally expose buffer(index), returning a 1-D uint8 view
of exactly the declared byte size of the buffer at that tensor-descriptor buffer index.
CSF has 2 * rank + 1 buffers and block-paged has three, none of which have named
accessors; without a generic accessor their parameters would be reachable while their
buffers were not. uint8 is the only honest element type for a generic view — the
buffers within one tensor differ (values take the tensor's dtype, index buffers are
uint64, MXFP scales are e8m0) — and it cannot misreport a dtype. The layout object
tells the caller what each index means. An index with no buffer MUST raise
IndexError.
Layout Descriptor Classes
hurray-python MUST expose a class hierarchy of layout descriptors (ADR-032). A layout
carries parameters — nnz, strides, page_size — that a string cannot.
The hierarchy
hurray.Layout # base: holds the core layout descriptor
├── RowMajorLayout ColMajorLayout
├── StridedLayout TiledLayout MortonLayout HilbertLayout
├── CooLayout CsrLayout CscLayout CsfLayout
├── BlockPagedLayout
├── CompositeLayout
├── PrivateExtensionLayout
└── UnknownLayout
The base MUST implement tag, name, buffer_count, is_dense and is_virtual
once; subclasses are typed façades reading their own fields off the same descriptor.
The base MUST NOT be constructible from Python.
buffer_count MUST be 0 for a composite head — a known zero — and None for a
private or unknown layout, whose count is genuinely not knowable.
When the core layout enum gains a variant a build has not bound, .layout MUST return
a bare Layout carrying tag and name. It MUST NOT be reported as UnknownLayout,
which asserts that the tag was unrecognised rather than merely unbound.
Value semantics
Layout objects MUST be immutable, MUST compare by value, and MUST hash consistently
with that equality. .layout is read-only, and MAY return a fresh object per access:
t.layout is t.layout is not guaranteed, t.layout == t.layout is.
Layout classes MUST NOT define equality against strings. repr MUST take the form
CsrLayout(nnz=4).
Frozen name and enumeration strings
Layout.name is one of: "row_major", "col_major", "strided", "tiled",
"morton", "hilbert", "coo", "csr", "csc", "csf", "block_paged",
"composite", or "extension" for a private or unrecognised tag. These MUST match
the layout names in docs/spec/memory-layout.md. PrivateExtensionLayout and
UnknownLayout therefore share a name; isinstance is what distinguishes them, and
they MUST remain separate classes because "a private layout I can identify" and "a tag
from a newer spec version I could not parse" are different facts a permissive relay
depends on.
A single internal helper MUST produce the name string, so that .name and the layout
named in error messages (__dlpack__, __array__, to_scipy) cannot drift.
Small closed enumerations are exposed as lowercase strings, not as Python enum classes:
| Property | Values |
|---|---|
BlockPagedLayout.kv_role | "key", "value", "fused" |
BlockPagedLayout.block_table_index_type | "uint32", "uint64" |
TiledLayout.outer_layout | "row_major", "col_major", "strided" |
TiledLayout.inner_layout | the above plus "tiled" |
CompositeLayout.composition_rule | "partition", "overlay", "group" |
CompositeLayout.combine_op | "replace", "add", or None |
CompositeLayout MUST expose the composition rule and the combine operation as two
properties, with combine being None for non-overlay rules. They MUST NOT be
flattened into one string, and the raw combine byte MUST NOT be exposed for rules where
it means "not applicable".
Strides are in logical elements
StridedLayout.strides and the tiled layouts' outer_strides and inner_strides are
in logical elements, signed, and may be negative or zero — not in bytes as NumPy's
are.
Authoring
hurray.Tensor MUST accept a layout keyword holding a hurray.Layout instance,
mirroring quantization=. Omitting it means row-major. A non-Layout value MUST raise
TypeError.
A layout string MUST NOT be accepted. A string cannot carry nnz or strides, so
layout="csr" is a request that cannot be honoured; accepting it would create a second,
lossy authoring path.
The layout object is a declaration; the buffers are evidence. They MUST agree, and the constructor MUST check three tiers:
| Tier | Check | Error |
|---|---|---|
| Shape | rank and shape constraints (CSR rank 2, CSF rank ≥ 3, len(strides) == rank, …) | hurray.InvalidDescriptorError |
| Buffer count | supplied buffers ≥ the layout's required count; quantization buffer indices fall beyond them | hurray.InvalidDescriptorError |
| Buffer size | each buffer at least as large as the layout's parameters imply | hurray.BufferError |
A layout MUST NOT be inferred from the buffers, and the buffers MUST NOT be
reinterpreted to fit the layout. CooLayout(nnz=4) supplied with a two-element values
buffer MUST raise: the descriptor MUST NOT be silently corrected to nnz=2, and MUST
NOT be accepted as given, since it would encode and decode cleanly and hand the
consumer an out-of-bounds read. Over-sized buffers MUST be permitted; alignment and
padding slack are legitimate.
Consequently nnz MUST be a required argument on the sparse layout constructors.
Inference belongs to the array-shaped constructors — hurray.sparse_coo,
hurray.from_scipy — which are handed the arrays and can derive it.
For every constructible layout, rebuilding a tensor from another tensor's layout,
quantization, statistics, shard and buffers MUST produce an equal descriptor.
Tag classification
The module MUST expose hurray.layout_tag_kind(tag) -> str, returning "named",
"reserved", "private", or "invalid". The four MUST partition the whole byte space.
A single function rather than the four predicates hurray-core exposes: the caller's
purpose is to branch on the answer, and the four categories are mutually exclusive. The
distinction is actionable — a reserved tag suggests a producer newer than this reader, so
relaying the tensor is reasonable while interpreting its buffer is not; a private tag
refers to an out-of-band agreement; an invalid tag cannot appear in a conformant
descriptor at all.
Checking a layout against a shape
Layout.validate_against_shape(shape) MUST apply the layout's own rank and extent
constraints, raising hurray.InvalidDescriptorError when the pair cannot go together.
It MUST accept a dynamic dimension without complaint: an unresolved extent cannot
violate an extent constraint.
The result MUST agree with what hurray.Tensor enforces at construction — the two are
the same rule reached by two doors.
Block-paged validation
BlockPagedLayout MUST expose two checks:
validate_index_buffers(seq_ptr, block_table)— the four storage invariants ofblock-paged.md§ Storage.num_pagesandnum_seqscome from the layout, so only the buffers are passed. Aliasing MUST be accepted: two sequences naming one page is how a shared prefix is represented.validate_quantization_compatibility(quantization)— that a scheme and the paging can go together. It MUST take the quantization descriptor object, not a scheme tag.
Both raise hurray.InvalidDescriptorError. They matter because a descriptor describes
its index buffers rather than containing them: their contents are unchecked at
construction, and they are what stands between a consumer and an out-of-bounds read.
Element addressing (element_offset) is deliberately not exposed. Resolving one
logical coordinate at a time is indexing, which this package does not do.
Composite, private, and unknown
CompositeLayoutMUST be readable in full, so a composite head decoded from a stream reports its own layout truthfully. Constructing ahurray.Tensorwith a composite layout MUST raisehurray.UnsupportedError: a composite head owns no buffers, which the PythonTensorcannot represent.UnknownLayoutMUST be constructible, so a permissive relay can reconstruct a descriptor it decoded and write it back out. Its constructor MUST reject any tag for which a named variant exists — calling a known tag "unknown" would smuggle an unvalidated descriptor past every rank and buffer check.PrivateExtensionLayoutexposestag,extension_layout_id, andextension_data.
Known gap: because the buffer count of a private or unknown layout is unknowable, the buffer-count and buffer-size tiers cannot run for them. Nothing in such a descriptor states how many buffers it needs or how large they should be.
Composites
hurray-python MUST expose composite tensors through a hurray.Composite class
(ADR-036). A composite MUST NOT be a hurray.Tensor: a composite contains tensors
where a tensor has data, its head owns zero buffers, and len(members) has no
meaning on a tensor.
composite = hurray.Composite(
"partition", shape=[8, 8], dtype=hurray.float32, members=[tile0, tile1]
)
Construction
composition_rule—"partition","overlay", or"group".shapeanddtype— the logical view the head presents. Both are required and MUST NOT be derived from the members: a partition's shape could be computed from its members' shards, and deriving it would silently reshape the head to match a caller's miscomputed offset.members— a sequence ofhurray.Tensororhurray.Composite, since the format nests. Anything else MUST raiseTypeErrornaming the offending index.combine_op— required for"overlay", and MUST be rejected for the other rules, matchinghurray.CompositeLayout, which is the same field.member_countis taken frommembersrather than stated: it counts what was passed, so it cannot disagree with it.
Validation MUST delegate to hurray-core's CompositeValidator — per-member boxes,
partition coverage, overlay ordering, member count. The binding MUST NOT keep a second
copy of those rules. Failures MUST surface as hurray.InvalidDescriptorError.
Surface
| Member | Meaning |
|---|---|
members | the members, in wire order, as a tuple |
member_count | how many the head declares |
layout | a hurray.CompositeLayout |
shape, ndim, dtype | the head's logical view |
A Composite MUST compare by value — head and members alike — so that the round-trip
obligation can be stated as equality. Its repr MUST be depth-aware, since composites
nest.
Composite MUST NOT expose values, buffer, buffer_count, __array__, or
__dlpack__. There is no data to expose; the members hold it.
I/O
StreamWriter.writeMUST accept aCompositeand emit it as head-then-members.StreamReaderMUST yield aCompositeas one item. It MUST NOT surface a head and its members as separate items — that would lose the composition without raising.saveMUST accept aCompositeas a named entry;loadMUST return one.- Because the file container gives every tensor an index entry, a composite's members
MUST be named
"{head}.{index}", recursively. Those names are an artifact of the container, not of the composite, so they are generated rather than requested. loadMUST return a composite under its head's name and MUST NOT also return its members as top-level entries. A member requested explicitly by name MUST still be returned.
Overlay member roles
An overlay's members carry a role — the first is the base and spans the whole index
space, the rest are corrections. The format fixes this by position, so hurray.Composite
MUST derive the roles rather than accept them: a parameter would ask the caller to restate
what the rule already determines, and give them a way to state it wrongly.
This is not inference in ADR-032 § 4's sense. Nothing is read out of the buffers; the role follows from the composition rule and the member's position, both of which the caller stated.
A member that already carries a role — one decoded from the wire — MUST keep it, so a round trip cannot relabel it.
The roles MUST be readable as Composite.member_roles, a tuple of "base" /
"correction" / None, and MUST NOT be exposed on the member tensor: a tensor has no
role, a tensor within an overlay does. Reading from the composite also makes an authored
overlay and a decoded one answer identically.
Because the role lives on the composite rather than on the caller's tensor, the descriptor a member presents to its parent differs from the one on its tensor. Implementations MUST use the former when validating, when writing, and when comparing composites for equality — a composite that does not equal its own round trip is the symptom of getting this wrong.
Not on the native protocol
Composite MUST NOT implement __hurray__. That capsule carries one tensor — one
descriptor and its buffers; a composite is a tree, and flattening one would require
wire structure the format does not define. hasattr(obj, "__hurray__") therefore keeps
meaning what it means. Use the streaming or file path, which the format defines for
exactly this.
Streaming
hurray-python MUST expose the streaming interchange format through two classes
(ADR-035). The format's defining property is that neither side buffers the whole
sequence, and the Python surface MUST preserve it: a reader MUST yield an item as soon
as it is complete — a tensor once its buffers have arrived, a composite at its last
member — and a writer MUST emit each item as it is given one.
with hurray.StreamWriter(destination) as writer:
writer.write(tensor)
for item in hurray.StreamReader(source):
...
Blocking
Both classes are blocking and MUST release the GIL around every call that touches
the transport, so other Python threads run while a stream waits on it. How the bytes are
moved is not specified here: the reference binding drives a hurray-io sans-io core over
blocking I/O (ADR-041 § 9), having previously given each object its own tokio runtime,
and neither is a requirement on an implementation. An asyncio surface is a second,
additive surface; see § Asyncio surface below, ADR-035 § 1 and
ADR-041 § 9.
hurray.StreamReader
- MUST implement
__iter__returning itself and__next__yielding ahurray.Tensoror ahurray.Composite, raisingStopIterationat a clean end of stream. - MUST implement the context-manager protocol, and expose
close(), so the transport can be released without waiting for collection. A closed reader MUST behave as exhausted rather than raise. - MUST read with
next_itemsemantics, notnext_tensor, so that a composite arrives as one item: a composite head owns no buffers, so reading it as an ordinary tensor would yield an empty tensor and then surface the head's members as top-level tensors — a stream that decoded "successfully" having lost the composition.
hurray.StreamWriter
- MUST accept a
hurray.Tensorinwrite, transmitting every buffer its descriptor references, in descriptor order. - MUST accept a
hurray.Compositeinwrite, emitting the head and then its members, recursively, in the wire order ADR-027 § Binding fixes. Any other argument MUST raiseTypeError, not ahurray.*exception: nothing reached the transport, so nothing about the stream is wrong. - MUST implement the context-manager protocol, finishing the stream on exit.
finish()MUST also be callable explicitly and MUST be idempotent, so the two paths compose. - MUST raise
hurray.StreamErrorwhen written to after finishing. - With no destination, MUST build the stream in memory and return it from
getvalue().getvalue()on a writer that has a destination MUST raisehurray.StreamError.
Reader limits
hurray.StreamReader MUST accept keyword arguments bounding what a single frame may
claim: max_descriptor_bytes, max_buffer_bytes, and max_composite_depth, each
defaulting to hurray-io's own default. Exceeding one MUST raise hurray.StreamError.
These are not optional hygiene. A descriptor's length field is read before its contents, so a stream from an untrusted peer can ask a reader to allocate an arbitrary amount.
They MUST be keyword arguments rather than an options object: Python has keyword arguments, and an options class here would be a Rust shape in Python clothing.
Cross-machine transport
hurray.StreamReader and hurray.StreamWriter MUST accept cross_machine: bool = False,
enforcing that every buffer's sync_mode is producer_synced. A device event or stream
handle is meaningless on the far side of a network, so a descriptor that carries one
across is unsatisfiable by construction; the check refuses it at the boundary rather than
leaving a consumer to wait on an event that does not exist.
Transports
| Argument | Meaning |
|---|---|
str | a filesystem path |
an object with fileno() | a socket, a pipe, or an open file |
bytes (reader only) | an in-memory stream |
| omitted (writer only) | build in memory; retrieve with getvalue() |
A file descriptor MUST be duplicated, so that finishing or closing the stream does not
close the caller's. An object with no descriptor — io.BytesIO — is not accepted
directly; the TypeError raised MUST say so, since the fix (getvalue()) is not
guessable from a bare type error.
Exceptions
| Failure | Exception |
|---|---|
| framing — truncated, malformed, or oversized frame | hurray.StreamError |
| the descriptor did not decode or validate | hurray.InvalidDescriptorError |
| the transport failed | hurray.FileError |
Note (non-normative): a stream has no end marker — frames are self-delimiting and the stream ends at EOF, which is the property that also forbids end-of-file indexes. A stream truncated mid-frame therefore raises
hurray.StreamError, while one truncated exactly on a frame boundary is indistinguishable from a shorter stream and yields no error. That is a property of the format, not of the binding.
File metadata
hurray.load_kv(path) -> dict MUST return a file's key-value section, and MUST be the
exact inverse of what save(path, tensors, kv=...) writes: a dict saved and reloaded
MUST compare equal. A file with no KV section MUST read as an empty dict.
It MUST be a separate call rather than an argument to load, which answers a different
question and returns a different thing.
bool MUST be tested before int in both directions, since Python's bool is a subclass
of int. Python's int writes as int64, so a value written from Python never carries
the uint64 tag; a uint64 written by another producer MUST read back as int.
A KvValue variant this build does not recognise MUST raise rather than being dropped:
a metadata dict with a silent hole in it is worse than a refusal.
Writer output
StreamWriter.getvalue() MUST be repeatable, like io.BytesIO.getvalue(). A destructive
implementation makes the second call return empty, which turns a later read into a
clean end of stream — data loss reported as success.
Asyncio surface
On Unix, hurray-python MUST expose a second streaming surface, hurray.aio, holding
hurray.aio.StreamReader and hurray.aio.StreamWriter (ADR-042). It is additive: the
blocking classes above keep their behaviour, and the async classes drive the same stream
state machines over the same wire format.
- For the same items, the async writer's output MUST be byte-identical to the blocking writer's (ADR-042, Consequences). There is one implementation of the protocol with two drivers over it, not two protocols.
- Everything the blocking surface can stream, the async surface MUST be able to stream, composites included.
- The surface MUST be driven by the caller's running event loop. It MUST NOT link an executor of its own, and MUST NOT drive the blocking transport vtable of ADR-041 § 8 (ADR-041 § 9): one core, two drivers, no blocking call inside a coroutine.
- § Exceptions applies unchanged — framing failures raise
hurray.StreamError, a descriptor that did not decode raiseshurray.InvalidDescriptorError, and a failed transport raiseshurray.FileError.
How the driver is divided between Python source and the extension is not specified here; the reference binding writes the readiness logic in Python over a private seam (ADR-042 § 1), and that seam is not part of this contract.
Import and platform
import hurrayMUST NOT importhurray.aio. A caller MUST reach the surface with an explicitimport hurray.aio.import hurrayhas to succeed on every platform the package ships wheels for, and this surface does not exist on all of them.- The surface is Unix-only. Importing
hurray.aioon a platform where it is unavailable MUST raisehurray.UnsupportedError, naming the restriction and naming the blocking classes as what works everywhere. It MUST NOT surface as anImportErroron a private name: the obstacle is a platform decision (issue #215), not a missing module.
Readiness, not transfers
Every await in this surface MUST wait for the descriptor to become readable or
writable, and MUST NOT cover a transfer.
- A transfer MUST move the bytes and report the byte count to the core in one synchronous step, with no suspension point between the two (ADR-041 § 6a, ADR-042 § 2).
- A driver MUST NOT await a transfer. This excludes
loop.sock_recv_intoandloop.sock_sendall: each performs its transfer inside a loop callback and then resolves a future, so a cancellation landing between those two events loses the byte count. A lost count is unrecoverable —0x00is valid tensor data, so no inspection of the stream can recover the position. - The apparent escape MUST NOT be used either. Reading through
asyncio.StreamReaderkeeps cancelled data in asyncio's own buffer and so cannot lose a count, but it hands back a byte sequence that must then be copied into the core's destination. That is the second, tensor-sized payload copy ADR-041 § 2 exists to forbid; resumability MUST NOT be bought with it. - A destination the core owns MUST NOT be exposed to Python, as a
memoryviewor otherwise. The core replaces that destination when a buffer completes, so a view that outlived one step would be a use-after-free (ADR-042 § 2). Keeping the transfer below the Python boundary is what prevents such a view from existing, rather than documenting that callers must not keep one. - A transport that reports not ready —
EAGAIN, or a syscall interrupted by a signal — MUST NOT be reported to the core as a transferred count of zero; the driver MUST wait for readiness again. On the read path a reported zero means end of input, so reporting a spurious wakeup as zero turns it into a stream that appears to end cleanly mid-item. - On the write path, a transfer that accepts zero bytes on a descriptor the loop reported
writable MUST be treated as a closed sink, not retried. It MUST raise
hurray.StreamError, and it MUST leave the writer poisoned: the item in flight can no longer be completed.
Because the transfer cannot block — the descriptor is non-blocking and the loop has already reported it ready — § Blocking's GIL-release requirement does not extend here. An implementation MAY hold the GIL across an async transfer.
Note (non-normative): this is the one rule in this section whose violation is silent. A binding built on
loop.sock_recv_intopasses every test that is not a cancellation test, and loses a byte count only under cancellation, where nothing can detect it afterwards.
Cancellation
Cancellation is observable only at a readiness await, which is what the rule above buys.
Its consequences are not symmetrical, and both halves are contract.
- A cancelled read MUST leave the reader usable. No byte moved at the only cancellation point the read path has, so the next call resumes the same item and reads it through (ADR-041 § 6). A partially filled destination MUST NOT be observable: it belongs to the core until the item completes, so cancellation can never hand a caller a half-written tensor.
- A cancelled write MUST poison the writer if, and only if, the item had already put a byte on the wire (ADR-041 § 6b). The item is truncated at the peer, a stream has no resynchronisation point, and no forward-only writer can make that output conforming again. A write cancelled before its first byte MUST leave the writer intact, and the same item MUST be writable again.
- A poisoned writer MUST refuse every later
writewithhurray.StreamError, and the message MUST state that a fresh writer over the same connection is not a recovery: the truncation is at the peer, which must be told, or the connection replaced. asyncio.CancelledErrorMUST propagate. An implementation MUST NOT convert it to ahurray.*exception and MUST NOT swallow it. It MUST drop the operation in flight before re-raising, so that the core's own record of whether a byte was emitted is what answers the question above — a driver MUST NOT keep a second copy of that predicate.
The reader
hurray.aio.StreamReader is § hurray.StreamReader's contract with an event loop in
place of a blocking thread. It MUST:
- implement
__aiter__returning itself and__anext__yielding ahurray.Tensoror ahurray.Composite, raisingStopAsyncIterationat a clean end of stream; - expose
next_item()as an awaitable returning the next item, orNoneat a clean end of stream, with__anext__layered on it. The primitive is named because outside anasync fora caller wants the end of stream as a value, not as an exception; - read with
next_itemsemantics, so that a composite arrives as one item, for the reason §hurray.StreamReadergives; - implement the asynchronous context-manager protocol, and expose
close(). A closed reader MUST behave as exhausted rather than raise.close()MUST NOT be a coroutine: it releases a descriptor and has nothing to wait for; - accept
max_descriptor_bytes,max_buffer_bytes,max_composite_depthandcross_machineas keyword arguments, with the same defaults and the same meaning § Reader limits and § Cross-machine transport require of the blocking reader. The limits are not optional hygiene on this surface either, and an untrusted peer is the case an event loop is reached for.
The writer
hurray.aio.StreamWriter MUST:
- accept a
hurray.Tensoror ahurray.Compositein an awaitablewrite, transmitting every buffer the item references in descriptor order and, for a composite, the head and then its members recursively in the wire order ADR-027 § Binding fixes. Any other argument MUST raiseTypeError, not ahurray.*exception, for the reason §hurray.StreamWritergives; - expose
finish()as an idempotent awaitable that releases the descriptor, and implement the asynchronous context-manager protocol, finishing the stream on exit; - raise
hurray.StreamErrorwhen written to after finishing; - accept
cross_machineas § Cross-machine transport requires; - hold a strong reference to every object that owns payload bytes for as long as the item is in flight, releasing it when the item completes or is abandoned and not before (ADR-041 § 6). The async writer borrows the payload across suspensions, so a source tensor or composite MUST NOT become collectable mid-item;
- refuse a second
writesubmitted while one is in flight withhurray.StreamError. One stream object is driven by one task; concurrency across streams is what this surface exists for, andasyncio.gatherover several readers or writers already expresses it.
Note (non-normative):
finish()flushes nothing. Every byte of an accepted item has already left through a write on the descriptor, so finishing releases the descriptor and nothing else — earlier than collection would, which is the whole of its value here.
Transport and the descriptor
The async surface accepts a narrower transport than § Transports, and each exclusion is a refusal with a named exception rather than a caveat:
| Argument | Async surface |
|---|---|
an object with fileno() naming a socket or a pipe | accepted |
an object with no fileno() — a str path, bytes, io.BytesIO | TypeError |
an object whose fileno() names a regular file | hurray.UnsupportedError |
| omitted (writer) | not accepted: no in-memory mode, no getvalue() |
- A path, a byte sequence, or an in-memory destination has no readiness to await. The
TypeErrorMUST name the blocking class as the thing to use instead, since the fix is not guessable from a bare type error — the same requirement § Transports makes forio.BytesIO. - A regular file MUST be refused with
hurray.UnsupportedError: an event loop cannot poll one, and a transfer against one does not block, so readiness has nothing to contribute. - The descriptor MUST be duplicated, as § Transports requires, so that finishing or closing the stream does not close the caller's. Readiness MUST be awaited on the same descriptor the transfers use, so that the two cannot drift apart.
- The descriptor MUST be put in non-blocking mode, and implementations MUST document that
this is observable through the object the caller passed in:
O_NONBLOCKbelongs to the open file description, whichdupshares. Any descriptor an event loop drives has to be in that mode regardless.
No async file API
hurray.load, hurray.save and hurray.load_kv MUST NOT be given hurray.aio
counterparts. asyncio has no asynchronous random access, so a coroutine over them would
be the synchronous call with a thread behind it — which asyncio.to_thread already
expresses in one line, at a cost the caller can see (ADR-042 § 2). The absence is a
decision, not a gap.
Error Handling
All errors from the Rust core MUST be surfaced as Python exceptions:
| Rust error | Python exception |
|---|---|
| Parse / validation errors | hurray.InvalidDescriptorError (subclass of ValueError) |
| Buffer size / alignment errors | hurray.BufferError (subclass of ValueError) |
| Unsupported type or layout | hurray.UnsupportedError (subclass of NotImplementedError) |
| File I/O errors (Layer 8b) | hurray.FileError (subclass of OSError) |
| Stream I/O errors (Layer 8b) | hurray.StreamError (subclass of OSError) |
hurray.FileError is raised by file-level operations (hurray.load(),
hurray.save()): file not found, permission denied, corrupt HRRYFILE container,
unexpected EOF.
hurray.StreamError is raised by the streaming reader/writer: frame corruption
mid-stream, unexpected stream termination, framing errors on a pipe or socket.
Both FileError and StreamError are subclasses of OSError; callers that do
not need to distinguish between the two MAY catch OSError directly.
FileError and StreamError are introduced in Layer 8b (file I/O bridge) and
are not present in Layer 8a (core types + DLPack).
Panics from the Rust core MUST NOT propagate as Python crashes. The PyO3 binding
layer MUST catch panics and convert them to hurray.InternalError (subclass of
RuntimeError).
hurray.from_hurray (Layer 8c) MUST raise:
hurray.BufferErrorif the capsule is null, already consumed (named"used_hurray_tensor_handle"), or otherwise invalid.hurray.UnsupportedErrorif theHURRAY_C_ABI_VERSIONthe capsule'sHurrayTensorreports does not match the consumer's linked version.
Conformance and Validation
hurray-python is validated against the shared golden test-vector corpus
(conformance/vectors/), the same corpus the Rust implementation is checked against,
plus the binding's own unit and integration tests.
- The Python binding MUST decode every descriptor and buffer in the golden corpus to
the same logical values as the Rust reference, and MUST re-encode round-trippable
vectors to byte-identical output. This is exercised in CI (see the
python-conformancejob). - The
array-api-testssuite is not used: it targets a whole conforming Array API namespace and presupposes the compute core, whichhurray-pythondeliberately does not implement (see ADR-029). Cross-checking against the golden corpus maps directly onto the parts of the Hurray specification the binding actually covers. - New behaviour MUST ship with tests that exercise the public interop surface (DLPack,
NumPy/PyTorch bridges, the native protocol,
save/load).
Benchmark Suite
hurray-python SHOULD maintain a benchmark suite that measures performance across the
Hurray interchange and interop paths — the operations that define the binding's
purpose: constructing tensors, moving buffers zero-copy, and serializing/parsing the
Hurray format.
Benchmark categories
The suite SHOULD cover the following categories:
| Category | Representative benchmarks |
|---|---|
| DLPack capsule | __dlpack__() round-trip (create + consume); capsule destructor overhead; from_dlpack() from NumPy and PyTorch. |
| NumPy interop | from_numpy() on an aligned source (shared) and an under-aligned one (copied); __array__() (zero-copy); dtype coverage (all Tier 1 types). |
| PyTorch interop | from_torch() and to_torch() round-trip on CPU and CUDA. |
| Native protocol | __hurray__() / from_hurray() round-trip (full-fidelity, all dtypes). |
| Construction | zeros, ones, full, arange, linspace for representative shapes and dtypes. |
| Serialization | save/load and streaming read/write throughput (GiB/s) for representative tensors. |
| Memory lifecycle | Tensor allocation + deallocation throughput; large-tensor zero-copy overhead (GiB-scale). |
| Sparse layouts | COO/CSR/CSC construction; SciPy round-trip; file round-trip. |
Tooling
- Benchmarks MUST be runnable via a standard Python benchmarking tool (e.g.,
pytest-benchmarkorairspeed-velocity (asv)). - Benchmarks SHOULD report: mean, standard deviation, and minimum latency; throughput in GiB/s for memory-bound operations.
- Regression tracking (detecting performance regressions across commits) SHOULD be automated in CI. Benchmarks that regress by more than 10% relative to the baseline SHOULD trigger a warning in the pull request.
- The benchmark suite MUST be runnable independently from the validation tests.
Compatibility Matrix
The hurray-python package MUST maintain a compatibility matrix document at
hurray-python/COMPAT-MATRIX.md. This document lives alongside the code — not in
the spec — because it changes with every release.
The compatibility matrix MUST record, for each hurray-python release series:
- The DLPack specification version(s) supported as producer and as consumer.
- The supported CPython version range.
- The Hurray format/descriptor version(s) the binding produces and consumes.
The matrix MUST be updated whenever a new hurray-python release changes any of these.
Packaging
- The package MUST be installable via
pip install pyhurray. The distribution name and the import name differ: the module imported MUST behurray. - Wheels MUST be provided for CPython ≥ 3.10 on Linux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64).
- The package MUST NOT require a Rust toolchain at install time (pre-built wheels are mandatory for distribution).
- Optional dependencies:
numpy,torch,scipy(none required at import time; interop functions raiseImportErrorif the target library is not installed).
Quickstart
Build a tensor, encode it to the Hurray wire format, and read it back — in Rust
with hurray-core, or in Python with the hurray package. Use the tabs to switch
languages; your choice is remembered across the book.
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Describe a float32 [2, 3] row-major tensor.
let shape = Shape::new(vec![2u64, 3])?;
let buffer = BufferHandle::new(
24, // 6 elements × 4 bytes
MIN_BUFFER_ALIGNMENT, // 64-byte SIMD alignment
DeviceTag::Cpu,
SyncMode::ProducerSynced,
)?;
let desc = TensorDescriptor::new(
1, 0, // descriptor format version 1.0
ElementType::Float32,
shape,
0, // byte_offset to element [0, 0]
LayoutDescriptor::RowMajor,
vec![buffer],
None, None, None, None, // no quantization / shard / statistics / extension-type
)?;
// Encode to the self-delimiting wire format …
let bytes = desc.encode()?;
println!("descriptor: {} bytes", bytes.len());
// … and decode it straight back.
let decoded = TensorDescriptor::decode(&bytes)?;
assert_eq!(decoded, desc);
println!(
"shape = {:?}, dtype = {:?}",
decoded.shape.dims(),
decoded.element_type,
);
Ok(())
}
import os
import tempfile
import numpy as np
import hurray
# Build a float32 [2, 3] tensor, zero-copy from a NumPy array.
arr = np.arange(6, dtype=np.float32).reshape(2, 3)
t = hurray.from_numpy(arr)
print("shape =", t.shape, "dtype =", t.dtype, "device =", t.device)
# Hand it back to NumPy zero-copy via DLPack (dense Tier-1 tensors share the buffer).
view = np.from_dlpack(t)
assert np.array_equal(view, arr)
# Round-trip through the Hurray file format.
path = os.path.join(tempfile.gettempdir(), "quickstart.hrry")
hurray.save(path, {"x": t})
loaded = hurray.load(path)
print("loaded:", list(loaded.keys()), "→", loaded["x"].shape)
os.unlink(path)
What just happened
- A tensor descriptor carries everything needed to interpret a buffer: element
type, shape, byte offset, memory layout, buffer handles, and optional sections
(quantization, shard, statistics, extension type). The four trailing
Nones in the Rust call are those optional sections. encodeproduces the self-delimiting binary descriptor — the first 10 bytes give its total length, so a reader can consume it without any external framing.decodereverses it exactly (decoded == desc).- On the Python side,
np.from_dlpackis zero-copy: the tensor and the array share one buffer.from_numpyshares too when the array's address meets the format's 64-byte alignment floor, and copies into an aligned allocation when it does not — see Buffer Protocol.save/loaduse the on-disk HRRYFILE container (named tensors, footer index, mmap-friendly alignment).
Where to next
- Framework Interop — zero-copy hand-off to NumPy, PyTorch, JAX, and CuPy.
- Quantized Inference — attach and round-trip quantization descriptors.
- IPC and Streaming Interchange — move tensors between a producer and a consumer.
- The Layer walkthroughs (Layer 0 onward) cover each part of the format in depth.
Framework Interop
hurray-python is a codec and zero-copy bridge, not a compute library: you move a
tensor into NumPy, PyTorch, JAX, or CuPy and do the math there. For dense Tier 1 tensors
the hand-off is DLPack, which every major array framework speaks.
The universal path: DLPack
Any framework whose from_dlpack accepts an object exposing __dlpack__ can consume a
Hurray tensor with no copy:
import numpy as np
import hurray
t = hurray.from_numpy(np.arange(6, dtype=np.float32).reshape(2, 3))
arr = np.from_dlpack(t) # NumPy, zero-copy — shares t's buffer
arr[0, 0] = 42.0
assert hurray.from_numpy(arr) # the write is visible through the shared buffer
DLPack also carries the tensor's device: t.__dlpack_device__() returns the
(DLDeviceType, device_id) pair, so a consumer sends data to the right place.
NumPy
# Ingest a NumPy array zero-copy (C-contiguous):
t = hurray.from_numpy(np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32))
# Export back to NumPy — via DLPack, or the __array__ protocol (with optional cast):
a = np.from_dlpack(t)
a64 = t.__array__(dtype=np.float64) # __array__ may copy when casting
PyTorch
DLPack works directly (torch.from_dlpack(t)), and hurray ships one-call conveniences:
import torch
torch_t = t.to_torch() # hurray.Tensor → torch.Tensor, zero-copy
back = hurray.from_torch(torch_t) # torch.Tensor → hurray.Tensor, zero-copy
JAX
import jax.numpy as jnp
x = jnp.from_dlpack(t) # zero-copy on the same device
CuPy (GPU)
For a Hurray tensor already on a CUDA device (device_tag = CUDA), CuPy shares the
device buffer — no host round-trip:
import cupy as cp
g = cp.from_dlpack(t) # device-to-device, zero-copy
What DLPack cannot carry
DLPack only describes dense, strided, standard-dtype tensors. Some things fall outside it:
bool— Hurray packs it 1 bit per element; DLPack'sboolis 1 byte, so there is no zero-copy mapping. Use__array__/from_numpyinstead.bfloat16— no native NumPy dtype; it crosses to PyTorch/JAX via DLPack but not to plain NumPy.- Everything beyond dense Tier 1 — sparse layouts, quantized and sub-byte element types, tiled/Morton/Hilbert/composite layouts. DLPack has no vocabulary for these.
For those, use Hurray's own full-fidelity protocol.
The native protocol
__hurray__ / hurray.from_hurray exchange the entire tensor
descriptor — quantization, sparse and exotic layouts, sub-byte types, device and sync
metadata — between Hurray-aware components, zero-copy:
capsule = t.__hurray__() # full-fidelity, all dtypes
u = hurray.from_hurray(t) # reconstruct from any object exposing it
What adoption would unlock (non-normative). Today only
hurray-pythonimplements__hurray__, so full-fidelity exchange is Hurray-to-Hurray. If a framework adopted the protocol, the copies that live at the edges today would disappear. For example,hurray.from_scipy/hurray.sparse_coocurrently repack SciPy's separaterow/colarrays into Hurray's packed[nnz, rank]layout (one interleave copy); a SciPy that spoke__hurray__could hand its sparse structure across without that copy. The same applies to quantized and sub-byte tensors, which have no DLPack representation at all — a consumer implementing the native protocol could receive them directly instead of falling back tosave/load.
See also
- Quickstart — the shortest path in and out of a tensor.
- Python: DLPack and NumPy Interop — more on the DLPack
capsule and
__array__details. - Python: Native Interchange Protocol — the capsule lifetime
and ABI-version rules behind
__hurray__. - Python: Sparse Tensors and SciPy — CSR/CSC/COO construction, including where the edge copies occur.
Quantized Inference
Hurray treats quantization as first-class metadata, orthogonal to the storage type: a tensor is quantized if and only if it carries a quantization descriptor. Five schemes are normative — per-tensor affine, per-channel affine, per-block affine, NF4 (QLoRA), and MXFP (OCP Microscaling) — and the quantization parameters (scales, zero-points) live in separate buffer-table entries, never interleaved with the data, so both stay zero-copy.
This recipe builds a per-block-affine int4 weight tensor in Rust, round-trips it, and
reads the scheme back.
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerBlockAffine,
QuantizationDescriptor, Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A [256, 256] int4 weight matrix, per-block affine along axis 1, block_size 64.
let shape = Shape::new(vec![256u64, 256])?;
// Buffer 0 — packed int4 data: 256 × 256 values, 2 per byte = 32,768 bytes.
let data = BufferHandle::new(
32_768, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
// Buffer 1 — float32 scales, one per block: 256 rows × (256 / 64) = 1024 scales.
let scales = BufferHandle::new(
1024 * 4, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
// Per-block affine (symmetric): axis 1, block_size 64, scales in buffer index 1,
// scale element type float32.
let quant = QuantizationDescriptor::PerBlockAffine(
PerBlockAffine::new_symmetric(1, 64, 1, ElementType::Float32)?,
)
.encode_to_vec();
let desc = TensorDescriptor::new(
1, 0,
ElementType::Int4, // storage type is orthogonal to the scheme
shape,
0,
LayoutDescriptor::RowMajor,
vec![data, scales], // data + scale buffers
Some(quant), // HAS_QUANTIZATION
None, None, None, // no shard / statistics / extension-type
)?;
// Round-trip the descriptor.
let bytes = desc.encode()?;
let decoded = TensorDescriptor::decode(&bytes)?;
assert_eq!(decoded, desc);
// Read the scheme back from the (raw) quantization section.
let (q, _) = QuantizationDescriptor::decode(decoded.quantization.as_ref().unwrap())?;
println!("scheme tag = 0x{:02X}", q.scheme_tag().tag()); // 0x03 = per-block-affine
if let QuantizationDescriptor::PerBlockAffine(pb) = q {
println!("axis = {}, block_size = {}", pb.axis(), pb.block_size());
}
Ok(())
}
Dequantization
The scheme's dequantization formula is normative (see
Layer 2: Quantization Descriptors and
docs/spec/quantization.md). For per-block affine, each element uses the scale (and, for
asymmetric, the zero-point) of the block it belongs to:
value = (q_code - zero_point) * scale[block_index]
For a symmetric descriptor the zero-point is implicitly 0, so value = q_code * scale[block]. The block index is derived from the element's coordinate on the quantized
axis and block_size.
Other schemes
- NF4 (QLoRA) uses a fixed 16-level lookup table —
hurray_core::NF4_LUT— plus a per-block scale:QuantizationDescriptor::Nf4(Nf4::new(axis, block_size, scale_buffer)). - MXFP (OCP Microscaling) pairs an 8-bit shared exponent per block with the element
micro-floats:
QuantizationDescriptor::Mxfp(Mxfp::new(axis, block_size, scale_buffer)). - Per-tensor / per-channel affine cover the classic INT8 cases
(
PerTensorAffine::new,PerChannelAffine::new_symmetric/new_asymmetric).
hurray_core::validate_buffer_placement checks that the scale/zero-point buffer indices a
descriptor references actually exist in the buffer table.
Reading quantized tensors elsewhere
Python authors and reads every scheme — per-tensor, per-channel, per-block, NF4 and MXFP — and the multi-buffer descriptors the last four need. See Authoring Quantized Tensors for the constructors and Python: Layouts for how a tensor carries its parameter buffers.
import struct
import hurray
weight_bytes = bytes(1024 * 512) # your quantizer's output
scale_bytes = struct.pack("1024f", *[0.02] * 1024) # one float32 scale per row
weights = hurray.Tensor(
weight_bytes,
hurray.dtype.int8,
[1024, 512],
aux_buffers=[scale_bytes],
quantization=hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1),
)
assert weights.quantization.axis == 0
What Python does not do is dequantize: applying the formula above is the consuming framework's job, not the codec's.
To inspect a quantized descriptor byte by byte (scheme, axis, block size, buffer indices), use the CLI:
hurray-inspect weights.hrry
See hurray-inspect CLI; it decodes every quantization scheme and formats Tier 2 / sub-byte element values.
Multi-Buffer Tensors
Most tensors have one buffer. Anything whose descriptor references a second one has more:
| Feature | Extra buffers |
|---|---|
| Per-channel affine quantization | scale buffer, optional zero-point buffer |
| NF4 / MXFP quantization | scale buffer |
| COO sparse | index array |
| CSR / CSC sparse | primary and secondary index arrays |
| Block-paged | page table |
Per-tensor affine quantization is the exception: its scale and zero_point are
inline in the descriptor, so a per-tensor-affine tensor still has exactly one buffer.
The rule that makes this work is positional: element i of the transport is buffer
index i of the descriptor's buffer table. A scale_buffer_index of 1 means the
second buffer handed over, whatever the transport. Drop a buffer along the way and the
descriptor still decodes cleanly — it just points at something that was never
delivered.
Carrying every buffer in process
__hurray__ puts all of a tensor's buffers in one capsule wrapping a
HurrayBufferList (ADR-030). One capsule, one lifetime, one destroy.
Sparse is not a special case here — it is simply the multi-buffer case, which is why
there is no separate __hurray_sparse_buffer__ to probe for.
use hurray_ffi::buffer::{hurray_buffer_byte_size, hurray_buffer_from_ptr};
use hurray_ffi::buffer_list::{
hurray_buffer_list_destroy, hurray_buffer_list_get, hurray_buffer_list_len,
hurray_buffer_list_new, hurray_buffer_list_push,
};
use hurray_ffi::{HurrayBuffer, HurrayBufferList, HURRAY_OK};
#[repr(align(64))]
struct Aligned([u8; 64]);
fn main() {
let mut weights = Aligned([0xAB; 64]);
let mut scales = Aligned([0x01; 64]);
let mut list: *mut HurrayBufferList = std::ptr::null_mut();
// SAFETY: out-pointer is a valid stack variable.
unsafe { hurray_buffer_list_new(2, &mut list) };
// Push order is descriptor buffer-table order: weights are index 0, the
// per-channel scales index 1 — what scale_buffer_index refers to.
for data in [&mut weights, &mut scales] {
let mut handle: *mut HurrayBuffer = std::ptr::null_mut();
// SAFETY: data is 64-byte aligned and 64 bytes long.
unsafe {
hurray_buffer_from_ptr(
data.0.as_mut_ptr().cast(),
64,
64,
0x00, // CPU
0x00, // ProducerSynced
0x00, // Standard
None,
std::ptr::null_mut(),
&mut handle,
);
// Push transfers ownership of the handle to the list.
hurray_buffer_list_push(list, handle);
}
}
let mut len: u64 = 0;
// SAFETY: list is live.
unsafe { hurray_buffer_list_len(list, &mut len) };
assert_eq!(len, 2);
for index in 0..len {
let mut borrowed: *mut HurrayBuffer = std::ptr::null_mut();
// SAFETY: list is live and index < len. The handle is BORROWED — the list
// owns it, so it must not be destroyed here.
unsafe { hurray_buffer_list_get(list, index, &mut borrowed) };
let mut byte_size: u64 = 0;
// SAFETY: borrowed is a live handle owned by the list.
unsafe { hurray_buffer_byte_size(borrowed, &mut byte_size) };
println!("buffer[{index}]: {byte_size} bytes");
}
// Destroys the list and every handle it owns, exactly once, then nulls the
// caller's pointer — so a second destroy is a safe no-op.
// SAFETY: first and only destroy.
unsafe { hurray_buffer_list_destroy(&mut list) };
assert!(list.is_null());
}
import numpy as np
import hurray
# A COO tensor keeps values in one buffer and coordinates in another.
values = np.array([5.0, 7.0], dtype=np.float32)
indices = np.array([[0, 0], [1, 1]], dtype=np.uint64) # [nnz, rank]
sparse = hurray.sparse_coo(values, indices, [2, 2])
# One protocol for every tensor kind — probe exactly as for a dense tensor.
assert hasattr(sparse, "__hurray__")
assert not hasattr(sparse, "__hurray_sparse_buffer__")
capsule = sparse.__hurray__()
# The consumer receives the full descriptor with every buffer attached, in
# descriptor order: values first, then the index array.
back = hurray.from_hurray(sparse)
assert back.shape == (2, 2)
assert back.dtype == hurray.float32
Ownership: one owner, everything else borrowed
The list owns every handle in it. hurray_buffer_list_get returns a borrowed
pointer: the caller reads it and must not destroy it. Destroying the list destroys
every handle exactly once.
This is the same discipline as Arrow's C Data Interface, where a consumer releases the base structure but never its children. Getting it wrong in the other direction — a consumer destroying a borrowed handle — double-frees when the list is destroyed.
hurray_buffer_list_destroy takes a pointer to your pointer and writes null
through it:
use hurray_ffi::buffer_list::{hurray_buffer_list_destroy, hurray_buffer_list_new};
use hurray_ffi::HurrayBufferList;
let mut list: *mut HurrayBufferList = std::ptr::null_mut();
unsafe { hurray_buffer_list_new(0, &mut list) };
// SAFETY: list is live; first and only destroy.
unsafe { hurray_buffer_list_destroy(&mut list) };
assert!(list.is_null());
// Idempotent: destroying an already-nulled pointer does nothing.
// SAFETY: *list is null, treated as a no-op.
unsafe { hurray_buffer_list_destroy(&mut list) };
Nulling the caller's variable is the sound half of Arrow's "release marks the structure released" trick. Arrow can leave a marker inside the struct because the consumer owns that memory and it outlives the call; here the list allocation is freed, so the only memory that can safely be marked is the caller's own pointer.
Files
hurray.save() writes every buffer of a tensor, and hurray.load() reads them back in
descriptor order. A multi-buffer tensor round-trips through a .hrry file byte for
byte.
A tensor whose buffer count disagrees with its descriptor's buffer table is rejected on both paths, rather than producing a tensor whose buffer indices do not resolve.
Version check
The capsule shape changed in C ABI version 3. A consumer built against version 2 that
receives a version 3 capsule raises hurray.UnsupportedError instead of misreading a
HurrayBufferList as a HurrayBuffer:
import hurray
# Producer and consumer must agree on the ABI version; the check happens before
# the capsule pointer is ever dereferenced.
tensor = hurray.Tensor(bytes(16), hurray.float32, [4])
received = hurray.from_hurray(tensor) # UnsupportedError on mismatch
What this does not yet do
The transport carries any number of buffers, but Python cannot yet author a quantization descriptor, so a per-channel-quantized tensor cannot be built from Python even though it now travels correctly once built. Descriptor-authoring classes are the next step.
See also
- Native Interchange Protocol — the single-buffer basics and how the protocol compares to DLPack
- Quantized Inference — which schemes need a scale buffer
- Sparse Tensors with SciPy — building COO/CSR/CSC tensors
cargo run --example buffer_list -p hurray-ffipython hurray-python/examples/multi_buffer.py
Authoring Quantized Tensors
Hurray describes quantization; it does not compute it. These types package scales and zero points you already have — from your own quantizer, or from a model you are converting — into a descriptor any Hurray reader understands.
Where the parameters live
| Scheme | Parameters | Buffers |
|---|---|---|
| Per-tensor affine | one scale + zero point, inline in the descriptor | 1 |
| Per-channel affine | one scale per slice along an axis | 2 (+1 if asymmetric) |
| Per-block affine | one scale per block of block_size | 2 (+1 if asymmetric) |
| NF4 | one scale per block | 2 |
| MXFP | one shared exponent per block | 2 |
Only per-tensor affine keeps its parameters in the descriptor. Every other scheme puts them in a separate buffer and refers to it by index — so building one means supplying the parameter bytes and the index that points at them.
Building one
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerChannelAffine,
QuantizationDescriptor, Shape, SyncMode, TensorDescriptor,
DESCRIPTOR_VERSION_MAJOR, DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = |len: u64| {
BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
};
// Buffer 0 is the int8 data; buffer 1 holds one float32 scale per row.
let buffers = vec![handle(8)?, handle(8)?];
// The scheme names buffer 1 by index — symmetric, so no zero points.
let quant = QuantizationDescriptor::PerChannelAffine(
PerChannelAffine::new_symmetric(0, 1)?,
);
let desc = TensorDescriptor::new(
DESCRIPTOR_VERSION_MAJOR,
DESCRIPTOR_VERSION_MINOR,
ElementType::Int8,
Shape::new(vec![2u64, 4])?,
0,
LayoutDescriptor::RowMajor,
buffers,
Some(quant.encode_to_vec()),
None, // no shard
None, // no statistics
None, // no extension type
)?;
println!("descriptor: {} bytes", desc.encode()?.len());
Ok(())
}
import struct
import hurray
# One float32 scale per row of a [2, 4] tensor.
scales = struct.pack("2f", 0.02, 0.017)
# The scheme names buffer 1 by index — symmetric, so no zero points.
quant = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)
weights = hurray.Tensor(
bytes(8), # buffer 0: the int8 weights
hurray.int8,
[2, 4],
aux_buffers=[scales], # buffer 1: what scale_buffer_index points at
quantization=quant,
)
Per-tensor affine needs no companion buffer, because its parameters are inline:
q = hurray.PerTensorAffine(0.02, 128)
t = hurray.Tensor(bytes(8), hurray.int8, [2, 4], quantization=q) # still 1 buffer
An index that points at nothing is refused
A descriptor claiming a scale buffer that was never supplied encodes and decodes perfectly well — the consumer simply finds a dangling index. So it is rejected where the mistake was made:
try:
# No aux_buffers, but the scheme references buffer 1.
hurray.Tensor(bytes(8), hurray.int8, [2, 4],
quantization=hurray.PerChannelAffine.symmetric(0, 1))
raise AssertionError("buffer 1 does not exist")
except hurray.InvalidDescriptorError as exc:
print(exc)
Symmetric and asymmetric
Schemes with an optional zero point expose two constructors rather than a flag, so "asymmetric but no zero-point buffer" cannot be expressed at all:
sym = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)
asym = hurray.PerChannelAffine.asymmetric(
axis=0, scale_buffer_index=1, zero_point_buffer_index=2
)
assert sym.zero_point_buffer_index is None # wire sentinel 0xFFFFFFFF
assert asym.zero_point_buffer_index == 2
Per-block affine additionally declares the element type of its scales, which must be float16, bfloat16, or float32:
q = hurray.PerBlockAffine.symmetric(1, 32, 1, hurray.float32)
assert q.scale_type == hurray.float32
Statistics
Each statistic carries a validity bit saying whether it means anything. You pass values and the mask is derived, so a number can never be present with its bit unset:
s = hurray.Statistics(nnz=1024, value_min=-1.0, value_max=1.0, value_abs_max=1.0)
assert s.nnz == 1024
assert s.value_mean is None # not supplied, so not claimed
Fields that share one bit on the wire must be supplied together — value_min /
value_max / value_abs_max, value_mean / value_stddev, nm_n / nm_m, and
has_nan / has_inf. A partial group raises rather than silently zero-filling the
rest:
try:
hurray.Statistics(value_min=-1.0)
raise AssertionError("value_max and value_abs_max are missing")
except hurray.InvalidDescriptorError as exc:
print(exc)
Shard
Records this tensor's position inside a larger logical one:
shard = hurray.Shard(parent_shape=[1024, 512], shard_offset=[512, 0])
piece = hurray.Tensor(bytes(8), hurray.int8, [2, 4], shard=shard)
Checking what you built
save() writes every buffer, so the scales travel with the weights:
hurray.save("weights.hrry", {"w": weights})
hurray-inspect then shows the scheme byte by byte:
141 14 00 00 00 quantization_length = 20
145 02 scheme_tag = 0x02 (per-channel-affine)
149 00 00 00 00 axis = 0
153 01 00 00 00 scale_buffer_index = 1
157 FF FF FF FF zero_point_buffer_index = none (symmetric)
161 03 scale_type = float32
Reading it back
A consumer that receives a tensor — off disk, off the wire, or over the native protocol — can ask what it is holding. The getters return the same classes the constructor accepts, so an inspected scheme can be passed straight back to build another tensor.
use hurray_core::{QuantizationDescriptor, TensorDescriptor};
fn describe(desc: &TensorDescriptor) -> Result<(), Box<dyn std::error::Error>> {
match desc.quantization.as_ref() {
None => println!("not quantized"),
Some(bytes) => {
let (scheme, _read) = QuantizationDescriptor::decode(bytes)?;
match scheme {
QuantizationDescriptor::PerChannelAffine(q) => println!(
"per-channel: axis {}, scales in buffer {}",
q.axis(),
q.scale_buffer_index()
),
other => println!("scheme: {other:?}"),
}
}
}
Ok(())
}
import hurray
loaded = hurray.load("weights.hrry")["w"]
q = loaded.quantization
if q is None:
print("not quantized")
else:
print(f"per-channel: axis {q.axis}, scales in buffer {q.scale_buffer_index}")
print(f"buffers: {loaded.buffer_count}")
statistics and shard read back the same way, and every section is None when
absent:
t = hurray.Tensor(bytes(16), hurray.float32, [4])
assert t.quantization is None
assert t.statistics is None
assert t.shard is None
A statistic that was never supplied stays unclaimed after the round trip — the validity mask travels with the values:
s = hurray.Statistics(nnz=6)
t = hurray.Tensor(bytes(24), hurray.float32, [2, 3], statistics=s)
assert t.statistics.nnz == 6
assert t.statistics.value_mean is None
See also
- Quantized Inference — choosing a scheme
- Multi-Buffer Tensors — how the scale buffer travels
- hurray-inspect CLI — reading a descriptor byte by byte
cargo run --example quantization_roundtrip -p hurray-corepython hurray-python/examples/quantized_authoring.py
Converting an Externally Quantized Tensor
An external toolchain hands you quantized weights, per-block scales, and a zero-point plane. Two of those three transfer as-is.
Hurray dequantizes as scale * (q - zero_point) — the stored zero point is the
value subtracted, with no implicit bias. Toolchains in the GPTQ family have
conventionally stored zero_point - 1 and removed the bias in the loader, so the
number in the file and the number Hurray subtracts are not the same number.
Nothing in either file records which convention produced it.
That makes this the rare conversion error with no symptom. A descriptor built from un-normalized values satisfies every validity constraint of its scheme, and a reader has no way to detect the discrepancy — it simply decodes every element off by one scale step.
What transfers
| From the toolchain | Into Hurray | Changes? |
|---|---|---|
| Quantized weights | buffer 0 | no |
| Per-block scales | scale buffer, float32 | no |
| Zero-point plane | zero-point buffer, one int32 per block | yes — rebuilt |
The last row is the useful one. Hurray's per-block affine scheme takes one
int32 per block, while the foreign plane packs two 4-bit values per byte. You
are rebuilding that buffer either way; the normalization is one line inside a
transform you are already writing, not a step to remember afterwards.
The conversion
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerBlockAffine,
QuantizationDescriptor, Shape, SyncMode, TensorDescriptor,
DESCRIPTOR_VERSION_MAJOR, DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};
/// 4-bit zero points packed two per byte, low nibble first, each holding
/// `zero_point - 1`.
fn normalize_zero_points(packed: &[u8], count: usize) -> Vec<i32> {
(0..count)
.map(|i| {
let byte = packed[i / 2];
let nibble = if i % 2 == 0 { byte & 0x0F } else { byte >> 4 };
i32::from(nibble) + 1 // remove the toolchain's bias, here or never
})
.collect()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A [2, 16] int8 weight, quantized in blocks of 8 along axis 1: 4 blocks.
let zero_points = normalize_zero_points(&[0x67, 0x78], 4);
assert_eq!(zero_points, vec![8, 7, 9, 8]);
let handle = |len: u64| {
BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
};
// Buffer 0: weights. Buffer 1: float32 scales. Buffer 2: int32 zero points.
let buffers = vec![handle(32)?, handle(16)?, handle(16)?];
let quant = QuantizationDescriptor::PerBlockAffine(
PerBlockAffine::new_asymmetric(1, 8, 1, 2, ElementType::Float32)?,
);
let desc = TensorDescriptor::new(
DESCRIPTOR_VERSION_MAJOR,
DESCRIPTOR_VERSION_MINOR,
ElementType::Int8,
Shape::new(vec![2u64, 16])?,
0,
LayoutDescriptor::RowMajor,
buffers,
Some(quant.encode_to_vec()),
None, // no shard
None, // no statistics
None, // no extension type
)?;
assert_eq!(desc.buffers.len(), 3);
Ok(())
}
import struct
import hurray
def normalize_zero_points(packed, count):
"""4-bit zero points packed two per byte, each holding zero_point - 1."""
out = []
for i in range(count):
byte = packed[i // 2]
nibble = byte & 0x0F if i % 2 == 0 else byte >> 4
out.append(nibble + 1) # remove the toolchain's bias, here or never
return out
# A [2, 16] int8 weight, quantized in blocks of 8 along axis 1: 4 blocks.
zero_points = normalize_zero_points(bytes([0x67, 0x78]), 4)
assert zero_points == [8, 7, 9, 8]
quant = hurray.PerBlockAffine.asymmetric(1, 8, 1, 2, hurray.float32)
weights = hurray.Tensor(
bytes(32), # buffer 0: the weights
hurray.int8,
[2, 16],
aux_buffers=[
struct.pack("4f", 0.02, 0.015, 0.025, 0.01), # buffer 1: scales
struct.pack("4i", *zero_points), # buffer 2: zero points
],
quantization=quant,
)
assert weights.buffer_count == 3
The scales went in untouched. The zero points did not.
What skipping it costs
Hurray describes tensors; it does not compute on them, so there is no
dequantize in the library and there should not be one. The formula below is
read out of the spec, not called from an API:
fn main() {
// One block: scale 0.02, true zero point 8, one quantized value.
let (s, q) = (0.02f32, 9i32);
let correct = s * (q - 8) as f32; // normalized
let wrong = s * (q - 7) as f32; // foreign value copied straight through
assert!((wrong - correct - s).abs() < 1e-6); // off by exactly one scale step
}
One scale step, on every element, in both descriptors that a reader accepts
without complaint. Which is the whole reason the normalization is normative:
quantization.md § Zero-Point Convention places the obligation on the writer,
because after the buffer is written the information needed to detect the mistake
is gone.
Scope
This recipe converts buffers you already hold in memory. Hurray is an interchange format, not a converter library — there is no GPTQ, safetensors, or GGUF reader here, and parsing those files is the caller's job.
Two limits worth knowing before you plan a conversion:
- Activation-order grouping is out of scope. GPTQ's act-order variant
reorders the quantized axis, which per-block affine cannot express. Only the
permutation-free subset — contiguous groups along one axis — maps onto scheme
0x03. Seequantization.md§ Extension Schemes. - GGUF K-quants are out of scope.
Q2_K–Q6_Kscale their per-block scales by a second super-block factor; per-block affine carries one scale array. See Authoring Quantized Tensors for what each scheme does cover.
See also
- Authoring Quantized Tensors — building a descriptor from parameters you own
- Quantized Inference — choosing a scheme
- Multi-Buffer Tensors — how the parameter buffers travel
cargo run --example convert_external_quantized -p hurray-corepython hurray-python/examples/convert_external_quantized.py
IPC and Streaming Interchange
Hurray's streaming format moves tensors between a producer and a consumer — in one process, across a pipe or socket (IPC), or between machines. It is self-delimiting and descriptor-before-data, so a reader can start work before the whole payload has arrived and a writer can emit tensors one at a time without buffering the output.
In Rust this is the hurray-io streaming reader/writer over any async byte stream.
Python has the same thing in two drivers — hurray.StreamReader
/StreamWriter blocking, and hurray.aio
on an event loop. The Python tab below shows the file hand-off instead, because that is
the shape to reach for when the two processes do not overlap in time.
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::stream::{StreamReader, StreamWriter};
fn tensor(elems: u64) -> Result<(TensorDescriptor, Vec<u8>), Box<dyn std::error::Error>> {
let handle = BufferHandle::new(
elems, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let desc = TensorDescriptor::new(
1, 0, ElementType::Uint8, Shape::new(vec![elems])?, 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)?;
Ok((desc, (0..elems as u8).collect()))
}
// Requires hurray-io's `tokio` feature.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Producer — `wire` stands in for any async writer (a TcpStream, a pipe, …).
let mut wire = Vec::<u8>::new();
let mut writer = StreamWriter::new(&mut wire);
let (d0, data0) = tensor(8)?;
let (d1, data1) = tensor(4)?;
writer.write_tensor(&d0, &[&data0]).await?; // descriptor then data buffers
writer.write_tensor(&d1, &[&data1]).await?;
writer.finish().await?;
// Consumer — reads incrementally; no back-references, no seeking.
let mut reader = StreamReader::new(wire.as_slice());
while let Some(t) = reader.next_tensor().await? {
let bytes: usize = t.buffers.iter().map(|b| b.len()).sum();
println!("got {:?} tensor, {bytes} bytes", t.descriptor.element_type);
}
Ok(())
}
import os
import tempfile
import numpy as np
import hurray
path = os.path.join(tempfile.gettempdir(), "handoff.hrry")
# Producer: write named tensors to the file container.
hurray.save(path, {
"a": hurray.from_numpy(np.arange(8, dtype=np.uint8)),
"b": hurray.from_numpy(np.arange(4, dtype=np.uint8)),
})
# Consumer (possibly another process): read them back.
loaded = hurray.load(path)
for name, t in loaded.items():
print(name, t.shape, t.dtype)
os.unlink(path)
Transports
The Rust StreamWriter / StreamReader work over anything implementing the async
read/write traits, so the same code drives:
- In-process hand-off (an in-memory buffer, as above).
- IPC over a Unix pipe or socket.
- Cross-machine streaming over TCP — use
StreamWriter::cross_machine/StreamReader::cross_machine, which add the length-prefixed framing needed when the transport does not preserve message boundaries.
Because the format is self-delimiting and forbids back-references and end-of-file indexes, the consumer never needs to seek — it can process each tensor as its bytes land.
See also
- Layer 5: Streaming Interchange — the wire framing in depth.
- Layer 6: File Format and
Python: File I/O — the HRRYFILE container behind
save/load. - Streaming Composite Tensors — streaming a composite (head + members) as a unit.
Private Extension Element Types
Element type tags 0xF0–0xFE are reserved for types the format deliberately says
nothing about — a private posit, a block scale your runtime invented, an experiment that
has not earned a standard tag. Nothing about their numeric semantics is specified, and two
implementations that both use 0xF2 need not mean the same thing by it.
The price of that freedom is one obligation: the tensor must describe the type well enough for a stranger to size its buffers. That description is the descriptor's extension type section — a fixed 20 bytes carrying the bit width, the packing, and the floating-point parameters. A reader that has never heard of your type still knows exactly how many bytes to move, which is what an interchange format has to guarantee.
The flag and the tag are one fact: a descriptor whose type tag is in 0xF0–0xFE MUST
carry the section, and one whose tag is anything else MUST NOT. Neither half stands alone.
The tag alone says almost nothing
An extension type reports a bit_width of 0. That is a sentinel, not a claim: the real
width lives in the section, so the generic buffer-size helper has nothing to compute from.
use hurray_core::{buffer_size_bytes, ElementType};
fn main() -> Result<(), hurray_core::Error> {
let private = ElementType::from_tag(0xF2)?;
assert_eq!(private.tag(), 0xF2);
assert_eq!(private.bit_width(), 0); // sentinel: ask the section
assert_eq!(buffer_size_bytes(private, 10), 0);
Ok(())
}
import hurray
private = hurray.Dtype.from_tag(0xF2)
assert private.tag == 0xF2
assert private.bit_width == 0 # sentinel: ask the section
# Python refuses rather than returning a plausible 0 — as a byte count that is
# wrong, not unknown, and would size a buffer to nothing.
try:
hurray.buffer_size_bytes(private, 10)
except hurray.InvalidDescriptorError as exc:
assert "ExtensionType" in str(exc)
Describing the type
packing_factor is how many elements fit in a byte. Whole-byte widths pack one; sub-byte
widths pack 8 / bit_width, and only 1, 2 and 4 bits are legal — anything else
would need a fractional number of elements per byte. Non-power-of-two sub-byte widths are
reserved to the built-in type space, which is where the 6-bit floats live with their
4-elements-per-3-bytes packing.
The Python constructor derives packing_factor rather than asking for it: the spec
leaves exactly one legal value per width, so restating it could only produce an error.
use hurray_core::descriptor::ExtensionTypeDescriptor;
fn main() -> Result<(), hurray_core::Error> {
// A private 24-bit signed integer: whole-byte, so packing_factor is 1.
let int24 = ExtensionTypeDescriptor::new(24, 1, false, true, 0, 0, 0, 0, false, false)?;
assert_eq!(int24.buffer_size_bytes(4), 12);
// A private 4-bit type: two per byte, rounding up.
let nibble = ExtensionTypeDescriptor::new(4, 2, false, false, 0, 0, 0, 0, false, false)?;
assert_eq!(nibble.buffer_size_bytes(7), 4);
// 6-bit is reserved to the built-in types.
assert!(ExtensionTypeDescriptor::new(6, 1, false, false, 0, 0, 0, 0, false, false).is_err());
Ok(())
}
import hurray
int24 = hurray.ExtensionType(bit_width=24, is_signed=True)
assert int24.packing_factor == 1
assert int24.buffer_size_bytes(4) == 12
nibble = hurray.ExtensionType(bit_width=4)
assert nibble.packing_factor == 2 # derived: 8 / 4
assert nibble.buffer_size_bytes(7) == 4 # ceil(7 / 2)
try:
hurray.ExtensionType(bit_width=6)
except hurray.InvalidDescriptorError as exc:
assert "1, 2 or 4" in str(exc)
Where a float's sign lives
A float carries its sign in sign_bits; is_signed describes integer types only.
Setting both would give a reader two answers to one question, so a float with is_signed
set is rejected.
This is not a claim that float extension types are unsigned — it is what makes an
unsigned float expressible. The built-in float8_e8m0 (0x42) is exactly that shape:
8 exponent bits, no sign, no mantissa, used as an MX block scale. A private analogue sets
is_float with sign_bits = 0.
use hurray_core::descriptor::ExtensionTypeDescriptor;
fn main() -> Result<(), hurray_core::Error> {
// Signed 16-bit float: 1-5-10, bias 15.
let half = ExtensionTypeDescriptor::new(16, 1, true, false, 1, 5, 10, 15, true, true)?;
assert_eq!(half.sign_bits, 1);
assert!(!half.is_signed);
// Unsigned float — the float8_e8m0 shape.
let scale = ExtensionTypeDescriptor::new(8, 1, true, false, 0, 8, 0, 127, true, false)?;
assert_eq!(scale.sign_bits, 0);
// Both set: rejected.
assert!(ExtensionTypeDescriptor::new(16, 1, true, true, 1, 5, 10, 15, true, false).is_err());
Ok(())
}
import hurray
half = hurray.ExtensionType(
bit_width=16, is_float=True,
sign_bits=1, exponent_bits=5, mantissa_bits=10, exponent_bias=15,
has_nan=True, has_inf=True,
)
assert half.sign_bits == 1
assert half.is_signed is False
# Unsigned float — the float8_e8m0 shape.
scale = hurray.ExtensionType(
bit_width=8, is_float=True, exponent_bits=8, exponent_bias=127
)
assert scale.sign_bits == 0
try:
hurray.ExtensionType(bit_width=16, is_float=True, is_signed=True, sign_bits=1)
except hurray.InvalidDescriptorError as exc:
assert "is_signed" in str(exc)
Authoring a tensor
The section travels with the descriptor and is what sizes the buffer check. Without it the check would compute zero bytes and wave an empty buffer through for a tensor of any length.
use hurray_core::{
descriptor::{ExtensionTypeDescriptor, TensorDescriptor},
layout::LayoutDescriptor,
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, DESCRIPTOR_VERSION_MAJOR,
DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};
fn main() -> Result<(), hurray_core::Error> {
let int24 = ExtensionTypeDescriptor::new(24, 1, false, true, 0, 0, 0, 0, false, false)?;
let buffer = BufferHandle::new(
int24.buffer_size_bytes(4),
MIN_BUFFER_ALIGNMENT,
DeviceTag::Cpu,
SyncMode::ProducerSynced,
)?;
let descriptor = TensorDescriptor::new(
DESCRIPTOR_VERSION_MAJOR,
DESCRIPTOR_VERSION_MINOR,
ElementType::from_tag(0xF2)?,
Shape::new(vec![4u64])?,
0,
LayoutDescriptor::RowMajor,
vec![buffer],
None,
None,
None,
Some(int24),
)?;
// Round-trips: a consumer that has never heard of 0xF2 still learns the width.
let restored = TensorDescriptor::decode(&descriptor.encode()?)?;
let ext = restored.extension_type.as_ref().expect("carries a section");
assert_eq!(ext.buffer_size_bytes(4), 12);
Ok(())
}
import hurray
private = hurray.Dtype.from_tag(0xF2)
int24 = hurray.ExtensionType(bit_width=24, is_signed=True)
tensor = hurray.Tensor(
bytes(int24.buffer_size_bytes(4)), private, [4], extension_type=int24
)
assert tensor.extension_type.bit_width == 24
# Round-trips: a consumer that has never heard of 0xF2 still learns the width.
restored = hurray.Descriptor.decode(tensor.descriptor.encode())
assert restored == tensor.descriptor
assert restored.extension_type.buffer_size_bytes(4) == 12
Both directions of the pairing are refused, at authoring time:
import hurray
private = hurray.Dtype.from_tag(0xF2)
int24 = hurray.ExtensionType(bit_width=24, is_signed=True)
# An extension dtype must describe itself.
try:
hurray.Tensor(bytes(12), private, [4])
except hurray.InvalidDescriptorError as exc:
assert "must describe itself" in str(exc)
# And the section cannot stand alone.
try:
hurray.Tensor(bytes(16), hurray.float32, [4], extension_type=int24)
except hurray.InvalidDescriptorError as exc:
assert "not a private extension" in str(exc)
# The section sizes the buffer check.
try:
hurray.Tensor(bytes(4), private, [4], extension_type=int24)
except hurray.BufferError as exc:
assert "need at least 12 bytes" in str(exc)
What this does not buy you
Portability. The spec is explicit: tensors using private extension tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band. The section lets a stranger move your bytes, not interpret them. If you need a type both ends understand without a side agreement, request a built-in tag through the specification governance process instead.
See also
- Layer 0: Element Types and Shape — the built-in type system the extension range sits beside
- Layer 4: Tensor Descriptor Encoding — where the optional sections sit in the wire format
docs/spec/metadata.md§ Extension Type Section — the normative field table- ADR-001 — why extension tags carry an inline descriptor at all
Layer 0: Element Types and Shape
Purpose
ElementType defines the numeric storage format for tensor elements (float32, int4, bool, etc.). Shape describes the dimensions of a tensor. Together, they form the foundation of the Hurray data model: what is stored (element type) and how many (shape). The buffer_size_bytes function computes how much memory a tensor requires.
ML Model with Mixed Precision
Suppose you're building an inference runtime for an LLM with quantization. Different layers use different types:
use hurray_core::{ElementType, Shape, buffer_size_bytes};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Weight tensor: float16 (half precision)
let weights_shape = Shape::new(vec![768u64, 3072])?;
let weights_type = ElementType::Float16;
let weights_elements = weights_shape.element_count().expect("no dynamic dims");
let weights_bytes = buffer_size_bytes(weights_type, weights_elements);
println!("Weights [768, 3072] as float16: {} bytes", weights_bytes);
// Activation tensor: float32 (full precision for numerical stability)
let activation_shape = Shape::new(vec![32u64, 768])?;
let activation_type = ElementType::Float32;
let activation_elements = activation_shape.element_count().expect("no dynamic dims");
let activation_bytes = buffer_size_bytes(activation_type, activation_elements);
println!("Activations [32, 768] as float32: {} bytes", activation_bytes);
// Quantized layer: int4 (4-bit integers)
let quantized_shape = Shape::new(vec![768u64, 1024])?;
let quantized_type = ElementType::Int4;
let quantized_elements = quantized_shape.element_count().expect("no dynamic dims");
let quantized_bytes = buffer_size_bytes(quantized_type, quantized_elements);
println!("Quantized layer [768, 1024] as int4: {} bytes", quantized_bytes);
Ok(())
}
import hurray
# Weight tensor: float16 (half precision)
weights = hurray.buffer_size_bytes(hurray.float16, 768 * 3072)
print(f"Weights [768, 3072] as float16: {weights} bytes")
# Activation tensor: float32 (full precision for numerical stability)
activations = hurray.buffer_size_bytes(hurray.float32, 32 * 768)
print(f"Activations [32, 768] as float32: {activations} bytes")
# Quantized layer: int4 (4-bit integers, two per byte)
quantized = hurray.buffer_size_bytes(hurray.dtype.int4, 768 * 1024)
print(f"Quantized layer [768, 1024] as int4: {quantized} bytes")
Output:
Weights [768, 3072] as float16: 4718592 bytes
Activations [32, 768] as float32: 98304 bytes
Quantized layer [768, 1024] as int4: 393216 bytes
Dynamic Dimensions (Batch Size Unknown)
When the batch dimension is not known at model load time, mark it DYNAMIC:
use hurray_core::{Shape, DYNAMIC};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Batch size unknown; sequence length fixed at 512
let shape = Shape::new(vec![DYNAMIC, 512u64, 768])?;
println!("Shape: {}", shape); // [?, 512, 768]
println!("Has dynamic: {}", shape.has_dynamic()); // true
println!("Element count: {:?}", shape.element_count()); // None
// At runtime, after batch size is resolved to (say) 8:
let resolved_shape = Shape::new(vec![8u64, 512, 768])?;
let elements = resolved_shape.element_count().expect("now static");
println!("Resolved element count: {}", elements); // 3145728
Ok(())
}
import hurray
# Batch size unknown; sequence length fixed at 512.
signature = hurray.Tensor(b"", hurray.float32, [None, 512, 768])
assert signature.shape == (None, 512, 768)
assert signature.size is None # an unknown extent, so an unknown count
# At runtime, once the batch size is resolved to (say) 8:
resolved = hurray.Tensor(bytes(4 * 8 * 512 * 768), hurray.float32, [8, 512, 768])
assert resolved.size == 3145728
Python spells a dynamic dimension None rather than exposing the wire sentinel, because
Tensor.shape already returns None for one — so a shape read off a tensor can be
handed straight back to the constructor. Anything that allocates (zeros, ones,
empty, full) refuses it: you cannot allocate an unknown number of bytes.
Type Properties and Alignment
Query element type metadata:
use hurray_core::ElementType;
fn main() {
let ty = ElementType::Float32;
println!("Type: {}", ty); // float32
println!("Wire tag: 0x{:02X}", ty.tag()); // 0x03
println!("Bit width: {}", ty.bit_width()); // 32
println!("Bytes per element: {}", ty.element_alignment()); // 4
println!("Is float: {}", ty.is_float()); // true
println!("Is integer: {}", ty.is_integer()); // false
println!("Is signed: {}", ty.is_signed()); // true
println!("Tier: {}", ty.tier()); // 1 (core type)
// Sub-byte types require special handling
let int4 = ElementType::Int4;
println!("\nType: {}", int4); // int4
println!("Bit width: {}", int4.bit_width()); // 4
println!("Is sub-byte: {}", int4.is_sub_byte()); // true
}
import hurray
ty = hurray.float32
print(f"Type: {ty.name}") # float32
print(f"Wire tag: 0x{ty.tag:02X}") # 0x03
print(f"Bit width: {ty.bit_width}") # 32
print(f"Element alignment: {ty.element_alignment}") # 4
print(f"Is float: {ty.is_float}") # True
print(f"Is integer: {ty.is_integer}") # False
print(f"Is signed: {ty.is_signed}") # True
print(f"Tier: {ty.tier}") # 1 (core type)
# Sub-byte types require special handling
int4 = hurray.dtype.int4
print(f"Bit width: {int4.bit_width}") # 4
print(f"Is sub-byte: {int4.is_sub_byte}") # True
print(f"Element alignment: {int4.element_alignment}") # 1 — two share a byte
element_alignment is the element's own alignment, not the buffer's: a float32 buffer
starts on a hurray.MIN_BUFFER_ALIGNMENT boundary, but its elements are 4-aligned within
it. A packed element reports 1, having no address of its own.
Buffer Size Calculations
For different element types, the buffer size formula varies. buffer_size_bytes handles all cases:
use hurray_core::{ElementType, buffer_size_bytes};
fn main() {
// Whole-byte types: element_count × byte_width
println!("float32 × 100 elements: {} bytes",
buffer_size_bytes(ElementType::Float32, 100)); // 400
// 6-bit types: ceil(N/4) × 3
println!("float6_e2m3 × 100 elements: {} bytes",
buffer_size_bytes(ElementType::Float6E2M3, 100)); // 75
// 4-bit types: ceil(N×4/8) = ceil(N/2)
println!("int4 × 7 elements: {} bytes",
buffer_size_bytes(ElementType::Int4, 7)); // 4
// Boolean (1-bit): ceil(N/8)
println!("bool × 9 elements: {} bytes",
buffer_size_bytes(ElementType::Bool, 9)); // 2
// Sub-byte types are packed; 0 elements always yields 0 bytes
println!("any type × 0 elements: {} bytes",
buffer_size_bytes(ElementType::Float32, 0)); // 0
}
import hurray
# Whole-byte types: element_count x byte_width
assert hurray.buffer_size_bytes(hurray.float32, 100) == 400
# 6-bit types: ceil(N/4) x 3
assert hurray.buffer_size_bytes(hurray.dtype.float6_e2m3, 100) == 75
# 4-bit types: ceil(N x 4 / 8) = ceil(N/2)
assert hurray.buffer_size_bytes(hurray.dtype.int4, 7) == 4
# Boolean (1-bit): ceil(N/8)
assert hurray.buffer_size_bytes(hurray.bool, 9) == 2
# Sub-byte types are packed; 0 elements always yields 0 bytes
assert hurray.buffer_size_bytes(hurray.float32, 0) == 0
Type Tag Round-Trip
Serialize and deserialize element types using the wire tag:
use hurray_core::ElementType;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Serialize: type to tag
let original_type = ElementType::Float8E4M3;
let tag = original_type.tag();
println!("Serialized {} to tag 0x{:02X}", original_type, tag);
// Deserialize: tag to type
let recovered_type = ElementType::from_tag(tag)?;
assert_eq!(original_type, recovered_type);
println!("Deserialized tag 0x{:02X} back to {}", tag, recovered_type);
Ok(())
}
import hurray
# Serialize: type to tag
tag = hurray.dtype.float8_e4m3.tag
assert tag == 0x40
# Deserialize: tag to type — and back to the same object, not a copy
assert hurray.Dtype.from_tag(tag) is hurray.dtype.float8_e4m3
Invalid Tags
Tags in reserved ranges are rejected:
use hurray_core::{ElementType, Error};
fn main() {
// Permanently invalid sentinels
assert!(matches!(ElementType::from_tag(0x00), Err(Error::InvalidTypeTag(0x00))));
assert!(matches!(ElementType::from_tag(0xFF), Err(Error::InvalidTypeTag(0xFF))));
// Reserved for future spec versions
assert!(matches!(ElementType::from_tag(0x47), Err(Error::ReservedTypeTag(0x47))));
assert!(matches!(ElementType::from_tag(0x80), Err(Error::ReservedTypeTag(0x80))));
// The private-extension range is NOT an error: 0xF0-0xFE resolve to
// ElementType::Extension(tag), whose semantics travel out of band.
assert!(matches!(ElementType::from_tag(0xF0), Ok(ElementType::Extension(0xF0))));
println!("All invalid tags correctly rejected");
}
import hurray
for tag in (
0x00, 0xFF, # permanently invalid sentinels
0x47, 0x80, # reserved for future spec versions
):
try:
hurray.Dtype.from_tag(tag)
raise AssertionError(f"0x{tag:02X} should not resolve")
except hurray.InvalidDescriptorError as exc:
print(exc)
# The private-extension range is not an error: it resolves to an extension type
# whose semantics travel out of band. The tag is what identifies it.
private = hurray.Dtype.from_tag(0xF0)
assert private.name == "extension"
assert private.tag == 0xF0
assert private != hurray.Dtype.from_tag(0xF5)
Python collapses the Rust error variants into InvalidDescriptorError, but the message
still says which case it was — and none of them is guessed at, since inventing a type for
a reserved tag turns "the producer is newer than this reader" into silent wrong data.
Empty and Scalar Tensors
Hurray supports edge cases:
use hurray_core::{Shape, ElementType, buffer_size_bytes};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Scalar tensor (rank 0): one element, no shape
let scalar = Shape::scalar();
assert_eq!(scalar.rank(), 0);
assert_eq!(scalar.dims(), &[]);
assert_eq!(scalar.element_count(), Some(1));
let scalar_bytes = buffer_size_bytes(ElementType::Float32, 1);
println!("Scalar float32: {} bytes", scalar_bytes); // 4
// Empty tensor: any zero dimension
let empty = Shape::new(vec![5u64, 0, 10])?;
assert!(empty.is_empty_tensor());
assert_eq!(empty.element_count(), Some(0));
let empty_bytes = buffer_size_bytes(ElementType::Float32, 0);
println!("Empty tensor: {} bytes", empty_bytes); // 0
Ok(())
}
import hurray
# Scalar tensor (rank 0): one element, no shape
scalar = hurray.Tensor(bytes(4), hurray.float32, [])
assert scalar.ndim == 0
assert scalar.shape == ()
assert scalar.size == 1
# Empty tensor: any zero dimension
empty = hurray.Tensor(b"", hurray.float32, [5, 0, 10])
assert empty.size == 0
assert empty.buffer_handles[0].byte_size == 0
assert empty.buffer_handles[0].alignment == 1 # nothing to align
Key Takeaways
- ElementType — an enum with 26 numeric types from Tier 1 (core) and Tier 2 (extended)
- Shape — a vector of
u64dimension sizes, supporting dynamic (DYNAMIC) and zero-size dimensions - buffer_size_bytes() — handles all packing rules (1-bit, 2-bit, 4-bit, 6-bit, and whole-byte types)
- Tags are serialized as
u8in descriptors; usefrom_tag()/tag()for round-trip conversion - In Python, a dynamic dimension is spelled
None— the same thingTensor.shapereturns — andhurray.buffer_size_bytes(dtype, count)applies the same packing rules - Scalar tensors have rank 0; empty tensors have 0 total elements but valid descriptors
See docs/spec/element-types.md and docs/spec/data-model.md for the normative specification.
Layer 1: Buffer Protocol
Purpose
A buffer handle declares a tensor's data buffer: its size in bytes, alignment guarantee, and which device (CPU, GPU, or custom) it resides in. A device tag identifies the memory space. A memory class describes how the buffer is accessible — standard device-private, host-pinned, unified, or peer-accessible. Together they form the bridge between the descriptor's binary metadata and the actual memory location — the handle does not hold a pointer (that comes out-of-band) but carries the rules readers must follow to safely dereference the data.
Creating Buffer Handles
The most common case: a CPU buffer with SIMD alignment (64 bytes minimum):
use hurray_core::{BufferHandle, DeviceTag, SyncMode, MIN_BUFFER_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a 1 KB CPU buffer with SIMD alignment.
let handle = BufferHandle::new(1024, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
assert_eq!(handle.byte_size(), 1024);
assert_eq!(handle.alignment(), 64);
assert_eq!(handle.device_tag(), DeviceTag::Cpu);
assert_eq!(handle.sync_mode(), SyncMode::ProducerSynced);
Ok(())
}
import hurray
# Python reads the buffer table rather than authoring it: the buffers a tensor
# holds already settle every field, so there is nothing left for a caller to
# supply. One handle per buffer, in descriptor order.
tensor = hurray.Tensor(bytes(1024), hurray.uint8, [1024])
handle, = tensor.buffer_handles
assert handle.byte_size == 1024
assert handle.alignment == hurray.MIN_BUFFER_ALIGNMENT
assert handle.sync_mode == "producer_synced"
assert handle.device is tensor.device # colocation: one device per descriptor
A handle is a value copied out of the table, not a view into it: it holds no reference to
its tensor and none to any buffer, so collecting handles across a stream pins nothing.
That is also why metadata and bytes have separate accessors — tensor.buffer(i) hands
back a byte view, tensor.buffer_handles[i] answers questions about those bytes without
touching them. On a CUDA tensor the second works where the first cannot.
For GPU or IPC buffers, use page alignment (4096 bytes):
use hurray_core::{BufferHandle, DeviceTag, SyncMode, PAGE_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// CUDA buffer aligned to one page — safe for GPU + IPC transport.
let gpu_buffer = BufferHandle::new(8192, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event)?;
assert_eq!(gpu_buffer.alignment(), 4096);
assert_eq!(gpu_buffer.device_tag(), DeviceTag::Cuda);
Ok(())
}
Choosing Alignment
| Device | Alignment | Why |
|---|---|---|
| CPU (SIMD) | 64 bytes | Minimum for AVX-512, NEON, SVE without per-op negotiation |
| GPU, IPC, RDMA | 4096 bytes | Host page size; avoids cross-page pinning and TLB fragmentation |
| Custom (private tag) | ≥64 bytes | Implementation-defined; typically matches SIMD or page boundary |
Always use the strongest alignment you can guarantee — readers may rely on it for performance.
Empty Buffers
A tensor with zero elements (e.g., shape [5, 0, 10]) has zero-byte buffers. Use BufferHandle::empty():
use hurray_core::{BufferHandle, DeviceTag};
fn main() {
// Empty buffer — no data, alignment is waived.
let empty = BufferHandle::empty(DeviceTag::Cpu);
assert!(empty.is_empty());
assert_eq!(empty.byte_size(), 0);
assert_eq!(empty.alignment(), 1); // Any power-of-two is valid
}
import hurray
empty, = hurray.Tensor(b"", hurray.float32, [0]).buffer_handles
assert empty.is_empty
assert empty.byte_size == 0
assert empty.alignment == 1 # no byte to load, so nothing to align
Readers MUST NOT dereference the pointer of an empty buffer. In C ABI contexts, it may be a null pointer; in others, it may be non-null but uninitialized. Do not read or write.
Memory Class
A buffer's memory class describes how it is accessible, orthogonally to which device it resides on. The default is Standard (device-private memory), but other classes enable zero-copy sharing patterns:
| Class | Wire byte | Meaning |
|---|---|---|
Standard | 0x00 | Device-private memory; default for all devices |
HostPinned | 0x01 | CPU-accessible pinned memory (e.g., CUDA cudaMallocHost) |
Unified | 0x02 | Unified/managed memory accessible from both CPU and GPU |
Peer | 0x03 | Peer-to-peer memory accessible from a second GPU |
Use BufferHandle::new() for the common case (Standard); use BufferHandle::with_memory_class() when the class is known:
use hurray_core::{BufferHandle, DeviceTag, MemoryClass, SyncMode, PAGE_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// CUDA unified (managed) memory — CPU and GPU can both access it directly.
let unified = BufferHandle::with_memory_class(
8192,
PAGE_ALIGNMENT,
DeviceTag::Cuda,
SyncMode::ProducerSynced,
MemoryClass::Unified,
)?;
assert_eq!(unified.memory_class(), MemoryClass::Unified);
// Host-pinned memory — GPU DMA can read it without staging.
let pinned = BufferHandle::with_memory_class(
4096,
PAGE_ALIGNMENT,
DeviceTag::Cuda,
SyncMode::Event,
MemoryClass::HostPinned,
)?;
assert_eq!(pinned.memory_class(), MemoryClass::HostPinned);
Ok(())
}
The memory class round-trips through the wire format:
use hurray_core::MemoryClass;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Serialize: type → byte
let original = MemoryClass::Unified;
let byte = original.to_byte();
assert_eq!(byte, 0x02);
// Deserialize: byte → type
let recovered = MemoryClass::from_byte(byte)?;
assert_eq!(original, recovered);
Ok(())
}
Private memory classes (0xF0–0xFE) are available for vendor-specific extensions, following the same pattern as private device tags:
use hurray_core::{BufferHandle, DeviceTag, MemoryClass, SyncMode, MIN_BUFFER_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let vendor_class = MemoryClass::from_byte(0xF1)?;
let handle = BufferHandle::with_memory_class(
2048,
MIN_BUFFER_ALIGNMENT,
DeviceTag::from_byte(0xF0)?,
SyncMode::ProducerSynced,
vendor_class,
)?;
assert!(handle.memory_class().is_private());
Ok(())
}
import hurray
# A vendor memory class on a vendor device.
device = hurray.Device(0xF0, 0, memory_class=0xF1)
assert device.memory_class == "private"
assert device.memory_class_tag == 0xF1
tensor = hurray.Tensor(bytes(2048), hurray.float32, [512], device=device)
assert tensor.buffer_handles[0].device is tensor.device
Private Device Tags
For experimental or vendor-specific hardware, use the private range (0xF0–0xFE):
use hurray_core::{BufferHandle, DeviceTag, SyncMode, MIN_BUFFER_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a private device tag for a custom accelerator (e.g., TPU, custom FPGA).
let custom_device = DeviceTag::from_byte(0xF2)?;
let handle = BufferHandle::new(4096, MIN_BUFFER_ALIGNMENT, custom_device, SyncMode::ProducerSynced)?;
assert!(custom_device.is_private());
assert_eq!(custom_device.to_byte(), 0xF2);
Ok(())
}
import hurray
# A private tag for a custom accelerator (e.g. a TPU, an FPGA).
custom = hurray.Device(0xF2)
assert custom.is_private
assert custom.tag == 0xF2
assert custom.kind == "private" # the spec gives these no name
tensor = hurray.Tensor(bytes(4096), hurray.float32, [1024], device=custom)
assert tensor.device.tag == 0xF2
Python takes a wire byte where the spec has no name to give. kind is "private" for
every tag in the range, so tag is what tells two apart — and repr carries it for the
same reason.
Important: Private tags must not be exchanged between independent implementations without an out-of-band agreement on semantics. Use only when both producer and consumer control the device tag value.
Alignment Validation
Alignment must be a power of two:
use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode};
fn main() {
// Alignment is not a power of two — rejected.
let result = BufferHandle::new(512, 63, DeviceTag::Cpu, SyncMode::ProducerSynced);
assert!(matches!(result, Err(Error::AlignmentNotPowerOfTwo { alignment: 63 })));
}
For non-empty buffers, alignment must be at least 64 bytes:
use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode};
fn main() {
// Non-empty buffer with alignment below SIMD minimum — rejected.
let result = BufferHandle::new(512, 32, DeviceTag::Cpu, SyncMode::ProducerSynced);
assert!(matches!(
result,
Err(Error::AlignmentBelowMinimum { alignment: 32, minimum: 64 })
));
// Empty buffers allow any power-of-two alignment, including 1.
let empty = BufferHandle::new(0, 1, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
assert!(empty.is_empty());
}
Alignment Is Measured, Not Asserted
The floor above is what makes the next part interesting. A producer does not get to claim 64-byte alignment — a consumer will issue aligned SIMD loads on the strength of that claim, and a claim the address cannot back invites a fault. So the Python binding measures the address it is given, and declares what it finds:
import numpy as np
import hurray
array = np.zeros(1 << 20, dtype=np.float32) # 4 MiB
# NumPy promises no alignment beyond the dtype's own, and a large allocation served
# by a fresh mmap is 16 bytes past a page boundary — glibc puts its chunk header
# there — so it never reaches 64. A recycled chunk may land anywhere, which is no
# better: the address is not something a producer can arrange.
tensor = hurray.from_numpy(array) # copied if it does not qualify
assert tensor.buffer_handles[0].alignment >= hurray.MIN_BUFFER_ALIGNMENT
from_numpy, from_torch, from_scipy, sparse_coo, from_dlpack and asarray
therefore take a copy argument, with the same meaning as NumPy's:
copy | Behaviour |
|---|---|
None (default) | Copy into a 64-byte-aligned allocation only if the source is under-aligned |
False | Never copy; raise hurray.CopyRequiredError naming the alignment the source actually has |
True | Always copy |
under_aligned = array[1:] # 4-byte aligned, guaranteed
try:
hurray.from_numpy(under_aligned, copy=False)
except hurray.CopyRequiredError as exc:
print(exc) # "array is 4-byte aligned, below the 64-byte minimum ..."
This is a real cost, and it is worth stating plainly rather than burying: zero-copy NumPy
ingest copies for most arrays. copy=False exists so a caller who needs the guarantee
gets an error instead of a silent memcpy. An array you allocated on a 64-byte boundary
yourself is shared, not copied — and from_scipy decides per component, so a matrix's
.data can be shared while its .indptr is copied.
Allocating arrays that need no copy
If you control the allocation, you can remove the copy entirely. NumPy ≥ 1.22 lets an extension install a data-memory handler (NEP 49), and alignment is the first motivation that NEP lists — NumPy considered guaranteeing it, declined, and shipped the hook instead, so this is the sanctioned answer rather than a workaround:
import numpy as np
import hurray
with hurray.aligned_allocator():
weights = np.zeros((512, 512), dtype=np.float32)
tensor = hurray.from_numpy(weights, copy=False) # accepted: no copy is needed
assert tensor.buffer_handles[0].alignment >= hurray.MIN_BUFFER_ALIGNMENT
That turns "Hurray always copies NumPy arrays" into "arrays allocated for Hurray are not copied" — a materially different bargain for a producer writing its own checkpoints.
Three properties make this safe to reach for, and one is a sharp edge:
- The handler is stored per array. An array allocated inside the block is freed through the matching deallocator long after the block exits, so arrays outlive their block safely.
- It is thread- and context-local, so installing it cannot leak into unrelated code, and it is restored on the way out even if the block raises. Blocks nest.
- Arrays allocated outside are untouched, including ones that already existed.
- A thread started inside the block does not inherit the policy. Arrays a worker thread allocates get NumPy's default allocator and are copied on ingest like any other. Enter the block on the thread that allocates.
One consequence worth knowing: alignment is exempt from the round-trip obligation that
governs layout, quantization, statistics and shard. Alignment describes an
address, and a rebuild that copies bytes has a different one. A tensor that arrived
declaring 4096 will honestly declare 64 after a rebuild through Python bytes.
Sync Mode
sync_mode says when a buffer may be read. buffer-protocol.md § Consumer Requirement
puts the duty on the consumer: for event and consumer_stream, wait on the producer's
device event before touching a byte.
Everything the Python binding constructs is producer_synced, and that is a consequence
rather than a default — the interpreter cannot enqueue device work through this API, so
it cannot promise anything else. There is deliberately no sync_mode= keyword: a
settable field could only author a contract nothing could honour.
tensor = hurray.Tensor(bytes(64), hurray.float32, [16])
assert tensor.buffer_handles[0].sync_mode == "producer_synced"
A tensor decoded from a stream, a file, or another producer's capsule reports what that
producer declared. If it is not producer_synced, the paths that hand out bytes —
buffer(), .values / .indices, __array__, to_torch, __dlpack__ — refuse, since
the binding cannot perform the wait the contract requires. Relaying such a tensor onward
with __hurray__ or StreamWriter.write still works: relaying a declaration is not
reading a byte.
Device Colocation
All buffers in a single tensor (data + quantization parameters) must reside on the same device and in the same memory class. Validate this before processing:
use hurray_core::{BufferHandle, DeviceTag, SyncMode, validate_colocation};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let data_buffer = BufferHandle::new(1024, 64, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
let scale_buffer = BufferHandle::new(16, 64, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
// All on CPU with Standard memory class — passes.
let device = validate_colocation(&[data_buffer, scale_buffer])?;
assert_eq!(device, DeviceTag::Cpu);
Ok(())
}
Mixed devices are rejected:
use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode, validate_colocation};
fn main() {
let cpu_buf = BufferHandle::new(1024, 64, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let gpu_buf = BufferHandle::new(256, 4096, DeviceTag::Cuda, SyncMode::ProducerSynced).unwrap();
// Different devices — fails.
let result = validate_colocation(&[cpu_buf, gpu_buf]);
assert!(matches!(
result,
Err(Error::DeviceTagMismatch { expected: 0x00, found: 0x01 })
));
}
Mixed memory classes are also rejected — even when all buffers share the same device:
use hurray_core::{BufferHandle, DeviceTag, Error, MemoryClass, SyncMode, PAGE_ALIGNMENT, validate_colocation};
fn main() {
let standard = BufferHandle::new(4096, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event).unwrap();
let unified = BufferHandle::with_memory_class(
4096, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event, MemoryClass::Unified,
).unwrap();
// Same device, different memory class — fails.
let result = validate_colocation(&[standard, unified]);
assert!(matches!(
result,
Err(Error::MemoryClassMismatch { expected: 0x00, found: 0x02 })
));
}
Why? Quantized tensor kernels dereference both data and quantization parameters. Cross-device and cross-class transfers are expensive; colocation ensures efficient access. If buffers must use different memory classes, emit a separate tensor descriptor.
Device Tag Round-Trip
Serialize a device to its wire byte and back:
use hurray_core::DeviceTag;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Serialize: type → byte
let original = DeviceTag::Cuda;
let byte = original.to_byte();
assert_eq!(byte, 0x01);
// Deserialize: byte → type
let recovered = DeviceTag::from_byte(byte)?;
assert_eq!(original, recovered);
println!("Round-trip: {} → 0x{:02X} → {}", original, byte, recovered);
Ok(())
}
Bytes in the range 0x09–0xEF (reserved for future spec versions) and 0xFF (permanently invalid) are rejected:
use hurray_core::{DeviceTag, Error};
fn main() {
assert!(matches!(DeviceTag::from_byte(0x09), Err(Error::ReservedDeviceTag(_))));
assert!(matches!(DeviceTag::from_byte(0xFF), Err(Error::InvalidDeviceTag(_))));
}
Named Device Tags
The spec defines nine named device types:
| Tag | Variant | Use |
|---|---|---|
0x00 | DeviceTag::Cpu | Host memory |
0x01 | DeviceTag::Cuda | NVIDIA CUDA GPU |
0x02 | DeviceTag::Rocm | AMD ROCm GPU |
0x03 | DeviceTag::Metal | Apple Silicon (Metal/MPS) |
0x04 | DeviceTag::Vulkan | Vulkan cross-platform GPU compute |
0x05 | DeviceTag::WebGpu | WebGPU (browser inference) |
0x06 | DeviceTag::Hexagon | Qualcomm HVX/HMX DSP |
0x07 | DeviceTag::LevelZero | Intel Level Zero / oneAPI |
0x08 | DeviceTag::OpenCl | OpenCL (embedded/legacy GPU) |
Tags 0x09–0xEF are reserved; 0xF0–0xFE are private; 0xFF is permanently invalid.
Key Takeaways
- DeviceTag identifies where a buffer resides (CPU, GPU, or custom hardware)
- MemoryClass describes how it is accessible: Standard (device-private), HostPinned, Unified, or Peer
- Alignment must be a power of two; at least 64 bytes for non-empty, any power-of-two for empty
- Page alignment (4096 bytes) recommended for GPU and IPC buffers
- Colocation validation requires all buffers to share both the same device tag and the same memory class
- Private tags (
0xF0–0xFE) allow vendor-specific devices or memory classes but require out-of-band agreement - Empty buffers are never dereferenced; alignment rules are waived
- In Python, alignment is measured from the address rather than asserted, and ingest copies an under-aligned source unless
copy=Falsetells it to refuse instead hurray.aligned_allocator()removes the copy for arrays you allocate yourself, via NumPy's NEP 49 handler — per-array and thread-local, so a child thread does not inherit itsync_modeis read-only in Python, and a buffer that is notproducer_syncedrefuses every path that hands out bytes
See docs/spec/buffer-protocol.md for the normative specification.
Layer 2: Quantization Descriptors
Purpose
Quantization descriptors specify how tensor elements are dequantized when retrieved from storage. Hurray supports five schemes covering per-tensor, per-channel, and per-block quantization strategies—essential for inference on quantized LLMs and diffusion models. Each scheme is encoded into a binary descriptor that precedes the tensor data.
Per-Tensor Affine Quantization (INT8)
When a single scale and zero point apply uniformly to all elements (the simplest case):
use hurray_core::{PerTensorAffine, QuantizationDescriptor};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a per-tensor affine descriptor: scale=0.015625, zero_point=128
let q = PerTensorAffine::new(0.015625, 128)?;
let desc = QuantizationDescriptor::PerTensorAffine(q);
// Encode to bytes (16 bytes total: 4-byte header + 8 bytes scale/zp + 4 reserved)
let mut buf = vec![0u8; desc.encoded_len()];
let written = desc.encode_into(&mut buf)?;
println!("Encoded: {} bytes", written); // 16
// Decode back
let (decoded, consumed) = QuantizationDescriptor::decode(&buf)?;
assert_eq!(consumed, 16);
assert_eq!(decoded, desc);
// Dequantization formula: x_real = scale * (q - zero_point)
// where q is a raw int8 value from storage
Ok(())
}
import hurray
# scale=0.015625, zero_point=128
scheme = hurray.PerTensorAffine(0.015625, 128)
wire = scheme.encode()
assert len(wire) == 16 # 4-byte header + 8 bytes scale/zp + 4 reserved
assert hurray.decode_quantization(wire) == scheme
# Dequantization formula: x_real = scale * (q - zero_point), where q is a raw
# int8 from storage. Applying it is the consuming framework's job, not the codec's.
Use case: Uniform quantization across an entire weight matrix or activation tensor.
Per-Channel Affine Quantization (Output Channel Scaling)
When each output channel has its own scale (typical in INT8 quantized LLM weights):
use hurray_core::{PerChannelAffine, QuantizationDescriptor, BufferHandle, DeviceTag, MIN_BUFFER_ALIGNMENT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Symmetric mode: scale array only, zero point implicit (all zeros)
let q_sym = PerChannelAffine::new_symmetric(
0, // quantization axis (e.g., output channels)
1, // scale buffer index in tensor's buffer table
)?;
let desc_sym = QuantizationDescriptor::PerChannelAffine(q_sym);
let encoded_sym = desc_sym.encode_to_vec();
println!("Symmetric per-channel: {} bytes", encoded_sym.len()); // 20
// Asymmetric mode: both scale and zero_point arrays
let q_asym = PerChannelAffine::new_asymmetric(
1, // quantization axis
2, // scale buffer index
3, // zero_point buffer index
)?;
let desc_asym = QuantizationDescriptor::PerChannelAffine(q_asym);
let encoded_asym = desc_asym.encode_to_vec();
println!("Asymmetric per-channel: {} bytes", encoded_asym.len()); // 20
// Dequantization formula: x_real = scale[c] * (q - zero_point[c])
// where c = logical_index[axis]
Ok(())
}
import hurray
scheme = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)
assert scheme.axis == 0
assert hurray.decode_quantization(scheme.encode()) == scheme
Use case: Per-output-channel quantization in transformer weight matrices; achieves better accuracy than per-tensor.
Per-Block Affine Quantization (QLoRA-Style)
Divide a tensor into fixed-size blocks along one axis; each block carries its own scale and (optionally) zero point:
use hurray_core::{PerBlockAffine, QuantizationDescriptor, ElementType};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Symmetric: scale only (zero_point implicit)
let q_sym = PerBlockAffine::new_symmetric(
0, // quantization axis
64, // block size (must be power of two ≥ 2)
1, // scale buffer index
ElementType::Float32, // scale element type
)?;
let desc_sym = QuantizationDescriptor::PerBlockAffine(q_sym);
let encoded_sym = desc_sym.encode_to_vec();
println!("Per-block symmetric: {} bytes", encoded_sym.len()); // 24
// Asymmetric: scale and zero_point arrays
let q_asym = PerBlockAffine::new_asymmetric(
1, // quantization axis
32, // block size
2, // scale buffer index
3, // zero_point buffer index
ElementType::Float16, // scale in float16 (more compact)
)?;
let desc_asym = QuantizationDescriptor::PerBlockAffine(q_asym);
let encoded_asym = desc_asym.encode_to_vec();
println!("Per-block asymmetric: {} bytes", encoded_asym.len()); // 24
// Dequantization formula: x_real = scale[b] * (q - zero_point[b])
// where b = block_index = logical_index[axis] / block_size
Ok(())
}
import hurray
scheme = hurray.PerBlockAffine.symmetric(
axis=0, block_size=64, scale_buffer_index=1, scale_type=hurray.float32
)
assert scheme.symmetric
assert hurray.decode_quantization(scheme.encode()) == scheme
Scale types: Float16, BFloat16, or Float32 (controlled per descriptor).
Use case: QLoRA-style quantization; balances compression and accuracy in large language models.
NF4 Block Quantization
A non-linear 4-bit scheme with 16 fixed quantization levels (from QLoRA). Each block has a single absolute-maximum scale:
use hurray_core::{Nf4, QuantizationDescriptor, NF4_LUT};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// NF4: 4-bit storage, 16 fixed levels
let q = Nf4::new(
0, // quantization axis
64, // block size (must be power of two ≥ 8)
1, // scale buffer index (contains absmax values)
)?;
let desc = QuantizationDescriptor::Nf4(q);
let encoded = desc.encode_to_vec();
println!("NF4: {} bytes", encoded.len()); // 16
// Inspect the fixed NF4 lookup table
println!("NF4 levels (indexed by 4-bit code):");
for (i, &level) in NF4_LUT.iter().enumerate() {
println!(" [{}] = {:.4}", i, level);
}
// Dequantization formula: x_real = scale[b] * NF4_LUT[q]
// where q ∈ [0, 15] (the 4-bit storage code)
// and b = block_index = logical_index[axis] / block_size
Ok(())
}
import hurray
scheme = hurray.NF4(axis=0, block_size=64, scale_buffer_index=1)
assert scheme.block_size == 64
assert hurray.decode_quantization(scheme.encode()) == scheme
Block size constraint: Must be a power of two ≥ 8.
Use case: Quantization of weight matrices in LLMs using the QLoRA approach; achieves ≤4-bit effective precision with minimal accuracy loss.
MXFP Block Quantization (OCP Microscaling)
Open Compute Project Microscaling (OCP MX) format: blocks share a single exponent-only scale in float8_e8m0 format. Requires exact divisibility (no partial trailing blocks):
use hurray_core::{Mxfp, QuantizationDescriptor, MXFP_CANONICAL_BLOCK_SIZE};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// MXFP: standard block size from OCP MX spec
let q = Mxfp::new(
0, // quantization axis
MXFP_CANONICAL_BLOCK_SIZE, // 32 (canonical OCP MX v1.0 block size)
1, // scale buffer index
)?;
let desc = QuantizationDescriptor::Mxfp(q);
let encoded = desc.encode_to_vec();
println!("MXFP: {} bytes", encoded.len()); // 16
// Alternative: custom block size (still must be power-of-two in [16, 2048])
let q_custom = Mxfp::new(0, 64, 1)?;
println!("MXFP with block_size=64: valid");
// IMPORTANT: Unlike per-block affine, MXFP requires exact divisibility.
// If shape[axis] is 1000 and block_size is 32, this descriptor is INVALID
// because 1000 is not divisible by 32.
// Dequantization formula: x_real = 2^(e - 127) * q
// where e is the float8_e8m0 scale byte from scale buffer
Ok(())
}
import hurray
scheme = hurray.MXFP(axis=0, block_size=32, scale_buffer_index=1)
assert scheme.block_size == 32
assert hurray.decode_quantization(scheme.encode()) == scheme
Block size constraint: Power of two in [16, 2048] (inclusive).
Divisibility requirement: shape[axis] must be a positive multiple of block_size. No partial blocks.
Use case: Hardware-friendly quantization for inference accelerators supporting OCP Microscaling (NVIDIA H100+, etc.).
Choosing a Quantization Scheme
| Scheme | Scope | Grain | Storage | Best For |
|---|---|---|---|---|
| Per-Tensor Affine | Entire tensor | Single scale/ZP | 8 bytes payload | Uniform quantization, simplicity |
| Per-Channel Affine | One axis | One scale/ZP per slice | Separate scale/ZP buffers | LLM weight quantization (INT8) |
| Per-Block Affine | Blocks along axis | One scale/ZP per block | Separate scale/ZP buffers | Moderate compression (QLoRA) |
| NF4 | Blocks along axis | One absmax per block | Fixed 16-level LUT | Aggressive 4-bit quantization |
| MXFP | Blocks along axis | Exponent-only scale | Separate scale buffer (float8_e8m0) | Hardware-accelerated OCP MX targets |
Validating Buffer Placement
Quantization descriptors reference external buffers (for scales and zero points). Validate placement before building a tensor descriptor:
use hurray_core::{
BufferHandle, DeviceTag, Nf4, QuantizationDescriptor, SyncMode,
validate_buffer_placement, MIN_BUFFER_ALIGNMENT,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create buffers on the same device
let data_buf = BufferHandle::new(4096, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
let scale_buf = BufferHandle::new(256, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
let buffers = [data_buf, scale_buf];
// Create an NF4 descriptor with scale at buffer index 1
let q = Nf4::new(0, 64, 1)?;
let desc = QuantizationDescriptor::Nf4(q);
// Validate: scale_buffer_index=1 must be in range, not alias data (index 0),
// and be on the same device.
validate_buffer_placement(&desc, &buffers, 0)?;
println!("Buffer placement valid!");
Ok(())
}
import hurray
# Python checks placement when the tensor is built, against the buffers it was
# actually given — there is no separate descriptor to validate in isolation.
tensor = hurray.Tensor(
bytes(4096),
hurray.dtype.int4,
[64, 64],
aux_buffers=[bytes(256)],
quantization=hurray.NF4(axis=0, block_size=64, scale_buffer_index=1),
)
assert tensor.buffer_count == 2
# An index that names no buffer is refused.
try:
hurray.Tensor(
bytes(4096),
hurray.dtype.int4,
[64, 64],
aux_buffers=[bytes(256)],
quantization=hurray.NF4(axis=0, block_size=64, scale_buffer_index=7),
)
raise AssertionError("buffer 7 does not exist")
except hurray.InvalidDescriptorError as exc:
print(exc)
Constraints checked:
- All quantization parameter buffer indices are within
buffers.len(). - No quantization buffer index equals the data buffer index (no aliasing).
- All quantization buffers are on the same device as the data buffer.
Encoding and Decoding
Use encode_into for streaming writers (zero-alloc) or encode_to_vec for convenience:
use hurray_core::{PerTensorAffine, QuantizationDescriptor};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let desc = QuantizationDescriptor::PerTensorAffine(
PerTensorAffine::new(0.5, 0)?
);
// Zero-alloc path (required for streaming)
let mut buf = vec![0u8; desc.encoded_len()];
let written = desc.encode_into(&mut buf)?;
assert_eq!(written, desc.encoded_len());
// Decode and verify round-trip
let (decoded, consumed) = QuantizationDescriptor::decode(&buf)?;
assert_eq!(consumed, written);
assert_eq!(decoded, desc);
println!("Roundtrip successful!");
Ok(())
}
import hurray
scheme = hurray.PerTensorAffine(0.5, 0)
# 16 to 24 bytes, so there is no zero-alloc variant to reach for: the section
# is smaller than the cost of arranging to avoid the allocation.
wire = scheme.encode()
assert len(wire) == 16
assert hurray.decode_quantization(wire) == scheme
decode_quantization returns whichever of the five classes the section's scheme tag
names, so a caller reads the bytes without knowing in advance which scheme wrote them —
which is what a tagged section is for. Trailing bytes are ignored: the section carries its
own length, which is what lets it sit inside a descriptor with other sections after it.
Note: encode_into is preferred in hot paths (streaming readers/writers) because it avoids allocation. The descriptor itself is small (16–24 bytes), so encode_to_vec is fine for initialization paths.
Layer 3: Layout Descriptors
Purpose
A layout descriptor tells a reader how the elements of a tensor are arranged in memory. Every tensor descriptor includes exactly one layout tag byte followed by layout-specific fields. The hurray-core LayoutDescriptor enum models all layouts defined in the spec, from the zero-overhead unit variants (RowMajor, ColMajor) to sparse multi-buffer formats and permissive-mode passthrough.
Quick reference: layout tags and buffer counts
| Variant | Tag | Buffer count | Notes |
|---|---|---|---|
RowMajor | 0x01 | 1 | No fields; strides are implicit |
ColMajor | 0x02 | 1 | No fields; strides are implicit |
Strided | 0x03 | 1 | Explicit strides: Vec<i64>; negative/zero valid |
Tiled | 0x04 | 1 | Tile shape, outer/inner layout tags, optional strides; recursive |
Morton | 0x05 | 1 | Per-dimension bit counts |
Coo | 0x06 | 2 | nnz, is_sorted; values + index buffers |
Csr | 0x07 | 3 | nnz; values + col_indices + row_ptr; rank-2 only |
Csc | 0x08 | 3 | nnz; values + row_indices + col_ptr; rank-2 only |
Csf | 0x09 | 2·rank+1 | nnz, mode_order permutation; values + per-level pos/crd; rank-3+ generalization of CSR/CSC. See CSF (Compressed Sparse Fiber) |
BlockPaged | 0x0A | 3 | PagedAttention KV cache; page_pool + block_table + seq_ptr; rank-3 only. See Block-Paged KV Cache |
Hilbert | 0x40 | 1 | hilbert_order, hilbert_rank; dims must be 2^order |
PrivateExtension | 0xF0–0xFE | None | Opaque; requires out-of-band agreement |
Unknown | any unrecognised | None | Permissive mode only; never dereference data |
Constructing dense layouts
Unit variants need no constructor:
use hurray_core::layout::LayoutDescriptor;
let rm = LayoutDescriptor::RowMajor;
let cm = LayoutDescriptor::ColMajor;
assert_eq!(rm.tag(), 0x01);
assert_eq!(cm.tag(), 0x02);
import hurray
rm = hurray.RowMajorLayout()
cm = hurray.ColMajorLayout()
assert rm.tag == 0x01
assert cm.tag == 0x02
Python spells each layout as its own class rather than a tag byte (ADR-032); the tag is still there on every one of them.
Strided layout — explicit per-dimension strides in logical elements. Negative strides reverse a dimension; zero strides broadcast (virtual dimension, no physical replication):
use hurray_core::layout::{LayoutDescriptor, StridedLayout};
// Row-major strides for a 3×4 tensor: last dim varies fastest.
let rm_strides = LayoutDescriptor::Strided(StridedLayout::new(vec![4, 1]));
// Same tensor with first dimension reversed.
let reversed = LayoutDescriptor::Strided(StridedLayout::new(vec![-4, 1]));
// Broadcast along dimension 0: all rows map to row 0.
let broadcast = LayoutDescriptor::Strided(StridedLayout::new(vec![0, 1]));
import hurray
# Row-major strides for a 3x4 tensor: last dim varies fastest.
rm_strides = hurray.StridedLayout([4, 1])
# Same tensor with first dimension reversed.
reversed_ = hurray.StridedLayout([-4, 1])
# Broadcast along dimension 0: all rows map to row 0.
broadcast = hurray.StridedLayout([0, 1])
assert rm_strides.strides == (4, 1)
Tiled / blocked layout
2×4 tiles with row-major outer ordering and column-major inner ordering:
use hurray_core::layout::{LayoutDescriptor, TiledLayout};
let tiled = LayoutDescriptor::Tiled(Box::new(
TiledLayout::new(
vec![2, 4], // tile_shape
0x01, // outer_layout: row-major
0x02, // inner_layout: column-major
None, // outer_strides: None (implicit for row-major outer)
None, // inner_strides: None
None, // inner_tiled: None (not recursive)
).unwrap(),
));
import hurray
tiled = hurray.TiledLayout(
[2, 4], # tile_shape
outer_layout="row_major",
inner_layout="col_major",
)
The nested layouts are named, not tagged: "row_major" rather than 0x01. Python
reads the tag back off layout.tag when it needs the wire value.
Strided tile grid — outer_strides must be provided when outer_layout == 0x03:
use hurray_core::layout::{LayoutDescriptor, OuterStrides, TiledLayout};
let tiled_strided = LayoutDescriptor::Tiled(Box::new(
TiledLayout::new(
vec![2, 2],
0x03, // strided outer
0x01, // row-major inner
Some(OuterStrides::new(vec![2, 1])), // tile-grid strides in units of tiles
None,
None,
).unwrap(),
));
import hurray
tiled_strided = hurray.TiledLayout(
[2, 2],
outer_layout="strided", # tile-grid strides required
inner_layout="row_major",
outer_strides=[2, 1], # in units of tiles
)
Recursive tiling (two levels of blocking, useful for hierarchical GEMM caches):
use hurray_core::layout::TiledLayout;
let inner = TiledLayout::new(vec![4, 4], 0x01, 0x01, None, None, None).unwrap();
let outer = TiledLayout::new(
vec![32, 32],
0x01,
0x04, // inner_layout is itself tiled
None,
None,
Some(Box::new(inner)),
).unwrap();
import hurray
inner = hurray.TiledLayout([4, 4], "row_major", "row_major")
outer = hurray.TiledLayout(
[32, 32],
outer_layout="row_major",
inner_layout="tiled", # the inner layout is itself tiled
inner_tiled=inner,
)
Maximum recursion depth is 8 levels; deeper nesting returns Error::InvalidLayout.
Sparse layouts
COO — two buffers (values + flat index array):
use hurray_core::layout::{CooLayout, LayoutDescriptor};
let coo = LayoutDescriptor::Coo(CooLayout::new(
42, // nnz
true, // is_sorted: non-zeros in lexicographic order
));
assert_eq!(coo.buffer_count().map(|n| n.get()), Some(2));
import hurray
coo = hurray.CooLayout(
nnz=42,
is_sorted=True, # non-zeros in lexicographic order
)
assert coo.buffer_count == 2
CSR — three buffers (values + col_indices + row_ptr), rank-2 only:
use hurray_core::layout::{CsrLayout, LayoutDescriptor};
let csr = LayoutDescriptor::Csr(CsrLayout::new(100)); // nnz = 100
assert_eq!(csr.buffer_count().map(|n| n.get()), Some(3));
import hurray
csr = hurray.CsrLayout(nnz=100)
assert csr.buffer_count == 3
CSC — three buffers (values + row_indices + col_ptr), rank-2 only:
use hurray_core::layout::{CscLayout, LayoutDescriptor};
let csc = LayoutDescriptor::Csc(CscLayout::new(100));
assert_eq!(csc.buffer_count().map(|n| n.get()), Some(3));
import hurray
csc = hurray.CscLayout(nnz=100)
assert csc.buffer_count == 3
CSF (Compressed Sparse Fiber) — the rank-N (rank ≥ 3) generalization of CSR/CSC, with
2·rank + 1 buffers (values plus a pos/crd pair per level). The buffer count is
derived from the rank, which CsfLayout carries via its mode_order permutation
(mode_order[L] is the logical dimension stored at level L). Writers SHOULD prefer
CSR/CSC for rank-2 sparse matrices and reserve CSF for rank ≥ 3:
use hurray_core::layout::{CsfLayout, LayoutDescriptor};
use hurray_core::Shape;
// Rank-3 sparse tensor, identity mode order, 4 non-zeros.
let csf = LayoutDescriptor::Csf(CsfLayout::new(4, vec![0, 1, 2]));
assert_eq!(csf.tag(), 0x09);
assert_eq!(csf.buffer_count().map(|n| n.get()), Some(7)); // 2*3 + 1
// rank ≥ 3 only; CSR/CSC own rank-2.
let shape = Shape::new(vec![2, 3, 4]).unwrap();
assert!(csf.validate_against_shape(&shape).is_ok());
assert!(csf
.validate_against_shape(&Shape::new(vec![3, 4]).unwrap())
.is_err());
import hurray
# Rank-3 sparse tensor, identity mode order, 4 non-zeros.
csf = hurray.CsfLayout(nnz=4, mode_order=[0, 1, 2])
assert csf.tag == 0x09
assert csf.buffer_count == 7 # 2*3 + 1
# rank >= 3 only; CSR/CSC own rank-2.
csf.validate_against_shape([2, 3, 4])
try:
csf.validate_against_shape([3, 4])
raise AssertionError("rank-2 should be refused")
except hurray.InvalidDescriptorError:
pass
See CSF (Compressed Sparse Fiber) for the full per-level buffer layout and lookup.
Space-filling curve layouts
Morton (Z-order) — per-dimension bit counts control how many index bits are
interleaved per dimension. Each shape[k] must satisfy shape[k] <= 2^morton_bits[k]:
use hurray_core::layout::{LayoutDescriptor, MortonLayout};
use hurray_core::Shape;
// 4×4 tensor: each dim needs 2 bits (4 <= 2^2).
let morton = LayoutDescriptor::Morton(MortonLayout::new(vec![2, 2]).unwrap());
let shape = Shape::new(vec![4, 4]).unwrap();
morton.validate_against_shape(&shape).unwrap();
import hurray
# 4x4 tensor: each dim needs 2 bits (4 <= 2^2).
morton = hurray.MortonLayout([2, 2])
morton.validate_against_shape([4, 4])
Hilbert curve — all dims must equal 2^hilbert_order; rank must be >= 2:
use hurray_core::layout::{HilbertLayout, LayoutDescriptor};
use hurray_core::Shape;
// 8×8×8 tensor: order=3 (8 = 2^3), rank=3.
let hilbert = LayoutDescriptor::Hilbert(HilbertLayout::new(3, 3).unwrap());
let shape = Shape::new(vec![8, 8, 8]).unwrap();
hilbert.validate_against_shape(&shape).unwrap();
import hurray
# 8x8x8 tensor: order=3 (8 = 2^3), rank=3.
hilbert = hurray.HilbertLayout(hilbert_order=3, hilbert_rank=3)
hilbert.validate_against_shape([8, 8, 8])
Tag introspection and validation
use hurray_core::layout::{
validate_layout_tag_strict, is_invalid_tag, is_named_tag, is_reserved_tag,
is_private_tag, LayoutDescriptor, UnknownLayout,
};
use hurray_core::Error;
// Check individual tag categories without constructing a descriptor.
// 0x10 is a genuinely unassigned tag in the Tier-1 reserved range.
assert!(is_invalid_tag(0x00));
assert!(is_named_tag(0x07)); // CSR — this crate knows how to check it
assert!(is_reserved_tag(0x10));
assert!(is_private_tag(0xF3));
// Strict-mode validation: rejects invalid, reserved, and private tags.
assert!(validate_layout_tag_strict(0x01).is_ok());
assert!(matches!(validate_layout_tag_strict(0x00), Err(Error::InvalidLayoutTag(0x00))));
assert!(matches!(validate_layout_tag_strict(0x10), Err(Error::ReservedLayoutTag(0x10))));
assert!(matches!(validate_layout_tag_strict(0xF0), Err(Error::PrivateLayoutTag(0xF0))));
// Permissive mode: wrap unrecognised tags in Unknown for passthrough.
// The reader must NOT dereference the tensor data buffer for Unknown layouts.
let unknown = LayoutDescriptor::Unknown(UnknownLayout::new(0x10, vec![]).unwrap());
assert_eq!(unknown.tag(), 0x10);
assert!(unknown.buffer_count().is_none());
// Only genuinely unrecognised tags: "unknown" is a claim, and it has to be true.
// A named tag wrapped this way would skip every check its own variant applies
// while still encoding to that tag on the wire.
assert!(matches!(UnknownLayout::new(0x07, vec![]), Err(Error::NamedLayoutTag(0x07))));
assert!(matches!(UnknownLayout::new(0xF0, vec![]), Err(Error::PrivateLayoutTag(0xF0))));
import hurray
# Classify a tag without constructing a descriptor. The four categories partition
# the byte space, so one call answers the question four predicates would.
assert hurray.layout_tag_kind(0x07) == "named" # CSR
assert hurray.layout_tag_kind(0x10) == "reserved"
assert hurray.layout_tag_kind(0xF3) == "private"
assert hurray.layout_tag_kind(0x00) == "invalid"
# Permissive mode: wrap an unrecognised tag for passthrough.
# The reader must NOT dereference the tensor data buffer for an Unknown layout.
unknown = hurray.UnknownLayout(0x10, b"")
assert unknown.tag == 0x10
assert unknown.buffer_count is None
# Only genuinely unrecognised tags: "unknown" is a claim, and it has to be true.
# A named tag wrapped this way would skip every check its own class applies while
# still encoding to that tag on the wire.
for taken in (0x07, 0xF0):
try:
hurray.UnknownLayout(taken, b"")
raise AssertionError(f"0x{taken:02X} is not unknown")
except ValueError as exc:
print(exc)
The four kinds call for different reactions, which is why the classification is worth having: reserved most likely means the producer is newer than this reader, so relaying the tensor on is reasonable while interpreting its bytes is not; private belongs to an out-of-band agreement; invalid means corruption or a framing error.
Validating a descriptor against a tensor shape
validate_against_shape is called by Layer 4 (tensor descriptor) to enforce
layout-specific rank and dimension constraints. Call it explicitly when building
descriptors to catch mismatches early:
use hurray_core::layout::{CsrLayout, LayoutDescriptor};
use hurray_core::Shape;
let csr = LayoutDescriptor::Csr(CsrLayout::new(5));
// Rank-2: valid.
assert!(csr.validate_against_shape(&Shape::new(vec![4, 5]).unwrap()).is_ok());
// Rank-3: rejected — CSR is only defined for rank-2 tensors.
assert!(csr.validate_against_shape(&Shape::new(vec![2, 3, 4]).unwrap()).is_err());
import hurray
csr = hurray.CsrLayout(nnz=5)
# Rank-2: valid.
csr.validate_against_shape([4, 5])
# Rank-3: rejected - CSR is only defined for rank-2 tensors.
try:
csr.validate_against_shape([2, 3, 4])
raise AssertionError("rank-3 should be refused")
except hurray.InvalidDescriptorError:
pass
hurray.Tensor runs this for you at construction. Call it directly when you are
choosing a layout for a shape you have not built a tensor for yet.
Private extension layouts
For hardware-specific panel/pack formats agreed out of band:
use hurray_core::layout::{LayoutDescriptor, PrivateExtensionLayout};
let private = LayoutDescriptor::PrivateExtension(
PrivateExtensionLayout::new(
0xF0, // tag: must be 0xF0–0xFE
0xDEAD_BEEF_0000_0001, // implementation-defined layout ID
vec![0x01, 0x00, 0x04], // opaque metadata
).unwrap(),
);
// buffer_count is None: the format doesn't know how many buffers this needs.
assert!(private.buffer_count().is_none());
import hurray
private = hurray.PrivateExtensionLayout(
0xF0, # tag: must be 0xF0-0xFE
0xDEAD_BEEF_0000_0001, # implementation-defined layout ID
b"\x01\x00\x04", # opaque metadata
)
# buffer_count is None: the format doesn't know how many buffers this needs.
assert private.buffer_count is None
Layer 4: Tensor Descriptor Encoding
Purpose
A TensorDescriptor is the top-level carrier for all metadata required to interpret a tensor's
data buffer: element type, rank, shape, memory layout, buffer handles, and optional quantization,
shard, statistics, and extension-type annotations.
The binary format is defined in docs/spec/metadata.md. A 20-byte fixed header is followed by
variable-length core fields, layout-specific payload, a buffer table, and up to four optional
sections selected by a flags bitmask.
Runnable example: cargo run --example encode_decode_descriptor
Quick reference: wire format sections
| Section | Always present? | Controlled by |
|---|---|---|
| Fixed header (20 bytes) | Yes | Always |
Shape uint64[rank] | Yes | shape.rank() |
byte_offset uint64 | Yes | Always |
| Layout payload | Yes | layout.tag() |
| Buffer table | Yes | buffers.len() |
| Quantization | HAS_QUANTIZATION flag | quantization.is_some() |
| Shard | HAS_SHARD flag | shard.is_some() |
| Extension type | HAS_EXTENSION_TYPE flag | extension_type.is_some() |
| Statistics | HAS_STATISTICS flag | statistics.is_some() |
Encoding and decoding
The spec's worked example — float32 [3, 4] row-major, one CPU buffer — encodes to exactly
61 bytes:
use hurray_core::{
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
descriptor::TensorDescriptor,
layout::LayoutDescriptor,
};
let shape = Shape::new(vec![3u64, 4]).unwrap();
// 192 = 3 × 64 bytes (one cache line per row) — the spec's worked example allocation.
// The tensor data itself needs only 3×4×4 = 48 bytes; byte_size records the physical
// allocation which may exceed the data footprint. In real code use buffer_size_bytes().
let buffer = BufferHandle::new(192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let desc = TensorDescriptor::new(
1, 0, // version_major, version_minor
ElementType::Float32,
shape,
0, // byte_offset
LayoutDescriptor::RowMajor,
vec![buffer],
None, // quantization
None, // shard
None, // statistics
None, // extension_type
).unwrap();
let bytes = desc.encode().unwrap();
assert_eq!(bytes.len(), 61); // spec worked example
// Decode back — descriptor is byte-exact round-trip.
let decoded = TensorDescriptor::decode(&bytes).unwrap();
assert_eq!(decoded, desc);
import hurray
# A descriptor comes from a tensor rather than being built on its own: it is the
# half of a tensor that travels first, not a separate thing to assemble.
tensor = hurray.Tensor(bytes(192), hurray.float32, [3, 4])
descriptor = tensor.descriptor
wire = descriptor.encode()
assert len(wire) == 61 # spec worked example
# Decode back — byte-exact round trip.
assert hurray.Descriptor.decode(wire) == descriptor
Descriptor is not constructible from Python: a constructor would duplicate
hurray.Tensor's whole parameter list to build the half of it that carries no data.
Descriptors come from a tensor, from a composite head (Composite.descriptor), or from
decode.
Advisory statistics
Attach pre-computed statistics (value range, NaN/Inf presence, etc.) using
Statistics and StatisticsMask. Only the bits set in computed_mask carry
valid values; all other fields are zero and MUST be ignored by readers:
use hurray_core::{
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
descriptor::{Statistics, StatisticsMask, TensorDescriptor},
layout::LayoutDescriptor,
};
// Construct Statistics with all fields explicitly — only VALUE_RANGE_VALID and
// NAN_INF_VALID bits are set; unset-mask fields are zero (undefined by spec).
let stats = Statistics {
computed_mask: StatisticsMask(
StatisticsMask::VALUE_RANGE_VALID | StatisticsMask::NAN_INF_VALID,
),
nnz: 0,
sparsity_ratio: 0.0,
value_min: -1.0,
value_max: 1.0,
value_abs_max: 1.0,
value_mean: 0.0,
value_stddev: 0.0,
nm_n: 0,
nm_m: 0,
has_nan: false,
has_inf: false,
};
let shape = Shape::new(vec![8u64, 8]).unwrap();
let buffer = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let desc = TensorDescriptor::new(
1, 0, ElementType::Float16, shape, 0,
LayoutDescriptor::ColMajor, vec![buffer],
None, None, Some(stats), None,
).unwrap();
// Statistics section appends 72 bytes; encode then decode.
let bytes = desc.encode().unwrap();
let decoded = TensorDescriptor::decode(&bytes).unwrap();
let s = decoded.statistics.as_ref().unwrap();
assert!(s.computed_mask.value_range_valid());
assert!(!s.has_nan);
import hurray
# Python derives computed_mask from which arguments you pass, so a value can
# never be present with its validity bit unset.
# value_min, value_max and value_abs_max share one validity bit, so they are
# supplied together — Python enforces that rather than letting you set a bit
# for a value you did not compute.
stats = hurray.Statistics(
value_min=-1.0, value_max=1.0, value_abs_max=1.0, has_nan=False, has_inf=False
)
tensor = hurray.Tensor(bytes(128), hurray.float16, [8, 8], statistics=stats)
decoded = hurray.Descriptor.decode(tensor.descriptor.encode())
assert decoded.statistics.value_min == -1.0
assert decoded.statistics.has_nan is False
Shard annotations
When a tensor is a rectangular sub-region of a larger logical tensor (e.g., a row shard of a
matrix), attach a ShardDescriptor. The parent_shape rank must match the tensor's rank and
shard_offset[k] + shape[k] <= parent_shape[k] must hold for every dimension k:
use hurray_core::{
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
descriptor::{ShardDescriptor, TensorDescriptor},
layout::LayoutDescriptor,
};
// Shard: rows 2048..3071 of a 4096×1024 parent matrix.
let shape = Shape::new(vec![1024u64, 1024]).unwrap();
let buffer = BufferHandle::new(524_288, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let shard = ShardDescriptor::new(
vec![4096u64, 1024], // parent_shape
vec![2048u64, 0], // shard_offset (origin in parent)
).unwrap();
let desc = TensorDescriptor::new(
1, 0, ElementType::Int4, shape, 0,
LayoutDescriptor::RowMajor, vec![buffer],
None, Some(shard), None, None,
).unwrap();
let bytes = desc.encode().unwrap();
let decoded = TensorDescriptor::decode(&bytes).unwrap();
let s = decoded.shard.as_ref().unwrap();
assert_eq!(s.parent_shape, [4096, 1024]);
assert_eq!(s.shard_offset, [2048, 0]);
import hurray
# Shard: rows 2048..3071 of a 4096x1024 parent matrix.
tensor = hurray.Tensor(
bytes(524_288),
hurray.dtype.int4,
[1024, 1024],
shard=hurray.Shard([4096, 1024], [2048, 0]),
)
decoded = hurray.Descriptor.decode(tensor.descriptor.encode())
assert decoded.shard.parent_shape == (4096, 1024)
assert decoded.shard.shard_offset == (2048, 0)
Validation errors
TensorDescriptor::new rejects invalid combinations:
use hurray_core::{
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT, Error,
descriptor::TensorDescriptor,
layout::LayoutDescriptor,
};
let shape = Shape::new(vec![4u64, 4]).unwrap();
let buffer = BufferHandle::new(64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
// Empty buffer table is rejected.
let result = TensorDescriptor::new(
1, 0, ElementType::Float32, shape.clone(), 0,
LayoutDescriptor::RowMajor, vec![], // no buffers
None, None, None, None,
);
assert!(matches!(result, Err(Error::EmptyBufferTable)));
// Extension type flag must be consistent with the element type tag.
// Float32 (tag 0x03) is not an extension type — providing ExtensionTypeDescriptor is an error.
use hurray_core::descriptor::ExtensionTypeDescriptor;
// An 8-bit float, 1-4-3. A float's sign is sign_bits; is_signed stays false.
let ext = ExtensionTypeDescriptor::new(8, 1, true, false, 1, 4, 3, 7, true, false).unwrap();
let result = TensorDescriptor::new(
1, 0, ElementType::Float32, shape.clone(), 0,
LayoutDescriptor::RowMajor, vec![buffer.clone()],
None, None, None,
Some(ext), // mismatch: Float32 is not an extension type
);
assert!(matches!(result, Err(Error::ExtensionTypeFlagMismatch { .. })));
Decode errors
TensorDescriptor::decode rejects malformed inputs:
use hurray_core::{descriptor::TensorDescriptor, Error};
// Truncated input.
let result = TensorDescriptor::decode(&[0x48, 0x52, 0x52, 0x59]);
assert!(matches!(
result,
Err(Error::DescriptorTooShort { .. } | Error::DescriptorTruncated { .. })
));
// Wrong magic bytes.
let mut bad = vec![0u8; 61];
bad[0..4].copy_from_slice(b"BAAD");
let result = TensorDescriptor::decode(&bad);
assert!(matches!(result, Err(Error::InvalidMagic { .. })));
Wire format anatomy (61-byte example)
Offset Size Field
────── ──── ─────────────────────────────────────────────────────────────
0x00 4 magic "HRRY" (0x48 0x52 0x52 0x59)
0x04 1 version_major = 0x01
0x05 1 version_minor = 0x00
0x06 4 descriptor_length = 61 (0x3D 0x00 0x00 0x00, little-endian)
0x0A 4 flags = 0x00000000 (no optional sections)
0x0E 1 type_tag = 0x03 (float32)
0x0F 1 layout_tag = 0x01 (row-major)
0x10 4 rank = 2 (0x02 0x00 0x00 0x00)
0x14 8 shape[0] = 3 (0x03 0x00 0x00 0x00 0x00 0x00 0x00 0x00)
0x1C 8 shape[1] = 4 (0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00)
0x24 8 byte_offset = 0
── layout payload: RowMajor has no additional bytes ──
0x2C 1 buffer_count = 1
0x2D 8 buffer[0].size_bytes = 192
0x35 4 buffer[0].alignment = 64 (0x40 0x00 0x00 0x00, little-endian uint32)
0x39 1 buffer[0].device_tag = 0x00 (CPU)
0x3A 3 buffer[0]._reserved = 0 0 0
── no optional sections (flags == 0) ──
Layer 5 — Streaming Interchange
This cookbook shows how to stream tensors between producers and consumers using
hurray-io's StreamWriter and StreamReader.
Feature flag
The streaming API requires the tokio feature:
# Cargo.toml
hurray-io = { path = "…/hurray-io", features = ["tokio"] }
Wire format
Tensors are written as bare concatenation — no outer framing, no padding:
[encoded TensorDescriptor][buffer 0 bytes][buffer 1 bytes]…
[encoded TensorDescriptor][buffer 0 bytes]…
…
EOF
The descriptor is self-delimiting: bytes 6–9 of every descriptor hold a
little-endian uint32 descriptor_length that tells the reader exactly how
many bytes to consume. Readers detect a clean EOF when zero bytes are available
before the first byte of a descriptor.
Writing a stream
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape,
SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::stream::StreamWriter;
async fn write_tensors(sink: impl tokio::io::AsyncWrite + Unpin)
-> hurray_io::Result<()>
{
let handle = BufferHandle::new(
192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let shape = Shape::new(vec![4u64, 6, 8]).unwrap();
let desc = TensorDescriptor::new(
1, 0,
ElementType::Float32,
shape,
0,
LayoutDescriptor::RowMajor,
vec![handle],
None, None, None, None,
)?;
let data = vec![0u8; 192];
let mut writer = StreamWriter::new(sink);
writer.write_tensor(&desc, &[&data]).await?;
writer.finish().await?; // flushes and returns the sink
Ok(())
}
import hurray
tensor = hurray.Tensor(bytes(768), hurray.float32, [4, 6, 8]) # 192 float32
# A path, an object with fileno(), or nothing at all for an in-memory buffer.
with hurray.StreamWriter("tensors.hrry") as writer:
writer.write(tensor)
# Leaving the block finishes the stream: the sink is flushed and closed.
The Python writer takes a hurray.Tensor, which already carries its descriptor and
its buffers, so the two cannot disagree — the length checks the Rust API performs have
nothing to check.
StreamWriter::write_tensor validates:
buffers.len()equalsdesc.buffers.len()— [Error::MultiBufferLengthMismatch]- each buffer's byte length equals its handle's
byte_size— [Error::BufferSizeMismatch]
Reading a stream
use hurray_io::stream::StreamReader;
async fn read_tensors(source: impl tokio::io::AsyncRead + Unpin)
-> hurray_io::Result<()>
{
let mut reader = StreamReader::new(source);
while let Some(tensor) = reader.next_tensor().await? {
println!(
"element_type={:?} buffers={}",
tensor.descriptor.element_type,
tensor.buffers.len(),
);
// tensor.buffers[i] is a `bytes::Bytes` — refcounted, zero-copy view
}
Ok(())
}
import hurray
for tensor in hurray.StreamReader("tensors.hrry"):
print(tensor.dtype, tensor.buffer_count)
# Nothing buffers the whole input: a tensor is available as soon as its
# descriptor and buffers have arrived. A clean end of stream ends the loop;
# a truncated one raises hurray.StreamError.
next_tensor() returns:
Ok(Some(StreamTensor))— a decoded tensorOk(None)— clean EOF (stream ended on a descriptor boundary)Err(Error::UnexpectedEof)— stream truncated mid-descriptor or mid-buffer
Cross-machine transport
When a stream crosses machine boundaries, GPU and semaphore sync primitives are
meaningless. Use cross_machine constructors to enforce ProducerSynced on
every buffer handle:
use hurray_io::stream::{StreamReader, StreamWriter};
let mut wire: Vec<u8> = Vec::new();
// Writer rejects any buffer whose sync_mode != ProducerSynced.
let mut writer = StreamWriter::cross_machine(&mut wire);
// Reader rejects any decoded buffer whose sync_mode != ProducerSynced.
let mut reader = StreamReader::cross_machine(wire.as_slice());
import hurray
# Writer refuses any buffer whose sync_mode is not "producer_synced".
writer = hurray.StreamWriter("tensors.hrry", cross_machine=True)
# Reader refuses any decoded buffer whose sync_mode is not "producer_synced".
reader = hurray.StreamReader("tensors.hrry", cross_machine=True)
Everything hurray-python constructs is producer_synced already (ADR-037 § 7), so
cross_machine=True costs a producer nothing — its value is on the reading end, and in
saying at the call site what the transport assumes.
Both constructors are equivalent to constructing with StreamReaderOptions /
checking manually, but they make the intent visible at the call site.
Configuring limits
Use StreamReaderOptions to protect against adversarial streams:
use hurray_io::stream::{StreamReader, StreamReaderOptions};
let source: &[u8] = &[];
let options = StreamReaderOptions {
max_descriptor_bytes: 1024 * 1024, // 1 MiB
max_buffer_bytes: 512 * 1024 * 1024, // 512 MiB
enforce_cross_machine_sync: true,
// Remaining limits keep their defaults; new ones get added over time.
..Default::default()
};
let mut reader = StreamReader::with_options(source, options);
import hurray
source = b"" # any source: a path, an object with fileno(), or a buffer
reader = hurray.StreamReader(
source,
max_descriptor_bytes=1 << 20, # 1 MiB
max_buffer_bytes=512 << 20, # 512 MiB
cross_machine=True,
)
Keyword arguments rather than an options object. max_descriptor_bytes defaults to
16 MiB, max_buffer_bytes is unbounded by default because a legitimate tensor can be
enormous, and max_composite_depth defaults to 64. Exceeding any of them raises
hurray.StreamError.
These matter because a descriptor's length field is read before its contents: a hostile stream can ask a reader to allocate whatever it likes, and a Python service reading from a socket is exposed exactly as a Rust one is.
The default max_descriptor_bytes is 16 MiB. max_buffer_bytes defaults to
u64::MAX (unbounded). Violations produce [Error::FrameTooLarge].
In-process pipe example
use tokio::io::duplex;
use hurray_io::stream::{StreamReader, StreamWriter};
#[tokio::main]
async fn main() {
let (mut client, mut server) = duplex(64 * 1024);
// Producer task
let producer = tokio::spawn(async move {
let mut writer = StreamWriter::new(&mut client);
// … write tensors …
// finish() hands back the inner writer; drop it rather than returning a
// borrow of `client`, which the spawned task would outlive.
writer.finish().await?;
hurray_io::Result::Ok(())
});
// Consumer task
let consumer = tokio::spawn(async move {
let mut reader = StreamReader::new(&mut server);
while let Some(tensor) = reader.next_tensor().await? {
// … process tensor …
}
hurray_io::Result::Ok(())
});
producer.await.unwrap().unwrap();
consumer.await.unwrap().unwrap();
}
import os
import threading
import hurray
read_fd, write_fd = os.pipe()
def produce():
with os.fdopen(write_fd, "wb") as sink:
with hurray.StreamWriter(sink) as writer:
writer.write(hurray.Tensor(bytes(16), hurray.float32, [4]))
producer = threading.Thread(target=produce)
producer.start()
with os.fdopen(read_fd, "rb") as source:
for tensor in hurray.StreamReader(source):
print(tensor.shape)
producer.join()
The reader releases the GIL while it waits on the pipe, so the producer thread runs.
Runnable example
cargo run --example stream_roundtrip --features tokio -p hurray-io
python hurray-python/examples/streaming.py
Sources: hurray-io/examples/stream_roundtrip.rs and
hurray-python/examples/streaming.py.
Error reference
| Error | Cause |
|---|---|
UnexpectedEof | Stream ended mid-descriptor or mid-buffer |
InvalidHeader | Descriptor prefix is malformed (e.g. descriptor_length < 10) |
FrameTooLarge | Descriptor or buffer exceeded configured limit |
MultiBufferLengthMismatch | buffers slice length ≠ desc.buffers.len() |
BufferSizeMismatch | A buffer's byte length ≠ its handle's byte_size |
InvalidCrossMachineSyncMode | Cross-machine mode + non-ProducerSynced buffer |
Core(…) | Descriptor encode/decode failed |
Io(…) | Underlying async I/O error |
Cookbook: Layer 6 — HRRYFILE Container Format
This guide shows how to write and read tensors using the hurray-io file format (HRRYFILE). The file format adds random-access lookup, optional KV metadata, and CRC-32C index integrity on top of the raw tensor stream.
Prerequisites
[dependencies]
hurray-core = { path = "../hurray-core" }
hurray-io = { path = "../hurray-io", features = ["tokio"] }
tokio = { version = "1", features = ["full"] }
Writing a file
FileWriter writes tensors in a single forward pass with no seeks. KV metadata and the footer index are flushed when you call finish.
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor,
Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::{FileWriter, FileWriterOptions, KvValue};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build a descriptor for a 4×4 float32 tensor (64 bytes)
let handle = BufferHandle::new(
64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let desc = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![4u64, 4])?,
0, LayoutDescriptor::RowMajor, vec![handle],
None, None, None, None,
)?;
let data: Vec<u8> = (0u8..64).collect();
// Write to a file; sorted_index enables binary search by readers
let file = tokio::fs::File::create("model.hrry").await?;
let opts = FileWriterOptions { sorted_index: true, ..Default::default() };
let mut writer = FileWriter::with_options(file, opts).await?;
writer.write_tensor("layer0.weight", &desc, &[&data]).await?;
writer.finish(vec![
("model".to_string(), KvValue::String("demo-v1".to_string())),
("layers".to_string(), KvValue::Uint64(1)),
]).await?;
println!("Wrote model.hrry");
Ok(())
}
import hurray
tensor = hurray.Tensor(bytes(range(64)), hurray.float32, [4, 4])
hurray.save(
"model.hrry",
{"layer0.weight": tensor},
kv={"model": "demo-v1", "layers": 1},
)
print("Wrote model.hrry")
One call rather than a writer object: save opens, writes every tensor in the dict,
flushes the KV section and the index, and closes. The forward-pass, no-seek property is
the writer's, not the caller's, so there is nothing to hold open.
Multi-buffer tensors
If a TensorDescriptor has multiple BufferHandles (e.g. quantized weight + scale), pass one &[u8] per buffer:
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerChannelAffine,
QuantizationDescriptor, Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::FileWriter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = |len: u64| {
BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
};
let quant = QuantizationDescriptor::PerChannelAffine(PerChannelAffine::new_symmetric(0, 1)?);
let desc = TensorDescriptor::new(
1, 0, ElementType::Int8, Shape::new(vec![2u64, 4])?, 0, LayoutDescriptor::RowMajor,
vec![handle(8)?, handle(8)?], Some(quant.encode_to_vec()), None, None, None,
)?;
let (weight_data, scale_data) = (vec![0u8; 8], vec![0u8; 8]);
let mut writer = FileWriter::new(tokio::fs::File::create("quantized.hrry").await?).await?;
writer.write_tensor("q_layer", &desc, &[&weight_data, &scale_data]).await?;
Ok(())
}
import struct
import hurray
weight_data = bytes(16) # 4x4 int8 weights
scale_data = struct.pack("4f", *[0.02] * 4) # one float32 scale per row
# A tensor carries its own buffers, so a multi-buffer one saves like any other.
quantized = hurray.Tensor(
weight_data,
hurray.dtype.int8,
[4, 4],
aux_buffers=[scale_data],
quantization=hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1),
)
hurray.save("quantized.hrry", {"q_layer": quantized})
Reading a file
FileReader requires a seekable source (AsyncRead + AsyncSeek). It reads the trailer on open, then seeks directly to each tensor on demand — no sequential scan.
use hurray_io::file::{FileReader, KvValue};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let file = tokio::fs::File::open("model.hrry").await?;
let mut reader = FileReader::open(file).await?;
// List all tensors (in index order — sorted if SORTED_INDEX was set)
println!("tensors: {:?}", reader.tensor_names().collect::<Vec<_>>());
// Read KV metadata
for (key, value) in reader.kv() {
println!("{key} = {value:?}");
}
// Load one tensor by name — seeks directly, skips others
let tensor = reader.read_tensor("layer0.weight").await?;
println!("shape: {:?}", tensor.descriptor.shape);
println!("buffer: {} bytes", tensor.buffers[0].len());
Ok(())
}
import hurray
# Every tensor in the file, by name.
tensors = hurray.load("model.hrry")
print("tensors:", sorted(tensors))
# The KV metadata section.
for key, value in hurray.load_kv("model.hrry").items():
print(f"{key} = {value!r}")
# One tensor by name — seeks directly, skips the others.
weight = hurray.load("model.hrry", names=["layer0.weight"])["layer0.weight"]
print("shape:", weight.shape)
print("buffer:", weight.buffer_handles[0].byte_size, "bytes")
load_kv is a separate call rather than an argument to load because it answers a
different question and returns a different thing. It costs a second open, which is a
footer read rather than a scan.
Descriptor-only reads
When you only need metadata (shape, element type) without loading the buffer bytes:
use hurray_io::file::FileReader;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut reader = FileReader::open(tokio::fs::File::open("model.hrry").await?).await?;
let desc = reader.read_descriptor("layer0.weight").await?;
println!("element type: {:?}", desc.element_type);
Ok(())
}
import hurray
# Python has no descriptor-only read: `load` returns tensors, whose metadata is
# already on the object. Naming one tensor is how you avoid paying for the rest.
weight = hurray.load("model.hrry", names=["layer0.weight"])["layer0.weight"]
print("element type:", weight.dtype.name)
Note (non-normative): a descriptor-only read has no Python counterpart yet. Reading one tensor's metadata still transfers its buffers.
KV value types
| Variant | Wire tag | Rust type | Python type |
|---|---|---|---|
KvValue::String(s) | 0x01 | UTF-8 string | str |
KvValue::Int64(v) | 0x02 | i64 | int |
KvValue::Uint64(v) | 0x03 | u64 | int (read only — see below) |
KvValue::Float64(v) | 0x04 | f64 | float |
KvValue::Bool(v) | 0x05 | bool | bool |
KvValue::Bytes(v) | 0x06 | raw bytes | bytes |
KvValue::Array(elems) | 0x07 | homogeneous non-empty array of the above | list |
Array elements must all share the same type and cannot be nested arrays.
In Python the mapping runs both ways — a dict passed to save(kv=...) comes back equal
from load_kv — with one asymmetry: Python's int writes as int64, so a value written
from Python never uses the uint64 tag, while one written by a Rust producer reads back
as an ordinary int. bool is checked before int, since Python's bool is a subclass
of it.
File layout overview
[ 64-byte file header ] magic "HRRYFILE", version, flags, alignment
[ Tensor region ] per tensor: descriptor → pad → buffer(s) → pad
[ KV section ] optional; count + (key, value) pairs
[ Index section ] count + (name, offsets, lengths, flags) entries
[ 40-byte trailer ] index_offset, index_length, kv_offset, kv_length,
index_crc32c, _reserved, magic "HRRY"
The reader locates the trailer at file_size - 40, reads offsets, verifies the CRC-32C of the index, then seeks to individual tensors. No full-file scan is ever needed.
Error handling
All errors are variants of hurray_io::Error:
| Error | Cause |
|---|---|
InvalidFileMagic | First 8 bytes are not HRRYFILE |
InvalidTrailerMagic | Last 4 bytes are not HRRY |
IndexCrc32cMismatch { stored, computed } | Index data is corrupt |
UnsupportedContainerVersion { major } | Future format version |
TensorNotFound(name) | No tensor with that name in the index |
DuplicateTensorName(name) | Writer received the same name twice |
TensorNameEmpty | Writer received an empty name string |
DuplicateKvKey(key) | finish() received duplicate KV keys |
Running the example
cargo run --example file_roundtrip -p hurray-io
Layer 7 — C FFI Cookbook
The hurray-ffi crate exposes a stable C ABI over hurray-core types.
All functions return a HurrayStatus integer (0 = OK, negative = error).
All handles are opaque — never inspect their internals.
Note (non-normative): this page has no Python tabs, and should not. The C ABI is the layer underneath a language binding —
hurray-pythonis one of its consumers, not a way to call it. A Python program that wants a Hurray tensor useshurray.Tensorand the__hurray__protocol; the function table here is for whoever is writing the next binding.
ABI version check
Always verify the ABI version at startup so mismatched builds are caught early.
C:
#include "hurray.h"
#include <assert.h>
void startup_check(void) {
uint32_t v = hurray_c_abi_version();
/* Compare against the header you compiled against, never a literal — the
ABI version moves, and a stale literal fails on the wrong builds. */
assert(v == HURRAY_C_ABI_VERSION && "unexpected Hurray C ABI version");
}
Rust (via FFI):
use hurray_ffi::{hurray_c_abi_version, HURRAY_C_ABI_VERSION};
assert_eq!(unsafe { hurray_c_abi_version() }, HURRAY_C_ABI_VERSION);
Creating a buffer with a release callback
The release callback is called exactly once by hurray_buffer_destroy.
Use it to free or unmap the underlying memory.
C:
#include "hurray.h"
#include <stdlib.h>
#include <stdio.h>
static void my_release(void *data, void *ctx) {
(void)ctx;
free(data);
printf("buffer freed\n");
}
HurrayBuffer *create_cpu_buffer(size_t n_bytes) {
void *data = aligned_alloc(64, n_bytes);
if (!data) return NULL;
HurrayBuffer *handle = NULL;
HurrayStatus s = hurray_buffer_from_ptr(
data, (uint64_t)n_bytes,
/*alignment=*/64,
/*device_tag=*/0x00, /* CPU */
/*sync_mode=*/0x00, /* ProducerSynced */
/*memory_class=*/0x00, /* Standard */
my_release, /*release_context=*/NULL,
&handle
);
if (s != HURRAY_OK) { free(data); return NULL; }
return handle;
}
Rust:
use hurray_ffi::{hurray_buffer_from_ptr, HurrayBuffer, HurrayReleaseCallback, HURRAY_OK};
use hurray_core::{DeviceTag, MemoryClass, SyncMode, MIN_BUFFER_ALIGNMENT};
use std::ffi::c_void;
unsafe extern "C" fn release(data: *mut c_void, _ctx: *mut c_void) {
drop(Vec::from_raw_parts(data as *mut u8, 4096, 4096));
}
let mut storage = vec![0u8; 4096];
let ptr = storage.as_mut_ptr() as *mut c_void;
std::mem::forget(storage);
let mut handle: *mut HurrayBuffer = std::ptr::null_mut();
let status = unsafe {
hurray_buffer_from_ptr(
ptr, 4096, MIN_BUFFER_ALIGNMENT,
DeviceTag::Cpu.to_byte(),
SyncMode::ProducerSynced.to_byte(),
MemoryClass::Standard.to_byte(),
Some(release),
std::ptr::null_mut(),
&mut handle,
)
};
assert_eq!(status, HURRAY_OK);
Destroying a buffer
HurrayStatus s = hurray_buffer_destroy(handle);
assert(s == HURRAY_OK);
/* handle is invalid after this point — do not dereference */
In debug builds, a second call to hurray_buffer_destroy on the same handle
returns HURRAY_ERR_INTERNAL (sentinel-based double-free detection).
Decoding a tensor descriptor
#include "hurray.h"
HurrayDescriptor *decode_descriptor(const uint8_t *bytes, size_t len) {
HurrayDescriptor *desc = NULL;
HurrayStatus s = hurray_descriptor_decode(bytes, len, &desc);
if (s != HURRAY_OK) return NULL; /* inspect s for the specific error */
return desc;
}
void inspect(HurrayDescriptor *desc) {
uint32_t rank;
hurray_descriptor_rank(desc, &rank);
uint64_t dims[64];
size_t capacity = rank;
hurray_descriptor_shape(desc, dims, &capacity);
/* capacity now holds the true rank; dims[0..rank] are the dimension sizes */
hurray_descriptor_destroy(desc);
}
Shape capacity/query pattern
hurray_descriptor_shape uses an in/out out_rank parameter:
- Set
*out_rankto the number ofuint64_tslots inout_dims. - If the function returns
HURRAY_ERR_BUFFER_TOO_SMALL,*out_rankhas been updated to the true rank — allocate that many slots and retry.
size_t cap = 0;
/* Query-only call: out_dims=NULL forces BUFFER_TOO_SMALL, writes true rank */
hurray_descriptor_shape(desc, NULL, &cap);
uint64_t *dims = malloc(cap * sizeof(uint64_t));
hurray_descriptor_shape(desc, dims, &cap);
Sync mode handoff cross-check
Before consuming a GPU buffer, call the matching handoff function to verify that the producer's declared sync mode matches your payload.
/* Event mode: producer recorded a CUDA event */
HurraySyncEventPayload payload = {
.sync_handle = cuda_event,
.sync_handle_device_tag = 0x01, /* CUDA */
.event_release_fn = my_event_release,
.event_release_context = NULL,
};
HurrayStatus s = hurray_buffer_handoff_event(buffer, &payload);
if (s == HURRAY_ERR_SYNC_MODE_MISMATCH) { /* handle disagreement */ }
/* ConsumerStream mode: consumer declares its target stream */
HurraySyncConsumerStreamPayload sp = {
.consumer_stream = my_cuda_stream,
.consumer_stream_device_tag = 0x01, /* CUDA */
};
HurrayStatus s = hurray_buffer_handoff_consumer_stream(buffer, &sp);
/* ProducerSynced mode: producer issued a host-side wait; no payload needed */
HurrayStatus s = hurray_buffer_handoff_producer_synced(buffer);
Reading a capsule from outside Python
A native-protocol capsule carries one thing: a HurrayTensor *. The handle owns the
descriptor that says what the bytes are — element type, shape, layout, quantization —
and the buffers that hold them, so a consumer holding the pointer cannot have one without
the other (ADR-043). Before it, the capsule was a HurrayBufferList pointer plus a
HurrayTensorContext context: two independent fetches, paired by convention, torn down in
a required order.
Check the version first. It is the one accessor guaranteed to work across ABI versions, and every other accessor assumes a caller that has already checked:
HurrayTensor *tensor = PyCapsule_GetPointer(capsule, "hurray_tensor_handle");
if (tensor == NULL) return -1;
uint32_t abi_version = 0;
if (hurray_tensor_abi_version(tensor, &abi_version) != HURRAY_OK) return -1;
if (abi_version != HURRAY_C_ABI_VERSION) {
/* Producer and consumer disagree — refuse rather than dereference. */
return -1;
}
Then borrow the descriptor. There is nothing to decode: construction decoded it, and the handle keeps only the decoded form.
HurrayDescriptor *descriptor = NULL; /* borrowed — do not destroy */
if (hurray_tensor_descriptor(tensor, &descriptor) != HURRAY_OK) return -1;
uint8_t type_tag = 0;
uint32_t rank = 0;
hurray_descriptor_element_type_tag(descriptor, &type_tag);
hurray_descriptor_rank(descriptor, &rank);
Every descriptor field is readable this way (see Reading the optional descriptor sections). The encoded bytes are not: verbatim byte forwarding went with the context, because a tensor from a stream or file read has no bytes to offer — the sans-io cores decode out of a staging buffer and drop it.
Now the buffers mean something, because the same handle said what they hold:
HurrayBufferList *buffers = NULL; /* borrowed — do not destroy */
if (hurray_tensor_buffers(tensor, &buffers) != HURRAY_OK) return -1;
uint64_t count = 0;
hurray_buffer_list_len(buffers, &count);
for (uint64_t i = 0; i < count; i++) {
HurrayBuffer *borrowed = NULL; /* owned by the list — do not destroy */
hurray_buffer_list_get(buffers, i, &borrowed);
void *ptr = NULL;
uint64_t size = 0;
hurray_buffer_data_ptr(borrowed, &ptr);
hurray_buffer_byte_size(borrowed, &size);
}
Keep the handle alive while you read those pointers — both halves are borrows. One call
ends the whole tensor, running every buffer's release callback and then the producer's
owner_release, which is how the producer learns it can let the tensor go:
hurray_tensor_destroy(&tensor); /* descriptor + buffers + owner, in that order */
Producing one is the same handle from the other side: accumulate the buffers in a list, then hand the finished list over.
HurrayBufferList *buffers = NULL;
hurray_buffer_list_new(1, &buffers);
hurray_buffer_list_push(buffers, buffer); /* the list takes ownership */
HurrayTensor *tensor = NULL;
HurrayStatus status = hurray_tensor_new(HURRAY_C_ABI_VERSION,
descriptor_bytes, descriptor_len,
buffers, /* ownership transfers */
owner, owner_release,
&tensor);
if (status != HURRAY_OK) {
/* The descriptor did not parse. The list is still yours on failure. */
hurray_buffer_list_destroy(&buffers);
return -1;
}
hurray_tensor_new decodes the descriptor, so a producer cannot export a tensor whose
descriptor does not parse — a failure that used to be discovered by the consumer, one
process or one machine later. A null or zero-length descriptor is refused outright.
Runnable version, in Rust because that is what this repository builds — but the sequence is the one any language follows:
cargo run -p hurray-ffi --example tensor
Reading a layout's payload fields
Decoding tells you the layout tag. It does not, on its own, tell you anything
inside the layout — and for a sparse or paged tensor that is most of what you
need. A consumer handed a CSR descriptor reads layout_tag == 0x07 and still
cannot address an element until it knows nnz.
Payload fields are named by a HURRAY_LAYOUT_FIELD_* identifier and read through
one of three functions — one for scalars, one for unsigned arrays, one for signed
arrays:
#include "hurray.h"
/* A scalar: 137 stored non-zeros in this CSR matrix. */
uint64_t nnz;
HurrayStatus s = hurray_descriptor_field_u64(
desc, HURRAY_LAYOUT_FIELD_CSR_NNZ, 0, &nnz);
if (s == HURRAY_ERR_FIELD_NOT_APPLICABLE) {
/* This descriptor is not CSR. Check layout_tag before asking. */
}
Arrays follow the same capacity/query pattern as
hurray_descriptor_shape:
size_t len = 0;
/* Query-only: out_values=NULL forces BUFFER_TOO_SMALL and writes the true length */
hurray_descriptor_field_i64_array(
desc, HURRAY_LAYOUT_FIELD_STRIDED_STRIDES, 0, NULL, &len);
int64_t *strides = malloc(len * sizeof(int64_t));
hurray_descriptor_field_i64_array(
desc, HURRAY_LAYOUT_FIELD_STRIDED_STRIDES, 0, strides, &len);
Strides are read through the signed accessor because their sign carries
meaning: a negative stride reverses a dimension and a zero stride broadcasts one.
An unsigned read would report -4 as 18446744073709551612.
Three statuses are specific to these reads:
| Status | Meaning |
|---|---|
HURRAY_ERR_FIELD_NOT_APPLICABLE | The field belongs to a different layout than this descriptor's — or is not a field of the type you asked for (a scalar read of an array field). |
HURRAY_ERR_FIELD_NOT_PRESENT | The field is valid for this layout but this descriptor does not carry it. Optional fields only: layer_index, outer_strides, inner_strides. |
HURRAY_ERR_INDEX_OUT_OF_BOUNDS | level exceeds the tile nesting depth, or is non-zero for a layout that does not nest. |
Absence is never reported as a zero. HURRAY_ERR_FIELD_NOT_PRESENT leaves
*out_value untouched, because a cleared optional field is a state in this
format rather than a value — see
ADR-039.
The level parameter
Every accessor takes a level. It selects which tile to read in a nested
tiled layout, where 0 is the outermost;
HURRAY_LAYOUT_FIELD_TILED_DEPTH reports how many levels remain from there
down. For every other layout level MUST be 0, and a non-zero value is
rejected rather than ignored — silently ignoring it would let a caller believe it
had read a nested level that does not exist.
Rust:
use hurray_core::layout::CsrLayout;
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode, TensorDescriptor,
MIN_BUFFER_ALIGNMENT,
};
use hurray_ffi::{
descriptor::hurray_descriptor_destroy,
hurray_descriptor_decode,
field::{hurray_descriptor_field_u64, HURRAY_LAYOUT_FIELD_CSR_NNZ},
HurrayDescriptor, HURRAY_OK,
};
let buffers = (0..3)
.map(|_| {
BufferHandle::new(
256,
MIN_BUFFER_ALIGNMENT,
DeviceTag::Cpu,
SyncMode::ProducerSynced,
)
.unwrap()
})
.collect();
let bytes = TensorDescriptor::new(
1,
0,
ElementType::Float32,
Shape::new(vec![8, 8]).unwrap(),
0,
LayoutDescriptor::Csr(CsrLayout::new(137)),
buffers,
None,
None,
None,
None,
)
.unwrap()
.encode()
.unwrap();
let mut desc: *mut HurrayDescriptor = std::ptr::null_mut();
assert_eq!(
unsafe { hurray_descriptor_decode(bytes.as_ptr(), bytes.len(), &mut desc) },
HURRAY_OK
);
let mut nnz: u64 = 0;
assert_eq!(
unsafe {
hurray_descriptor_field_u64(desc, HURRAY_LAYOUT_FIELD_CSR_NNZ, 0, &mut nnz)
},
HURRAY_OK
);
assert_eq!(nnz, 137);
assert_eq!(unsafe { hurray_descriptor_destroy(desc) }, HURRAY_OK);
A runnable walk through a sparse, a strided and a nested tiled descriptor:
cargo run -p hurray-ffi --example layout_fields
Reading the optional descriptor sections
A descriptor may carry five sections beyond its fixed header — quantization, shard, statistics, extension type, composite member — each gated by a flag bit. They use the same identifiers and the same accessors as the layout payload; only the range in the high half-word differs:
| Range | Section | Constants |
|---|---|---|
0x0002 | quantization | HURRAY_QUANT_FIELD_* |
0x0003 | statistics | HURRAY_STATS_FIELD_* |
0x0004 | shard | HURRAY_SHARD_FIELD_* |
0x0005 | extension type | HURRAY_EXT_TYPE_FIELD_* |
0x0006 | composite member | HURRAY_COMPOSITE_MEMBER_FIELD_* |
Read the quantization scheme tag first. It plays the part layout_tag plays for
the payload: it decides which scheme-specific identifiers apply. Asking for the wrong
scheme's field is diagnosable (HURRAY_ERR_FIELD_NOT_APPLICABLE), but the tag is what
tells you which to ask for.
#include "hurray.h"
uint64_t scheme;
HurrayStatus s = hurray_descriptor_field_u64(
desc, HURRAY_QUANT_FIELD_SCHEME_TAG, 0, &scheme);
if (s == HURRAY_ERR_FIELD_NOT_PRESENT) {
/* Not a quantized tensor — it stores its values directly. */
} else if (scheme == 0x03) {
uint64_t block_size, scale_buffer;
hurray_descriptor_field_u64(
desc, HURRAY_QUANT_FIELD_PER_BLOCK_BLOCK_SIZE, 0, &block_size);
hurray_descriptor_field_u64(
desc, HURRAY_QUANT_FIELD_PER_BLOCK_SCALE_BUFFER, 0, &scale_buffer);
/* A symmetric scheme has no zero point at all — absence, not zero. */
uint64_t zp_buffer;
if (hurray_descriptor_field_u64(
desc, HURRAY_QUANT_FIELD_PER_BLOCK_ZERO_POINT_BUFFER, 0, &zp_buffer)
== HURRAY_ERR_FIELD_NOT_PRESENT) {
/* symmetric */
}
}
Two accessors exist only for the sections. A per-tensor affine zero_point is signed
and its scale is real, so neither can go through the u64 accessor:
/* value = (stored - zero_point) * scale */
double scale;
int64_t zero_point;
hurray_descriptor_field_f64(desc, HURRAY_QUANT_FIELD_PER_TENSOR_SCALE, 0, &scale);
hurray_descriptor_field_i64(desc, HURRAY_QUANT_FIELD_PER_TENSOR_ZERO_POINT, 0, &zero_point);
A zero point is routinely negative. Reading one through the unsigned accessor is not
merely wrong but plausibly wrong — -128 would arrive as 18446744073709551488 —
so the unsigned accessor rejects the field outright rather than widening it.
Statistics are mask-guarded, and the accessor enforces the guard. Every statistics
field is physically present whatever computed_mask says, holding whatever the writer
left there. Rather than hand you those bytes and trust you to check the bit, a field
whose guard bit is clear reads as HURRAY_ERR_FIELD_NOT_PRESENT:
uint64_t nnz;
if (hurray_descriptor_field_u64(desc, HURRAY_STATS_FIELD_NNZ, 0, &nnz) == HURRAY_OK) {
/* nnz was actually computed by the writer */
}
/* Or branch on the mask directly — it is always readable. */
uint64_t mask;
hurray_descriptor_field_u64(desc, HURRAY_STATS_FIELD_COMPUTED_MASK, 0, &mask);
Shard bounds are arrays, so they use the same capacity/query pattern as the strides above:
size_t len = 0;
hurray_descriptor_field_u64_array(desc, HURRAY_SHARD_FIELD_PARENT_SHAPE, 0, NULL, &len);
uint64_t *parent = malloc(len * sizeof(uint64_t));
hurray_descriptor_field_u64_array(desc, HURRAY_SHARD_FIELD_PARENT_SHAPE, 0, parent, &len);
Rust:
use hurray_core::descriptor::{Statistics, StatisticsMask};
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerTensorAffine,
QuantizationDescriptor, Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_ffi::{
descriptor::hurray_descriptor_destroy,
field::{
hurray_descriptor_field_f64, hurray_descriptor_field_i64, hurray_descriptor_field_u64,
HURRAY_QUANT_FIELD_PER_TENSOR_SCALE, HURRAY_QUANT_FIELD_PER_TENSOR_ZERO_POINT,
HURRAY_QUANT_FIELD_SCHEME_TAG, HURRAY_STATS_FIELD_NNZ, HURRAY_STATS_FIELD_VALUE_MEAN,
},
hurray_descriptor_decode, HurrayDescriptor, HURRAY_ERR_FIELD_NOT_PRESENT, HURRAY_OK,
};
let buffers = (0..1)
.map(|_| {
BufferHandle::new(
256,
MIN_BUFFER_ALIGNMENT,
DeviceTag::Cpu,
SyncMode::ProducerSynced,
)
.unwrap()
})
.collect();
// int8 weights: value = (stored - zero_point) * scale, with only nnz computed.
let quantization = QuantizationDescriptor::PerTensorAffine(
PerTensorAffine::new(0.0078125, -128).unwrap(),
);
let statistics = Statistics {
computed_mask: StatisticsMask(StatisticsMask::NNZ_VALID),
nnz: 4096,
sparsity_ratio: 0.0,
value_min: 0.0,
value_max: 0.0,
value_abs_max: 0.0,
value_mean: 0.0,
value_stddev: 0.0,
nm_n: 0,
nm_m: 0,
has_nan: false,
has_inf: false,
};
let bytes = TensorDescriptor::new(
1,
0,
ElementType::Int8,
Shape::new(vec![64, 64]).unwrap(),
0,
LayoutDescriptor::RowMajor,
buffers,
Some(quantization.encode_to_vec()),
None,
Some(statistics),
None,
)
.unwrap()
.encode()
.unwrap();
let mut desc: *mut HurrayDescriptor = std::ptr::null_mut();
assert_eq!(
unsafe { hurray_descriptor_decode(bytes.as_ptr(), bytes.len(), &mut desc) },
HURRAY_OK
);
// The scheme tag first: it says which identifiers apply.
let mut scheme: u64 = 0;
unsafe { hurray_descriptor_field_u64(desc, HURRAY_QUANT_FIELD_SCHEME_TAG, 0, &mut scheme) };
assert_eq!(scheme, 0x01);
let mut scale: f64 = 0.0;
let mut zero_point: i64 = 0;
unsafe {
hurray_descriptor_field_f64(desc, HURRAY_QUANT_FIELD_PER_TENSOR_SCALE, 0, &mut scale);
hurray_descriptor_field_i64(
desc,
HURRAY_QUANT_FIELD_PER_TENSOR_ZERO_POINT,
0,
&mut zero_point,
);
}
assert_eq!(scale, 0.0078125);
assert_eq!(zero_point, -128);
// nnz was computed; the mean was not, and reads as absent rather than as 0.0.
let mut nnz: u64 = 0;
assert_eq!(
unsafe { hurray_descriptor_field_u64(desc, HURRAY_STATS_FIELD_NNZ, 0, &mut nnz) },
HURRAY_OK
);
assert_eq!(nnz, 4096);
let mut mean: f64 = f64::NAN;
assert_eq!(
unsafe { hurray_descriptor_field_f64(desc, HURRAY_STATS_FIELD_VALUE_MEAN, 0, &mut mean) },
HURRAY_ERR_FIELD_NOT_PRESENT
);
assert!(mean.is_nan(), "a failed read leaves the output untouched");
assert_eq!(unsafe { hurray_descriptor_destroy(desc) }, HURRAY_OK);
A runnable walk through a per-tensor quantized tensor, a per-block quantized shard with statistics, and a plain tensor carrying no sections at all:
cargo run -p hurray-ffi --example section_fields
Key takeaways
- No panics cross the boundary. Every function returns
HURRAY_OKor a negative error code.HURRAY_ERR_INTERNAL_PANICmeans the library panicked internally — the handle is in an undefined state and MUST NOT be reused. - Opaque handles.
HurrayBuffer,HurrayDescriptor,HurrayReader, andHurrayWriterare opaque; never dereference or cast their pointers. - Check
layout_tagbefore reading payload fields, and the quantization scheme tag before reading quantization fields. A field identifier names the sub-object it belongs to, so asking the wrong descriptor is diagnosable (HURRAY_ERR_FIELD_NOT_APPLICABLE) rather than silent — but the tag is what tells you which identifier to use in the first place. - Absence is a status, never a value. An absent section, an unset optional
field, a symmetric scheme's zero point, and an uncomputed statistic all read as
HURRAY_ERR_FIELD_NOT_PRESENTwith the output left untouched. HURRAY_ERR_NULL_POINTERfor null required arguments. Every function checks its required pointer arguments and returns this code immediately if any is null. Optional context pointers (e.g.,release_context) MAY be null.- Exactly one destroy per create. Each handle created by a
*_from_ptr,*_decode, or*_newfunction MUST be destroyed exactly once. - Read the ABI version before anything else. For a
HurrayTensorthis is normative, not advisory: it is what lets later versions add accessors without breaking consumers compiled against an earlier header. - One handle is the whole tensor.
hurray_tensor_descriptorandhurray_tensor_buffershand out borrows, so keep the handle alive while you read them and end it with a singlehurray_tensor_destroy.
Streaming from C
A C consumer never hands Hurray a path or a file descriptor. It hands over a context pointer and two callbacks, and Hurray asks them for bytes. That is the transport vtable, and it is what lets the same stream format be read from a socket, a pipe, a memory span, or a caller's own object — without a file-descriptor conversion that does not port, and without an async runtime in the library.
typedef struct HurrayTransport {
void* ctx;
HurrayTransportReadFn read; /* required by a stream reader */
HurrayTransportWriteFn write; /* required by a writer */
HurrayTransportReleaseFn release; /* optional; frees ctx */
HurrayTransportReadAtFn read_at; /* required by a file reader */
HurrayTransportSizeFn size; /* required by a file reader */
} HurrayTransport;
One struct serves both surfaces; a caller populates only the callbacks its transport has.
A stream needs the first three — read_at and size are the file container's, because it
is addressed rather than consumed
(Random access without a runtime).
Every call is synchronous and returns a HurrayStatus. The callbacks are allowed to block
— that is the point of them — and nothing inside Hurray does.
Reading a stream
#include "hurray.h"
static HurrayStatus sock_read(void *ctx, uint8_t *dst, size_t len, size_t *out_filled) {
ssize_t n = recv(*(int *)ctx, dst, len, 0);
if (n < 0) { *out_filled = 0; return HURRAY_ERR_TRANSPORT; } /* moved nothing */
*out_filled = (size_t)n; /* 0 == end of input */
return HURRAY_OK;
}
int fd = /* a connected socket */;
HurrayTransport transport = { .ctx = &fd, .read = sock_read };
HurrayReader *reader = NULL;
if (hurray_reader_new(&transport, NULL, &reader) != HURRAY_OK) return -1;
for (;;) {
HurrayTensor *tensor = NULL;
HurrayStatus s = hurray_reader_next_tensor(reader, &tensor);
if (s == HURRAY_END_OF_STREAM) break; /* positive: not an error */
if (s < 0) break; /* every error is negative */
HurrayDescriptor *descriptor = NULL; /* borrowed — do not destroy */
HurrayBufferList *buffers = NULL; /* borrowed — do not destroy */
hurray_tensor_descriptor(tensor, &descriptor);
hurray_tensor_buffers(tensor, &buffers);
/* ... read fields off the descriptor, hand the buffers to your kernels ... */
hurray_tensor_destroy(&tensor); /* one call frees both halves */
}
hurray_reader_destroy(&reader);
HurrayTensor exists because a tensor is a descriptor plus its buffers. Keeping them in
one handle means a consumer cannot end up holding bytes it cannot interpret, or a
description of bytes it does not have, and the teardown order is not its problem.
Writing a stream
A tensor's payloads are passed as borrowed slices, one per buffer in the descriptor's buffer table:
static HurrayStatus sock_write(void *ctx, const uint8_t *src, size_t len, size_t *out) {
ssize_t n = send(*(int *)ctx, src, len, 0);
if (n < 0) { *out = 0; return HURRAY_ERR_TRANSPORT; }
*out = (size_t)n; /* a short write is normal */
return HURRAY_OK;
}
HurrayTransport transport = { .ctx = &fd, .write = sock_write };
HurrayWriter *writer = NULL;
hurray_writer_new(&transport, NULL, &writer);
HurrayIoSlice payload = { .ptr = data, .len = data_len };
HurrayStatus s = hurray_writer_write_tensor(writer, descriptor, &payload, 1);
hurray_writer_destroy(&writer);
Nothing is retained after the call returns: Hurray holds no pointer into data once the
tensor is written. There is also nothing to flush — a tensor is either entirely on the
wire or the call that wrote it failed.
The four rules a transport has to keep
| Rule | Why it is not obvious |
|---|---|
| Report the count you actually moved | Fewer bytes than asked for is normal, not an error, and not end of input. |
0 from read is end of input; 0 from write is a closed sink | They are not symmetrical. A sink accepting nothing is a distinct signal, and an error if an item is in flight. |
| A failing callback must have moved no bytes | This is what makes a failure recoverable. A transport that partly succeeds then fails must report the partial count with HURRAY_OK and fail on the next call — what POSIX read and write already do. |
| Never call back into the handle that invoked you | The alternative is a reentrancy contract on every handle, bought for a use case nobody has. |
Keep those and a failed call is retryable: the handle's position still matches the
transport's, so calling hurray_reader_next_tensor again resumes mid-tensor rather
than restarting it. The one exception is a write that fails after its first byte — a
truncated item is on the wire, a stream cannot be un-sent, and the writer is poisoned
rather than left pretending.
One handle, one thread at a time. Hurray runs no threads of its own, so that is the whole threading model.
What a buffer points at
The reader allocates each buffer at the alignment the buffer handle declares — 64 bytes by default, 4096 for GPU and IPC memory — and the transport writes straight into that allocation. So the pointer a consumer gets is aligned for the SIMD loads the format entitles it to, and no copy of the payload happened after the transport's write.
The same stream, in Rust and in Python
The Rust tab drives the C entry points, because that is what a binding does. The Python
tab is hurray-python's own streaming API, which is blocking today and gets an
async/await surface of its own.
use std::ffi::c_void;
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode, TensorDescriptor,
MIN_BUFFER_ALIGNMENT,
};
use hurray_ffi::{
buffer::{hurray_buffer_byte_size, hurray_buffer_data_ptr},
buffer_list::hurray_buffer_list_get,
hurray_descriptor_decode, hurray_reader_destroy, hurray_reader_new,
hurray_reader_next_tensor, hurray_tensor_buffers, hurray_tensor_destroy,
hurray_writer_destroy, hurray_writer_new, hurray_writer_write_tensor, HurrayBuffer,
HurrayBufferList, HurrayDescriptor, HurrayIoSlice, HurrayReader, HurrayStatus, HurrayTensor,
HurrayTransport, HurrayWriter, HURRAY_END_OF_STREAM, HURRAY_OK,
};
/// Stands in for a socket: bytes, a read cursor, and a small transfer size so the short
/// read and short write paths are the ones exercised.
struct Pipe {
bytes: Vec<u8>,
read_pos: usize,
chunk: usize,
}
unsafe extern "C" fn pipe_read(
ctx: *mut c_void,
dst: *mut u8,
len: usize,
out_filled: *mut usize,
) -> HurrayStatus {
let pipe = &mut *(ctx as *mut Pipe);
let n = len.min(pipe.chunk).min(pipe.bytes.len() - pipe.read_pos);
std::ptr::copy_nonoverlapping(pipe.bytes.as_ptr().add(pipe.read_pos), dst, n);
pipe.read_pos += n;
*out_filled = n; // 0 means end of input; fewer than `len` does not
HURRAY_OK
}
unsafe extern "C" fn pipe_write(
ctx: *mut c_void,
src: *const u8,
len: usize,
out_written: *mut usize,
) -> HurrayStatus {
let pipe = &mut *(ctx as *mut Pipe);
let n = len.min(pipe.chunk);
pipe.bytes.extend_from_slice(std::slice::from_raw_parts(src, n));
*out_written = n; // never 0: that would mean the sink is closed
HURRAY_OK
}
fn vtable(pipe: &mut Pipe) -> HurrayTransport {
HurrayTransport {
ctx: pipe as *mut Pipe as *mut c_void,
read: Some(pipe_read),
write: Some(pipe_write),
release: None, // the context is ours; Hurray never frees it
// A stream needs no range reads: `read_at` and `size` stay null.
..HurrayTransport::default()
}
}
let handle =
BufferHandle::new(64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let descriptor = TensorDescriptor::new(
1, 0, ElementType::Uint8, Shape::new(vec![64u64]).unwrap(), 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)
.unwrap();
let payload: Vec<u8> = (0u8..64).collect();
let mut pipe = Pipe { bytes: Vec::new(), read_pos: 0, chunk: 24 };
// A C caller names a descriptor by decoding its bytes.
let encoded = descriptor.encode().unwrap();
let mut descriptor_handle: *mut HurrayDescriptor = std::ptr::null_mut();
unsafe {
assert_eq!(
hurray_descriptor_decode(encoded.as_ptr(), encoded.len(), &mut descriptor_handle),
HURRAY_OK
);
// Write.
let transport = vtable(&mut pipe);
let mut writer: *mut HurrayWriter = std::ptr::null_mut();
assert_eq!(hurray_writer_new(&transport, std::ptr::null(), &mut writer), HURRAY_OK);
let slice = HurrayIoSlice { ptr: payload.as_ptr(), len: payload.len() };
assert_eq!(
hurray_writer_write_tensor(writer, descriptor_handle, &slice, 1),
HURRAY_OK
);
hurray_writer_destroy(&mut writer);
// Read it back.
let transport = vtable(&mut pipe);
let mut reader: *mut HurrayReader = std::ptr::null_mut();
assert_eq!(hurray_reader_new(&transport, std::ptr::null(), &mut reader), HURRAY_OK);
let mut tensor: *mut HurrayTensor = std::ptr::null_mut();
assert_eq!(hurray_reader_next_tensor(reader, &mut tensor), HURRAY_OK);
let mut list: *mut HurrayBufferList = std::ptr::null_mut();
hurray_tensor_buffers(tensor, &mut list);
let mut buffer: *mut HurrayBuffer = std::ptr::null_mut();
hurray_buffer_list_get(list, 0, &mut buffer);
let mut data: *mut c_void = std::ptr::null_mut();
let mut size: u64 = 0;
hurray_buffer_data_ptr(buffer, &mut data);
hurray_buffer_byte_size(buffer, &mut size);
assert_eq!(
std::slice::from_raw_parts(data as *const u8, size as usize),
&payload[..]
);
// The declared alignment survives the trip into C.
assert_eq!(data as usize % MIN_BUFFER_ALIGNMENT as usize, 0);
hurray_tensor_destroy(&mut tensor);
assert_eq!(hurray_reader_next_tensor(reader, &mut tensor), HURRAY_END_OF_STREAM);
hurray_reader_destroy(&mut reader);
hurray_ffi::descriptor::hurray_descriptor_destroy(descriptor_handle);
}
import numpy as np
import hurray
tensor = hurray.from_numpy(np.arange(64, dtype=np.uint8))
# Nowhere to put it: the writer keeps the bytes.
writer = hurray.StreamWriter()
writer.write(tensor)
writer.finish()
wire = writer.getvalue()
# The reader is an iterator; each tensor is available as its bytes arrive.
for received in hurray.StreamReader(wire):
assert received.shape == (64,)
assert np.array_equal(np.from_dlpack(received), np.arange(64, dtype=np.uint8))
Beyond a flat stream
- Composite tensors.
hurray_reader_next_tensoris the flat view: a composite head arrives as the buffer-less tensor it is on the wire, and its members as the tensors that follow. For the assembled view — one item, walked in preorder — see Streaming Composite Tensors, which has the C tab forhurray_reader_next_itemandhurray_writer_write_composite. - File containers.
HRRYFILEis addressed rather than consumed, so it asks the same vtable for absolute ranges throughread_atandsizeinstead of a byte feed: Random Access Without a Runtime.
Not here yet
- Building a descriptor in C. A writer can emit descriptors it decoded; originating one from C needs an encode surface of its own.
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
Block-Paged KV Cache
Purpose
The block-paged layout (tag 0x0A) is the interchange form of a PagedAttention-style
KV cache — the central data structure moved between prefill and decode workers in
disaggregated LLM inference. It is an indirect layout: every logical element exists, but
the mapping from a logical index to a physical buffer position is resolved through a
block table rather than an affine stride formula.
A block-paged descriptor is a static snapshot of one whole batch for one
{kv_role, layer} pair. It carries no live allocator state; prefix sharing across
sequences is expressed as static structure (two block-table entries naming the same
physical page). See docs/spec/layouts/block-paged.md and ADR-024.
The three buffers
| Buffer | Name | Contents |
|---|---|---|
| 0 | page_pool | The flat pool of fixed-size pages (num_pages × page_size × num_heads × head_dim elements). |
| 1 | block_table | Physical page id of each logical page, concatenated across sequences. |
| 2 | seq_ptr | Offset array delimiting each sequence's slice of block_table (CSR-style). |
The logical shape is [total_tokens, num_heads, head_dim] — a hyperrectangle. The ragged
per-sequence structure lives in seq_ptr, not in the shape.
Building a descriptor
use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole, LayoutDescriptor};
use hurray_core::Shape;
// One snapshot: keys for layer 3, a batch of 2 sequences, 4 tokens per page.
let layout = LayoutDescriptor::BlockPaged(BlockPagedLayout::new(
4, // page_size (tokens per page)
5, // num_pages (pool capacity)
0, // paged_axis (MUST be 0 in this version)
2, // num_seqs
KvRole::Key, // this descriptor holds keys (Value / Fused also exist)
Some(3), // layer_index (None = not layer-scoped, wire 0xFFFFFFFF)
BlockTableIndexType::U32, // 32-bit block_table / seq_ptr (U64 for huge pools)
));
assert_eq!(layout.tag(), 0x0A);
assert_eq!(layout.buffer_count().map(|n| n.get()), Some(3));
// Block-paged is rank-3 only.
let shape = Shape::new(vec![9, 2, 8]).unwrap(); // [total_tokens, num_heads, head_dim]
assert!(layout.validate_against_shape(&shape).is_ok());
assert!(layout.validate_against_shape(&Shape::new(vec![9, 2]).unwrap()).is_err());
import hurray
# One snapshot: keys for layer 3, a batch of 2 sequences, 4 tokens per page.
layout = hurray.BlockPagedLayout(
page_size=4, # tokens per page
num_pages=5, # pool capacity
paged_axis=0, # MUST be 0 in this version
num_seqs=2,
kv_role="key", # "value" and "fused" also exist
layer_index=3, # None = not layer-scoped
block_table_index_type="uint32", # "uint64" for huge pools
)
assert layout.tag == 0x0A
assert layout.buffer_count == 3
# Block-paged is rank-3 only.
layout.validate_against_shape([9, 2, 8]) # [total_tokens, num_heads, head_dim]
try:
layout.validate_against_shape([9, 2])
raise AssertionError("rank-2 should be refused")
except hurray.InvalidDescriptorError:
pass
Python names the enum-like parameters rather than importing them: kv_role="key",
block_table_index_type="uint32".
Element lookup through the block table
Resolving a logical (sequence, token, head, dim) to a flat page_pool offset follows
the spec formula:
page_in_seq = token / page_size
offset_in_page = token % page_size
phys_page = block_table[seq_ptr[seq] + page_in_seq]
flat = ((phys_page * page_size + offset_in_page) * num_heads + head) * head_dim + dim
use hurray_core::layout::addressing::block_paged::element_offset_u32;
// seq 0 owns block_table[0..2] = [0, 1]; seq 1 owns block_table[2..3] = [0].
let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0];
// seq 0, token 4 → page 1 (4/4), offset 0 → phys_page = block_table[1] = 1.
// flat = ((1*4 + 0)*2 + 0)*8 + 0 = 64.
let flat = element_offset_u32(
/*s=*/ 0, /*t=*/ 4, /*h=*/ 0, /*d=*/ 0,
/*page_size=*/ 4, /*num_pages=*/ 5, /*num_heads=*/ 2, /*head_dim=*/ 8,
block_table, seq_ptr,
)
.unwrap();
assert_eq!(flat, 64);
import hurray
# Python has no element_offset: resolving one logical coordinate at a time is
# indexing, and Hurray is an interchange format rather than a compute library
# (and a Python loop over elements would be the wrong tool regardless). The
# formula above is what a consumer implements in its own kernel; what Python
# gives you is the descriptor that says how to read it.
layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)
print(layout.page_size, layout.num_pages, layout.num_seqs)
Prefix sharing (copy-on-write, zero copy)
Two sequences share a prefix when their block tables name the same physical page. The
aliasing is internal to block_table: both references resolve to the same page_pool
offset, so nothing is copied.
use hurray_core::layout::addressing::block_paged::element_offset_u32;
let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0]; // seq 1's page 0 aliases seq 0's page 0
let seq0 = element_offset_u32(0, 0, 0, 0, 4, 5, 2, 8, block_table, seq_ptr).unwrap();
let seq1 = element_offset_u32(1, 0, 0, 0, 4, 5, 2, 8, block_table, seq_ptr).unwrap();
assert_eq!(seq0, seq1); // shared prefix → identical physical slot
import hurray
layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)
seq_ptr = [0, 2, 3]
block_table = [0, 1, 0] # seq 1's page 0 aliases seq 0's page 0
# Aliasing is not an error — it is the point. The validator accepts it.
layout.validate_index_buffers(seq_ptr=seq_ptr, block_table=block_table)
Validating the storage invariants
validate_index_buffers_u32 / _u64 check the four storage invariants:
seq_ptr[0] == 0, seq_ptr non-decreasing, seq_ptr[num_seqs] == block_table.len(), and
every block_table[k] < num_pages. The empty batch (num_seqs == 0, seq_ptr == [0]) is
valid.
use hurray_core::layout::addressing::block_paged::validate_index_buffers_u32;
let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0];
assert!(validate_index_buffers_u32(/*num_pages=*/ 5, /*num_seqs=*/ 2, seq_ptr, block_table).is_ok());
// A page id outside [0, num_pages) is rejected.
let bad: &[u32] = &[0, 5, 0]; // page 5 == num_pages
assert!(validate_index_buffers_u32(5, 2, seq_ptr, bad).is_err());
import hurray
layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)
layout.validate_index_buffers(seq_ptr=[0, 2, 3], block_table=[0, 1, 0])
# A page id outside [0, num_pages) is rejected.
try:
layout.validate_index_buffers(seq_ptr=[0, 2, 3], block_table=[0, 5, 0])
raise AssertionError("page 5 == num_pages should be refused")
except hurray.InvalidDescriptorError as exc:
print(exc)
num_pages and num_seqs come from the layout, so only the two buffers are passed. This
is worth calling before shipping a descriptor: the buffers' contents are not checked
when the descriptor is built — the descriptor describes them, it does not contain them —
so they are the only thing standing between a consumer and an out-of-bounds read.
Quantization compatibility
KV caches are often fp8/int8. Block-paged reuses the existing quantization schemes, with
one rule: per-block-affine (scheme_tag = 0x03) requires the quantization axis to be 0
and block_size to equal page_size, so scales stay per-page-slot and a shared page
carries its own scales.
use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole};
let bp = BlockPagedLayout::new(4, 5, 0, 2, KvRole::Key, Some(3), BlockTableIndexType::U32);
// per-block-affine: valid only when axis == 0 and block_size == page_size.
assert!(bp.validate_quantization_compatibility(0x03, 0, 4).is_ok());
assert!(bp.validate_quantization_compatibility(0x03, 0, 8).is_err()); // block_size != page_size
assert!(bp.validate_quantization_compatibility(0x02, 0, 4).is_err()); // per-channel on the paged axis
import hurray
layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)
# per-block-affine: valid only when axis == 0 and block_size == page_size.
layout.validate_quantization_compatibility(
hurray.PerBlockAffine.symmetric(
axis=0, block_size=4, scale_buffer_index=3, scale_type=hurray.float32
)
)
for incompatible in (
hurray.PerBlockAffine.symmetric( # block_size != page_size
axis=0, block_size=8, scale_buffer_index=3, scale_type=hurray.float32
),
hurray.PerChannelAffine.symmetric( # per-channel on the paged axis
axis=0, scale_buffer_index=3
),
):
try:
layout.validate_quantization_compatibility(incompatible)
raise AssertionError("should be refused")
except hurray.InvalidDescriptorError:
pass
The Python check takes the quantization object rather than a scheme tag: the caller
already has one, and 0x03 is a byte to look up rather than a thing to pass.
Sharding
A block-paged descriptor MUST NOT carry a shard descriptor in this version;
TensorDescriptor::new rejects the combination. Tensor-parallel / multi-GPU sharding of a
paged KV cache is a deferred open question (see block-paged.md § Sharding).
Runnable example
cargo run --example block_paged_kv_cache
python hurray-python/examples/block_paged.py
Composite Tensors
Purpose
A composite tensor is a virtual descriptor (head) that owns no data, plus an ordered set of member tensors, combined under a declared composition rule. This unifies three previously distinct capabilities: partitioning an index space across heterogeneous regions (sharding), overlaying a base tensor with scattered corrections (SpQR-style outlier quantization), and grouping independent tensors under one logical identity.
The head presents one logical view — one shape and one type_tag — while the members
supply the actual data. Every member is an ordinary TensorDescriptor with its own
layout, buffers, quantization, and device placement. See
Composite / Virtual Tensor for the full normative
specification (ADR-027).
Quick reference
| Rule | Purpose | Base shape | Overlap | Corrections |
|---|---|---|---|---|
| Partition | Exact-cover tiling | Required | Not allowed | Not applicable |
| Overlay | Base + corrections | Whole space | Allowed | Yes, ordered |
| Group | Independent multi-output | Advisory | N/A | N/A |
Partition: Zero-Copy Tiling
Members' shard boxes MUST exactly cover the head's index space with no gap and no overlap. Logical index lookup is zero-copy: select the member whose box contains the index, compute the local offset, apply that member's addressing.
Build a [8, 8] head split into two [8, 4] members:
use hurray_core::{
composite::CompositeTensor,
descriptor::TensorDescriptor,
layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
BufferHandle, DeviceTag, ElementType, Shape, ShardDescriptor, SyncMode,
MIN_BUFFER_ALIGNMENT,
};
// Head: float32 logical view, partition of 2 members.
let head_shape = Shape::new(vec![8u64, 8]).unwrap();
let head_layout = LayoutDescriptor::Composite(
CompositeLayout::new(CompositionRule::Partition, 2).unwrap(),
);
let head = TensorDescriptor::new(
1, 0, ElementType::Float32, head_shape, 0,
head_layout, vec![], None, None, None, None,
).unwrap();
// Helper to build a member at a given shard offset.
let member = |offset: u64| {
let buf = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let shard = ShardDescriptor::new(vec![8, 8], vec![0, offset]).unwrap();
TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 4]).unwrap(), 0,
LayoutDescriptor::RowMajor, vec![buf],
None, Some(shard), None, None,
).unwrap()
};
// Two [8, 4] members at columns 0..4 and 4..8 exactly tile the head.
let composite = CompositeTensor::new(head, vec![member(0), member(4)]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray
def tile(offset, width):
return hurray.Tensor(
bytes(4 * 8 * width),
hurray.float32,
[8, width],
shard=hurray.Shard([8, 8], [0, offset]),
)
# Two 8x3 tiles cannot cover an 8x8 head: columns 6 and 7 are left out.
try:
hurray.Composite(
"partition",
shape=[8, 8],
dtype=hurray.float32,
members=[tile(0, 3), tile(3, 3)],
)
raise AssertionError("a coverage gap should be refused")
except hurray.InvalidDescriptorError as exc:
print(exc)
Validation is core's, not the binding's: the same checks run whichever language builds the composite, and the head is a declaration checked against its members rather than derived from them.
import hurray
# A member is an ordinary tensor with a shard box saying where it sits in the head.
def tile(offset):
return hurray.Tensor(
bytes(128), hurray.float32, [8, 4], shard=hurray.Shard([8, 8], [0, offset])
)
# The head is stated — shape and dtype — and the members are checked against it.
composite = hurray.Composite(
"partition",
shape=[8, 8],
dtype=hurray.float32,
members=[tile(0), tile(4)],
)
assert composite.member_count == 2
assert composite.layout.composition_rule == "partition"
hurray.Composite is not a hurray.Tensor: the head owns no buffers, so there is no
.values and no __dlpack__. The data belongs to the members, each an ordinary
tensor (ADR-036).
Element [3, 6] (row 3, column 6) resolves to member 1 at local index [3, 2] (column
2 within that member's [8, 4] box). The read is zero-copy once the member is selected.
Sealed Overlay: SpQR-Style Quantization
A base member spanning the whole index space plus corrections (outlier values) that may overlap one another and the base. Precedence is stream order — later corrections win. Two combine operations are supported:
- Replace (
0x01): within a correction's box, the topmost member covering an index wins; outside every correction's box, the base shows through. - Add (
0x02): the value at an index is the base value plus the sum of all covering corrections' values at that index.
Example: float16 logical view with int4 per-block-affine quantized base and float16
COO sparse outlier correction:
use hurray_core::{
composite::CompositeTensor,
descriptor::{CompositeMemberDescriptor, MemberRole, TensorDescriptor},
layout::{CombineOp, CompositeLayout, CompositionRule, CooLayout, LayoutDescriptor},
BufferHandle, DeviceTag, ElementType, PerBlockAffine, QuantizationDescriptor,
Shape, ShardDescriptor, SyncMode, MIN_BUFFER_ALIGNMENT, buffer_size_bytes,
};
let shape = Shape::new(vec![4096u64, 4096]).unwrap();
// Head: float16 logical view, overlay with replace combine, 2 members.
let head = TensorDescriptor::new(
1, 0, ElementType::Float16, shape.clone(), 0,
LayoutDescriptor::Composite(
CompositeLayout::new(CompositionRule::Overlay(CombineOp::Replace), 2).unwrap()
),
vec![], None, None, None, None,
).unwrap();
// Member 0 (base): int4 storage with per-block-affine quantization.
let pba = PerBlockAffine::new_symmetric(1, 128, 1, ElementType::Float16).unwrap();
let num_blocks = pba.num_blocks_per_axis(4096) * 4096;
let quant = QuantizationDescriptor::PerBlockAffine(pba);
let base_data = BufferHandle::new(
buffer_size_bytes(ElementType::Int4, 4096 * 4096),
MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let base_scales = BufferHandle::new(
buffer_size_bytes(ElementType::Float16, num_blocks),
MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let base_shard = ShardDescriptor::new(vec![4096, 4096], vec![0, 0]).unwrap();
let base = TensorDescriptor::new(
1, 0, ElementType::Int4, shape.clone(), 0,
LayoutDescriptor::RowMajor, vec![base_data, base_scales],
Some(quant.encode_to_vec()),
Some(base_shard),
None, None,
).unwrap()
.with_composite_member(CompositeMemberDescriptor::new(MemberRole::Base));
// Member 1 (correction): float16 COO sparse outliers.
let nnz = 128u64;
let coo_values = BufferHandle::new(
buffer_size_bytes(ElementType::Float16, nnz),
MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let coo_indices = BufferHandle::new(
nnz * 2 /* rank */ * 8 /* uint64 */,
MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let correction_shard = ShardDescriptor::new(vec![4096, 4096], vec![0, 0]).unwrap();
let correction = TensorDescriptor::new(
1, 0, ElementType::Float16, shape, 0,
LayoutDescriptor::Coo(CooLayout::new(nnz, false)),
vec![coo_values, coo_indices],
None, Some(correction_shard),
None, None,
).unwrap()
.with_composite_member(CompositeMemberDescriptor::new(MemberRole::Correction));
let composite = CompositeTensor::new(head, vec![base, correction]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray
# The base spans the whole index space; the correction is a sparse overlay.
base = hurray.Tensor(
bytes(4096 * 4096 // 2), # int4: two elements per byte
hurray.dtype.int4,
[4096, 4096],
aux_buffers=[bytes(4096 * 4096 // 64 * 4)],
quantization=hurray.PerBlockAffine.symmetric(
axis=0, block_size=64, scale_buffer_index=1, scale_type=hurray.float32
),
shard=hurray.Shard([4096, 4096], [0, 0]),
)
correction = hurray.Tensor(
bytes(2 * 16), # 16 float16 outlier values
hurray.float16,
[4096, 4096],
aux_buffers=[bytes(16 * 2 * 8)], # packed [nnz, rank] uint64 indices
layout=hurray.CooLayout(nnz=16, is_sorted=True),
shard=hurray.Shard([4096, 4096], [0, 0]),
)
weights = hurray.Composite(
"overlay",
shape=[4096, 4096],
dtype=hurray.float16,
members=[base, correction],
combine_op="replace",
)
assert weights.member_roles == ("base", "correction")
Python does not ask which member is the base: the format fixes the roles by position — member 0 is the base and must span the index space, the rest are corrections — so the constructor attaches them. They are read back from the composite rather than from a member, because a tensor has no role; a tensor inside an overlay does.
Python states the combine operation as combine_op="replace" on the composite rather
than inside the rule. Member roles are positional: the first member of an overlay is the
base.
The merged logical view is: for each index, the correction's outlier value if present (within its COO sparse structure), otherwise the dequantized base value. The consumer computes this merge; it is not zero-copy at the composite level.
Contrast with Add combine for residual-correction overlays:
use hurray_core::{
descriptor::TensorDescriptor,
layout::{CombineOp, CompositeLayout, CompositionRule, LayoutDescriptor},
ElementType, Shape,
};
let shape = Shape::new(vec![4096u64, 4096]).unwrap();
// Head with add combine instead of replace.
let head = TensorDescriptor::new(
1, 0, ElementType::Float16, shape.clone(), 0,
LayoutDescriptor::Composite(
CompositeLayout::new(CompositionRule::Overlay(CombineOp::Add), 2).unwrap()
),
vec![], None, None, None, None,
).unwrap();
// ... base and correction members as above ...
// The logical value is: base_value + correction_value (at indices
// where the correction is present; outside it, just the base).
import hurray
# The same members, combined by addition instead of replacement.
weights = hurray.Composite(
"overlay",
shape=[4096, 4096],
dtype=hurray.float16,
members=[base, correction],
combine_op="add",
)
# The logical value is base + correction where the correction is present,
# and just the base outside it.
assert weights.layout.combine_op == "add"
Group: Heterogeneous Multi-Output
Members are independent tensors under one head identity, with no spatial or ordering semantics. Members MAY differ arbitrarily in rank, shape, element type, layout, and device. Useful for weight collections, multi-head attention outputs, and other use cases where multiple tensors are delivered together.
use hurray_core::{
composite::CompositeTensor,
descriptor::TensorDescriptor,
layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
};
// Head: shape and type_tag are advisory (members may differ).
let head = TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![1u64]).unwrap(), 0,
LayoutDescriptor::Composite(
CompositeLayout::new(CompositionRule::Group, 2).unwrap()
),
vec![], None, None, None, None,
).unwrap();
// Member 0: int8 vector, 100 elements.
let buf0 = BufferHandle::new(100, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let member0 = TensorDescriptor::new(
1, 0, ElementType::Int8, Shape::new(vec![100u64]).unwrap(), 0,
LayoutDescriptor::RowMajor, vec![buf0],
None, None, None, None,
).unwrap();
// Member 1: float64 3×3×3 tensor (completely different shape and type).
let buf1 = BufferHandle::new(216 * 8, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let member1 = TensorDescriptor::new(
1, 0, ElementType::Float64, Shape::new(vec![3u64, 3, 3]).unwrap(), 0,
LayoutDescriptor::RowMajor, vec![buf1],
None, None, None, None,
).unwrap();
// Both members grouped under one head.
let composite = CompositeTensor::new(head, vec![member0, member1]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray
# A group makes no claim about coverage, so its members need not agree on
# anything — different shapes, different element types.
tokens = hurray.Tensor(bytes(100), hurray.int8, [100])
logits = hurray.Tensor(bytes(216 * 8), hurray.float64, [3, 3, 3])
composite = hurray.Composite(
"group", shape=[1], dtype=hurray.int8, members=[tokens, logits]
)
assert composite.member_count == 2
assert composite.layout.combine_op is None # only overlays have one
Validation
[CompositeTensor::new] constructs a head + members set, driving a [CompositeValidator]
internally to perform per-member and close-time checks per the spec (§ Validation). Any
violation — shard coverage gap, overlap, type mismatch, missing/misplaced base in overlay,
member count mismatch — returns an error.
Example: a partition with a coverage gap is rejected:
use hurray_core::{
composite::CompositeTensor,
descriptor::TensorDescriptor,
layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
BufferHandle, DeviceTag, ElementType, Error, Shape, ShardDescriptor, SyncMode,
MIN_BUFFER_ALIGNMENT,
};
let head_shape = Shape::new(vec![8u64, 8]).unwrap();
let head = TensorDescriptor::new(
1, 0, ElementType::Float32, head_shape, 0,
LayoutDescriptor::Composite(
CompositeLayout::new(CompositionRule::Partition, 2).unwrap()
),
vec![], None, None, None, None,
).unwrap();
// Both members start at column 0: they overlap.
let member_at = |offset: u64| {
let buf = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let shard = ShardDescriptor::new(vec![8, 8], vec![0, offset]).unwrap();
TensorDescriptor::new(
1, 0, ElementType::Float32, Shape::new(vec![8u64, 4]).unwrap(), 0,
LayoutDescriptor::RowMajor, vec![buf],
None, Some(shard), None, None,
).unwrap()
};
let err = CompositeTensor::new(head, vec![member_at(0), member_at(0)]).unwrap_err();
assert!(matches!(err, Error::CompositePartitionOverlap { a: 0, b: 1 }));
The validator also returns
[Error::CompositePartitionGap] when members leave an uncovered region, and
[Error::CompositeOverlayBaseNotFirst] / [Error::CompositeOverlayBaseNotSpanning]
when overlay base rules are violated.
Deferred features
This implementation covers v1.0 scope: partition, group, and sealed overlay only. Reserved for a future ADR (versioned / open overlay and related work):
- Versioned overlay — an appendable, time-travel-capable overlay identified by an
open-composite
member_countsentinel (0xFFFFFFFF) and per-member version fields. - Cross-descriptor streaming and file binding — Layers 5/6 will define how composites
flow through IPC framing and file format sections; this pass is
hurray-coretype/codec/validator only. - Heterogeneous per-member device placement — currently deferred; the spec reserves room for it.
See docs/spec/layouts/composite.md § Deferred for the full reserved wire-format list.
Runnable example
cargo run --example composite_tensors
Streaming Composite Tensors
Purpose
A composite tensor — a data-less head plus an ordered set of
member tensors — is streamed as its head followed by each member's descriptor and data, in
order. This "head precedes its members precede their data" rule is a forward promise: no
back-references and no end-of-file index, so a composite stays streamable exactly like an
ordinary tensor (ADR-027 § Binding; docs/spec/interchange.md).
hurray-io gives you a matched pair:
StreamWriter::write_composite— validates the whole group up front (member count, partition exact-cover, overlay ordering), then writes the head and members. A torn or invalid composite never reaches the wire.StreamReader::next_item— reads the next item and, when it is a composite head, reassembles the head with its declared members (validating as it goes), returning aStreamItem::Composite. Members that are themselves composites are assembled recursively.
Writing a composite
Build the head (a descriptor with a Composite layout and no buffers) and the members
(ordinary descriptors), then hand them to write_composite as CompositeNodes:
use hurray_core::{
buffer_size_bytes,
layout::{CompositeLayout, CompositionRule},
ElementType, LayoutDescriptor, Shape, ShardDescriptor, TensorDescriptor,
};
use hurray_io::stream::{CompositeNode, StreamWriter};
async fn run(left: TensorDescriptor, right: TensorDescriptor,
left_data: Vec<u8>, right_data: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
// A partition head presenting one logical [8, 8] float32 view over two [8, 4] tiles.
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![
CompositeNode::Tensor { descriptor: &left, buffers: &left_buffers },
CompositeNode::Tensor { descriptor: &right, buffers: &right_buffers },
];
let mut wire = Vec::<u8>::new();
let mut writer = StreamWriter::new(&mut wire);
writer.write_composite(&head, &members).await?; // validated before any byte is written
writer.finish().await?;
Ok(())
}
import hurray
def tile(offset):
return hurray.Tensor(
bytes(128), hurray.float32, [8, 4], shard=hurray.Shard([8, 8], [0, offset])
)
weight = hurray.Composite(
"partition", shape=[8, 8], dtype=hurray.float32, members=[tile(0), tile(4)]
)
plain = hurray.Tensor(bytes(16), hurray.float32, [4])
with hurray.StreamWriter(destination) as writer:
writer.write(plain)
writer.write(weight) # takes a composite exactly as it takes a tensor
/* C has no sum type, so the tree crosses as the format's own shape: the head, then its
members, in preorder, each node saying how many of the nodes after it are its own. */
HurrayCompositeNode nodes[3] = {
{ .kind = HURRAY_ITEM_COMPOSITE, .descriptor = head, .member_count = 2 },
{ .kind = HURRAY_ITEM_TENSOR, .descriptor = left,
.payloads = &left_slice, .payload_count = 1 },
{ .kind = HURRAY_ITEM_TENSOR, .descriptor = right,
.payloads = &right_slice, .payload_count = 1 },
};
/* Validated whole before a byte goes out: a torn or invalid composite never reaches the
wire. Names are ignored here — the stream format carries none. */
HurrayStatus s = hurray_writer_write_composite(writer, nodes, 3);
Reading a composite
next_item returns a StreamItem — either a plain Tensor or a Composite with its
members already grouped and validated:
use hurray_io::stream::{StreamItem, StreamReader};
async fn run(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut reader = StreamReader::new(wire);
while let Some(item) = reader.next_item().await? {
match item {
StreamItem::Tensor(t) => {
println!("tensor: {} buffer(s)", t.buffers.len());
}
StreamItem::Composite(c) => {
println!("composite: head {:?}, {} member(s)", c.head.shape.dims(), c.members.len());
for member in &c.members {
// Each member is itself a StreamItem — a nested composite recurses here.
println!(" member governed by {:?}", member.descriptor().layout);
}
}
}
}
Ok(())
}
import hurray
for item in hurray.StreamReader(source):
if isinstance(item, hurray.Composite):
print(item.layout.composition_rule, item.member_count)
else:
print(item.shape)
HurrayItem *item = NULL;
HurrayStatus s = hurray_reader_next_item(reader, &item);
if (s == HURRAY_END_OF_STREAM) return 0; /* positive: not an error */
if (s < 0) return -1;
/* One item, however many tensors it took on the wire. Walking it is a loop with a stack
of outstanding member counts — `remaining` below is the whole algorithm. */
uint64_t count = 0;
hurray_item_node_count(item, &count);
uint32_t remaining[64];
size_t depth = 0;
for (uint64_t i = 0; i < count; i++) {
HurrayItemNode node = {0};
hurray_item_node(item, i, &node);
if (depth > 0) { remaining[depth - 1]--; }
size_t level = depth;
while (depth > 0 && remaining[depth - 1] == 0) { depth--; }
if (node.member_count > 0) { remaining[depth++] = node.member_count; }
/* node.tensor is borrowed: its descriptor and buffers are the item's, and a head's
buffer list is empty. node.name is NULL here — a stream carries no names. */
printf("%*s%s\n", (int)(2 * level), "",
node.kind == HURRAY_ITEM_COMPOSITE ? "composite head" : "tensor");
}
hurray_item_destroy(&item); /* one call for the whole subtree */
A stream carrying one composite of two members yields one item, not three. The Python
reader uses next_item for exactly the reason this page gives: next_tensor would hand
back the head as an empty tensor and its members as top-level ones, losing the composition
without raising.
Nested composites
A member may itself be a composite. On the wire this is just more heads and members in
forward order; next_item assembles the tree recursively. Reads are bounded by
StreamReaderOptions::max_composite_depth (default 64) so a maliciously deep composite on
an untrusted stream cannot exhaust the stack.
Choosing the API
| You want… | Use |
|---|---|
| Composites grouped and validated for you | next_item → StreamItem (C: hurray_reader_next_item → HurrayItem) |
| Every descriptor flat, composition-agnostic | next_tensor → StreamTensor (C: hurray_reader_next_tensor → HurrayTensor) |
next_tensor is unchanged: it still yields the head (with no buffers) and then each member
as individual tensors, leaving composition to the caller. next_item is the
composite-aware layer on top. Both views come from one core and the assembly happens above
it, so mixing them on one reader is allowed but rarely meant: a flat read taken while a
head is open consumes the member that head was waiting for, and the group stays open.
Errors
Error::TornComposite— the stream ended before the head's declaredmember_countmembers were read.Error::CompositeNestingTooDeep— nesting exceededmax_composite_depth.Error::Core— composite validation failed (member-count mismatch, partition does not cover the index space, overlay ordering).
Runnable example
cargo run --example composite_stream --features tokio -p hurray-io
python hurray-python/examples/composites.py
cargo run --example composite_vtable -p hurray-ffi
See hurray-io/examples/composite_stream.rs, hurray-python/examples/composites.py, and
hurray-ffi/examples/composite_vtable.rs for the full programs. The last one drives the C
entry points, over a transport vtable and with no runtime linked
(Streaming from C).
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.
hurray-inspect CLI
Purpose
hurray-inspect is a diagnostic CLI tool that decodes a Hurray binary tensor descriptor and
prints a 3-column hex table showing the byte offset, raw hex value, and field name of every
field in the descriptor.
It is useful for:
- Verifying that a hand-crafted or encoded descriptor matches the spec.
- Debugging format mismatches between implementations.
- Learning the wire format interactively.
Parsing is delegated entirely to hurray-core's TensorDescriptor::decode() — the tool
never implements its own format logic.
Building
cargo build -p hurray-inspect
# binary lands at target/debug/hurray-inspect
For a release build:
cargo build --release -p hurray-inspect
Usage
hurray-inspect <file> # inspect a file on disk
hurray-inspect - # read from stdin
Inspecting a file
Produce a binary descriptor, save it to disk, then pass it to hurray-inspect:
// src/bin/write_example.rs (or any scratch binary)
use hurray_core::{
BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
descriptor::TensorDescriptor,
layout::LayoutDescriptor,
};
use std::fs;
fn main() {
let shape = Shape::new(vec![3u64, 4]).unwrap();
let buffer = BufferHandle::new(
192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
).unwrap();
let desc = TensorDescriptor::new(
1, 0, ElementType::Float32, shape, 0,
LayoutDescriptor::RowMajor, vec![buffer],
None, None, None, None,
).unwrap();
fs::write("example.hrry", desc.encode().unwrap()).unwrap();
println!("wrote example.hrry ({} bytes)", desc.encode().unwrap().len());
}
import hurray
# No scratch binary needed: a tensor's descriptor encodes on its own.
tensor = hurray.Tensor(bytes(192), hurray.float32, [3, 4])
wire = tensor.descriptor.encode()
with open("example.hrry", "wb") as sink:
sink.write(wire)
print(f"wrote example.hrry ({len(wire)} bytes)")
Python can also do what hurray-inspect does — hurray.Descriptor.decode(wire) gives
back every field the CLI prints, as objects rather than as text. The CLI's advantage is
that it works on a file you cannot import a library to read, and that it renders the byte
offsets alongside.
Then inspect it:
hurray-inspect example.hrry
Output:
Offset Value (hex) Field
------ ------------------------------ -----
0 48 52 52 59 magic = "HRRY"
4 01 version_major = 1
5 00 version_minor = 0
6 3D 00 00 00 descriptor_length = 61
10 00 00 00 00 flags = 0x00000000
14 03 type_tag = 0x03 (float32)
15 01 layout_tag = 0x01 (row-major)
16 02 00 00 00 rank = 2
20 03 00 00 00 00 00 00 00 shape[0] = 3
28 04 00 00 00 00 00 00 00 shape[1] = 4
36 00 00 00 00 00 00 00 00 byte_offset = 0
44 01 buffer_count = 1
45 C0 00 00 00 00 00 00 00 buffer[0].byte_size = 192
53 40 00 00 00 buffer[0].alignment = 64
57 00 buffer[0].device_tag = 0x00 (cpu)
58 00 buffer[0].sync_mode = 0x00 (producer_synced)
59 00 00 buffer[0]._reserved
Reading from stdin
Pipe raw bytes directly — useful for scripting or combining with other tools:
# Pipe the spec's 61-byte worked example (little-endian hex literals)
printf '\x48\x52\x52\x59\x01\x00\x3D\x00\x00\x00\x00\x00\x00\x00\x03\x01' \
'\x02\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00' \
'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xC0\x00\x00' \
'\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00\x00\x00' \
| hurray-inspect -
Optional sections
When a descriptor carries optional sections (quantization, shard, statistics, or extension type), those fields appear after the buffer table in spec-mandated order.
Example with statistics attached (flags bit 3 set):
...
44 01 buffer_count = 1
45 C0 00 00 00 00 00 00 00 buffer[0].byte_size = 192
53 40 00 00 00 buffer[0].alignment = 64
57 00 buffer[0].device_tag = 0x00 (cpu)
58 00 buffer[0].sync_mode = 0x00 (producer_synced)
59 00 00 buffer[0]._reserved
61 08 00 00 00 flags = 0x00000008 ← HAS_STATISTICS
...
XX 04 00 00 00 stats.computed_mask = 0x00000004
XX 00 00 00 00 stats._reserved
XX ... stats.nnz, sparsity_ratio, value_min/max, ...
Error output
When a descriptor is malformed, hurray-inspect prints whatever it could read before the
failure, then an error row and a message on stderr:
echo -n "BAAD" | hurray-inspect -
Offset Value (hex) Field
------ ------------------------------ -----
0 42 41 41 44 magic = "BAAD"
ERROR: invalid magic bytes
error: invalid magic bytes
Exit code is 1 on any parse or I/O error, 0 on success.
Relationship to hurray-core
hurray-inspect contains no format parsing logic of its own. It calls
TensorDescriptor::decode() from hurray-core and then walks the original byte slice a
second time — guided by the decoded struct's field values — to annotate each byte range for
display. This means:
- Any format change in
hurray-coreis automatically reflected inhurray-inspect. - An unrecognised layout tag produces an
Unknownvariant; its raw bytes are shown as a single opaque hex block. - Future minor-version additions (bytes beyond the fields this version understands) appear
as a
(unknown / padding bytes)row at the end.
hurray-python: Dtype, Device, and Tensor
This entry covers three foundational Python types:
hurray.Dtype— element type descriptor class +hurray.dtypesubmodulehurray.Device— device descriptor class +hurray.devicesubmodulehurray.Tensor— tensor constructor and core properties
Dtype system
Tier 1 vs Tier 2
The Hurray element type system is split into two tiers, matching the spec:
| Tier | Criterion | Python location |
|---|---|---|
| 1 | Array API-compatible | hurray.<name> and hurray.dtype.<name> |
| 2 | Extended / sub-byte | hurray.dtype.<name> only |
Tier 1 types (accessible at the top level):
| Name | Bit width | Kind |
|---|---|---|
bool | 1 | boolean |
int8, uint8 | 8 | integer |
int16, uint16 | 16 | integer |
int32, uint32 | 32 | integer |
int64, uint64 | 64 | integer |
float16, bfloat16 | 16 | float |
float32 | 32 | float |
float64 | 64 | float |
Tier 2 types (only on hurray.dtype.*):
| Name | Bit width | Kind |
|---|---|---|
int4, uint4 | 4 | integer |
int2, uint2 | 2 | integer |
float8_e4m3, float8_e5m2, float8_e8m0 | 8 | float |
float4_e2m1 | 4 | float |
float6_e2m3, float6_e3m2 | 6 | float |
float128 | 128 | float |
complex64, complex128 | 64 / 128 | complex |
Singleton identity
Tier 1 dtype constants are the same Python object on the top-level module
and on hurray.dtype:
import hurray
assert hurray.float32 is hurray.dtype.float32 # same object, not just ==
assert hurray.int8 is hurray.dtype.int8
This ensures that set membership and dict key lookups work correctly regardless of which path is used to import the constant.
Using Dtype as a dict key
Dtype is frozen (immutable) and implements __hash__ via the wire tag byte:
import hurray
lookup = {hurray.float32: "fp32 weights", hurray.dtype.int4: "quantized"}
assert lookup[hurray.float32] == "fp32 weights"
assert lookup[hurray.dtype.int4] == "quantized"
from_name round-trip
import hurray
d = hurray.Dtype.from_name("float32")
assert d == hurray.float32
assert d.name == "float32"
assert d.bit_width == 32
assert d.is_float
assert d.is_array_api
# Unknown name raises hurray.InvalidDescriptorError
try:
hurray.Dtype.from_name("not_a_type")
except hurray.InvalidDescriptorError as e:
print(f"rejected: {e}")
Submodule import
The hurray.dtype submodule is registered in sys.modules, so the following
import forms all work:
import hurray.dtype
from hurray.dtype import int4, float8_e4m3
Device system
A Device is a (kind, device_id, memory_class) triple. It is frozen and
hashable.
Well-known constants
hurray.device exposes a constant for each supported device kind, all at
device_id=0 and memory_class="standard":
import hurray
hurray.device.cpu # CPU host memory
hurray.device.cuda # CUDA device 0
hurray.device.rocm # ROCm device 0
hurray.device.metal # Metal (Apple Silicon)
hurray.device.vulkan
hurray.device.webgpu
hurray.device.hexagon
hurray.device.level_zero
hurray.device.opencl
Constructor
import hurray
# Default: device_id=0, memory_class="standard"
cpu = hurray.Device("cpu")
# Specific GPU
gpu1 = hurray.Device("cuda", 1)
# Unified memory (hardware-coherent CPU+GPU access)
gpu_um = hurray.Device("cuda", 0, "unified")
# Pinned host memory (CPU-accessible, device-mapped)
pinned = hurray.Device("cuda", 0, "host_pinned")
Equality and hashing
import hurray
assert hurray.Device("cpu") == hurray.device.cpu # same triple
assert hurray.Device("cuda", 0) != hurray.Device("cuda", 1) # different id
# Usable as dict key
device_names = {hurray.device.cpu: "host", hurray.device.cuda: "gpu0"}
assert device_names[hurray.Device("cpu")] == "host"
Submodule import
import hurray.device
from hurray.device import cpu, cuda
Constructing a Tensor
import struct
import hurray
# Pack six float32 values as raw bytes (little-endian)
buf = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
# Construct a 2×3 float32 tensor on the CPU
t = hurray.Tensor(buf, hurray.float32, [2, 3])
assert t.shape == (2, 3) # tuple[int | None, ...]
assert t.ndim == 2
assert t.size == 6 # total element count (None if any dim is dynamic)
assert t.dtype == hurray.float32
assert t.device == hurray.device.cpu
Explicit device
import hurray, struct
buf = struct.pack("4f", 1.0, 2.0, 3.0, 4.0)
t_gpu = hurray.Tensor(buf, hurray.float32, [4], hurray.Device("cuda", 0))
assert t_gpu.device.kind == "cuda"
Sub-byte types
Buffer sizes for sub-byte types are computed with ceiling division over the bit
width. For int4, two elements pack into one byte:
import hurray
buf = bytes([0xAB, 0xCD]) # 2 bytes = 4 nibbles = 4 int4 elements
t = hurray.Tensor(buf, hurray.dtype.int4, [4])
assert t.size == 4
assert t.dtype.is_sub_byte
Dynamic dimensions
If the tensor shape contains a DYNAMIC dimension (wire value u64::MAX), that
dimension maps to None in the Python shape tuple and size returns None:
# shape tuple with a dynamic dim: (1, None, 768)
# t.size == None
Running the examples
Build the wheel with maturin, then run:
cd hurray-python
maturin develop # build + install in-place (requires a venv)
python examples/dtype.py
python examples/device.py
python examples/tensor.py
All three examples print a confirmation line and exit with code 0 on success.
Spec references
docs/spec/element-types.md— Tier 1 / Tier 2 partition and type propertiesdocs/spec/buffer-protocol.md—DeviceTagandMemoryClasswire tablesdocs/spec/data-model.md—DYNAMICdimension sentinel (u64::MAX)docs/impl/python-bindings.md— Python binding implementation guide
Tensor Construction Functions
hurray-python provides a set of functions for building hurray.Tensor objects in
Python: zeros, ones, full, empty, their *_like variants, arange,
linspace, eye, asarray, and from_dlpack. These constructors produce Tier 1
(standard numeric) tensors, which you then serialize with save or hand off zero-copy
to NumPy/PyTorch. Tier 2 / quantized / sparse tensors are not built here — they arrive
via the decode and interop paths.
hurray.Tensor is an interchange object, not an Array API array: it exposes an
inspection and interop surface (shape, dtype, device, __dlpack__,
__hurray__, …), not array computation. See ADR-029.
Creation functions
All creation functions default to dtype=float64 when a dtype is not specified.
zeros and ones
import hurray
z = hurray.zeros([3, 4])
assert z.shape == (3, 4)
assert z.dtype == hurray.float64
o = hurray.ones([2, 3], dtype=hurray.float32)
assert o.shape == (2, 3)
assert o.dtype == hurray.float32
full and empty
full infers the dtype from the fill value when dtype is omitted:
f = hurray.full([4], 7.0) # float64 inferred
fi = hurray.full([4], 7, dtype=hurray.int32) # explicit int32
e = hurray.empty([5, 5], dtype=hurray.float64)
empty zero-initialises the buffer; values must not be relied upon.
*_like variants
Each creation function has a _like counterpart that inherits shape and dtype
from a source tensor:
src = hurray.ones([3, 3], dtype=hurray.float32)
z = hurray.zeros_like(src) # shape=(3,3), dtype=float32
o = hurray.ones_like(src)
f = hurray.full_like(src, -1.0)
e = hurray.empty_like(src)
# Override dtype or device:
z64 = hurray.zeros_like(src, dtype=hurray.float64)
arange
Generates integer or float sequences. Dtype is inferred as int64 when all
arguments are Python integers, float64 otherwise:
t = hurray.arange(5) # [0, 1, 2, 3, 4], int64
t2 = hurray.arange(0, 10, 2) # [0, 2, 4, 6, 8], int64
t3 = hurray.arange(0.0, 1.0, 0.25) # [0.0, 0.25, 0.5, 0.75], float64
linspace
Generates num evenly spaced values in [start, stop]:
t = hurray.linspace(0.0, 1.0, 5)
# [0.0, 0.25, 0.5, 0.75, 1.0]
# Exclude stop:
t2 = hurray.linspace(0.0, 1.0, 4, endpoint=False)
# [0.0, 0.25, 0.5, 0.75]
eye
Creates a 2-D identity matrix. k offsets the diagonal:
identity = hurray.eye(3) # 3×3 float64 identity
rect = hurray.eye(2, 4) # 2×4 float64 with 1s on main diagonal
upper = hurray.eye(3, k=1, dtype=hurray.int32) # k=1 super-diagonal
lower = hurray.eye(4, k=-1) # k=-1 sub-diagonal
asarray — generic conversion
asarray converts Python lists, NumPy arrays, and other array objects to
hurray.Tensor. For NumPy arrays and hurray.Tensor inputs the data buffer
is shared zero-copy where possible.
import numpy as np
# From a Python list
t = hurray.asarray([1.0, 2.0, 3.0])
assert t.dtype == hurray.float64
# With explicit dtype
t2 = hurray.asarray([[1, 2], [3, 4]], dtype=hurray.int32)
assert t2.shape == (2, 2)
# From NumPy (zero-copy)
np_arr = np.array([10.0, 20.0], dtype=np.float32)
t3 = hurray.asarray(np_arr)
assert t3.dtype == hurray.float32
# From another hurray tensor (zero-copy via DLPack)
src = hurray.zeros([4])
t4 = hurray.asarray(src)
bfloat16 limitation: NumPy has no native bfloat16 dtype. Passing
dtype=hurray.bfloat16 to asarray raises UnsupportedError. Use
hurray.from_numpy on a bfloat16 array from PyTorch or a custom converter
instead.
from_dlpack — DLPack zero-copy
from_dlpack accepts any object with __dlpack__() and wraps it zero-copy:
import numpy as np
arr = np.array([1.0, 2.0, 3.0], dtype=np.float64)
t = hurray.from_dlpack(arr)
assert t.shape == (3,)
assert t.dtype == hurray.float64
DLPack is an independent zero-copy interchange protocol (not the Array API); see
Python: DLPack and NumPy Interop. For NumPy arrays
you can also use hurray.from_numpy, which shares the array's buffer when its alignment
allows and copies when it does not.
Tier 2 types are not constructible here
The construction functions are Tier 1 only. Passing a Tier 2 dtype (e.g.
hurray.dtype.int4) raises UnsupportedError — there are no meaningful fill/step
semantics for sub-byte or micro-float types in these helpers:
try:
t = hurray.zeros([4], dtype=hurray.dtype.int4)
except hurray.UnsupportedError as e:
print(f"Tier 2 dtype rejected: {e}")
Tier 2 / quantized tensors are produced by decoding Hurray data (hurray.load) or by
the interop paths, not by these constructors.
Runnable example
# From the repo root:
cd hurray-python
maturin develop # build the extension
python examples/construction.py
DLPack and NumPy Zero-Copy Interop
This guide covers zero-copy data exchange between hurray.Tensor and NumPy / PyTorch
via the DLPack v1.0 protocol.
DLPack export
hurray.Tensor implements __dlpack__() and __dlpack_device__(), so any DLPack v1.0
consumer (NumPy 2.0, PyTorch 2.1+, JAX, CuPy) can consume it without copying:
import numpy as np, struct, hurray
buf = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
t = hurray.Tensor(buf, hurray.float32, [2, 3])
# Emit a DLPack v1.0 capsule ("dltensor_versioned").
# The capsule holds a strong reference to `t`, keeping the buffer alive.
arr = np.from_dlpack(t)
assert arr.shape == (2, 3)
assert arr.dtype == np.float32
__dlpack_device__() returns a (DLDeviceType, device_id) tuple:
device_type, device_id = t.__dlpack_device__()
assert device_type == 1 # kDLCPU
assert device_id == 0
Supported dtypes
All Tier 1 Hurray types map to DLPack: int8/16/32/64, uint8/16/32/64,
float16, bfloat16, float32, float64, complex64, complex128.
bool raises builtins.BufferError — Hurray packs 8 booleans per byte while DLPack
uses 1 byte per element; there is no lossless zero-copy mapping.
Tier 2 / quantized types (int4, float8 variants) also raise builtins.BufferError.
Forward-compatibility kwargs
__dlpack__ accepts stream, max_version, dl_device, and copy keyword arguments
for compatibility with DLPack v1.0 consumers. Only stream has defined semantics (all
tensors are ProducerSynced; GPU stream handling is deferred to a future pass).
NumPy interop
Import: hurray.from_numpy
import numpy as np, hurray
arr = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32)
t = hurray.from_numpy(arr)
assert t.shape == (2, 3)
assert t.dtype == hurray.float32
from_numpy stores a raw pointer into NumPy's buffer and holds a strong Python
reference to arr — provided the buffer's base address satisfies the format's 64-byte
alignment floor. NumPy does not promise one, and a large array served by a fresh mmap
never has one, so most arrays are copied into an aligned allocation instead. Pass copy=False to get a hurray.CopyRequiredError rather
than a silent copy; see
Buffer Protocol.
The NumPy array must be C-contiguous (row-major). For Fortran-order or strided
arrays, call numpy.ascontiguousarray first:
f_arr = np.asfortranarray(np.zeros((3, 4), dtype=np.float32))
t = hurray.from_numpy(np.ascontiguousarray(f_arr))
Attempting to pass a non-C-contiguous array raises hurray.UnsupportedError.
Zero-copy export: Tensor.__array__
t = hurray.Tensor(bytes(24), hurray.float32, [2, 3])
arr = t.__array__()
assert arr.shape == (2, 3)
assert arr.dtype == np.float32
Pass a target dtype to cast (a copy is made when casting):
arr_f64 = t.__array__(dtype=np.float64)
assert arr_f64.dtype == np.float64
Pass copy=False if you want to assert that no copy will occur — raises
hurray.CopyRequiredError if a dtype cast is needed:
import hurray
try:
arr = t.__array__(dtype=np.float64, copy=False)
except hurray.CopyRequiredError:
print("A copy would be required for the dtype cast")
__array__ is only supported for CPU tensors with Tier 1 element types.
Non-CPU tensors and Tier 2 / quantized dtypes raise hurray.UnsupportedError.
PyTorch interop
Zero-copy export: Tensor.to_torch
import hurray
t = hurray.Tensor(bytes(16), hurray.float32, [4])
torch_t = t.to_torch() # raises ImportError if torch is not installed
Zero-copy import: hurray.from_torch
import torch, hurray
torch_t = torch.zeros(2, 3, dtype=torch.float32)
t = hurray.from_torch(torch_t)
assert t.shape == (2, 3)
assert t.dtype == hurray.float32
torch is imported at call time — import hurray does not require PyTorch to be
installed.
Error reference
| Error | When raised |
|---|---|
builtins.BufferError | __dlpack__ on bool, int4, float8, or quantized types |
hurray.UnsupportedError | non-CPU __array__, Tier 2 dtype __array__, non-C-contiguous from_numpy |
hurray.CopyRequiredError | __array__(copy=False) when a dtype cast is needed |
hurray.UnsupportedError | DLPack device/layout not representable (PEER memory, tiled layout) |
ImportError | to_torch or from_torch when PyTorch is not installed |
Native Interchange Protocol
hurray.Tensor exposes a __hurray__() method and a matching
hurray.from_hurray() constructor for in-process zero-copy tensor exchange
between Hurray-aware Python extensions.
Unlike DLPack, the native protocol preserves the full Hurray descriptor — device tag,
memory class, sync mode, element type, shape, and layout — without flattening to
DLPack's DLDeviceType enum. It is available on all dtypes (including Tier 2 /
quantized) and in both strict and relaxed modes.
Quick start
import struct, hurray
# Create a source tensor.
raw = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
source = hurray.Tensor(raw, hurray.float32, [2, 3])
# Zero-copy transfer via the native protocol.
target = hurray.from_hurray(source)
assert target.shape == source.shape # (2, 3)
assert target.dtype == source.dtype # hurray.float32
No data is copied. target borrows source's buffer; source is kept alive for
as long as target exists.
Discovery
Probe support with hasattr before calling:
obj = source # whatever you were handed
if hasattr(obj, "__hurray__"):
tensor = hurray.from_hurray(obj)
else:
# Fall back to DLPack or another protocol.
tensor = hurray.from_dlpack(obj)
Why not DLPack?
DLPack is the right tool for interoperating with external libraries (PyTorch, JAX,
NumPy). Use it when your consumers do not link hurray-ffi. The native protocol fills
three gaps that DLPack v1.0 cannot express:
| Situation | DLPack | Native protocol |
|---|---|---|
ROCm UNIFIED memory | UnsupportedError | Supported |
PEER memory (any device) | UnsupportedError | Supported |
Private device tags (0xF0–0xFE) | UnsupportedError | Supported |
| Tier 2 / quantized dtypes | BufferError | Supported |
Tier 2 and quantized tensors
__hurray__ is available unconditionally — it is not gated on strict or relaxed
mode and does not require the dtype to be an Array API Tier 1 type:
import hurray
q_tensor = hurray.Tensor(bytes(64), hurray.dtype.int4, [128])
q_copy = hurray.from_hurray(q_tensor)
assert q_copy.dtype == hurray.dtype.int4 # works in strict mode
Capsule lifecycle
__hurray__() returns a PyCapsule named "hurray_tensor_handle". The capsule holds
one pointer — a HurrayTensor (from hurray-ffi) owning the descriptor, every buffer,
and a strong Python reference to the source Tensor as its opaque owner — and no
context (ADR-043).
hurray.from_hurray() renames the capsule to "used_hurray_tensor_handle" before
taking ownership — preventing double-free if the capsule is later GC'd. Attempting to
consume the same capsule twice raises hurray.BufferError.
t = hurray.Tensor(bytes(8), hurray.float32, [2])
cap = t.__hurray__() # fresh capsule
t2 = hurray.from_hurray(t) # OK: calls __hurray__() internally
cap2 = t.__hurray__() # OK: each call produces a new capsule
Error handling
import hurray
try:
hurray.from_hurray(42)
raise AssertionError("42 exposes no __hurray__")
except TypeError as exc:
print(exc)
The other failure needs two builds to reproduce, so it cannot be shown here: a tensor
produced by an extension built against a different hurray-ffi ABI version raises
hurray.UnsupportedError, naming both versions. The capsule carries the producer's
version precisely so the mismatch is caught at the boundary rather than read as
whatever the consumer's layout happens to be.
Python: Layout Descriptors
t.layout returns a hurray.Layout object, not a string (ADR-032). The wire
format models a layout as a tag plus that layout's parameters — nnz, strides,
page_size, mode_order — and a string carries the tag while throwing the rest
away. The object carries all of it, compares by value, and can be handed straight
back to the hurray.Tensor constructor.
The string is still there, as layout.name.
Reading a layout
import hurray
t = hurray.Tensor(bytes(16), hurray.float32, [4])
print(repr(t.layout)) # RowMajorLayout()
print(t.layout.name) # row_major
print(hex(t.layout.tag)) # 0x1
print(t.layout.buffer_count) # 1
print(t.layout.is_dense) # True
isinstance(t.layout, hurray.RowMajorLayout) # True
isinstance(t.layout, hurray.Layout) # True — every layout shares a base
isinstance is the discriminator, and it encodes a distinction the wire format
genuinely makes: the layout tag.
The class hierarchy
hurray.Layout # base: tag, name, buffer_count, is_dense, is_virtual
├── RowMajorLayout ColMajorLayout
├── StridedLayout TiledLayout MortonLayout HilbertLayout
├── CooLayout CsrLayout CscLayout CsfLayout
├── BlockPagedLayout
├── CompositeLayout
├── PrivateExtensionLayout
└── UnknownLayout
hurray.Layout itself is not constructible — there is no layout that is only "a
layout". It is returned directly in exactly one case: a layout tag this build of
hurray does not yet bind, where tag and name still work. That is
deliberately not UnknownLayout, which means "the tag was unrecognised" — a
different and load-bearing fact for a permissive reader.
Authoring: layout=
import struct
import hurray
# A 2x2 CSR matrix holding [[5.0, 0.0], [0.0, 7.0]].
csr = hurray.Tensor(
struct.pack("2f", 5.0, 7.0), # buffer 0 — values
hurray.float32,
[2, 2],
aux_buffers=[
struct.pack("2Q", 0, 1), # buffer 1 — col_indices
struct.pack("3Q", 0, 1, 2), # buffer 2 — row_ptr
],
layout=hurray.CsrLayout(nnz=2),
)
csr.layout == hurray.CsrLayout(nnz=2) # True
csr.nnz # 2
Omitting layout means row-major, as before.
The layout is a declaration; the buffers are evidence
They must agree. The constructor checks three tiers:
| Tier | Check | Error |
|---|---|---|
| Shape | rank and shape constraints (CSR rank 2, CSF rank ≥ 3, len(strides) == rank) | hurray.InvalidDescriptorError |
| Buffer count | enough buffers for the layout; quantization indices fall beyond them | hurray.InvalidDescriptorError |
| Buffer size | each buffer at least as large as the layout's parameters imply | hurray.BufferError |
Nothing is inferred and nothing is reinterpreted:
try:
hurray.Tensor(
struct.pack("2f", 5.0, 7.0), # two values...
hurray.float32,
[2, 2],
aux_buffers=[struct.pack("8Q", *range(8))],
layout=hurray.CooLayout(nnz=4), # ...but the layout declares four
)
raise AssertionError("the values buffer holds two, not four")
except hurray.BufferError as exc:
print(exc) # buffer 0 (values) is 8 bytes, but this coo layout implies at least 16
The descriptor is not quietly corrected to nnz=2, and it is not accepted as
given — it would encode and decode cleanly and hand the consumer an
out-of-bounds read. Over-sized buffers are allowed: alignment and padding slack
are legitimate.
This is why nnz is a required argument on the sparse layout constructors.
Inference belongs to the array-shaped constructors — hurray.sparse_coo,
hurray.from_scipy — which are handed the arrays and can derive it honestly.
A layout string is not accepted
try:
hurray.Tensor(bytes(16), hurray.float32, [4], layout="csr")
raise AssertionError("a layout is an object, not a name")
except TypeError as exc:
print(exc) # layout must be a hurray.Layout instance
# (e.g. hurray.CsrLayout(nnz=4)), got str
A string cannot carry nnz or strides, so layout="csr" is a request that
cannot be honoured. Accepting it would open a second, lossy authoring path.
Value semantics
Layout objects are immutable, compare by value, and hash:
hurray.CsrLayout(nnz=4) == hurray.CsrLayout(nnz=4) # True
hurray.CsrLayout(nnz=4) == hurray.CsrLayout(nnz=5) # False
len({hurray.CooLayout(nnz=1), hurray.CooLayout(nnz=1)}) # 1
t.layout is t.layout # False — a fresh object
t.layout == t.layout # True
t.layout == "row_major" # False — always
t.layout is read-only: assigning one would silently reinterpret the buffers the
tensor already holds. And a layout never equals a string — keeping that
comparison alive as a special case would break the hash/equality contract and
leave the lossy path open indefinitely.
The parameters a string could not carry
hurray.StridedLayout([4, 1]).strides # (4, 1)
hurray.MortonLayout([3, 3]).morton_bits # (3, 3)
hurray.HilbertLayout(3, 2).hilbert_order # 3
hurray.CooLayout(nnz=7, is_sorted=True).is_sorted # True
hurray.CsfLayout(nnz=5, mode_order=[2, 0, 1]).mode_order # (2, 0, 1)
Strides are in logical elements, signed, and may be negative or zero — not in
bytes, as NumPy's are. That applies to StridedLayout.strides and to the tiled
layouts' outer_strides and inner_strides.
Small closed enumerations are lowercase strings, matching device.kind and
layout.name:
paged = hurray.BlockPagedLayout(
page_size=16, num_pages=64, paged_axis=0, num_seqs=2,
kv_role="key", layer_index=3, block_table_index_type="uint32",
)
paged.kv_role # 'key'
paged.block_table_index_type # 'uint32'
A composite head keeps its rule and its combine operation as two properties:
overlay = hurray.CompositeLayout("overlay", member_count=3, combine_op="add")
overlay.composition_rule # 'overlay'
overlay.combine_op # 'add'
hurray.CompositeLayout("partition", 2).combine_op # None — it does not apply
They are not flattened into one string, because for a partition or a group the operation is not merely unset: it has no meaning.
Reaching buffers that have no named accessor
values, indices, row_ptr, col_indices, row_indices and col_ptr cover
COO, CSR and CSC. CSF has 2 * rank + 1 buffers and block-paged has three, none
of them named. t.buffer(index) reaches any of them, and the layout object says
what each index holds:
csf = hurray.Tensor(
struct.pack("4f", 1.0, 2.0, 3.0, 4.0),
hurray.float32,
[2, 3, 4],
aux_buffers=[
struct.pack("2Q", 0, 2), # pos_0
struct.pack("2Q", 0, 1), # crd_0
struct.pack("3Q", 0, 2, 3), # pos_1
struct.pack("3Q", 0, 2, 1), # crd_1
struct.pack("4Q", 0, 1, 2, 4), # pos_2
struct.pack("4Q", 1, 3, 0, 2), # crd_2
],
layout=hurray.CsfLayout(nnz=4, mode_order=[0, 1, 2]),
)
csf.buffer_count # 7 (2 * rank + 1)
csf.buffer(6).shape # (32,) — the leaf crd, 4 uint64 entries
csf.buffer(6).dtype # hurray.uint8
The view is 1-D uint8 covering exactly the buffer's declared byte size. uint8
is the only honest element type for a generic view: the buffers of one tensor do
not share a dtype — values take the tensor's dtype, index buffers are uint64,
MXFP scales are e8m0 — and uint8 cannot misreport any of them.
Private, unknown, and composite
PrivateExtensionLayout and UnknownLayout are separate classes. "A private
layout I can identify by its extension id" and "a tag from a newer spec version I
could not parse" are different facts, and a permissive relay needs both.
private = hurray.PrivateExtensionLayout(0xF0, extension_layout_id=7, extension_data=b"\x01")
unknown = hurray.UnknownLayout(0x0C, b"\x01\x02")
private.name == unknown.name == "extension" # True — isinstance separates them
private.extension_layout_id # 7
unknown.raw_bytes # b'\x01\x02'
UnknownLayout is constructible so a relay can rebuild what it decoded and write
it back out. Its constructor rejects any tag that has a named class:
try:
hurray.UnknownLayout(0x07)
raise AssertionError("0x07 is a tag this implementation knows")
except ValueError as exc:
print(exc) # tag 0x07 is the csr layout, not an unknown one;
# use hurray.CsrLayout instead
Calling a known tag "unknown" would smuggle a descriptor past every rank and buffer check the named class applies.
Known gap: because a private or unknown layout's buffer count is not knowable, the buffer-count and buffer-size tiers cannot run for it. Nothing in such a descriptor says how many buffers it needs or how large they should be.
A CompositeLayout is readable in full, so a composite head decoded from a
stream reports its own layout truthfully. Building a hurray.Tensor with one
raises, because a composite head owns no buffers:
try:
hurray.Tensor(bytes(16), hurray.float32, [4],
layout=hurray.CompositeLayout("group", 2))
raise AssertionError("use hurray.Composite for a composite head")
except hurray.UnsupportedError as exc:
print(exc) # a composite layout cannot be given to hurray.Tensor:
# a composite head owns no buffers, which this class cannot represent
Round-tripping a descriptor
A tensor's own layout goes straight back into the constructor, which is what lets a relay read a descriptor and write an equal one:
values_bytes = struct.pack("4f", 1.0, 2.0, 3.0, 4.0)
col_indices_bytes = struct.pack("4Q", 0, 2, 1, 0)
row_ptr_bytes = struct.pack("4Q", 0, 2, 3, 4)
original = hurray.Tensor(
values_bytes,
hurray.float32,
[3, 3],
aux_buffers=[col_indices_bytes, row_ptr_bytes],
layout=hurray.CsrLayout(nnz=4),
)
rebuilt = hurray.Tensor(
values_bytes,
original.dtype,
list(original.shape),
aux_buffers=[col_indices_bytes, row_ptr_bytes],
layout=original.layout,
quantization=original.quantization,
statistics=original.statistics,
shard=original.shard,
)
rebuilt.layout == original.layout # True
Runnable example
python hurray-python/examples/layouts.py
See also
- Python: Sparse Tensors and SciPy — the
array-shaped constructors that do infer
nnz. - Python: Tensor Construction
- Layout Descriptors (Rust) — the core types these classes wrap.
Sparse Tensors and SciPy Interop
Hurray exposes COO, CSR, and CSC sparse tensors as ordinary hurray.Tensor
objects whose .layout is a CooLayout, CsrLayout, or CscLayout (ADR-031,
ADR-032). There is no
separate sparse class — sparse is a layout, not a different kind of object.
For CSR and CSC, buffers are shared zero-copy with SciPy sparse matrices via
hurray.from_scipy and Tensor.to_scipy().
Constructing a CSR tensor from SciPy
SciPy's csr_matrix stores three NumPy arrays: .data (values), .indices
(column indices), and .indptr (row pointers). hurray.from_scipy wraps all
three — the resulting Tensor holds a strong reference to the original SciPy
matrix so the buffers remain valid.
Each component is shared or copied on its own merits: three arrays are three
allocations with three addresses, and only an address that meets the format's
64-byte alignment floor can be shared. Pass copy=False to be told which one
fell short instead of paying for a silent copy — see
Buffer Protocol.
Index dtype requirement: Hurray's wire format requires uint64 index
arrays. SciPy defaults to int32. Cast before calling from_scipy:
import numpy as np
import scipy.sparse as sp
import hurray
dense = np.array(
[[1.0, 0.0, 2.0],
[0.0, 3.0, 0.0],
[4.0, 0.0, 5.0]],
dtype=np.float32,
)
m = sp.csr_matrix(dense)
# Cast index arrays to uint64 (required by Hurray's spec).
m.indices = m.indices.astype(np.uint64)
m.indptr = m.indptr.astype(np.uint64)
sparse = hurray.from_scipy(m)
print(sparse)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=5, dtype=float32)
Accessing component views
Each component buffer is accessible as a zero-copy hurray.Tensor view.
The view borrows the parent tensor's buffer — the parent is kept alive for
as long as any view is alive.
| Format | Attribute | Shape | dtype |
|---|---|---|---|
| CSR | .values | (nnz,) | values dtype |
| CSR | .col_indices | (nnz,) | uint64 |
| CSR | .row_ptr | (nrows+1,) | uint64 |
| CSC | .values | (nnz,) | values dtype |
| CSC | .row_indices | (nnz,) | uint64 |
| CSC | .col_ptr | (ncols+1,) | uint64 |
| COO | .values | (nnz,) | values dtype |
| COO | .indices | (nnz, rank) | uint64 |
Accessing a format-specific attribute on the wrong format raises AttributeError:
try:
sparse.indices # COO's attribute, on a CSR tensor
raise AssertionError("a csr tensor has col_indices, not indices")
except AttributeError as exc:
print(exc) # 'Tensor' object has no attribute 'indices';
# this is a csr tensor
To read values into a NumPy array (zero-copy for Tier 1 types):
vals_np = np.array(sparse.values) # zero-copy via DLPack
col_idx_np = np.array(sparse.col_indices)
row_ptr_np = np.array(sparse.row_ptr)
SciPy zero-copy export
Tensor.to_scipy() returns the matching scipy.sparse matrix type.
copy=False is passed to the SciPy constructor; SciPy may copy internally if
it cannot accept uint64 index arrays (version-dependent).
m2 = sparse.to_scipy()
assert isinstance(m2, sp.csr_matrix)
assert (m2.toarray() == dense).all()
CSC tensors return csc_matrix. COO tensors raise hurray.UnsupportedError
(see below).
COO format caveats
hurray.from_scipy does not support COO format zero-copy. SciPy stores
COO row/col coordinates as two separate arrays, while Hurray's spec requires a
single packed [nnz, rank] uint64 buffer. Passing a coo_matrix raises
hurray.UnsupportedError with instructions.
Workaround — convert to CSR first (zero-copy from Hurray's perspective):
m_coo = sp.coo_matrix(np.eye(5, dtype=np.float32))
m_csr = m_coo.tocsr() # SciPy makes one copy here
m_csr.indices = m_csr.indices.astype(np.uint64)
m_csr.indptr = m_csr.indptr.astype(np.uint64)
sparse = hurray.from_scipy(m_csr)
Preferred — hurray.sparse_coo from packed arrays:
hurray.sparse_coo(values, indices, shape) builds a COO Tensor directly,
sharing each array whose alignment allows it and copying the rest (copy= works
here too). indices is a 2-D uint64 array of shape
[nnz, rank] (Hurray's packed layout); values is 1-D of length nnz.
import numpy as np, hurray
# Straight from packed arrays:
values = np.array([5.0, 7.0], dtype=np.float32)
indices = np.array([[0, 0], [1, 1]], dtype=np.uint64) # [nnz, rank]
t = hurray.sparse_coo(values, indices, [2, 2])
assert t.layout == hurray.CooLayout(nnz=2) and t.nnz == 2
# Repacking a SciPy coo_matrix (one copy to interleave row/col, then zero-copy):
indices = np.stack([m_coo.row, m_coo.col], axis=1).astype(np.uint64)
t = hurray.sparse_coo(m_coo.data, indices, m_coo.shape)
Only the np.stack interleave copies; sparse_coo itself borrows both arrays and holds
strong references so the buffers stay alive for the tensor's lifetime.
Tensor.to_scipy() on a COO tensor raises hurray.UnsupportedError.
Access .values and .indices directly and construct scipy.sparse.coo_matrix
manually if needed.
Strict mode and Tier 2 / quantized types
to_scipy() raises hurray.UnsupportedError for tensors with Tier 2 or
quantized values dtypes (int4, float8 variants, etc.) because SciPy has no
equivalent dtype. The index arrays are always uint64 and are unaffected.
SciPy as an optional dependency
import hurray does not require SciPy. from_scipy and to_scipy import
scipy.sparse lazily at call time and raise ImportError if it is not
installed:
try:
sparse = hurray.from_scipy(m)
except ImportError:
print("scipy not installed")
Runnable example
# From the repo root:
cd hurray-python
maturin develop # build the extension
python examples/sparse_scipy.py
Python: Streaming
A file is written once and read back whole. A stream is different: the writer emits tensors one at a time without buffering the output, and the reader gets each tensor as soon as its bytes arrive. That is what the streaming format is for, and ADR-035 brings it to Python.
import hurray
with hurray.StreamWriter("tensors.hrry") as writer:
for tensor in tensors:
writer.write(tensor)
for tensor in hurray.StreamReader("tensors.hrry"):
print(tensor.shape, tensor.dtype)
The reader is an iterator and the writer is a context manager. Both are blocking, and both release the GIL while they wait on their transport, so other Python threads keep running.
The property that matters
Nothing buffers the whole sequence. A tensor is available before the rest of the stream has been read:
reader = hurray.StreamReader(source)
first = next(reader) # available as soon as its bytes arrived
... # do work while the rest is still in flight
rest = list(reader)
If you only ever want everything at once, use hurray.load — a file gives you random
access by name, which a stream deliberately does not.
Transports
| You have | Pass |
|---|---|
| a path | hurray.StreamReader("t.hrry") |
| a socket, pipe, or open file | the object itself — anything with fileno() |
| bytes in hand | hurray.StreamReader(data) |
| nowhere to put it | hurray.StreamWriter() and then getvalue() |
Descriptors are Unix-only. Passing an object with
fileno()raiseshurray.UnsupportedErroron Windows, wherefileno()is a CRT descriptor for files but a rawSOCKETfor sockets — two handle spaces that cannot share one conversion. Paths,bytesand the in-memory writer work everywhere. See issue #215.
import socket
producer, consumer = socket.socketpair()
with hurray.StreamWriter(producer) as writer:
writer.write(tensor)
producer.shutdown(socket.SHUT_WR) # tell the peer there is no more
for received in hurray.StreamReader(consumer):
...
The stream duplicates the descriptor, so finishing it does not close your socket:
with hurray.StreamWriter(sock) as writer:
writer.write(tensor)
sock.send(b"something else") # still yours
io.BytesIO has no descriptor, so pass its contents instead:
hurray.StreamReader(buffer.getvalue())
Finishing
finish() flushes. A writer that is never finished may leave the tail of the stream
sitting in a buffer, which is why the writer is a context manager — the with block
cannot forget. If you cannot use with, call it yourself; it is idempotent, so the two
compose:
writer = hurray.StreamWriter(path)
try:
writer.write(tensor)
finally:
writer.finish()
Writing after finishing raises hurray.StreamError.
Multi-buffer tensors travel whole
Every buffer a descriptor references crosses the stream, in descriptor order — sparse index arrays, quantization scales, page tables:
import struct
csr = hurray.Tensor(
struct.pack("2f", 5.0, 7.0),
hurray.float32,
[2, 2],
aux_buffers=[struct.pack("2Q", 0, 1), struct.pack("3Q", 0, 1, 2)],
layout=hurray.CsrLayout(nnz=2),
)
with hurray.StreamWriter() as writer:
writer.write(csr)
(back,) = list(hurray.StreamReader(writer.getvalue()))
back.layout # CsrLayout(nnz=2)
back.buffer_count # 3
What can go wrong
| Failure | Exception |
|---|---|
| a truncated or malformed frame | hurray.StreamError |
| a descriptor that will not decode | hurray.InvalidDescriptorError |
| the transport failed | hurray.FileError |
| the stream contains a composite | hurray.UnsupportedError |
Composites are refused, not skipped. A composite head owns no buffers and
hurray.Tensor cannot represent one, so the reader raises rather than hand back a
stream that decoded "successfully" having lost the composition. Read those with the
Rust API until composite support reaches Python.
Truncation is only half-detectable. A stream has no end marker — frames are self-delimiting and it ends at EOF, which is the same property that forbids end-of-file indexes. A cut mid-frame raises
hurray.StreamError; a cut exactly on a frame boundary is indistinguishable from a producer that wrote fewer tensors. If you need to know a stream was complete, say so above this layer.
Runnable example
python hurray-python/examples/streaming.py
See also
- IPC and Streaming — the Rust side of the same format
- Streaming Interchange — the wire framing
- Python: File I/O — when random access beats incrementality
Streaming Without Blocking
A blocking stream reader parks its thread until bytes arrive. That is the right shape for a script and the wrong one for a process serving several peers at once: one slow consumer stalls everything behind it, and a thread per peer is the workaround that stops scaling first.
Both languages have an answer, and they are the same state machine underneath. Rust drives
it with tokio; Python drives it with its own event loop and no Rust runtime at all —
there is none in the extension (ADR-041 § 9).
use hurray_core::{
BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::stream::{StreamReader, StreamWriter};
fn tensor(elems: u64) -> Result<(TensorDescriptor, Vec<u8>), Box<dyn std::error::Error>> {
let handle = BufferHandle::new(
elems, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
)?;
let descriptor = TensorDescriptor::new(
1, 0, ElementType::Uint8, Shape::new(vec![elems])?, 0,
LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
)?;
Ok((descriptor, (0..elems as u8).collect()))
}
// Requires hurray-io's `tokio` feature (on by default).
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// A duplex pair stands in for a socket: both halves are AsyncRead + AsyncWrite.
let (producer, consumer) = tokio::io::duplex(64 * 1024);
// Both halves run concurrently in one task — `join!` polls them together, so this
// needs no second thread and no `spawn`.
let writing = async {
let mut writer = StreamWriter::new(producer);
for elems in [8u64, 4, 16] {
let (descriptor, data) = tensor(elems)?;
writer.write_tensor(&descriptor, &[&data]).await?;
}
writer.finish().await?;
Ok::<_, Box<dyn std::error::Error>>(())
};
let reading = async {
let mut reader = StreamReader::new(consumer);
let mut shapes = Vec::new();
// One `next_item` is one item: a tensor, or a whole composite tree.
while let Some(item) = reader.next_item().await? {
shapes.push(item.descriptor().shape.dims().to_vec());
}
Ok::<_, Box<dyn std::error::Error>>(shapes)
};
let (written, shapes) = tokio::join!(writing, reading);
written?;
assert_eq!(shapes?, vec![vec![8u64], vec![4], vec![16]]);
Ok(())
}
import asyncio
import socket
import hurray
import hurray.aio
def tensor(elems):
return hurray.Tensor(bytes(elems), hurray.uint8, [elems])
async def produce(sock):
async with hurray.aio.StreamWriter(sock) as writer:
for elems in (8, 4, 16):
await writer.write(tensor(elems))
sock.shutdown(socket.SHUT_WR) # a clean end of stream for the peer
async def consume(sock):
return [item.shape async for item in hurray.aio.StreamReader(sock)]
async def main():
producer, consumer = socket.socketpair()
with producer, consumer:
# Both coroutines on one loop, in one thread.
_, shapes = await asyncio.gather(produce(producer), consume(consumer))
return shapes
assert asyncio.run(main()) == [(8,), (4,), (16,)]
The reader is an async iterator and the writer an async context manager. One __anext__
is one item: a hurray.Tensor, or a whole hurray.Composite when the stream carries
one.
What await waits for
Every await in hurray.aio waits for the descriptor to become readable or writable. It
never awaits a transfer. Each transfer is a single synchronous call that moves the bytes
and reports the count to the state machine before it returns.
That is a requirement, not a preference (ADR-041 § 6a). Await a transfer instead — with
loop.sock_recv_into, say — and a cancellation landing between the recv and the
future's resolution loses the byte count. A lost count cannot be recovered by inspection,
because 0x00 is valid tensor data. The apparent way out, asyncio.StreamReader.read,
keeps cancelled data in its own buffer and hands back a bytes that must then be copied
into the reader's destination — which is the second, tensor-sized copy the format exists
to avoid.
Cancellation
The two directions are not symmetrical, and the asymmetry is in the format rather than in the binding.
| You cancel | What happened | What you get |
|---|---|---|
| a read, mid-tensor | no byte moved: the only cancellation point is the wait for readiness | the same reader resumes and reads the tensor through |
| a write, before its first byte | nothing was emitted, so the item never happened | the writer is intact; write the same tensor again |
| a write, mid-item | bytes are on the wire and cannot be un-sent | the writer is poisoned and refuses every later write |
async def cancel_a_read(reader):
task = asyncio.create_task(reader.next_item())
await asyncio.sleep(0)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
return await reader.next_item() # the same tensor, read through
A poisoned writer means the peer is holding a truncated item, so a fresh
hurray.aio.StreamWriter over the same connection is not a recovery — the peer has to
be told, or the connection replaced. hurray.StreamError says so when you try.
What this surface does not do
- Descriptors only, and Unix only. The argument must be an object whose
fileno()an event loop can poll: a socket or a pipe. A path orbyteshas no readiness to await, and a regular file cannot be polled at all (epollrefuses one, and a file read does not block) — use the synchronoushurray.StreamReaderfor those. Same boundary as issue #215. - No async file API.
hurray.loadandhurray.savehave nohurray.aiocounterpart. asyncio has no asynchronous random access, so a coroutine over them would be the synchronous call with a thread behind it — whichasyncio.to_threadalready expresses, at a cost the caller can see. - The descriptor is put in non-blocking mode.
O_NONBLOCKbelongs to the open file description, whichdupshares, so this is visible through the object you passed in. Any descriptor an event loop drives has to be in that mode regardless.
The bytes are the same bytes
hurray.aio.StreamWriter and hurray.StreamWriter produce byte-identical streams, and
either reader reads either writer's output — including through the C surface and the
frozen corpus in hurray-io/tests/fixtures/. The driver is what changed; the format did
not.
import io
items = [tensor(8), tensor(4)]
blocking = hurray.StreamWriter()
for item in items:
blocking.write(item)
blocking.finish()
# The same items through hurray.aio, over a pipe this time.
import os
read_fd, write_fd = os.pipe()
class Fd:
def __init__(self, fd):
self._fd = fd
def fileno(self):
return self._fd
async def write_through_aio():
async with hurray.aio.StreamWriter(Fd(write_fd)) as writer:
for item in items:
await writer.write(item)
os.close(write_fd)
async def both():
collected = io.BytesIO()
async def drain():
loop = asyncio.get_running_loop()
source = Fd(read_fd)
os.set_blocking(read_fd, False)
while True:
future = loop.create_future()
loop.add_reader(read_fd, lambda: future.done() or future.set_result(None))
try:
await future
finally:
loop.remove_reader(read_fd)
chunk = os.read(read_fd, 1 << 16)
if not chunk:
return
collected.write(chunk)
await asyncio.gather(write_through_aio(), drain())
os.close(read_fd)
return collected.getvalue()
assert asyncio.run(both()) == blocking.getvalue()
See also
- Python: Streaming — the blocking surface, paths and
bytesincluded. - Streaming Composite Tensors — what a composite is on the wire.
- Streaming from C — the same cores over a transport vtable.
File I/O — Saving and Loading Tensors
hurray.save() writes a collection of named tensors to an HRRYFILE container.
hurray.load() reads them back. Both functions are synchronous; the GIL is
released during I/O so other Python threads are not blocked.
Saving tensors
import hurray
weights = hurray.zeros((512, 512), dtype=hurray.float32)
bias = hurray.zeros((512,), dtype=hurray.float32)
hurray.save(
"model.hrry",
{"weights": weights, "bias": bias},
kv={"arch": "linear", "version": 1},
)
kv is optional file-level metadata. Values may be bool, int, float,
str, bytes, or a homogeneous list of one of those scalar types.
Loading tensors
# Load all tensors
tensors = hurray.load("model.hrry")
w = tensors["weights"] # hurray.Tensor
print(w.shape, w.dtype) # (512, 512) float32
# Load a subset by name
subset = hurray.load("model.hrry", names=["bias"])
hurray.load() returns a dict[str, hurray.Tensor]. Tensors arrive with
an owned buffer (a copy of the bytes from disk). For zero-copy access via
memory-mapped files, use the native protocol (Layer 8c).
Round-trip example
import os, tempfile, hurray
t = hurray.eye(3, dtype=hurray.float64)
with tempfile.NamedTemporaryFile(suffix=".hrry", delete=False) as f:
path = f.name
try:
hurray.save(path, {"identity": t})
loaded = hurray.load(path)
print(loaded["identity"].shape) # (3, 3)
finally:
os.unlink(path)
Error handling
try:
hurray.load("missing.hrry")
except hurray.FileError as e:
# subclass of OSError — also caught by `except OSError`
print(f"file error: {e}")
| Exception | Raised by | Cause |
|---|---|---|
hurray.FileError | load(), save() | File not found, corrupt container, CRC mismatch, unexpected EOF |
hurray.InvalidDescriptorError | load() | Tensor descriptor failed to decode |
hurray.UnsupportedError | load(), save() | Sparse (multi-buffer) tensors are not yet supported |
Limitations in this release
- Loaded tensors hold an owned copy of the buffer data. Zero-copy memory-mapped loading will be added in a future release.
Sparse and other multi-buffer tensors round-trip through save() and load():
every buffer is written in descriptor order and read back with the layout intact.
Tensor Display: __repr__ and __str__
hurray.Tensor implements __repr__ and __str__
following NumPy/PyTorch display conventions.
hurray.Tensor
__repr__
For Tier 1 CPU tensors (when NumPy is installed), repr() shows the data
values formatted by numpy.array2string, plus the dtype:
import hurray
t = hurray.ones([2, 3], dtype=hurray.float32)
repr(t)
# hurray.Tensor([[1. 1. 1.]
# [1. 1. 1.]], dtype=float32)
t2 = hurray.arange(5)
repr(t2)
# hurray.Tensor([0 1 2 3 4], dtype=int64)
Large tensors are truncated automatically (NumPy threshold, default 1000 elements):
t = hurray.zeros([1000], dtype=hurray.float64)
repr(t)
# hurray.Tensor([0. 0. 0. ... 0. 0. 0.], dtype=float64)
Fallback (Tier 2 types, non-CPU devices, or NumPy not installed):
# Tier 2 — no NumPy equivalent
t = hurray.Tensor(b'\x21', hurray.dtype.int4, [2])
repr(t)
# hurray.Tensor(shape=(2,), dtype=int4, device=cpu)
__str__
str() returns the bare NumPy-style array string without the hurray.Tensor(...)
wrapper — suitable for print():
t = hurray.linspace(0.0, 1.0, 5)
print(t)
# [0. 0.25 0.5 0.75 1. ]
t2 = hurray.full([3, 3], 7.0, dtype=hurray.float32)
print(t2)
# [[7. 7. 7.]
# [7. 7. 7.]
# [7. 7. 7.]]
Falls back to repr() when NumPy is unavailable or for Tier 2 types.
Sparse-layout tensors
Both repr() and str() show format, shape, nnz, and dtype:
import numpy as np
import scipy.sparse as sp
import hurray
m = sp.csr_matrix(([1.0, 2.0], ([0, 1], [1, 0])), shape=(2, 2))
# Hurray's spec requires uint64 index arrays; SciPy builds int32 ones.
m.indices = m.indices.astype(np.uint64)
m.indptr = m.indptr.astype(np.uint64)
t = hurray.from_scipy(m)
repr(t)
# hurray.Tensor(layout='csr', shape=(2, 2), nnz=2, dtype=float64)
print(t)
# hurray.Tensor(layout='csr', shape=(2, 2), nnz=2, dtype=float64)
str() is identical to repr() for sparse tensors. By default the display is
metadata only (SciPy-style).
Display options: metadata vs. content
Switch sparse display to a PyTorch-style content form that also shows
the per-format buffer arrays. Use hurray.set_print_options to set it globally, or
hurray.print_options(...) as a context manager for a scoped change (auto-reverts
on exit). The default is "metadata", so existing behavior is unchanged.
import numpy as np
import scipy.sparse as sp
import hurray
m = sp.csr_matrix(([1.0, 2.0, 3.0, 4.0], ([0, 0, 1, 2], [0, 2, 1, 0])), shape=(3, 3))
m.indices = m.indices.astype(np.uint64)
m.indptr = m.indptr.astype(np.uint64)
t = hurray.from_scipy(m)
# Default — metadata only:
repr(t)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=4, dtype=float64)
# Global switch to content:
hurray.set_print_options(sparse_display="content")
repr(t)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=4, dtype=float64,
# values=[1. 2. 3. 4.], col_indices=[0 2 1 0], row_ptr=[0 2 3 4])
hurray.get_print_options()
# {'sparse_display': 'content'}
# Or scope it to a block (reverts automatically):
hurray.set_print_options(sparse_display="metadata")
with hurray.print_options(sparse_display="content"):
print(repr(t)) # content form
print(repr(t)) # back to metadata
The per-format arrays shown in content mode are:
| Format | Arrays |
|---|---|
| COO | indices, values |
| CSR | values, col_indices, row_ptr |
| CSC | values, row_indices, col_ptr |
Note: the sparse component accessors cover only the rank-2, SciPy-interop layouts COO, CSR, and CSC. The CSF (Compressed Sparse Fiber) layout exists in
hurray-core(docs/spec/layouts/csf.md); a CSF tensor loads as ahurray.Tensorwithlayout == "csf", but has no component accessors yet, so it has no Python display form. Exposing rank-N CSF in the Python bindings is future work.
Content mode formats the arrays via NumPy (honoring your active numpy print
options); if NumPy is not installed it falls back to the metadata string.
set_print_options and print_options are backed by a contextvars.ContextVar,
so the setting is isolated per asyncio task / thread context (like the strict/relaxed
mode config). An invalid sparse_display value raises ValueError.
Runnable example
cd hurray-python
maturin develop
python examples/display.py
hurray-python Error Handling
This entry covers the hurray Python exception hierarchy and the catch_panic
utility for converting Rust panics to typed Python exceptions.
Exception class tree
ValueError
├── hurray.InvalidDescriptorError — parse / validation errors
└── hurray.BufferError — buffer size / alignment errors
NotImplementedError
└── hurray.UnsupportedError — unsupported element type or layout
RuntimeError
└── hurray.InternalError — unexpected Rust panics
Note:
hurray.BufferError(subclass ofValueError) is distinct from the Python built-inbuiltins.BufferError. The built-in is used by__dlpack__for element types outside the DLPack type enum (per the Array API Standard).hurray.BufferErroris used for buffer size and alignment errors from the Rust core.
Catching hurray exceptions
import hurray
# Catch a specific hurray error
try:
# ... operation that may fail ...
pass
except hurray.InvalidDescriptorError as exc:
print(f"bad descriptor: {exc}")
# Catch by base class (when the exact subtype doesn't matter)
try:
pass
except ValueError as exc: # catches InvalidDescriptorError and BufferError
print(f"value error: {exc}")
except NotImplementedError as exc: # catches UnsupportedError
print(f"unsupported: {exc}")
except RuntimeError as exc: # catches InternalError
print(f"internal: {exc}")
catch_panic — converting Rust panics to InternalError
The errors::catch_panic helper wraps a closure in std::panic::catch_unwind
and converts any panic to hurray.InternalError. Use it in Rust code that calls
into potentially-panicking operations:
// Not compiled: `#[pyfunction]` only expands inside a PyO3 module, and
// `compute_something` stands in for whatever the binding calls into.
use hurray::errors::catch_panic;
#[pyfunction]
fn risky_operation(py: Python<'_>) -> PyResult<i64> {
catch_panic(|| {
// ... call into Rust core that might panic on bad input ...
Ok(compute_something())
})
}
PyO3 already catches panics in #[pyfunction] wrappers and raises RuntimeError,
but catch_panic ensures the more specific hurray.InternalError subclass is raised
with the panic message embedded.
Build notes
[lints.rust] for PyO3 0.22 create_exception!
PyO3 0.22's create_exception! macro emits cfg(feature = "gil-refs") into the
destination crate's scope. Without explicit configuration this triggers an
unexpected_cfgs warning that -D warnings promotes to an error. The fix in
hurray-python/Cargo.toml:
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("gil-refs"))'] }
Spec references
docs/impl/python-bindings.md— § Error Handling
API Reference
Generated reference documentation for this version of Hurray. Both references are built from this version's own source tree, so they describe exactly the code the rest of this book describes.
Note (non-normative): The links below are relative to the documentation site and resolve only there — they are generated output, not files in the repository. Reading this page on GitHub, follow it on the documentation site instead.
Rust
cargo doc output for every crate in the workspace.
| Crate | What it covers |
|---|---|
hurray_core | Element types, shape, buffer handles, quantization and layout descriptors, TensorDescriptor encoding. No I/O. |
hurray_io | Async streaming interchange and the HRRYFILE container. |
hurray_ffi | The C ABI: opaque handles, function table, release callbacks. |
hurray | The Rust side of the Python bindings — the PyO3 classes behind the hurray module. |
hurray_inspect | The descriptor hex-viewer CLI. |
Python
The hurray module — every class, function, and constant the bindings
expose, with signatures and examples.
| Page | What it covers |
|---|---|
hurray | Tensor, Composite, Descriptor, layout and quantization classes, creation and interop functions, file and stream I/O, exceptions. |
hurray.dtype | The element-type constants and Dtype. |
hurray.device | The device constants and Device. |
For task-shaped introductions rather than an exhaustive surface, start from the cookbook's Python entries — Dtype, Device, and Tensor is the first one.
Tutorials
Guided, end-to-end walkthroughs of Hurray. Where the Cookbook gives focused recipes for one feature at a time, tutorials string those recipes together into a start-to-finish path.
Guided path: build up a Hurray implementation, one layer at a time
The layered cookbook is written to be read in order. Follow it top to bottom for a complete tour of the format, from element types to the C FFI:
- Layer 0 — Element Types and Shape
- Layer 1 — Buffer Protocol
- Layer 2 — Quantization Descriptors
- Layer 3 — Layout Descriptors
- Layer 4 — Tensor Descriptor Encoding
- Layer 5 — Streaming Interchange
- Layer 6 — File Format
- Layer 7 — C FFI
Task-focused tutorials
- Integrating a Python Library with Hurray — the four interop paths available to a library with its own array type, and how to choose between them.
More tutorials will be added here over time. To propose or contribute one, open an issue or pull request on GitHub.
Integrating a Python Library with Hurray
You maintain a Python library with its own array type — a sparse linear algebra package, an inference runtime, a columnar store. Someone asks it to read and write Hurray tensors. There are four ways to do that, and they differ enormously in cost.
This tutorial is about choosing, not about mechanism. Each individual mechanism is already documented in the Cookbook; what is harder to find is which one your library should adopt, and what you sign up for by doing so.
Throughout, the running example is sparselib — a fictional stand-in for a
SciPy-shaped library. It has its own storage types, cares about sparse layouts, and
would rather not take a hard dependency on a format it merely supports. That makes it
a good lens: it exercises the trade-offs instead of the happy path.
Choose a path
Answer four questions:
| Question | If yes | If no |
|---|---|---|
| Do you need zero-copy? | paths 2, 3 | path 1 is fine |
| Are producer and consumer in the same process? | paths 2, 3 | paths 1, 4 |
| Do you handle Tier 2 or quantized dtypes, or non-standard devices? | path 3 | path 2 suffices |
Can you take a dependency on hurray? | paths 1, 2, 3 | path 4 |
Which gives:
| Path | You write | You depend on | Zero-copy | |
|---|---|---|---|---|
| 1 | Import hurray | Python | hurray | on the buffers, yes |
| 2 | Speak DLPack | nothing Hurray-specific | nothing | yes |
| 3 | Implement __hurray__ | C/C++/Rust extension | hurray-ffi | yes, full fidelity |
| 4 | Parse the bytes | a reader/writer | nothing | your choice |
Note (non-normative): Most integrations should start at path 2, discover it covers their case, and stop. Path 3 exists for what DLPack cannot express; path 4 for when a dependency is unacceptable.
Path 1 — Import hurray
The direct route. Your library converts between its own type and hurray.Tensor,
and uses hurray for file I/O.
For sparselib, whose arrays are already SciPy-compatible, conversion is nearly
free — hurray.from_scipy wraps CSR/CSC/COO component arrays without copying, and
Tensor.to_scipy() converts back:
import hurray
sparse = hurray.from_scipy(matrix) # zero-copy over the component arrays
assert sparse.layout.name == "csr"
assert sparse.nnz == matrix.nnz
matrix_again = sparse.to_scipy() # back to scipy.sparse
Dense arrays go through hurray.from_numpy and numpy.asarray. Either kind
persists the same way — save() writes every buffer a tensor has, so a sparse
tensor's index arrays travel with its values:
tensor = hurray.from_numpy(dense_array)
hurray.save("out.hrry", {"a": tensor})
loaded = hurray.load("out.hrry")["a"]
What you get. The full descriptor — quantization, statistics, shard — and the
file format, or hurray.StreamWriter / hurray.StreamReader for the streaming one
(see Python: Streaming).
What it costs. A hard dependency on pyhurray, and your users install a compiled
extension. For a library whose Hurray support is one feature among many, that is the
main objection — and path 2 exists precisely to avoid it.
Path 2 — Speak DLPack, and write no Hurray code at all
DLPack is an independent, header-only ABI plus a PyCapsule protocol. It is not
part of Hurray, and Hurray does not own it — it is the same protocol NumPy, PyTorch,
JAX, and CuPy already implement.
If sparselib's dense array type exposes __dlpack__ and __dlpack_device__, then
Hurray can already consume it and sparselib ships nothing Hurray-specific:
# In hurray-aware code, elsewhere — sparselib itself needs no changes.
import hurray
tensor = hurray.from_dlpack(sparselib_array) # zero-copy, no copy of your data
hurray.save("out.hrry", {"a": tensor})
Going the other way, a hurray.Tensor exposes __dlpack__ too, so your library
consumes it with the machinery you already have:
arr = sparselib.asarray(tensor) # via your existing from_dlpack support
What you get. Zero-copy in both directions, no dependency, no build changes.
Implementing __dlpack__ is worth doing regardless of Hurray — it buys
interoperability with the whole array ecosystem at once.
What it costs. DLPack's type and device enums are narrower than Hurray's descriptor. It cannot represent:
- Tier 2 element types —
int4,float8variants, sub-byte packed types - quantized tensors and their scale buffers
UNIFIED/PEER/ private memory classes and device tags- sparse layouts, block-paged layouts, composite tensors
hurray.Tensor.__dlpack__ raises BufferError for those rather than lying about
the payload. If your library only ever handles dense Tier 1 data on CPU or CUDA,
none of that matters and you are done.
Path 3 — Implement __hurray__
When DLPack is too narrow, implement Hurray's native protocol on your own
type. This is for libraries with a compiled extension: you link hurray-ffi and hand
back a PyCapsule.
This path is Python-plus-C, not Rust — there is no Rust counterpart to show, because a Rust producer would use
hurray-coretypes directly rather than crossing a Python capsule boundary.
The contract, in full:
- Expose
__hurray__(stream=None)returning aPyCapsulenamed"hurray_tensor_handle". - The capsule pointer is a
HurrayTensor, and there is no context. Accumulate your buffers withhurray_buffer_list_newand onehurray_buffer_list_pushper buffer, in descriptor buffer-table order, then hand the finished list tohurray_tensor_newalong with your encodedTensorDescriptor,HURRAY_C_ABI_VERSION, and whatever reference keeps your memory alive (as the opaqueowner). hurray_tensor_newdecodes the descriptor, so a descriptor that does not parse is refused where you create the capsule rather than where it is consumed.- Consumers rename the capsule to
"used_hurray_tensor_handle"on consumption; your destructor callshurray_tensor_destroyif it was never consumed — one call, which releases the descriptor, every buffer, and your owner reference.
Discovery is duck-typed. Nothing registers anything:
if hasattr(obj, "__hurray__"):
tensor = hurray.from_hurray(obj)
elif hasattr(obj, "__dlpack__"):
tensor = hurray.from_dlpack(obj) # narrower, but widely available
else:
raise TypeError("no supported interchange protocol")
For sparselib, this is the path that makes its sparse types first-class: a COO
matrix is values plus coordinates, two buffers, which DLPack cannot express as one
object but one HurrayTensor carries natively.
What you get. Full fidelity — every element type, every device tag and memory class, quantization with its scale buffers, multi-buffer layouts.
What it costs. A compiled extension linking hurray-ffi, and the lifetime
discipline that comes with it. Two rules do most of the work:
- A handle from
hurray_buffer_list_getis borrowed. Do not destroy it; the list owns it. hurray_buffer_list_destroytakes a pointer to your pointer and nulls it. Destroy the list exactly once; it frees every handle it holds.
See Multi-Buffer Tensors for worked code and the C FFI guide for the normative rules.
Path 4 — Parse the bytes yourself
Nothing obliges you to link anything. The format is designed to be re-implemented:
- little-endian throughout
- self-delimiting — every section states its own length
- no back-references and no end-of-file index, so a reader can start work before it has seen the whole input
A pure-Python .hrry reader over struct and numpy is a reasonable weekend
project, and for a library that refuses new binary dependencies it may be the only
acceptable option.
What you get. Zero dependencies, full control, and the ability to read Hurray data anywhere Python runs.
What it costs. You own conformance. The compliance checklist is the contract, and the conformance vectors are how you check yourself against the reference implementation. You also inherit every future format addition.
hurray-inspect is the tool to develop against — it decodes any descriptor field by
field, so you can compare your parser's interpretation against the reference one byte
at a time:
hurray-inspect weights.hrry
Worked example: sparselib picks a path
sparselib handles float32 and float64 sparse matrices, has a compiled extension
already, and does not want a hard hurray dependency for a feature only some users
need.
Ruling out. Path 1 is rejected on the dependency. Path 4 is rejected as disproportionate — it is a lot of surface to own for one feature.
The choice. Path 2 for dense arrays, since sparselib already implements
__dlpack__ and it costs nothing. Path 3 for the sparse types, because a COO matrix
is inherently multi-buffer and DLPack has nowhere to put the coordinate array.
The seam. hurray becomes an optional dependency, imported lazily inside the
conversion functions:
def to_hurray(matrix):
try:
import hurray
except ImportError as exc:
raise RuntimeError(
"Hurray support requires the hurray module: pip install pyhurray"
) from exc
return hurray.from_scipy(matrix)
Users who never touch Hurray never install it; the sparse fast path stays zero-copy for those who do.
Where to go next
- Quickstart — the smallest end-to-end example
- Framework Interop — NumPy, PyTorch, DLPack in detail
- Python: Sparse Tensors with SciPy — path 1 and 2 for sparse
- Multi-Buffer Tensors — the protocol behind path 3
- Authoring Quantized Tensors — building and reading quantized descriptors
- Compliance Checklist — the contract for path 4
Tensor Data Interchange for AI/ML Systems: A Survey and the Hurray Proposal
Pascal Gillet
pascalgillet@ymail.com
Revision: September 2026 · Also available as PDF
Hurray project: https://pgillet.github.io/hurray/
Source code and specification: https://github.com/pgillet/hurray
Abstract
AI/ML systems move large amounts of tensor data between libraries, processes, machines, accelerators, and storage. Model weights are loaded from files into accelerator memory. Frameworks exchange tensors without copying them. Distributed training splits tensors across GPUs. Disaggregated inference transfers key-value (KV) caches between prefill and decode workers.
A tensor is a multidimensional array of values. Moving a tensor from one system to another requires transferring its data, but also enough information to interpret that data: its element type, shape, and memory layout, i.e. how its elements are arranged in memory. Some tensors require additional information. Quantized tensors store values using lower-precision encodings and associated parameters such as scales. Sparse tensors store selected values together with indexes instead of storing every value. Paged KV caches use blocks whose physical order can differ from their logical sequence order. Tensors can also be split, or sharded, across several devices.
Several interchange solutions cover parts of this information. DLPack describes strided tensors in memory, including their device. Apache Arrow defines dense and sparse tensor IPC (inter-process communication) representations and has added fixed- and variable-shape tensor types to its columnar data model. SafeTensors and GGUF store model tensors in files, with GGUF supporting many quantized representations. Zarr and NetCDF store large multidimensional arrays. NIXL and UCX move memory efficiently between machines and devices, while NCCL provides communication operations between GPUs.
This paper compares these solutions and the tensor information they preserve. The comparison shows a practical gap for tensors whose physical representation matters to computation. Layout, quantization, device memory, paging, and sharding increasingly appear at the boundaries between runtimes, but among the systems surveyed here, no widely used interchange format combines these properties in one tensor description reusable across memory, files, and network transfers.
We then present Hurray, an open-source tensor interchange project designed for this use case. Hurray defines a language-independent descriptor for a tensor's type, shape, layout, quantization, device and memory placement, buffers, and composition. The same descriptor is used for streaming and persistent files and can accompany data moved through existing communication systems.
The goal is simple: when two runtimes support the same tensor representation, they should be able to exchange it without first converting it to an intermediate layout. When they do not, the difference should be explicit so that the required conversion can be selected.
1. Introduction
AI/ML applications rarely run inside a single library.
A Python application may prepare input with NumPy, execute a model with PyTorch, call kernels through CUDA, exchange tensors with another runtime, and store model weights in SafeTensors or GGUF. A distributed training job can split one tensor across dozens or hundreds of accelerators. An inference service can compute a KV cache on one GPU and consume it on another machine.
At each boundary, two things have to move: the tensor data and the information needed to interpret it.
For a simple tensor this information is small. Consider a float32
matrix with shape [1024, 4096], stored consecutively by rows. A
receiver needs to know the data type, the two dimensions, and where the
data starts.
Modern tensor representations can be more complicated.
A matrix may be transposed without moving its data. A GPU kernel may require values to be stored in tiles. A four-bit weight tensor may pack several values into each byte and use one scale per block. A sparse matrix may consist of a value buffer and several index buffers. A KV cache may be divided into pages spread across GPU memory. A distributed tensor may be split across several GPUs.
These representations matter because converting between them costs time and memory bandwidth.
The raw parameter payload of a 70-billion-parameter model is about 140 GB at 16 bits per parameter and about 35 GB at 4 bits per parameter, before quantization metadata and other overheads. Converting a tensor of this size merely to cross a software boundary can require reading and writing tens or hundreds of gigabytes of memory.
The same issue appears at smaller scales but higher frequencies. An inference server can transfer KV-cache blocks for many requests between workers. If producer and consumer already support the same cache layout, converting those blocks to a generic dense representation before transfer is unnecessary work.
A tensor interchange format determines how much of the original representation survives such a boundary.
This paper surveys existing approaches to tensor interchange. It covers in-memory interfaces, data formats, scientific array storage, communication systems, and recent work on distributed inference. The systems considered include DLPack, Apache Arrow, SafeTensors, GGUF, ONNX, Zarr, NetCDF, NIXL, UCX, and NCCL, with MLIR, PJRT, and PyTorch DTensor considered as adjacent approaches.
The survey then considers Hurray, an open-source project that defines a broader tensor descriptor covering layout, quantization, memory placement, and tensor composition.
Hurray is currently a beta, pre-1.0 project. Its relevance therefore depends less on its current adoption than on the question examined in this paper: can one stable tensor descriptor carry enough of the execution representation --- layout, quantization, placement, buffers, and composition --- to be reused at runtime, in streams, and in files?
2. What Describes a Tensor?
2.1 Shape and data type
A tensor generalizes a scalar, vector, or matrix to any number of dimensions.
A vector of length 10 has shape [10]. A matrix with 1024 rows and 4096
columns has shape [1024,4096]. A batch of 32 RGB images with height
and width 224 may have shape [32,3,224,224].
The number of dimensions is the tensor's rank. The tensor also has
an element type, such as float32, float16, bfloat16, or int8.
Shape and type describe the logical array, but not necessarily how it is stored.
2.2 Dense tensors and strides
A dense tensor stores a value for every position in the array.
The simplest dense layout stores values consecutively. A C-style matrix normally stores one complete row after another. A Fortran-style matrix normally stores one complete column after another.
Many tensor libraries use strides to describe more general layouts.
For a tensor with indexes (i_0,...,i_{n-1}), an ordinary strided
representation can calculate the element position as:
o + sum(i_k * s_k)
where o is an initial offset and s_k is the stride of dimension k.
Strides make many tensor operations cheap. A transpose can often exchange dimensions and strides without moving the underlying values. A slice can adjust the starting offset and strides. Broadcasting can use a zero stride for a repeated dimension.
DLPack uses this model. It covers a large and important set of tensor views, but not every layout used by current accelerators and inference systems.
2.3 Memory layout
The memory layout of a tensor is the rule that maps a logical
element such as [i,j,k] to the bytes that store it.
Strides are one such rule.
A tiled layout divides a tensor into smaller rectangular blocks and specifies how the blocks and their contents are ordered. Tiling can improve cache locality or match the matrix representation expected by accelerator hardware.
A packed layout rearranges values into the exact order a particular instruction or kernel reads them, and the result may no longer be described by one ordinary stride per dimension.
A paged layout divides the tensor into separately allocated blocks. A table maps logical regions of the tensor to those physical blocks. KV-cache management in modern LLM serving systems is an important example.
The layout is therefore part of the information a consumer needs if it wants to use the existing representation directly.
2.4 Sparse tensors
A sparse tensor avoids storing positions whose value is implicitly zero or another default value. Instead, it stores selected values plus indexes identifying their positions. Instead of storing a million-element matrix with only ten thousand non-zero values, a sparse representation stores those ten thousand values and the indexes that locate them.
COO, or coordinate format, records coordinates for stored values. CSR, or compressed sparse row format, compresses indexes by matrix row. CSC performs a similar operation by column. Higher-dimensional sparse arrays can use structures such as CSF, compressed sparse fiber.
A sparse tensor is consequently often made from several buffers: values, indexes, and offsets. Shape alone cannot describe it.
Apache Arrow is notable here because it defines standardized sparse tensor representations in addition to dense tensors.
2.5 Quantization
Quantization stores numerical values at lower precision to reduce memory use and often increase compute throughput.
A simple affine scheme can reconstruct an approximate value as
x_hat = s(q-z), where q is the stored integer, s a scale, and z
a zero point. The scale can apply to the whole tensor, one channel, or a
small block of values.
Other schemes work differently. NF4 uses a small codebook. Block floating-point formats share scaling information across groups. Microscaling formats combine low-precision values with block-level scales.
A quantized tensor can therefore require more information than a data-type name: the logical type, storage type, quantization scheme, scales and optional zero points, grouping or block size, and, for sub-byte values, how bits are packed.
Two formats both called "4-bit" are not necessarily compatible.
2.6 Where the tensor is stored
Tensor data can reside in different kinds of memory.
Host memory is memory directly available to the CPU. Accelerator memory is memory associated with a GPU or another accelerator. Unified memory provides an address-space abstraction shared across processors, with the underlying system managing access or migration.
Other relevant cases include pinned host memory, operating-system shared memory, GPU memory accessible by peer devices, and memory registered for RDMA.
This information matters for zero-copy interchange. A consumer may understand a tensor's layout perfectly but still be unable to access its buffers where they currently reside.
2.7 Sharding and tensor composition
Large tensors are often divided among devices. This is called sharding. Distributed training systems treat tensor partitioning over devices as a first-class concern [17].
For example, a matrix with shape [65536,16384] might be divided by
rows across eight GPUs. Each GPU stores a [8192,16384] shard.
Describing each shard independently does not describe the global tensor. The receiver also needs to know which part of the global tensor each shard represents.
A related concept is tensor composition. In this paper, that means describing one tensor or tensor artifact using several constituent tensors or regions. It can cover shards of one distributed tensor, several named tensors grouped in one model, sparse values and their index tensors, a quantized tensor and its scale tensors, and heterogeneous regions stored in different formats.
3. What an Interchange Format Needs to Do
3.1 Describe the tensor
The receiver needs enough metadata to interpret the buffers. For a basic dense tensor, this means shape, type, and layout. Other tensors can require sparse indexes, quantization parameters, page tables, or information about constituent tensors.
3.2 Avoid copies when possible
Zero-copy interchange means the receiver reads the producer's existing buffer rather than a copy of it. What crosses the interface is a pointer or a memory handle, not the bytes.
Zero-copy is not always possible. The receiver must understand the representation, be able to access the memory, satisfy alignment requirements, and observe the correct lifetime and synchronization rules. Buffer alignment is the requirement that a buffer start at an address that is a multiple of some specified size. Particular instructions and device interfaces can require it, and where they do not, it can still affect throughput. A format can state a minimum alignment rather than leaving it to convention. Synchronization matters because the producer may not have finished: if an accelerator is still writing the data, the consumer must wait for the appropriate synchronization point before reading it.
A format cannot guarantee zero-copy in every situation. It can provide enough information for the receiver to determine whether zero-copy is possible.
3.3 Stream large tensors
A streaming representation should let the receiver read the tensor description first and then process its payload incrementally. This is useful for network transfers and pipelines where a tensor can begin moving before the complete object is available at the destination.
3.4 Read individual tensors from files
A model can contain hundreds or thousands of named tensors. A reader should be able to locate one tensor without scanning or loading the entire file. Memory mapping is also useful because the operating system can map file pages into the process address space and load them on demand.
3.5 Work across languages
A tensor format is more useful when its definition does not depend on a Python or C++ object. DLPack and Apache Arrow both use language-neutral specifications and C-compatible interfaces to connect independent implementations. Such an interface is an ABI, or application binary interface: a fixed binary representation of structures and calls that separately compiled components can rely on without agreeing at source level.
3.6 Stay separate from the transport
Describing a tensor and moving its bytes are different jobs.
Shared memory, TCP, RDMA, CUDA IPC, NIXL, UCX, and other mechanisms can all carry data. A tensor descriptor does not need to replace them. It needs to tell the receiving application what the transferred buffers contain.
4. DLPack: In-Memory Tensor Exchange
DLPack [1] is one of the most widely used mechanisms for exchanging tensors between machine-learning frameworks.
Its basic DLTensor structure carries a data pointer, a device, the
number of dimensions, a data type, a shape, strides, and a byte offset.
Current DLPack also defines low-precision floating-point data types,
including several FP8 and smaller formats. Managed tensor structures add
lifetime information so the consumer and producer can coordinate
ownership.
This is already a useful tensor descriptor.
A PyTorch tensor can, for example, be passed to another DLPack-compatible framework without converting it into an intermediate file or copying its data simply because the framework changes.
DLPack is especially well matched to ordinary strided tensors. It also carries device information. Its scope is intentionally small.
DLPack does not define a file format or a network framing protocol. A
DLTensor describes one tensor rather than a distributed group of
shards. Its core tensor structure does not provide standard descriptions
for sparse indexes, paged KV caches, arbitrary accelerator tiling, or
generic quantization parameters.
DLPack's main strength is precisely that a small common representation
is easy for frameworks to adopt, and the Python array ecosystem has
converged on it. The Python Array API Standard [25] weighed the Python
buffer protocol and __cuda_array_interface__ against it and chose
DLPack as its recommended protocol, on the grounds that a device-specific
protocol leaves a consumer no defined order in which to try protocols,
and that "DLPack has the widest support". The CUDA Array Interface [26]
is itself worth noting here, because it demonstrates the same model on
GPUs: a device pointer, shape, typestr, optional strides, and a
stream field naming the stream on which the producer may still have
work in flight, so the consumer knows what to synchronize against.
5. Apache Arrow
Apache Arrow [2] is the most mature example of a common physical data representation shared across many languages and systems.
Arrow is primarily designed for the tabular model: data as a set of records, typically rows from a relational database, each a set of named typed fields, stored in a columnar physical representation, with buffers defined by each Arrow type. Its basic objects are arrays, record batches, and tables. The Arrow specification defines their in-memory representation independently of a particular language implementation. The C Data Interface allows libraries in one process to share Arrow buffers, while Arrow IPC defines serialized messages for exchanging Arrow data between processes or storing it in streams and files.
Arrow also has substantial tensor support.
5.1 Standalone Tensor
Arrow defines a standalone Tensor structure [3] for multidimensional
arrays. The representation contains tensor shape and strides and can
therefore describe conventional strided multidimensional arrays.
Arrow also defines alignment rules for these tensor IPC structures. Standalone tensor bodies are aligned to 64-byte boundaries.
5.2 SparseTensor
Arrow separately defines SparseTensor [3].
This is significant because sparse tensors cannot in general be reduced to shape plus strides. Arrow defines standard sparse index structures and specifies how the associated buffers are represented.
Arrow therefore covers more than one tensor layout family.
5.3 Tensor-valued Arrow columns
Arrow also defines the canonical extension types
arrow.fixed_shape_tensor and arrow.variable_shape_tensor [4].
These allow tensor-valued observations to participate in Arrow's normal columnar data model. The fixed-shape representation records tensor shape and can include dimension names and a permutation between logical and physical dimensions. The variable-shape representation supports tensor values whose dimensions vary.
The elements of these canonical tensor extensions are stored in row-major, C-contiguous order. A permutation can change the logical interpretation of dimensions, but it does not define an arbitrary tiled or paged physical layout.
5.4 Arrow Flight
Arrow Flight [5] provides high-performance network transfer for Arrow data. Flight uses Arrow IPC and gRPC with implementation optimizations designed to avoid unnecessary serialization and memory copies, and published benchmarks report multi-gigabyte-per-second transfers with high utilization of the available network bandwidth [6].
Flight is therefore strong prior art for network data interchange. The more relevant difference for computational tensors is what the transferred metadata describes.
Arrow's mature streaming ecosystem is centered on its columnar data model and RecordBatches. Its tensor structures do not provide a general description of accelerator-specific packed layouts, quantization schemes, paged KV caches, device memory, or distributed tensor composition.
5.5 What Arrow establishes
Arrow demonstrates that a public physical format can be shared by independent implementations; metadata and buffers can be separated; the same data model can support memory sharing and serialized interchange; alignment can be part of the format; extensions can add domain-specific semantics; and high-performance network transfer can be built around the same representation.
These principles are a major influence on Hurray.
6. Tensor and Array Files
6.1 SafeTensors
SafeTensors [7] is a simple format for storing named tensors, particularly model weights. Its header records tensor names, types, shapes, and byte ranges. The format supports memory-mapped and selective access.
SafeTensors deliberately keeps the tensor representation simple. It is not intended to describe a live GPU allocation, a paged cache, a sharded tensor, or a kernel-specific packed matrix.
6.2 GGUF
GGUF [8] is a model format developed in the GGML ecosystem. It stores model metadata and named tensors in one file and is especially relevant because it supports many quantized tensor types.
Types such as Q4_0 and Q4_K identify concrete GGML encodings. Their
block structure, scales, and packing follow the definition of the
selected GGML tensor type rather than a generic quantization descriptor
stored with each tensor.
GGUF therefore provides strong prior art for preserving quantized representations in a portable artifact. Its quantization model is tied to named GGML tensor encodings rather than a general parameterized quantization descriptor intended for arbitrary runtimes.
6.3 Zarr and NetCDF
Zarr [9] divides large multidimensional arrays into independently stored chunks. NetCDF [10] provides named multidimensional scientific variables and supports partial access, with modern NetCDF storage able to use chunked layouts through HDF5.
These formats show that large arrays need not be loaded as complete files. Their layouts primarily optimize persistence and data access rather than the in-memory representation expected directly by accelerator kernels.
6.4 ONNX
ONNX [21] is a model interchange format, and its tensor representation
is part of that. TensorProto carries dims, a data_type, and the
values either in typed fields or in raw_data. SparseTensorProto
pairs a values tensor with an indices tensor and the dense dims.
Quantization parameters can be associated with tensors through
annotations rather than fields of TensorProto itself: a
TensorAnnotation maps a tensor name to the names of its quantization
parameter tensors through quant_parameter_tensor_names, so scale and
zero point are themselves tensors. The data type enum includes low-bit
types, among them UINT4, INT4, FLOAT4E2M1, and the FLOAT8
variants. Tensor data need not sit inside the file: setting
data_location to EXTERNAL moves the bytes to a file identified by
location, with optional offset, length, and checksum.
ONNX therefore covers more of this paper's subject than a weights-only format does. Its scope is different. The tensor representation is part of a computational-model interchange format, the storage model is more constrained than an execution-layout descriptor, and it is not intended as a transport-independent physical tensor descriptor carried unchanged through runtime memory, streams, and files.
7. Moving Data: UCX, NIXL, and NCCL
Tensor formats and communication libraries solve different problems. The former describe data. The latter move it.
UCX [11] provides communication primitives over different hardware transports and generally treats transferred memory as buffers whose meaning is supplied by the application.
NIXL [12] targets point-to-point data movement in distributed inference and abstracts memory and storage types including GPU HBM, CPU DRAM, SSDs, and distributed storage. On suitable systems, it can use GPU Direct RDMA to transfer data directly between registered GPU memory regions.
NCCL [13] provides optimized collective and point-to-point communication between GPUs. It can use several paths depending on the hardware, including PCIe, NVLink, InfiniBand, and network sockets.
These systems do not need to understand that a buffer is a tiled matrix or a paged KV cache. A tensor interchange format can complement them by providing the description shared by the applications at either end.
8. Adjacent Systems: Compilers, Runtimes, and Frameworks
Three systems describe parts of a tensor without being interchange formats. They mark the boundary between describing a representation and exchanging one.
MLIR memref. The memref type [22] carries a shape, an element type,
a layout, and a memory space. Its layout is either a strided form with an
offset and per-dimension strides, or a semi-affine map, which the
documentation notes is "sufficiently flexible to represent a wide variety
of dense storage layouts, including row- and column-major and tiled".
This is a highly expressive layout model covering many of the dense
layouts discussed here. The relevant difference here is not simply
layout expressiveness: memref is a type in a compiler intermediate
representation, used inside a compilation pipeline, rather
than a stable binary format that two independently built runtimes
exchange directly. Expressiveness of a tensor representation is not by
itself sufficient to make it an interchange format.
PJRT. PJRT [23] is a device plugin API rather than a data format, and
it is relevant to the accessibility side of the problem. It exposes
devices, buffers, and memory spaces as first-class objects:
PJRT_Device_AddressableMemories returns the memories a device can
address, and PJRT_Memory_AddressableByDevices returns the devices that
can address a memory. Buffer layout is exposed as tiled or strided and
may be backend-specific. PJRT describes and exposes device and
memory accessibility; it does not define a portable representation of
what the buffer contains.
PyTorch DTensor. DTensor [24] represents a logical tensor distributed
over a DeviceMesh, with placements per mesh dimension: Shard for a
tensor dimension split across devices, Replicate for a full copy on
each, and Partial for values pending reduction. It is framework-specific
rather than an interchange format, but it shows that placement and
sharding are increasingly part of the tensor abstraction itself rather
than metadata kept beside it.
9. Comparison
| System | Main use | Tensor model and layout | Quantization | Device / memory | Composition | File | Network / stream |
|---|---|---|---|---|---|---|---|
| DLPack | In-process framework exchange | Shape, type, dense strides | No generic scheme | Device | Single tensor | No | No |
| Arrow Tensor | Tensor IPC | Shape, type, dense strides | No generic scheme | Not central | Single tensor | Standalone IPC | Standalone IPC |
| Arrow SparseTensor | Sparse tensor IPC | Shape, type, standard sparse formats | No generic scheme | Not central | Multi-buffer sparse tensor | Standalone IPC | Standalone IPC |
| Arrow tensor extensions | Tensor-valued columns | Shape, type, C-contiguous + logical permutation | No generic scheme | External | Arrow arrays/tables | Arrow IPC | Flight / IPC |
| SafeTensors | Model files | Shape, type, conventional dense | No generic scheme | No live placement | Named tensors | Yes | No standard runtime stream |
| GGUF | Model files | Shape, type, GGML encodings | GGML quantized types | No live placement | Named tensors | Yes | No runtime protocol |
| ONNX | Model interchange | Shape, type, dense and sparse | Annotated parameter tensors | No live placement | Within a model graph | Yes, plus external data | No |
| Zarr / NetCDF | Large arrays | Shape, type, storage-oriented | Application specific | No live placement | Dataset hierarchy | Yes | Remote access possible |
| UCX | Communication | None; opaque buffers | Opaque | Memory buffers | Application-defined | No | Yes |
| NIXL | Inference data movement | None; opaque buffers | Opaque | CPU/GPU/storage aware | Application-defined | Storage backends | Yes |
| NCCL | GPU communication | Element type and count only | Opaque | GPU-oriented | Application-defined | No | Yes |
Each of these systems solves a different part of the problem. DLPack is a compact in-memory tensor ABI. Arrow is a language-independent memory representation with tensor structures and IPC. SafeTensors and GGUF are persistent model storage, GGUF including practical quantized model representations. Zarr and NetCDF are large chunked multidimensional storage. ONNX is model interchange carrying tensor, sparse, quantization, and external-data metadata. UCX, NIXL, and NCCL move data. The adjacent systems of § 8 cover compiler representation, device and memory abstraction, and distributed placement.
The less standardized case is a tensor that is simultaneously, for example, quantized, paged, sharded, resident in GPU memory, and composed from several buffers. Frameworks can represent such tensors internally, but this information is normally exchanged through framework-specific structures or application protocols.
10. Distributed LLM Inference as a Concrete Case
Transformer inference stores previously computed keys and values in a KV cache. Systems such as vLLM divide this cache into reusable blocks rather than allocating one contiguous buffer.
Kwon et al. introduced PagedAttention and vLLM [14], applying paging ideas to KV-cache management. Logical cache blocks can map to non-contiguous physical blocks.
DistServe [15] separates prefill and decode onto different GPUs. Once those phases run on different workers, the KV cache must cross the boundary between them.
Mooncake [16] develops this further by treating KV cache as distributed state spanning GPU memory, CPU DRAM, and SSD storage.
Figure 1. KV cache transfer between a prefill worker and a decode worker. The transport moves the blocks; what the blocks represent is agreed outside the transfer.
Different workers can also use different parallel decompositions. TensorRT-LLM's disaggregated-serving support [18] includes cache-layout transformation when context and generation workers use different parallel strategies.
This gives a concrete interchange problem:
Producer: "Here is the KV cache in representation A."
Consumer: "I can consume A directly," or "I require representation B."
Today this agreement is generally implemented inside the serving system. A common descriptor can make it explicit.
11. What Is Still Needed, and What Hurray Proposes
The survey suggests several requirements that are useful together:
- A common description of dense, sparse, tiled, packed, and paged layouts where these representations are shared between runtimes.
- Quantization metadata including storage type, scheme, grouping, scales, zero points where applicable, and packing.
- Memory and device information sufficient to decide how buffers can be accessed.
- Support for tensors made from several buffers.
- A way to describe shards as parts of a larger logical tensor.
- A tensor description reusable in streams and files.
- Negotiation so producer and consumer can agree on a representation before moving a large payload.
- A language-neutral runtime interface.
Supporting many representations creates complexity. A useful standard therefore needs a small common subset and explicit optional capabilities.
Hurray [19], [20] is an open-source project that defines this broader tensor description. The project is currently beta and pre-1.0. The rest of this section states what it standardizes and how it addresses each requirement above.
11.1 Interoperability boundary
Hurray standardizes the representation needed to decide whether a tensor can be consumed directly. It does not standardize the mechanism that makes the tensor accessible.
Those are two independent questions, and keeping them apart is what lets a descriptor be useful without duplicating a transport.
Representation compatibility asks whether the two sides agree on what the bytes mean: element type, shape, layout, quantization, buffer relationships, composition. Memory accessibility asks whether the consumer can reach those buffers where they currently are.
Neither answer implies the other. Two runtimes may agree exactly on BF16, a paged layout, and 64-token blocks, and still need NIXL or CUDA IPC before either can touch the other's buffers. Conversely, CUDA IPC may give one runtime access to another's GPU allocation, and that access is useless unless both sides agree on what the bytes mean.
Figure 2. Representation compatibility and memory accessibility are independent. A tensor descriptor establishes representation compatibility; allocators, memory-sharing mechanisms, and transports establish accessibility.
Hurray standardizes representation compatibility. It defines the tensor's logical type and shape, physical layout, quantization information, buffer relationships, device and memory information, composition, and the synchronization information needed to determine when the data can be consumed, which is enough for an independent runtime to decide whether it can consume the representation directly.
It does not standardize how a GPU buffer is allocated, how RDMA or CUDA IPC establishes access to it, how a communication library moves it, how a kernel is scheduled, or how a runtime internally converts an unsupported representation.
In short, Hurray answers:
"What tensor is in these buffers, and how is it represented?"
The surrounding runtime and communication stack answer:
"How do I access, move, convert, or execute on those buffers?"
This keeps Hurray complementary to DLPack, CUDA IPC, UCX, NIXL, NCCL, and similar systems.
11.2 How Hurray addresses the requirements
Hurray's central object is a language-independent tensor descriptor. It describes properties including logical element type, storage type, shape, memory layout, quantization, device and memory class, buffers, synchronization, and composition. The same description can be bound to different kinds of buffers depending on where it is used: a file offset, a host pointer, and a GPU memory handle are different ways of locating data, and they do not change the tensor's shape, quantization, or layout.
The eight requirements are addressed as follows.
- Layouts. Hurray currently defines twelve layout families, covering conventional dense layouts as well as strided, sparse, space-filling, paged, and composite representations. Each layout has a tag and layout-specific parameters, so the address mapping is stated per tensor rather than fixed by a single format-wide convention.
- Quantization. Hurray treats quantization separately from storage type. Its current specification includes normative schemes for common affine quantization cases and additional low-precision representations such as NF4 and MXFP. This lets runtimes reason independently about logical type, storage type, and quantization.
- Device and memory. Device, memory, and synchronization information travels with the tensor. This does not replace CUDA IPC, RDMA registration, NIXL, or another memory-transfer API; those mechanisms provide the actual buffer access.
- Several buffers. One descriptor can reference the buffers a tensor is made from, such as values and their indexes, or values and their scales, rather than a single base pointer.
- Shards and composition. Composite descriptions let one logical object refer to several regions or tensors. This provides a basis for sharded tensors, sparse representations, quantization parameters stored separately from values, paged structures, and heterogeneous regions.
- Streams and files. The streaming form places the tensor descriptor before its payload, so a receiver can determine what is arriving before all tensor bytes have arrived. The persistent file form contains named tensors and an index for locating them, and reuses the same tensor descriptor as the streaming form.
- Negotiation. Naming the layout, element type, and quantization scheme explicitly is what makes capability comparison possible; Hurray's interchange protocol carries the exchange itself, with each side advertising the layouts it supports and a request stating an ordered preference. The worked example below turns the outcome into three cases: direct use, relocation without reformatting, or explicit conversion.
- Language-neutral interface. Hurray provides a C ABI as its runtime boundary, following the same practical approach used by DLPack and Arrow's C interfaces.
12. Hurray Compared with Existing Solutions
Hurray overlaps with DLPack on language-independent tensor exchange, with Arrow on publicly specified buffer representations, with SafeTensors on indexed persistent storage, and with GGUF on preserving low-precision representations.
It is complementary to UCX, NIXL, and NCCL, which move the data rather than describe the complete tensor.
| Capability | DLPack | Arrow tensor facilities | SafeTensors | GGUF | NIXL/UCX/NCCL | Hurray |
|---|---|---|---|---|---|---|
| Shape and element type | Struct fields | IPC message fields | Header fields | Tensor entry fields | Opaque buffers; NCCL: datatype + count | Descriptor fields |
| Dense strides | Strides field | Strides in Tensor message | Row-major only | Fixed by tensor type | Opaque bytes | Strided layout tag |
| Standard sparse representation | Not defined | SparseTensor message | Not defined | Not defined | Opaque bytes | Sparse layout tags |
| Specialized layouts | Not defined | Not defined | Not defined | Within GGML types | Opaque bytes | Layout tag, extensible |
| Paged tensor layout | Not defined | Not defined | Not defined | Not defined | Opaque bytes | Paged layout tag |
| Generic quantization metadata | Not defined | Not defined | No standardized scheme | GGML tensor type | Opaque bytes | Quantization descriptor |
| Device information | Device field | Not central | Not defined | Not defined | Transport handles | Device, memory fields |
| Sharding / composition | Single tensor | Higher-level structures | Named tensors | Named tensors | Application-defined | Composite descriptor |
| In-process ABI | C struct ABI | C Data Interface | Not defined | Library-specific | Library APIs | C ABI |
| Stream representation | Not defined | Arrow IPC, Flight | Not defined | File-oriented | Byte transport only | Streaming form |
| Indexed file | Not defined | Arrow IPC file | Header offsets | Tensor offsets | Not defined | File form with index |
| Representation negotiation | Application | Application | Not defined | Not defined | Application | Capability comparison / negotiation |
This is not a scorecard. Simpler formats can be easier to implement and more interoperable: DLPack's simplicity has helped its adoption, Arrow's strict physical formats make zero-copy interoperability predictable, and SafeTensors' restricted model makes files easy to parse safely. Hurray makes a different trade-off: it describes more physical representations in order to avoid forcing every computational tensor through one common dense layout.
A richer descriptor has to justify its complexity, and the clearest case is where converting the representation is itself expensive. If two runtimes both understand the same tiled or paged representation, converting a large tensor to row-major solely for interchange wastes memory bandwidth. If they do not understand the same representation, the conversion is unavoidable and should be explicit.
13. Example: Exchanging a Paged KV Cache
Consider a prefill worker that has produced a BF16 KV cache stored in GPU memory, divided into 64-token blocks, sharded across four GPUs, represented by a block table, and ready for a decode worker on another machine.
The transfer itself could use NIXL.
Without a shared tensor description, the two applications need a private agreement covering model-dependent dimensions, element type, block size, block-table format, shard mapping, GPU buffers, synchronization, and the relationship between transferred blocks and the request.
Figure 3. Application-specific agreement and descriptor-based interchange. A descriptor covers the representation; agreement about the request and the model remains application-specific.
With a common descriptor, the consumer can make one of three decisions. The result depends on both representation compatibility and memory accessibility.
Direct use. The consumer supports the same representation. No layout conversion is necessary.
Relocation without reformatting. The consumer supports the layout but needs the buffers in different GPU memory.
Conversion. The consumer requires another block size, shard mapping, element type, or layout.
The descriptor does not eliminate conversion. It identifies when conversion is required.
Use the existing representation when possible; convert explicitly when necessary.
14. Open Questions
Hurray is not yet a mature standard.
Every additional layout increases implementation work. If the standard defines too few, it cannot preserve the representations that motivated it. If it defines too many, implementations may support disjoint subsets.
Hardware layouts and quantization schemes also change quickly. Extensions are necessary, but an extension identifier alone does not create interoperability. Public layouts and quantization schemes still need precise definitions and canonical test data.
Device handles pose another boundary. A device identifier is portable metadata; a live CUDA IPC or RDMA handle is not. Hurray therefore needs to keep tensor semantics separate from transport-specific buffer access.
Security also matters. Shapes, strides, offsets, indexes, and composition metadata feed into memory calculations. Implementations need strict validation, overflow checks, fuzzing, and adversarial test cases.
Most importantly, the format needs independent implementations.
Useful conformance tests include C++ producer to Rust consumer, PyTorch to a non-PyTorch runtime, independently decoded quantized tensors, sparse tensors, paged KV caches, GPU-to-GPU transfer through NIXL, and sharded tensors with different destination layouts.
For each standard layout and quantization scheme, the project should provide small canonical byte-level examples with known results.
15. Conclusion
AI/ML systems increasingly move tensors between frameworks, accelerators, processes, machines, and storage.
For ordinary dense tensors, this problem is already well served. DLPack provides a compact in-memory ABI with shape, type, strides, byte offset, and device information. Apache Arrow provides standardized dense and sparse tensor IPC and fixed- and variable-shape tensor types in its columnar model.
Persistent formats cover another part of the problem. SafeTensors provides simple indexed model storage, while GGUF preserves a wide range of quantized model representations. Zarr and NetCDF provide scalable access to large multidimensional datasets.
Communication systems cover the data path. UCX, NIXL, and NCCL can move memory efficiently without needing to understand the complete tensor stored in that memory. ONNX carries tensor and quantization metadata as part of model interchange, while MLIR, PJRT, and DTensor demonstrate richer layout, memory-accessibility, and distributed-placement abstractions within compilers and runtimes.
At the same time, the tensors used by current compute systems are becoming more varied. PagedAttention made block-based KV-cache storage a central part of LLM serving. DistServe separated prefill and decode across GPUs. Mooncake treats KV cache as distributed state spanning GPU memory, host memory, and storage. Distributed training systems treat tensor partitioning across devices as a first-class concern.
In these cases, moving the bytes is only part of the problem. The receiver also needs to know how those bytes represent the tensor.
Hurray proposes one description for that information: shape and type, but also layout, quantization, memory placement, buffers, synchronization, and composition. The descriptor is shared between its streaming and file formats and is designed to sit above existing memory and communication mechanisms.
The proposal can be summarized in one rule:
If producer and consumer support the same tensor representation, preserve it. If they do not, describe the difference clearly enough to perform the required conversion.
Whether Hurray becomes useful will depend on adoption and interoperability, not on how many features its specification contains. Independent implementations need to agree on layouts and quantization byte for byte. Real integrations need to show that preserving tensor representations removes meaningful copies or conversions. The common subset must remain simple enough for runtimes to implement.
Apache Arrow showed the value of agreeing on the physical representation of data rather than on one library's objects. DLPack showed that the same principle works for tensors when the representation is kept small.
Hurray explores how far that idea can be extended to tensors that are quantized, sparse, tiled, paged, sharded, and resident on accelerators.
Project website: https://pgillet.github.io/hurray/
Source code and specification: https://github.com/pgillet/hurray
References
- DLPack Project. DLPack: Open In-Memory Tensor Structure,
specification and
dlpack.h. DMLC. https://dmlc.github.io/dlpack/latest/ and https://github.com/dmlc/dlpack - Apache Arrow Project. Apache Arrow Columnar Format. Apache Software Foundation. https://arrow.apache.org/docs/format/
- Apache Arrow Project. Other Data Structures: Tensor and SparseTensor. https://arrow.apache.org/docs/format/Other.html
- Apache Arrow Project. Canonical Extension Types: Fixed Shape Tensor and Variable Shape Tensor. https://arrow.apache.org/docs/format/CanonicalExtensions.html
- Apache Arrow Project. Arrow Flight RPC. https://arrow.apache.org/docs/format/Flight.html
- T. Ahmad, Z. Al Ars, and H. P. Hofstee. "Benchmarking Apache Arrow Flight: A Wire-Speed Protocol for Data Transfer, Querying and Microservices." ACM Conference on Big Data and Internet of Things (BID), 2022. https://doi.org/10.1145/3527199.3527264
- Hugging Face. SafeTensors. https://github.com/huggingface/safetensors
- GGML Project. GGUF Specification. https://github.com/ggml-org/ggml/blob/master/docs/gguf.md
- Zarr Developers. Zarr Specification. https://zarr-specs.readthedocs.io/
- Unidata. NetCDF Documentation. https://docs.unidata.ucar.edu/netcdf-c/
- OpenUCX Project. Unified Communication X. https://openucx.org/
- NVIDIA / Dynamo Project. NVIDIA Inference Xfer Library (NIXL). https://github.com/ai-dynamo/nixl
- NVIDIA. NCCL User Guide. https://docs.nvidia.com/deeplearning/nccl/user-guide/
- W. Kwon et al. "Efficient Memory Management for Large Language Model Serving with PagedAttention." SOSP, 2023. https://arxiv.org/abs/2309.06180
- Y. Zhong et al. "DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving." OSDI, 2024. https://www.usenix.org/conference/osdi24/presentation/zhong-yinmin
- R. Qin et al. "Mooncake: Trading More Storage for Less Computation ---A KVCache-centric Architecture for Serving LLM Chatbot." 23rd USENIX Conference on File and Storage Technologies (FAST), 2025. https://www.usenix.org/conference/fast25/presentation/qin
- L. Zheng et al. "Alpa: Automating Inter- and Intra-Operator Parallelism for Distributed Deep Learning." OSDI, 2022. https://www.usenix.org/conference/osdi22/presentation/zheng-lianmin
- NVIDIA. TensorRT-LLM: Disaggregated Serving, including KV cache transfer and cache layout conversion across parallel strategies. https://nvidia.github.io/TensorRT-LLM/advanced/disaggregated-service.html
- Hurray Project. Hurray: A Zero-Copy, Streamable, Language-Agnostic Tensor Interchange Format for AI/ML Inference Pipelines and Scientific Arrays. https://github.com/pgillet/hurray
- Hurray Project. Hurray Project Website. https://pgillet.github.io/hurray/
- ONNX Project. Open Neural Network Exchange: IR specification and
onnx.proto, includingTensorProto,SparseTensorProto,TensorAnnotation, and external data. https://onnx.ai/onnx/repo-docs/IR.html and https://github.com/onnx/onnx/blob/main/onnx/onnx.proto - MLIR Project. Builtin Dialect: MemRefType. LLVM. https://mlir.llvm.org/docs/Dialects/Builtin/#memreftype
- OpenXLA Project. PJRT: uniform device API, and the PJRT C API header. https://openxla.org/xla/pjrt and https://github.com/openxla/xla/blob/main/xla/pjrt/c/pjrt_c_api.h
- PyTorch Project. torch.distributed.tensor: DTensor, DeviceMesh, and placement types. https://docs.pytorch.org/docs/stable/distributed.tensor.html
- Consortium for Python Data API Standards. Array API Standard: Data interchange mechanisms. https://data-apis.org/array-api/latest/design_topics/data_interchange.html
- Numba Project. CUDA Array Interface (version 3). https://numba.readthedocs.io/en/stable/cuda/cuda_array_interface.html
Hurray Documentation Website — Specification
Status: Accepted. Decision recorded in ADR-028. This document specifies the concrete structure of the website; it is infrastructure spec, not part of the normative format specification under
docs/spec/.
This document uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.
1. Scope and goals
The website is the public face of Hurray, modelled on arrow.apache.org: a mostly-technical
site for reference-implementation users, format implementers, and ML/inference engineers.
Goals, in priority order:
- Faithful, full-history versioned docs. Every released spec version is browsable, built from its own git tag.
- Simple, fully automated CI pipeline with no Node/npm toolchain.
- Easy to reorganize and extend — content is Markdown authored in place.
- Portable — a plain static tree deployable to GitHub Pages today and any static host later.
2. Toolchain
| Concern | Tool | Notes |
|---|---|---|
| Versioned technical book | mdBook | Spec + impl + cookbook + tutorials. Single Rust binary. Built-in per-book search. |
| Outer site shell | Zola | Landing, FAQ, blog, community. Single Rust binary. |
| Rust API reference | cargo doc | Per version, per crate; published under the version path. |
| Python API reference | pdoc + maturin | Per version; maturin builds that version's wheel, pdoc introspects it (ADR-038). |
| CI / deploy | GitHub Actions → GitHub Pages | Full static-tree deploy. |
Both site generators are prebuilt binaries pinned to explicit versions in the workflow. The build MUST NOT require a Node or npm toolchain, and every tool it installs MUST be pinned to an explicit version in the workflow — the two prebuilt binaries, the Rust toolchain already used by the workspace, and the Python packages named above. It MUST NOT install anything unpinned or resolve a dependency tree at build time.
3. Deployed URL scheme
The site deploys as a single static tree. Paths (relative to the Pages site root):
/ Landing / overview (Zola)
/faq/ FAQ (Zola)
/blog/ Blog index + posts (Zola)
/community/ Contributing, CoC, governance, mailing lists (Zola)
/docs/ → redirects to /docs/stable/
/docs/stable/ Latest stable release book (mdBook; copy of the stable tag build)
/docs/dev/ Book built from `main` (mdBook)
/docs/<version>/ Book built from tag <version> (mdBook) e.g. /docs/0.1.0/
/docs/<version>/api/ cargo doc for that version (rustdoc)
/docs/<version>/python-api/ pdoc for that version's `hurray` module (pdoc)
/docs/stable/api/ cargo doc for the stable release
/docs/dev/api/ cargo doc for `main`
/versions.json Version manifest (drives the dropdown)
- Version path segments MUST be the exact git tag name. The tag convention is
MAJOR.MINOR.PATCHwith no leadingv(e.g.0.1.0); release tags MUST follow it. - The API reference for a version MUST live under that version's
api/(Rust) andpython-api/(Python) subpaths so a single version prefix scopes the book and both API references. - Both API reference directory URLs MUST resolve to a landing page.
cargo docon a multi-crate workspace emits no rootindex.html, so the build MUST emit one;pdocemits its own. - The book MUST carry a navigation entry linking both API references, using links relative to the version path so each version links to its own.
/docs/MUST redirect to/docs/stable/(an emittedindex.htmlmeta-refresh is acceptable, since GitHub Pages does not honour symlinks).
4. Versioning policy
- A version is a spec version. Release tags follow the spec version. The set of
published versions is exactly the set of matching git tags, plus the special
dev(built frommain). stableis the highest non-prerelease semantic-version tag. Its build MUST be copied to/docs/stable/(a copy, not a symlink) on each deploy.- Default entry point. The site navigation "Docs" link and
/docs/MUST resolve to/docs/stable/. Thedevversion MUST be reachable and MUST be clearly labelled as unreleased in the version dropdown and via an in-page banner on everydevpage. - Bootstrap fallback. Until the first release tag exists, there is no stable build;
/docs/,/docs/stable/, and the "Docs" nav link MUST fall back to/docs/dev/. The workflow MUST detect "no release tags" and emit this fallback rather than a broken link. - Prerelease tags (e.g.
0.2.0-rc.1) MAY be published as their own version entry but MUST NOT be selected asstable. - Immutability. A published version path, once deployed for a given tag, MUST reflect
that tag's content; version builds MUST come from
git checkout <tag>, never frommain.
4.1 versions.json schema
A single manifest at the site root drives the version dropdown. It is regenerated on every deploy. Shape:
{
"stable": "0.1.0",
"dev": "dev",
"versions": [
{ "id": "dev", "label": "dev (unreleased)", "path": "/docs/dev/", "released": null, "prerelease": false, "dev": true },
{ "id": "0.1.0", "label": "0.1.0", "path": "/docs/0.1.0/", "released": "2026-08-01", "prerelease": false, "dev": false }
]
}
versionsMUST be ordered newest-first, withdevfirst.stableMUST name theidof the stable version, or benullbefore the first release.- Consumers (the dropdown script) MUST treat an absent/
nullstableby pointing atdev.
5. Repository layout
New site sources live under website/; published content stays authored in place under
docs/.
website/
├── book/
│ └── book.toml # mdBook config; src points at the curated doc tree
├── site/ # Zola project
│ ├── config.toml
│ ├── content/
│ │ ├── _index.md # landing
│ │ ├── faq/
│ │ ├── blog/
│ │ └── community/
│ ├── templates/
│ ├── sass/ or static/
│ └── static/
├── theme/ # shared tokens (colors, fonts) used to keep book + site coherent
├── build-site.sh # builds the whole published tree (shell + one book per version)
├── check-doc-links.py # CI check: internal .md links resolve on GitHub and on the site
├── check-rust-blocks.py # CI check: the cookbook's Rust blocks compile and run
├── check-coverage-matrix.py # CI check: regenerates the Implementation Status page from the
│ # real crates, the Python module, and the C header
├── build-prior-art-pdf.py # renders docs/prior-art.md to docs/prior-art.pdf (pandoc + typst)
└── coverage-matrix.toml # canonical spec-feature list (rows) and implementations (columns)
docs/
├── SUMMARY.md # mdBook table of contents — the single reorganization surface
├── spec/ # (existing) format specification
├── impl/ # (existing) implementation requirements
├── cookbook/ # (existing) cookbook entries
├── tutorials/ # (new) longer-form guided tutorials
├── adr/ # (existing) architecture decision records — published as appendix
├── prior-art.md # (existing) prior-art survey — published as appendix
├── prior-art.pdf # generated from prior-art.md; served alongside it by the book
└── figures/ # SVG diagrams — the one image format GitHub, mdBook, and the
# PDF pipeline all render without a preprocessor
- The book is a view over
docs/.book.tomlsetssrcto the doc tree and the book's navigation is defined solely bydocs/SUMMARY.md. Reorganizing the book is editingSUMMARY.md; adding a page is adding a Markdown file and oneSUMMARY.mdline. - The ADRs (
docs/adr/) anddocs/prior-art.mdMUST be published as a book appendix in v1, listed under an "Appendix" section inSUMMARY.md. - The Zola shell MUST NOT duplicate versioned technical content; it links into
/docs/.
6. Visual coherence
- The mdBook book and the Zola shell are themed independently (see ADR-028 Consequences).
- A shared set of design tokens (brand colors, typography, logo) under
website/theme/SHOULD be applied to both so navigation between shell and book feels like one site. - Both themes MUST support light and dark modes and MUST be responsive.
7. CI/CD pipeline
A GitHub Actions workflow builds and deploys the entire tree. It is stateless: it reconstructs all versions from git on every run.
Triggers: push to main, push of a release tag (semver MAJOR.MINOR.PATCH[-prerelease],
no leading v), and manual dispatch.
Stages (in order):
- Checkout with full history and tags (
fetch-depth: 0). - Install pinned tools: the Rust toolchain,
mdbook(pinned version),zola(pinned version), and Python withmaturinandpdoc(pinned versions). - Build the shell:
zola buildfromwebsite/site/into the output root (public/). - Build
dev: from the currentmaintree,mdbook build→public/docs/dev/,cargo doc --no-deps --workspace→public/docs/dev/api/, thenmaturin build+pdoc→public/docs/dev/python-api/. Stamp the "unreleased" banner. - Build each release version: for every release tag (semver, no leading
v), in a detached worktree at that tag,mdbook build→public/docs/<tag>/,cargo doc→public/docs/<tag>/api/, andmaturin build+pdoc→public/docs/<tag>/python-api/. The Python reference MUST be built from that tag's own tree, and MUST be skipped — not faked from another version — for a tag whose tree has nohurray-python. - Resolve stable: compute the highest non-prerelease tag; copy its build to
public/docs/stable/. If no release tag exists, makestablefall back todev. - Emit
public/versions.jsonand thepublic/docs/redirect tostable. - Deploy
public/to GitHub Pages (actions/deploy-pages).
The workflow MUST fail the build (not silently skip) if a tagged version fails to build, so
history stays trustworthy. Pull-request builds SHOULD build the shell + dev only (no full
history) for fast preview.
Note (non-normative): Stage 5 is O(number of release tags), and each version now costs a
cargo docplus a debug build of the Python extension. This is acceptable at current scale (zero tags today). When it becomes slow, switch to incremental builds that carry prior version outputs forward and rebuild only new/changed tags — see ADR-028.
7.1 Cookbook code blocks
Every fenced block in docs/cookbook/ is verified, in both languages, by the test and
python-conformance jobs in ci.yml — not by the docs workflow, because a block rots
when the API moves and that diff touches no Markdown.
| Language | Runner | Tiers |
|---|---|---|
| Rust | website/check-rust-blocks.py (rustdoc --test per page) | rust compiles and runs · rust,no_run compiles only · rust,ignore is skipped and MUST carry a comment saying why |
| Python | hurray-python/tests/test_cookbook_python_blocks.py | RUN executes every block · COMPILE parses only and MUST carry a reason |
The Python tiers are a table in the test module rather than a fence annotation, because
lang-tabs.js groups blocks by language: writing ```python,ignore would change
that tab's identity and break the page. A page with Python blocks that appears in neither
table fails the suite, so a new page cannot skip the decision.
Within a page, Python blocks share one namespace and one scratch directory: a page is read top to bottom, so a later block may use an earlier one's names and the files it wrote.
8. Search
- mdBook's built-in search is enabled per book, giving per-version search for free.
- Each API reference carries its own generator's search (rustdoc's, pdoc's), scoped to that reference and separate from the book's.
- Cross-version and whole-site search are out of scope for v1 (ADR-028).
9. Content model
| Section | Source | Owner |
|---|---|---|
| Landing / overview | website/site/content/_index.md | core |
| FAQ | website/site/content/faq/ | core |
| Blog | website/site/content/blog/ | core contributors only |
| Community (contributing, CoC, governance, mailing lists) | website/site/content/community/ | core |
| Spec / impl / cookbook / tutorials book | docs/ via docs/SUMMARY.md | per existing agent ownership |
| Appendix: ADRs + prior-art | docs/adr/, docs/prior-art.md via docs/SUMMARY.md | per existing agent ownership |
| Rust API reference | cargo doc output | generated |
| Python API reference | pdoc output, from the bindings' doc comments | generated |
| API reference index page | docs/api-reference.md via docs/SUMMARY.md | core |
10. Open questions
[OQ-1]: Resolved. Tag naming is
MAJOR.MINOR.PATCHwith no leadingv(e.g.0.1.0). See §4.
[OQ-2]: Resolved. ADRs and
prior-art.mdare published as a book appendix in v1. See §5, §9.
[OQ-3]: Branding — logo, color palette, and typography for the shared theme tokens. Deferred; to be decided before the theme is built.
ADR-001: Private Extension Types Must Carry Inline Descriptors
Status
Accepted
Context
The element-types spec defines a uint8 type tag space with a private extension range (0xF0–0xFE) reserved for implementation-specific types. The question (OQ-3 in element-types.md) was whether these extension tags should be opaque identifiers or carry enough inline metadata for any conforming reader to handle them gracefully — specifically, to compute buffer sizes and refuse cleanly rather than corrupting memory or crashing.
A related question arose: should the core type system be replaced entirely by a parameterized float descriptor (sign bits, exponent width, mantissa width, exponent bias, NaN/Inf flags) rather than an enumerated tag space? This would make the format self-describing for any IEEE 754-family format without requiring spec updates.
Decision
Private extension type tags (0xF0–0xFE) MUST carry an inline type descriptor in the tensor metadata. The descriptor MUST include at minimum:
- Bit width of a single logical element
- Whether the type is sub-byte packed (bool), and if so the packing factor
- Whether the type is a floating-point type (bool)
- For floating-point types: sign bits (uint8), exponent bits (uint8), mantissa bits (uint8), exponent bias (uint16), and a flags field encoding NaN/Inf semantics
A human-readable name was considered for the inline descriptor but was omitted from the binary encoding: a variable-length utf8 field would break the fixed 20-byte descriptor size and complicate reader logic. Diagnostic names for extension types are an application-layer concern; implementations that require them should maintain an out-of-band registry keyed on the implementation-defined tag value.
The core enumerated type tag space (Tier 1 and Tier 2) is retained as-is. The parameterized descriptor lives only in the extension mechanism, not in the core type system.
Alternatives Considered
Fully parameterized type system (no enumerated tags): Replace all type tags with inline descriptors. Rejected because:
- Structural parameters alone (sign, exponent, mantissa widths) cannot fully describe floating-point semantics — NaN patterns, infinity availability, and rounding conventions differ between formats that are structurally identical (e.g., OCP OFP8
float8_e4m3vs. a hypothetical IEEE 754 binary8 with the same bit widths but different NaN/Inf rules). - Every reader would need to parse a descriptor in hot-path interchange code instead of switching on a single byte.
- Enumerated tags allow the spec to make unambiguous semantic commitments for each named type.
Opaque extension tags (no inline descriptor): Extension tags carry no metadata. Rejected because readers cannot compute buffer sizes for unknown types, making safe rejection impossible without risking out-of-bounds memory access.
Consequences
- The
metadata.mdspec section must define the inline type descriptor binary encoding for extension type tags. - Readers that encounter an unknown extension tag MUST parse the inline descriptor to determine buffer size, then reject the tensor (or skip it in permissive mode). They MUST NOT attempt to interpret the data buffer.
- OQ-3 in
element-types.mdis resolved and can be removed whenmetadata.mdis written. - The parameterized float descriptor format defined here for extensions may also serve as the canonical way to describe
float4andfloat6variants (OQ-4, OQ-5) if those are added to Tier 2 before being formally standardized.
ADR-002: Multi-Buffer Descriptor for Sparse Layouts
Status
Accepted
Context
Sparse tensor formats (CSR, CSC, COO, BSR, ELLPACK) require multiple distinct
component arrays — values, index arrays, and pointer arrays — each with a different
element type and length. The original memory-layout.md draft assumed a single buffer
per tensor descriptor and deferred the question of how to handle this as OQ-1.
The options considered were:
-
Single buffer with offsets: pack all component arrays into one contiguous buffer, addressing each via
byte_offset. Simple but problematic: the components have different element types, different alignment requirements, and sizes that are only known after computingnnz. Packing them together also breaks the zero-copy invariant when only one component needs to be shared. -
Nested tensor descriptors: each component array is itself a full Hurray tensor descriptor. Clean but heavyweight — a CSR tensor would require three complete tensor descriptors, each with its own layout tag, shape, and framing overhead.
-
Multi-buffer descriptor (buffer table): the tensor descriptor carries a
uint8count followed by an ordered list of buffer handles. Dense tensors have count = 1; sparse layouts declare how many buffers they need and what each holds. This follows the same model Apache Arrow uses for arrays (up to three buffers per column: validity bitmap, offsets, data).
Decision
A buffer table is introduced as a first-class field in every tensor descriptor.
The buffer table is a uint8-prefixed ordered list of buffer handles (as defined in
buffer-protocol.md). Dense tensors always have count 0x01. Sparse layout tags
declare their required buffer count and the role of each buffer (values, indices,
pointers).
The existing buffer_index field in the general subpaving layout (0x06) already
anticipated this; the buffer table formalises what those indices reference.
Alternatives Considered
- Single buffer with offsets: rejected. Different element types in one buffer breaks type-safe access and complicates zero-copy sharing of individual components.
- Nested tensor descriptors: rejected. Excessive framing overhead for what are logically sub-arrays of a single sparse tensor, not independent tensors.
Consequences
- All tensor descriptors carry a
uint8buffer count, costing one byte for every dense tensor on the wire. This is accepted in exchange for a uniform decoder path (no special-casing of dense vs. sparse). - Sparse layout tags are now unblocked. They can be assigned and fully specified in
a future revision of
memory-layout.md. buffer-protocol.mdmust define the buffer handle encoding used in the table entries, including size, alignment, and device fields.- The general subpaving layout's
buffer_indexfield continues to work unchanged; it indexes into the new buffer table.
ADR-003: Panel/Pack Formats via Extension Layout Tags and Content Negotiation
Status
Accepted
Context
Panel/pack formats are internal buffer layouts used by BLAS/BLIS libraries (and equivalents such as cuBLAS, oneDNN) when preparing matrix inputs for GEMM kernels. Before a multiply, inputs are repacked into a layout tuned to the target hardware's cache hierarchy, SIMD register width, and panel dimensions. The repacked buffer is consumed immediately by the kernel and then discarded.
OQ-2 in memory-layout.md asked whether panel/pack should be a named Tier 1 or Tier 2
layout tag, or explicitly out of scope.
The initial analysis favoured "out of scope" on the grounds that these formats are
implementation-specific and not portable. However, the design of content negotiation
in interchange.md changed the calculus: a client can advertise its hardware profile
to the server, the server transcodes and packs on the fly, and the client hands the
result directly to the BLAS kernel. The packed buffer never crosses an incompatible
boundary; portability is not required.
The remaining question was whether to define a named Tier 2 layout tag with normative hardware-parameter fields, or to use the existing extension layout mechanism.
Decision
Panel/pack formats are not given a named layout tag. They are explicitly supported
via the extension layout mechanism (0xF0–0xFE) combined with the transport
protocol's content negotiation.
The layout entry encoding in interchange.md is extended so that extension layout tags
in preferred_layouts (in TENSOR_REQUEST) and supported_layouts (in CLIENT_HELLO
/ SERVER_HELLO) MAY carry opaque metadata (ext_metadata) alongside the tag byte.
For panel/pack, this metadata encodes the client's hardware profile. The server either
recognises the profile and transcodes, or skips to the next preference.
Alternatives Considered
-
Named Tier 2 layout tag with normative hardware-parameter fields: rejected. BLIS, OpenBLAS, cuBLAS, and oneDNN do not agree on the relevant parameters or their semantics. Any normative definition would either be too narrow (tied to one library's model) or too abstract to be actionable. Named layouts must be interpretable by any conforming reader; panel/pack cannot meet that bar without locking in specific library internals.
-
Explicitly out of scope: rejected. The content negotiation mechanism makes panel/pack tractable without requiring portability. Saying "out of scope" would miss a real use case that the extension mechanism already handles cleanly.
Consequences
- The layout entry encoding in
interchange.mdis variable-length: core layout tags are a single byte; extension tags carry an additionaluint16length and opaque metadata. Decoders MUST be able to skip unrecognised extension entries using the length field. - Panel/pack is explicitly documented in
memory-layout.mdas the canonical use case for extension layouts via content negotiation. - No central registry of extension layout identifiers is defined. Producers and
consumers must agree on the
extension_layout_idandext_metadataschema out of band (e.g. via a shared library or published profile specification). - NVIDIA Tensor Core fragment layouts and other hardware-internal formats remain out of scope even under this decision, as they are not intended for interchange at all.
ADR-004: Shard Descriptor Uses Offset + Shape (Axis-Aligned Box)
Status
Accepted
Context
A Hurray tensor MAY carry a shard descriptor indicating its position within a larger
logical parent tensor. OQ-3 in memory-layout.md asked whether the shard descriptor
should use the current offset + shape model (an axis-aligned hyperrectangle / box) or
a more general subpaving region descriptor that could express non-rectangular or
non-contiguous shards.
Decision
The shard descriptor retains the offset + shape design. A shard is an axis-aligned
box in the parent tensor's index space, fully described by parent_shape,
shard_offset, and the shard's own shape. For each dimension k the shard covers
indices [shard_offset[k], shard_offset[k] + shape[k]).
Alternatives Considered
- General subpaving region descriptor: would reuse the machinery of layout tag
0x06to allow non-rectangular or non-contiguous shards. Rejected for two reasons:- Conflates distinct concepts — the subpaving layout describes how elements are arranged in memory; the shard descriptor describes the logical position of a sub-tensor within a parent. Merging these adds implementation complexity without benefit.
- No identified use case: all practical sharding patterns in ML inference (batch splitting, tensor parallelism, pipeline stages) produce axis-aligned boxes.
Consequences
- The shard descriptor is simple to encode, decode, and validate (one bounds check per dimension).
- The parallel transfer protocol in
interchange.mdcan rely on box semantics for coverage and non-overlap validation. - If a future use case for non-rectangular shards is identified, a general shard descriptor MAY be added alongside the current one as an optional field; the offset + shape form would remain the default.
ADR-005: Morton Layout Uses Zero-Padding for Non-Power-of-Two Dimensions
Status
Accepted
Context
The Morton (Z-order curve) layout uses morton_bits[k] to define the number of bits
allocated to each dimension in the interleaved Morton code. The buffer must hold
2^(sum(morton_bits)) elements. When a dimension size is not a power of two,
morton_bits[k] must be set to ceil(log2(shape[k])), padding the dimension to the
next power of two. Elements with Morton codes corresponding to indices outside the
tensor's shape are padding with undefined values.
OQ-4 asked whether this zero-padding approach should be mandated, or whether a "compact Morton" scheme should be defined to eliminate the padding waste.
Decision
The zero-padding approach is mandated. Writers SHOULD set morton_bits[k] to the
minimum value satisfying shape[k] <= 2^morton_bits[k]. The buffer holds
2^(sum(morton_bits)) elements; padding elements are undefined and readers MUST NOT
access them as tensor data.
Alternatives Considered
- Compact Morton addressing: a bijective mapping from Morton codes to valid
elements, skipping codes that fall outside the tensor's shape. Rejected because:
- It requires non-trivial per-access index computation (lookup tables or specialised bit manipulation), breaking the branchless bit-interleaving that makes Morton fast.
- It significantly complicates the implementation and the spec (the mapping is not self-evident and requires a normative algorithm).
- Morton layouts are inherently power-of-two structures; non-power-of-two use is already an unusual choice. Writers for whom padding waste is unacceptable should use a tiled or row-major layout instead.
Consequences
- The padding factor per dimension is strictly less than 2× in the worst case
(when
shape[k] = 2^(b-1) + 1). For typical ML dimensions (224, 256, 512, etc.) the waste is small or zero. - Morton index computation remains a simple, branchless bit-interleaving operation with no special cases.
- The
morton_bits[k]field gives writers explicit control over the trade-off between waste and address space: a writer MAY choose a largermorton_bits[k]than the minimum (e.g. for alignment reasons), at the cost of more padding.
ADR-006: Hilbert Curve Is a Named Tier 2 Layout with the Skilling (2004) Algorithm
Status
Accepted
Context
The Hilbert curve layout (tag 0x40) was placed in Tier 2 as a provisional entry
with no normative index mapping. OQ-5 asked whether to confirm the Tier 2 placement
(requiring a complete normative algorithm) or move it to the implementation-private
extension range (0xF0–0xFE) where no normative definition would be needed.
Decision
The Hilbert curve is confirmed as a named Tier 2 layout. The normative index
mapping is the algorithm from Skilling (2004), reproduced verbatim in
memory-layout.md. Both directions — CoordsToHilbert and HilbertToCoords — are
specified as normative pseudocode.
Alternatives Considered
- Extension range only: rejected. A layout tag in the extension range requires out-of-band agreement between producer and consumer, providing no interoperability benefit. If the mapping is not normative, there is no point including it in the spec.
- Drop entirely: rejected. The Hilbert curve has a meaningful advantage over Morton for 2D/3D spatial tensors (no large jumps across quadrant boundaries), and a clean normative algorithm exists. Dropping it would leave a gap for spatial-locality use cases with no standard answer.
Consequences
- Conforming implementations that support layout tag
0x40MUST implement the Skilling (2004) algorithm exactly. Two compliant implementations will produce identical index mappings for identical inputs. - The algorithm imposes the constraint that all tensor dimensions equal
2^hilbert_order. Non-power-of-two spatial tensors must be padded or use a different layout. - The Skilling algorithm has O(r * p) time complexity per element (r = rank, p = order), which is more expensive than Morton's O(r * p) bit-interleave but with higher constant factors due to the rotation/Gray-code state machine. This is acceptable for a reference implementation; performance-critical paths may cache the mapping.
- The conformance example table in
memory-layout.md(16 entries for the 4×4 case) gives implementations a concrete test vector.
ADR-007: Empty Tensors Are Normatively Permitted
Status
Accepted
Context
OQ-2 in docs/spec/data-model.md asked whether a tensor with any dimension size
equal to 0 (an empty tensor) should be permitted by the format, or rejected
as invalid. The current spec text permits empty tensors ("a reader MUST accept an
empty tensor without treating it as an error"); the question was whether to
confirm that permissive rule or tighten it for v1.
Hurray's primary goal is faithful zero-copy interchange between runtimes and
languages. The producers Hurray is designed to wrap — PyTorch, NumPy, JAX, Apache
Arrow, DLPack — all permit zero-size dimensions. ML compiler IRs (XLA/StableHLO,
TorchDynamo, MLIR tensor dialect) produce them as valid intermediate results
from shape inference under dynamic batching, masked selection, and uniform
broadcasting. ONNX is the principal outlier; its restriction is a documented
source of friction between training and deployment toolchains.
Hurray already distinguishes an empty dimension (size 0, fully resolved,
zero elements) from a dynamic dimension (the sentinel
0xFFFFFFFFFFFFFFFF, unresolved size to be supplied by the interchange
channel). The two concepts are orthogonal and must not be conflated.
The secondary concern — that zero-byte GPU allocations are implementation-defined
on some runtimes — is real but resolvable by specifying that an empty tensor's
data buffer carries size = 0 and MAY be represented with a null pointer, so no
device allocation is required.
Compatibility asymmetry matters: permitting now and forbidding later is a breaking change for v1 producers; forbidding now and permitting later is non-breaking. That asymmetry argues for caution, but the cost of forbidding — every runtime that permits empty tensors must insert a defensive check before every Hurray export — exceeds the cost of potentially tightening via a future stricter conformance profile.
Decision
Empty tensors are normatively permitted in Hurray v1.
- Any dimension size in the shape array MAY be
0. A tensor with one or more zero-size dimensions is empty and haselement_count = 0. - A writer MAY emit an empty tensor. A reader MUST accept an empty tensor without treating it as an error.
- An empty tensor MUST carry a complete, valid descriptor: rank, shape, element type, layout tag, buffer table, and any applicable quantization descriptor. No descriptor fields are optional on account of emptiness.
- The data buffer(s) of an empty tensor MUST have byte size
0. The buffer pointer MAY be null. The 64-byte buffer alignment requirement does not apply to a zero-length buffer (there are no addressable bytes to align); a non-null zero-length buffer pointer MAY have any alignment. - The value
0(resolved empty dimension) and the sentinel0xFFFFFFFFFFFFFFFF(dynamic, unresolved dimension) are distinct. A reader MUST NOT treat them as equivalent, and MUST NOT substitute one for the other when resolving dynamic dimensions. - For sparse layouts (COO, CSR, CSC, and any future sparse layout),
nnz = 0is valid and is independent of whether any logical shape dimension is0. An empty sparse tensor has bothelement_count = 0implied by shape andnnz = 0. - For sub-byte element types (
bool,int4,uint4,int2,uint2), an empty tensor occupies0bytes; no partial trailing byte is emitted. - A quantization scheme with a per-axis or per-block descriptor MUST accept
a shape in which the quantization axis has size
0: the scales and zero-point arrays are themselves empty (length0) in that case.
Alternatives Considered
Reject empty tensors at the format level (ONNX-style). Pros: matches a deployment-focused lineage; eliminates the zero-byte buffer edge case in C FFI and device allocators. Cons: breaks zero-copy import from PyTorch, NumPy, JAX, Apache Arrow, and DLPack — every producer-to-Hurray handoff would need a defensive shape check and a fallback path. Rejected because it violates Hurray's primary goal of faithful zero-copy interchange with the ecosystem it targets.
Reject in a strict conformance profile only. Pros: permits the core format to stay liberal while giving deployment pipelines a way to enforce stricter rules. Cons: profiles are a v2-and-later concern; introducing one prematurely adds spec surface area before the compliance matrix has stabilised. Deferred — a future stricter conformance profile MAY forbid empty tensors without altering the core spec defined here.
Conflate zero-size and dynamic dimensions. Pros: one sentinel to carry both "unknown" and "zero". Cons: loses information. A producer that knows a batch is empty (a filter that selected zero rows) has different downstream semantics than a producer that has not yet resolved the batch size. Rejected as a clear information loss.
Consequences
- Zero-copy import paths from PyTorch, NumPy, JAX, Arrow, and DLPack work without shape-gating. This is the intended outcome.
docs/spec/buffer-protocol.md(to be written) MUST specify that zero-length buffers MAY have a null pointer, that the 64-byte alignment requirement is waived for zero-length regions, and that consumers MUST NOT dereference a zero-length buffer regardless of its pointer value.- The C FFI layer (
docs/impl/c-ffi.md) MUST treat a zero-length buffer handle as a valid input and MUST NOT issue a zero-byte device allocation. A null data pointer withsize = 0is the canonical representation. docs/impl/compliance.mdMUST require at least one round-trip test vector for an empty tensor: recommended cases are shape[0], shape[3, 0, 5], and an empty sparse CSR (nnz = 0).docs/spec/data-model.mdOQ-2 is resolved and the marker MUST be removed.- Any future stricter conformance profile that forbids empty tensors MUST be introduced as an additional constraint layered on top of this ADR, never by modifying the core rule.
ADR-008: Normative Rank Cap of 64
Status
Accepted
Context
OQ-1 in docs/spec/data-model.md asked whether the specification should impose
a normative maximum rank (e.g., 64) or leave rank unconstrained with only a
SHOULD-level recommendation for implementations.
The rank field in the tensor descriptor is encoded as a uint32, which allows
values up to 0xFFFFFFFF. Without a normative cap, a conforming reader would be
required to attempt to parse a shape array of up to ~4 billion uint64 values
(32 GB) before determining whether the descriptor is valid. This is a latent
denial-of-service vector for any implementation that reads Hurray descriptors
from an untrusted source — IPC, cross-machine streaming, or file format.
In practice, no known ML workload uses tensors with rank above single digits. PyTorch caps rank at 64 (MAX_DIMS). NumPy caps at 32 (NPY_MAXDIMS). TensorFlow and Apache Arrow impose no formal cap, but neither targets the same security-sensitive interchange contexts as Hurray.
A normative cap also has implementation benefits: shape arrays, stride arrays, and per-dimension layout parameters can be stack-allocated with a fixed bound, eliminating heap allocation on the hot path of descriptor parsing.
Decision
The maximum rank of a Hurray tensor is 64.
- A writer MUST NOT emit a descriptor with
rank > 64. A reader MUST reject a descriptor withrank > 64. - A conforming implementation MUST support tensors of rank
0through64inclusive. - The cap applies to all layout descriptors: strides, tile shapes, Morton bits,
sparse index arrays, and shard offsets are all bounded by
rank ≤ 64. - The
uint32encoding ofrankis unchanged; values65–0xFFFFFFFFare reserved and MUST be rejected.
Alternatives Considered
Leave rank unconstrained (SHOULD-level recommendation only). Pros: maximum flexibility for scientific computing use cases with very high dimensional data. Cons: exposes every reader to a DoS vector — a four-byte field that instructs the reader to consume up to 32 GB before rejecting the descriptor. For a format designed for IPC and cross-machine streaming, this is unacceptable. Also eliminates stack-allocation optimisations that simplify implementation. Rejected.
Cap at 32 (NumPy NPY_MAXDIMS). Pros: smaller stack footprint; matches NumPy. Cons: rejects tensors that PyTorch (MAX_DIMS = 64) considers valid, breaking round-trip fidelity at the upper edge of PyTorch's own range. Rejected in favour of 64 to match the dominant ML framework.
Cap at 8 or 16 (practical ML maximum). Pros: tightest possible bound; eliminates edge cases entirely. Cons: overly restrictive — future architectures (e.g., multi-dimensional attention with explicit head, sequence, and batch axes) may reach 8 naturally. Rejected as unnecessarily limiting.
Consequences
- All spec sections that reference per-dimension arrays (
shape,strides,tile_shape,morton_bits,outer_strides,inner_strides, shard offsets) are implicitly bounded at 64 entries. No individual section needs to repeat the cap; a normative cross-reference to this ADR fromdata-model.mdsuffices. - Implementations MAY use fixed-size stack arrays of length 64 for all per-dimension data, eliminating heap allocation from the descriptor-parsing hot path.
docs/spec/data-model.mdOQ-1 is resolved and the marker MUST be removed. The existing conformance text ("MUST support tensors of rank up to 64 inclusive") is confirmed and extended with the rejection rule forrank > 64.docs/impl/compliance.mdMUST include a test vector with a descriptor carryingrank = 65and verify that a conforming reader rejects it.
ADR-009: Release Callback is Normative; Reference Counting is an Implementation Detail
Status
Accepted
Context
OQ-1 in docs/spec/buffer-protocol.md asked whether the C ABI should expose a
normative retain/release pair for reference-counted buffer sharing, or
whether reference counting should remain an implementation detail with only the
single-consumer release callback being normative.
The core use case for normative reference counting would be: two independent Hurray implementations simultaneously holding a reference to the same buffer, each needing to signal its release so the memory is freed only when both are done. This requires a shared, interoperable retain/release protocol at the C ABI boundary.
DLPack, the closest existing tensor ABI, takes the simpler path: a single
deleter function on DLManagedTensor. The producer wraps its actual
deallocation inside the deleter; if the producer wants to share the buffer
across multiple consumers, it implements reference counting internally and
provides each consumer with a separate DLManagedTensor pointing to the same
data, each with a release-aware deleter. Consumers are not aware of the internal
reference count.
Decision
Reference counting is an implementation detail. The only normative contract at the C ABI level is:
- A buffer handle carries a release callback.
- The consumer MUST call the release callback exactly once.
- The release callback MUST be safe to call from any thread.
A producer that wishes to support multi-consumer sharing MUST implement reference counting internally and hand each consumer a separate buffer handle whose release callback decrements the internal count, invoking the actual deallocation only when the count reaches zero. Consumers are not aware of this; they call their release callback exactly once, as the normative contract requires.
No normative retain function is added to the C ABI.
Alternatives Considered
Normative retain/release pair at the C ABI level.
Pros: makes cross-implementation multi-consumer sharing interoperable without
coordination — any consumer can extend a buffer's lifetime by retaining it.
Cons: forces every language binding (Python/GC, Rust/Arc, Go, Java) to bridge
between its own memory management idiom and the C ref-count, adding complexity.
The scenario requiring this — two independent implementations sharing a single
buffer region with no producer coordination — is effectively nonexistent in
inference-serving pipelines, which are sequential stage-to-stage rather than
parallel fan-out across runtimes. Rejected as over-engineering for the target
use case.
No release callback; ownership is always transferred. Pros: simplest possible ABI — the consumer owns the memory and is responsible for freeing it. Cons: forces a specific allocator on the producer (the consumer must know how to free memory allocated by the producer). Incompatible with GPU device memory, shared memory segments, and arenas. Rejected.
Consequences
docs/spec/buffer-protocol.mdOQ-1 is resolved. The section on reference counting ("Implementations MAY use reference counting…") is confirmed as non-normative description, not a requirement.docs/impl/c-ffi.mdMUST define the release callback signature:void (*release_fn)(void *user_data)or equivalent, called exactly once, thread-safe. Noretainfunction is defined.- Language bindings (
hurray-python, and future Go/Java bindings) implement their own lifetime management on top of the single release callback. For Python, the__dlpack__/__dlpack_device__protocol already provides the correct abstraction. - The internal reference counting implementation in
hurray-core(when multi-consumer sharing is needed within a single process) is an implementation detail ofhurray-ffiand is not visible at the ABI boundary.
ADR-010: Multi-Tensor Collections Deferred; Streams Are Sequences of Self-Delimiting Tensors
Status
Accepted
Amended 2026-07-27 (deferral resolved): The multi-tensor use cases this ADR deferred have since been addressed by later decisions — informed by the very implementation experience this ADR said to wait for:
- Grouping (multi-output inference; independent tensors under one logical identity) ships as the composite Group rule (ADR-027), bound by stream/file adjacency rather than a name namespace. ADR-027 records that it "amends the deferral scope of ADR-010."
- Named / indexed storage (the SafeTensors/GGUF-style use case, including key-value metadata) ships as the HRRYFILE container (ADR-011): names + footer index + typed KV. ADR-011 revisited and accepted, for the at-rest file format, the naming and KV metadata this ADR rejected for the runtime wire format — the two remain distinct.
- Batch streaming is the Option A sequential stream decided below, unchanged.
What remains out of scope is only a standalone
hurray-archivesibling specification distinct from HRRYFILE; it is optional and not a v1.0 blocker. The Option A decision below still stands as the canonical multi-tensor streaming encoding.
Context
The Hurray spec defines one tensor per descriptor. The question arose whether Hurray should define a normative multi-tensor collection format — a file or stream that groups multiple tensors under shared framing, names, or an index.
Use cases that motivate this question:
- Multi-output inference: a model server returning multiple output tensors in a single response (e.g., logits + hidden states + attention weights).
- Model weight storage: a collection of named weight tensors stored in a file for distribution and loading (analogous to SafeTensors or GGUF).
- Batch streaming: a stream of tensors produced by a pipeline stage, consumed incrementally by the next.
Three options were evaluated:
- Option A — Sequential stream of self-delimiting descriptor+data pairs. No index, no names, pure streaming. Falls out of the existing invariants for free.
- Option B — Named tensor map: a header with a name→offset index, followed by data blobs. Like SafeTensors but zero-copy at runtime.
- Option C — Both: a streaming mode (no index) for runtime RPC, and an indexed file mode for model storage.
Decision
Option A is the normative multi-tensor encoding for Hurray v1. Options B and C are out of scope for v1.
A Hurray stream MAY contain zero or more tensors. Back-to-back concatenation of self-delimiting descriptor+data pairs is the canonical multi-tensor encoding. A reader processes tensors one at a time, advancing to the next descriptor after the current tensor's data is consumed. No new framing bytes, no container header, no names are defined.
A named/indexed tensor collection format (analogous to SafeTensors or GGUF) is
deferred to a future hurray-archive sibling specification, not this document.
Rationale
Option A is free. The self-delimiting invariant (a reader can determine each descriptor's total byte length from its first 10 bytes) already makes sequential concatenation parse-able. Apache Arrow IPC uses exactly this model: a stream is zero or more record batches, each self-delimiting. Hurray adopts the same idiom for tensors.
Options B and C are premature. Introducing naming and indexing commits the spec to a set of decisions that are orthogonal to runtime interchange:
- A string encoding and uniqueness policy for tensor names.
- A namespace model (flat, hierarchical, Zarr-like groups?).
- A lookup mechanism that is in tension with the no-back-references invariant: a header index requires knowing all descriptor and buffer sizes before writing begins (breaks streamable writers); a footer index requires scanning to end-of-file before reading begins (breaks streamable readers).
- Eventual pressure to add key-value metadata (model provenance, quantization config, tokenizer parameters) — a parallel type system.
Making these decisions in v1, before any implementation experience, risks locking in choices that prove wrong in practice.
Ecosystem positioning. Hurray's differentiator is zero-copy at runtime, not
better model storage. SafeTensors and GGUF are mature, ecosystem-supported, and
solve the at-rest model-distribution problem. A hurray-archive format, if
designed, should be informed by the experience of running the runtime format in
production — not designed up front in parallel.
Compatibility. A future indexed format is additive: it would use a distinct magic byte sequence and would not alter the v1 tensor descriptor encoding. Deferral is safe.
Alternatives Considered
SafeTensors-style header (JSON name→offset map + flat data). Breaks the streamable-writer invariant: a writer must know all tensor sizes before it can write the header. Rejected for v1.
GGUF-style KV metadata + named tensors. Commits to a key-value metadata schema and a type system for metadata values. Scope is much larger than runtime interchange. Rejected for v1.
Zarr-style hierarchical namespace. Commits to a group/array namespace model with storage-backend abstraction. Orthogonal to runtime interchange. Rejected.
Consequences
docs/spec/interchange.mdMUST add one normative statement: a stream MAY contain zero or more tensors; back-to-back concatenation of self-delimiting descriptor+data pairs is the canonical multi-tensor encoding.TODO.mdMUST recordhurray-archiveas a future exploration item.- No other spec files require changes.
- A future
hurray-archivespecification may define a named/indexed format as a separate document. It will use a distinct magic byte sequence and will not modify the v1 tensor descriptor encoding. It may be introduced as a minor version increment (additive) or as a sibling specification, to be decided when the design is ready.
ADR-011: File Format — Random-Access Container for Hurray Tensors
Status
Accepted
Context
Hurray defines a streaming IPC format (descriptor + data, back-to-back, no seek) suitable for sockets, pipes, and RDMA. For on-disk model distribution, cold-start inference, and multi-tensor archives, a random-access container is needed: the ability to open a file, enumerate named tensors, and mmap any tensor's data without reading the rest of the file.
Prior art (SafeTensors, GGUF, Arrow IPC file) converges on a footer-based index with optional typed key-value metadata. SafeTensors uses a JSON header; GGUF uses a custom binary header. Arrow IPC file uses a FlatBuffers footer.
Both formats share the same tensor descriptor encoding (metadata.md); the file
format is purely a container layer on top. This is the key design constraint: the
file format MUST NOT redefine how individual tensors are described.
Decision
Define a Hurray file format as a named-tensor container wrapping the existing tensor descriptor encoding, with the following design:
-
Magic: 8-byte
HRRYFILE(0x48 0x52 0x52 0x59 0x46 0x49 0x4C 0x45). Distinguishes the file format from the streaming format (which begins withHRRYfollowed by a tensor descriptor). AnHRRYFILEmagic byte sequence is never a valid streaming tensor descriptor. -
Container version: Independent
container_version_major/container_version_minor(uint8each) in the file header, separate from the tensor descriptor's version. Current:0x01/0x00. -
Tensor names: UTF-8, length-prefixed with
uint16(max 65 535 bytes), no null terminator. Names MUST be unique within a file (case-sensitive, byte-exact). Names MUST NOT be empty. No hierarchical semantics are assigned to any character, including/; names are opaque identifiers at the spec level. -
Index position: Footer only. The index is written after all tensor data; a fixed-size 32-byte trailer at the end of the file locates the index. This is the only design compatible with single-pass streaming writes.
-
Trailer: Fixed 40 bytes at
file_size - 40. Contains:index_offset(uint64),index_length(uint64),kv_offset(uint64),kv_length(uint32),index_crc32c(uint32, valid whenHAS_INDEX_CRC32Cfile flag is set),_reserved(uint8[4], MUST be0x00), andtrailer_magic(HRRY, 4 bytes ASCII). The trailer was extended from 32 to 40 bytes to addindex_crc32cand 4 reserved bytes;trailer_magicchanged fromHRRY_ENDtoHRRYto fit within 4 bytes. TheHAS_INDEX_CRC32Cflag (bit 2 offile_flags) governs whetherindex_crc32cis populated and MUST be verified. -
Alignment: Tensor data buffers MUST be aligned to a page boundary within the file. The default page size is 4096 bytes; the file header MAY declare a larger alignment (up to 2 MiB) for huge-page environments. Tensor descriptors MUST be aligned to 8 bytes. All padding bytes MUST be
0x00. -
KV metadata: Optional file-level key-value metadata section with typed values (UTF-8 string, int64, uint64, float64, bool, byte sequence, typed array). This is required for ecosystem adoption: model architecture, tokenizer parameters, and quantization configuration cannot be expressed as tensor descriptors.
-
Streaming write: MUST be supported. A writer tracks byte offsets as it writes each tensor inline, then appends KV metadata (if any), then the index, then the 40-byte trailer. No backward seeks are required.
-
Streaming read (no seek): OPTIONAL. Tensor descriptors and data appear inline in file order; a non-seeking reader MAY consume them sequentially without the footer. This is a courtesy for pipe-based pipelines, not a required reader mode.
-
Shared descriptor encoding: Both formats use the tensor descriptor encoding from
metadata.mdverbatim. The file format index cachesdescriptor_lengthper entry (for O(index) fast enumeration without parsing descriptors), but MUST NOT cache shape, dtype, layout, or any other descriptor field.
Alternatives Considered
Header index (offsets written before data). Rejected: a streaming writer cannot know tensor offsets before writing them. Requires full buffering or two-pass writes.
Dual index (header + footer). Rejected: doubles write cost; creates a reconciliation problem on disagreement.
FlatBuffers for the index. Rejected: adds an external dependency and a schema parser. The index schema is static and narrow; a hand-rolled binary format is simpler and fully specifiable in RFC 2119 terms.
Reuse Arrow IPC file format. Rejected: Arrow is columnar and cannot express Hurray's layout diversity (tiled, Morton, sparse) or quantization descriptors. Wrapping Hurray descriptors in Arrow's framing would add complexity without benefit.
No KV metadata in v1. Rejected: GGUF's widespread adoption is driven by typed KV metadata. Without it, producers must store model metadata out-of-band, creating a fragmented ecosystem.
Compress tensor data within the file. Rejected: compression and zero-copy mmap are mutually exclusive. Hurray is zero-copy-first.
Content-addressable/chunked storage (Zarr-style). Rejected: Zarr's strength is cloud storage with per-chunk compression. Incompatible with zero-copy mmap.
Consequences
- Hurray now covers the full lifecycle: runtime interchange (streaming format) and model storage / distribution (file format).
- The file format enables SafeTensors/GGUF replacement with zero-copy mmap and rich layout/quantization metadata.
- README.md Core Property 2 ("no end-of-file index") must be scoped to the streaming format. The file format explicitly uses a footer index.
hurray-iomust implement both a streaming writer/reader and a file writer/reader.hurray-inspectshould print the format kind on open (streamvsfile).- The C FFI API should expose separate entry points (
hurray_file_openvshurray_stream_open) to prevent format confusion. - A new spec file
docs/spec/file-format.mdis the normative reference for the container layer.
ADR-011: Server Device Selection Algorithm
Status
Accepted
Context
The Hurray network transport protocol advertises device capabilities in
CLIENT_HELLO and SERVER_HELLO (supported_devices lists) and accepts a
preferred_device tag in TENSOR_REQUEST. The spec overview claimed "device
negotiation" support, but no normative selection algorithm existed. Without
one, the DEVICE_UNAVAILABLE error code and preferred_device field had no
defined semantics.
The core problem is that device selection, unlike layout selection, has no
natural ordered preference list on the wire (preferred_device is a single
tag). A silent fallback to a different device (e.g., CUDA → CPU) produces
correct results but may cause catastrophic performance regressions in
inference workloads.
Decision
The server follows a strict ordered algorithm upon receiving
preferred_device:
- Serve on
preferred_deviceif available. - If
preferred_deviceis CPU and unavailable:DEVICE_UNAVAILABLEerror. - If
preferred_deviceis non-CPU and unavailable:DEVICE_UNAVAILABLEerror (no silent fallback). - Single exception: if
preferred_devicewas advertised but is transiently unavailable, the server MAY fall back to CPU if and only if the client also advertised CPU. This is the only permitted silent fallback.
The actual device is reported in the buffer handle device_tag fields of
TENSOR_DESCRIPTOR.
Alternatives Considered
Silent fallback to CPU always. Rejected — hides performance collapses in inference workloads.
Client supplies a preference list (ordered). Rejected for v1 — expands the
TENSOR_REQUEST wire format and conflates device selection with layout
negotiation. The DEVICE_UNAVAILABLE error gives the client enough
information to retry with a different preferred_device.
Never fallback, always error. Rejected — the narrow CPU-fallback exception provides a useful graceful-degradation path for clients that advertise CPU support, at zero protocol complexity cost.
Consequences
DEVICE_UNAVAILABLE(0x00000005) is now a normative response to an unsatisfiablepreferred_device.- Clients that want graceful CPU fallback MUST advertise CPU in
supported_devices. - The
hurray-ioLayer 5 implementation will need a server-side device-availability hook (not a spec concern). buffer-protocol.md§ Device Colocation gains a cross-reference noting thatTENSOR_PUTdevice_tagis binding on the receiver.
ADR-012: GPUDirect RDMA via Bidirectional RDMA_REGISTER
Status
Accepted
Context
The RDMA data plane handshake (defined for OQ-2) only covered the source-buffer owner registering its memory region. For GPUDirect — where the sender writes directly into the receiver's GPU memory, eliminating the host-to-device copy — the receiver must also register a destination GPU memory region and share its rkey with the sender. No message or capability existed for this.
The existing RDMA_REGISTER message tag (0x0000000C) is defined as "Either"
direction but the prose only described its use by the source-buffer owner.
Decision
Bidirectional RDMA_REGISTER: the destination-buffer owner sends a second
RDMA_REGISTER (same message tag, same payload shape) after the
source-buffer owner's RDMA_REGISTER, before RDMA_READY. This is gated by a
new capability flag RDMA_GPUDIRECT (bit 3 of capability_flags), which MUST
imply RDMA_DATA_PLANE (bit 2).
The mechanism is fully symmetric: the same rules apply to TENSOR_REQUEST
(server is source, client is destination) and TENSOR_PUT (client is source,
server is destination) with roles inverted.
For TENSOR_PUT, the client unilaterally declares the destination
device_tag in the descriptor; the server rejects with DEVICE_UNAVAILABLE
before the RDMA handshake if it cannot honor that device.
Alternatives Considered
Option A — Extend RDMA_READY to carry an optional destination rkey.
Rejected — conflates an acknowledgement message with a registration message;
creates an asymmetry between TENSOR_REQUEST (client sends RDMA_READY with
rkey) and TENSOR_PUT (server sends RDMA_READY, which direction carries
the rkey?).
Option B — New RDMA_REGISTER_DST message type. Rejected — adds a new
tag that is semantically identical to RDMA_REGISTER; the only distinction
is "which direction"; that distinction is already captured by the message
sequence and the capability flag.
Option C (chosen) — Bidirectional RDMA_REGISTER. Reuses the existing
tag; the payload is role-agnostic (rkey + remote_addr + length); the sequence
position and capability flag make the role unambiguous.
Consequences
- New capability flag
RDMA_GPUDIRECT(bit 3). Reserved range updated to 4–63. - No new message type tags.
RDMA_REGISTERpayload prose updated to describe both source-owner and destination-owner roles.- New normative subsection: § GPUDirect Destination Registration.
TENSOR_PUT with RDMAsubsection rewritten to include full diagram and GPUDirect path.- Receivers advertising
RDMA_GPUDIRECTare responsible for ensuring NIC-GPU topology compatibility (PCIe root complex colocation or NVLink); the protocol cannot validate this. - Per-direction
RDMA_GPUDIRECTcapability flags (TX vs RX) are deferred to a future revision.
ADR-013: Rust Representation of LayoutDescriptor
Status
Accepted
Context
Layer 3 of hurray-core introduces the in-memory representation of layout descriptors.
The format spec (docs/spec/memory-layout.md and docs/spec/layouts/*.md) defines:
- A 1-byte layout tag with a partitioned tag space: core (
0x01–0x3F), extended (0x40–0x7F), reserved, private extension (0xF0–0xFE), and two invalid sentinels (0x00,0xFF). - Per-layout descriptor fields ranging from "none" (row-major, column-major, Morton)
through fixed scalar fields (Hilbert
hilbert_order, COOnnz+is_sorted, CSR/CSCnnz) to variable-length structures (Strided, Tiled with recursion up to 8 levels, Subpaving region lists, Private Extension opaque payload). - A
buffer_countthat depends on the layout (1 for dense non-quantized, 2 for COO, 3 for CSR/CSC). - A permissive-mode requirement: a reader MUST be able to hold an unrecognised layout tag without dereferencing its data.
The constraints that drive the choice are:
- Permissive mode requires a non-fatal "unknown layout" representation.
- Sparse layouts demand a deterministic mapping from descriptor to required
buffer_count. - Strides, tile shapes, and shape are coupled with
rank; mismatches must be detectable at construction time. - The common path (row-major, column-major, Morton) MUST NOT allocate.
- Adding Tier 2 layouts in a future spec version should not be a breaking change to consumer code.
- The Rust type SHOULD mirror the wire layout cleanly enough that decoding does not require pivot/translation logic.
Decision
LayoutDescriptor is a fat enum with a small-data discipline and an explicit
Unknown variant for permissive mode. Layout-specific structs live in their own
files under hurray-core/src/layout/ and are referenced from the enum.
// hurray-core/src/layout/mod.rs (illustrative sketch)
#[non_exhaustive]
pub enum LayoutDescriptor {
RowMajor, // 0x01 — no payload, no alloc
ColMajor, // 0x02 — no payload, no alloc
Strided(StridedLayout), // 0x03
Tiled(Box<TiledLayout>), // 0x04 — Box keeps enum small (recursive)
Morton, // 0x05 — no payload, no alloc
Subpaving(SubpavingLayout), // 0x06
Coo(CooLayout), // 0x07
Csr(CsrLayout), // 0x08
Csc(CscLayout), // 0x09
Hilbert(HilbertLayout), // 0x40
PrivateExtension(PrivateExtensionLayout), // 0xF0..=0xFE
Unknown(UnknownLayout), // permissive mode only
}
Key rules:
#[non_exhaustive]onLayoutDescriptorand on every payload struct that may grow fields. Adding a Tier 2 layout is then a non-breaking change at the source level.Box<TiledLayout>becauseTiledLayoutis recursive (inner_layoutcan beTiledagain, up to 8 levels). Boxing only the recursive variant keeps the enum size bounded.Unknown(UnknownLayout)carries{ tag: u8, raw_bytes: Vec<u8> }. It is the only path for tags the reader does not recognise. Constructors for named variants reject0x00,0xFF, and any reserved tag. The wire decoder routes unknown-but- not-invalid tags throughUnknownonly in permissive mode; in strict mode it returns an error.UnknownLayoutcarries the layout-section raw bytes, not just the tag. This preserves zero-copy forwarding: a relay in permissive mode can re-emit an unrecognised descriptor byte-for-byte.buffer_count()is a method onLayoutDescriptorreturningOption<NonZeroU8>. ForUnknown, it returnsNone. Sparse-layout buffer counts are constants on the per-variant struct, exposed through this method.- Layout tag is not stored in the enum payload. The discriminant is the tag for
known variants;
Unknowncarries it explicitly. Apub fn tag(&self) -> u8returns the canonical wire tag for any variant, eliminating any tag/params mismatch. - Rank validation is explicit and external.
LayoutDescriptordoes not storerank. A methodvalidate(&self, shape: &Shape) -> Result<(), Error>is called at the tensor descriptor boundary (Layer 4), where shape and layout are assembled together. Per-variant constructors validate intra-descriptor invariants only (e.g.,tile_shapevalues > 0,hilbert_order> 0). - One file per layout under
hurray-core/src/layout/:row_major.rs,col_major.rs,strided.rs,tiled.rs,morton.rs,subpaving.rs,coo.rs,csr.rs,csc.rs,hilbert.rs,private_extension.rs,unknown.rs.mod.rsdeclares the enum and re-exports.
Alternatives Considered
Tag + separate LayoutParams enum (Option B)
A { tag: LayoutTag, params: LayoutParams } struct mirrors the wire format.
Rejected: admits invalid combinations (LayoutTag::RowMajor with
LayoutParams::Strided(..)) at the type level, forcing every consumer to handle
"should never happen" branches. The fat enum makes invalid states unrepresentable.
Trait object — Box<dyn Layout> (Option C)
Heap allocation on the hot path (every row-major tensor) violates constraint 4.
Exhaustive matching is lost. Downcasting requires TypeId-based escape hatches.
Rejected.
Storing rank inside LayoutDescriptor
Allows construction-time stride validation but replicates rank across the tensor descriptor boundary, creating a second source of truth that can drift during reshape. Validation is performed once at Layer 4 where rank and layout meet. Rejected.
Consequences
- Zero-copy:
Unknownretains raw layout-section bytes for permissive forwarding. Known variants own O(rank) or O(region_count) heap data — unavoidable given the wire format. Buffer data itself is never touched. - Spec stability:
#[non_exhaustive]makes adding a Tier 2 layout non-breaking at the source level. Old strict-mode readers correctly reject new tags per spec. - FFI (Layer 7): The C ABI MUST NOT expose this enum directly. It exposes opaque
handles plus a
hurray_layout_tag()getter and per-layout typed accessors. - Quantization interop: Layout and quantization remain orthogonal. The tensor
descriptor (Layer 4) combines
layout.buffer_count()with the quantization scheme's parameter-buffer count to verify the buffer table. - Follow-up: Layer 4 MUST call
layout.validate(&shape)during tensor construction.
Open / Deferred
- Whether
RegionDescriptor(Subpaving) recursion should be boxed: start with a flatVec<RegionDescriptor>; box if benchmarks show enum size is a problem. serdederives on layout types: deferred; not required for Layer 3.- ADR-011 numbering conflict (
ADR-011-file-format-random-access-container.mdandADR-011-server-device-selection.mdshare a number): flag to format-spec-writer for renumbering.
Date
2026-05-04
ADR-014: Layout Address Computation Lives in hurray-core as a Trait-Based Sub-Module
Status
Accepted
Context
Layer 3 has just landed descriptor-only types in hurray-core/src/layout/ (LayoutDescriptor,
StridedLayout, TiledLayout, CooLayout, etc.). These structs carry the metadata fields
defined by the spec but contain zero address-computation logic. Layer 4 (tensor descriptor
encoding) is about to begin, and Layer 4 also requires hurray-inspect to drop its
self-contained parser and depend on hurray-core (CLAUDE.md, "Implementation rules").
The spec defines an element-address formula for every layout in docs/spec/memory-layout.md
and docs/spec/layouts/*.md. These formulas are normative. The question is where in the
workspace the implementation of those formulas lives.
Key forces:
- Spec fidelity. Address formulas are part of the format contract. The closer the formula sits to the descriptor whose fields parameterise it, the harder it is for the two to drift.
- Reusability.
hurray-inspect,hurray-ffi,hurray-python, and the future array-database engine all need to compute addresses. None should need an extra crate dependency for this. hurray-corecharter. The crate already carries quantization descriptors, alignment validation, andrayon. "No I/O, no async" — not "no logic".- Implementation complexity (Morton and Hilbert):
- Morton: a doubly-nested loop over
(bit_position, dimension)— trivial integer bitops. - Hilbert (Skilling's algorithm): two nested loops with bit-XOR swaps. Non-trivial to derive, mechanically straightforward to transcribe from the spec pseudocode. No SIMD or lookup table needed for v1. Neither algorithm justifies a separate crate.
- Morton: a doubly-nested loop over
- Deferral cost.
hurray-inspect's self-contained parser MUST be replaced in Layer 4. Without address computation in core, inspect cannot display element values, forcing it to keep its private addressing code — the drift risk the design is meant to prevent.
Decision
Address computation MUST live in hurray-core, in a dedicated sub-module
hurray-core/src/layout/addressing/ (one file per layout, mirroring the descriptor layout).
It MUST NOT live in a new crate.
The implementation is organised around two traits defined in
hurray-core/src/layout/addressing/mod.rs:
/// Implemented by every dense layout descriptor.
pub trait ElementAddress {
/// Returns the linear element offset (in logical elements, not bytes)
/// of the element at the given multi-dimensional index.
fn element_offset(&self, index: &[u64]) -> Result<u64, Error>;
}
/// Implemented by sparse layout descriptors; requires borrow of index buffers.
pub(crate) trait SparseElementAddress {
/// Returns the storage offset of the given index, or None if structurally absent.
fn sparse_element_offset(&self, index: &[u64], buffers: &SparseBuffers<'_>) -> Result<Option<u64>, Error>;
}
Key rules:
- Each dense layout file under
hurray-core/src/layout/addressing/MUST carry a// Spec: docs/spec/layouts/<name>.md § <section>comment at the top of each impl block to maintain auditable spec-to-code traceability. LayoutDescriptorgains a single dispatching methodelement_offset(&self, index: &[u64]) -> Result<u64, Error>inhurray-core/src/layout/mod.rsvia amatchover the enum.- Sparse impls are
pub(crate)initially; promoted topubafter the first consumer (Layer 4) validates the API shape. - A free function
byte_address_from_element_offset(element_offset, byte_offset, element_type) -> ByteAddressinmod.rshandles the common whole-byte / sub-byte byte-address conversion frommemory-layout.md § Element Address Computation, separating pure layout geometry from element-type byte arithmetic. unsafeMUST NOT be used in addressing code in v1. SIMD / lookup-table optimisations are explicitly deferred and MUST be benchmark-gated with a separate ADR before introduction.
Alternatives Considered
New hurray-access crate (Option B)
Would keep hurray-core as a pure-types crate. Rejected: the total addressing code is
~600–800 LOC; a separate crate adds dependency overhead for every consumer while the
supposed purity boundary is already not held (quantization logic, rayon). The real
boundary is reference CPU addressing (core) vs backend-optimised addressing (future
backend crates).
Methods directly on structs, no trait (Option A-flat)
Simpler, but callers cannot dispatch polymorphically on LayoutDescriptor without their
own match, and adding a new layout in v2 gives no compile-time reminder that addressing
is needed. The trait costs almost nothing and pays for itself the first time a new layout
is added.
Defer (Option C)
Directly contradicts the Layer 4 obligation to refactor hurray-inspect. Every Layer 5+
consumer that needs addressing would write its own copy, multiplying drift risk.
Consequences
Positive:
hurray-coreis the single normative source for descriptor structure and addressing semantics.hurray-inspectLayer 4 refactor drops its self-contained parser in one move.hurray-ffi,hurray-python, and the array-DB engine get addressing at no extra dependency cost.- The trait creates a compiler-enforced coverage hook when new layouts are added.
- Sparse/dense distinction is type-safe (two traits, different return types).
Obligations created:
hurray-coredescription inCargo.tomlSHOULD be updated to mention reference address computation.Errorenum gainsIndexOutOfRangeandIndexRankMismatchvariants (additive, non-breaking).// Spec:comment on everyElementAddressimpl is a review-time obligation.- Conformance-table tests for Morton and Hilbert MUST be reproduced as unit tests.
Open Questions
- OQ-014.1:
PromoteResolved. TheSparseElementAddresstopubafter Layer 4 validation?SparseElementAddresstrait /SparseBuffersshape was not promoted; it was removed. CSF (ADR-025) validated a simpler shape — a standalone publicelement_offsetfunction per sparse layout, taking typed index-buffer slices and returningOk(Some(offset))orOk(None)for a structural zero. COO, CSR, and CSC now follow that same shape (addressing::{coo,csr,csc}::element_offset), so sparse element lookup is uniform and public across all sparse layouts. - OQ-014.2: Should
byte_address_from_element_offsetreturn a single struct withbit_offset: 0for whole-byte types, or a sum type? (Defer to first FFI consumer.) - OQ-014.3:
Subpaving region lookup — are regions pre-sorted?Resolved. Writers SHOULD emit regions in lexicographic order oforigin; readers MUST NOT rely on it. Seedocs/spec/layouts/subpaving.md§ Region Order. The linear scan inSubpavingLayout::locate_elementremains conformant; a binary-search fast-path is deferred to a benchmark-gated ADR (see// TODO(OQ-014.3)in addressing code).
Layer 4 Impact
The Layer 4 plan gains a prerequisite sub-task before the hurray-inspect refactor:
hurray-core/src/layout/addressing/mod.rs— traits +byte_address_from_element_offset- One addressing impl file per dense layout under
hurray-core/src/layout/addressing/ - Sparse impls (
coo.rs,csr.rs,csc.rs) aspub(crate) - Unit tests reproducing Morton and Hilbert conformance tables from the spec
Errorenum additions- Runnable example in
hurray-core/examples/element_offset.rsand cookbook entry
Date
2026-05-05
Amendment 2026-05-06
Three Layer 4a planning questions resolved before implementation:
-
ElementAddress::element_offsettakes&Shape. Revised signature:fn element_offset(&self, index: &[u64], shape: &Shape) -> Result<u64, Error>. Row-major and column-major carry no stride fields; every dense layout requires shape for either stride derivation or index-envelope validation. Index rank and bounds validation MUST happen inside the trait method, not in callers.LayoutDescriptor::element_offsetadopts the same signature. -
Strided returns
u64via two's-complement reinterpretation of ani64sum. The trait return type staysu64; widening it toi64would impose signed arithmetic on every non-strided layout. The strided impl MUST compute the sum ini64withchecked_mul/checked_add(overflow →Error::IndexOutOfRangeor newError::AddressOverflow), then castsigned_sum as u64. The final bounds check is centralised inbyte_address_from_element_offset, which gains abuffer_size: u64parameter and returnsErrif the byte address falls outside[0, buffer_size). Sub-byte element-type bit arithmetic MUST be done on the signedi64offset before reinterpretation (floor division semantics). -
Subpaving does NOT implement
ElementAddress. The trait models a singleu64offset against a single buffer; subpaving is fundamentally multi-buffer. Instead,SubpavingLayoutexposes apubinherent method:pub fn locate_element(&self, index: &[u64], shape: &Shape) -> Result<SubpavingLocation, Error>; pub struct SubpavingLocation { pub region_index: u32, pub buffer_index: u32, pub region_byte_offset: u64, pub region_element_offset: u64, // fully resolved within the region's buffer }locate_elementMUST recurse into the region's inner layout and return a fully-resolved element offset within the region's buffer. Region lookup uses a linear scan in v1 with an inline// TODO(OQ-014.3)comment; OQ-014.3 (region ordering) is routed toformat-spec-writerin parallel and MUST NOT be resolved by implementation choice. Recursive subpaving is supported with an implementation-level depth limit (8 levels) returningError::SubpavingNestingTooDeep— a non-normative safeguard.LayoutDescriptor::element_offsetfor theSubpavingvariant returnsError::LayoutRequiresMultiBuffer { layout_tag: 0x06 }and its docstring redirects callers toSubpavingLayout::locate_element.
Error enum additions (all additive):
AddressOverflow— strided/byte-conversion arithmetic overflowLayoutRequiresMultiBuffer { layout_tag: u8 }— Subpaving dispatchSubpavingNestingTooDeep— recursion guardDynamicDimInIndexing { dim: u32 }— DYNAMIC dimension cannot be addressedIndexNotInAnyRegion { index: Vec<u64> }— subpaving missIndexArithmeticOverflow— Morton/Hilbert shift overflowByteAddressOverflow— byte address outside buffer bounds
ADR-015: Subpaving Region Inline Layout Encoding
Status
Accepted
Context
The general subpaving layout (0x06) partitions a tensor's index space into
non-overlapping rectangular regions, each with its own inner layout. Each
RegionDescriptor carries a region_layout_tag byte that identifies the inner
layout, but the format left the encoding of layout-specific fields for that tag
(e.g., strides for 0x03, tile dimensions for 0x04, Morton bits for 0x05,
Hilbert order/rank for 0x40, or a nested region list for recursive 0x06)
undefined.
This was filed as subpaving.md OQ-1. Without a normative encoding, the reference
implementation could only handle row-major (0x01) and column-major (0x02)
inner regions — both of which have no additional descriptor fields. The Layer 4
restriction note was added to subpaving.md and the addressing code pending
resolution of this OQ.
Three options were considered:
(a) Reuse the top-level layout encoding inline, with a length prefix
Add region_layout_length: uint32 immediately after region_byte_offset. The
next region_layout_length bytes carry the layout-specific fields for
region_layout_tag, using the same field encoding defined in metadata.md
§ Layout-Specific Fields for that tag — with the tag byte itself omitted (it is
already present in region_layout_tag). Set region_layout_length = 0 for
layouts that have no additional fields (row-major 0x01, column-major 0x02).
(b) Tag-specific inline structs without a length field Encode the layout-specific fields inline in a fixed, tag-defined order with no framing length. Readers must know the exact byte size for every tag they may encounter; unknown tags make the descriptor un-parsable without forward-skipping.
(c) A separate "inner layout table" referenced by index Add a parallel table of inner layout descriptors to the subpaving header, and have each RegionDescriptor carry an index into that table instead of inline fields. More compact when many regions share the same inner layout, but introduces a non-sequential encoding dependency and is harder to stream.
Decision
Use option (a): a region_layout_length: uint32 (little-endian) prefix
immediately following region_byte_offset in every RegionDescriptor, followed
by region_layout_length bytes of layout-specific fields using the same
per-tag encoding defined in metadata.md § Layout-Specific Fields (tag byte
omitted).
The RegionDescriptor binary layout becomes:
| Field | Type | Description |
|---|---|---|
origin | uint64[rank] | Starting index along each dimension (inclusive). |
region_shape | uint64[rank] | Size along each dimension. Every value MUST be > 0. |
region_layout_tag | uint8 | Inner layout tag. MUST NOT be 0x00 or 0xFF. |
_reserved | uint8[3] | MUST be 0x00. |
buffer_index | uint32 | Index into the tensor's buffer table for this region. |
region_byte_offset | uint64 | Byte offset to the start of this region's data in the buffer. |
region_layout_length | uint32 | Byte count of the inner layout payload that follows. MUST be 0 for region_layout_tag values 0x01 and 0x02. |
region_layout_payload | bytes[region_layout_length] | Layout-specific fields for region_layout_tag, encoded identically to metadata.md § Layout-Specific Fields for that tag, with the tag byte omitted. |
An 8-level nesting depth limit applies: a reader MUST reject any descriptor
where the total subpaving recursion depth exceeds 8. This limit matches the
existing implementation constant MAX_SUBPAVING_DEPTH.
In the Rust reference implementation, RegionDescriptor gains a new field:
inner_layout: Option<Box<LayoutDescriptor>>
None for tags that carry no additional fields (0x01, 0x02); Some for all
other tags, where the LayoutDescriptor variant must have tag() == region_layout_tag.
Alternatives Considered
Option (b) — tag-specific inline structs, no length field was rejected because it requires every reader to know the exact byte width of every possible inner layout tag. An unknown future tag would make the remaining RegionDescriptors un-parsable. The length prefix enables safe forward-skipping and is consistent with the length-prefixed approach used throughout the Hurray format.
Option (c) — inner layout table was rejected because it introduces a backward reference (each RegionDescriptor points into a table that precedes it in the byte stream only if the table is in the header), which conflicts with the format's streamability requirement. It also adds per-table parsing complexity for what is often a small number of distinct inner layouts.
Consequences
- Every RegionDescriptor is 4 bytes larger due to the mandatory
region_layout_lengthfield. For the common case of row-major or col-major inner regions the payload length is 0, so the only overhead is the 4-byte length field itself. - True recursive subpaving (inner
region_layout_tag = 0x06) is now encodable; the 8-level depth cap prevents unbounded recursion at parse time. - The
subpaving.mdspec must be updated: remove OQ-1, addregion_layout_lengthandregion_layout_payloadto the RegionDescriptor table. metadata.md§ General Subpaving (0x06) must be updated with the same RegionDescriptor table change.- The
RegionDescriptor::new()constructor signature is unchanged; awith_inner_layout()builder is added for the non-trivial case. - Layer 4 restriction in
addressing/subpaving.rsis lifted;region_inner_offsetnow dispatches throughinner_layoutfor all supported tags. OQ-014.3(region ordering/pre-sorting) remains open and is tracked separately.
ADR-016: Device Tag Assignments
Status
Accepted
Context
docs/spec/buffer-protocol.md § Device Tags currently assigns four named device
tags (0x00 CPU, 0x01 CUDA, 0x02 ROCm, 0x03 Metal), reserves 0x04–0xEF
for future specification versions, reserves 0xF0–0xFE for implementation-private
device types, and treats 0xFF as permanently invalid.
The same tag space is reproduced in docs/spec/interchange.md § Device Tags (for
transport convenience), in CLIENT_HELLO/SERVER_HELLO supported_devices lists,
and in TENSOR_REQUEST.preferred_device.
Once a tag value is published, it is frozen: changing it is a breaking wire-format change. The reserved range provides 236 unallocated slots, so there is no slot pressure, but gratuitous proliferation creates a maintenance and conformance burden — every named tag is a device that compliant readers MUST be able to identify, even if they cannot execute on it.
DLPack — the dominant zero-copy tensor protocol that the Python binding layer
(hurray-python) will bridge to via __dlpack__ / __dlpack_device__ — currently
enumerates these additional device types beyond the four Hurray already names:
kDLOpenCL, kDLVulkan, kDLVPI, kDLOneAPI, kDLWebGPU, kDLHexagon,
kDLMAIA, kDLTrn. Hurray does not need to match DLPack's integers (the binding
layer translates), but DLPack's coverage is a strong signal of which devices are
used for cross-runtime tensor exchange today.
Decision
Named device tags (v1 amendment)
The following named tags are added to buffer-protocol.md § Device Tags. All other
reserved values remain reserved.
| Value | Device |
|---|---|
0x00 | CPU host memory |
0x01 | CUDA device memory |
0x02 | ROCm device memory |
0x03 | Metal device memory (Apple Silicon unified memory) |
0x04 | Vulkan device memory |
0x05 | WebGPU device memory |
0x06 | Qualcomm Hexagon (HVX/HMX) memory |
0x07 | Intel Level Zero / oneAPI device memory |
0x08 | OpenCL device memory |
0x09–0xEF | Reserved for future specification versions |
0xF0–0xFE | Implementation-private device types |
0xFF | Reserved (invalid) |
Rationale for each addition:
0x04Vulkan — Cross-platform GPU compute on Android, desktop Linux/Windows. Used by llama.cpp (Vulkan backend), MNN, ncnn. Fully standardised memory model.0x05WebGPU — Browser-based inference (ONNX Runtime Web, Transformers.js, WebLLM). W3C-standardised memory model. Growing rapidly; already in DLPack.0x06Hexagon — Qualcomm DSP is the dominant on-device accelerator for Android inference. QNN SDK uses it heavily. Mobile inference is in scope for Hurray.0x07Intel Level Zero / oneAPI — Intel Arc GPUs and Gaudi accelerators are in production inference deployments today. DLPack addedkDLOneAPIin 2022. Without a named tag every implementation would converge on a private tag; promoting it later is more disruptive.0x08OpenCL — Declining, but still present on embedded Arm/Intel hardware and in long-tail ML runtimes. Cheap to name now; promoting after implementations have settled on private tags is costly.
The numeric ordering is contiguous from the existing assignments and reflects rough
priority (Vulkan, WebGPU first; Hexagon, Level Zero next; OpenCL last among named
tags). The values intentionally do not match DLPack's integers. Translation
occurs at the Python binding layer; binding implementors MUST consult
docs/impl/python-bindings.md for the authoritative Hurray ↔ DLPack mapping table.
The exact memory model for each tag (allocation API, alignment requirements beyond
the 64-byte minimum, synchronisation requirements, and DLPack mapping) MUST be
documented in buffer-protocol.md before this ADR is considered fully realised.
This ADR assigns the integers; the per-device prose subsection is an editorial
follow-up routed to format-spec-writer.
Generic NPU tag — deferred
A "generic NPU" tag is not added. The label covers vastly different architectures
(Apple ANE, Hexagon NPU subsystem, Intel NPU, Google Edge TPU, Rockchip NPU, Huawei
Ascend, AWS Trainium, Microsoft Maia, etc.) with different memory models and
synchronisation primitives. A reader that sees a generic NPU tag cannot dereference,
transcode, or verify alignment without out-of-band knowledge — which is exactly what
the 0xF0–0xFE private range already handles. Specific NPUs with broad cross-runtime
adoption are promoted as named tags individually.
Future device tag policy (private-first promotion)
- Private slot first. A new device starts in the
0xF0–0xFErange. Implementations document their chosen private tag and exchange it only with peers that have agreed on the semantics out of band. - Promotion criteria. A private tag MAY be proposed for promotion when:
- At least two independent implementations (not just two bindings of the same library) use the device for tensor interchange.
- The device's memory model is sufficiently specified that a non-Rust reader can implement allocation, alignment validation, and DLPack translation from the spec alone.
- The device is in active production use, not a discontinued or research-only platform.
- Promotion process. A new ADR records the tag assignment and rationale;
buffer-protocol.mdandinterchange.mdare amended in lockstep;python-bindings.mdis updated with the DLPack mapping. - Tag values are forever. Once published, a tag MUST NOT be reassigned.
- No silent expansion. Adding a named tag is a minor-revision change. Readers compiled
against a prior revision will reject the new tag; the
supported_devicesadvertisement inCLIENT_HELLO/SERVER_HELLOensures this is detected at session establishment, not mid-stream.
Alternatives Considered
Match DLPack's integers exactly. Rejected. Hurray's tag space is uint8 with a private
range at 0xF0+; DLPack's enum is open-ended with non-contiguous integers. Forcing alignment
would either waste Hurray slots or constrain future assignments. Translation at the Python
binding layer is the correct boundary.
Add only WebGPU and Hexagon; defer everything else. Rejected as too conservative. The cost of naming Vulkan, OpenCL, and Intel Level Zero today is one byte each. Deferring forces implementations to use private tags that then need to be migrated.
Add a generic NPU tag. Rejected. The label is too coarse to be actionable without out-of-band information, which is exactly what the private range handles.
Widen device_tag from uint8 to uint16. Rejected. The buffer handle is a fixed
16-byte structure; widening requires a breaking re-layout. The 236-slot reserved range
will not be exhausted within the v1 spec lifetime.
Consequences
buffer-protocol.md§ Device Tags: table gains rows0x04–0x08; reserved-future range narrows to0x09–0xEF; a per-device prose subsection is authored byformat-spec-writer.interchange.md§ Device Tags: reproduced table updated in lockstep.docs/impl/python-bindings.md: Hurray ↔ DLPack mapping table added for all eight named tags (CPU, CUDA, ROCm, Metal, Vulkan, WebGPU, Hexagon, Level Zero, OpenCL).hurray-core/src/buffer.rs:DeviceTagenum extended with five new variants at the assigned values; reserved-range validation narrows to0x09–0xEF. Routed torust-developeras part of the next implementation pass that touches buffer types.- Backward compatibility: producers using only
0x00–0x03continue to work unchanged. Readers compiled before this amendment reject the new tags;supported_devicesadvertisement surfaces this at session establishment. - Devices explicitly deferred: NVIDIA VPI, Google TPU, AWS Trainium/Inferentia, Microsoft Maia, generic NPU. All eligible for private tags; revisit when cross-runtime adoption evidence emerges.
- Memory-class sub-distinctions (CUDA Managed, CUDA Host, ROCm Host) are a separate design question and are deferred. Implementations MAY use private tags in the interim.
ADR-017: Extensibility as a Core Property
Status
Accepted
Context
docs/spec/README.md § Scope and Goals already includes the line "Be extensible
without breaking existing readers", but the term is undefined. The mechanisms that
implement extensibility are scattered across multiple sections:
- Tag-space partitioning with a private extension range (
0xF0–0xFE) for element types (element-types.md), layout tags (memory-layout.md), and device tags (buffer-protocol.md). - Reserved-for-future-spec ranges (e.g.
0x80–0xEF) that no implementation may consume. - Reserved flag bits in the descriptor header for future optional sections.
- Length-prefixed sections that allow unknown trailing bytes to be skipped by older readers.
- Independent version axes (descriptor, container, quantization scheme) per
versioning.md, with aMAJOR/MINOR/PATCHchange classification. - Permissive-mode parsing for unknown layout and quantization scheme tags.
ExtensionTypeDescriptorfor custom numeric types within the extension tag range.
The risk of leaving extensibility implicit:
- Future spec authors and implementors discover the extensibility contract by reverse-engineering the tag tables and version policy, and may inadvertently break it.
- External implementors evaluating Hurray cannot quickly determine the extensibility posture.
- The boundary between "private extensions are forever" and "private extensions are unsupported" is ambiguous.
The risk of making it explicit:
- A named guarantee invites maximalist interpretations ("a v1 reader will accept anything I put in the descriptor").
- It may be read as a commitment that every reserved range will eventually be filled, or that the spec will accommodate any feature a downstream user wants.
- It locks the existing extension-point inventory into a contractual surface that cannot be reduced without a major version bump.
Decision
Hurray adopts extensibility as a named core property of the format and protocol. The property is defined precisely and narrowly to avoid over-promising.
What Hurray commits to
The format MUST provide the following extension points, and these extension points MUST remain stable across all minor versions of a given major version:
- Reserved tag ranges in the element-type, layout, device-tag, and quantization-scheme tag spaces. The spec MUST NOT repurpose, narrow, or remove any reserved range within a major version.
- Implementation-private tag ranges (
0xF0–0xFEfor element types, layouts, and device tags). These ranges MUST remain implementation-private for the lifetime of major version1.x. The spec MUST NOT allocate any named value into a private range. - Reserved flag bits in the descriptor header, file header, and per-section flag
fields. Bits reserved at version
1.0MUST remain available for backward-compatible feature gating throughout1.x. - Length-prefixed sections. Every variable-length section in the descriptor and file format MUST carry a length prefix that enables an older reader to skip unknown trailing content without rejecting the whole structure.
- Permissive-mode parsing for unknown layout tags and unknown quantization scheme tags. A reader MUST be able to parse the descriptor's shape and buffer table even when it cannot interpret the layout or scheme.
- Independent version axes. The descriptor, container, and per-quantization-scheme versions MUST evolve independently. Adding a new feature on one axis MUST NOT force a version bump on the others.
- A spec amendment process for named values. Any new public tag value (element type, layout, device, quantization scheme, KV value tag, or flag bit) MUST go through a spec amendment that increments the appropriate minor version. New named values are added by the spec, not by implementations.
What Hurray explicitly does NOT commit to
- No forward compatibility across major versions. A reader MUST reject data at a higher major version on the relevant axis.
- No commitment to interpret unknown content. Permissive mode allows the descriptor to be parsed; it does not allow the data buffer to be interpreted. A reader that does not understand a quantization scheme MUST NOT attempt to dequantize.
- No interoperability of private-range values. Tags in the private range MUST NOT be exchanged between independent implementations without out-of-band agreement.
- No runtime plugin or codec mechanism. The extension surface is a curated tag space, not a runtime-loadable codec pipeline. Adding a layout or scheme requires a spec amendment, not a registered plugin.
- No user-defined non-numeric element types. The extension range exists for new numeric encodings, not for arbitrary user types.
- No back-compatibility guarantee for pre-
1.0drafts. The extensibility guarantee begins at1.0.
Where this is documented
docs/spec/README.md§ Scope and Goals — extensibility is added as a named bullet replacing the current "Be extensible without breaking existing readers" line, with a pointer to the "Extensibility Contract" section inversioning.md.docs/spec/versioning.md— a new "Extensibility Contract" section is added that enumerates the seven commitments and six non-commitments above in RFC 2119 normative language. It cross-references the per-tag-space rules already inelement-types.md,memory-layout.md,buffer-protocol.md, andquantization.mdrather than restating them.- No new normative text is needed in the individual per-section spec files.
Alternatives Considered
Leave extensibility implicit. Continue relying on the one-line goal in README.md and
per-section tag tables. Rejected because implementors discover the contract piecemeal, and
the boundary between "private ranges are forever" and "private ranges are unsupported"
remains ambiguous. The cost of naming it is one section in versioning.md.
Name it as a goal but add no normative text. Add the bullet to the README without a normative contract section. Rejected — a goal without normative backing is not a contract; spec-checker cannot verify compliance against an unstated rule.
Define extensibility as a runtime plugin contract (Zarr v3-style). Treat extensions as runtime-loadable codecs. Rejected — conflicts with zero-copy, language-agnostic, and streaming goals. Changes Hurray's category from "stable interchange format" to "extensible compute substrate."
Narrow the contract to reserved bits and tag ranges only, excluding permissive mode and independent version axes. Rejected — permissive mode and independent version axes are already normative; excluding them from the named property splits the extensibility surface across "named guarantees" and "unnamed guarantees" for no benefit.
Consequences
- External evaluators find the extensibility posture in one place (
versioning.md§ Extensibility Contract). - Spec-checker audits gain a concrete normative anchor: every amendment that touches a reserved range, private range, length-prefixed section, or version axis is testable against the contract.
- The non-commitments are explicit, providing documented responses to requests for runtime-loadable codecs, cross-vendor private tags, or user-defined element types.
- The contract creates a documented expectation that reserved ranges remain reserved across
1.x. The spec MUST NOT recover bytes from a reserved range mid-major-version. - This decision is non-breaking: it documents existing behaviour and adds no new wire-format constraints.
Follow-up work
format-spec-writer: editdocs/spec/README.md§ Scope and Goals to replace "Be extensible without breaking existing readers" with the named extensibility bullet.format-spec-writer: add "Extensibility Contract" section todocs/spec/versioning.md(after § Compatibility Matrix, before § Writer Requirements) with the seven commitments and six non-commitments in RFC 2119 language, cross-referencing the per-section files.spec-checker: add a checklist item "Does this amendment preserve the Extensibility Contract?" todocs/SPEC_CHECKLIST.mdonce the section is in place.- No code changes required in any
hurray-*crate.
ADR-018: GPU Stream and Event Synchronisation at Buffer Handoff
Status
Proposed (revised)
Context
The buffer protocol (docs/spec/buffer-protocol.md) defines how buffer handles carry size, alignment, and device tag, and how ownership transfer is signalled via a release callback (ADR-009). It is silent on GPU stream and event synchronisation: when a producer hands off a CUDA/ROCm/Metal/Vulkan/Level-Zero/OpenCL/WebGPU buffer to a consumer, nothing in the spec guarantees that the producer's previously-enqueued device writes are visible when the consumer dereferences the buffer.
The interchange protocol (docs/spec/interchange.md) inherits the same gap. TENSOR_DATA_END is described as the authoritative "buffer is ready" signal for the RDMA path (the receiver "MUST NOT read from the transferred buffer before receiving TENSOR_DATA_END"), but for in-process and IPC handoff there is no such signal; for the RDMA GPUDirect path, the spec relies on the sender's RDMA completion queue to ensure the write retired before TENSOR_DATA_END is sent — which gives a producer-side fence but does not establish a consumer-side device-stream ordering relationship.
Concretely, three failure modes exist today:
- In-process CUDA handoff. Producer issues a kernel on stream A that writes the buffer, then hands the buffer to a consumer. Consumer enqueues a kernel on stream B that reads the buffer. Streams A and B are not ordered. The consumer's kernel may execute before the producer's kernel retires. Data race.
- IPC GPU handoff. Same as above, across processes, with a CUDA IPC handle. The handle carries no ordering information.
- Cross-machine GPUDirect. Sender's NIC has DMA-written into the receiver's GPU memory. Receiver's first read on a GPU stream may not observe the write without a device-side fence, depending on the RDMA provider and the GPU vendor's PCIe ordering guarantees.
DLPack issue #176 is the closest prior-art discussion. DLPack currently treats producer-side stream synchronisation as advisory; the issue debates four options (producer-MUST-sync, pass producer stream handle, pass event handle, sync-version field). The Python array API has converged on passing a consumer stream handle to __dlpack__(stream=...) so the producer can record an event on its own stream and make the consumer stream wait — pushing the cost onto the producer but localising it to a single stream-wait rather than a full stream sync.
The choice has direct implications for Hurray:
- The buffer handle is a 16-byte fixed binary record (
buffer-protocol.md§ Buffer Handle). Three bytes at offsets 13–15 are currently reserved. - Whatever rule is chosen MUST be implementable by a non-Rust reader from the spec alone (interoperability invariant).
- The rule MUST NOT close the door on additional device types (
device_tag0x09–0xEF reserved) whose synchronisation primitives may not yet exist or may differ structurally from CUDA events. - The rule MUST integrate cleanly with ADR-009: the release callback signals end-of-use, not start-of-use; the new synchronisation rule occupies the symmetric position at the start of consumer access.
Open questions resolved by this ADR:
- [OQ-A] Producer-side requirement: nothing, sync stream, or record event? → Three tiers, producer's choice.
- [OQ-B] Consumer-side requirement: nothing, sync stream, or wait on event? → Depends on producer's chosen tier, declared in the buffer handle.
- [OQ-C] Wire-format change: extend buffer handle, extend interchange message, or neither? → Discriminant in the binary buffer handle (offset 13); handle in the C ABI out-of-band.
- [OQ-D] Per-transport variation: should in-process, IPC, and cross-machine differ? → Yes; see § Per-transport rules.
Decision
Hurray defines a normative handoff-completed-before-handoff-observed rule, stated as a behavioural contract on the producer. The buffer handle binary layout is extended by one byte to carry a sync_mode discriminant (consuming one of the three existing reserved bytes); the synchronisation primitive itself (CUDA stream, CUDA event, hipEvent_t, MTLSharedEvent, VkSemaphore, etc.) is not carried in the binary descriptor and is exchanged out of band via the C ABI for in-process and IPC, and is subsumed by TENSOR_DATA_END for cross-machine.
The rule has three tiers, one per producer mechanism, and per-transport rules govern which tiers are valid on which transport.
1. Producer requirement (all transports)
A producer of a non-CPU buffer MUST ensure that, at the instant ownership of the buffer is transferred to the consumer, all device-side writes enqueued by the producer that affect the buffer's bytes have reached a point at which a properly-synchronised consumer access on the same device will observe them. The producer satisfies this requirement by exactly one of the following mechanisms; the choice is the producer's, and the choice MUST be declared in the buffer handle's sync_mode field (see §3):
- (P1)
SYNC_PRODUCER_SYNCED = 0x00. Producer issues a host-side wait on the device stream(s) that wrote the buffer, ensuring all preceding work has completed before handoff. Strongest guarantee, highest cost. This is the universal default and the only mode valid on cross-machine transports. - (P2)
SYNC_EVENT = 0x01. Producer records a device event on the stream(s) that wrote the buffer and provides the event handle to the consumer through the C ABI. The producer-side stream is not blocked. Valid in-process; valid for IPC only when the device supports IPC-exportable events. - (P3)
SYNC_CONSUMER_STREAM = 0x02. When the consumer has advertised its target stream at handoff time via the C ABI, the producer issues a device-side wait that orders the consumer's stream after the producer's writing stream(s). The producer-side stream is not blocked; no event handle crosses the boundary.
Values 0x03–0xFE are reserved for future specification versions. Value 0xFF is reserved (invalid). A reader MUST reject a buffer handle whose sync_mode is not one of the values defined for the format version it implements.
For CPU buffers (device_tag = 0x00) sync_mode MUST be SYNC_PRODUCER_SYNCED (0x00); the producer MUST still ensure that any concurrent host-side writes are sequenced before handoff using a host memory fence (a release-store or equivalent).
2. Consumer requirement (all transports)
A consumer that has received a non-CPU buffer MUST inspect the buffer handle's sync_mode field and apply the matching rule:
- If
sync_mode == SYNC_PRODUCER_SYNCED, the consumer MAY access the buffer immediately on any stream — the producer's host-side wait (or, for cross-machine, the producer-side fence precedingTENSOR_DATA_END) established a host-program-order point. - If
sync_mode == SYNC_EVENT, the consumer MUST retrieve the producer's event handle from the C ABI handoff structure and MUST issue a device-stream-wait on it on every stream that will access the buffer, before enqueuing any work that touches it. The consumer MUST release the event handle exactly once via the event-release callback defined in §4. - If
sync_mode == SYNC_CONSUMER_STREAM, the consumer MAY access the buffer on the stream(s) it declared at handoff, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait.
Because exactly one mode is declared per buffer handle, a consumer MUST NOT need to negotiate or guess the producer's chosen mechanism. A consumer that does not recognise the declared sync_mode value MUST reject the descriptor.
3. Wire format: sync_mode added to the buffer handle
The buffer handle binary layout in buffer-protocol.md § Buffer Handle is updated by reallocating one byte of the existing _reserved field to a sync_mode discriminant:
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | byte_size | uint64 | Size of the buffer in bytes (little-endian). 0 denotes an empty buffer. |
| 8 | alignment | uint32 | Minimum alignment of the buffer's base address in bytes (little-endian). |
| 12 | device_tag | uint8 | Device where this buffer resides. |
| 13 | sync_mode | uint8 | Producer-side synchronisation mechanism in effect. See §1. |
| 14 | _reserved | uint8[2] | MUST be 0x00. Readers MUST reject a descriptor with non-zero reserved bytes. |
Total size: 16 bytes. All multi-byte fields MUST be encoded in little-endian byte order.
The opaque event handle (for SYNC_EVENT) and the consumer stream handle (for SYNC_CONSUMER_STREAM) are not carried in the binary descriptor. They are exchanged out of band via the C ABI (see §4) because event and stream handles are pointers / opaque IDs valid only in the producer's driver context and have a transient lifetime that does not match the descriptor's. The sync_mode field is a declaration of a buffer's synchronisation properties; the synchronisation handle is a transport detail, not a buffer property.
Note (non-normative):
hurray-inspectand any other static inspector can surfacesync_modefrom the descriptor alone, without reaching into the C ABI layer.
No synchronisation field is added to TENSOR_DESCRIPTOR, RDMA_REGISTER, RDMA_READY, or TENSOR_DATA_END. The buffer handle inside TENSOR_DESCRIPTOR already carries sync_mode, so the interchange protocol inherits it for free.
4. C ABI
The C ABI handoff structure carries the handle corresponding to the sync_mode declared in the buffer handle. docs/impl/c-ffi.md MUST define:
- For
SYNC_PRODUCER_SYNCED: no additional fields. The C ABI handoff carries only the buffer pointer and release callback. - For
SYNC_EVENT: an opaquesync_handlepointer (device-vendor-specific event), async_handle_device_tag(which MUST equal the buffer'sdevice_tag), and anevent_release_fncallback the consumer MUST call exactly once after it has issued its stream-wait. The lifetime model mirrors ADR-009: a single normative release, thread-safe, with internal refcounting at the producer's discretion. - For
SYNC_CONSUMER_STREAM: at handoff request time, the consumer supplies an opaqueconsumer_streamhandle of type matching the buffer'sdevice_tag; the producer issues the stream wait before returning the buffer handle.
The C ABI MUST validate that the handle provided at handoff time matches the sync_mode declared in the buffer handle. A mismatch is a producer-side bug that the ABI layer MUST reject before returning the handle to the consumer.
The C ABI no longer carries a sync_mode discriminant of its own — the descriptor is the single source of truth; the ABI provides only the payload for whichever mode is declared.
The exact C struct layout is the C ABI layer's concern; this ADR fixes only the abstract contract and the binary-descriptor field.
5. Relationship to the release callback (ADR-009)
The release callback and the synchronisation contract are independent and symmetric:
- The synchronisation contract governs the start of consumer access (when is it safe to read?).
- The release callback governs the end of consumer access (when does the producer learn the consumer is done?).
A consumer that has issued device work using the buffer MUST NOT call the release callback until that device work has completed on the device. The consumer is free to satisfy this by host-side waiting, by recording its own completion event and waiting on it, or by deferring the release callback to a completion callback registered on its stream. This is symmetric to the producer's obligation in §1 and is an existing implication of ADR-009's "consumer MUST NOT access the buffer after calling the release callback" rule; this ADR makes the implication explicit.
The event-release callback (for SYNC_EVENT mode) is separate from the buffer release callback. A consumer therefore makes two release calls in SYNC_EVENT mode: one for the event handle (called after the consumer has issued its stream-wait — typically immediately after handoff) and one for the buffer (called after all device work on the buffer is complete). Conflating the two would force the producer to keep the event alive for the buffer's entire lifetime, which defeats the purpose of using events instead of full stream sync.
6. Per-transport rules
In-process
All three sync_mode values are valid. Event handles in SYNC_EVENT are valid because both parties share the same device context. Stream handles in SYNC_CONSUMER_STREAM are valid for the same reason.
IPC (same machine, different processes)
SYNC_PRODUCER_SYNCED(P1) is always valid.SYNC_EVENT(P2) is valid only if the device supports IPC-exportable events (CUDAcudaIpcEventHandle_t, ROCmhipIpcEventHandle_t). The C ABI MUST expose a query that tells a binding whichsync_modevalues are available for a givendevice_tagand IPC channel.SYNC_CONSUMER_STREAM(P3) is valid only if the device supports IPC-exportable streams (not universally available).
A producer that cannot offer any valid mode for an IPC GPU buffer handoff MUST fall back to a host-staged copy. This degradation is not negotiated on the wire; it is the producer's local decision.
Cross-machine (network transport)
A producer MUST set sync_mode = SYNC_PRODUCER_SYNCED for every buffer handle transmitted over a network transport. SYNC_EVENT and SYNC_CONSUMER_STREAM are forbidden across machines because device event and stream handles are not valid in a different driver context on a different host. A receiver MUST reject a cross-machine TENSOR_DESCRIPTOR whose buffer handle declares any other mode.
The existing TENSOR_DATA_END message is the authoritative producer-synced signal:
- For the non-RDMA path (
TENSOR_DATAframes), the sender MUST ensure all device-side writes to the source buffer are visible to host reads before sending the firstTENSOR_DATAframe whosebyte_offset_in_buffercovers those bytes. - For the RDMA path without GPUDirect, the sender MUST ensure source-buffer writes have retired on the device before posting the RDMA send; the receiver MUST NOT read the buffer before
TENSOR_DATA_END. - For the RDMA path with GPUDirect (ADR-012), the sender MUST ensure: (a) producer-side device writes to the source buffer have retired before posting the RDMA send, and (b) the RDMA completion has been observed via the sender's completion queue before sending
TENSOR_DATA_END. The receiver, upon receivingTENSOR_DATA_END, MAY assume the destination GPU buffer is visible to subsequent device-stream reads on the receiver's GPU, provided the receiver is using a CUDA/ROCm/etc. driver version that establishes PCIe-write-ordering between an inbound NIC DMA and a subsequent kernel launch on the same device — the spec MUST state this as a receiver-side responsibility, not a producer-side one, because it depends entirely on the receiver's hardware topology.
TENSOR_DATA_END is the cross-machine equivalent of SYNC_PRODUCER_SYNCED.
7. Versioning
Because Hurray has not yet shipped a 1.0 wire format and has no external users, the buffer handle layout change lands in the still-draft 1.0 spec without a version bump; no migration path is required.
Forward extensibility:
- New
sync_modevalues MAY be added in future format revisions by allocating from the reserved range0x03–0xFE. Adding a value is a wire-incompatible change (a v1 reader that does not understand the new value MUST reject the descriptor) and therefore requires a format version bump. - A future device type added in
device_tag0x09–0xEFwhose synchronisation model does not fit any of P1/P2/P3 MUST motivate either a newsync_modevalue or a separate extension ADR. P1 remains the universal fallback because every device supports host-side wait.
The C ABI version MUST be bumped to expose the sync_handle / event_release_fn / consumer_stream payloads defined in §4.
Alternatives Considered
Alternative A — Normative "producer MUST sync" rule, no negotiation
Mandate SYNC_PRODUCER_SYNCED always; the producer MUST host-wait on its device stream before handoff. Simplest spec, simplest consumer.
- Pros: zero ABI surface, zero negotiation, trivially correct, matches what most CPU code expects of GPU buffers.
- Cons: full stream synchronisation is the single most expensive thing one can do on a CUDA stream; it serialises the entire inference pipeline. For the inference workloads Hurray targets, this collapses pipeline parallelism.
- Rejected because: the cost is unacceptable for the primary use case. Hurray's value proposition is zero-copy zero-stall handoff; mandating a full stream sync defeats both halves.
Alternative B — Carry both the discriminant AND a sync-object handle in the buffer handle binary layout
Burn 8 bytes of _reserved (or extend the buffer handle from 16 to 24 bytes) for an opaque sync-object handle, in addition to the discriminant.
- Pros: fully self-describing on the wire; no out-of-band C ABI handle needed for in-process use.
- Cons: (1) event and stream handles are not portable across processes (need IPC export) or machines (meaningless); the same field would carry incompatible payloads or be null across transports. (2) Bakes a pointer-shaped value into a format that is otherwise pointer-free, breaking the rule that the binary descriptor is a declaration of properties, not a transport detail. (3) The handle has a lifetime that does not match the descriptor's — the descriptor may be inspected long after the event has been released, leaving a dangling pointer encoded in the format. (4) Closes the door on future devices whose synchronisation primitives do not fit a single 8-byte handle (e.g., a 16-byte UUID, or a handle + context pair).
- Rejected because: handles are transport-layer state, not format-layer state. The discriminant belongs in the format; the handle does not. See Alternative F for the chosen hybrid.
Alternative C — Add a new BUFFER_SYNC interchange message
Define a new 0x0000000E BUFFER_SYNC message carrying a sync-object payload, sent after TENSOR_DESCRIPTOR and before TENSOR_DATA_END.
- Pros: clean separation; opt-in via capability flag; does not pollute the buffer handle.
- Cons: meaningless for in-process and IPC (which do not use the wire framing for handoff at all) and meaningless for cross-machine (where event handles do not cross host boundaries).
- Rejected because: solves a wire-format problem where the problem is not wire-format.
Alternative D — Defer entirely, leave to bindings
Make no normative statement; each binding defines its own rule.
- Pros: zero spec surface.
- Cons: directly violates the language-agnostic interoperability invariant. Two implementations both claiming Hurray 1.0 compliance could fail to share GPU buffers safely.
- Rejected because: synchronisation is the single most common source of silent data corruption in GPU code. Leaving it implementation-defined is a guarantee of interoperability failures.
Alternative E — DLPack-style: consumer passes a stream at request time, producer waits on it
Adopt only SYNC_CONSUMER_STREAM (P3); forbid P1 and P2.
- Pros: matches the Python array API; pushes the wait onto the producer's device side (cheap) rather than the host side (expensive); no event handle lifetime to manage.
- Cons: forces every consumer to have a designated stream before requesting a buffer. Some consumers (CLI inspectors, disk writers) have no GPU stream; they would have to construct one purely to satisfy the protocol. Also fails the IPC case where stream handles are not shareable across drivers without IPC-stream support.
- Rejected because: too restrictive. P3 is offered as one option among three; mandating it loses the host-side use case and the cross-driver IPC use case.
Alternative F (chosen) — Hybrid: sync_mode discriminant in the binary descriptor, sync handle in the C ABI
Add a single uint8 sync_mode field at offset 13 of the buffer handle (consuming one of the three existing reserved bytes, leaving two), and keep the actual sync object handle (event pointer, stream pointer) in the C ABI handoff structure.
- Pros:
- Self-describing at the binary level: a static inspector (
hurray-inspect) can show the synchronisation contract for any buffer without reaching into the C ABI. - Zero byte cost: uses an already-reserved byte; the buffer handle stays 16 bytes; CPU buffers naturally encode
sync_mode = SYNC_PRODUCER_SYNCED = 0x00, which is the natural zero value. - No device-specific primitives baked into the format: the field carries a discriminant, not a vendor-specific handle. Future devices can allocate a new
sync_modevalue without restructuring the descriptor. - Transports stay clean: across machines, the discriminant is
SYNC_PRODUCER_SYNCEDand no handle is needed; in-process and IPC carry the handle out of band in the C ABI. - C ABI simplifies: the discriminant moves from the C ABI handoff struct into the descriptor, leaving the C ABI to carry only the handle payload matching the declared mode.
- Forward-compatible: two reserved bytes remain at offsets 14–15 for future use.
- Self-describing at the binary level: a static inspector (
- Cons:
- Wire-incompatible with any earlier draft that assumed offset 13 was reserved. Acceptable because Hurray 1.0 has not shipped and has no external users.
- The discriminant and the handle live in two different places; the C ABI layer MUST cross-check them at handoff time.
- Accepted because: it captures the discoverability benefit of Alternative B without inheriting its cost (a non-portable pointer baked into the format). The user's explicit removal of backward-compatibility pressure is what made this option reachable.
Consequences
Positive
- The buffer handle gains a
sync_modediscriminant in a byte previously reserved; the handle stays 16 bytes; CPU-only workloads naturally encodeSYNC_PRODUCER_SYNCED = 0x00and are unaffected. - The producer-consumer synchronisation contract is normative, closing a silent-data-corruption gap before 1.0 freeze.
- All three sync mechanisms are available; producers and consumers can choose the cheapest valid option for their workload.
- Cross-machine GPUDirect inherits
TENSOR_DATA_ENDas the producer-synced signal — no new message types, no new fields, full alignment with ADR-012. - Event-release callback is symmetric with the buffer-release callback (ADR-009), so the C ABI gains one mechanism shaped like one the bindings already implement.
hurray-inspectcan surfacesync_modewithout reaching into the C ABI.
Negative
- The buffer handle layout is updated in-draft (offset 13 moves from
_reservedtosync_mode). Must land before 1.0 freeze; readers MUST validatesync_mode. - The C ABI surface grows: per-mode payloads and an event-release callback are added. The C ABI version MUST be bumped.
- Bindings MUST implement three code paths for the three sync modes.
- IPC GPU handoff requires producers to know whether the target device supports exportable events.
- Two-callback handoff (event-release + buffer-release) is more error-prone than one; binding authors MUST be guided clearly.
Risks
- A producer using
SYNC_EVENTthat fails to record the event before handoff could cause a deadlock. Mitigation: producers MUST record the event before calling the handoff function; the C ABI contract MUST state this. - A future device type (≥ 0x09) may have a synchronisation model that fits none of P1/P2/P3. Mitigation: P1 is universal; a fourth
sync_modecan be added in a later ABI version. - The receiver-side PCIe ordering assertion for GPUDirect is hardware-dependent. Must be stated as a receiver responsibility and documented in
c-ffi.mdor a hardware-compatibility note.
Compatibility impact
- Wire format: buffer handle layout updated in-draft. No backward-compatibility hazard — Hurray 1.0 has not shipped.
- C ABI: requires a version bump. The default
sync_modefor legacy clients MUST beSYNC_PRODUCER_SYNCED, preserving safe-but-slow behaviour for pre-ADR consumers. - Forward compatibility: a future ADR MAY add
sync_modevalues without affecting existing values.
Handoff
format-spec-writer: updatebuffer-protocol.md§ Buffer Handle table — split offset 13 from_reservedtosync_mode(uint8); shrink_reservedtouint8[2]at offsets 14–15. Add a new normative subsection § Stream and Event Synchronisation covering §1, §2, §5, §6 of this decision, including thesync_modeenumeration (SYNC_PRODUCER_SYNCED = 0x00,SYNC_EVENT = 0x01,SYNC_CONSUMER_STREAM = 0x02, reserved0x03–0xFE, invalid0xFF). Add a cross-reference ininterchange.md§ RDMA Data Plane and § Streaming: Tensor Descriptor and Data Frames stating that cross-machine senders MUST setsync_mode = SYNC_PRODUCER_SYNCEDand thatTENSOR_DATA_ENDis the cross-machine producer-synced signal.format-spec-writer: updatedocs/impl/c-ffi.mdto define the per-mode handoff payloads (sync_handle+event_release_fnforSYNC_EVENT;consumer_streamforSYNC_CONSUMER_STREAM) and the ABI-side cross-check against the descriptor'ssync_mode. The C ABI no longer carries async_modediscriminant of its own. Bump the documented C ABI version.rust-developer:hurray-core'sBufferHandlegains async_mode: SyncModefield at byte offset 13. Add aSyncModeenum with three named values plus aReserved(u8)carrier for forward-compat rejection (decoders reject reserved values; producers cannot construct them). Thehurray-inspectrefactor ontohurray-core(already scheduled for Layer 4) MUST surfacesync_modein the per-buffer view.architect: revisit if a device type added in the reserved range cannot express its synchronisation model via P1/P2/P3; that would prompt an extension ADR rather than amending this one.spec-checker: audit the new buffer-protocol subsection and the interchange cross-references for RFC 2119 correctness and consistency with ADR-009 (release callback) and ADR-012 (GPUDirect RDMA) once the spec edits land. Verify thatsync_mode = SYNC_PRODUCER_SYNCEDis stated normatively as the constraint for both CPU buffers and cross-machine transports.
Open Questions Deferred
- Per-direction GPUDirect sync semantics for asymmetric NIC-GPU topologies (deferred with ADR-012's per-direction
RDMA_GPUDIRECTflag). - Whether
SYNC_CONSUMER_STREAMcan be carried over IPC for drivers that support stream IPC export (CUDA'scuStreamGetCtxis not exportable; ROCm has experimental support). Resolution depends on driver capability evolution; not 1.0 blocking.
Date
2026-05-12
ADR-019: Format Evolvability as a Core Property
Status
Proposed
Context
ADR-017 named extensibility as a core property of Hurray and codified the stable extension surface (reserved tag ranges, private ranges, reserved flag bits, length-prefixed sections, permissive-mode parsing, independent version axes, spec-amendment process). Extensibility — also called evolvability, modifiability, or plasticity depending on the literature — is the single property of a format that makes it easy for engineers to change it in the future, adapting it for unanticipated use cases as requirements change. ADR-017 defined the extension surface; it did not formally specify the operational rules for using that surface: how backward and forward compatibility are achieved, what a reader is obliged to do when it meets data written by a newer minor version, how tags are added, and how an enum variant is safely deprecated and removed.
The mechanics required for safe evolution are largely already in place:
- Magic bytes (
HRRY,HRRYFILE). - Three independent version axes (descriptor
(major, minor), container(major, minor), per-schemescheme_version). descriptor_lengthin bytes 6–9 (self-delimiting from the first 10 bytes).- Length prefixes on every optional section, gated by a flag bit.
- Reserved tag ranges and reserved flag bits (MUST be zero until allocated).
- Permissive-mode parsing for unknown layout and quantization scheme tags.
- A change-classification table (MAJOR / MINOR / PATCH) and compatibility matrix in
versioning.md.
A focused review against the evolution rules of Protobuf, Avro, Thrift, FlatBuffers, and the DDIA Chapter 4 framework surfaced seven concrete gaps:
- Compatibility direction is never named with industry-standard vocabulary. The mechanics implement backward compatibility within
1.xand a particular shape of forward compatibility, but the spec never uses those words. - Forward compatibility is asymmetric and undocumented. Inside an existing length-prefixed section, an old reader silently skips trailing bytes added in a later minor version (forward-compatible). For a new optional section gated by a new flag bit, an old reader MUST reject (not forward-compatible). The asymmetry is correct, but it is not explained.
- No defaults table for newly appended trailing fields. If
1.1appends a field to an existing section, the spec does not say what value a1.0reader conceptually sees for that field. - No anti-rebind rule. Nothing prevents a future minor revision from reusing the same tag-byte value for a different meaning after the original is deprecated. Protobuf's
reservedkeyword closes this gap explicitly. - No migration-spec commitment. When
2.0ships, the spec does not promise a normative migration path from1.x. - No deprecation convention. There is no wire signal or spec convention for "this tag value still works but writers SHOULD NOT emit it."
- No spec-writer guard rail preventing a future minor amendment from allocating a new field at a fixed offset that an older reader would mis-parse (instead of properly gating it behind a flag bit or length-prefixed section).
The research also confirmed two design choices that are NOT appropriate for Hurray:
- Protobuf per-field tagging imposes per-field tag/length-type overhead on every read, defeating fixed-offset zero-copy.
- FlatBuffers vtables add an indirection table per object that breaks the "read the next field at a known offset" property required by streaming readers.
Hurray's existing flag-bit + length-prefix model is the correct analogue for a zero-copy, fixed-offset format and must remain the sole evolution mechanism for the optional surface of the descriptor and container.
This ADR closes the seven gaps above by naming the compatibility direction, adopting normative writer/reader/spec rules, and elevating evolvability to a named core property alongside extensibility. It introduces no new wire-format constraints; it documents and disciplines the existing one.
Decision
Hurray adopts format evolvability as Core Property #4. Evolvability — the same concept as extensibility, modifiability, or plasticity — is the property of a format that makes it easy for engineers to change it in the future. This ADR formally specifies the rules that make that property concrete: four compatibility direction declarations, two normative writer rules, two normative reader rules, six normative spec-amendment rules, and an explicit rejection of per-field tagging and vtables.
CD — Compatibility direction (named and bounded)
Within major version 1.x, on each of the three axes (descriptor, container, per-scheme):
- CD1 — BACKWARD compatible within a major. A reader at minor
MMUST correctly parse data written at any minorN ∈ {0, …, M}. This is the existing behaviour; CD1 names it. - CD2 — FORWARD_ADDITIVE within a major. A reader at minor
Mreading data written at minorN > MMUST correctly parse every field defined at minorM(fixed header up toM, buffer table, sections gated by flag bits defined atM, and trailing bytes of those sections up to the prefix length). The reader MUST reject the data when it encounters any flag bit or public tag value not defined at minorM, subject only to the permissive-mode exceptions defined inquantization.md§ Descriptor Header andmemory-layout.md§ Layout Tag Space. - CD3 — No forward compatibility across major versions. A reader supporting major
KMUST reject data whose declared major version on the relevant axis isK + 1or higher. - CD4 — No automatic backward-transitive compatibility across major versions. A reader supporting major
K + 1is not required to read major-Kdata directly. Cross-major reading is supported only via the migration specification described in S5.
The CD2 name FORWARD_ADDITIVE is normative. It is defined as: a reader correctly parses every part of newer-minor data that its own minor version defines, ignores trailing additive content inside known length-prefixed sections, and rejects newer-minor data that uses any feature gate (flag bit or public tag value) not defined at the reader's minor. It is deliberately a stricter property than Protobuf's "ignore unknown fields" forward compatibility, because Hurray's zero-copy fixed-offset model cannot safely skip unknown gated sections whose semantics may affect the data buffer.
W — Writer rules (new, normative)
- W3. A writer MUST NOT emit a deprecated public tag value or a deprecated flag bit when a non-deprecated equivalent exists.
- W4. When a writer appends an optional trailing field to an existing length-prefixed section under a
version_minorincrement, the writer MUST emit that field at the documented offset for that section and MUST update the enclosing length prefix accordingly. A writer MUST NOT emit a partial trailing field.
R — Reader rules (new, normative)
- R3. A deprecated public tag value MUST be treated as semantically equivalent to its non-deprecated definition. Deprecation MUST NOT change a value's wire semantics; deprecation only signals "writers SHOULD prefer the replacement."
- R4. When a reader at minor
Mencounters a length-prefixed section whose encoded length is shorter than the length defined for minorM(because the data was written at minorN < M), the reader MUST treat every field beyond the data's section length as carrying its documented default for minorM. The defaults table is normative and MUST be maintained per S4.
S — Spec amendment rules (new, normative)
- S1. A new public tag value MUST be allocated from the documented public reserved range of its tag space (element type, layout, device, quantization scheme, KV value, or named flag bit). It MUST NOT be allocated from an implementation-private range.
- S2 — Anti-rebind. An allocated public tag value MUST NOT be rebound to a different meaning within the same major version, even after it has been marked deprecated. Once allocated, a tag-byte value's meaning is fixed for the lifetime of
1.x. - S3 — Deprecation convention. When a public tag value or named flag bit is deprecated, the relevant tag table in the spec MUST mark it
deprecated since 1.Nand SHOULD include a pointer to its replacement. Deprecation is a writer-facing signal only; deprecation MUST NOT change reader behaviour (per R3). - S4 — Defaults for appended trailing fields. Any new optional trailing field appended to an existing section under a minor bump MUST be accompanied by a normatively documented default in the same minor revision. The default is what a reader at the prior minor conceptually sees for that field, per R4. Defaults MUST be expressible without reference to other fields in the same descriptor unless that dependency is documented.
- S5 — Major-version migration commitment. A future major version (e.g., descriptor
2.0, container2.0) MUST be accompanied by a normative migration specification mapping the prior major version's encoding to the new one for every tensor it can represent. The migration spec is normative for tools but does not impose a runtime obligation on a2.xreader to consume1.xdata. - S6 — No fixed-offset additions in minor revisions. A new field added under a minor bump MUST be gated by a flag bit (for a new section), by a tag value (for a new variant), or by a length-prefixed trailing extension of an existing section. A minor amendment MUST NOT allocate a new field at a fixed offset that an older reader would parse as part of an existing structure.
Anti-patterns explicitly rejected
The following evolution mechanisms are incompatible with Hurray's zero-copy fixed-offset access model and MUST NOT be adopted within major version 1.x:
- Per-field tagging (Protobuf-style). Every read would require scanning a tag/length/type sequence to locate the next field, defeating the property that a reader can compute the offset of any field from a small set of inputs (rank, layout tag, flag bits).
- vtables (FlatBuffers-style). Each tensor descriptor would carry a per-object virtual table indirecting every field access, losing single-pass streamability and the self-delimiting property.
Hurray's evolvability surface is the flag-bit + length-prefix model:
- New optional content is gated by a flag bit and length-prefixed. Old readers that don't know the bit reject; old readers that don't enter the section can skip it via the length prefix.
- Additive growth of an existing section happens by appending trailing bytes under a minor bump (W4 + R4 + S4 + S6). Old readers see defaults for the appended fields; new readers parse them.
- Tag spaces grow only within reserved ranges, never by rebinding (S1 + S2).
Resolutions of the open questions
- OQ-A (spec fingerprint KV entry). Deferred, non-blocking. A future
hurray.spec_fingerprintKV entry MAY be defined infile-format.md§ KV Value Types for archival forensics. Not part of this ADR's normative scope. - OQ-B (descriptor header reserved bytes for
compat_flags). Rejected on premise. Bytes 6–9 of the descriptor header aredescriptor_length(uint32), not reserved. Future compatibility-flag bits MUST be allocated from the reserved bits of the descriptorflagsfield (currently bits 4–31), which is the correct architectural channel. A minor amendment MUST NOT allocate a new byte at a fixed offset per S6. - OQ-C (name for selective forward compatibility). Resolved as FORWARD_ADDITIVE. See CD2.
- OQ-D (worked example placement). Resolved in two parts. One worked example is added inline in
versioning.md(the MXFPscheme_versionscenario). A full evolution playbook with multiple scenarios is deferred todocs/cookbook/evolution-playbook.mdas a Layer 5+doc-updaterdeliverable.
New Core Property #4
The following paragraph is inserted into README.md as Core Property #4, with the existing properties #4–#11 renumbered to #5–#12:
4. Format Evolvability
Hurray is designed to evolve. Within major version
1.x, the format is BACKWARD-compatible: a reader at minorMcorrectly parses data written at any minorN ≤ M. The format is also FORWARD_ADDITIVE: a reader at minorMcorrectly parses every part of newer-minor data that its own minor version defines, ignores additive trailing bytes inside known length-prefixed sections, and rejects newer-minor data that relies on an unknown flag bit or tag value. Public tag values are never rebound once allocated; deprecated tags retain their original semantics forever within1.x. A future major version is accompanied by a normative migration specification. Per-field tagging (Protobuf) and vtables (FlatBuffers) are explicitly rejected as incompatible with Hurray's zero-copy fixed-offset access model. See Versioning § Evolvability Contract for the full normative definition.
Alternatives Considered
Leave the compatibility direction unnamed. Rejected: implementors comparing Hurray to Protobuf/Avro/FlatBuffers cannot match the property to a familiar label, and spec-checker has no anchor for auditing compatibility direction. Naming costs almost nothing.
Adopt full forward compatibility (Protobuf-style) by silently ignoring unknown gated sections. Replace CD2 with "a reader MUST skip any unknown flag-gated section using its length prefix." Rejected: an unknown flag bit signals that the writer relied on a feature whose semantics are unknowable to the reader. Silently dropping the feature could allow a reader to parse a tensor's shape while mis-interpreting its data buffer if the unknown section changes how byte_offset or sync_mode is interpreted. FORWARD_ADDITIVE captures exactly the cases where skipping is genuinely safe (trailing bytes inside an already-understood section).
Adopt per-field tags or vtables to gain Protobuf/FlatBuffers-style evolvability. Rejected on architectural grounds (see § Anti-patterns rejected).
Defer S5 (major-version migration commitment) to the actual 2.0 planning cycle. Rejected: the value of S5 is the promise made before any user bets a workflow on 1.x archives. Deferring it weakens the evolvability property at the exact point users need it most.
Encode deprecation as a wire-level flag bit. Rejected: deprecation is a writer-facing recommendation, not a reader-facing state. R3 mandates identical wire semantics for deprecated and non-deprecated tags, so a wire flag would carry no information for the reader. A spec-table annotation (S3) is the right channel.
Allow tag rebind after a grace period. Rejected: a tag value's meaning being stable for the lifetime of 1.x is more valuable than recovering byte space. Tag spaces have large reserved ranges; exhaustion within 1.x is not a realistic concern.
Consequences
Positive
- The compatibility posture of Hurray is expressible in a single sentence per axis ("BACKWARD within
1.x, FORWARD_ADDITIVE within1.x, no cross-major automatic compat, migration via S5"). - Spec amendments have a normative checklist (S1–S6) that
spec-checkercan apply mechanically. - Long-lived archives are protected by the anti-rebind rule (S2) and the migration commitment (S5).
- The deprecation convention (S3 + R3 + W3) lets the spec retire wire forms cleanly without breaking readers.
Negative / obligations created
- Every future minor amendment MUST document a default for any appended trailing field (S4). New editorial obligation on
format-spec-writer. - S6 constrains spec authors: a future minor revision MUST gate new content behind flag bits, tag values, or length-prefixed trailing extensions.
- S5 binds the project to writing a migration specification before any future major bump can be Accepted.
- A
docs/cookbook/evolution-playbook.mddeliverable is created for Layer 5+.
Risks
- FORWARD_ADDITIVE misread as "Hurray is forward-compatible." Mitigation: the name explicitly contains
ADDITIVEandversioning.md§ Evolvability Contract explains the asymmetry with a worked example. - Defaults table becoming inconsistent. Mitigation: S4 mandates the default appears in the same minor revision that introduces the trailing field;
spec-checkergains a corresponding checklist item. - S2 misread as forbidding tag reuse across majors. It is not. S2 binds
1.xonly; a2.0migration spec MAY remap tag values entirely.
Compatibility impact
This ADR introduces no new wire-format constraints and no new fields. It documents and disciplines existing behaviour and adds editorial rules for future spec amendments. W3 and W4 constrain future writers; they do not invalidate any existing writer's output, because no deprecated tags exist yet and no trailing fields have been appended.
Handoff
format-spec-writer: add § Evolvability Contract toversioning.md(sub-sections: Compatibility Direction, Writer Evolution Rules, Reader Evolution Rules, Spec Amendment Rules, Defaults for Appended Trailing Fields, Anti-Patterns, worked MXFP example). Add cross-reference in § Change Classification MINOR row to S6. Add § Core Property 12 toREADME.md. Updatedocs/SPEC_CHECKLIST.mdpreamble (13→14 categories, 11→12 Core Properties) and insert new § 12 Format Evolvability category with the 6 checklist items listed below.spec-checker: the new § 12 checklist items are: (1) appended trailing fields have documented defaults (S4); (2) new public tags allocated from reserved ranges (S1); (3) no allocated tag rebound (S2); (4) deprecated tags marked in their table (S3); (5) new fields gated, not at fixed offsets (S6); (6) FORWARD_ADDITIVE preserved for prior-minor readers (CD2).doc-updater(Layer 5+): createdocs/cookbook/evolution-playbook.mdwith four worked scenarios (new flag-gated section, appended trailing field, new KV value tag, cross-major migration outline).
Date
2026-05-14
ADR-020: Memory Class Field in the Buffer Handle
Status
Draft
Context
The buffer handle (defined in docs/spec/buffer-protocol.md) carries a device_tag
field that identifies where a buffer resides (CPU, CUDA, ROCm, Metal, etc.). This
encodes a single dimension of buffer identity: the allocator or hardware domain.
A second, orthogonal dimension is left unrepresented: how a buffer is accessible — specifically, whether it can be read without copying by more than one compute unit simultaneously. Modern hardware offers at least three meaningfully distinct access classes beyond device-exclusive memory:
-
Host-pinned — CPU RAM page-locked for GPU DMA. The CPU can read it at native speed; the GPU can access it over PCIe/interconnect without a copy, but at reduced bandwidth (no device-local caching). Example:
cudaMallocHost,hipHostMalloc,CL_MEM_ALLOC_HOST_PTR. -
Unified / managed — A single allocation coherently accessible by both the CPU and one or more accelerators, with hardware-managed page migration or physical sharing. Examples:
cudaMallocManaged, ROCm HMM (hipMallocManagedon supported hardware), MetalMTLStorageModeSharedon Apple Silicon. -
Peer-to-peer (P2P) — A device-local buffer made directly accessible to a specific set of peer accelerators (not the CPU) via NVLink, xGMI, or PCIe BAR mapping. Examples: CUDA P2P (
cudaDeviceEnablePeerAccess), ROCm P2P.
Under the current model, a consumer receiving a CUDA buffer handle cannot distinguish
cudaMalloc (GPU-exclusive VRAM) from cudaMallocManaged (CPU+GPU) from
cudaMallocHost (CPU-accessible, GPU-mapped). All three carry device_tag = 0x01.
The consumer must therefore either copy unconditionally or agree out-of-band — which
defeats the purpose of a self-describing handle.
ADR-016 explicitly deferred "Memory-class sub-distinctions (CUDA Managed, CUDA Host, ROCm Host)" as a follow-up design question. This ADR resolves it.
DLPack already encodes this as separate device type values: kDLCUDAManaged (13),
kDLCUDAHost (3), kDLROCMHost (11). Hurray previously chose not to mirror DLPack's
integers (ADR-016 rationale), but the translation cost is low and bounded to the
Python bindings layer. The question is whether to follow DLPack's flat-enum approach
(Option 2) or to factor the concept into a separate field (this ADR, Option 3).
Why not Option 2 (new device tags per access mode)?
Adding CudaManaged, CudaHost, RocmHost, RocmManaged, etc. as separate tags
encodes the same information but hides the structure. Every new accelerator needs
2–4 tag variants; the tag space grows quadratically with the number of device types
and access modes. It also obscures that the distinction is conceptually orthogonal to
device identity: a consumer that doesn't care about access mode must enumerate all
variants for each device type it supports.
Why not Option 1 (a single "unified" flag bit)?
A single boolean cannot model the three-way distinction above. Host-pinned and Unified have different performance and coherency semantics: pinned memory has no hardware-managed coherency; unified/managed memory does. A consumer choosing between a GPU kernel and a CPU-path fast route needs to distinguish them.
Decision
Wire format change
Repurpose byte 14 of the 16-byte buffer handle — currently _reserved[0] — as a new
memory_class field. Byte 15 remains _reserved (MUST be 0x00; readers MUST
reject non-zero values).
Updated buffer handle layout:
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | byte_size | uint64 | Size of the buffer in bytes (little-endian). |
| 8 | alignment | uint32 | Minimum alignment in bytes (little-endian). |
| 12 | device_tag | uint8 | Device where this buffer resides. |
| 13 | sync_mode | uint8 | Producer-side synchronisation mechanism. |
| 14 | memory_class | uint8 | Memory access class. See § Memory Class Values. |
| 15 | _reserved | uint8 | MUST be 0x00. |
The total handle size remains 16 bytes. No existing field is displaced.
Memory class values
| Value | Name | Semantics |
|---|---|---|
0x00 | STANDARD | Device-exclusive memory. Only the primary compute unit of the tagged device can access this buffer without a copy. Default for all device types. CPU buffers (device_tag = 0x00) with this class are standard heap allocations. |
0x01 | HOST_PINNED | CPU-accessible, device-mapped. The CPU can read and write at native cache speed. The device can access it over its interconnect (PCIe, NVLink) without an explicit copy, but with reduced bandwidth compared to device-local memory. No hardware-managed coherency between CPU and device caches. |
0x02 | UNIFIED | Hardware-managed unified/coherent memory. Both CPU and device can access this buffer at any time; the hardware (driver or MMU) ensures coherency. Physical pages may migrate. |
0x03 | PEER | Peer-to-peer device memory. Directly accessible by a specific set of peer accelerators agreed out-of-band (NVLink, xGMI, PCIe BAR mapping). Not accessible from the CPU without a copy. The set of peers is communicated via the interchange protocol, not this field. |
0x04–0xEF | (reserved) | Reserved for future specification versions. Readers MUST reject a buffer handle with a memory_class in this range. |
0xF0–0xFE | (private) | Implementation-private memory classes, valid only when paired with a private device_tag (0xF0–0xFE). Semantics are agreed out-of-band. Readers that do not recognise the private class MUST reject the handle unless they have agreed out-of-band. |
0xFF | (invalid) | Reserved. Readers MUST reject a buffer handle whose memory_class is 0xFF. |
Per-device validity table
Not all (device_tag, memory_class) pairs are meaningful. The following table
defines the valid combinations. Readers MUST reject handles with combinations not
listed as valid for the declared device. Private device tags (0xF0–0xFE) MAY
use any private memory class; semantics are out-of-band.
| Device | STANDARD | HOST_PINNED | UNIFIED | PEER |
|---|---|---|---|---|
CPU (0x00) | ✓ | ✓ (pinned for GPU DMA) | ✓ (unified addr space, device = CPU side) | ✗ |
CUDA (0x01) | ✓ cudaMalloc | ✓ cudaMallocHost | ✓ cudaMallocManaged | ✓ P2P |
ROCm (0x02) | ✓ | ✓ hipHostMalloc | ✓ hipMallocManaged (hw-dependent) | ✓ xGMI/PCIe |
Metal (0x03) | ✓ StoragePrivate | ✓ StorageManaged (discrete GPU only) | ✓ StorageShared (Apple Silicon) | ✗ |
Vulkan (0x04) | ✓ DEVICE_LOCAL | ✓ HOST_VISIBLE | ✓ DEVICE_LOCAL|HOST_VISIBLE (integrated) | ✓ via external memory ext |
WebGPU (0x05) | ✓ | ✗ | ✗ | ✗ |
Hexagon (0x06) | ✓ VTCM/DDR | ✓ FastRPC shared | ✓ FastRPC coherent | ✗ |
Level Zero (0x07) | ✓ zeMemAllocDevice | ✓ zeMemAllocHost | ✓ zeMemAllocShared | ✓ |
OpenCL (0x08) | ✓ | ✓ CL_MEM_ALLOC_HOST_PTR | ✓ SVM (OpenCL 2.0+) | ✗ |
Note (non-normative): Metal
HOST_PINNED(StorageManaged) is deprecated and unavailable on Apple Silicon. Producers targeting Apple Silicon MUST useUNIFIED(StorageShared) instead. TheHOST_PINNEDvalue remains defined for discrete Metal GPU configurations.
Note (non-normative): ROCm
UNIFIEDrequires hardware support for Heterogeneous Memory Management (HMM). Producers MUST verify hardware support before tagging a bufferUNIFIED; consumers MAY fall back to a copy-based path ifUNIFIEDis declared but the consumer's runtime does not support HMM on the current device.
Backward compatibility
Existing descriptors that set _reserved[0] to 0x00 are implicitly STANDARD
(memory_class = 0x00), which is the correct interpretation for all allocations that
predate this field. The field defaults to the most conservative semantics; no existing
consumer is broken.
Readers compiled before this amendment will reject descriptors with memory_class != 0x00 at the _reserved byte check. This is the correct fail-safe: a consumer that
doesn't understand the memory class should not silently treat a UNIFIED buffer as
STANDARD, as it may issue incorrect synchronisation.
The supported_memory_classes field SHOULD be added to CLIENT_HELLO/SERVER_HELLO
in the interchange protocol to allow peers to advertise which memory classes they
support, analogous to supported_devices. This is a follow-up editorial change routed
to format-spec-writer.
DLPack mapping
The Python bindings layer MUST translate (device_tag, memory_class) pairs to DLPack
DLDeviceType values. The mapping is maintained in docs/impl/python-bindings.md.
Representative entries:
Hurray device_tag | Hurray memory_class | DLPack DLDeviceType |
|---|---|---|
0x00 CPU | STANDARD | kDLCPU (1) |
0x01 CUDA | STANDARD | kDLCUDA (2) |
0x01 CUDA | HOST_PINNED | kDLCUDAHost (3) |
0x01 CUDA | UNIFIED | kDLCUDAManaged (13) |
0x02 ROCm | STANDARD | kDLROCM (10) |
0x02 ROCm | HOST_PINNED | kDLROCMHost (11) |
0x03 Metal | STANDARD / UNIFIED | kDLMetal (8) |
Metal STANDARD and UNIFIED both map to kDLMetal (8) because DLPack does not
distinguish Metal storage modes. Consumers that need to distinguish storage modes
MUST use the Hurray memory_class field directly.
Alternatives Considered
Option 2 — new named device tags per access mode (e.g., 0x0A CudaUnified,
0x0B CudaHostPinned). Rejected: encodes the same information but hides the
factored structure, requiring O(devices × access_modes) tag assignments. Each new
device would need multiple tag variants, growing the tag table quadratically.
Option 1 — a single is_unified flag bit in a reserved byte. Rejected: a boolean
cannot distinguish HOST_PINNED from UNIFIED, which have different coherency and
performance semantics. Consumers choosing between GPU and CPU execution paths need the
three-way distinction.
Widen device_tag to uint16 and encode access mode in the high byte. Rejected: breaking wire layout change with no benefit over a separate byte; the handle is already 16 bytes with a free byte available.
Consequences
docs/spec/buffer-protocol.md:- Buffer handle table: byte 14 renamed from
_reserved[0]tomemory_class; byte 15 remains_reserved(now a single byte, not a two-byte array). - New § Memory Class Values section.
- New § Per-Device Validity Table section.
- Existing per-device alignment subsections amended to note valid memory classes.
- Buffer handle table: byte 14 renamed from
docs/spec/interchange.md:supported_memory_classesadvertisement added toCLIENT_HELLO/SERVER_HELLO(editorial follow-up →format-spec-writer).docs/impl/python-bindings.md: DLPack mapping table extended with(device_tag, memory_class)→DLDeviceTypeentries.hurray-core/src/buffer.rs:BufferHandlestruct gains amemory_class: MemoryClassfield at byte offset 14;MemoryClassenum defined with the values above. The existing_reserved: [u8; 2]field is replaced bymemory_class: MemoryClass+_reserved: u8. Routed torust-developeras part of the next buffer-touching pass.- ADR-016 § Consequences note "Memory-class sub-distinctions deferred": resolved by this ADR.
- Backward compatibility: descriptors with
memory_class = 0x00(STANDARD) are identical to pre-ADR-020 descriptors with_reserved[0] = 0x00. No existing producer or consumer is broken if they write0x00(the common case).
ADR-021: NVFP4 Quantization Scheme — Deferred
Status
Deferred
Context
NVFP4 is NVIDIA's 4-bit floating-point quantization format introduced with the Blackwell GPU architecture (B100, B200, GB200). It is implemented in hardware via Blackwell Tensor Cores and used in production by NVIDIA TensorRT-LLM, vLLM (Blackwell path), and — for model-weight compatibility on non-NVIDIA hardware — Apple's MLX framework.
Encoding summary
- Element type: 4-bit float, E2M1 (2-bit biased exponent, 1-bit mantissa, 1-bit sign), packed LSB-first into byte sequences.
- Scale format: E4M3 (4-bit) per-group scale value. One scale per group of 16 elements. No zero-point / bias term.
- Group size: Fixed at 16 elements.
How it differs from existing Hurray schemes
NVFP4 cannot be expressed by any scheme currently defined in quantization.md:
| Property | MXFP (0x05) | NVFP4 |
|---|---|---|
| Scale format | E8M0 (8-bit, OCP MX) | E4M3 (4-bit, NVIDIA) |
| Default group size | 32 | 16 |
| Standardisation body | OCP (Open Compute Project) | NVIDIA (proprietary) |
A different scale type and group size means the MXFP scheme tag cannot
accommodate NVFP4 even with a different block_size parameter.
Arguments for adding NVFP4 as Tier 2
- Hardware support is real and shipping. Blackwell Tensor Cores execute NVFP4 natively. Tier 2 precedent (MXFP) was set on the same basis.
- Cross-ecosystem adoption is observable. Apple MLX added NVFP4 support on Apple Silicon solely for model-weight portability — not native hardware support. This is a concrete instance of the interchange scenario Hurray targets.
- Existing scheme machinery applies. Tier 2 schemes are OPTIONAL; adding one imposes no conformance burden on implementations that don't support it.
Arguments for deferring
- Vendor-proprietary, not an open standard. MXFP is specified by OCP; NVFP4
is documented in NVIDIA CUDA/CUTLASS SDK documentation. If NVIDIA alters the
encoding, the Hurray spec would require a
scheme_versionbump — coupling the spec lifecycle to a vendor's SDK. - Published NVFP4 model weights remain scarce. As of May 2026, the dominant open-weight formats are GGUF (Q4–Q8), safetensors (bf16/fp16), and AWQ int4. NVFP4 weights exist but are not yet widely distributed.
- Specification source is not stable enough to write a normative spec section. The canonical NVFP4 encoding details are spread across NVIDIA CUTLASS headers and TensorRT-LLM documentation rather than a versioned, citable specification document. Writing a normative spec section against a moving target risks drift.
- Private extension range already covers the interim need. Implementations that
need NVFP4 interchange today MAY use a private scheme tag (
0xF0–0xFE) and agree on semantics out of band. This is the intended path for formats that are not yet ready for standardisation.
Decision
Deferred. NVFP4 is not assigned a Tier 2 (or Tier 1) scheme tag at this time.
Implementations that need to exchange NVFP4 tensors MAY use a private scheme tag
in the 0xF0–0xFE range with the encoding derived from NVIDIA CUTLASS / TRT-LLM
documentation. The recommended private encoding mirrors the Tier 2 MXFP scheme
structure (same 4-byte header, same buffer table placement rules) with:
scale_format = E4M3(to be defined if/when the scheme is promoted)block_size = 16- No zero-point field
Promotion criteria
This decision SHOULD be revisited when two or more of the following conditions are met:
- NVFP4 weights are distributed in at least two major open-weight model releases (e.g., on Hugging Face Hub) in a format that requires cross-runtime interchange (not just single-runtime inference).
- A second hardware vendor implements native NVFP4 Tensor Core support, or a standards body (OCP, ISO, JEDEC) adopts a compatible specification.
- NVIDIA publishes a versioned, citable NVFP4 specification document (not just SDK headers) that can be referenced normatively.
- At least two independent Hurray implementations have shipped private-tag NVFP4 support and request promotion.
Alternatives Considered
Add NVFP4 as Tier 2 immediately. Rejected at this stage: the normative source
is not stable enough to write a durable spec section. scheme_version provides a
forward migration path, but repeated version bumps due to upstream churn would erode
spec credibility.
Add NVFP4 as Tier 1. Not considered: Tier 1 requires implementation by all conforming implementations that advertise quantization support. A vendor-specific 4-bit format with no open standard is not an appropriate Tier 1 candidate.
Do nothing / no ADR. Rejected: the question has been raised and evaluated; an explicit deferral with promotion criteria is more useful than silence. Future reviewers will otherwise re-investigate the same question from scratch.
Consequences
- No changes to
quantization.mdor the scheme tag table at this time. - Implementations that need NVFP4 exchange today SHOULD use private scheme tag
0xF0and document their encoding internally, pending promotion. - This ADR is the tracking record for the NVFP4 promotion decision. It SHOULD be revisited when the promotion criteria above are met, at which point it is superseded by a new ADR assigning a Tier 2 tag.
TODO.md: add a note to re-evaluate NVFP4 when Blackwell model-weight distribution becomes mainstream (estimated: late 2026 based on hardware ramp).
ADR-022: hurray-python Runtime Compliance Modes
Status
Superseded by ADR-029
Note: The runtime compliance modes described here existed solely to gate
__array_namespace__visibility by dtype tier. ADR-029 drops the Array API conformance claim and removes both__array_namespace__and these modes. This ADR is retained for historical context.
Context
hurray-python targets two audiences simultaneously:
-
Array API consumers — libraries (NumPy, PyTorch, JAX, SciPy, scikit-learn, Xarray, …) that accept any Python Array API Standard-conformant array. For these consumers,
hurray.Tensormust behave exactly as the standard requires, including the requirement that__array_namespace__MUST NOT be implemented on tensors with non-Array-API dtypes (Tier 2, quantized). -
Hurray-native consumers — code that deliberately uses the full Hurray type system:
int4,float8variants, quantized types, non-standard memory layouts. These consumers do not need Array API conformance; they need access to the complete Hurray feature set without hitting artificial walls.
The Python Array API Standard clearly delineates the two cases — Tier 1 types are Array-API-conformant; Tier 2 and quantized types are not — but does not prescribe how an implementation must behave when a user tries to cross that line. Different implementations have made different choices (raise, return a special object, do nothing), creating interoperability confusion.
The project TODO records the need for: "hurray-python runtime modes: support two modes — strict Array API compliance mode (Tier 1 types only, all Array API invariants enforced) and standard-free mode (Tier 2 / quantized types exposed, Array API constraints relaxed)."
An open question during Layer 8a planning (OQ-A) asked: for hurray.Tensor instances
with Tier 2 / quantized dtypes, should __array_namespace__ be (a) absent (raises
AttributeError, hasattr returns False) or (b) present but raises TypeError?
The correct answer depends on the runtime-modes design: in strict mode (a) is correct;
in relaxed mode (b) is wrong — it must be present and functional.
A third concern is thread safety: hurray-python is used in multi-threaded inference
pipelines (Torch DataLoader workers, Triton server thread pools, asyncio coroutine
groups). A plain global flag (hurray.config.strict = False) would be unsafe across
threads.
Decision
Mode carrier: contextvars.ContextVar
The compliance mode is stored in a module-level contextvars.ContextVar[bool] named
_strict_mode, with a default value of True (strict). This is thread-safe and
coroutine-safe: each OS thread and each asyncio Task inherits a copy of the context
on spawn; changes in one thread do not affect others.
Note (non-normative): Threads created with the raw
threading.ThreadAPI inherit the default value of the ContextVar (True, strict), not the caller's current context. This is standard Pythoncontextvarsbehavior. Document it prominently in the user guide.
Public API (reserved in Layer 8a, fully implemented in a later layer)
Four names are reserved in the hurray module namespace from Layer 8a onward:
| Name | Signature | Behaviour in Layer 8a |
|---|---|---|
hurray.set_strict | set_strict(strict: bool) -> None | set_strict(True) is a no-op; set_strict(False) raises NotImplementedError |
hurray.is_strict | is_strict() -> bool | always returns True |
hurray.strict | context manager | always a no-op (already in strict mode) |
hurray.relaxed | context manager | raises NotImplementedError |
The full implementation (allowing set_strict(False) and relaxed() to actually
switch mode) is deferred to a later layer. Reserving the names now prevents users or
third-party packages from squatting on them.
OQ-A resolution: __array_namespace__ visibility
| Tensor dtype | Mode | __array_namespace__ | hasattr(t, '__array_namespace__') |
|---|---|---|---|
| Tier 1 | strict | present, returns hurray namespace | True |
| Tier 1 | relaxed | present, returns hurray namespace | True |
| Tier 2 / quantized | strict | absent | False |
| Tier 2 / quantized | relaxed | present, returns hurray namespace | True |
In strict mode, Tier 2 / quantized tensors MUST NOT expose __array_namespace__.
This satisfies the Array API Standard literally (the attribute does not exist) and
ensures array-api-tests conformance checks pass correctly.
In relaxed mode, __array_namespace__ is present on all tensors and returns the
hurray module, which acts as a non-conformant extended namespace for Tier 2 types.
The user has explicitly opted out of Array API conformance guarantees for that scope.
Implementation: __getattribute__ override on Tensor
To make hasattr(tier2_tensor, '__array_namespace__') return False in strict mode
while keeping a single Tensor class, Tensor MUST override __getattribute__ in
PyO3:
# Pseudo-code; actual implementation is in Rust via PyO3 #[pymethods]
def __getattribute__(self, name):
if name == '__array_namespace__' and not is_tier1_dtype(self.dtype):
if is_strict():
raise AttributeError(
f"hurray.Tensor with dtype {self.dtype.name!r} is a Tier 2 type "
f"and does not implement __array_namespace__ in strict mode. "
f"Use `with hurray.relaxed(): ...` to access Hurray-native features."
)
return type(self).__getattribute__(self, name)
The fast-path early return (name not in GATED_ATTRIBUTES) MUST be placed before the
mode check to minimise overhead on non-gated attribute accesses. GATED_ATTRIBUTES is
a small frozen set; initially {'__array_namespace__'}.
A private helper is_tier1_dtype(dtype) -> bool MUST be factored out from the gate
check. Layer 8b+ will call it from multiple sites.
Scope of the mode: narrow
The compliance mode gates exactly two things:
- Tier 2 / quantized dtype admission through Array-API-shaped construction APIs
(
hurray.zeros,hurray.ones,hurray.asarray, etc.). In strict mode these raisehurray.UnsupportedErrorfor non-Tier-1 dtypes. In relaxed mode they succeed. __array_namespace__visibility on Tier 2 / quantized tensor instances, per the table above.
The mode does not affect:
sizereturningNonefor dynamic dimensions (correct Array API behavior, not a constraint to relax).TraisingValueErrorfor non-rank-2 tensors (Array API specification).shapereturningTuple[Optional[int], ...](Array API specification).- DLPack
BufferErrorfor element types outside the DLPack type enum (structural limitation of DLPack, not a compliance constraint). - Error hierarchy or exception semantics.
These invariants hold in both modes. They are properties of well-formed operations, not of compliance scope.
The namespace object returned by __array_namespace__() for Tier 1 tensors is the
same hurray module in both modes. It is not "extended" or restricted based on mode.
Tier 2 entry is through bare hurray module functions (e.g., hurray.zeros), not
through the namespace returned by __array_namespace__.
Alternatives Considered
Option 1a: Plain module-level global flag. hurray.config.strict = False sets a
global boolean. Rejected: not thread-safe. Two Torch DataLoader worker threads can
race on the flag. In a Triton inference server, one request handler's mode flip leaks
to concurrent handlers. Unsafe by default in all multi-threaded ML environments.
Option 2: Tensor subclass (hurray.RawTensor). Two classes: hurray.Tensor
(strict, Tier 1, full Array API) and hurray.RawTensor (standard-free, all types).
Rejected: contradicts the user's intent for a simple global setting, and requires
every consumer library to widen isinstance checks. Makes from_numpy ambiguous
when the dtype is int4.
Option 3: Per-instance __getattr__ dispatch. The method is absent from the
class definition; __getattr__ raises AttributeError for Tier 2 in strict mode.
Rejected: __getattr__ only fires on attribute-not-found; it cannot conditionally
expose an attribute that exists on the class. Furthermore, the mode belongs to the
call site, not the tensor instance — a Tier 2 tensor created in relaxed mode
should not carry __array_namespace__ for its entire lifetime. __getattribute__
is the correct hook.
(b) for OQ-A: __array_namespace__ present, raises TypeError for Tier 2.
Rejected: the Array API conformance test suite (array-api-tests) probes
hasattr(t, '__array_namespace__'). Returning True and raising on call makes the
tensor claim Array API capability and then crash the consumer at the worst moment.
Worse than absent.
Consequences
- Layer 8a ships strict-mode only.
set_strict(False)andrelaxed()raiseNotImplementedError. This is documented indocs/impl/python-bindings.md. Tensor.__getattribute__is overridden from day one. The strict-mode gate for__array_namespace__is active; the relaxed-mode branch is a no-op placeholder. Adding relaxed mode later requires no public API change.is_tier1_dtype(dtype) -> boolis a first-class internal helper. It is factored out in Layer 8a and reused in Layer 8b+ wherever Tier-2 behavior diverges.- TODO.md item "hurray-python runtime modes" is resolved by this ADR for architecture; the implementation of the relaxed path is a separate tracked layer.
docs/impl/python-bindings.md§ "Tier 2 and quantized types" MUST be amended to reference this ADR and describe strict vs relaxed behavior. A new § "Runtime modes" MUST be added.- Threads created with raw
threading.Threadalways enter strict mode regardless of the spawning thread's mode. This SHOULD be documented in the user guide.
ADR-023: hurray-python Native Buffer Interchange Protocol
Status
Accepted
Context
hurray-python interoperates with the broader Python ecosystem through three
buffer-sharing surfaces:
- DLPack (
__dlpack__/__dlpack_device__) — the cross-library protocol used by PyTorch, JAX, NumPy, CuPy, etc. - NumPy (
__array__/from_numpy) — CPU-only, Tier 1 dtypes only. - Python buffer protocol — CPU-only, byte-level access.
DLPack is the right tool for cross-library zero-copy, but its device/memory model is
strictly less expressive than Hurray's. Per the device mapping table in
docs/impl/python-bindings.md, hurray.UnsupportedError is raised today in every
one of the following situations because DLPack v1.0 has no representation for them:
- ROCm +
UNIFIED—kDLROCMManageddoes not exist in the DLPackDLDeviceTypeenum. PEERmemory for any device — DLPack has no flat enum value for peer-mapped memory.- Private device tags (
0xF0–0xFE) — by design, the format reserves these for implementation-private use; DLPack cannot represent them.
For hurray-to-hurray transfers — hurray-python ↔ hurray-python,
hurray-python ↔ hurray-ffi consumer, or hurray-python ↔ another binding built
on hurray-ffi — DLPack is not the correct protocol. The Hurray C ABI buffer handle
(HurrayBuffer in hurray-ffi) already carries the full descriptor: device_tag,
memory_class, sync_mode, alignment, byte_size, release callback, release
context. A native protocol can share all of that losslessly without flattening into
DLPack's DLDeviceType enum.
The proposal: an opt-in __hurray_buffer__() / hurray.from_hurray_buffer() PyCapsule
protocol. The capsule wraps a HurrayBuffer pointer from hurray-ffi, with the same
lifetime discipline as DLPack capsules (refcount on create, decrement on
consume/delete; capsule renamed after consumption to prevent double-free).
This ADR does not propose replacing DLPack. DLPack remains the only protocol consumed by external libraries (PyTorch, JAX, NumPy). The native protocol exists strictly to plug the holes the device mapping table calls out, and to give two Hurray-aware peers a path that preserves the full descriptor.
What was decided in OQ-A (ADR-022)
ADR-022 establishes that runtime compliance modes gate exactly two things: Tier 2 /
quantized dtype admission and __array_namespace__ visibility on Tier 2 tensors. The
mode does not gate DLPack, __array__, error hierarchy, or buffer-protocol
semantics. That scope decision is load-bearing for this ADR: the native buffer protocol
is not an Array API construct and falls outside the modes' jurisdiction.
Decision
1. Protocol name: __hurray_buffer__ / from_hurray_buffer
Amended by ADR-033: the protocol is named
__hurray__/from_hurray, and the capsule is named"hurray_tensor"/"used_hurray_tensor"— renamed once more to"hurray_tensor_handle"/"used_hurray_tensor_handle"by ADR-043 § 5, when the pointer's type changed. The original text follows unchanged.
hurray.Tensor MUST expose a dunder method
__hurray_buffer__(stream=None) -> PyCapsule. The hurray module MUST expose
hurray.from_hurray_buffer(obj, /) -> hurray.Tensor that accepts any object whose
__hurray_buffer__ returns a valid capsule.
The dunder name __hurray_buffer__ mirrors the established pattern of __dlpack__,
__array__, and __cuda_array_interface__. The hurray_ prefix is namespace-safe:
dunders prefixed with a project identifier are an accepted convention (PyTorch's
__torch_function__, JAX's __jax_array__).
2. Layer placement: deferred to Layer 8c (post-FFI Python exposure)
The protocol MUST NOT be implemented in Layer 8a. Layer 8a ships the core
hurray.Tensor, DLPack, NumPy interop, and strict mode only. The hurray-ffi C ABI
is not yet exposed to Python through any documented interface in Layer 8a.
The native buffer protocol logically belongs in a layer downstream of Layer 8b (file
I/O bridge) and downstream of the work that exposes hurray-ffi to Python. This ADR
designates that layer Layer 8c — Native buffer protocol. Layer 8c may run in
parallel with or after the relaxed-mode implementation; it has no dependency on relaxed
mode.
In Layer 8a and 8b:
__hurray_buffer__MUST NOT be present onhurray.Tensor.hasattr(t, '__hurray_buffer__')MUST returnFalse.hurray.from_hurray_bufferMUST NOT exist as a public name.
The names are reserved by this ADR to prevent third-party squatting.
3. Mode gating: neither strict nor relaxed; available unconditionally in Layer 8c
The native buffer protocol is not an Array API construct, and ADR-022's mode scope
explicitly excludes everything except __array_namespace__ visibility and Tier 2 dtype
admission. Therefore:
- Once Layer 8c is shipped,
__hurray_buffer__MUST be present onhurray.Tensorinstances of all dtypes (Tier 1, Tier 2, quantized) in both strict and relaxed modes. - The Array API conformance test suite (
array-api-tests) does not probe__hurray_buffer__and is therefore unaffected.
A Hurray-aware consumer that needs a Tier 2 / quantized tensor's buffer in strict mode
still has a path: the native protocol. A non-Hurray-aware consumer that probes
__array_namespace__ still sees False for Tier 2 in strict mode, as required by
ADR-022. The two surfaces are orthogonal.
4. Spec placement: implementation-only
The native buffer protocol MUST be documented only in
docs/impl/python-bindings.md. It MUST NOT appear in docs/spec/buffer-protocol.md
or any other file under docs/spec/.
Justification:
- The protocol is a Python-only transport — it does not affect the wire format, the binary descriptor, the C ABI, or any non-Python binding.
- The wire payload of the capsule is the
HurrayBufferpointer defined inhurray-ffi; the C FFI implementation guide is already authoritative for that handle. - Putting the protocol in the format spec would create a normative obligation on all
bindings (C, Java, JS, etc.) to expose a "native" protocol — out of scope for
non-Python bindings that already pass
HurrayBufferdirectly through C.
5. Capsule lifetime: same discipline as DLPack, with Hurray-specific deleter
Amended by ADR-034: the capsule context is a
HurrayTensorContextfromhurray-ffi, readable from any language, and the ABI version is read throughhurray_tensor_context_abi_version. C ABI version 4.Superseded in turn by ADR-043: there is no capsule context. The capsule carries one
HurrayTensor *owning both halves, the ABI version is read throughhurray_tensor_abi_version, and teardown is onehurray_tensor_destroy. C ABI version 10.
The PyCapsule lifetime rules MUST match DLPack semantics:
- Capsule name on creation:
"hurray_buffer". - Capsule name after consumption:
"used_hurray_buffer". The consumer MUST rename the capsule before transferring ownership, exactly as DLPack consumers rename"dltensor"to"used_dltensor". - Capsule destructor (producer side): If the capsule is destroyed while its name is
still
"hurray_buffer"(the consumer did not take ownership), the destructor MUST callhurray_buffer_destroyon the wrappedHurrayBufferpointer, which invokes the registered release callback exactly once. - Consumer-side responsibility: Once the capsule has been renamed to
"used_hurray_buffer", the consuminghurray.Tensorowns theHurrayBufferand MUST callhurray_buffer_destroyexactly once at its own finalisation. - Source
hurray.Tensorreference counting: When__hurray_buffer__()is called, the sourceTensor's Python refcount MUST be incremented and stored as the capsule context; the destructor MUST decrement it. This mirrors the rule required for__dlpack__in § Buffer Lifetime and Ownership.
Note (non-normative): The Python refcount on the source
Tensorand theHurrayBufferinternal release callback are independent. The Python refcount keeps the producer-side Python object alive while the capsule exists; the release callback governs the underlying buffer memory.HurrayBufferis not internally refcounted at the C ABI; producers wanting multi-consumer fan-out MUST issue distinctHurrayBufferhandles per consumer.
6. stream parameter: same semantics as DLPack
__hurray_buffer__(stream=None) MUST accept an optional stream parameter with the
same semantics as __dlpack__(stream=None) in § Stream parameter semantics:
stream | Requirement |
|---|---|
None | The tensor MUST have SyncMode::ProducerSynced. |
-1 | The binding layer MUST perform a device-level synchronisation before returning the capsule. |
| Positive integer (stream handle) | If ProducerSynced, the stream is ignored. For SyncMode::Event or SyncMode::ConsumerStream, the binding MUST raise BufferError. |
7. Discovery: hasattr(tensor, '__hurray_buffer__')
Amended by ADR-033: the protocol is named
__hurray__/from_hurray, and the capsule is named"hurray_tensor"/"used_hurray_tensor"— renamed once more to"hurray_tensor_handle"/"used_hurray_tensor_handle"by ADR-043 § 5, when the pointer's type changed. The original text follows unchanged.
Consumers MUST discover support by probing hasattr(obj, '__hurray_buffer__'). There
MUST NOT be a capability flag on the hurray namespace. This matches the discovery
convention of __dlpack__, __array__, and __cuda_array_interface__. A consumer
that detects __hurray_buffer__ MAY still fall back to __dlpack__ if it does not
link hurray-ffi. The two probes are independent.
8. Error semantics
Amended by ADR-034: the capsule context is a
HurrayTensorContextfromhurray-ffi, readable from any language, and the ABI version is read throughhurray_tensor_context_abi_version. C ABI version 4.Superseded in turn by ADR-043: there is no capsule context. The capsule carries one
HurrayTensor *owning both halves, the ABI version is read throughhurray_tensor_abi_version, and teardown is onehurray_tensor_destroy. C ABI version 10.
Amended by ADR-033: the protocol is named
__hurray__/from_hurray, and the capsule is named"hurray_tensor"/"used_hurray_tensor"— renamed once more to"hurray_tensor_handle"/"used_hurray_tensor_handle"by ADR-043 § 5, when the pointer's type changed. The original text follows unchanged.
- A consumer receiving an object lacking
__hurray_buffer__MUST raiseTypeError. - If the wrapped
HurrayBufferpointer is null or the capsule name is not"hurray_buffer"(already consumed),hurray.from_hurray_bufferMUST raisehurray.BufferError. - ABI version mismatch between producer and consumer MUST raise
hurray.UnsupportedError. The capsule context MUST includeHURRAY_C_ABI_VERSIONfrom the producer; the consumer MUST verify it before dereferencing the handle.
Alternatives Considered
Option A: Extend DLPack upstream. Lobby for kDLROCMManaged, peer-memory enum
values, and a private-tag escape hatch in DLPack itself. Rejected: upstream
evolution is slow; DLPack's structural flat-enum constraint cannot absorb Hurray's
full (device_tag, memory_class, sync_mode) space without losing the protocol's
cross-library simplicity.
Option B: Reuse __dlpack__ with a sentinel device-type integer. Allocate a
private DLDeviceType value for "Hurray native" and embed the HurrayBuffer pointer
in DLTensor.data. Rejected: misuses a published cross-library protocol; silently
breaks DLPack consumers that do not recognise the sentinel value.
Option C: Per-instance opt-in via a constructor flag
(Tensor(..., expose_native=True)). Only tensors created with expose_native=True
carry __hurray_buffer__. Rejected: protocol availability belongs to the call
site, not the tensor instance — consistent with ADR-022's mode-scope decision. An
always-on dunder (once Layer 8c ships) is the uniform rule.
Option D: Implement in Layer 8a. Rejected: hurray-ffi is not yet reachable
from Python in Layer 8a. Implementing against a moving target adds risk with no
corresponding user value.
Option E: Different capsule lifetime discipline. For example refcount-only, no name
change. Rejected: the "name" → "used_name" rename is what makes DLPack's capsule
destructor safe under all consumption paths. Reinventing the discipline creates a
divergent mental model for binding authors.
Option F: Capability flag on the hurray namespace (e.g.,
hurray.NATIVE_BUFFER_PROTOCOL_VERSION = 1). Rejected: redundant atop the
hasattr probe; creates a synchronisation burden between the flag and the dunder.
Consequences
- Closes the DLPack representability gaps for ROCm
UNIFIED, allPEERmemory, and private device tags0xF0–0xFE. Hurray-aware peers can transfer these buffers losslessly. - Preserves DLPack as the cross-library protocol. Non-Hurray consumers (PyTorch,
JAX, NumPy) continue to use
__dlpack__unchanged. - Mode-orthogonal. Tier 2 / quantized tensors gain a buffer-sharing path in strict mode without relaxing Array API conformance.
- No spec churn.
docs/spec/buffer-protocol.mdis untouched. Non-Python bindings are unaffected. - Layer 8c added to the roadmap. Scope: one dunder, one constructor function, one capsule destructor, ABI version check, and associated tests. Small, contained, and user-approvable independently.
- ABI version coupling. Changes to
HurrayBufferinhurray-ffiaffect the capsule payload. Mitigated byHURRAY_C_ABI_VERSIONembedding and consumer-side verification.
Required Spec Amendments
The following amendments to docs/impl/python-bindings.md are required as a follow-up
by format-spec-writer. Do NOT apply them here.
-
New section
## Native Buffer Interchange Protocol(after## DLPack Interoperability, before## Buffer Lifetime and Ownership). Covers: dunder name, constructor, capsule names, lifetime rules,streamsemantics, ABI versioning, mode independence, discovery, and reference to ADR-023. -
Amendment to
## DLPack Interoperability§ mapping table notes: add a note pointing thehurray.UnsupportedErrorrows (ROCmUNIFIED, allPEER, private tags) to the native protocol as the recommended fallback for Hurray-aware consumers. -
Amendment to
### Layer 8a status: add bullets stating that__hurray_buffer__andhurray.from_hurray_bufferare reserved but not implemented in Layer 8a;hasattrreturnsFalsefor the dunder. -
Amendment to
## Error Handling: addhurray.BufferErrorfor null / already-consumed capsules passed tofrom_hurray_buffer; andhurray.UnsupportedErrorfor ABI version mismatches. -
Amendment to
hurray-python/COMPAT-MATRIX.md: add a column recording, per release, whether the native buffer protocol is available and the minimumHURRAY_C_ABI_VERSIONrequired.
Open Questions Deferred
- Multi-buffer tensors (
SparseTensor). Should__hurray_buffer__return a tuple of capsules or be restricted to dense tensors? RESOLVED by ADR-030: one capsule carries every buffer, wrapping aHurrayBufferList. The initial recommendation recorded here — restrict to dense and add__hurray_sparse_buffer__later — is superseded; it conflated multi-buffer with sparse, while dense per-channel-quantized, block-paged, and composite tensors are multi-buffer too. - Cross-process (IPC) variant. A capsule only works in-process; cross-process Hurray-native exchange would need an OS-handle-based protocol. Deferred to a later layer.
- Async counterpart. Whether an
__ahurray_buffer__async variant is needed. Deferred. Initial recommendation: no;sync_modealready handles the synchronisation contract per-buffer.
ADR-024: Block-Paged Layout as an Indirect-Dense Whole-Batch Snapshot
Status
Draft
Context
Disaggregated LLM inference is, at its core, a data-plane problem: the KV cache must
move between prefill and decode workers. The systems surveyed in docs/prior-art.md
§3 — DistServe, Mooncake, vLLM's KVConnector, NVIDIA Dynamo / TensorRT-LLM, llm-d, and
LMCache — all move the KV cache continuously, and all of them agree shape, dtype, paged
layout, and quantization out-of-band, shipping only opaque blocks plus IDs. §3.7
establishes this metadata gap; §3.8 names a Hurray block-paged layout as the planned
fix.
The KV cache is stored paged (PagedAttention): a flat pool of fixed-size pages plus a per-sequence block table mapping logical token positions to physical page IDs. Different sequences can share physical pages (prefix sharing / copy-on-write), which the engines manage with internal reference counts.
Hurray's job is to describe the static snapshot on the wire, not live allocator state. It must do so within the format's existing constraints:
- Streamability (
README.md,interchange.md): descriptors precede their data, the format is self-delimiting, and there are no back-references and no end-of-file index. - Single-owner buffers (
buffer-protocol.md; ADR-009 keeps reference counting engine-internal and non-normative at the ABI boundary). - Hyperrectangle shape model (
data-model.md): the logical shape is always a rectangular index space. - Reuse of the existing quantization schemes (
quantization.md) rather than a paged-specific scheme.
This ADR records the design of a new block-paged layout that closes the §3.8 gap
within those constraints. Three open questions raised during design have been resolved
by the user and are folded into the Decision and Consequences below; no open questions
remain.
Decision
1. A new addressing category: Indirect
block-paged introduces a third layout addressing category alongside Dense and Sparse:
Indirect. In an indirect layout every logical element exists (there are no
implicit zeros, unlike Sparse), but the mapping from a logical index to a physical
buffer position is non-affine — it is resolved through a block table rather than an
affine stride formula. The layout is assigned Tier-1 tag 0x0B (tag 0x0A is reserved
for the future CSF — Compressed Sparse Fiber — layout, keeping the sparse family
contiguous).
2. Whole-batch snapshot, not per-sequence descriptors
A single block-paged descriptor encodes one whole batch: one page pool, one flat
block table, and a CSR-style seq_ptr offset array delimiting each sequence's slice of
the block table. A per-sequence descriptor was rejected because cross-sequence prefix
sharing would then require a cross-tensor back-reference (one sequence's descriptor
pointing into another's pages), which the streamability contract forbids. Encoding the
whole batch turns prefix sharing into internal aliasing — two block-table entries
naming the same physical page ID — which is self-contained and streamable.
3. Ragged structure via seq_ptr; the shape stays a hyperrectangle
Per-sequence lengths are carried by seq_ptr, exactly as CSR carries ragged rows by
row_ptr. The logical shape [total_tokens, num_heads, head_dim] remains a
hyperrectangle, so the data model in data-model.md is unmodified. A padded dense shape
([num_seqs, max_seq_len, ...]) was rejected: it wastes memory on padding and
misrepresents the snapshot. A conforming implementation MUST require rank == 3 in this
version, mirroring CSR's rank-2-only restriction (see Consequences, OQ-3).
4. One descriptor per {kv_role, layer_index}
A full transformer KV cache ([layers, 2, ...]) is transmitted as a stream of
descriptors, one per key/value role per layer. This matches how the engines transfer
the cache — layer by layer (DistServe; vLLM's save_kv_layer / wait_for_layer_load) —
and keeps each descriptor a self-contained, streamable unit. A single whole-cache
descriptor was rejected because it would defeat layer-by-layer streaming.
5. Buffer table
block-paged is the first layout to mix a values buffer with two index/pointer buffers
and optional quantization-parameter buffers:
| Index | Buffer | Notes |
|---|---|---|
| 0 | page_pool | flat pool of fixed-size pages |
| 1 | block_table | flat per-sequence concatenation of physical page IDs; uint32 by default, uint64 opt-in |
| 2 | seq_ptr | CSR-style offset array, num_seqs + 1 entries |
| 3+ | quant-param buffers | present only when the tensor is quantized, per quantization.md placement rules |
Scalar descriptor fields: page_size, num_pages, paged_axis (= 0 in this version),
num_seqs, kv_role, layer_index, and block_table_index_type.
6. Quantization reuses existing schemes, paged through the same block table
Quantized paged KV caches reuse the schemes already defined in quantization.md — no
paged-specific scheme is introduced. Per-page-slot scales (and zero points, for asymmetric
schemes) are paged through the same block table as the values: a page carries its
own scales, so a shared page remains numerically coherent for every sequence that
aliases it. Quantization-parameter buffers occupy buffer indices 3 and up, per the
placement rules in quantization.md.
The composition rules are now specified, not merely asserted (see
docs/spec/layouts/block-paged.md § Quantization Compatibility): scales are per
page slot (num_pages * page_size entries, paged through block_table); the
scale-buffer size MUST be computed from the page structure rather than the standard
per-block-affine formula; per-tensor and per-channel (along num_heads or head_dim)
schemes compose normally; and per-block-affine (scheme_tag = 0x03) composes only when
block_size == page_size with the paged/token axis as the quantization axis.
7. Ownership is unchanged
block-paged requires no amendment to buffer-protocol.md or ADR-009. Aliasing is data
inside the block_table buffer; it does not create shared buffer ownership and
introduces no wire-level reference count. The engine-internal copy-on-write reference
counts that govern live page lifetime are out of scope: Hurray describes the snapshot,
not the allocator.
Alternatives Considered
Per-sequence descriptor (one descriptor per sequence). Rejected: cross-sequence prefix sharing would require a forbidden cross-tensor back-reference, violating the streamability contract.
Padded dense shape [num_seqs, max_seq_len, num_heads, head_dim]. Rejected: wastes
memory on padding for ragged batches and cannot express page sharing across sequences.
Classifying block-paged as a Sparse layout. Rejected: a sparse layout implies implicit zeros for unstored coordinates. A paged KV cache has no implicit zeros — every logical element along the paged axis is materialised in some page. The mismatch would mislead readers about the data model.
A single whole-cache descriptor ([layers, 2, ...]). Rejected: it defeats
layer-by-layer streaming, which is the dominant transfer pattern in disaggregated
serving.
A paged-specific quantization scheme. Rejected: it would duplicate the per-block
machinery already defined in quantization.md. Reusing the existing schemes and paging
the scales through the block table is sufficient.
Wire-level reference counts on shared pages. Rejected: it contradicts ADR-009 (reference counting is engine-internal and non-normative at the ABI) and the snapshot framing — a snapshot has no live lifetime to count.
Consequences
docs/spec/memory-layout.mdgains theIndirecttype, the0x0Brow in the Named Layout Tags table, and a buffer-table clause covering indirect layouts. Tag0x0Ais reserved for the future CSF (Compressed Sparse Fiber) layout so the sparse family (COO0x07, CSR0x08, CSC0x09, CSF0x0A) stays contiguous.- A new layout file
docs/spec/layouts/block-paged.mdis added. block-pagedis the first layout to mix a values buffer with two index/pointer buffers plus optional quantization buffers — a new buffer-table shape for the format.- A new reader validation surface is introduced: page-ID bounds checking
(
0 <= block_table[k] < num_pages) andseq_ptrmonotonicity. Readers SHOULD validate these and MUST reject violations unless operating in permissive mode. - OQ-1 (partial trailing page) — resolved. Slots beyond a sequence's valid token
count are left undefined. A reader MUST NOT read past a sequence's valid token
count (bounded by
seq_ptr); a writer SHOULD zero unused slots when transferring across a trust or tenant boundary. No padding-value field is added. - OQ-2 (aliasing) — resolved. Aliasing is expressible, not validated in this version. A reader validates page-ID bounds but is NOT required to verify that aliased pages hold identical content. No content-hash mechanism is added in v1.
- OQ-3 (rank) — resolved.
rank == 3is required in this version ([total_tokens, num_heads, head_dim]), mirroring CSR's rank-2-only restriction. Generalisation is deferred to a future revision. - No ownership impact (confirmed).
buffer-protocol.mdand ADR-009 are untouched; aliasing creates no shared buffer ownership and no wire-level reference count. - Sharding forbidden in v1. A shard descriptor MUST NOT be applied to a block-paged
tensor in this version (
docs/spec/layouts/block-paged.md§ Sharding). Tensor-parallel / multi-GPU sharding of a paged KV cache (e.g. alongnum_heads) is an open question: the interaction between a shard'sshard_offsetand the absoluteseq_ptroffsets into the whole-batchblock_tablemust be resolved before sharding can be permitted.
Date: 2026-06-23.
ADR-025: CSF (Compressed Sparse Fiber) as the Rank-N Sparse Layout
Status
Draft
Context
Hurray already defines three sparse layouts: COO (0x07, any rank), CSR (0x08,
rank-2), and CSC (0x09, rank-2). CSR/CSC compress one mode with a dense outer
pointer array — compact and interop-canonical (SciPy, cuSPARSE, MKL, Eigen;
hurray-python already performs SciPy zero-copy sparse interop) — but they do not
generalise past rank 2. COO generalises to any rank but stores a full coordinate
tuple per non-zero with no hierarchical structure.
TACO uses CSF (Compressed Sparse Fiber): the rank-N generalisation of CSR/CSC. A
CSF tensor is a tree of rank levels, each compressing one mode with a (pos, crd)
pair, plus one shared values array. CSF is the natural higher-rank complement to COO
for structured sparsity — attention masks, sparse activations, and higher-order
factorisations. TODO.md names CSF, and tag 0x0A was reserved for it in ADR-024 to
keep the sparse family contiguous (COO 0x07, CSR 0x08, CSC 0x09, CSF 0x0A).
The settled framing is that CSR/CSC/COO are kept; CSF complements them. CSR's
dense outer row_ptr is more compact and more interop-canonical for rank-2 matrices,
and an all-compressed CSF tree is a physically different layout. Writers SHOULD prefer
CSR/CSC for rank-2 data.
This ADR must respect the format's existing constraints:
- Streamability / self-delimitation (
README.md,interchange.md): descriptors precede their data, all buffer lengths derive from theposchain, and there are no back-references and no end-of-file index. - Single-owner buffers (
buffer-protocol.md; ADR-009). - Hyperrectangle shape model (
data-model.md): the logical shape is always a rectangular index space. byte_offset = 0for sparse layouts (memory-layout.md).- Reuse of the existing quantization schemes (
quantization.md).
This is the pre-1.0 draft period, so assigning the already-reserved tag 0x0A
requires no version_minor bump, exactly as ADR-024 established for 0x0B.
Decision
1. All-compressed CSF, not the full per-mode model
Every level of the CSF tree is a compressed (pos, crd) pair. The TACO per-mode
dense/compressed model is not adopted in this version: dense-outer rank-2 cases
are already covered by CSR/CSC, and a per-level mode_format[rank] array is reader
burden Hurray does not need yet. This is forward-compatible: a future revision MAY add
a mode_format: uint8[rank] field to admit dense levels, and a v1 reader rejecting
that unknown field is intended versioning behaviour.
2. Explicit mode ordering
A CSF descriptor carries mode_order: uint32[rank], a permutation of 0..rank-1.
mode_order[L] is the logical dimension stored at level L (level 0 is outermost;
level rank-1 is the leaf level directly above values). The bounding size for level
L is shape[mode_order[L]]. The logical shape is unchanged — mode_order affects
storage traversal only.
mode_order is a performance knob: it lets a writer match the tree's nesting to
its access pattern. A conforming reader MUST honour any valid mode_order permutation
for lookup and iteration, and MUST NOT reject a descriptor merely because mode_order
is non-identity — permutation support is mandatory for an interchange format. When a
writer has no access-pattern preference, the identity ordering
[0, 1, ..., rank-1] (row-major, outer-to-inner) is the RECOMMENDED default, for
reproducibility and cache-friendliness.
3. Buffer table = 2 × rank + 1
values is at index 0 (consistent with CSR/CSC/COO). Each level L contributes a
pos_L buffer at index 2L + 1 and a crd_L buffer at index 2L + 2. The top level
retains pos_0 = [0, n_0] (length 2) for structural uniformity; omitting the top
pos was rejected. All pos/crd buffers are uint64. Quantization-parameter
buffers, when present, occupy indices 2·rank + 1 and up.
4. Rank scope: rank >= 3
CSR, CSC, and COO own rank ≤ 2. A conforming reader MUST reject a CSF descriptor with
rank < 3. Rank remains capped at 64 by data-model.md.
5. Element lookup by per-level descent
Lookup descends the tree level by level, binary-searching each level's crd slice
delimited by the parent's pos entry. A coordinate not found at any level means the
element is implicitly zero.
6. Storage invariants mirror CSR, per level
For each level L: pos_L[0] = 0; pos_L is non-decreasing; the terminal pos
entry equals the level's stored count (pos_0[1] = n_0, pos_L[n_{L-1}] = n_L, with
n_{rank-1} = nnz); within each parent slice the crd values are strictly increasing
(no duplicate siblings); and 0 <= crd_L[i] < shape[mode_order[L]]. In addition,
mode_order MUST be a valid permutation of 0..rank-1.
7. Cross-cutting properties
byte_offset MUST be 0 (sparse). The layout is self-delimiting: every buffer length
derives from the pos chain, so no back-references or EOF index are needed.
Quantization decorates the values leaves (values is buffer 0); per-tensor,
per-channel, per-block, NF4, and MXFP schemes compose exactly as they do for COO/CSR,
with quant-parameter buffers at index 2·rank + 1 and up. Sharding is forbidden
in this version: the interaction between a shard's shard_offset and the per-level
pos/crd tree is unresolved. CSF is classified as Sparse (implicit zeros).
8. Tag assignment
CSF takes Tier-1 tag 0x0A, classified Sparse. The memory-layout.md row for 0x0A
is promoted from "(reserved — planned)" to a link to layouts/csf.md.
Alternatives Considered
Full per-mode dense/compressed model (TACO mode formats). Rejected for v1:
dense-outer rank-2 cases are covered by CSR/CSC, and a per-level mode_format array is
reader burden Hurray does not need yet. Deferred and forward-compatible via a future
mode_format field.
Replacing CSR/CSC with CSF. Rejected (settled framing): CSR's dense outer pointer is more compact and interop-canonical for rank-2, and all-compressed CSF is a physically different layout.
Omitting the top-level pos_0. Rejected: structural uniformity across all levels
is worth the 16 bytes of a length-2 pos_0.
Allowing CSF at rank 2 and up. Rejected: it would duplicate CSR/CSC/COO. CSF is
restricted to rank >= 3.
uint32 indices. Rejected for v1 to match the uint64 indices used by
CSR/CSC/COO; an opt-in narrower index type may be added later.
Non-Sparse classification. Rejected: CSF has implicit zeros, so it is Sparse.
Consequences
docs/spec/memory-layout.md: the0x0Arow is promoted from reserved to a link tolayouts/csf.md, and the sparse buffer-table clause notes CSF's variable rank-dependent count of2·rank + 1.- A new layout file
docs/spec/layouts/csf.mdis added. - CSF is the first layout with a variable, rank-dependent buffer count and the
first sparse layout with a permutation descriptor field (
mode_order). - A new reader validation surface is introduced:
mode_orderpermutation validity, per-levelposmonotonicity and terminal checks, and per-levelcrdbounds. Readers SHOULD validate these and MUST reject violations unless operating in permissive mode. - The forward-reference note in
csr.md(and the analogous mention) is now satisfied and updated to point atcsf.md(editorial). - Writers SHOULD prefer CSR/CSC for rank-2; CSF MUST NOT be used below rank 3.
- No
version_minorbump (the tag was already reserved; pre-1.0 draft period). - The
TODO.mdCSF entry can be marked done oncecsf.mdlands.
Date: 2026-06-27.
ADR-026: General Subpaving as Nested Region Descriptors with Per-Region Buffer Sub-Tables
Status
Superseded by ADR-027 (Composite Tensors — Head + Members + Composition Rule)
Superseded while still in Draft; the "Supersedes: ADR-015" intent below therefore never took effect. ADR-015 remains the record of the currently implemented inline region encoding until ADR-027's implementation lands.
Supersedes: ADR-015 (Subpaving Region Inline Layout Encoding)
Context
A spec-checker audit of the General Subpaving layout (docs/spec/layouts/subpaving.md,
tag 0x06) surfaced five design-level findings (F-1, F-2, F-4, F-5, F-10). Their common
root cause: subpaving is classified Dense and inherits the dense-layout rule
buffer_count == 1, yet each RegionDescriptor (per ADR-015) carries a single
buffer_index + region_byte_offset and its region_layout_payload carries only the
inner layout's scalar descriptor fields — never buffer references. Sparse
(0x07–0x0A) and indirect (0x0B) inner layouts bind their component arrays (values,
indices, pointers, page pool, block table) positionally to a buffer table with a
mandated exact count, so a sparse or indirect inner region is syntactically encodable but
semantically uninterpretable (F-1). The per-region buffer_index also contradicts the
buffer_count == 1 rule (F-2); private-extension inner tags have undefined buffer needs
(F-4); per-region quantization is undefined (F-5); and the tensor-level byte_offset is
unused by subpaving addressing (F-10).
The project's array-database vision treats heterogeneous chunked tensors as first-class: one very large logical tensor whose regions differ in structure — dense tiles beside sparse blocks beside paged blocks — is a target use case, not an edge case. A prior draft of this ADR proposed a dense-only inner-layout whitelist (deferring sparse-in-subpaving); that direction is rejected. This ADR adopts full support for sparse and indirect inner regions in v1.0 via a nested descriptor per region, each carrying its own buffer sub-table.
Constraints in force: streamability (descriptors precede data; self-delimiting; no
back-references; no end-of-file index — README.md, interchange.md); the flat
buffer-table model in which handle properties live in the descriptor and buffer
locations are supplied positionally by the transport (file-format data region walked in
table order at data_buffer_alignment; streaming TENSOR_DATA frames; in-process C ABI);
the uint8 (max 255) buffer-count wire ceiling; the 64-byte minimum per-buffer alignment
and the tensor-wide device-colocation rule (buffer-protocol.md); the ADR-017/019
extensibility and evolvability contracts.
Decision
The general subpaving layout is redefined as a container of nested region descriptors. Each region carries a self-delimiting descriptor body that includes the region's own layout-specific fields, its own buffer sub-table, and an optional per-region quantization section. Any layout tag — dense, sparse, indirect, recursive subpaving, or private extension — MAY be a region layout, because each region declares and owns the buffers its layout requires.
D1 — Region descriptor is a trimmed "descriptor-tail" profile, not a byte-for-byte full TensorDescriptor
A region does not repeat the full tensor-descriptor frame. Fields that a full
TensorDescriptor carries but that would be pure redundancy or a new mismatch failure
mode per region are omitted and inherited from the outer descriptor:
magic,version_major,version_minor— inherited (a nested magic per region would waste 4 bytes/region and add a consistency check).rank— inherited;originandregion_shapeare alreadyuint64[rank].shape— a region's shape is itsregion_shape; there is no separate nested shape field, so the "nested shape MUST equal region_shape" hazard cannot arise by construction.element_type— inherited. A subpaving tensor has exactly one element type in v1.0; per-region element types are out of scope (a region's sparsevaluesbuffer uses the outer element type, index/pointer buffers use their layout-defineduint64, exactly as a top-level sparse tensor).shard,statistics,extension_type— forbidden per region (see D5).
Each region is encoded as a fixed prefix followed by a length-delimited body:
Region prefix:
| origin | uint64[rank] | region start index, inclusive |
| region_shape | uint64[rank] | region extent; every value > 0 |
| region_layout_tag | uint8 | any valid layout tag (0x01–0x0B, 0x40, |
| | | 0xF0–0xFE); MUST NOT be 0x00/0xFF |
| region_flags | uint8 | bit 0 = HAS_REGION_QUANTIZATION; |
| | | bits 1–7 reserved, MUST be 0 |
| _reserved | uint8[2] | MUST be 0x00 |
| region_body_length | uint32 | byte count of the body that follows |
Region body (region_body_length bytes):
| byte_offset | uint64 | per the region layout's own byte_offset |
| | | rule (see D4) |
| layout-specific | variable | fields for region_layout_tag, encoded as |
| fields | | in metadata.md § Layout-Specific Fields, |
| | | tag byte omitted (recursive for 0x06) |
| buffer_count | uint8 | region sub-table size (see D2) |
| buffer_handles | 16 × count | the region's own buffer handles |
| quantization | present iff | uint32 length + quantization_descriptor |
| section | flags bit 0 | bytes (see D5) |
The outer subpaving layout-specific field remains region_count: uint32 (> 0), followed
by region_count region descriptors.
region_body_length generalises ADR-015's region_layout_length: it enables a reader
that does not recognise a region's inner layout to skip the whole region body and continue
parsing subsequent regions (permissive mode); a strict-mode reader MUST reject an
unrecognised region layout tag.
D2 — Buffer binding: outer table empty, effective table is the flattened region sub-tables
The buffer properties of every region live in that region's sub-table inside its body; buffer locations continue to be supplied positionally by the transport. The binding rule:
- The outer subpaving descriptor's top-level buffer table is empty:
buffer_count = 0. This is a deliberate carve-out from the currentmetadata.mdrule "buffer_countMUST be at least 1", which is amended to admit0for tag0x06. - The tensor's effective buffer list is the depth-first, region-order concatenation of
every region's sub-table, recursing into nested subpavings. For each region in region
order: if the region layout is a leaf, emit its sub-table buffers in sub-table order
(layout data buffers first, then quantization-parameter buffers per D5); if the region
is itself a subpaving, recurse. This ordering is fully determined by the descriptor,
contains no back-references, and is the order in which the file-format data region and
streaming
TENSOR_DATAframes lay the buffers down. Streamability is preserved. - Per-region sub-table size is exact. A region's
buffer_countMUST equal the number of buffers its layout requires (dense = 1, COO = 2, CSR/CSC = 3, CSF = 2·rank+1, block-paged ≥ 3, private extension = whatever the region declares) plus the number of quantization-parameter buffers required by the region's active scheme whenHAS_REGION_QUANTIZATIONis set. A reader MUST reject a region whose sub-table is over- or under-supplied (this is F-2's bounds and no-dangling safety rules, reborn per-region).
The 255-buffer ceiling is fully dissolved. The uint8 cap now applies per region
(≤ 255 buffers per region), while region_count is uint32. A subpaving of thousands of
rank-3 CSF regions (7 buffers each) is representable; the effective buffer count is bounded
only by region_count × 255, i.e. effectively unbounded. This resolves the CSF-exhaustion
concern that made the per-region-buffer-list alternative (old Option B) unattractive.
D3 — Any layout tag may be a region layout (resolves F-1, F-4)
Because each region owns its buffers, the ADR-015-era restriction is removed:
region_layout_tag MAY be any valid layout tag — dense (0x01–0x06, 0x40), sparse
(0x07–0x0A), indirect (0x0B), or private extension (0xF0–0xFE) — subject to that
layout's own rank and shape constraints validated against region_shape (e.g. a
block-paged or CSF region forces the whole tensor to the rank that layout requires). A
private-extension region declares its own buffer_count in its sub-table; the sub-table
count is authoritative for extension layouts whose needs are otherwise out-of-band. The
standard private-tag interoperability caveat (no cross-implementation exchange without
out-of-band agreement) applies unchanged (F-4 resolved: permitted, with that caveat).
D4 — byte_offset (resolves F-10)
The outer subpaving descriptor's tensor-level byte_offset MUST be
0x0000000000000000 (there is no single first element at a fixed offset; element [0,…,0]
is located through region lookup). Each region body carries its own byte_offset
governed by that region layout's own rule: for dense region layouts it MAY be non-zero and
MUST be ≤ the region's buffer-0 size; for sparse and indirect region layouts, and for a
nested subpaving region, it MUST be 0, exactly as those layouts require at top level.
D5 — Per-region quantization falls out for free (resolves F-5)
A region MAY carry a quantization section in its body, gated by region_flags bit 0. The
quantization.md § Buffer Table Placement Rules apply within the region's sub-table
unchanged: the region's quantization-parameter buffers occupy the sub-table indices after
its layout data buffers, MUST NOT alias the data buffer, and MUST share the region's
device_tag and memory_class. Heterogeneous per-region quantization (different schemes in
different regions) is therefore expressible in v1.0 at no extra machinery cost, because the
quantization descriptor is carried as opaque length-prefixed bytes (as it already is at the
top level).
HAS_SHARD, HAS_STATISTICS, and HAS_EXTENSION_TYPE are forbidden per region in
v1.0 (region_flags bits 1–7 MUST be 0): a region is not independently a shard of a parent
(the whole subpaving MAY be a shard), per-region statistics are deferred, and element type
is inherited so per-region extension-type descriptors are meaningless.
D6 — Validation set
Coverage and non-overlap are unchanged (they are properties of origin/region_shape
only, independent of region contents; the volume-sum coverage check remains valid). New
normative rules:
- Each region's layout MUST validate against its own
region_shape(validate_against_shapeapplied recursively). - Each region's sub-table
buffer_countMUST exactly equal its layout requirement plus its quantization-parameter requirement (D2). Unconditional MUST-reject on mismatch (memory safety). - Device colocation is tensor-wide over the flattened buffer set. All buffers of all
regions MUST share the same
device_tagandmemory_class(buffer-protocol.md§ Device Colocation, applied to the effective buffer list). A heterogeneous-device subpaving is out of scope in v1.0 and noted as a future open question. - Recursion depth: a subpaving region nested inside a subpaving increments depth; a reader
MUST reject nesting deeper than 8 levels (the existing
MAX_SUBPAVING_DEPTH/MAX_TILED_DEPTHguard, on both encode and decode). region_flagsreserved bits and region_reservedbytes MUST be 0.
D7 — Addressing API is redefined to region-resolution + per-region delegation
A subpaving element address can no longer be a single pure-arithmetic offset, because a
sparse or indirect region's value lookup is data-dependent: locating element [i,j] in
a COO region requires searching that region's indices buffer contents, yielding either
values[p] or an implicit zero — this cannot be expressed as a byte offset without reading
buffer bytes, which the descriptor-only addressing layer does not possess. Therefore:
SubpavingLayout::locate_elementis redefined to resolve the containing region and the local index within it (pure arithmetic, recursing through nested subpavings), returning a handle:{ region_index, local_index, &RegionDescriptor }.- For a dense region, addressing then returns the element's byte offset within the
region's sub-table buffer 0 (via the region layout's existing
element_offset). - For a sparse or indirect region, addressing returns a "requires buffer lookup" result
carrying the region index, local index, and the region's buffer sub-table; the caller (a
higher layer that holds actual buffer memory) performs the value lookup using that
layout's Element Lookup algorithm (
coo.md/csr.md/csc.md/csf.md/block-paged.md).
This is consistent with the existing model: top-level sparse tensors already return
Error::LayoutRequiresMultiBuffer from pure-offset addressing. Subpaving simply delegates
per region. It is nonetheless a genuine public-API change to SubpavingLocation and is
called out as the single largest code cost below.
Alternatives Considered
Dense-only inner-layout whitelist (prior draft's Option A). Restrict region_layout_tag
to dense tags and defer sparse-in-subpaving. Rejected by decision: it forecloses the
heterogeneous-sparsity array-database use case the project explicitly wants in v1.0.
Per-region buffer list (Option B): replace buffer_index with buffer_index_count +
buffer_index[] indexing a single flat outer buffer table. Rejected: the flat outer
table is uint8-counted, so the 255-buffer ceiling caps the whole tensor — a handful of
CSF regions exhaust it. Nesting per-region sub-tables (this ADR) moves the cap per-region
and dissolves it. Option B also still could not carry per-region quantization without
further extension.
Packed single-buffer sub-format (Option C): concatenate a sparse region's component arrays into one buffer slice at computed offsets. Rejected: it re-creates the "single buffer with offsets" approach ADR-002 rejected for sparse — heterogeneous element types in one slice, computed sub-offsets that break the 64-byte per-component alignment guarantee, and loss of independent zero-copy component sharing.
Byte-for-byte full TensorDescriptor per region. Rejected in favour of the trimmed
descriptor-tail profile (D1): repeating magic/version/rank/element_type/shape per region
wastes bytes across potentially millions of regions and manufactures a "MUST equal the
outer value" consistency check for each repeated field. The trimmed profile inherits those
fields and keeps only what genuinely varies per region (layout, byte_offset, buffers,
quantization).
Keep the outer buffer table non-empty as the flattened list. Rejected: it would place
the flattened list back under the uint8 outer count, reinstating the 255 ceiling. The
outer buffer_count = 0 carve-out (D2) is the price of an unbounded effective count.
Consequences
Positive
- Heterogeneous-sparsity tensors — dense tiles, sparse blocks, and paged blocks in one logical tensor — are first-class in v1.0, serving the array-database vision.
- The 255-buffer ceiling is dissolved for subpaving (per-region cap,
uint32region count). - Per-region quantization and per-region layout diversity fall out of one mechanism; F-1, F-2, F-4, F-5, F-10 are all resolved coherently.
- Streamability, self-delimitation, and no-back-reference properties are preserved: the effective buffer order is a pure function of the descriptor, laid down in region order.
Negative / obligations created
- New wire format for regions. ADR-015's
RegionDescriptorencoding (buffer_index+region_byte_offset+region_layout_length+ layout-only payload) is replaced by the descriptor-tail profile. ADR-015 is superseded. Every existing subpaving descriptor byte layout, doc-comment, and round-trip test is invalidated. metadata.mdinvariant relaxed. "buffer_countMUST be at least 1" gains a subpaving exception (0). Every reader that assumes ≥ 1 for all layouts must special-case0x06.TensorDescriptor::new/decode'sEmptyBufferTablecheck must exempt subpaving.- Transport must flatten. The file-format reader/writer, the index
data_lengthcomputation, and the streamingTENSOR_DATAwalk must iterate the effective (flattened) buffer list for subpaving rather than the top-level table, and must handle > 255 effective buffers. - Addressing API redesign (D7):
SubpavingLocationchanges from a pure offset to a region-resolution enum; sparse/indirect regions return a "requires buffer lookup" result. Downstream callers oflocate_elementmust adapt. - Codec layering change.
decode_region/encode_regionmust now encode a buffer sub-table and an optional quantization section inside the layout payload. Buffer-handle codec and quantization-section codec currently live in the descriptor-levelencode.rs/decode.rs; they must be factored into shared helpers thatlayout_codec.rscan call.layout_codecgains a dependency on the buffer-table codec. - Per-region wire overhead. Each region costs
16·rankbytes (origin + region_shape) plus the body; for tensors with millions of regions this is significant. This design trades wire compactness for generality and streamability.
Risks
- Effective-buffer flattening bugs. The flattening order is load-bearing (it defines
the on-disk / on-wire data order). Mitigation: a single normative flattening algorithm in
memory-layout.md, one shared implementation, and round-trip tests through the file format with mixed dense/sparse regions. - Naive reader mis-reads
buffer_count = 0. A reader that does not understand tag0x06sees an empty top-level table; it MUST already reject unknown layout tags in strict mode, and MUST NOT dereference data in permissive mode, so0is safe. - Device-colocation over-restriction. Tensor-wide colocation forbids per-region devices; the array-DB use case may eventually want per-region device placement. Deferred as an OQ, not closed off (an additive relaxation under the evolvability contract).
Compatibility Impact
During the pre-1.0 draft period this redefines the region wire format and relaxes the
buffer_count >= 1 invariant for tag 0x06. No previously interpretable descriptor is
silently changed (sparse/private inner regions were never interpretable). Under ADR-019,
the additive features left open here (per-region statistics, per-region element type,
heterogeneous-device subpaving) arrive later as gated additive changes without rebinding any
1.x value. Supersedes ADR-015.
Date
2026-07-05
ADR-027: Composite Tensors — Head + Members + Composition Rule
Status
Accepted — scope limited to partition, group, and sealed overlay.
Versioned overlay is descoped from v1.0, deferred to a future ADR: member_version,
the 0xFFFFFFFF open-composite sentinel, file append + footer regeneration for overlays,
and time-travel reads. Rationale: that machinery's value is driven almost entirely by the
array-database vision, which is explicitly long-term/not-current-sprint; it introduces
unbounded, indefinitely-open cross-descriptor state (no closing verdict, ever) versus the
bounded, ADR-026-precedented state partition/sealed-overlay already require; and it adds
file-mutation (append + footer regen) and version-monotonicity machinery to Layers 5–8
for a use case not yet on the roadmap. Sealed overlay (definite count, stream-order
precedence, no member_version) ships now — it is cheap and delivers SpQR/KVQuant-style
outlier quantization, an immediate, published use case. See the sections below, each
annotated where content was trimmed accordingly.
Supersedes: ADR-026 (Subpaving Nested Region Descriptors) — see § Consequences Amends the deferral scope of: ADR-010 (Multi-Tensor Collections Deferred)
Amended 2026-07-23 (layout-tag renumber): The composite head's layout tag is reassigned
0x0C→0x0B. The General Subpaving layout (former tag0x06) was retired from v1.0 entirely, and the tags that followed it — COO through Composite — were shifted down by one to keep the Tier-1 named layout range contiguous (0x01–0x0B, no hole), since Hurray is pre-release with no compatibility obligation. The head is therefore now a named Tier-1 tag (0x0B), not a tag borrowed from the reserved range0x0C–0x3F; and tag0x06is now permanently COO's. D1 and § Disposition of ADR-026 below are updated to match. Other in-text references to0x0C(as the head tag) and to "inline0x06" compaction elsewhere in this ADR predate this amendment and are retained as the historical record, superseded by this note.
Context
Three capabilities that Hurray has treated as distinct are, on inspection, one idea seen from three angles:
- Subpaving (layout 0x06, ADR-026): one logical tensor whose index space is a partition of heterogeneous regions, each with its own layout, buffers, and (per ADR-026 D5) quantization.
- Sharding (shard section, ADR-004): a tensor that declares itself a rectangular
sub-region (
shard_offset+shape) of a larger logicalparent_shape. - Tensor grouping (ADR-010, deferred): several tensors delivered together under one logical identity (multi-output inference; weight collections).
ADR-026's design work drove the recognition. To make sparse/paged regions work, ADR-026 had
to (a) let regions carry their own buffer sub-tables, (b) invent a buffer_count = 0 head
carve-out, and (c) forbid or defer per-region statistics, per-region element type, and
per-region device placement (D5). But a region that carries its own layout, buffers, and
quantization is very nearly a full tensor descriptor, and a region positioned in a parent
index space is exactly a shard. If a region simply were a full tensor descriptor with a
shard section, every capability ADR-026 had to hand-build — and several it had to forbid —
would fall out of machinery that already exists (ADR-004 shard section; ordinary member
descriptors carry dtype, layout, buffers, quantization, statistics, and device tags for
free).
Prior art (docs/prior-art.md § 8) confirms two composition models a single-partition
layout cannot span:
- Partition (exact-cover, non-overlap): AMReX/Chombo
DisjointBoxLayout, OpenVDB tiles, HDF5 Virtual Datasets. This is what subpaving implements. - Overlay (overlapping composition: a base spanning the space plus scattered corrections at shared indices): SpQR / KVQuant outlier quantization, and — the case that makes overlay strategically important — TileDB timestamped fragments, i.e. tensor data versioning: region/partial updates and time-travel reads over a fixed index space. This directly serves the array-database vision.
This ADR unifies subpaving, sharding, and grouping under one primitive — the composite tensor — and adds the overlay model. Originally proposed (2026-07-07 Draft) to also specify versioned overlay (partial updates + time travel) as a first-class v1.0 feature; on review (see § Status) that half is descoped to a future ADR, since the array-database use case it serves is explicitly long-term rather than current, and it is the one part of this design that introduces unbounded, file-mutating state. What ships in v1.0 is partition, group, and sealed (non-versioned) overlay.
Constraints preserved: streamability (descriptors precede data; self-delimiting; no back-references; no end-of-file index); zero-copy; 64-byte alignment; language-agnostic naming; RFC 2119; the ADR-017/019 extensibility and evolvability contracts (for future, post-1.0 additions — everything in this ADR's v1.0 scope ships in the initial v1.0 format, as the format is pre-release).
Decision
A composite tensor is a head descriptor plus an ordered set of member tensors, combined by a declared composition rule.
D1 — The head is a virtual (data-less) tensor descriptor under layout tag 0x0B
Amended 2026-07-23: head tag
0x0C→0x0B(see § Status).
The head is an ordinary tensor descriptor with layout_tag = 0x0B ("Composite / Virtual",
Tier 1, a new addressing category Virtual alongside Dense / Sparse / Indirect). It:
- carries the composite's logical shape (
shape) and logical element type (type_tag) — the view the composite presents to a consumer; - owns no data:
buffer_countMUST be0andbyte_offsetMUST be0.
Tag 0x0B is a named Tier-1 layout tag, not one borrowed from the reserved range: it
was assigned when the General Subpaving layout was retired from v1.0 and COO through Composite
shifted down by one to close the gap (see § Status). Because Hurray is pre-release, it is
allocated as part of the initial v1.0 format (no version-increment ceremony). A strict reader
rejects an unrecognised 0x0B; a permissive reader may read the head's shape and dtype but
MUST NOT dereference data (there is none).
The head's layout-specific fields encode the composition rule:
| Field | Type | Description |
|---|---|---|
composition_rule | uint8 | 0x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF reserved; 0xF0–0xFE private; 0xFF invalid. |
combine_op | uint8 | Overlay only: 0x01 replace (last-wins), 0x02 add. MUST be 0x00 for partition and group. |
_reserved | uint8[2] | MUST be 0x00. |
member_count | uint32 | Number of member tensors that immediately follow. v1.0: MUST be a definite count for all composition rules, including overlay. The sentinel 0xFFFFFFFF (an open composite, see D3) is RESERVED — a strict v1.0 reader MUST reject it; open composites are deferred to a future ADR. |
D2 — Members are ordinary tensor descriptors positioned by the shard section
A member is a complete, ordinary TensorDescriptor (its own layout — dense, sparse, paged,
or a nested composite — its own buffers, quantization, statistics, device tags). For
partition and overlay composites, each member MUST carry a shard section
(ADR-004; metadata.md § Shard Section) whose parent_shape equals the head's logical
shape; the member's shard_offset and its own shape define its box in the head's index
space. For group composites, members MAY omit the shard section.
Overlay members additionally carry a Composite Member section (a new optional descriptor
section gated by descriptor flag bit 4, HAS_COMPOSITE_MEMBER, appended after the Extension
Type section):
| Field | Type | Description |
|---|---|---|
member_role | uint8 | 0x00 correction, 0x01 base. 0x02–0xFF reserved (see § Deferred: tombstones). |
_reserved | uint8[15] | MUST be 0x00. |
v1.0 carries member_role only. member_version is deferred: a sealed overlay's
precedence is plain stream/emission order (last-wins under combine_op = replace), so no
explicit version field is needed until versioned overlay (time-travel) is taken up. The
reserved padding leaves room for a future ADR to add member_version as an additive field
under the ADR-017/019 evolvability contract, without reallocating the section.
Partition and group members do not carry this section. This is the crux of the unification: a region ≡ a shard ≡ a member. Per-member layout, buffers, quantization, statistics, and device placement all come from the ordinary descriptor machinery — including the three things ADR-026 D5 had to forbid or defer.
Plain sharding (ADR-004 / interchange parallel transfer) is the status quo: members without a head. The head upgrades an ephemeral shard set into a persistent, composition-typed collection.
D3 — Binding: forward stream adjacency, no new namespace, all v1.0 composites definite-count
A head with a definite member_count = N binds the next N self-delimiting tensors in
stream / file write order as its members. This is a forward promise (head precedes
members precede their data), not a back-reference, so it is streamable for readers and
writers and introduces no name namespace. It works uniformly across transports:
- In-process: the head handle plus an array of member handles (in-memory; no wire concern).
- IPC / network streaming: the head's
TENSOR_DESCRIPTOR, then each member'sTENSOR_DESCRIPTOR→TENSOR_DATA→TENSOR_DATA_END. - File: the head, then the members' descriptors+data, written contiguously in the tensor
region; every tensor gets a footer-index entry; membership is recovered from the head's
member_countplus descriptor-offset order (preserved regardless ofSORTED_INDEX).
Open composites — deferred. The 0xFFFFFFFF open-composite sentinel and its
append-oriented membership-delimitation rules (maximal run of Composite-Member-tagged
tensors; file tensor-region delimitation between heads) are reserved for a future ADR
(versioned overlay). All v1.0 composites — partition, group, and overlay — use a
definite member_count, bound uniformly by the forward-adjacency rule above.
Nested composites are permitted for definite-count composites (pre-order parse, depth cap 8). Explicit member identifiers for out-of-order random access are not defined in this version (see Deferred).
D4 — Composition semantics
Partition (0x01). Members' shard boxes MUST exactly cover the head's index space with no
overlap (ADR-026 D6 validation, evaluated across members). Each logical index belongs to
exactly one member, so the composite view is zero-copy: value lookup = box selection +
that member's own addressing. This is subpaving semantics as a collection.
Overlay (0x02, v1.0: sealed only). One member is the base (member_role = 0x01,
the first member, box spanning the whole space); the rest are corrections whose boxes
MAY overlap. See D6 for precedence and reads. Per-member storage is zero-copy; the merged
logical view is computed by the consumer (exactly as SpQR/KVQuant-aware and array-DB
kernels operate).
Group (0x03). Unordered, no spatial semantics; members are independent tensors under one
head identity (multi-output inference). The head's shape/type_tag are advisory; members
MAY differ arbitrarily. This occupies ADR-010's grouping gap using adjacency, not naming.
Element type across members. For partition and overlay, each member MAY declare a
different stored element type and its own quantization, but each member's decoded
value type MUST equal the head's type_tag. Example (SpQR): head type_tag = float16; base
= int4 + per-block quantization → decodes to float16; outlier correction = float16
sparse (COO). Overlay combine happens in float16. Dequantization already yields a canonical
real-valued view, so this needs no new machinery. Composite versioning changes values over
a fixed index space; shape evolution is out of scope.
D6 — Sealed overlay: precedence, combine, and reads (v1.0)
Precedence (single-pass friendly, no explicit version). Precedence is emission/stream
order — later wins: writers emit the base first, then corrections in the order they take
effect. A single-pass reader applies members as they arrive; no version field or reordering
is needed. (A future ADR may reintroduce an explicit member_version for time-travel — see
Status.)
Reads. Apply the base, then all corrections in emission order, under combine_op
(replace: the topmost member covering an index wins within its box; add: base plus the sum
of covering corrections), evaluated in the head's element type. A correction's box
replaces/adds within its box only; outside it, lower-precedence members (down to the base)
show through.
combine_op for v1.0. Both 0x01 replace and 0x02 add are defined. Replace is the
default (a region overwrite); add serves residual/outlier overlays (e.g. SpQR/KVQuant).
Sealed only. A v1.0 overlay is a complete snapshot: definite member_count, not
appendable without rewriting the head (D3). Versioned/appendable overlay is deferred.
Deletes. Logical delete (reverting a region to base, or masking it) is out of scope for
v1.0, stated explicitly. A region is reverted by writing a new correction carrying the
desired values. A tombstone member kind (member_role = 0x02, data-less, revert-to-base-
within-box) is reserved for a future addition (the role byte is already present).
D5 — Validation: cross-member, stateful, bounded
Per-member checks are immediate: shard parent_shape == head shape; box in bounds; decoded
dtype == head type_tag; combine_op legal for the rule; for overlay, the Composite Member
section is present with a valid member_role; the first overlay member is the base
(role = 0x01) and spans the index space.
Close-time checks depend on the rule:
- Partition (definite count): on receiving the Nth member, run exact-cover + non-overlap over the N boxes (ADR-026 D6: volume-sum + sweep/pairwise). Overlap or a gap → reject.
- Sealed overlay (definite count): close at the Nth member; base-spans is checked at the first member; overlap is legal; no exact-cover.
- Group (definite count): close at the Nth member; no coverage or overlap check (members MAY differ arbitrarily, D4).
All v1.0 composition rules use a definite count, so all validation is bounded: a reader accumulates state only up to the known N, reaches one verdict at the Nth member, and is done. (Open, unbounded, every-prefix-valid overlay validation is deferred — see Status.)
Failure semantics: a per-member violation MUST cause rejection of the composite (network
stream: ERROR + close). A torn definite-count composite (stream ends before N members,
of any composition rule) is incomplete: a strict reader MUST reject it; a permissive reader
MAY expose the arrived members as independent shard tensors but MUST NOT present the
composite as complete.
Alternatives Considered
Head via a HAS_COMPOSITION flag on an ordinary descriptor (no new tag). Rejected: the
head has no data, so any real layout_tag would misdescribe it; a Virtual tag 0x0C with
buffer_count = 0 is honest and reuses the descriptor frame.
A new message/container kind outside the tensor descriptor. Rejected: breaks the
"everything is a self-delimiting tensor descriptor" uniformity and forces new framing in
interchange.md and file-format.md.
Explicit group IDs (a group_id namespace) as the binding. Rejected for v1:
reintroduces the namespace ADR-010 warned against; forward adjacency binds streamably without
it. Left as a Deferred item for out-of-order random access.
Wall-clock timestamps as the version axis (TileDB-style). Considered for a future versioned-overlay ADR; not part of v1.0 (no version axis ships at all — see Status). A writer-controlled logical sequence number would be simpler, reproducible, and skew-free than wall-clock time if/when versioning is taken up; wall-clock time, if needed, would go in KV metadata.
Explicit member_version for v1.0 (implicit version = position, rejected in the other
direction). Considered and descoped: an explicit version field would give a stable,
addressable time-travel key, but time-travel is not a v1.0 feature, so the field would be
dead weight. v1.0 uses plain emission order (D6). Left as future work alongside the open
sentinel.
Sealed fixed-count composites only, with versioning via external file conventions. This is effectively what v1.0 ships (sealed-only overlay, no version axis). A true append-only version log (open sentinel, immutable head, footer-regeneration append) remains the better design if and when versioning is taken up — descoped here, not rejected outright.
Keep ADR-026's trimmed "descriptor-tail" region profile for inline 0x06. Rejected in favour of a region being a full nested descriptor with a shard section, so inline-region ≡ member exactly.
Leave subpaving (ADR-026) and composites as two mechanisms (composites in 1.1). Rejected: two overlapping mechanisms would diverge; the value here is unification.
Consequences
Disposition of ADR-026
ADR-026 is marked Superseded by ADR-027. Its durable insights survive and are generalised: nested descriptors, the data-less head, and full per-region capability. What changes:
- The bespoke ADR-026 D1 "trimmed region tail" wire is dropped. A partition composite's
regions are members = full tensor descriptors with shard sections (the "uniform
full-descriptor variant"). This removes the trimmed-profile consistency rules, the bespoke
addressing-API redesign, and the
layout_codec→buffer/quant relayering ADR-026 required. - ADR-026 D5's forbidden/deferred items (per-region statistics, per-region element type, per-region device) become supported for free (a member is an ordinary descriptor).
- Inline subpaving compaction is dropped, and tag
0x06is permanently reassigned to COO (amended 2026-07-23; see § Status). ADR-026's inline single-frame compaction of a partition composite is no longer associated with tag0x06and is not available for any future subpaving-compaction use; reviving that idea would require a fresh layout tag allocated from the reserved range0x0C–0x3F. The region ↔ member equivalence it relied on (inline region origin ↔ membershard_offset; region within headshape↔ memberparent_shape) survives conceptually, but the collection form (members) is the v1.0 deliverable for partition.
Net: ADR-027's core is smaller than ADR-026's would have been (reuses the shard section and ordinary members), at the cost of a persistent grouping concept threaded through the transport layers (below).
Reopening ADR-010 (addressed head-on)
ADR-010 deferred a named/indexed archive container; ADR-027 is a streamable composition
primitive, not that. Against ADR-010's four deferral reasons: (1) no string names/uniqueness
policy — binding is member_count + adjacency; (2) no general namespace — a composite is a
flat, bounded, single-level (optionally nested, depth-capped) grouping tied to one head; (3)
the header/footer-index-vs-streamability tension — resolved by forward adjacency (no header
index, no footer index, no back-reference); (4) no KV-metadata pressure — composites carry
composition structure, not arbitrary metadata. ADR-010's core decision (no named
hurray-archive in v1) stands; ADR-027 fills only the grouping gap, by composition.
Scope, layers touched, and schedule cost (honest)
Lands in v1.0 (spec + hurray-core): the 0x0C head descriptor; the composition-rule
payload codec; the HAS_COMPOSITE_MEMBER section and its codec (member_role only);
shard-based member positioning (shard section already exists); the cross-member stateful
validator (a CompositeValidator accumulating member boxes, bounded to a definite count);
and the sealed-overlay read model (current view only) as a specified semantic (the merge
itself is a consumer concern). This is a bounded addition to the Layer-4 core; it does
not materially delay Layers 0–4.
Staged with their layers (not pulled forward):
- Layer 5 (streaming): head→member adjacency; composite "close" (definite counts) on the
Nth
TENSOR_DATA_END; reuse of shard-consistency validation. - Layer 6 (file): head + members as consecutive index entries.
- Layer 7 (FFI): a composite handle kind (head handle + member iterator).
- Layer 8 (Python): a
CompositeTensorview;__dlpack__per member; overlay current view materialised on demand.
Deferred (Open Questions): versioned/open overlay in full — member_version, the
0xFFFFFFFF sentinel, append + footer-regeneration, time-travel reads (see Status; the
primary deferral); explicit member IDs / out-of-order random access; wall-clock timestamp
versioning; tombstone / logical-delete (member_role = 0x02); inline 0x06 compaction;
nested composites inside open overlays; heterogeneous per-member device placement.
Schedule statement: ADR-027 replaces (does not add to) the ADR-026 partition implementation budget and is smaller there. With versioned overlay descoped, it introduces a persistent grouping concept (bounded, definite-count binding + close-time validation) across Layers 5–8, but not the open-ended file append/footer-regeneration or version-cutoff read paths — those are deferred with the feature that needed them. The v1.0 transport/binding cost is therefore comparable in shape to partition's, not materially larger.
Positive
- One primitive unifies subpaving, sharding, and grouping; overlay (SpQR/KVQuant) is first-class as a sealed snapshot.
- Per-member dtype/layout/quantization/statistics/device come from existing machinery.
- Streamable (forward adjacency), zero-copy per member, no new namespace.
- Bounded, definite-count validation for every v1.0 composition rule (D5) — no open-ended state, no file-mutation story, kept out of v1.0 until actually needed.
Negative / obligations
- A persistent, cross-descriptor, stateful validation + framing concept (head→member binding; partition's coverage check; sealed-overlay's base-span check) is new to Layers 5–8, though bounded to a definite count in every case.
- Overlay's merged view is consumer-computed, not zero-copy at the composite level.
Risks
- Overlay misread as zero-copy — mitigated by the explicit "structure described, merge computed" statement (D4/D6).
- Scope creep into a general versioned DB — mitigated directly by descoping the version axis, the open sentinel, and file append/footer-regeneration from v1.0 entirely (see Status), not merely by scoping out deletes and wall-clock timestamps as before.
- Deferred work resurfaces as a rushed addition later — versioned overlay is real, array-DB-vision-serving work, not abandoned; when it's picked up it should get its own ADR, research pass, and spec-checker audit rather than being reconstituted ad hoc.
Compatibility Impact
Hurray is pre-release; the scope of this ADR that ships within the initial v1.0
format is: tag 0x0C, descriptor flag bit 4 (HAS_COMPOSITE_MEMBER) and its section
(member_role only), the buffer_count = 0 head rule, and combine_op — for partition,
group, and sealed overlay only. No minor-version increment is involved. The
0xFFFFFFFF open-composite sentinel and member_version are RESERVED, not usable in
v1.0, and are deferred to a future ADR alongside the rest of the Deferred list (member
IDs, tombstones, inline 0x06, wall-clock timestamps) — each an additive minor under the
ADR-017/019 evolvability contract that rebinds no v1.0 value. Supersedes ADR-026; leaves
ADR-004 and ADR-010's core decisions intact.
Date
2026-07-07 (Draft); scope narrowed and Accepted 2026-07-23 (versioned overlay descoped — see § Status)
ADR-028: Documentation Website — mdBook + Zola, Per-Tag Versioning, GitHub Pages
Status
Accepted.
Context
Hurray needs a public HTML site, in the mould of arrow.apache.org: a mostly-technical
site targeting reference-implementation users, format implementers, and ML/inference
engineers. It must carry two kinds of content:
- Versioned technical documentation — the format specification (
docs/spec/), implementation requirements (docs/impl/), cookbook (docs/cookbook/), tutorials, and the Rust API reference (cargo doc) for thehurray-*crates. - An unversioned outer site — landing/overview, FAQ, a blog (posts by core contributors), and a community section (contributing guidelines, code of conduct, governance, mailing lists).
Confirmed requirements from the project owner:
- Versioning is by spec version, which git tags/releases follow, and the full history must be browsable — not just the two or three most recent versions.
- Fully automated in CI, and simple. Content must be easy to reorganize and extend.
- GitHub Pages initially; a custom domain will come later (none is owned yet); the host may change — the pipeline must stay portable.
- Default landing on the latest stable release, with the unreleased development version reachable but clearly marked.
- Per-version search is sufficient for v1.
- The Rust API reference is in scope and published alongside each version.
The project is a Rust workspace with a strong "single binary, no supply chain" ethos.
At the time of writing there are zero release tags and the spec is 0.1.0-draft.
Decision
Build the site from two Rust single-binary generators, deployed together to GitHub Pages as one static tree:
- mdBook renders the versioned technical book (spec + impl + cookbook + tutorials).
The book is a view over the existing in-repo Markdown (authored in place under
docs/), curated by a maintainedSUMMARY.md, so reorganizing content stays a Markdown-and-table-of-contents edit. - Zola renders the unversioned outer shell (landing, FAQ, blog, community). Markdown plus a folder-per-section layout keeps adding content trivial.
Versioning strategy — build per git tag. CI reconstructs the full multi-version tree
on every deploy: for each release tag it checks out that tag and builds its book and API
reference into a version-scoped path; main is additionally built as the dev version.
A generated versions.json manifest drives a version-selector dropdown injected into the
book. This maps one-to-one onto "spec versions which git tags follow, full history," keeps
main free of frozen doc snapshots, and renders every version faithfully from its own tag.
Rust API reference is produced by cargo doc per version and published under that
version's path, linked from the book's navigation.
Hosting is GitHub Pages via GitHub Actions. Because the generators emit a plain static tree and nothing depends on Pages-specific features, the same artifact deploys to any static host later.
Concrete URL scheme, directory layout, CI pipeline stages, the versions.json schema, and
the content model are specified in Documentation Website Spec.
Alternatives Considered
-
Docusaurus (single tool for everything). Docs + native versioning + blog + search + landing under one theme is the most turnkey option and was the strongest single-tool candidate. Rejected as the default for two reasons: (1) it requires a Node/npm toolchain, against the project's single-binary, minimal-supply-chain ethos; (2) its versioning snapshots docs into the main branch (
versioned_docs/), which bloatsmainas history grows and does not build from tags — a poor fit for "versions are git tags, full history." It remains the fallback if a single unified theme ever outweighs tag-faithful history. -
Starlight (Astro). Similar unified-theme appeal, but still a Node toolchain, and its versioning is a third-party plugin that is also snapshot-based. No advantage over Docusaurus for our constraints.
-
mdBook only, minimal (defer blog/community/FAQ). Simplest, but does not deliver the Arrow-style outer site the owner asked for. Rejected as under-scoped.
-
Zola only (book as a Zola section). One tool, single binary, but re-implements the book's sidebar/nav/search UX that mdBook gives for free and would require fully custom versioning. More work for a worse book experience.
-
Snapshot-in-repo versioning (regardless of tool). Copy current docs into a per-version folder committed to
mainon each release. Rejected: bloatsmain, makes historical edits awkward, and is less faithful than rebuilding from the tag itself. -
Incremental version builds (only build the new/changed version each deploy). Faster than full rebuild-from-tags as history grows, but stateful (must carry prior builds forward). Deferred as a future optimization; v1 rebuilds all versions each deploy for a stateless, simpler pipeline.
Consequences
-
Two themes to keep visually consistent. The mdBook book and the Zola shell are themed independently; a modest, ongoing investment is needed to keep them coherent. Apache Arrow accepts the same split (Jekyll site + Sphinx docs); this is a known, tolerable trade-off.
-
Build time grows with tag count. Rebuilding every tag's book and
cargo docon each deploy is O(number of releases). Acceptable at current scale; the incremental-build optimization above is the escape hatch when it hurts. -
No stable version until the first tag. With zero release tags today, only
devexists initially;/docs/stable/and the default "Docs" entry point MUST fall back todevuntil the first release tag is cut. The spec defines this fallback. -
Search is per-version. mdBook's built-in search covers a single book; there is no cross-version or whole-site search in v1. Revisit if users need it.
-
A new authoring surface appears:
docs/tutorials/and a maintained bookSUMMARY.md. The ADRs (docs/adr/) anddocs/prior-art.mdare published as a book appendix in v1. Release tags useMAJOR.MINOR.PATCHwith no leadingv. The doc-site spec owns these conventions. -
New CI responsibility. A GitHub Actions workflow builds and deploys the whole tree; it needs full git history/tags (
fetch-depth: 0) and Pages deploy permissions. -
Relationship to the array-database vision. A browsable full-history spec site is directly useful to the long-term versioned-data direction; nothing here forecloses it.
ADR-029: hurray-python is interchange-first — drop the Array API conformance claim
Status
Accepted (2026-08-06), implemented 2026-08-07 (PRs #128, #129, #130)
Supersedes ADR-022 (hurray-python Runtime Compliance Modes).
Context
hurray-python currently declares itself a strict reference implementation of the
Python Array API Standard for Tier 1 element types (see docs/impl/python-bindings.md
and ADR-022). A review of the actual surface and of the standard's conformance model
shows this claim is not tenable and does not serve the format's purpose:
-
The claim is unmet.
hurray-pythonimplements the Array API's creation and inspection surface (and returns thehurraymodule from__array_namespace__advertising version2025.12), but implements none of the mandatory compute core — no elementwise functions, reductions, manipulation, linear algebra, searching/sorting/set functions, indexing (__getitem__), or operator dunders. A consumer that obtains the namespace via__array_namespace__()and calls, e.g.,xp.reshape,xp.sum,xp.matmul, or usesx[0], fails. -
Partial implementation is only sanctioned along designated seams. The standard permits omitting the optional extensions (
linalg,fft— "Each array library supporting this standard may, but is not required to, implement an extension") and negotiating capabilities/dtypes/devices (__array_namespace_info__().capabilities()). It does not sanction dropping the mandatory core while still presenting a conforming namespace. Conformance is defined operationally by thearray-api-testssuite, which exercises the full specified surface. -
Exposing
__array_namespace__without the core is actively harmful. Array-agnostic consumers (scikit-learn, SciPy's array-api support, einops, …) treat__array_namespace__as the promise that the full core exists; ahurray.Tensorbreaks them at runtime rather than being cleanly rejected. -
Compute is not hurray's purpose.
hurray-pythonis the Python face of the Hurray interchange format — a codec and zero-copy bridge (produce / consume / hand off), in the same spirit as the Python packages of other data formats. It is not, and should not become, a numerical library. Numerical work belongs to the frameworks the buffer is handed to (NumPy, PyTorch, JAX, …). -
DLPack is not the Array API. DLPack is an independent, header-only ABI + PyCapsule protocol that the Array API merely adopts.
from_dlpackworks on any object exposing__dlpack__without requiring__array_namespace__. The valuable zero-copy interop hook and the conformance claim were never coupled; dropping the claim does not cost us DLPack reach. -
The runtime modes exist only to serve the claim. ADR-022's strict/relaxed modes (
set_strict/is_strict/strict/relaxed,modes.rs) exist for the sole purpose of gating__array_namespace__visibility by dtype tier (Tier 1 vs Tier 2 / quantized). With the claim removed, they gate nothing.
Decision
hurray-python is positioned as interchange-first: a codec and zero-copy bridge for
the Hurray format, not an Array API implementation.
-
Drop the Array API conformance claim.
hurray-pythonMUST NOT describe itself as an Array API implementation, reference implementation, or conforming namespace. -
Remove
__array_namespace__.hurray.TensorMUST NOT implement__array_namespace__(for any dtype tier). Thehurraymodule is not an Array API namespace. -
Remove the runtime compliance modes.
set_strict,is_strict,strict,relaxed,StrictCtx,RelaxedCtx, and the_strict_modecarrier are removed. ADR-022 is superseded. (Pre-1.0, no compatibility guarantee applies; seedocs/spec/versioning.md.) -
Keep the interchange and producer/consumer surface, which never depended on the claim:
- Zero-copy interop protocols:
__dlpack__/__dlpack_device__,__array__/__array_interface__, and the native__hurray_buffer__/from_hurray_buffer. - Structural/inspection surface on
Tensor:shape,dtype,device,ndim,size,T. - Construction and ingest:
zeros/ones/full/empty(+_like),arange/linspace/eye,asarray,from_dlpack,from_numpy,from_torch,to_torch,from_scipy,save/load. These are framed as standalone interop protocols, not as Array API surface.
- Zero-copy interop protocols:
-
Dtype identity. Tier 1 and Tier 2 / quantized dtypes remain first-class
hurray.dtype.*objects. They are no longer described in terms of "Array API dtype" mapping; the NumPy/DLPack dtype correspondence is documented purely as an interop detail (what a given Hurray type becomes when handed to NumPy/PyTorch, and which types cannot cross a given bridge — e.g.boolover DLPack). -
Validation. The conformance anchor for
hurray-pythonis the shared golden test-vector corpus (conformance/vectors/, cross-checked Rust ↔ Python) plus the binding's own unit/integration tests.array-api-testsis not used — it targets a whole conforming namespace and presupposes the compute core, which is the wrong shape for a producer/consumer-only surface.
Consequences
Positive
- The binding's advertised behaviour matches its actual behaviour; no consumer is
misled by
__array_namespace__. - Smaller, more coherent surface; the modes machinery and its thread-safety caveats disappear.
- DLPack /
__array__reach to NumPy/PyTorch/JAX/CuPy is fully retained. - Positioning is honest and defensible: "Array-API-interoperable (via DLPack), not Array-API-implementing."
Negative / cost
- User-facing API removal:
__array_namespace__and theset_strict/relaxedfamily are gone. Acceptable pre-1.0 (no compatibility guarantee), but must be called out in the changelog. - A consumer that wants an Array API namespace from Hurray data must first hand the
buffer to a real backend (zero-copy), e.g.
xp = array_namespace(np.from_dlpack(t)). This is documented as the recommended pattern.
Follow-up work (sequenced; each user-approved)
- This ADR.
- Spec / impl docs:
docs/impl/python-bindings.md(rewrite the normative Array API sections),docs/impl/README.md,docs/spec/element-types.mdanddocs/spec/README.md(reframe Tier-1 "Array API dtype" language as interop),docs/SUMMARY.md. - Code: remove
__array_namespace__andmodes.rs; keep interop; drop dependent tests. - Cookbook / examples: retire or rewrite
docs/cookbook/hurray-python-array-api.mdanddocs/cookbook/hurray-python-runtime-modes.md, and theexamples/array_api.pyexample; refreshhurray-python/COMPAT-MATRIX.md.
Notes (non-normative)
The docs/impl/python-bindings.md Rationale section (added 2026-08-06) already
states the interchange-first motivation; this ADR makes it normative and reconciles the
surrounding requirements.
ADR-030: Multi-buffer native buffer protocol — one capsule carrying a HurrayBufferList
Status
Accepted (2026-08-11), implemented 2026-08-15 (PRs #150, #151, #153)
Resolves the Multi-buffer tensors open question deferred in ADR-023
(hurray-python Native Buffer Interchange Protocol) and supersedes its initial
recommendation of a separate __hurray_sparse_buffer__ protocol.
Context
hurray-python can hold, transport, save, and load exactly one buffer per tensor,
while the format, hurray-core, and hurray-io are all multi-buffer. Every format
feature whose descriptor references a second buffer is therefore unreachable from
Python (issue #146):
| Path | Current limit |
|---|---|
__hurray_buffer__ capsule | one data_ptr + byte_size |
hurray.save() | passes exactly one buffer to FileWriter::write_tensor |
hurray.load() | raises UnsupportedError on any tensor with more than one buffer |
SparseTensor | does not implement __hurray_buffer__ at all |
hurray_io::FileWriter::write_tensor already accepts a slice of buffers; only the
Python binding narrows it.
Three observations drive this decision.
-
The descriptor channel is already lossless. The capsule context carries the encoded
TensorDescriptor, andfrom_hurray_bufferreconstructs it withTensorDescriptor::decode. Quantization, statistics, shard, and extension type all survive the hop today. The gap is the buffer channel alone. -
A single-buffer transport turns a valid descriptor into silent corruption. A
PerChannelAffine,Nf4, orMxfpdescriptor references ascale_buffer_index. Such a descriptor encodes and decodes perfectly well, so a consumer would receive a descriptor pointing at a scale buffer that was never transported — a dangling buffer index rather than a clean error. -
Multi-buffer is not a synonym for sparse. ADR-023's initial recommendation (restrict
__hurray_buffer__to dense tensors; add__hurray_sparse_buffer__later) assumed the two coincide. They do not: a dense per-channel-quantized tensor is multi-buffer, as are block-paged and composite tensors. A sparse-specific protocol would solve the narrower half of the problem and leave quantization unreachable.
This also stands against the standing requirement (issue #147) that hurray-python
must fully expose what hurray-core and hurray-io can express.
Decision
1. One capsule carries every buffer
__hurray_buffer__() MUST return a single PyCapsule carrying all of the tensor's
buffers. Capsule names are unchanged: "hurray_buffer" on creation and
"used_hurray_buffer" after consumption. There is one deleter and one lifetime,
exactly as in ADR-023 § 5.
A single-buffer tensor is the N = 1 case of this protocol, not a separate path.
2. The capsule pointer is a HurrayBufferList
Amended by ADR-043: the capsule pointer is a
HurrayTensor *, which owns the buffer list. What this section was protecting survives — a C consumer still reaches every buffer throughhurray-ffiaccessors without linking PyO3, now viahurray_tensor_buffers— and §§ 1 and 3 stand unchanged. The original text follows.
hurray-ffi MUST gain an opaque HurrayBufferList handle holding an ordered
collection of HurrayBuffer handles, with C accessors:
| Function | Purpose |
|---|---|
hurray_buffer_list_len | number of buffers in the list |
hurray_buffer_list_get | borrow the HurrayBuffer at index i |
hurray_buffer_list_destroy | destroy the list and every handle it owns |
The capsule pointer MUST be *mut HurrayBufferList. This preserves ADR-023's decision
D-NB1 — a C consumer can call hurray-ffi accessors on the capsule pointer without
linking PyO3 — while extending it from one buffer to N.
hurray_buffer_list_get MUST return a borrowed handle: ownership stays with the
list, and the consumer MUST NOT call hurray_buffer_destroy on it. Destroying the list
destroys every handle it owns, exactly once.
3. Buffer order is descriptor order
Element i of the list MUST be the buffer at index i of the descriptor's buffer
table. Every buffer index appearing in a quantization descriptor
(scale_buffer_index, zero_point_buffer_index), a layout descriptor, or a composite
member therefore indexes the list directly.
A consumer MUST reject a capsule whose list length does not match the descriptor's
buffer count, and MUST validate that every referenced buffer index resolves — the
check hurray_core::validate_buffer_placement performs.
4. C ABI version becomes 3
HURRAY_C_ABI_VERSION MUST be raised from 2 to 3. The capsule context MUST carry
the producer's version, and the consumer MUST verify it before dereferencing the
pointer, as already required by ADR-023 § 8. A consumer built against version 2 that
receives a version 3 capsule raises hurray.UnsupportedError rather than
misinterpreting a HurrayBufferList as a HurrayBuffer.
Pre-1.0, no compatibility guarantee applies (see docs/spec/versioning.md); the
version check exists so the mismatch is diagnosed rather than dereferenced.
5. __hurray_sparse_buffer__ is not introduced
Amended by ADR-033: the one probe is now
hasattr(obj, '__hurray__'). One protocol, one probe — only its spelling changed.
The separate sparse protocol floated in ADR-023 is superseded. SparseTensor MUST
implement __hurray_buffer__ using this protocol, with its values and index buffers in
descriptor order. Consumers discover support exactly as before, via
hasattr(obj, '__hurray_buffer__') (ADR-023 § 7) — one protocol, one probe.
6. File I/O accepts multi-buffer tensors
hurray.save() MUST pass every buffer of a tensor to FileWriter::write_tensor, and
hurray.load() MUST accept multi-buffer tensors — the current buffer-count rejection is
removed. load() MUST validate buffer placement before handing a tensor to the caller.
7. Spec placement is unchanged
ADR-023 § 4 stands: the native buffer protocol remains documented only in
docs/impl/python-bindings.md and MUST NOT appear under docs/spec/. This decision
changes a Python-side transport and adds a C ABI type; it does not touch the wire
format, the binary descriptor encoding, or any layout definition. The
HurrayBufferList type is documented in docs/impl/c-ffi.md, which is authoritative
for the C ABI.
Alternatives Considered
A tuple of capsules, one per buffer. Rejected. It admits partially-consumed states where some buffers have been taken and others not, multiplies the deleter logic by N, and forces the encoded descriptor to ride in one arbitrarily-chosen capsule, making that capsule privileged and the others meaningless on their own.
Keep the capsule pointer as *mut HurrayBuffer (buffer 0) and hide buffers 1..N in
the capsule context. Rejected. It is source-compatible for existing consumers, which
is precisely the danger: a C consumer that reads only the capsule pointer — the access
pattern D-NB1 exists to support — silently sees a one-buffer tensor and reads a
quantized tensor as if it were unquantized. A silent wrong answer is worse than an ABI
bump.
__hurray_sparse_buffer__ as ADR-023 initially recommended. Rejected, per Context
§ 3: it conflates multi-buffer with sparse, leaves dense quantized tensors unreachable,
and forces consumers to probe for two protocols and reconcile their semantics.
Widen HurrayBuffer itself to hold N allocations. Rejected. HurrayBuffer maps to
one allocation with one release callback, and each buffer of a tensor declares its own
byte_size, alignment, and sync_mode. Collapsing them into one handle would lose
that per-buffer metadata and, more importantly, the per-buffer release callback that
lets buffers with independent owners be freed independently. (device_tag and
memory_class are not per-buffer in practice: buffer-protocol.md
§ Device Colocation requires every buffer of one descriptor to share both.)
Require the producer to slice one contiguous allocation into N sub-buffers. Rejected as a requirement, though it remains permitted as a producer strategy — see § Notes below.
Consequences
Positive
- Quantization beyond per-tensor affine becomes expressible from Python, unblocking the descriptor-authoring work in issue #146.
- Sparse file I/O stops being rejected on buffer count.
- Block-paged and composite tensors gain a transport path for the same reason.
- One protocol and one probe for every tensor kind, dense or sparse.
Negative
- An ABI bump: any existing C consumer of the capsule must be updated. Pre-1.0 this costs nothing externally, but it is a real change to a published handle shape.
HurrayBufferListis a new owning type whose lifetime discipline must be exactly right — the borrowed-handle rule in § 2 is the part most likely to be misused.hurray.Tensorgrows from oneBufferStoreto a collection, touching every construction path in the bindings.
Required Documentation Amendments
docs/impl/python-bindings.md— the native buffer protocol section: one capsule, N buffers, descriptor order, list-length validation, andSparseTensorsupport.docs/impl/c-ffi.md—HurrayBufferListand its three accessors; ABI version 3.docs/adr/ADR-023-*.md— mark the Multi-buffer tensors open question resolved by this ADR.
No amendments under docs/spec/ are required.
Open Questions Deferred
- Cross-process (IPC) variant. Unchanged from ADR-023: a capsule only works in-process. Deferred.
- Async counterpart. Unchanged from ADR-023: no
__ahurray_buffer__;sync_modealready carries the per-buffer synchronisation contract. Deferred. - Per-buffer
streamhandling. ADR-023 § 6 definesstreamfor a single buffer. Buffers of one descriptor always share a device and memory class (buffer-protocol.md§ Device Colocation), butsync_modeis not covered by that rule and may differ between them. Whether astreamargument applies per-buffer or per-tensor when sync modes differ is left open until a concrete case appears; the present implementation targets buffers sharing one sync mode.
Notes (non-normative)
Why not one contiguous allocation, sliced? A producer MAY do exactly that: the
buffer handle is a declaration of properties, not a pointer
(buffer-protocol.md § Buffer Handle), and nothing requires the N pointers behind a
descriptor to live in distinct allocations. A producer that controls all of its buffers
can allocate one block, place each buffer at a 64-byte-aligned offset within it, and
hand out N handles pointing into that block. This decision does not forbid that; it
declines to require it, for three reasons.
- Buffers frequently have independent owners. A quantized tensor's data may be a PyTorch storage while its scales are a NumPy array, or its values may be an mmap region while its indices were just computed. Requiring one contiguous block means copying foreign memory into it at the boundary, which contradicts the zero-copy-first principle the protocol exists to serve.
- Release is per buffer. Each
HurrayBuffercarries its own release callback (ADR-009). One block collapses that into all-or-nothing: a consumer could not release the data and retain the scales, and a producer could not hand out buffers whose lifetimes differ. - It saves no bookkeeping. A sliced block still needs N
(offset, length)pairs plus inter-slice alignment padding — the same cardinality as N handles, with strictly less generality.
ADR-031: One Python Tensor class for every layout — retire SparseTensor
Status
Accepted (2026-08-15), implemented 2026-08-19 (PR #159). § 2 amended 2026-08-16: inapplicable accessors raise AttributeError, not UnsupportedError — see the reasoning in that section.
Amends the Sparse Tensor Support section of docs/impl/python-bindings.md, which
currently requires COO / CSR / CSC tensors to be exposed as a distinct
hurray.SparseTensor class.
Context
hurray-python exposes two tensor classes: hurray.Tensor for dense tensors and
hurray.SparseTensor for COO, CSR, and CSC. The split is mandated normatively in
docs/impl/python-bindings.md § Sparse Tensor Support, but no ADR records a reason
for it. It was asserted rather than decided.
Four observations argue against keeping it.
1. The format does not make this distinction
There is no sparse tensor descriptor. Sparse is a layout_tag inside the ordinary
TensorDescriptor — same element type, same shape, same buffer table, same optional
quantization / shard / statistics sections. hurray-core models every layout as one
LayoutDescriptor enum.
A binding whose stated purpose is to expose what the format can express (issue #147)
should not invent a type boundary the wire format does not have. The split makes
hurray-python the only place in the stack where "sparse" is a kind of object
rather than a property of a tensor.
2. The taxonomy is already incomplete
hurray.SparseFormat models exactly three layouts: COO, CSR, CSC. But COO is not the
only multi-buffer layout, and sparse is not the only non-dense one:
| Layout | Buffers | Python class today |
|---|---|---|
| RowMajor, ColMajor, Strided, Tiled, Morton, Hilbert | 1 | Tensor |
| COO | 2 | SparseTensor |
| CSR, CSC | 3 | SparseTensor |
| CSF | 2·rank+1 | (none — falls back to Tensor) |
| BlockPaged | 3 | (none — falls back to Tensor) |
| Composite | 0 | (none) |
CSF and block-paged are as far from row-major as CSR is, yet they land in Tensor.
The boundary does not track any property of the format; it tracks which layouts
happened to be bound first. Every new layout re-opens the question of which class it
belongs to — a question that would not exist if there were one class.
3. The split leaks into every API
Because the two types are unrelated, each new capability must be threaded through twice or make a dispatch decision:
hurray.save()acceptsTensoronly and raisesUnsupportedErrorforSparseTensor— the whole of issue #156.hurray.load()must decide which class to construct from a decoded descriptor.hurray.from_hurray_bufferreturnsTensor, so a sparse tensor that travels over the native protocol comes back as the wrong type (a leftover from #146).__hurray_buffer__is implemented twice, once per class.
The fix proposed for #156 was a shared "reconstruct into the right class" function. That function is pure overhead created by this decision; unifying deletes it rather than writing it.
4. The nearest analogue in this domain already unified
The ecosystem is genuinely split on this question:
| Library | Design |
|---|---|
| PyTorch | one torch.Tensor with a .layout attribute (torch.strided, torch.sparse_coo, torch.sparse_csr) |
| TensorFlow | separate tf.SparseTensor |
| SciPy | separate scipy.sparse matrix types |
| Apache Arrow | one Array hierarchy; layout is a property of the type |
SciPy's split is the one hurray-python currently mirrors, and it is the least
applicable: SciPy's sparse types are a matrix library with their own arithmetic,
not an interchange surface. PyTorch — the closest analogue, an ML tensor library where
layout is metadata rather than a different kind of object — unified, and calling
.values() on a dense tensor simply raises. The unified design is proven in practice.
Decision
hurray-python exposes exactly one tensor class, hurray.Tensor, for every
layout. hurray.SparseTensor is removed.
1. Layout becomes a property
Amended by ADR-032:
layoutreturns ahurray.Layoutinstance, not a string. The string islayout.name, drawn from the same set listed below. § 2 through § 5 are unchanged.
hurray.Tensor MUST expose a layout property reporting the descriptor's layout as a
string: "row_major", "col_major", "strided", "tiled", "morton", "hilbert",
"coo", "csr", "csc", "csf", "block_paged", "composite".
This replaces SparseTensor.format, which reported only the three sparse cases.
2. Layout-specific accessors live on the one class and raise AttributeError
values, indices, col_indices, row_ptr, row_indices, col_ptr, and nnz
MUST be available on hurray.Tensor and MUST raise AttributeError when the
tensor's layout does not define them.
This extends design decision D10 — already applied within SparseTensor, where a
CSR tensor raises AttributeError for .indices — from the three sparse formats to
every layout.
AttributeError rather than hurray.UnsupportedError, because it is the only choice
that keeps hasattr honest:
hasattr(coo_tensor, "row_ptr") # False — genuinely not available
hasattr(csr_tensor, "row_ptr") # True
UnsupportedError subclasses NotImplementedError, so it is raised after attribute
lookup succeeds; hasattr would return True for every accessor on every tensor and
callers would be forced to switch on layout instead. Feature detection matters more
under a unified class, not less: once the type no longer tells you what a tensor
supports, hasattr is what remains.
Note (non-normative): PyTorch raises
RuntimeErrorhere, so it is not a model to follow on this point — its users check.layout. The cost of the unified class is thatdense.row_ptrfails at call time rather than being absent from the type;AttributeErrorrecovers as much of the "absent" behaviour as Python allows.
3. Dense-only protocols reject non-dense layouts by layout, not by type
__dlpack__, __array__, __array_interface__, and to_torch MUST raise
hurray.UnsupportedError (or BufferError where the protocol requires it) for
layouts they cannot represent. Previously the type system enforced this by making the
methods absent from SparseTensor; now the check is explicit and states the layout in
the message.
4. Constructors and interop keep their names
hurray.sparse_coo, hurray.from_scipy, and to_scipy remain, returning and
accepting hurray.Tensor. They are named for what they do, not for the class they
produce.
5. One protocol implementation
Answered by ADR-036: a composite is a container of tensors, not a tensor.
hurray.Compositeis its own class, andhurray.Tensoris unchanged.
__hurray_buffer__ is implemented once. hurray.from_hurray_buffer,
hurray.load(), and hurray.save() handle every layout uniformly, which closes
issue #156 and the sparse half of the #146 follow-up without any per-class dispatch.
Alternatives Considered
Keep both classes and give SparseTensor its own file I/O (the original #156
plan). Rejected: it fixes one symptom and leaves the cause. Every future capability —
streaming (#157), extension types, new layouts — pays the same tax again, and the
class boundary still does not correspond to anything in the format.
Keep both, and add classes for the missing layouts (CsfTensor,
BlockPagedTensor, …). Rejected: it multiplies the problem. Each new layout in the
spec would require a new Python class and another round of protocol implementations,
and consumers would have to switch on type to do anything generic.
One class, with layout-specific accessors on a namespace object
(t.sparse.row_ptr). Rejected as a middle road that costs an extra concept without
removing the failure mode: t.sparse still has to raise for a dense tensor.
Subclass: SparseTensor(Tensor). Rejected. It would fix the "save() rejects
sparse" symptom via inheritance while keeping the taxonomy question ("which layouts
get a subclass?") permanently open, and isinstance checks in user code would quietly
encode a boundary the format does not have.
Consequences
Positive
- The Python object model matches the format's: a tensor has a layout, rather than a layout implying a type.
- Issue #156 is closed by construction —
save/load/from_hurray_bufferstop caring what layout a tensor has. - CSF, block-paged, and composite tensors gain the same accessors and protocol support
as everything else, instead of falling back to a partial
Tensor. - One implementation of
__hurray_buffer__, one of__repr__, one of the property surface.
Negative
- A visible API break.
hurray.SparseTensordisappears;isinstance(x, hurray.SparseTensor)andx.formatstop working. Pre-1.0 this carries no compatibility guarantee (seedocs/spec/versioning.md), but it is a real change for anyone already using the sparse API. - Static analysis loses a signal.
dense.row_ptrstill raisesAttributeError, sohasattrandgetattrbehave as before, but a type checker or IDE can no longer tell from the class alone which accessors a given tensor supports — every accessor exists on everyTensor. - A wider class surface. One class carries every layout's accessors, most of which raise for any given instance. Documentation must be explicit about which apply where.
Required Documentation Amendments
docs/impl/python-bindings.md§ Sparse Tensor Support — rewritten around one class with alayoutproperty; thehurray.SparseTensorrequirement removed.docs/cookbook/hurray-python-sparse-scipy.md— updated for the unified class.docs/tutorials/python-interop-paths.md— the note recording thatsave()rejectsSparseTensoris removed, since it no longer will.
No amendments under docs/spec/ are required: this decision changes only the Python
binding's object model, not the format.
Open Questions Deferred
- Composite tensors. A composite head owns no buffers and is a container rather than a tensor. Whether it belongs on the unified class at all, or needs its own representation, is left open — it is the one case where a separate type may be genuinely justified, and nothing in Python constructs composites today.
- Layout-specific construction.
hurray.sparse_coostays, but whether every layout eventually gets a matching constructor, or a single generic one taking a layout argument, is left to whoever binds the next layout.
ADR-032: A Python layout descriptor hierarchy — t.layout returns an object, not a string
Status
Accepted (2026-08-18), implemented 2026-08-19 (PR #161)
Extends ADR-031 (One Python Tensor class for every layout). Amends its § 1 —
layout returns a hurray.Layout instance rather than a string — and leaves § 2–§ 5
in force.
Context
ADR-031 unified the Python tensor classes and gave hurray.Tensor a layout
property. That property returns a string, which is lossy: hurray-core models
layout as an enum whose variants carry data, and none of that data survives the
translation.
| Layout | Fields in hurray-core | Reachable from Python |
|---|---|---|
| COO | nnz, is_sorted | no |
| CSR / CSC | nnz | no |
| CSF | nnz, mode_order | no |
| Strided | strides | no |
| Tiled | tile_shape, outer/inner strides and layouts | no |
| Morton | morton_bits | no |
| BlockPaged | page_size, num_pages, paged_axis, num_seqs, kv_role | no |
| Composite | composition rule, combine op, member count | no |
| PrivateExtension | extension_layout_id, extension_data | no |
| Unknown | tag, raw bytes | no |
A string cannot carry any of it. Under the standing requirement that hurray-python
expose what hurray-core and hurray-io can express (issue #147), this is a hole.
It is also an inconsistency. Every other optional descriptor section already has a
Python class — the five quantization schemes, Statistics, Shard — accepted by the
Tensor constructor and returned by the matching getter. Layout is the only section
of the tensor descriptor with no Python representation of its own.
Note (non-normative): This does not re-open
SparseTensor.CsrLayoutis a descriptor, not a tensor: it has no buffers, no protocols, and nosave().isinstance(t.layout, CsrLayout)encodes a distinction the wire format genuinely makes — the layout tag — whereisinstance(t, SparseTensor)encoded one it does not. ADR-031 removed the hierarchy from the tensor; this ADR puts one where the format actually has it.
Decision
1. A class hierarchy with a data-carrying base
hurray.Layout # base: holds the core descriptor
├── RowMajorLayout ColMajorLayout
├── StridedLayout TiledLayout MortonLayout HilbertLayout
├── CooLayout CsrLayout CscLayout CsfLayout
├── BlockPagedLayout
├── CompositeLayout
├── PrivateExtensionLayout
└── UnknownLayout
The base MUST store the core LayoutDescriptor and implement tag, name,
buffer_count, is_dense, and is_virtual once; subclasses are typed façades
reading their own fields off it. This mirrors how Statistics and the quantization
schemes already wrap core types.
The base class also gives the binding a legal fallback object. LayoutDescriptor
is #[non_exhaustive]: when core gains a variant this build has not bound, t.layout
MUST return a bare Layout carrying tag and name. It MUST NOT be reported as
UnknownLayout, which would claim the tag is unrecognised when it is merely unbound —
destroying the signal a permissive reader depends on.
All layout classes MUST be immutable, with value equality and hashing, and a __repr__
of the form CsrLayout(nnz=4).
2. t.layout returns an instance; the string moves to .name
t.layout == "csr" no longer works; t.layout.name == "csr" replaces it. A single
internal helper MUST produce that string, so .name and the layout named in error
messages (__dlpack__, __array__, to_scipy) cannot drift.
Layout classes MUST NOT define equality against strings. It would break the hash/equality contract and keep a lossy comparison path alive indefinitely.
layout is read-only: assigning a layout would silently reinterpret existing buffers.
3. Component views stay on Tensor, plus a generic accessor
values, indices, row_ptr, col_indices, row_indices, col_ptr, and nnz
remain on hurray.Tensor exactly as ADR-031 § 2 defines them, including the
AttributeError discipline.
hurray.Tensor MUST additionally expose buffer(index), returning a 1-D uint8 view
of exactly the declared byte size of the buffer at that descriptor index. CSF has
2·rank+1 buffers and block-paged has three; without a generic accessor their
parameters would become reachable while their buffers stayed unreachable, leaving
issue #147 unsatisfied for those layouts. uint8 is the only honest element type for a
generic view — buffers within one tensor differ (values take the tensor's dtype, index
buffers are uint64, MXFP scales are e8m0) — and it cannot misreport a dtype. The
layout object tells the caller what each index means.
4. Authoring: layout= is accepted, and never inferred
hurray.Tensor MUST accept a layout keyword holding a hurray.Layout instance,
mirroring quantization=. Omitting it means row-major, as today. A non-Layout value
MUST raise TypeError.
A layout string MUST NOT be accepted. A string cannot carry nnz or strides, so
layout="csr" is a request that cannot be honoured; accepting it would create a second,
lossy authoring path.
The layout object is a declaration; the buffers are evidence. They MUST agree, and the constructor MUST check three things:
| Tier | Check | Error |
|---|---|---|
| Shape | rank and shape constraints (CSR rank 2, CSF rank ≥ 3, len(strides) == rank, …) | InvalidDescriptorError |
| Buffer count | supplied buffers ≥ the layout's required count; quantization indices fall beyond it | InvalidDescriptorError |
| Buffer size | each buffer at least as large as the layout's parameters imply | BufferError |
Never reinterpret, never infer. CooLayout(nnz=4) supplied with a two-element
values buffer MUST raise — the descriptor MUST NOT be silently corrected to nnz=2,
and MUST NOT be accepted as given. Such a descriptor encodes and decodes cleanly and
hands the consumer an out-of-bounds read: the same class of failure the existing
quantization buffer-placement check exists to prevent. Over-sized buffers are permitted
(alignment and padding slack are legitimate); under-sized are not.
Consequently nnz MUST be a required argument on the sparse layout constructors.
Inference belongs to the array-shaped constructors — hurray.sparse_coo,
hurray.from_scipy — which are handed the arrays and can derive it. That yields two
clear paths: high-level constructors infer and are ergonomic; Tensor(...) requires the
descriptor to be stated and validates it.
Round-trip obligation. For every constructible layout, rebuilding a tensor from a
tensor's own layout, quantization, statistics, shard, and buffers MUST produce
an equal descriptor.
5. Units and enumerations
- Strides —
StridedLayout.stridesand the tiled layouts' outer and inner strides — are in logical elements, signed, and may be negative or zero. This MUST be stated in the binding documentation: a reader arriving from NumPy will otherwise assume bytes. - Small closed enumerations (
kv_role, block-table index type, tiled inner and outer layout tags) are exposed as lowercase strings, matchingdevice.kindandlayout.name. No new Python enum classes. CompositeLayoutexposes the composition rule and the combine operation as two properties, with the combine operationNonefor non-overlay rules. They MUST NOT be flattened into one string, and the raw combine byte MUST NOT be exposed for rules where it means "not applicable".
6. Private, unknown, and composite layouts
PrivateExtensionLayout and UnknownLayout MUST be separate classes. "A private
layout I can identify by its extension id" and "a tag from a newer spec version I could
not parse" are different facts, and merging them erases the signal a permissive relay
needs.
PrivateExtensionLayout— exposes tag, extension id, and extension data; buffer count is unknown. Constructible and accepted for authoring. Because the buffer count is unknown, the size tier of § 4 cannot run; that hole MUST be documented rather than left implicit.UnknownLayout— exposes tag and raw bytes; buffer count unknown. Constructible, so that a permissive relay can reconstruct a descriptor it decoded and write it back out. The Python constructor MUST reject any tag for which a named variant exists.
Amended by ADR-036: the rule is unchanged — a composite layout still cannot be given to
hurray.Tensor— but the error now nameshurray.Composite, which holds a head and its members together.
CompositeLayout— readable in full: a composite head decoded from a stream MUST NOT have its layout misreported. Constructing a tensor with a composite layout MUST raisehurray.UnsupportedError; a composite head owns no buffers, which the PythonTensorcannot yet represent. ADR-031's deferral of whether composites belong on the unified class is unaffected; this answers only whatt.layoutreports.
Resolved (2026-08-19, issue #162): core now rejects named and private tags in
UnknownLayout::new, via a newis_named_taghelper shared withvalidate_layout_tag_strict. The finding as originally written follows.Finding for
hurray-core:UnknownLayout::newcurrently rejects only the reserved tags0x00and0xFF, so a caller can construct an "unknown" layout on a tag that has a named variant — smuggling an unvalidated descriptor past every rank and buffer check. The Python constructor closes this per § 6; core should be tightened to the same rule.
Alternatives Considered
Layout objects holding a back-reference to their tensor, so t.layout.row_ptr
returns a view. Rejected, and recorded here as rejected rather than deferred because
it is otherwise certain to be re-proposed.
It turns a metadata accessor into a buffer-lifetime anchor. t.layout would hold a
strong reference to the tensor, which holds the buffers, so
layouts = {name: t.layout for name, t in stream} # "just collecting metadata"
pins every tensor's buffers for the lifetime of that dictionary. In a format whose
premise is that the consumer decides when a zero-copy buffer is released, a descriptor
attribute that silently extends buffer lifetime is a defect, and nothing in its name
warns the caller. The Tensor → layout → Tensor reference cycle is a second cost of the
same choice: uncollectable without GC traversal support on every layout class, and
clearing the back-reference leaves a partially-dead object whose getters must then
raise.
It also asserts a containment the wire format does not have. The buffer table is a
sibling section of the layout section, not a child — which is precisely why quantization
descriptors reference buffers by index into that shared table. t.quantization returns
a descriptor carrying scale_buffer_index and not the scale buffer; a layout that
owned views would be the only descriptor section in the binding behaving differently.
Finally it does not deliver its own headline benefit without a second, worse change:
dir(t.layout) lists only what applies only if the accessors are removed from
Tensor, which repeals ADR-031 § 2 and takes hasattr with it. And t.layout.values
reads as though the layout has values. The tensor has values; the layout describes how
they are arranged.
Keeping layout as a string and adding separate properties for the parameters
(t.nnz, t.strides, t.page_size, …). Rejected: it moves every layout's fields onto
Tensor, multiplying exactly the AttributeError-guarded surface ADR-031 already had
to justify, and leaves no object to pass to layout= for authoring.
Thirteen independent classes with no common base. Rejected: tag, name,
buffer_count, is_dense, and is_virtual would be written thirteen times, the
layout= keyword would need a downcast chain instead of one type check, and — decisively
— there would be no legal object to return when core gains a variant the binding has not
yet bound.
Accepting a layout string for authoring, alongside objects. Rejected under § 4: a string cannot carry the parameters, so it can only produce a descriptor that is wrong.
Consequences
Positive
- Every layout parameter in
hurray-corebecomes reachable from Python, closing the layout half of issue #147. - Layout joins quantization, statistics, and shard as a descriptor section with a Python class, so authoring and inspection read the same way across all four.
isinstance(t.layout, hurray.CsrLayout)is a stronger discriminator than a string comparison, anddir(t.layout)is fully honest about parameters.- The
layout=keyword plus the three validation tiers make an inconsistent descriptor unauthorable, rather than merely undetected.
Negative
t.layoutis not identity-stable: each access builds a fresh object, sot.layout is t.layoutis false. Value equality and hashing mitigate it. Caching one object per tensor would restore identity without a cycle (tensor → layout is safe; only layout → tensor is not) and is recorded below as deferred.- A large mechanical surface: thirteen classes, each with a constructor, getters,
__repr__, equality, a doc example, and a stub entry. The shared base is what keeps this mechanical rather than combinatorial. TiledLayoutis recursive — a tiled layout may nest another — so the Python class is self-referential and its__repr__nests. Core bounds the depth, but the representation should be depth-aware rather than naively recursive.Layout.namestrings become public API. They are already public through the current string property, so this is not new surface, but pinning them to a per-class property makes them harder to change later. They MUST be frozen in the binding documentation and MUST match the specification's layout names, so a third naming vocabulary does not emerge alongside the spec's andhurray-inspect's.- Private and unknown layouts skip buffer-size validation, because their buffer count is unknowable. This is a genuine hole in an otherwise complete check.
Required Documentation Amendments
docs/impl/python-bindings.md— a normative section defining the class hierarchy, thelayout=keyword, the three validation tiers, the frozen string sets fornameand the enumeration-valued properties, the statement that strides are in logical elements and signed, and the private/unknown size-validation hole.docs/adr/ADR-031-*.md§ 1 — amended by reference:layoutreturns aLayoutinstance; the string islayout.name. § 2 through § 5 are unchanged.docs/cookbook/hurray-python-sparse-scipy.md,docs/cookbook/composite-streaming.md, anddocs/tutorials/python-interop-paths.md— updated fort.layout.name.hurray-inspect— its layout rendering and the Pythonreprof a layout should agree field for field; any fieldhurray-inspectprints that the Python class omits is a remaining issue #147 gap.
No amendments under docs/spec/ are required: this changes the Python binding's object
model, not the format.
Open Questions Deferred
- Named CSF and block-paged component accessors (
pos/crd, page table). The genericbuffer(index)accessor covers them for now, and the layout object explains what each index holds. - Composite authoring from Python, which requires representing a tensor that owns no buffers.
- Caching the layout object per tensor for identity stability.
- Per-layout high-level constructors beyond
sparse_coo— whether each layout eventually gets an array-shaped constructor that infers its parameters.
ADR-033: The native protocol is named for the format — __hurray__ / from_hurray
Status
Accepted (2026-08-20), implemented 2026-08-20 (PR #171)
Amends ADR-023 § 1 (protocol name), § 7 (discovery), and § 8 (error semantics, which names the capsule string), and ADR-030 § 5 (one protocol, one probe). Every other decision in both ADRs is unaffected.
Context
The native interchange protocol is currently hurray.Tensor.__hurray_buffer__() and
hurray.from_hurray_buffer(). The name is wrong twice over.
It is wrong about arity. ADR-030 widened the capsule from a single
HurrayBuffer to a HurrayBufferList, because a tensor with per-channel scales,
sparse index arrays, or a page table has several buffers and every one of them has to
reach the consumer. The protocol has carried N buffers since then; the name still says
one.
It is wrong about category, which matters more. The capsule does not transport buffers. It transports a whole tensor:
| Capsule part | Contents |
|---|---|
| pointer | HurrayBufferList — one HurrayBuffer per descriptor buffer index |
| context | the encoded TensorDescriptor, the ABI version, and a strong reference to the source |
The descriptor is what makes this protocol full-fidelity — element type, shape, layout, quantization, statistics, shard — and it is precisely what DLPack cannot carry, which is the protocol's reason to exist (ADR-023 § Context). Naming the thing after its buffers hides the part that justifies it.
The clearest evidence is the signature: from_hurray_buffer(obj) -> hurray.Tensor. A
function named for buffers that returns a tensor is describing its payload wrongly.
The original reasoning already pointed the other way. ADR-023 § 1 justified the
name by analogy to __dlpack__, __torch_function__, and __jax_array__ — and each
of those is named for a format or project, never for the bytes underneath.
__dlpack__ does not describe what a DLPack capsule contains. The _buffer suffix was
drift from the precedent the decision itself cited.
Decision
1. The protocol is named for the format
hurray.Tensor.__hurray__(stream=None) -> PyCapsule
hurray.from_hurray(obj, /) -> hurray.Tensor
Naming the format rather than the payload is what keeps the protocol legible next to the one it complements. The two are always read together, because choosing between them is the decision a consumer actually makes:
if hasattr(obj, "__hurray__"): # full fidelity: every dtype, layout, device
t = hurray.from_hurray(obj)
elif hasattr(obj, "__dlpack__"): # universal, but lossy
t = hurray.from_dlpack(obj)
It is also the only name that stays true as the protocol grows. A capsule that later carries a composite group or a stream frame is still Hurray; it is no longer a tensor, and would not be buffers either.
2. The capsule keeps a payload name
Amended by ADR-043: the capsule is named
"hurray_tensor_handle", and"used_hurray_tensor_handle"once consumed, because the pointer became aHurrayTensor *and a stale consumer must not find a familiar name on an unfamiliar type. The rule below still holds:_handlenames the payload, not a version, and the ABI version still travels in the payload rather than in the string. The original text follows.
The PyCapsule is named "hurray_tensor", and "used_hurray_tensor" once consumed.
This is deliberately not symmetric with the method name, and follows DLPack exactly:
__dlpack__ returns a capsule named "dltensor_versioned". The method name is
ergonomics — read by humans choosing a protocol. The capsule name is a wire contract —
read by PyCapsule_IsValid in a C consumer that never sees the Python method. Precision
belongs on the wire; brevity belongs in the call.
No version suffix is added, unlike DLPack's _versioned. DLPack needed one because
its v0.8 and v1.0 structs differ with no in-band version field. Hurray carries
HURRAY_C_ABI_VERSION in the capsule context, and from_hurray MUST verify it
(ADR-023 § 8), so the version travels in the payload where it can be checked and
reported rather than in a string that can only match or fail to match.
3. Discovery follows the method
hasattr(obj, "__hurray__") replaces hasattr(obj, '__hurray_buffer__') as the single
probe (ADR-023 § 7, ADR-030 § 5). One protocol, one probe, unchanged in substance.
4. The rename is clean — no aliases
The old names MUST be removed, not deprecated alongside the new ones. Keeping
__hurray_buffer__ as an alias would mean two names for one protocol, two feature
probes a consumer must decide between, and a permanent second path through the
capsule code — the "flag to avoid making a decision" that CLAUDE.md § Guiding
Principles forbids.
Hurray is pre-1.0, where the versioning policy permits breaking changes precisely so
that mistakes like this one are fixed rather than carried. Renaming after 1.0 would
cost a deprecation cycle; renaming now costs a search and replace.
5. hurray.from_hurray stutters, and that is accepted
Inside the hurray module the name reads redundantly. That is the one genuine cost,
and it lands on the least important consumer: hurray → hurray is a round trip, not
the case the protocol was built for. Every other implementation reads correctly —
torch.from_hurray(t), mylib.from_hurray(t) — exactly as numpy.from_dlpack does.
DLPack avoids the stutter only because its consumers live in other libraries; here one
of them happens to be us.
The status quo hurray.from_hurray_buffer stutters identically, so nothing is lost.
Alternatives Considered
__hurray_tensor__ / from_hurray_tensor. Accurate on both counts, symmetric with
hurray.Tensor, and matching Arrow's PyCapsule interface, which names the logical
object (__arrow_c_array__, __arrow_c_stream__) even though an Arrow array is itself
a schema plus several buffers. Rejected because the protocol's nearest neighbour is
DLPack, not Arrow: a consumer picks between __hurray__ and __dlpack__ in a single
hasattr chain, and the parallel is worth more there than descriptive precision. The
precision is not lost — it moves to the capsule name, per § 2.
__hurray_buffers__ / from_hurray_buffers. The minimal edit: pluralise and stop.
Rejected because it corrects the arity error while preserving the category error, which
is the more misleading of the two. The capsule would still be named for the part that
does not distinguish it.
Keep __hurray_buffer__ and add __hurray__ as an alias. Rejected under § 4.
Do nothing. Rejected. The name is load-bearing documentation: it is the first thing
an implementer of a third-party binding reads, and it currently tells them the protocol
moves buffers when it moves tensors. That misdirection gets more expensive with every
binding written against it, and the cost of fixing it only rises after 1.0.
Consequences
Positive
- The name matches what the protocol transports, and stays true if the payload grows.
__hurray__and__dlpack__read as the alternatives they are, in the one code shape — ahasattrchain — where a consumer chooses between them.- The capsule string says
tensorwhere a C consumer validates it, so the wire contract is more descriptive than before, not less. - One protocol, one probe, one name: no alias, no second path, nothing to deprecate later.
Negative
- The capsule name change is a protocol break. A consumer keying on
"hurray_buffer"viaPyCapsule_IsValidstops recognising Hurray capsules. This is permitted pre-1.0and is the reason to do it now, but it is a real break and MUST be called out in the release notes rather than folded into a rename. hurray.from_hurraystutters (§ 5).- Roughly 150 references move, across
hurray-python/src, its tests and examples, the cookbook, the tutorials,docs/impl/python-bindings.md, and ADR-023/ADR-030. Most are prose; the mechanical surface is small. - Two ADRs gain amendment notes. ADR-023 § 1, § 7, § 8 and ADR-030 § 5 keep their original text with a pointer here, matching how ADR-031 § 1 records its amendment by ADR-032.
Required Documentation Amendments
docs/impl/python-bindings.md— § Native Buffer Interchange Protocol: the method, the function, the capsule names, and thehasattrprobe. The section title should lose "Buffer" too.docs/adr/ADR-023-*.md§ 1, § 7, and § 8 — amendment note pointing here. § 8's rules are unchanged in substance; only the two names they quote move.docs/adr/ADR-030-*.md§ 5 — amendment note; the "one protocol, one probe" rule is unchanged, only the spelling of the probe.docs/cookbook/hurray-python-native-buffer.mdanddocs/tutorials/python-interop-paths.md— worked examples.hurray-python/examples/native_buffer.pyandmulti_buffer.py.
No amendments under docs/spec/ are required: the native protocol is
implementation-only (ADR-023 § 4), not part of the format specification.
Open Questions Deferred
WhetherResolved by ADR-034 § 5: no rename. Every C name was accurate for what it named — the C layer simply had no protocol type to misname. ADR-034 gives it one,hurray-ffishould expose a matching C-level name.HurrayTensorContext, named for what it carries.- Whether a future capsule carrying a composite group or a stream frame keeps the
"hurray_tensor"capsule name or introduces a sibling. § 1 makes the method name survive that change; the capsule name would not have to.
ADR-034: The capsule context becomes a C ABI handle
Status
Accepted (2026-08-20), implemented 2026-08-22 (PR #173)
§ 1 superseded by ADR-043:
HurrayTensorContextis deleted, and a capsule carries oneHurrayTensor *holding both the descriptor and the buffers. §§ 2, 3 and 4 are carried forward onto that handle, not withdrawn — opacity with accessors, the owner behindvoid *, and version-read-first all survive, re-targeted at the type that now carries them. § 5 stands as written.
Amends ADR-023 § 5 (capsule lifetime, which owns the context) and § 8 (the ABI version check), and resolves the C-level question ADR-033 deferred.
Context
The native protocol (__hurray__ / from_hurray, ADR-023, ADR-030, ADR-033) exists
so that two Hurray-aware peers can exchange a tensor without the fidelity loss DLPack
imposes. ADR-023 § Context names three peer pairs it is meant to serve:
hurray-python↔hurray-python,hurray-python↔hurray-fficonsumer, orhurray-python↔ another binding built onhurray-ffi
Only the first of those works.
A capsule carries two things. The capsule pointer is a HurrayBufferList — a proper
hurray-ffi handle, reachable by anyone linking the C ABI. The capsule context holds
everything else:
struct NativeBufferContext { // hurray-python/src/native_protocol.rs
abi_version: u32,
descriptor_bytes: Vec<u8>,
tensor_ref: Py<PyAny>,
}
That struct is private to hurray-python, is not #[repr(C)], contains a Vec<u8> and
a Python object reference, and is declared nowhere in hurray.h. Its layout is
unspecified and may change with a compiler version. A consumer that is not
hurray-python can call PyCapsule_GetContext and receive a pointer it has no legal
way to interpret.
So the buffers cross the boundary and the descriptor does not — and the descriptor
is the entire reason this protocol exists rather than DLPack. A Go or Julia binding on
hurray-ffi receives element bytes with no element type, no shape, no layout, no
quantization.
The gap also makes a normative rule unimplementable. docs/impl/python-bindings.md
§ ABI version requires:
The capsule context MUST include the
HURRAY_C_ABI_VERSIONconstant from the producinghurray-ffibuild. […] the consumer MUST verify it before dereferencing the handle.
A non-Python consumer cannot verify the version it is required to verify, because the version sits inside the struct it cannot read. The check exists precisely to stop a consumer dereferencing handles from an incompatible build, and it is unavailable to every consumer that is not the producer's twin.
This was found while resolving ADR-033's deferred question about C-level naming. That question turns out to have a short answer — see § 5 — and this is the real defect underneath it.
Decision
1. The context becomes a hurray-ffi handle
hurray-ffi gains HurrayTensorContext: an opaque handle carrying what a capsule
needs beyond its buffers.
typedef struct HurrayTensorContext HurrayTensorContext;
HurrayStatus hurray_tensor_context_new(uint32_t abi_version,
const uint8_t *descriptor_bytes,
uint64_t descriptor_len,
void *owner,
void (*owner_release)(void *owner),
struct HurrayTensorContext **out);
HurrayStatus hurray_tensor_context_abi_version(const struct HurrayTensorContext *ctx,
uint32_t *out);
HurrayStatus hurray_tensor_context_descriptor(const struct HurrayTensorContext *ctx,
const uint8_t **out_bytes,
uint64_t *out_len);
void hurray_tensor_context_destroy(struct HurrayTensorContext **ctx);
The handle owns a copy of the descriptor bytes; hurray_tensor_context_destroy frees
them and invokes owner_release(owner) exactly once, then nulls the caller's pointer —
the same discipline hurray_buffer_destroy already follows.
2. Opaque with accessors, not a public repr(C) struct
The obvious fix is to make the struct #[repr(C)] and declare its fields in
hurray.h, so a consumer reads them directly. This ADR rejects that.
Every other handle in the C ABI — HurrayBuffer, HurrayBufferList,
HurrayDescriptor — is opaque with accessor functions, and says so in its own
documentation: "this struct is not repr(C); its internal layout is an
implementation detail." A single struct with a frozen public layout would be the one
exception, and it would freeze that layout for the life of the major version.
The usual argument for a public layout — that the consumer avoids linking the producing
library — does not apply. A consumer holding a capsule already links hurray-ffi;
it has to, because the capsule pointer is a HurrayBufferList and reading it requires
hurray_buffer_list_get. Nothing is saved by exposing this one type differently, and
consistency across the ABI is worth more.
3. The Python owner reference travels behind void *
The context must keep the source tensor alive while the capsule lives, and today it
does that with a Py<PyAny> — a type the C ABI must never see.
owner plus owner_release keeps it out. hurray-python boxes its strong reference,
passes it as void *owner with a release function that drops it, and the C ABI stores
two pointers it never interprets. This mirrors release / release_context on
hurray_buffer_from_ptr, which solves the same problem for buffer memory, so the
pattern is already the house idiom rather than a new invention.
It also puts the fix from PR #164 in one place: the release function hurray-python
supplies is the only code that touches Python, so it remains the only code that must
cope with running during interpreter finalization.
4. abi_version is read before anything else is trusted
hurray_tensor_context_abi_version MUST be callable on any context pointer produced by
any version of this ABI, and a consumer MUST call it first. Every other accessor MAY
assume the version has been checked.
This is what makes the handle extensible: fields added in a later ABI version are reachable only through accessors added in that version, and a consumer that checked the version knows which ones exist. Without that ordering rule, an opaque handle is as frozen as a public struct.
5. The C ABI is not renamed
ADR-033 deferred "whether hurray-ffi should expose a matching C-level name". It should
not. HurrayBuffer is one buffer, hurray_buffer_* operates on one buffer,
HurrayBufferList is a list of buffers, HurrayDescriptor is a descriptor — every
name is accurate for what it names. The mistake ADR-033 corrected was a protocol
carrying a tensor while named for buffers, and the C layer had no protocol type to
misname.
It has one now, and it is named for what it carries. That closes the question.
6. C ABI version 3 → 4
New types and functions are additive, but a consumer must be able to tell whether a
context handle is available at all, so the version moves. HURRAY_C_ABI_VERSION
becomes 4.
Alternatives Considered
#[repr(C)] public struct in hurray.h. Rejected under § 2: inconsistent with
every other handle in the ABI, and freezes a layout for no benefit a consumer that
already links hurray-ffi can use.
Put the descriptor bytes in the capsule pointer instead, as a combined
HurrayTensor handle owning both the buffer list and the descriptor. Cleaner in the
abstract — one handle rather than a pointer/context pair — and worth revisiting. Rejected
here because ADR-030 § 2 fixed the capsule pointer as a HurrayBufferList and consumers
written against it would break for a change that buys elegance rather than capability.
Recorded as deferred below.
Leave it, and document the protocol as hurray-python ↔ hurray-python only.
Rejected. It would mean withdrawing a claim ADR-023 makes twice, and the protocol's
whole justification is preserving what DLPack cannot. A full-fidelity protocol that only
two instances of the same binding can speak is a private optimization, not an
interchange protocol — and the format's first principle is that it is language-agnostic.
Expose the descriptor through the existing HurrayDescriptor handle instead of raw
bytes. Attractive: the consumer would get a parsed descriptor rather than a byte
range. Rejected for now because it forces every producer to parse before sending and
every context to own a decoded structure, where today the encoded bytes are already in
hand and hurray_descriptor_decode is one call away for a consumer that wants one. The
bytes are the cheaper and more faithful thing to carry.
Consequences
Positive
- The protocol's stated purpose becomes true: a non-Python binding on
hurray-ffican read the descriptor and the ABI version, not just the buffers. - The
MUST verify the versionrule becomes implementable by every consumer rather than only by the producer's twin. - The C ABI keeps one shape — opaque handles, accessor functions, explicit destroy — with no exception carved out for this type.
- The Python reference is confined behind
void *, so the C ABI stays free of Python types and the finalization hazard stays in one function.
Negative
- An ABI version bump, with the compatibility-matrix and rebuild consequences every bump carries.
hurray-pythonno longer owns its context type, and must construct it throughhurray-ffi. That is the point, but it does mean the capsule path crosses one more boundary than before.- The descriptor bytes are copied into the context. A borrow would avoid it, but would tie the context's validity to a buffer the producer might drop. A descriptor is small next to the tensor it describes.
- No consumer exists to prove the design. The first real non-Python binding may still find this insufficient; § 4's version-then-accessors rule is what leaves room to fix that without another break.
Required Documentation Amendments
docs/impl/c-ffi.md—HurrayTensorContext, its four functions, the ownership and version-check rules, and ABI version 4 in the version table.docs/impl/python-bindings.md— § Native Interchange Protocol: the capsule context is aHurrayTensorContext, and the version check is a documented C call rather than an internal detail.docs/adr/ADR-023-*.md§ 5 and § 8 — amendment notes pointing here. The design noteD-NB2inhurray-python/src/native_protocol.rsdescribes the context too and moves with the implementation.docs/adr/ADR-033-*.md§ Open Questions Deferred — the C-level naming question is resolved by § 5.hurray-python/COMPAT-MATRIX.md— minimumHURRAY_C_ABI_VERSION4.docs/cookbook/layer-7-c-ffi.md— a consumer-side example: check the version, read the descriptor, walk the buffer list.
Open Questions Deferred
Both resolved by ADR-043. The combined handle is adopted, at the ABI break ADR-043 spends deliberately. The streaming question was answered by what shipped: ADR-041 step 3 gave a stream read a
HurrayTensor, and ADR-043 is the capsule joining it rather than a parallel type persisting. The original text follows.
- A combined
HurrayTensorhandle owning both the buffer list and the descriptor, so a capsule carries one handle instead of a pointer/context pair. Better shape; breaks ADR-030 § 2's pointer contract. Worth doing at the next deliberate ABI break, not this one. - Whether
hurray-io's streaming frames should reuseHurrayTensorContextas their C-level representation, rather than growing a parallel one when Layer 5 gains a C surface.
ADR-035: The Python streaming API is blocking, and iterates
Status
Accepted (2026-08-22), implemented 2026-08-22 (PR #176)
Resolves the design questions in issue #157. Extends docs/impl/python-bindings.md
with a streaming section; no existing ADR is amended.
Context
hurray-io implements the streaming interchange format — StreamWriter::write_tensor
/ write_composite / finish, and StreamReader::next_tensor / next_item. None of
it is reachable from Python, which exposes load and save only.
So a Python producer can write files but not streams, and the format's headline
property is missing from the language most of the ecosystem uses: a reader that starts
before the whole input has arrived, and a writer that emits tensors one at a time
without buffering the output. hurray.StreamError has been defined and registered
since Layer 8b — an exception for an API that does not exist.
Four questions have to be settled before any code, and the first one settles the rest.
Decision
1. Blocking, with an owned runtime — not asyncio
Amended by ADR-041 step 5a. The runtime is gone.
hurray.StreamReaderandhurray.StreamWritereach own ahurray-iosans-io core and drive it overstd::io, so no async runtime is linked into the extension; the GIL release and every observable behaviour below are unchanged. What this section decided — a blocking surface rather than anasyncioone — stood until ADR-041 § 9, which adds theasynciosurface alongside it rather than in place of it.The deferral below is closed (ADR-042).
hurray.aioexists. The condition this section attached to it — "when a real multiplexing consumer appears, this ADR should be revisited rather than worked around" — was met by the revisit itself: ADR-041 § 9 settled the semantics and ADR-042 the shape. Two things this section could not have known made it cheaper than it looked. The binding no longer owns a runtime, so an async surface no longer means bridging two executors; and the driver is ~120 lines of Python over the same core, not a second implementation of the protocol. The "two APIs for one protocol" objection below therefore does not apply as written: there is one protocol implementation, with two drivers over it — which is the shape ADR-041 § 1 chose everywhere else, for C as well.
hurray.StreamReader and hurray.StreamWriter each own a current-thread tokio
runtime for their lifetime and release the GIL around every call into it.
load and save already do this per call, building a disposable runtime and wrapping
the work in py.detach. Streaming is long-lived, so the runtime moves from the call to
the object; nothing else about the bridge changes.
An asyncio surface is deferred, not rejected. It is a second API with its own
integration layer, and the case for it is concurrency across many streams — a Python
process multiplexing dozens of peers. Nothing needs that yet, and a Python caller who
does can run blocking readers on threads, which is what the GIL release is for. When a
real multiplexing consumer appears, this ADR should be revisited rather than worked
around; the note in § Open Questions records what evidence would justify it.
Shipping both surfaces now was considered and rejected outright: two APIs for one
protocol is the shape CLAUDE.md § Guiding Principles forbids, doubling the surface to
avoid choosing.
2. The reader is an iterator; the writer is a context manager
with hurray.StreamWriter(path) as writer:
for tensor in tensors:
writer.write(tensor)
for tensor in hurray.StreamReader(path):
... # each tensor as it arrives
The reader implements __iter__ / __next__, raising StopIteration at clean EOF.
That maps exactly onto next_tensor returning Ok(None), and it is the shape a Python
caller expects from something that yields values incrementally. It also composes with
everything that consumes an iterator, at no cost.
The writer is a context manager because finish flushes, and a caller who forgets
it loses however much of the stream was still buffered. Making the close automatic
means that cannot happen silently on the happy path. finish() remains available
explicitly for callers who cannot use with, and MUST be idempotent so both paths
compose. A writer used after finishing MUST raise hurray.StreamError.
Correction (2026-08-22): this section first said
finishwrites a terminator. It does not —StreamWriter::finishflushes and returns the sink. The Hurray stream format is self-delimiting per frame and has no end marker; a stream ends at EOF, which is the same property that forbids end-of-file indexes. The decision is unchanged and the reason is if anything stronger: a forgottenfinishtruncates the stream rather than merely leaving it unterminated.One consequence follows and is worth stating: a stream truncated exactly at a frame boundary is indistinguishable from a complete one, because EOF is the only end marker there is. Truncation mid-frame raises
hurray.StreamError; truncation on a boundary yields a short stream and no error. That is a property of the format, not of this binding.
The reader MUST also be usable as a context manager, so a caller can release the transport deterministically rather than waiting for garbage collection.
3. Transports: a path, a file descriptor, or bytes
| Source | Reader | Writer |
|---|---|---|
| filesystem path | StreamReader(path) | StreamWriter(path) |
anything with fileno() — sockets, pipes, open files | StreamReader(obj) | StreamWriter(obj) |
| in-memory | StreamReader(data: bytes) | StreamWriter() → getvalue() |
hurray-io needs AsyncRead / AsyncWrite, and a Python file object is neither. Two
bridges were possible and only one is sound.
Rejected: implementing AsyncRead over a Python object's .read(). It would need
the GIL inside the poll loop, on a thread that deliberately released it — legal, but
it reintroduces the contention the release exists to avoid, and every read becomes a
reacquisition. It would also make the transport's failure modes Python exceptions
raised from inside a Rust poll.
Chosen: fileno(). A file descriptor is already what tokio wants, and it covers
sockets, pipes, and real files — which is the pipeline case the streaming format exists
for. Objects with no descriptor (io.BytesIO) are served by the bytes path, which
covers the rest of what a Python caller would reach for.
The descriptor MUST be duplicated (dup) so the stream owns its own and closing one
side does not invalidate the caller's object. The stream MUST close its duplicate on
finish or on drop.
4. Composites are rejected by name, not skipped
Amended by ADR-036: the reader now yields a
hurray.Compositeinstead of raising. The rule that survives is the one this section was really about: a composite is one item, never a head plus loose members.
StreamReader::next_item yields a tensor or a composite. A composite head owns no
buffers, and hurray.Tensor cannot represent that — ADR-031 and ADR-032 both deferred
composite authoring from Python for exactly this reason, and ADR-032 § 6 already makes
constructing a tensor with a composite layout raise hurray.UnsupportedError.
The Python reader therefore iterates tensors, and MUST raise
hurray.UnsupportedError naming the composite when it meets one. It MUST NOT skip it:
silently dropping a composite would hand the caller a stream that decoded "successfully"
while losing data, which is worse than refusing.
This is a real gap and is recorded as such rather than papered over. It closes when composite authoring does.
5. hurray.StreamError finally means something
Framing errors — a truncated frame, a bad magic, a length that overruns — MUST surface
as hurray.StreamError, the exception registered since Layer 8b and unused since. I/O
failures on the transport keep raising hurray.FileError (an OSError subclass), and
descriptor-level problems keep raising hurray.InvalidDescriptorError, so the three
stay distinguishable.
Alternatives Considered
An asyncio API instead of a blocking one. Rejected for now under § 1: it is the
right answer only for a multiplexing consumer, which does not exist yet, and it costs an
integration layer that would have to be maintained through every pyo3 upgrade.
A callback API — hurray.read_stream(src, on_tensor=fn). Rejected: it inverts
control for no gain, cannot be composed with itertools or a for loop, and makes
early exit awkward. The iterator gives the same incrementality in the shape Python
already has.
Reading the whole stream into a list — hurray.load_stream(src) -> list[Tensor].
Rejected as the primary API: it buffers the entire input, which is the exact property
the streaming format exists to avoid, and would make the Python surface a worse version
of load. It may be added later as a convenience on top of the iterator, where its
cost is explicit in its name.
Exposing next_item and returning composites as tuples or dicts. Rejected: it
would invent a second, ad-hoc representation of a composite in the binding, which the
next pass on composite authoring would then have to keep or break. Refusing is honest
and costs nothing to undo.
Consequences
Positive
- The format's defining property becomes available in Python: incremental in, incremental out, no whole-input buffering on either side.
- The API is the one a Python caller would guess — a
forloop and awithblock. fileno()makes sockets and pipes work without a Python-object bridge, so the pipeline story is real rather than file-only.- One protocol surface, not two; the async question stays open without an API standing in for its answer.
Negative
- A runtime per stream object. A current-thread runtime is cheap, but a caller holding hundreds of open streams pays for hundreds of them. That is the same caller who will want the async API, which is the signal § 1 asks for. (Resolved by ADR-041 step 5a: a stream object owns a state machine, not a runtime.)
- Composites are unreadable from Python, and a stream containing one fails rather than degrading. Deliberate, and the alternative is worse.
io.BytesIOis not accepted directly, only itsgetvalue(). A caller must know which of the two paths their object takes, which is a wart on an otherwise uniform constructor.- A duplicated descriptor is a resource the caller cannot see. It is closed on
finishor drop, but a leaked reader leaks an fd until collection.
Required Documentation Amendments
docs/impl/python-bindings.md— a normative § Streaming section: the two classes, the transports, the iterator and context-manager protocols, the composite rejection, and the exception mapping.docs/tutorials/python-interop-paths.md— currently says "the streaming format has no Python API yet; it ishurray-io, Rust only". That becomes false.docs/cookbook/— a Python streaming recipe beside the Rust ones inipc-streaming.mdandlayer-5-streaming-interchange.md, per issue #147.hurray-python/examples/streaming.py— runnable producer and consumer.
Open Questions Deferred
- An
asynciosurface. Revisit when a consumer needs to multiplex many streams in one process — that is the case a blocking API cannot serve by adding threads. - Composite streaming, which unblocks when
hurray.Tensorcan represent a buffer-less head. - Whether the writer should accept anything buffer-like rather than only
hurray.Tensor— a NumPy array or a DLPack producer could be converted on the way in. Convenience, not capability; decide once the core API has users.
ADR-036: A composite is a container of tensors, not a tensor
Status
Accepted (2026-08-22), implemented 2026-08-23 (PR #178)
Answers the question ADR-031 § 5 deferred — whether composites belong on the
unified hurray.Tensor class. Amends ADR-032 § 6 (the error a composite layout
raises) and ADR-035 § 4 (the stream reader stops refusing composites).
Context
Composites are the last capability hurray-core and hurray-io can express that
hurray-python cannot, and they are blocked in three places at once:
| Where | Today |
|---|---|
| authoring | hurray.Tensor(..., layout=CompositeLayout(...)) raises UnsupportedError (ADR-032 § 6) |
| streaming | StreamReader raises UnsupportedError on a composite, by name (ADR-035 § 4) |
| files | save / load have no composite path at all |
All three trace to one root: hurray.Tensor cannot represent a head that owns no
buffers. One decision unblocks all of them, which is why this is a single ADR rather
than three.
The question ADR-031 left open is the one that has to be answered first. ADR-031 removed
hurray.SparseTensor on the grounds that sparse is a layout, not a kind of object —
a sparse tensor still has data, a shape, a dtype, and buffers, merely arranged
differently. Does that argument reach composites?
Decision
1. hurray.Composite is its own class
It does not, and here is the difference. A sparse tensor has data. A composite contains tensors:
- its head owns zero buffers — the format's own model calls it virtual, a fourth addressing category beside dense, sparse, and indirect (ADR-027)
len(composite.members)has no meaning on a tensor- there is no
composite.values, no__dlpack__, no bytes to hand anyone — the data belongs to the members, each of which is an ordinaryhurray.Tensor
ADR-031's rule was that a layout must not become a class. A composite is not a layout
applied to data; it is a grouping of tensors that happens to be introduced by a
descriptor. Giving it a class does not reopen SparseTensor, because nothing about a
composite is expressible as "a tensor whose bytes are arranged differently".
composite = hurray.Composite(
"partition",
shape=[8, 8],
dtype=hurray.float32,
members=[tile0, tile1],
)
composite.members # (Tensor, Tensor)
composite.layout # CompositeLayout(composition_rule='partition', member_count=2)
composite.shape # (8, 8)
2. The head is stated, never derived
shape, dtype, the composition rule, and the combine operation are required —
member_count is the one field taken from the members, because it is a count of what
was passed rather than a claim about it.
A partition's head shape could in principle be derived from its members' shards, and it
MUST NOT be. The same rule governs layout= on hurray.Tensor (ADR-032 § 4): the
descriptor is a declaration, the members are evidence, and they must agree. Deriving
would mean a caller who miscomputed a shard offset gets a head quietly reshaped to match
their mistake, and a consumer downstream reading a composite that is self-consistent and
wrong.
Validation is delegated entirely to hurray-core's CompositeValidator, which already
enforces per-member checks, partition coverage, overlay ordering, and member count. The
binding MUST NOT grow a second copy of those rules.
3. Members may be composites
members accepts hurray.Tensor or hurray.Composite, because the format nests
(ADR-027 § Binding) and hurray-io already represents members as a tree. A nested
member MUST be validated by the same path as a top-level one, and the depth limit is
core's.
4. hurray.Tensor does not change
Constructing a tensor with a composite layout still raises hurray.UnsupportedError
(ADR-032 § 6). The rule is unchanged; only the message changes, to name
hurray.Composite instead of describing a gap.
This matters more than it looks. The alternative — letting Tensor hold zero buffers —
would put a second family of inapplicable accessors on the class ADR-031 had to justify
carefully: values, buffer, buffer_count, __dlpack__, __hurray__, __array__
would each need a composite branch, and hasattr would stop discriminating in the way
ADR-031 § 2 relies on.
5. Composites travel over both I/O paths
StreamWriter.writeMUST accept aCompositeand emit it as head-then-members (write_composite).StreamReaderMUST yield aCompositewhere it previously raised, amending ADR-035 § 4. Its iteration remains one item pernext(), a composite counting as one.saveMUST accept aCompositeas a named entry, andloadMUST return one.
A composite read back MUST equal what was written, member for member, so the round-trip obligation ADR-032 § 4 states for descriptors extends to composite trees.
6. Composites stay out of the native protocol
Composite MUST NOT implement __hurray__ or __dlpack__. The native protocol capsule
carries a buffer list and one descriptor (ADR-030 § 2, ADR-034); a composite is a tree,
and there is no honest way to flatten one into that shape without inventing wire
structure this ADR has no business inventing.
A caller who wants a composite across a process boundary uses the streaming or file
path, which the format already defines for exactly this. hasattr(obj, "__hurray__")
therefore keeps meaning what it means, rather than becoming true for an object the
protocol cannot actually carry.
Alternatives Considered
Let hurray.Tensor hold a buffer-less head. The instinct ADR-031 established, and
the reason this question was deferred rather than answered there. Rejected under § 4: it
buys uniformity in the type and pays for it in every accessor, and it would make
hasattr — which ADR-031 § 2 chose deliberately over UnsupportedError — stop telling
the truth. It also asserts something the format does not: that a head is a thing you can
hold and use, when on the wire a head never appears without its members.
Represent a composite as a tuple or a dict — (head, [members]). Rejected for the
reason ADR-035 already rejected it for streaming: it invents a second, ad-hoc
representation that the next pass has to keep or break, and it gives the caller nothing
to validate against.
Derive the head's shape and dtype from the members. Rejected under § 2. Convenient for the common partition case, wrong in exactly the case that matters.
Expose composites read-only first, defer authoring. Rejected: reading is the half that is nearly free (the reader already decodes the tree and throws it away), and authoring is the half that unblocks a Python producer. Shipping the easy half would close none of the three gaps in the table above.
Consequences
Positive
- The last capability gap between
hurray-pythonand the Rust layers closes; all three blocked paths open on one decision. hurray.Tensorkeeps its accessor discipline intact, andhasattrkeeps discriminating.- The distinction the class draws — container versus tensor — is the one the format
draws, so
isinstanceteaches the reader something true about the wire.
Negative
- A second top-level object. Callers must now handle two kinds of thing coming out of a stream or a file, where before there was one. That is the format's shape, but it is still a branch every consumer has to write.
Compositecannot cross the native protocol, so the fastest in-process path does not carry them. Deliberate (§ 6), and a real limitation.- The head's shape and dtype are the caller's to get right. Core will reject a mismatch, but the caller must state something to be rejected — which is more work than deriving, and is the point.
- Nesting makes
repra tree. It should be depth-aware, asTiledLayout's already is (ADR-032 § Consequences).
Required Documentation Amendments
docs/impl/python-bindings.md— a normative § Composites: the class, the required head parameters, nesting, the I/O paths, and the native-protocol exclusion.docs/adr/ADR-031-*.md§ 5 — the deferral is answered; note pointing here.docs/adr/ADR-032-*.md§ 6 — theUnsupportedErrormessage now namesComposite.docs/adr/ADR-035-*.md§ 4 — the reader yields composites; note pointing here.docs/cookbook/composite-tensors.md,composite-streaming.md,composite-file.md— Python tabs beside the Rust recipes (#147).hurray-python/examples/composites.py— runnable.
Open Questions Deferred
- Whether a composite should be indexable —
composite[0]as sugar forcomposite.members[0]. Ergonomics; decide once there are users. - Whether
loadshould return composites lazily, reading members on access rather than eagerly with the head. Matters only for large trees, and the file reader would need to keep its handle open. - A native-protocol representation for trees (§ 6), which would need wire structure the format does not currently define.
ADR-037: Buffer metadata is a read-only value object, and alignment is measured
Status
Accepted (2026-08-23), implemented 2026-08-24 (§ 1–9, then § 6a)
Correction (2026-08-24, from implementation): § 6 specified hurray.BufferError for
copy=False on an under-aligned source. The implementation raises
hurray.CopyRequiredError instead — the binding already reserves that class for
"copy=False requested but a copy is needed", and __array__ raises it for the same
reason, so a caller catching one should catch both. Both subclass ValueError.
Extends ADR-032 to the last descriptor section without a Python representation, and applies its § 4 declaration-versus-evidence rule to a field where, unusually, the evidence is authoritative.
Context
The gap
docs/cookbook/layer-1-buffer-protocol.md is fourteen Rust blocks with no Python
counterpart, because none of it is reachable. BufferHandle in hurray-core carries
five fields; Python reaches three, two of them indirectly:
| Field | Per-buffer? | Reachable from Python |
|---|---|---|
byte_size | yes | indirectly — t.buffer(i).shape[0] |
alignment | yes | no |
sync_mode | yes | no |
device_tag | no — descriptor-wide | t.device.kind |
memory_class | no — descriptor-wide | t.device.memory_class |
buffer-protocol.md § Device Colocation requires device_tag and memory_class to be
identical across every buffer of one descriptor, so Tensor.device being a single
device is correct and stays. The genuinely per-buffer gap is alignment and
sync mode.
Issue #147 makes this a gap by policy. Two findings make it more than that.
Finding 1: the producer was declaring an alignment it did not provide
hurray.Tensor hardcoded MIN_BUFFER_ALIGNMENT (64) for every non-empty buffer, over
Box<[u8]> allocations made at align_of::<u8>() == 1. Measured:
owned, 256 bytes -> address % 64 = 32
owned, 1024 bytes -> address % 64 = 16
The spec makes a 64-byte-aligned base address a MUST and says a reader MAY rely on the declared value, so every descriptor this binding produced invited a consumer's aligned SIMD load against an address that usually was not. Fixed for owned buffers in PR #179, which over-aligns the allocation so the declaration becomes true.
Borrowed buffers are not fixed, and cannot be without this decision. NumPy's alignment, measured over twenty samples per size:
64 bytes: 10/20 were 64-byte aligned
1024 bytes: 0/20
16384 bytes: 0/20
4194304 bytes: 0/20
Essentially never — and it is worse than chance for exactly the arrays where copying
costs most. Above glibc's MMAP_THRESHOLD (128 KiB by default) an allocation is served
by mmap, which returns a page-aligned block; glibc then places a 16-byte header before
the pointer it hands back. Measured, and identical for plain malloc, so it is the
allocator rather than NumPy:
np.zeros(n) address % 4096, ten samples each
262144 bytes -> [16]
1048576 bytes -> [16]
4194304 bytes -> [16]
16777216 bytes -> [16]
A large NumPy array served by a fresh mmap is therefore exactly 16 bytes past a page
boundary, and never 64-byte aligned. Small arrays land on 64 about a quarter of the time,
by luck.
Correction (2026-08-24, from CI). An earlier draft of this section said every large array is 16 bytes past a page, deterministically. That overstates it, and a test written on the strength of it failed on CI. The mechanism is real — 0/40 allocations of 1 MiB and above landed on 64 when the arrays were held, so a fresh
mmapgenuinely never qualifies — but glibc also recycles freed chunks, and a large allocation that lands in a recycled chunk inherits whatever offset the heap's history gives it, including 64. The honest claim is therefore: NumPy's alignment is unpredictable, reliably wrong for a fresh mmap and a matter of heap history otherwise. Nothing may be written that assumes either outcome for a particular array — including a test.
NumPy documents no alignment guarantee of its own: numpy.org/devdocs/dev/alignment
defines only "true" and "uint" alignment for its internal copy code, both derived from
dtype.alignment — 8 bytes for float64, never 64.
BufferHandle::with_memory_class rejects any declared alignment below 64, so the binding
cannot state the truth either. hurray-python therefore cannot
currently ingest a NumPy array zero-copy and be conformant. That is not a bug in the
binding; it is a collision between the format's alignment floor and what the Python
ecosystem allocates.
Finding 2: an accessor is only worth shipping if the value behind it is true
Exposing alignment on top of a fabricated constant would convert a latent producer bug
into a documented API that lies to its caller, and would give the fabricated value users.
So the accessor and the measurement rule are one decision, not two.
What the prior art already settled
- ADR-031 — a layout is a property of a tensor, not a kind of object; and
AttributeErroroverUnsupportedError, sohasattrkeeps discriminating. - ADR-036 — a composite contains tensors, so it is a different kind of thing.
- ADR-032 — layouts get a class, but as immutable value objects with no back-reference to their tensor, because a metadata accessor that pins buffer lifetime is a defect in a zero-copy format. Its rejection text carries the sentence that decides the shape here: "The buffer table is a sibling section of the layout section, not a child."
Layout, quantization, statistics, and shard each have a Python class. The buffer table is the only descriptor section that does not.
Decision
1. hurray.BufferHandle — one frozen value object per buffer-table entry
class BufferHandle: # frozen; NOT constructible from Python
byte_size: int # the declared size in bytes
alignment: int # power of two; >= 64 when byte_size > 0
sync_mode: str # "producer_synced" | "event" | "consumer_stream"
device: Device # descriptor-wide; the same object t.device returns
is_empty: bool # byte_size == 0
with value equality, hashing consistent with it, and a repr of the form
BufferHandle(byte_size=1024, alignment=64, sync_mode='producer_synced', device=cpu).
sync_mode is a lowercase string per ADR-032 § 5's rule for small closed enumerations,
matching hurray_core::SyncMode's Display output — which hurray-inspect also
prints — and produced by a single internal helper so the three cannot drift.
A BufferHandle holds no reference to its tensor and none to any buffer. It is five
scalars copied out of the descriptor, so [h for t in stream for h in t.buffer_handles]
pins nothing. This is ADR-032's rejection of layout back-references applied verbatim.
2. t.buffer_handles is a tuple property; t.buffer(i) is unchanged
class Tensor:
buffer_handles: tuple[BufferHandle, ...] # descriptor order
def buffer(self, index: int) -> Tensor: ... # unchanged: 1-D uint8 data view
Metadata and data get separate accessors deliberately: reading how large or how aligned a buffer is MUST NOT require materializing anything that references its bytes. On a CUDA tensor, asking "how is buffer 2 aligned?" must not construct a view over device memory the caller cannot read.
A property rather than a method, because the asymmetry is informative — buffer(i) does
work and hands back something that pins memory; buffer_handles is five scalars per
buffer. len(t.buffer_handles) == t.buffer_count is then self-evident, and IndexError
comes from tuple indexing rather than a second hand-written error path.
hurray.Composite MUST NOT expose buffer_handles, for the reason ADR-036 § 4 excluded
buffer and values: a head owns zero buffers, and an empty tuple answers a question
that has no meaning.
3. BufferHandle.device is the tensor's Device object, identically
t.buffer_handles[i].device is t.device MUST hold for every i.
The wire row has five fields and the Python image should show five, so a reader diffing
against hurray-inspect does not have to ask where two went. But colocation means the
per-handle device is a redundant encoding of one fact, and five separately constructed
Device objects would invite callers to compare them and branch on a difference the
format forbids. Returning the same object gives wire fidelity without the hazard.
4. Everything is read-only, and the constructor is not touched
No alignment=, no sync_mode=, no buffer_handles=. hurray.BufferHandle is not
constructible from Python, like the hurray.Layout base class.
This follows ADR-032 § 4 rather than departing from it. ADR-032 made layout= a
constructor argument because a layout is a declaration whose truth the buffers cannot
supply — nnz and strides are the author's intent, and a buffer of the right size is
consistent with many of them. A buffer handle has no field of that kind:
| Field | Where its value comes from |
|---|---|
byte_size | len(buffer) — evidence, exact |
alignment | the base address — evidence, exact (§ 5) |
device_tag, memory_class | already the device= argument |
sync_mode | fixed by what Python can do (§ 6) |
A buffer_handles= parameter would be a parameter with no free variables: its only
possible use is to contradict the buffers, and every contradiction must be rejected. A
parameter whose only reachable effect is to raise is not an API.
5. Alignment is measured, never asserted
This does not carve an exception out of "never infer". ADR-032's rule governs structural claims — statements about what bytes mean, which bytes cannot settle. Alignment is a physical property of an address that the binding can observe. Measuring is observation; asserting 64 without looking is the actual violation, and it is what the code did.
- Empty buffers declare
alignment = 1, matchingBufferHandle::empty. - Owned buffers are allocated over-aligned to at least 64 and declare it (PR #179).
- Borrowed buffers MUST have their base address measured, and MUST declare the
largest power of two the address actually satisfies, capped at
PAGE_ALIGNMENT. A stronger true declaration is legal and useful to IPC and RDMA consumers, and free.
6. An under-aligned borrowed source is copied, and the caller can say otherwise
Because the spec's floor is 64 and NumPy essentially never provides it, from_numpy and
its siblings gain an explicit argument, matching the convention __array__ already
follows in this binding:
def from_numpy(array, *, copy: bool | None = None) -> Tensor: ...
# copy=None copy into a 64-byte-aligned allocation only if the source is under-aligned
# copy=False raise hurray.CopyRequiredError naming the measured alignment; never copy
# copy=True always copy
copy=None is the default because the alternatives are worse: refusing by default breaks
from_numpy for essentially every array, and copying unconditionally gives up zero-copy
even when the source would have qualified.
This is a real cost and it must be stated plainly rather than buried. Zero-copy
NumPy ingest, which the binding appeared to offer, was never conformant; the honest
version of it copies for most arrays. copy=False exists so that a caller who needs the
guarantee gets an error instead of a silent memcpy, and so the cost is measurable rather
than mysterious.
Note where the cost falls: a large array freshly served by mmap never qualifies, so the
copy is all but unavoidable exactly where it costs most. It is not certain — a large
allocation that lands in a chunk glibc recycled may happen to be aligned — but a producer
cannot arrange for that, and unpredictability is no better than a copy. This is the
strongest argument for the escape hatch below.
6a. The escape hatch: allocate through NumPy's pluggable allocator
Resolved (2026-08-24), implemented. Shipped as
hurray.aligned_allocator(), a context manager only — no module-level install. The policy is thread-local, so "install once at startup" would quietly do nothing for arrays allocated on worker threads; one shape avoids teaching that footgun. Noalignment=parameter either: 64 is the floor the format requires and the only reason the feature exists, and page alignment serves a different question (IPC/RDMA) that can be answered separately if it earns it.A prototype settled four things that the write-up below had left to assumption:
- The API slots must be called with the GIL held. They read and write a
ContextVar; callingPyDataMem_GetHandlerwithout the GIL segfaults immediately. Free under PyO3, but it means these calls must never sit inside apy.detach()block.np.zerosgoes throughcalloc, notmalloc. An implementation covering onlymalloclooks correct and silently allocates nothing.reallocis exercised (ndarray.resize) and is handed only the new size, so the allocator carries a 64-byte header recording each block's size. That also keeps everyalloc/deallocpair inside Rust, which is what the NEP's implementation notes warn to preserve.- Slots 304/305 verified against the installed NumPy 2.5.2 headers rather than recalled.
NumPy ≥ 1.22 lets an extension install a data-memory handler (NEP 49;
numpy._core.multiarray.get_handler_name() reports default_allocator today).
This is the use case NEP 49 was written for. Its Motivation lists "ensuring data alignment" first, citing a 2005 numpy-discussion thread on SIMD alignment and issue #5312, "Use an aligned allocator for NumPy?", where 64-byte alignment produced a 40× improvement in one reported case. NumPy considered guaranteeing alignment itself, declined, and shipped the hook instead — so "bring your own allocator" is not a workaround here, it is the ecosystem's answer to exactly this question. That also strengthens § Alternatives: NumPy's own maintainers did not treat a 64-byte requirement as unreasonable, they treated satisfying it as the consumer's job.
Three properties of the mechanism make a scoped installer safe, each verified against NumPy's own tests and headers rather than assumed:
- The handler is stored per array. "each
ndarraycarries with it the functions used at the time of its instantiation, and these will be used to reallocate or free the data memory of the instance." An array allocated inside the block is therefore freed by the matchingfreelong after the block exits. - It is thread- and context-local.
numpy/_core/tests/test_mem_policy.pyasserts both:test_thread_localityrequires that "the policy is not affected by changes in parallel threads", andtest_context_localitycoversasyncio. Installing a handler cannot leak into unrelated code. PyDataMem_SetHandlerreturns the previous handler, andNULLrestores the default, so save-and-restore is the intended usage.
With one gotcha that MUST be documented: child threads do not inherit the policy. Arrays allocated by a worker thread started inside the block get the default allocator, and will be copied on ingest like any other.
hurray-python SHOULD offer a handler that allocates 64-byte-aligned blocks:
with hurray.aligned_allocator():
weights = np.zeros(shape, dtype=np.float32) # 64-byte aligned
t = hurray.from_numpy(weights, copy=False) # genuinely zero-copy
This turns "Hurray always copies NumPy arrays" into "arrays allocated for Hurray are not copied", which is a materially different bargain for a producer that controls its own allocations — the case that matters for an inference pipeline writing checkpoints.
Deferred rather than decided here only because it is additive and independent: the
copy argument is needed regardless, for arrays the caller did not allocate.
Two implementation notes for whoever takes it: setting a handler is C-API only —
NumPy exposes get_handler_name and get_handler_version to Python but no setter — and
the numpy Rust crate this binding already depends on declares PyDataMem_SetHandler
and PyDataMem_GetHandler at API slots 304/305 but leaves them commented out, so
the binding must reach them itself. NEP 49's implementation PR also warns that mixing
allocators risks mismatched alloc/free pairs, and recommends a PyCapsule base when
taking ownership of data.
7. sync_mode is read-only, and byte-yielding paths refuse anything else
Read-only for a different reason than alignment, and the difference matters. Alignment is
a fact the binding can observe. Sync mode is a promise the binding cannot keep:
SYNC_EVENT means the producer recorded a device event and the consumer can retrieve
it through the C ABI, and no Python API supplies an event handle. A sync_mode= keyword
would let a caller emit a descriptor whose contract is unsatisfiable by construction —
the consumer does the correct thing, waits for an event that does not exist, and gets a
hard failure or a race. That is the same failure class ADR-030 and ADR-032 § 4 exist to
prevent: a descriptor that encodes and decodes cleanly and is wrong.
Anything Python constructs is producer_synced, which is a consequence rather than a
default: the interpreter cannot enqueue device work through this API.
Reading it carries an obligation. buffer-protocol.md § Consumer Requirement places a
normative duty on the consumer — inspect the field, and for SYNC_EVENT wait on the
producer's event before touching a byte. So:
A tensor holding any buffer whose
sync_modeis notproducer_syncedMUST refuse the paths that hand out its bytes —buffer(i),__array__,__array_interface__,to_torch,__dlpack__— withhurray.UnsupportedErrornaming the mode, until the binding can honour the wait.
This is not new policy. docs/impl/python-bindings.md § Stream parameter semantics
already requires BufferError from __dlpack__ for Event and ConsumerStream. This
extends the same discipline to every byte-yielding path, because the reason is the
buffer's contract rather than the protocol's — and it makes the refusal explicable,
since the caller can now read t.buffer_handles[0].sync_mode and see why.
__hurray__ and StreamWriter.write continue to relay such tensors unchanged. Relaying
a declaration is not reading a byte.
8. Module constants
hurray.MIN_BUFFER_ALIGNMENT = 64 and hurray.PAGE_ALIGNMENT = 4096, mirroring
hurray-core. The cookbook page's alignment sections cannot be translated without them.
9. Alignment is exempt from the round-trip obligation
ADR-032 § 4 requires that rebuilding a tensor from another's layout, quantization,
statistics, shard, and buffers produce an equal descriptor. That obligation does
not extend to alignment, and must not be made to: alignment describes an address,
and a rebuild that copies bytes has a different address. A tensor that arrived declaring
4096 and is rebuilt through Python bytes will honestly declare 64. Adding a settable
alignment= to force equality would restore the exact fiction § 5 removes.
Alternatives Considered
Parallel tuples on Tensor — t.alignments, t.sync_modes. Rejected: it
decomposes one 16-byte wire row into unrelated columns the caller must re-zip, and it is
the shape ADR-032 already rejected as "keeping layout as a string and adding separate
properties for the parameters". It leaves nothing to compare, hash, or diff against
hurray-inspect, and would make the buffer table the only descriptor section without a
class.
Put the metadata on the view t.buffer(i) returns. Rejected on three independent
grounds, the first fatal. Tensor::buffer returns a hurray.Tensor built by
new_borrowed_view, so this is literally "add .alignment to hurray.Tensor" — and
that view's descriptor carries a freshly fabricated handle, not the parent's
buffer-table entry, so t.buffer(0).alignment would report the view's value rather than
the parent's. Plumbing the parent's handle through would leave one attribute with two
referents. Second, it puts a per-buffer field on every tensor, so t.alignment exists on
a CSF tensor with nine buffers and answers about one. Third, it makes reading a number
require materializing a view over bytes — ADR-032's lifetime objection exactly.
A constructible or settable BufferHandle. Recorded as rejected, not deferred,
following ADR-032's treatment of layout back-references, because the alignment asymmetry
in § 9 makes it certain to be re-proposed. Every field is either evidence or an
unkeepable promise.
Relax the spec's 64-byte MUST for foreign memory. The tempting escape from § 6, and rejected — though it deserves the full argument, because it is the one option that would restore zero-copy NumPy ingest.
The case for it: DLPack imposes no alignment requirement at all, and a format whose most
important on-ramp must copy has an adoption problem — sharpened by the measurement above,
since the copy is certain for large arrays rather than occasional. The case against is stronger on this
project's own terms. docs/prior-art.md § 699 lists "alignment guarantees — 64-byte
minimum for SIMD; page-aligned for GPU/IPC — expressed in the spec, not left to
convention" among the gaps Hurray deliberately fixes; relaxing it gives away a stated
differentiator. The same document, at § 335, records that Arrow Flight loses alignment
through gRPC and that receivers must copy to aligned memory — so copying at an
unaligned boundary is the established remedy in the closest prior art, not a novel
penalty. And a MUST that consumers can rely on is worth more than one they must
defensively check, precisely because the consumer is the party that cannot see how the
buffer was made.
Recorded here rather than settled silently: this is a format question, and a binding
ADR cannot decide it. If the copy cost proves unacceptable in practice, the escalation
path is a spec amendment through format-spec-writer, not a quiet relaxation in
hurray-python.
Naming it BufferInfo or BufferSpec. Rejected: the spec calls it a buffer handle.
Inventing a third vocabulary is what ADR-032 § Consequences warned about for layout
names. Confusion with hurray-ffi's HurrayBuffer is already borne by hurray-core,
which has both, and is answered with a sentence of documentation.
Consequences
Positive
- The last descriptor section without a Python representation gets one, closing the buffer-protocol half of #147.
- The binding stops declaring an alignment guarantee it does not provide, so a consumer's aligned SIMD load stops being a coin flip.
- The consumer obligation
sync_modeencodes becomes visible instead of suppressed, and the paths that cannot honour it fail loudly with a message the caller can verify. - The house pattern holds across all five sections: layout, quantization, statistics,
shard, and buffer table are each a frozen value object with value equality, string
enums, and a
reprthat agrees withhurray-inspect.
Negative
from_numpynow copies for most arrays. The largest cost in this ADR, and the one most likely to be reported as a regression. The previous behaviour was zero-copy and non-conformant;copy=Falsemakes the difference diagnosable.- A sixth class in the namespace whose instances most callers never inspect.
alignmentdoes not round-trip through a Python rebuild, by design (§ 9).t.buffer_handlesis not identity-stable — a fresh tuple per access, liket.layout. Value equality mitigates it.- Refusing byte access on non-
producer_syncedbuffers converts a silent race into a visible failure, and will look like a regression to whoever meets it first.
Required Documentation Amendments
docs/impl/python-bindings.md— a normative § Buffer Handles covering the class, the tuple property, the identical-Devicerule, non-constructibility, the measured alignment rule withcopy, thesync_modestring set and byte-path refusal, and the § 9 exemption from the round-trip obligation.docs/cookbook/layer-1-buffer-protocol.md— Python tabs, plushurray-python/examples/buffer_protocol.py.hurray-python/src/buffer.rs— the D2 design note describes anOwnedvariant that PR #179 already replaced.
Open Questions Deferred
Shipping the NEP 49 aligned allocator of § 6a— resolved and implemented 2026-08-24; see the note on § 6a.- Requesting a stronger alignment at construction —
hurray.empty(..., alignment=4096)that allocates to the request and declares what it allocated. Explicitly not a reopening of § 4: an allocation request is an instruction to the allocator, not a declaration about memory the caller already holds. - Authoring
eventandconsumer_stream, which needs an event-handle type in Python and a CUDA-capable path. ADR-030's deferred per-bufferstreamquestion belongs with it. - Honouring a non-
producer_syncedbuffer rather than refusing it (§ 7). - Private device tags are lossy in Python —
device.rscollapses every tag in0xF0–0xFEto"private"and cannot author one. Same cookbook page, separate decision; raised by the architect during this review. validate_colocationhas no Python surface. Probably correct to leave unexposed, but the cookbook's § Device Colocation has nothing to translate to, so it wants an explicit decision rather than an omission.
ADR-038: Publish the Python API reference on the docs website with pdoc
Status
Accepted (2026-08-27).
Extends ADR-028, which put the Rust API reference (cargo doc) on the site but said
nothing about Python. Amends docs/website/README.md § 2, § 3, § 7, and § 9.
Context
The site publishes cargo doc for every version at /docs/<version>/api/. Python gets
nothing. A reader who wants to know what hurray.Tensor.buffer(i) returns, or which
keyword arguments hurray.save takes, has three options: the cookbook pages (task-shaped,
not exhaustive), docs/impl/python-bindings.md (normative requirements, not an API
reference), or help() in a REPL after installing the wheel. For a binding whose stated
job is to expose everything the Rust layers can express (issue #147, closed 2026-08-26),
having no browsable API surface is the last hole of that work.
Three facts make this cheap to close.
The content is already written. Every #[pyclass], #[pymethods] item, and
#[pyfunction] carries a /// comment — CLAUDE.md requires one on every public item —
and PyO3 puts those verbatim into __doc__. They are Markdown, including the
## Examples (Python) blocks and the parameter tables. A generator that renders Markdown
docstrings gets the whole binding documented on day one.
Signatures come for free. PyO3 0.29 synthesises __text_signature__ from the Rust
argument list, including keyword-only markers and defaults:
>>> hurray.Tensor.__text_signature__
(buffer, dtype, shape, device=None, *, aux_buffers=None, layout=None,
quantization=None, statistics=None, shard=None)
>>> hurray.arange.__text_signature__
(start, stop=None, step=None, *, dtype=None, device=None)
No text_signature attributes have to be added — there are none in the tree today, and
none are needed.
The submodules survive introspection. hurray.dtype and hurray.device are module
objects added as attributes rather than importable packages; a generator driven by
introspection rather than by source files documents them as pages of their own.
What the obstacle actually is
docs/website/README.md § 2 says the build "MUST NOT require Node, npm, or any
package-manager network install beyond fetching the two pinned binaries and the Rust
toolchain already used by the workspace." Every Python documentation generator is a pip
install, so the rule as written forbids all of them. The rule exists to keep the supply
chain small and the pipeline free of a Node toolchain (ADR-028), not to forbid Python —
and CI already pip-installs maturin, numpy, scipy, and pytest in the
python-conformance job. The constraint needs to be stated as what it means.
There is a second, harder constraint that no amount of wording removes: a Python doc generator has to import the module, and the module is a compiled extension. Generating the reference for a version means building that version's wheel first. The docs pipeline does not touch Python today.
Two defects found while scoping this
/docs/<version>/api/is a 404.cargo doc --no-deps --workspaceon a multi-crate workspace writestarget/doc/<crate>/index.htmlfor each crate and notarget/doc/index.html, so the directory the spec advertises has no landing page.- Nothing links to the API reference. ADR-028 says it is "linked from the book's navigation"; no such link was ever added, to the book, the shell nav, or any page.
Both are in scope here: publishing a second API tree next to a broken, unreachable first one would not be finishing the job.
Decision
1. pdoc, pinned, at /docs/<version>/python-api/
pdoc generates the Python API reference. It is one pip package with no configuration
file, it renders Markdown docstrings (--docformat markdown), it works by importing the
module — so a compiled PyO3 extension is no special case — and it emits a self-contained
static tree with its own search index, which is exactly the shape the deploy already
handles for cargo doc.
Output goes to /docs/<version>/python-api/, alongside /docs/<version>/api/, so one
version prefix continues to scope everything about that version. pdoc writes an
index.html that redirects to hurray.html, so the directory URL resolves.
2. Every version, not just dev and stable
The Python reference is generated for each version the site builds, exactly as cargo doc
is, and skipped for a version whose tree has no hurray-python — the same shape as the
existing "no website/book, skip this version" rule.
Restricting it to dev plus the stable tag was considered and rejected. stable is a
copy of a tag's build, so producing it requires knowing which tag is stable before
building — an ordering dependency in the script that buys nothing today, when the repo has
zero release tags. Uniformity with cargo doc costs less than the special case. If tag
count ever makes this slow, the escape hatch is ADR-028's: incremental builds that carry
prior version outputs forward.
3. The version's own wheel, built from the version's own tree
build-site.sh builds each version's wheel with maturin from that version's source tree
and installs it before running pdoc. A version's API reference therefore describes that
tag's binding, which is the same immutability rule § 4 of the website spec already states
for books.
The wheel is a debug build: the docs are made from docstrings and signatures, and
--release would multiply the build time of every version for no change in output.
4. build-site.sh requires the Python tooling, and says so
The script already hard-requires git, zola, mdbook, and cargo; python3 with
maturin and pdoc joins that list. Both documentation workflows install them at pinned
versions, the same way they install the two pinned binaries. The script does not silently
skip the Python reference when the tools are missing — a version that half-builds is worse
than one that fails, which is the rule the website spec already sets for tagged builds.
5. One "API Reference" page in the book, linking both
A new book page lists the Rust crate docs and the Python module docs with relative links
that resolve inside the version's own path. This is the navigation entry ADR-028 called for
and never got, and it is version-correct by construction: the 0.2.0 book links to
0.2.0's API docs.
build-site.sh additionally emits api/index.html as a redirect to hurray_core, so the
advertised /docs/<version>/api/ path resolves instead of 404ing.
6. Docstrings are user-facing text now
Publishing them makes every /// comment on a Python-visible item part of the public
documentation. Two consequences apply immediately:
- The
#[pymodule]doc comment is the landing page of the reference. It contained an internal "Module layout" table keyed by development phase (8a.1,8a.2,8c) and issue numbers. It is rewritten as a user-facing overview, in line with the standing rule that phase references do not appear in user-facing files. - Rustdoc shortcut links (
[`Descriptor::decode`]) render as literal brackets in a Python docstring. Where such a link appears on a Python-visible item it is rewritten as prose naming the Python attribute. Rust-internal items keep theirs.
Alternatives Considered
-
Sphinx +
autodoc. The standard in the Python ecosystem, and what NumPy, SciPy, and PyTorch use; it also offers intersphinx cross-linking to NumPy's docs. Rejected: it bringsconf.py, a theme dependency, adocs/source tree of.rststubs listing every class, and a build step per version — a whole authoring surface for output we would immediately reduce back to "one page per module." The intersphinx benefit is small for a binding whose public API is thirty-odd classes in one flat namespace. -
mkdocstrings(MkDocs). Same objection as Sphinx plus a second site generator, when the project already runs two. -
Hand-written Markdown API pages in the book. No new tooling at all, and it would live next to the cookbook. Rejected: it duplicates the docstrings, and a duplicate drifts. The docstrings are the thing CI already forces to exist; the reference should be generated from them.
-
Generate
.pyistubs and document those. Would also give type checkers something to read, which is real value. Rejected here: stubs are a separate deliverable with their own correctness question (they can disagree with the extension), and hanging the API reference off them makes the docs depend on that correctness. Stub generation stays an open idea on its own merits. -
Link
help()and publish nothing. Rejected: it requires installing the wheel to read the docs, and it is not reachable from a search engine.
Consequences
-
The docs build now compiles
hurray-pythononce per version. Cost is one debugcargo buildper version on top of the existing per-versioncargo doc. With zero release tags it is one build; it grows linearly with tags, and shares the workspace cache with thecargo docstep for the same tree. -
The website spec's toolchain rule is narrower than it was. § 2 now bans the Node/npm toolchain and unpinned installs specifically, rather than all package managers. This is the rule ADR-028 intended; the previous wording over-reached.
-
Two API doc UIs. rustdoc and pdoc look nothing alike, and neither looks like mdBook. This is the same trade-off ADR-028 already accepted for mdBook plus Zola, at a place readers cross less often.
-
Docstrings are now published. Every Python-visible
///comment is user-facing text and should be written that way.rust-developeranddoc-updaterown this; the two cleanups in § 6 are the first application of it, not a one-off. -
A reader can now find the API reference. The book has a navigation entry for it, and
/docs/<version>/api/resolves — neither was true before this ADR.
ADR-039: Structured descriptor sub-objects cross the C ABI as typed field reads
Status
Accepted (2026-10-08), implemented 2026-10-11 (PR #228)
Resolves the design question deferred to architect by
issue #200. Applies ADR-019's
evolution rules to the C ABI surface; amends no earlier decision.
Superseded in part by ADR-040 (2026-10-12), on naming only. When the five optional sections joined this identifier space,
HurrayLayoutFieldbecameHurrayDescriptorFieldand the three accessors below lost theirlayout_infix:hurray_descriptor_field_{u64,u64_array,i64_array}, plus a new..._i64and..._f64. Everything else in this ADR — theuint32_ttag space, the identifier encoding, the widening rule, the capacity idiom, the two statuses, thelevelparameter, the placement onHurrayDescriptor— is unchanged and still governs.
Context
hurray_descriptor_decode parses and validates a descriptor through hurray-core, so
the reading is complete for every layout the core supports. The C surface then
exposes almost none of the result. Its 29 symbols cover scalars — rank, element type
tag, layout tag, byte offset, buffer count — and exactly one variable-length field,
hurray_descriptor_shape. Nothing with internal structure crosses the boundary.
The consequence is that a C consumer handed a CSR descriptor learns
layout_tag == 0x07 and nothing else: not nnz, not which buffer is row_ptr. It
cannot address a single element. Compliance Level 1 requires an implementation to
correctly interpret layout-specific fields for all Tier 1 layouts (0x01–0x09),
which is why the generated status page reports hurray-ffi as a partial Level 1 reader.
The layout payload is not the only gap, and that is what shapes this decision. The
five optional descriptor sections — quantization, shard, statistics, extension type,
composite member — each appear in website/coverage-matrix.toml with a c_symbols
entry, and not one of those symbols exists in hurray.h. Six structured sub-objects are
unreachable from C, for the same reason, and whatever pattern is chosen for layouts will
be the pattern each of the other five inherits. The question is therefore not "how does
a C caller read CSR nnz" but "how does a structured descriptor sub-object cross the C
ABI" — asked once, answered once.
The field inventory
What a complete layout payload surface must carry, read from hurray-core/src/layout/:
| Layout | Tag | Scalar fields | Variable-length fields |
|---|---|---|---|
| Row-major | 0x01 | — | — |
| Column-major | 0x02 | — | — |
| Strided | 0x03 | — | strides (signed) |
| Tiled | 0x04 | outer_layout, inner_layout, nesting depth | tile_shape, outer_strides, inner_strides |
| Morton | 0x05 | — | morton_bits |
| COO | 0x06 | nnz, is_sorted | — |
| CSR | 0x07 | nnz | — |
| CSC | 0x08 | nnz | — |
| CSF | 0x09 | nnz | mode_order |
| Block-paged | 0x0A | page_size, num_pages, paged_axis, num_seqs, kv_role, layer_index*, block_table_index_type | — |
| Composite | 0x0B | rule, combine_op, member_count | — |
| Hilbert | 0x40 | hilbert_order, hilbert_rank | — |
* optional — absent on a conforming descriptor that does not carry it.
Three properties of that table drive the decision:
- It is about 26 fields, against an existing ABI of 29 symbols. One accessor per field nearly doubles the surface for one sub-object out of six.
- Every scalar is unsigned or boolean. There is no signed scalar payload field.
Tiledis recursive (inner_tiled: Option<Box<TiledLayout>>, bounded byMAX_TILED_DEPTH), so any design must address a nested level or deliberately refuse to.
Constraints that bind the answer
.claude/rules/unsafe-ffi.md: public handles MUST be opaque, Rust struct layout MUST NOT cross the boundary, and every exported function MUST return aHurrayStatus.- ADR-019: the format evolves by allocating tag values in reserved ranges behind flag bits and length prefixes — never by adding a fixed-offset field that an older reader mis-parses. A retired tag value is never rebound. The C ABI should be disciplined the same way, or the two evolution models diverge.
hurray_descriptor_shapealready establishes the house idiom for a variable-length read: the caller passes a capacity, receives the true length, and retries afterHURRAY_ERR_BUFFER_TOO_SMALL.
Decision
1. Three typed field accessors, keyed by a field identifier
HurrayStatus hurray_descriptor_layout_field_u64(
const HurrayDescriptor *handle, HurrayLayoutField field,
uint32_t level, uint64_t *out_value);
HurrayStatus hurray_descriptor_layout_field_u64_array(
const HurrayDescriptor *handle, HurrayLayoutField field,
uint32_t level, uint64_t *out_values, size_t *out_len);
HurrayStatus hurray_descriptor_layout_field_i64_array(
const HurrayDescriptor *handle, HurrayLayoutField field,
uint32_t level, int64_t *out_values, size_t *out_len);
Three symbols, not twenty-six. Two array functions rather than one because the stride fields are the only signed arrays in the format and widening them into an unsigned accessor would misreport the negative strides the spec explicitly permits.
2. HurrayLayoutField is a uint32_t tag space, not a C enum
pub type HurrayLayoutField = u32;
Named constants, exactly as HurrayStatus is done. Not a #[repr(C)] enum: a C
caller can pass any integer, and an out-of-range value materialised as a Rust enum is
undefined behaviour. A u32 parameter validated in Rust is sound for every input, which
is the only form that holds at an ABI boundary where the caller is not trusted.
3. A field identifier encodes the layout it belongs to
field_id = 0x0001_0000 | (layout_tag << 8) | field_index
| Constant | Value |
|---|---|
HURRAY_LAYOUT_FIELD_STRIDED_STRIDES | 0x0001_0300 |
HURRAY_LAYOUT_FIELD_TILED_TILE_SHAPE | 0x0001_0400 |
HURRAY_LAYOUT_FIELD_TILED_OUTER_LAYOUT | 0x0001_0401 |
HURRAY_LAYOUT_FIELD_CSR_NNZ | 0x0001_0700 |
HURRAY_LAYOUT_FIELD_BLOCK_PAGED_PAGE_SIZE | 0x0001_0A00 |
HURRAY_LAYOUT_FIELD_HILBERT_ORDER | 0x0001_4000 |
The embedded layout tag makes § 6's applicability check a comparison rather than a
lookup table, and makes the field space inherit the layout tag space's existing
reserved and private ranges for free. 0x0000_0000 is never a valid identifier, so a
zeroed variable is caught rather than silently read as the first field.
The high half-word is the sub-object range. Layout fields occupy 0x0001_xxxx;
each of the five optional sections takes its own range when it is implemented
(quantization 0x0002_xxxx, and so on). Allocation follows ADR-019's anti-rebind rule:
a retired identifier is never reused for a different field.
4. Scalars widen to uint64_t
Every scalar payload field is unsigned or boolean (§ Context), so widening is lossless:
bool reads as 0 or 1, and the u8 wire bytes of kv_role, block_table_index_type,
rule and combine_op read as their documented tag values. One accessor replaces one
per width. Should a signed scalar field ever be specified, it gets its own accessor
then — additively, without touching these three.
5. Arrays reuse the hurray_descriptor_shape capacity idiom
*out_len carries the caller's capacity in and the true length out; a short or null
buffer returns HURRAY_ERR_BUFFER_TOO_SMALL with the true length written, so the
caller can allocate and retry. Being consistent with the one variable-length accessor
that already exists matters more than any marginal improvement on it.
6. Absence and inapplicability are distinct statuses, never sentinel values
Two new codes:
| Code | Value | Meaning |
|---|---|---|
HURRAY_ERR_FIELD_NOT_PRESENT | -12 | The field is valid for this layout but this descriptor does not carry it — an absent optional field (layer_index, outer_strides, inner_strides). |
HURRAY_ERR_FIELD_NOT_APPLICABLE | -13 | The field belongs to a different layout than this descriptor's. |
Absence is a state in this format, not a value — it is what a cleared flag bit means — so it must not be reported as a zero a caller could mistake for data. Keeping the two apart tells a caller whether it asked a reasonable question about a tensor that lacks the field, or asked the wrong question entirely.
HURRAY_ERR_FIELD_NOT_APPLICABLE is the honest cost of § 1: a C compiler cannot check
that a field belongs to the layout in hand, so the check moves to runtime. Making it a
named status rather than a generic error is what keeps that cost diagnosable.
7. The level parameter is present from the outset
Only Tiled nests. level MUST be 0 for every other layout, and a non-zero level
against a non-nesting layout returns HURRAY_ERR_INDEX_OUT_OF_BOUNDS.
HURRAY_LAYOUT_FIELD_TILED_DEPTH reports how many levels exist, so a caller can walk
them.
It is carried now, unused by nine of the twelve layouts, because adding a parameter later is an ABI break while an unused parameter costs a register. Exposing only the outermost level and deferring nesting — the option #200 raises — buys nothing and spends a future break.
8. The accessors stay on HurrayDescriptor
Not on the combined handle #174 will
introduce: a consumer may legitimately hold descriptor bytes and no buffers at all, and
that consumer needs these reads. HurrayTensor will expose its descriptor as a borrowed
const HurrayDescriptor * rather than duplicating the accessor family, which also makes
this decision independent of that one.
9. C ABI version 4 → 5
Additive — new symbols, new constants, no change to an existing signature. #174's
breaking change bumps again when it lands, rather than sharing this one: two accurate
bumps describe the surface better than one that conflates an addition with a break, and
a consumer that only needs these reads can gate on >= 5.
Alternatives Considered
One named accessor per field — hurray_descriptor_csr_nnz, and twenty-five more.
What #200 proposed, and the most discoverable option: a C programmer gets autocompletion,
the right type, and a compile error for a field that does not belong to the layout in
hand. Rejected on how it scales. Twenty-six symbols for one sub-object out of six means
the surface grows linearly in fields forever, and each of the five remaining sections
re-grows it — quantization alone has five schemes. It also needs twenty-six near-identical
layout-tag guards. The decisive argument is ADR-019: that document's thesis is that this
format evolves by allocating tag values, not by adding fixed members, and § 3 is that
thesis applied to the ABI. The cost of rejecting it is real and is recorded in § 6 and
§ Consequences.
One #[repr(C)] payload struct or union — the other option #200 raises. Rejected on
three independent grounds, any one sufficient: it publishes Rust struct layout across the
boundary, which .claude/rules/unsafe-ffi.md forbids; it cannot carry the six
variable-length fields without embedding pointers whose validity is tied to the
descriptor handle, which is a use-after-free waiting for a caller who keeps the struct
and drops the handle; and it cannot express recursive Tiled at all. It would also
change shape on every new layout — an ABI break per layout, inverting ADR-019 exactly.
An opaque layout sub-handle — hurray_descriptor_layout returning a
HurrayLayout * carrying its own accessors. Rejected because it reduces nothing: the
per-field accessors still have to exist on the sub-handle, and it adds a second handle
plus a borrow-lifetime question against the parent descriptor.
Return the encoded layout payload as bytes and let the caller parse it. Rejected
because it makes every binding reimplement descriptor parsing, which is the specific
thing hurray-ffi exists to prevent. It would also re-expose the wire format to
consumers that should only see decoded values.
Consequences
hurray-ffi's Level 1 cell flips from ◐ to ✅ once every Tier 1 layout's fields are reachable. Level 2 stays ❌: there is no encode entry point in C at all, which is a separate deferred decision, not a missing accessor.- The five optional sections become additive work against a settled pattern — the same three functions and a new sub-object range, with no further design.
- A field-to-layout mismatch surfaces at runtime, not compile time. Higher-level
bindings should wrap these reads in typed accessors;
hurray-pythonalready has the layout object hierarchy (ADR-032) to hang them on. - The header is less self-documenting than named functions would be. Mitigated by prefixing every constant with its layout and by a worked consumer example in the cookbook, not by the signatures themselves.
- Test surface: every field of every layout in the table, the capacity-retry path, both
new statuses, nested
Tiledat more than one level, and alevel != 0rejection.
Required Documentation Amendments
docs/impl/c-ffi.md— the accessor family, the field identifier scheme, and the sub-object range allocation.docs/cookbook/layer-7-c-ffi.md— read a CSR descriptor'snnzand a strided descriptor'sstridesfrom C, including the capacity retry.website/coverage-matrix.toml— thelevel-1row'sffi = "partial:ffi-level-1"override is replaced by ac_symbolslist, and theffi-level-1note is deleted.docs/SUMMARY.md— add this ADR, and ADR-038, which is on disk but absent from the navigation.
Open Questions Deferred
- The writer side.
HurrayWriteris a typedef with nothing behind it and there is no encode entry point, deferred pending the async bridge design. Not an accessor question, and untouched here. - Whether the five optional sections share one identifier enum with reserved ranges, as § 3 assumes, or take one type each. Settled when the first of them is implemented; the range discipline in § 3 leaves both open.
ADR-040: One field identifier space covers the layout payload and all five optional sections
Status
Accepted (2026-10-12)
Settles the identifier-space question ADR-039 deferred, and renames the three
accessors it introduced. Supersedes ADR-039 §§ 1–3 on naming only; its mechanics —
the uint32_t tag space, the widening rule, the capacity idiom, the status pair, the
level parameter, the placement on HurrayDescriptor — are unchanged and still
govern. Closes issue #231.
Context
ADR-039 settled how a structured descriptor sub-object crosses the C ABI: a field is
named by a uint32 identifier whose high half-word says which sub-object it addresses,
and read through a typed accessor. It shipped that for the layout payload (PR #228) and
left one question open, to be answered by whichever section came first:
Whether the five optional sections share one identifier enum with reserved ranges, as § 3 assumes, or take one type each. Settled when the first of them is implemented; the range discipline in § 3 leaves both open.
All five are now being implemented at once, so the question is live. They are the last
sub-objects hurray-core can describe and C cannot read: every one is ✅ for
hurray-core and hurray-python on the generated status page and ❌ for hurray-ffi,
and website/coverage-matrix.toml names five hurray_descriptor_* symbols for them
that do not exist in hurray.h.
Two further facts bear on the answer. First, the sections introduce field types the
layout payload does not have: per-tensor affine zero_point is an int32 and its
scale a float32, and six of the statistics are float64. ADR-039 § 4 anticipated
exactly this — "should a signed scalar field ever be specified, it gets its own
accessor then — additively, without touching these three." Second, Statistics
guards each field group with a bit in computed_mask, and the spec requires a reader
to check that bit before using a field.
Decision
1. One identifier space, not five
HurrayLayoutField becomes HurrayDescriptorField, a single uint32_t tag space
addressing the layout payload and every optional section:
field_id = (range << 16) | (discriminant << 8) | field_index
| Range | Sub-object | Discriminant byte |
|---|---|---|
0x0001 | layout payload | layout tag |
0x0002 | quantization | scheme tag; 0x00 addresses the section itself |
0x0003 | statistics | 0x00 |
0x0004 | shard | 0x00 |
0x0005 | extension type | 0x00 |
0x0006 | composite member | 0x00 |
This is what ADR-039 § 3 already assumed, and it keeps the applicability check a comparison. Five separate types would have multiplied the accessor family by five for no gain: a caller must know which sub-object it is addressing in either design, and in this one the identifier says so.
Quantization is the only section with an inner sum type, so it is the only one that
uses the discriminant byte — for the scheme tag, exactly as the layout range uses the
layout tag. 0x00 is not a valid scheme tag, which frees that slot for
HURRAY_QUANT_FIELD_SCHEME_TAG: the field a caller must read first, since it decides
which scheme-specific identifiers apply. The other four sections are flat and carry
0x00 there, leaving 256 field indices each and the discriminant byte available should
one of them ever grow a variant.
Allocation follows ADR-019's anti-rebind rule: a retired identifier is never reused for a different field. 43 identifiers are allocated here — 18 quantization, 12 statistics, 2 shard, 10 extension type, 1 composite member.
2. The accessors are renamed, not duplicated
One space addressing six sub-objects cannot be read through functions named for one of them. The three shipped symbols are renamed and two are added:
| Before (ABI 5) | After (ABI 6) |
|---|---|
hurray_descriptor_layout_field_u64 | hurray_descriptor_field_u64 |
hurray_descriptor_layout_field_u64_array | hurray_descriptor_field_u64_array |
hurray_descriptor_layout_field_i64_array | hurray_descriptor_field_i64_array |
| — | hurray_descriptor_field_i64 |
| — | hurray_descriptor_field_f64 |
The HURRAY_LAYOUT_FIELD_* constants keep their names; they do name layout fields. The
new constants are prefixed by section: HURRAY_QUANT_FIELD_*, HURRAY_STATS_FIELD_*,
HURRAY_SHARD_FIELD_*, HURRAY_EXT_TYPE_FIELD_*, HURRAY_COMPOSITE_MEMBER_FIELD_*.
..._i64 serves per-tensor affine zero_point; ..._f64 serves scale and the six
float64 statistics. Both widen losslessly from their wire widths. A signed read is
not a convenience here: an unsigned accessor would report a zero_point of -128 as
2^64 - 128, and a quantized dequantization cannot absorb that.
No deprecated aliases. The ABI window is open until 1.0 by the policy on issue #225, and carrying two names for one function past this pass would cost more than the relink does.
3. A statistic reads as absent unless its guard bit is set
HURRAY_ERR_FIELD_NOT_PRESENT covers an absent section, an unset optional field —
and, for the statistics, a field whose computed_mask bit is clear. The output is left
untouched, as it is for any other absence.
This enforces the spec rule rather than restating it. The statistics fields are
physically present whatever the mask says, each holding whatever the writer left there,
so the only failure mode of this section is a reader that forgets to test the mask —
and a C caller is exactly the reader with no type system to remind it.
HURRAY_STATS_FIELD_COMPUTED_MASK reads the raw mask for a caller that would rather
branch on it directly.
4. The quantization section is decoded per call
TensorDescriptor stores the quantization payload verbatim, because typed decode
belongs to a layer with schema context. A field read therefore decodes it on each call
rather than caching a typed form in HurrayDescriptor.
Caching would move a possible failure into hurray_descriptor_decode, which today
accepts a descriptor whose quantization bytes it never typed — a decode that currently
succeeds would start failing. The payload is at most 24 bytes of fixed-width fields, so
the work is arithmetic, not allocation.
5. C ABI version 5 → 6
The rename removes three exported symbols, so a v5 consumer must relink. Two new functions and 43 new identifiers are additive.
Alternatives Considered
A parallel hurray_descriptor_section_field_* family, keeping the layout names.
Rejected: the identifier's range already tells a caller which sub-object it is
addressing, so a second family would duplicate every signature and every dispatch to
re-encode information the argument carries. It would also leave the question of which
family a future sub-object joins, permanently.
One identifier type per section (the deferred alternative). Rejected: five types
force either five accessor families or one family with an untyped parameter, which is
the shared space with extra ceremony. The embedded range already partitions the space,
and C has no type-level benefit to collect — these are uint32_t typedefs precisely
because a C caller may pass any integer.
Named accessors for the sections (hurray_descriptor_statistics_nnz, …). Rejected
for the reason ADR-039 rejected it for layouts: 43 symbols now, and the count grows
with every section the format gains. ADR-019's thesis is that this format evolves by
allocating tag values.
A repr(C) struct per section. Rejected for ADR-039 § Alternatives' reasons,
which apply unchanged: it publishes Rust layout across the boundary, and it cannot
express Statistics's mask-guarded optionality without the caller reimplementing the
guard.
Keeping zero_point in the u64 accessor, documented as a bit pattern. Rejected:
it makes every caller cast, and a caller that forgets gets a plausible-looking huge
number rather than an error.
Consequences
- All five section rows flip to ✅ for
hurray-ffion the generated status page. What remains ❌ there is Level 2 (no encode entry point in C at all, deferred pending the async bridge design) and Level 3 (unscheduled by decision, stated on the page). - A v5 consumer must relink. There is no released ABI 5 consumer outside this repository; 0.1.0 shipped ABI 4.
- The accessor family is now five functions and will not grow again for a new
sub-object — only for a new field type, and the format has no scalar type left that
u64/i64/f64cannot carry. - Reading a quantization field costs a decode. Measured in fixed-width arithmetic over ≤ 24 bytes; if a caller ever reads enough fields per descriptor for it to matter, the fix is a typed-section handle, not a cache inside this one.
- The guard-bit enforcement means a C caller cannot reproduce a Rust caller's ability to
read a stale statistic deliberately. That is the intended asymmetry;
hurray-corekeeps the fields public for a writer that is computing them.
Required Documentation Amendments
docs/impl/c-ffi.md— the renamed family, the two new accessors, the range table.docs/cookbook/layer-7-c-ffi.md— read a quantization scheme and its parameters, a shard's parent shape, and a mask-guarded statistic from C.website/coverage-matrix.toml— the five section rows'c_symbolslists, and thelevel-1row's accessor names.docs/SUMMARY.md— add this ADR.- ADR-039 — a Status note pointing here for the naming.
Open Questions Deferred
- The writer side. Unchanged from ADR-039:
HurrayWriteris a typedef with nothing behind it and there is no encode entry point, deferred pending the async bridge design. Not an accessor question. - Whether a composite head's member descriptors are reachable from C.
HURRAY_LAYOUT_FIELD_COMPOSITE_MEMBER_COUNTsays how many follow andHURRAY_COMPOSITE_MEMBER_FIELD_ROLEreads one member's role once that member's own descriptor has been decoded, but C has no way to walk from a head to its members — that is a streaming/container concern, not a field read, and it waits on the Layer 5 C surface.
ADR-041: hurray-io splits into a sans-io core; every binding drives it
Status
Accepted (2026-10-19)
Direction approved on review; the invariants in §§ 2–6 were tightened in response to it. Accepted once the first implementation pass landed (#237), which answered the destination-allocator question and settled on two state machines rather than one. Step 2 followed in #239, closing issue #238: the Tokio readers and writers became drivers over the core, and composite reassembly moved out of the read core into a synchronous
CompositeAssembler— reassembly consumes no bytes, so it is not the byte machine's work, and keeping them apart is what lets one core serve both the flat and the assembled view of a stream.Step 3 followed:
HurrayReaderandHurrayWriterbecame real handles over the § 8 transport vtable, driving the same core with no runtime linked, and the two stream rows flipped forhurray-ffi. Two decisions it settled. § 8's rule that a failing callback moved no bytes is what makes a failed call retryable — it leaves the handle's position matching the transport's, soUnusable::LostReadCountandLostWriteCountare unreachable from a conforming C transport, exactly as step 2 found for the Tokio drivers; a write that fails after its first byte is the one irrecoverable case, and is reported as a closed sink because that is what it is. And #174 was re-examined here as this section said it should be: a stream read yields a descriptor and its buffers, so it yields oneHurrayTensorowning both. The capsule half of #174 — ADR-030 § 2's pointer — is deliberately left, so that issue stays open. Steps 4–6 remain, and thelevel-2row with them: a C writer can emit descriptors it decoded but cannot yet originate one, and the encode surface that fixes it is a descriptor builder rather than the singlehurray_descriptor_encodesymbol the row names.Step 4's first half followed (#242): the file container got its own two machines,
FileReaderCoreasked by range andFileWriterCoreemitting one forward pass, withFileReader/FileWriterreduced to drivers. It settles the one-vs-two open question for good: four cores, not one. The deciding property is that the request vocabulary is the core's entire interface — "the nextnbytes" and "the bytes[a, b)" are different interfaces, not two settings of one — and the file carries no stream frame prefix, so a shared machine would have needed a mode flag. What is shared is what is genuinely one contract: theDestinationAllocseam, because §§ 2 and 5 are one promise about payload memory.It also found an asymmetry § 6 did not anticipate. A random-access core needs no unusable state at all. Every request is absolute and the position advances only on a reported count, so a lost count is recovered by re-issuing the same range, and re-filling the destination from the range's start is idempotent. By the same argument a failed job leaves the core usable — only a failed open is terminal, there being no index to start again from. § 6's unusable state is a property of a stream, not of the contract.
Step 4's second half followed (#247):
HurrayFileReaderandHurrayFileWriterdrive the random-access core over the same vtable, with the KV section reachable in both directions, and thefile_write,file_readandfile_kvrows flipped forhurray-ffi. ABI 7 → 8.It replaced § 8's
seekwith a positionalread_at, and the reason is step 4a's own finding: the core's request vocabulary is absolute ranges. Aseek+readpair reintroduces a cursor the core never uses, turns one range request into two callbacks with two statuses, and leaves a "seek succeeded, read failed" interleave to reason about. One positional call carries its own position, which makes § 8's "a failing callback moved no bytes" rule say something stronger here than it does for a stream: a failed range read is retryable because re-issuing it reads the same bytes.sizestayed, called once at open, because a container is located from its end and no amount of reading discovers a file's length.It also found the one place where the C surface must promise less than the stream surface does. [RETRACTED by step 4c — see § 6b.] A failed file write ends the file. By the time bytes move, the tensor has consumed its name in the index and staged its descriptor, and the core offers no rollback for either, so a retry under the same name would describe a file other than the one on the sink. The writer is poisoned whether the failure landed before or after the first byte. The underlying sharp edge is the Rust driver's too, and is reported as #248 rather than fixed here.
Step 4c retracts the paragraph above. Investigating #248 showed that the burnt name and the staged descriptor were not a constraint to document but a state split to remove: an operation was committing to the core at submission while earning its index entry at completion. Two further defects came with it. The orphan descriptor does not merely waste space — it makes the file non-conforming, because
file-format.md's tensor region is a sequence of complete tensors and the orphan is never padded to 8, so the next real descriptor can begin unaligned. And neither tokio driver poisoned the core on a mid-itemErr, soFileWritercould emit a half-written tensor and a clean footer index: a self-consistent index over a non-conforming region, with no error raised anywhere. § 6b replaces the retracted promise; the terminal case is a partially emitted item, not a failed one.Step 5's first half followed (#251):
hurray-python's synchronous surface stopped driving the Tokio drivers through ablock_onand now drives the cores itself overstd::io, which is the half of § 9 that is not theasyncioAPI. No async runtime is linked into the extension any more —hurray-pythonwas the last member askinghurray-iofor itstokiofeature — and the nested-block_onconstraint that shapedfile_io.rs's composite write is gone with it. Every byte the writers emit is unchanged, verified against the pre-refactor build, and the frozen corpora now read through a third reader.It made
FileWriterCore::poisonpublic, and the reason generalises: a composite group spans several operations, so poisoning it is the driver's job, and § 1 puts drivers at the edge — in another crate, for every binding but one. A tool the in-crate driver needs and an out-of-crate driver cannot reach is a hole in the sans-io boundary, which the dead-code warning on atokio-less build made visible.It also found that the Python binding copies every payload out of the core's aligned destination into a 64-aligned allocation, so a handle declaring 4096 arrives at an address satisfying 64 — #235 one layer up, and the only binding still paying a tensor-sized copy per read. Reported as #253 rather than fixed in a pass whose acceptance was unchanged behaviour; it is the same question § Open Questions parks as "who allocates the aligned destination".
#253 is fixed. A Python tensor now adopts the
Bytesthe core froze, as a thirdBufferStorevariant whose owner is theBytesitself — the same answer § 2 gives C, wherehurray-ffihands back aHurrayBufferpointing into the destination. No binding pays a payload copy per read any more, and the declared alignment is true of the bytes in all three. The destination is still the core's allocation, not a NumPy one: aDestinationAllocthat allocated a NumPy array would need the GIL at the point the core callsallocate, inside thepy.detacha read holds for its whole I/O section. That seam stays open and unused by Python, which costs nothing now that nothing is copied.Step 5b landed (#252, ADR-042).
hurray.aio.StreamReaderandhurray.aio.StreamWriterare driven by Python's own event loop: readiness is awaited, and each transfer reports its byte count in the same synchronous call, so § 6a's case 2 is unreachable rather than merely unlikely. The two tests § 11 step 5 names both exist and both pass — a read cancelled mid-tensor resumes on the same reader, a write cancelled after its first byte poisons — and so does the third case they imply: a write cancelled before its first byte leaves the writer usable, which is § 6b's other half.It needed nothing new in
hurray-io, and less of it than expected: the driver never evaluates § 6b's predicate.StreamWriterCoresetstornat an item's first accepted byte and clears it on completion, andis_unusable()reports it — so dropping a cancelled operation is the driver's whole obligation. (WriteOp::emitted()is public and would answer the same question; consulting it would duplicate the predicate in the binding.) Step 5a had to publishFileWriterCore::poisonfor the equivalent need, so this is the sans-io boundary holding rather than leaking again. ADR-042 records the three shapes § 9 left open, plus a fourth it did not anticipate: a composite's borrowed node tree has to be pinned in a flat arena, because the binding's continuation-passing builder cannot outlive the frame that built it.Step 6 landed (#258), and with it the last open question this ADR carried. Composites now cross the C boundary on both surfaces, which flips the last two
hurray-ffirows of the interchange table: every one of the seven § Context names is ✅ in all three columns.The C sum type is preorder plus
member_count— the format's own shape. A head followed by its members, each node declaring how many of the nodes after it are its direct members, asHurrayItemon the read side and an array ofHurrayCompositeNodeon the write side. The deciding argument is not ergonomics: a tree of pointers crossing an ABI can be cyclic, and a walker on either side loops forever on one, while an array with member counts cannot describe a cycle at all. It is also one allocation for a C caller instead of one per level, and it is what the wire already says. The kind tag is not redundant with the count, becauseCompositeLayoutpermitsmember_count == 0: a zero-member head and a plain tensor are otherwise indistinguishable.It needed nothing new in
hurray-io— both cores have handled composites since the passes that built them (§ 10's explicit stack, step 2'sCompositeAssembler, step 4a'sflatten_composite), andFileWriterCore::poison, published for step 5a, is what the C file driver needed for the same reason. Converting the C array into the borrowed treehurray-iotakes is the binding's own work, done in reverse preorder — a node's members always sit after it — with one isolatedunsafeto publish a boxed array for the rest of the call, the shape ADR-042 § 4 wrote down.Two things it exposed, both fixed here. The whole composite-validation family of
hurray_core::Errormapped toHURRAY_ERR_INTERNAL, blaming the library for a caller's tree; they are layout-structural errors and now reportHURRAY_ERR_INVALID_LAYOUT. They were unreachable from C before this step, which is why nothing had caught it. And a file composite spans several index entries rather than being one item, so a defect found after the head is on the sink — a member's name, not only a transport failure — cannot be recovered from: the writer is poisoned, and § 6b's retry applies only to a failure on the head itself. The stream surface has no such case, a composite there being one item.
Resolves the bridge question HurrayReader and HurrayWriter have carried as bare
typedefs since C ABI 1, and which ADR-034 deferred alongside "does streaming reuse
this handle". Addresses issue #233.
Unblocks three parked things: Level 2 for the C surface, the seven streaming and file
rows that are ❌ for hurray-ffi, and the half of
#174 that waits on a Layer 5 C surface —
which puts this upstream of the 1.0 gate in
#225.
Context
hurray-io is generic over tokio::io::AsyncRead/AsyncWrite and every entry point is
an async fn: StreamReader::next_tensor, StreamReader::next_item,
FileReader::read_tensor, FileWriter::finish. The C ABI is six versions of blocking,
HurrayStatus-returning functions. Nothing connects them, so the generated status page
shows seven consecutive ❌ in the hurray-ffi column — stream write, stream read, stream
composites, file write, file read, file KV metadata, file composites — against ✅ in both
other columns. A C, Go or Julia consumer can decode a descriptor handed to it and cannot
obtain one.
What the bindings do today
hurray-python has already answered this question, and its answer is the thing to look
at before choosing another one. Every I/O entry point in the bindings is
py.detach(|| runtime.block_on(/* … */))
— the GIL is released so other Python threads keep running, but the calling coroutine is
blocked for the duration. There is no async method in the Python surface at all.
The cost is recorded in the code, at the point where it was paid
(hurray-python/src/file_io.rs):
// One block_on per step rather than one around the whole loop: a composite's
// borrowed node tree is built by a *synchronous* continuation (see
// with_file_nodes), and block_on cannot be called from inside another.
A nested block_on panics, so the composite file writer is structured around the bridge
rather than around the problem. That is one wart today; it is the shape of every future
wart in the same place.
This matters more than it looks, because hurray-python is not a courtesy binding. The
parity rule in CLAUDE.md says anything the Rust layers can express about a tensor, the
Python bindings must express too — and Python is the canonical runtime with an event
loop, which is exactly the audience the one async interface in this domain was built for.
What comparable projects chose
Surveyed for #233. The useful axis is not sync versus async but who drives progress: whose thread runs the work, and who owns the file handle or socket.
| Implementation | Whose thread | Whose I/O | What the C caller must own |
|---|---|---|---|
| Arrow C stream interface | caller, blocked | producer | nothing |
| GGUF / llama.cpp | caller, blocked | library | nothing |
| zenoh-c | library | library | a callback, a drop policy |
| rustls-ffi | caller | caller | sockets, buffers |
| quiche | caller | caller | sockets, buffers, timers |
| gRPC core | caller, polling | library | threads, a poll loop |
| UniFFI | caller's executor | library | an event loop |
| Arrow async device stream handler | producer | producer | an event loop |
Four findings bear on the decision.
- Nothing in this domain exposes async over a C ABI. Arrow — whose C data interface
Hurray's is modelled on — documents
ArrowArrayStream::get_nextas a blocking pull-style iteration function, witherrnocodes and an in-band end-of-stream (get_nextsucceeds and returns an already-released array). GGUF is blunter:llama_model_load_from_filetakes a path and blocks, mmapping tensor data. - The one async interface in the domain was built for event-loop languages, not C.
Arrow's
ArrowAsyncDeviceStreamHandleris consumer-allocated and producer-pushed, and its stated motivation is that the synchronous interface is difficult to use with anything resembling Pythonasync/await— the targets named being R, Python and Ruby. It remains experimental years after the blocking interface stabilised. - The sans-io entries answer a different question. rustls-ffi and quiche are not C API shapes; they are architectures from which a small C API falls out. rustls-ffi's own claim is the point: "Rustls doesn't do any I/O on its own … because of that it can be used equally well in a blocking or non-blocking I/O context." quiche goes further, and makes the application own the event loop and its timers too, because QUIC has a clock.
- The alternatives that give C real concurrency charge for it. gRPC core creates no threads of its own and requires the application to poll a completion queue "preferably in a tight loop" or no I/O progress happens. UniFFI hands futures across by making the foreign side supply the executor, does not support cancellation at all, and assumes an event loop exists on the other side — which, for C, it does not.
So the C surface should be blocking. That much every comparable project agrees on, and it was never really in doubt. The open question is what sits behind it.
What the two paths already do, which is not symmetrical
The read path allocates: read_buffers creates one BytesMut per buffer, resizes it,
reads into it and freezes it. The write path borrows: write_tensor(&desc, buffers: &[&[u8]]) validates each slice's length against its handle, writes the encoded
descriptor, then write_alls each caller-owned slice in turn. It never assembles a
contiguous output buffer, and it never copies a payload.
Any core that serves both has to preserve both properties, and they are different properties. § 2 and § 3 state them separately for that reason.
An alignment gap that exists today
buffer-protocol.md is normative twice over: the base address of every non-empty buffer
MUST be at least 64-byte aligned, and a reader MAY rely on the declared alignment for
SIMD loads. BytesMut allocates through Vec<u8>, so what both readers hand out carries
the allocator's alignment for a 1-byte type — 8 or 16 in practice — while the descriptor
beside it declares 64, or 4096 for a GPU/IPC writer.
That is a live conformance bug, filed as #235 and fixable without this refactor. It is recorded here because it is the reason § 5 states alignment as a tested guarantee rather than assuming the current behaviour is already correct.
Decision
1. hurray-io splits into a sans-io core and an async shell
The core is a state machine that consumes and produces bytes and touches no file, socket
or runtime. The Tokio StreamReader/StreamWriter/FileReader/FileWriter become thin
drivers over it, keeping their current public signatures.
This is the layer boundary rustls and quiche are built on, and it is the format's own shape: descriptors precede their buffers, the encoding is self-delimiting, back-references are forbidden, and the file writer emits in a single forward pass. A format designed so a reader never looks backwards is a format whose reader wants to be a state machine.
Half of it already exists. TensorDescriptor::decode is pure and lives in hurray-core;
stream/frame.rs is 71 lines that read a 10-byte prefix, take descriptor_length, read
that many bytes and hand them to the core. That is a state machine, written in async.
2. On the read path the core supplies the destination
The core MUST hand the driver an aligned, correctly sized destination to fill, and
MUST NOT accept payload bytes the driver has already placed somewhere else.
rustls_connection_read_tls is the model: the caller supplies a callback that writes
into rustls's own buffer.
What this does and does not promise. It promises no payload copy after the transport writes into the destination — the transport's write is the only copy, and for a socket that copy is unavoidable. It is not "end-to-end zero-copy", which only an mmap or a shared memory handle can give and which is a separate path, not this one. Keeping those two claims distinct matters, because the naive design this rules out — driver buffer in, core buffer out — adds a second, tensor-sized copy on every tensor, and that is what makes it unmergeable rather than merely suboptimal.
The destination is owned by the core for the lifetime of the item being read, and is handed to the caller only when that item completes (§ 6).
3. On the write path the core stays borrowed and scatter/gather
Symmetrically, and this is not the same requirement restated: the core MUST accept
borrowed, non-contiguous payload slices and MUST NOT assemble them into one output
buffer. It emits the encoded descriptor and then yields each caller-owned slice in turn
for the driver to write, which is exactly what write_tensor does today with write_all
per buffer.
A core that concatenated would copy every payload on the way out, which is the same defect as § 2's in the other direction, and it would also lose the vectored-write opportunity the current shape leaves open.
Borrowed payloads need a stated lifetime, because an async binding cannot assume one. A Python coroutine cannot hold arbitrary borrowed memory across a suspension point, so:
- The caller MUST keep every payload slice valid until the core reports that item written.
The core MUST expose that point explicitly — a write is complete when the core says so,
not when the last
writecallback returns. - A binding that cannot make that promise for caller memory MUST pin it for the duration:
hurray-pythonholds a reference to the backing object (a NumPy array, a buffer handle) from the moment a write begins until the core releases it, and the release is what drops the reference. - The core MUST NOT retain a payload pointer after reporting the item written.
4. The progress contract is explicit, and partial progress never loses position
The core's driver-facing API is a step function. One request is outstanding at a time, and the driver answers it before the core produces another:
NeedInput { dst: &mut [u8] } driver fills, then reports how many bytes
NeedOutput { src: &[u8] } driver writes, then reports how many bytes
Item(…) one tensor or composite node is complete
Finished clean end, at an item boundary
Error(…) see § 6 for what survives it
The rules that make short and zero-length progress unambiguous:
- A short read is not EOF. Reporting
0 < n < dst.len()is normal: the core keeps its position and re-offers the remainder. Onlyn == 0means the transport reached end of input — and then end at an item boundary isFinished, while end mid-item is an error.stream/frame.rsalready draws exactly this line ("a 0-byte read before any descriptor byte is a clean EOF"), and the core MUST preserve it. - A short write is not completion, and a zero-length write is not success. Reporting
n < src.len()re-offers the remainder. Reportingn == 0MUST NOT be treated as progress; a driver whose sink is closed MUST say so through a distinct signal, which is an error if an item is in flight. - The progress API never blocks and never waits. It does not know what a transport is. A driver that cannot proceed without blocking returns to its own executor, and the core holds position until the driver comes back. This is the property that lets one core serve both a blocking C driver and a Python event loop (§ 8, § 9).
- Byte counts are reported for every
NeedInputandNeedOutput. Nothing is inferred from a callback's return value alone.
5. Alignment is a declared guarantee with a test that can fail
The destination of § 2 MUST satisfy the alignment the buffer handle declares — not 64 as a constant, and not whatever the allocator happens to return. The spec requires that declaration to be a power of two and at least 64 for a non-empty buffer, and permits a reader to rely on it for SIMD loads, so the declaration is the contract.
This is stated as a decision because today's implementation does not keep it (#235), and
because BytesMut is the obvious thing to reach for and is wrong. An empty buffer
(byte_size == 0) stays legal at any declared power of two.
The test that proves it: read tensors whose handles declare 64, 128 and 4096 and assert
ptr as usize % declared == 0 for every buffer handed out, across the multi-buffer and
composite paths. It must fail if the allocation path ever regresses to a plain Vec-backed
allocation. hurray-python/src/numpy_allocator.rs and tests/test_aligned_allocator.py
are the in-repo precedent for both the technique and the test.
6. Cancellation, resumability, and the unusable state
Cancellation interrupts the driver's transport operation. It never interrupts the core, which has no I/O to interrupt.
- The core is resumable across cancellation if and only if the byte position is known.
A driver MUST perform a transport operation and report its byte count as one
uninterruptible step; cancellation is observable only at a step boundary. Then a
cancelled
awaitleaves the core exactly where the last report left it, and the next call continues. - Otherwise the core enters an explicit unusable state. If bytes moved and could not be reported, the core MUST be marked unusable by name — a state its API exposes and every subsequent call reports — never left to diverge silently from the transport. "Probably still fine" is not a state.
- A partially filled destination is never observable. It belongs to the core until the item completes, so a cancelled read cannot hand anyone a half-written or partially initialised buffer.
- Caller memory outlives the operation that uses it. A buffer the binding owns — Python's included — MUST remain valid until every outstanding native operation has released it. The core releases on item completion or on entering the unusable state, and the binding drops its reference then, not before.
- In Python: a cancelled
await reader.next_item()leaves the reader usable and the nextawaitresumes the same tensor. A reader that became unusable raises a distinct, named error on next use rather than an arbitrary one.
Deciding this now rather than at step 5 is deliberate: it constrains the core's state transitions and its ownership model, which are built in step 1.
6a. What the atomic step costs in asyncio, and the shape it forces
The atomicity requirement above is cheap in a blocking C driver and is the hardest thing
in this ADR to honour in Python, because asyncio can deliver CancelledError at an
await after the operation behind it has already moved bytes. Writing the three cases
out is what makes the constraint visible:
dst = core.need_input() # a memoryview into the core's destination
n = await transport.readinto(dst) # cancellation point
core.report_filled(n) # synchronous, never blocks (§ 4)
- Cancelled before any byte lands — nothing moved, position known, resumable.
- Cancelled after bytes landed in
dstbut before the count is reported — the bytes are in the core's buffer and the core believes none arrived. Position unknown, and no inspection can recover it, because0x00is valid tensor data. Unusable. - The await returned and the report has not run yet — unreachable, provided the report is a synchronous call with no await between it and the transfer. § 4's non-blocking progress API is what guarantees that, so this case is closed by construction.
Case 2 is reachable with exactly the primitive this design wants —
loop.sock_recv_into(sock, buf) performs the recv in a loop callback and then resolves
a future, and a cancellation that lands between those two events loses the count.
The apparent escape is a trap. Using asyncio.StreamReader.read(n) avoids case 2
entirely, because cancelled data stays in the StreamReader's own buffer and never reaches
ours — but it hands back a bytes object that must then be copied into the core's
destination. That is precisely the second, tensor-sized copy § 2 exists to forbid. A
Python async driver cannot buy resumability with a copy; it would be trading the reason
for the refactor against one of its requirements.
So the async driver MUST be readiness-driven, not transfer-await-driven. The await
covers readiness only (loop.add_reader/add_writer, or a protocol's
data_received); the transfer and its byte-count report happen together inside one
synchronous callback, where no cancellation can interleave. Then case 2 cannot arise and
§ 6's resumable branch is the normal path rather than a theoretical one.
The write side is not symmetrical, and is worse. A cancelled read with an unknown
count damages only our own state. A cancelled write with an unknown count has already put
an unknown number of payload bytes on the wire, and a stream cannot be un-sent: the
reader at the far end will see a truncated item. So when the count is unknown on the write
path, the core MUST become unusable and the sink MUST be treated as poisoned — a fresh
writer over the same connection is not a recovery. sock_sendall is unusable here for
that reason; the same readiness-driven shape is required.
This is step 5's hardest requirement and step 1 has to leave room for it: the report API must be callable from inside an event-loop callback, which § 4 already requires by making it non-blocking.
6b. An item commits at completion; a partially emitted item is terminal
A submitted item MUST NOT mutate any state the core carries past the operation until the core reports that item written. The name it consumes, the index entry it earns, and the bytes it stages all belong to the operation, not the core, until that point. An operation abandoned before its first byte reaches the transport therefore leaves the core exactly as it was, and resubmitting the same item — under the same name — MUST succeed.
The "nothing emitted" predicate is exactly decidable, which is what makes the rule implementable rather than aspirational: a core's write position advances in exactly one place, the progress report of § 4, so a core always knows whether the item in flight has had a byte accepted.
Once an item's first byte has been accepted, the output can no longer be made conforming by
any forward-only writer: a stream has no resynchronisation point, and a file container's
tensor region is a sequence of complete tensors (file-format.md §§ Tensor Region,
Descriptor Placement). Rollback is not available either — the file writer never seeks, and
the transport vtable of § 8 has no truncate. So the core MUST become unusable at that first
byte and MUST retire that state only when the item completes. It MUST NOT depend on a
driver reporting the abandonment, because a driver that returns an error and drops the
operation reports nothing.
finish is an item under this rule. A footer that emitted nothing is retryable; one that
emitted anything is terminal.
Note (non-normative): this supersedes "a failed file write ends the file" in the step 4b preamble above. The terminal case is a partially emitted item, not a failed one, and the two surfaces now promise the same thing.
7. Two drivers, because the format has two access patterns
The sequential driver feeds bytes forward: stream read and write, and the file writer's
single pass. The random-access driver answers range requests — the core asks for bytes
[a, b) and is given them — because the file reader seeks: the trailer sits at
file_size - 40, the index is located from it, and tensors are read by name.
Whether that is one state machine with two drivers or two machines is left to § Open
Questions; what is decided here is that a sequential byte feed alone does not serve
FileReader, so the core's interface admits both.
8. The C ABI: a blocking surface over a transport vtable, with stated rules
HurrayReader and HurrayWriter become real handles. Every call is synchronous and
returns a HurrayStatus. Transport is a vtable of callbacks — read, write, read_at, size
— supplied by the caller at construction.
Amended by step 4b: this section said
seekwhere it now saysread_at. Step 4a established that the file core asks in absolute ranges, so the positional read is the callback that matches the request: one call, one count, no cursor anyone must agree about, and a failed call that re-reads the same bytes when re-issued. A callback is free to implement it as a seek and a read of its own — that is where a cursor belongs.
Not a path and not a file descriptor: a file reader must address its input, file descriptors are already a portability wart of their own (#215), and a vtable composes with a socket, a pipe, a memory span, or a caller's own object. It is the structural choice Arrow makes when it hands a consumer four producer callbacks.
The vtable is blocking by contract — and that contract belongs to the driver, not the core. A C callback that blocks until it has bytes is correct and expected here, precisely because § 4's progress API is independently non-blocking.
The rules the vtable needs, all of which are ABI surface and none of which may be left to inference:
- Partial transfers are legal. The read callback returns the number of bytes written into the destination; fewer than requested is a short read, not an error, and not EOF.
- Zero is not success on the write side. The read callback returning
0means end of input. The write callback returning0is not completion — a closed or failing sink is reported through a distinct status, which is an error if an item is in flight. - EOF, error and closed are three statuses, never one.
- A range request is absolute.
read_atreads from the offset it is handed and keeps no cursor of its own;sizeis asked once, when a file reader is created.read_atreporting0is a file shorter than its own structure claims, not an end of input — a container's end is known from its size, never discovered by reading. - No reentrancy. A callback MUST NOT call any Hurray function on the handle that invoked it. The alternative is a reentrancy and thread-safety contract on every handle, bought for a use case nobody has.
- The context pointer belongs to the caller. Hurray never frees it. The vtable carries an optional release callback for it, symmetric with the buffer release callbacks that already cross this ABI (ADR-009).
- One handle, one thread at a time, which the absence of an internal runtime makes a one-sentence rule rather than a threading chapter.
9. Python keeps its synchronous API and gains an asynchronous one
The existing sync surface does not change; its py.detach(|| block_on(…)) calls become
py.detach(|| /* drive the core */). Alongside it, an async surface driven by Python's
own event loop — await reader.next_item() — with no Rust runtime involved. This is
UniFFI's "the foreign bindings supply the executor" without anything being generated, and
it is the parity rule satisfied in the one direction it never has been.
The async surface MUST NOT drive the blocking vtable of § 8. Python's loop drives the core directly through § 4's progress API and performs its own awaits for transport. One core, two drivers, no blocking call inside a coroutine.
10. Composite traversal becomes an explicit stack
StreamReader::read_item currently recurses through nested composites via
Box::pin(self.read_item(depth + 1)), and StreamWriter::write_composite_unchecked
recurses the same way. A state machine cannot, so the nesting becomes an explicit stack.
This is an improvement to be collected rather than a cost to be absorbed:
max_composite_depth is currently enforced against an implicit call depth, and becomes a
bound on a visible structure that a C caller can also walk.
Step 6 made that literal. The visible structure a C caller walks is the preorder node array of § Open Questions' resolved sum-type entry, and the bound reached the C surface as
HurrayReaderOptions::max_composite_depth, which bounds the assembler and leaves the flat view of the same bytes untouched.
11. Sequencing, and the condition that stops it
- Sequential stream core, proving the contracts that the rest depends on: § 2's destination ownership, § 3's borrowed scatter/gather output, § 4's partial-progress rules (short read, zero read at a boundary and mid-item, short write, zero write), § 5's alignment test, and § 6's resumability — including at least one nested-composite case. Composite state is where suspension gets interesting, and a core validated only on single tensors can be elegant there and awkward the moment a composite must survive being suspended mid-member.
- Rust drivers over it. Acceptance is behavioural, not textual: unchanged public signatures, unchanged observable behaviour, byte-for-byte wire compatibility on a corpus, and the existing integration tests passing. New or adapted test harnesses are legitimate and expected; what is not legitimate is a changed signature or a changed byte.
- The C surface: handles, the transport vtable and its § 8 rules, stream read and write. ABI version bump, decided in its own pass.
- Random-access driver and the file core, then the C file surface. Split in two when
scheduled, as step 3's precedent required: the Rust core and drivers
(#242), then the C file surface with
its own ABI bump (#247), which is where
seekbecameread_at. - Python async surface, implementing the semantics § 6 already fixes — readiness-driven per § 6a, with a test that cancels mid-tensor and asserts the reader resumes rather than poisons, and one that cancels mid-write and asserts it poisons rather than silently truncating.
- Composites across both surfaces, including the C sum-type answer. Full C composite
support lands here; the core's composite handling was proven in step 1. Landed as
#258:
hurray_reader_next_item,hurray_writer_write_composite,hurray_file_reader_read_composite,hurray_file_writer_write_composite, theHurrayItem/HurrayCompositeNodepair,max_composite_depthonHurrayReaderOptions(ABI 8 → 9), andHURRAY_ERR_NOT_COMPOSITE— which is a question wrongly asked, not a defect, exactly as step 4b argued forHURRAY_ERR_NOT_FOUND.
Stop condition: stop if the contract in §§ 2–4 cannot be satisfied without an additional payload copy — a flaw in the abstraction, demonstrated by argument and not by a single disappointing benchmark. A first prototype that copies once and can be improved is not that. The fallback is then the C-only blocking façade in § Alternatives, which this ADR rejects on architecture rather than on feasibility, and which step 1 leaves untouched.
Alternatives Considered
A blocking façade over a hidden runtime, for C only. Each HurrayReader owns a
current-thread Tokio runtime and block_ons one operation per call; hurray-io is
unchanged. This was the first recommendation for #233 and it works — it is what Arrow's C
consumers live with. Rejected because it builds a second copy of hurray-python's wall
instead of removing the first, leaves the nested-block_on constraint in place for every
future binding, and puts a Tokio runtime inside a C consumer's binary to do work that
needs no concurrency. Retained explicitly as the fallback under § 11's stop condition.
A completion queue (gRPC core's model). Buys genuine concurrency, at the price of a C consumer that must donate threads and poll in a tight loop or see no progress at all. No consumer has asked for concurrency; several will want a single blocking call.
Futures across the C boundary (UniFFI's model). Requires an executor on the far side, and C does not come with one. Note that this is precisely what § 9 does for Python, where an executor is a given — the model is right for one binding and wrong for the other, which is itself an argument for putting the split below both.
A library-owned background runtime with completion callbacks (zenoh-c's shape). Puts a thread-safety contract on every handle and makes buffer-release-callback timing the hardest thing in the documentation to state correctly.
A feed(bytes) core that returns assembled buffers. The conventional sans-io shape,
and the reason §§ 2–3 are written as requirements. It copies each payload twice over the
round trip — once into the driver's buffer, once into the core's — which for a format whose
first principle is zero-copy converts the refactor from a gain into a regression.
Leave the C ABI without I/O. The status quo, and defensible for exactly as long as nobody writes a non-Python binding. It also leaves #174 — and therefore the 1.0 compatibility contract — blocked on a question nobody is answering.
Consequences
- The seven streaming and file rows flip for
hurray-ffi, and Level 2 for the C surface stops being "deferred pending the async bridge design" because the bridge exists. hurray-pythongains an async surface. An asyncio application stops needing a thread pool to read a Hurray stream.- Surgery on shipped, tested code: 1,879 lines across
hurray-io/src/stream/andhurray-io/src/file/, released in 0.1.0. The public API is intended to survive unchanged, which § 11 step 2 turns into a behavioural check. - The invariants in §§ 2–6 are binding on both possible core shapes, which is what makes leaving the one-versus-two-machines question open safe rather than vague.
hurray-iogrows an internal API with its own tests and documentation. This only breaks even if the Tokio layer genuinely shrinks to a driver; if both layers end up substantial, the result is net new surface, whichCLAUDE.mdcalls slop. Reviewers should hold step 2 to that standard.- The risk this trades into: complexity moves out of Tokio and into a cross-language memory and progress protocol. §§ 2–6 exist to make that protocol specified rather than emergent, and they are the part of this ADR to review hardest.
- A future WASM, Go, or Node binding inherits a core it can drive, rather than arriving with its own bridge question.
- The C surface still has no cancellation mid-call, and this ADR does not invent one. Arrow's has none either and UniFFI declines to support it where the host can. § 6 gives Python real cancellation semantics because Python can express them.
- The atomicity rule in § 6 constrains the shape of the Python async driver, not just its
internals: it must be readiness-driven (§ 6a). A driver written the obvious way —
awaita transport method that transfers into our buffer — is either unsound under cancellation or buys soundness with the copy § 2 forbids. This is the single most likely place for this design to be implemented wrongly and still look correct.
Required Documentation Amendments
docs/impl/c-ffi.md— the reader and writer handles, the transport vtable and every § 8 rule, the blocking contract, the thread-affinity rule, and § 6b's writer retry contract (which replaces "a failed file write ends the file").docs/impl/rust-reference.md— the core/driver split, and §§ 2–6 as requirements on an implementation rather than internal details, including § 6b's commit-at-completion rule for writer cores.docs/impl/python-bindings.md— the async surface, its relationship to the sync one, and § 6's cancellation and buffer-lifetime semantics.docs/cookbook/— a C consumer reading a stream through the vtable, and the Python async counterpart on the same page, per the parity rule.website/coverage-matrix.toml— the seven streaming and file rows'c_symbols, and thelevel-2row.docs/SUMMARY.md— add this ADR.
Open Questions Deferred
One state machine or two.Answered: two per direction, four in all. Step 1 split the stream's read and write because §§ 2 and 3 are different properties rather than mirror images; step 4 split the file from the stream because the request vocabulary — "the nextnbytes" versus "the bytes[a, b)" — is the core's interface, and because the file's descriptors carry no stream frame prefix. Sharing one machine would have meant a mode flag, which this project does not do. §§ 2–6 bound every shape, as this question assumed, and theDestinationAllocseam is shared across all four because §§ 2 and 5 are one promise.Who allocates the aligned destination.Answered: a binding may. The core supplies the destination (§ 2) at the declared alignment (§ 5), andDestinationAllocis the seam a binding implements so a Python read can land directly in a NumPy array, asnumpy_allocator.rsalready does elsewhere. Step 1 made it aBox<dyn DestinationAlloc>handing outBox<dyn Destination>; step 2 made it an associated type on a core generic over the allocator instead. The reason is not taste: a boxed trait object erasedSync,UnwindSafeandRefUnwindSafefrom the core and so from every published type containing it, whichcargo semver-checkscaught against 0.1.0'sStreamReader. The generic form keeps those traits, drops a heap allocation per buffer, and letsfreezetakeselfby value. Type erasure is no longer available by default, which costs nothing yet: a C allocator is a concrete struct holding the caller's vtable, so step 3 instantiates the core over that rather than erasing it.- The read result versus #174.
next_tensoryields a descriptor plus its buffers, exactly the pairing #174 folds into oneHurrayTensor. Whichever lands first constrains the other; this ADR deliberately does not decide it, but it does remove the reason #174 was postponed, so #174 should be re-examined when step 3 is scheduled. Composites as a C sum type.Resolved in step 6: preorder plusStreamItemis a tensor or a composite head with members, and the C surface has no way to express a sum type. § 10's explicit stack has to produce something C can walk.member_count, the format's own shape —HurrayItemout, an array ofHurrayCompositeNodein, one node per tensor, nesting in the count column. Chosen over a pointer tree because an array with member counts cannot describe a cycle, which a caller-built tree crossing an ABI can; and thekindtag stays because a head may legally declare zero members.A lateResolved by § 6b in step 4c:finishfailure. The file writer's footer index is written last, so a writer that fails after the data leaves a file with no index. What both surfaces promise about that state is unstated today and should not stay that way.finishis an item, so a footer that emitted nothing is retryable and one that emitted anything is terminal. Both surfaces promise that; the core-level retry is reachable from C, and not from the tokio driver, whosefinish(self)consumes the sink.
ADR-042: The asyncio surface is a Python driver over the stream cores
Status
Accepted (2026-11-01)
Implements ADR-041 § 9 and § 11 step 5b, and records the three shape decisions
§ 9 left open. Closes ADR-035 § 1's deferral of an asyncio surface — § 1 gains an
amendment note rather than being worked around — and closes
issue #252.
The semantics are not decided here. ADR-041 §§ 6, 6a and 6b fixed cancellation, resumability and the terminal state before any of this was written, deliberately, so that the hard part was settled while the cores were being built rather than discovered while being driven. This ADR is about where the driver lives and what it may hold.
Context
ADR-035 § 1 chose a blocking Python surface and deferred asyncio with a condition
attached: revisit when a real multiplexing consumer appears, rather than work around it.
ADR-041 § 9 is that revisit. It also removes the reason the deferral was comfortable — the
binding no longer owns a tokio runtime at all, so "async in Python" no longer means
bridging two executors. It means driving a state machine from the loop that is already
running.
What § 9 fixed: the sync surface does not change, the async surface is driven by Python's own event loop with no Rust runtime involved, and the async surface MUST NOT drive the blocking transport vtable of § 8. One core, two drivers, no blocking call inside a coroutine.
What § 9 did not fix: which language the driver is written in, what the boundary between Python and Rust carries, and how a borrowed-payload write operation survives a suspension. Those are below.
Decision
1. The driver is Python source, not pyo3
hurray-python becomes a mixed Rust/Python package: python-source in pyproject.toml,
the __init__.py maturin was generating checked in as-is, and hurray/aio.py beside it.
The readiness logic is loop.add_reader / add_writer, a future per wait, and
remove_reader on the way out — about 120 lines that read as what they are in Python, and
as hand-built future plumbing in pyo3. ADR-041 § 6a's own pseudo-code is Python-level;
writing the driver in Python keeps the thing being reviewed the same shape as the thing
that was decided.
This is not a restructure: maturin already shipped hurray/ as a package with a generated
__init__.py doing from .hurray import *, so the import path, the module identity, and
every __module__ are unchanged. Nothing moves except what the wheel copies in.
Consequence accepted: the package now has Python that CI must exercise in its own
right. The repr contract (test_reprs.py) grew a submodule walk and switched from class
names to class identity — hurray.StreamReader and hurray.aio.StreamReader are
different classes with the same name, and a name-keyed guard would have let either stand
in for the other.
2. The surface is descriptor-only, and the transfer stays in Rust
Python awaits readiness. One synchronous call into the extension then performs the
read(2) or write(2) and reports the byte count to the core before it returns.
Two things follow, and both are the point:
- ADR-041 § 6a's case 2 — bytes landed, count never reported — is unreachable by construction. There is no suspension point between the syscall and the report, so no cancellation can land there.
- No
memoryviewinto the core's destination ever reaches Python. One that outlived a single step would be a use-after-free, becausestep()replaces the destination when a buffer completes. Keeping the transfer on the Rust side is what prevents such a view from existing, rather than documenting that callers must not keep one.
Consequences stated rather than hidden:
- Unix-only — the same boundary as the sync surface's
fileno()support (issue #215). A path orbyteshas no readiness to await; a regular file is refused outright, becauseepollcannot poll one and a file read does not block anyway. - No async file API.
hurray.loadandhurray.saveget nohurray.aiocounterpart. asyncio has no asynchronous random access, so one would be ADR-041 step 5a's API wearing a coroutine — whichasyncio.to_threadalready expresses at a cost the caller can see. - The descriptor is put in non-blocking mode, which
dupshares with the caller's own object. Any descriptor an event loop drives must be in that mode regardless.
3. WriteOp<'a> is held across suspension by a self-referential holder
A WriteOp borrows the core, the descriptors and the payload slices for as long as it
lives, and an async writer has to hold one across a suspension. Neither alternative
survives inspection:
- Drop the op when the sink would block. Mid-item that sets the core's
tornflag and poisons the writer by ADR-041 § 6b — so a stream larger than a socket buffer could never be written at all. - Buffer the item's wire bytes and write them later. That is the second, tensor-sized payload copy ADR-041 § 2 exists to forbid: stop-condition territory, not a trade-off.
So one #[pyclass] owns a boxed StreamWriterCore, the pinned payload, and the op with
its lifetime erased to 'static. The unsafe is one erase helper with its invariants
written out, and the type's field order is load-bearing: op is declared first because
Rust drops fields in declaration order and the op borrows every field below it.
WriteOp::park() / core.resume(..) in hurray-io was considered and rejected. It
would publish public surface for exactly one consumer, and parking a composite means
rebuilding the frame stack from a node path on resume — moving the hard part into the
crate that is supposed to have no opinion about drivers.
4. A composite's borrowed node tree is pinned in a flat arena
composite.rs builds the borrowed tree for a synchronous composite write by
continuation-passing: each node's &[&[u8]] lives in the stack frame that built it, so
every node is constructed around the call that consumes it. That is safe, and it cannot
survive an await.
The async writer therefore flattens the tree into an arena of boxed descriptors, boxed
slice vectors and boxed node vectors, children before parents. Boxing each level is the
mechanism: appending to one of the arena's Vecs may reallocate the Vec, but it never
moves a Box's pointee, so references already handed out stay valid — including across
the move that returns the arena to its owner.
The two builders are deliberately not merged. Pointing the synchronous writer at the
arena would trade its safe, stack-scoped builder for an erased-lifetime one in order to
delete duplication, which is the wrong way round: the duplication is two different
answers to two different questions, and only one of them needs unsafe. A comment on each
says so, because "unify these" is the obvious wrong move for a future reader.
Async composite writes were briefly scoped out of this pass, to be added later behind a clear error. That was rejected on the grounds that it is the same work either way, plus a temporary refusal, an issue, and a second pass through the same file to delete both — while shipping a surface we already knew we would change.
Consequences
-
hurray.aio.StreamReaderandhurray.aio.StreamWriterexist, and the parity rule inCLAUDE.mdis satisfied in the one direction it never has been: anything the sync surface can stream, the async one can stream, composites included. -
The bytes are identical.
hurray.aio.StreamWriterandhurray.StreamWriteremit the same stream for the same items, which is asserted rather than assumed, and the frozen corpus inhurray-io/tests/fixtures/now has a fourth reader. -
Cancellation is observable and specified: a cancelled read resumes, a cancelled write poisons if and only if the item had already emitted a byte. Both are tested, and the poisoned writer's error names the one recovery that is not available — a fresh writer over the same connection.
-
hurray-iogained nothing, and needed to be asked for less than expected. The driver does not evaluate § 6b's predicate at all:StreamWriterCoresets its owntornflag at an item's first accepted byte and clears it when the item completes, and reports it throughis_unusable()— so dropping the cancelled operation is the whole of the driver's obligation, andpoisoned()answers the question afterwards.WriteOp::emitted()is public and would have made the same decision explicitly; using it would have put a second copy of the predicate in the binding, to be kept in step with the core's for no new answer. Unlike step 5a, which had to publishFileWriterCore::poison, this pass needed no new surface in the core crate at all. -
No new coverage-matrix rows. Async is a driver style, not a format capability; the matrix describes what the format can express, and the same honesty applied to
level-2in #240.Affirmed by #264: this bullet was re-opened as a missing row and upheld, so the two mechanical reasons are recorded here rather than re-derived.
check-coverage-matrix.pyraises when two rows claim one capability, and the Rust probe already provesstream_read/stream_writethrough the tokio async driver — async streaming in Rust is not an unlisted capability, it is how the listed rows are proven. And neither thehurray-fficell nor the Unix-only restriction has an honest glyph: ❌ would assert a C gap that § 8 of ADR-041 denies, and ◐ means "partly implemented" where this surface is fully implemented and conditionally available — a restriction the table already carries unlabelled for the sync reader'sfileno()transport. What the matrix gained instead is one sentence in its interchange intro: rows are framings, not driver styles.
Alternatives Considered
loop.sock_recv_into / sock_sendall. The obvious primitives, and ADR-041 § 6a rules
them out: both perform the transfer inside a loop callback and then resolve a future, so a
cancellation landing between those two events loses the byte count — unrecoverably, since
0x00 is valid tensor data.
asyncio.StreamReader / StreamWriter and the protocol API. Avoids the lost count
entirely, because cancelled data stays in asyncio's own buffer. It hands back a bytes
that must then be copied into the core's destination, which is the copy § 2 forbids.
Resumability cannot be bought with a payload copy.
A thread-backed façade — asyncio.to_thread around the blocking surface, presented as
hurray.aio. Rejected as dishonest packaging: it is the sync API with a thread behind it,
which callers can write in one line and see the cost of. It is also what an async file
API would have to be, which is why there isn't one.
Generating the driver with pyo3-asyncio or an equivalent. Rejected: it exists to
bridge a Rust executor to Python's, and after ADR-041 there is no Rust executor to bridge.
Open Questions Deferred
- Whether the readiness wait should use a protocol/transport instead of
add_reader.loop.add_readeris unavailable on Windows' proactor loop, so aProtocol-based driver is the shape a Windows port would need — along with an answer to issue #215's handle-space problem, which is the actual blocker. Revisit if Windows support is scheduled. - Whether
hurray.aioshould offer a bounded-concurrency helper — a reader pool over many peers. Not until something needs it; the loop already composes withasyncio.gather, and a helper that only wrapsgatheris surface without capability.
ADR-043: One HurrayTensor handle — a capsule carries a tensor, not a pair
Status
Accepted (2026-11-06), implemented 2026-11-08
Closes issue #174 and both of
ADR-034's deferred open questions. Amends ADR-030 § 2 (the capsule pointer) and
ADR-033 § 2 (the capsule name). Supersedes ADR-034 § 1, whose type is deleted;
its §§ 2–4 are carried forward onto HurrayTensor rather than withdrawn, and § 5
stands. C ABI version 9 → 10.
This is a breaking change to the C ABI, deliberate and scheduled: pre-1.0 permits one on a minor bump, so the window runs up to 1.0 and closes there (#225 sequences it before the spec flip).
Context
A native-protocol capsule carries a tensor in two halves:
| Capsule part | Handle | Holds |
|---|---|---|
| pointer | HurrayBufferList (ADR-030 § 2) | the tensor's buffers, in descriptor order |
| context | HurrayTensorContext (ADR-034 § 1) | the encoded descriptor, the ABI version, the owner reference |
Nothing enforces the pairing. PyCapsule_GetPointer and PyCapsule_GetContext are
separate calls returning unrelated handles, and a consumer that reads one without the
other gets either bytes it cannot interpret or a description of bytes it does not have.
Teardown is two calls in a required order — hurray_tensor_context_destroy first,
because releasing the context drops the producer's reference.
ADR-034 recorded the better shape — one handle owning both halves — and deferred it: ADR-030 § 2 had fixed the capsule pointer, and the change bought elegance rather than capability. It was picked up alongside ADR-039 and deliberately put down again on 2026-10-11, because that pass landed purely additive and nothing else forced a break.
What changed is that the handle now exists. ADR-041 gave hurray-io a C surface,
and a stream read produces exactly a descriptor paired with its buffers, so step 3
introduced HurrayTensor: one handle owning a HurrayDescriptor and a
HurrayBufferList, with borrowed accessors and a single hurray_tensor_destroy. Two
consequences follow.
- ADR-034's second deferred question is answered by what shipped. It asked whether streaming frames would reuse the capsule's representation or grow a parallel one. Streaming grew the tensor-shaped handle — the right one — and the question is now only whether the capsule joins it.
- The duplication ADR-034 predicted is real. The ABI carries two shapes for one
concept:
HurrayTensoron the read side,HurrayBufferList+HurrayTensorContexton the capsule side.docs/impl/c-ffi.md's handle table lists both, one line apart, and a binding author has to learn which paths produce which.
So the remaining work is no longer spending a break on ergonomics. It is deleting a duplicate, and the break it costs is the same one it has always cost.
Decision
1. HurrayTensor becomes the whole tensor, and gains a public constructor
The handle ADR-041 introduced keeps its shape and gains the three things the capsule role needs and a stream read does not: the producing build's ABI version, and an opaque owner with its release callback.
typedef struct HurrayTensor HurrayTensor;
HurrayStatus hurray_tensor_new(uint32_t abi_version,
const uint8_t *descriptor_bytes,
uint64_t descriptor_len,
struct HurrayBufferList *buffers, /* ownership transferred */
void *owner,
HurrayOwnerReleaseFn owner_release,
struct HurrayTensor **out_tensor);
HurrayStatus hurray_tensor_abi_version(const struct HurrayTensor *tensor, uint32_t *out);
HurrayStatus hurray_tensor_descriptor(const struct HurrayTensor *tensor,
struct HurrayDescriptor **out_descriptor);
HurrayStatus hurray_tensor_buffers(const struct HurrayTensor *tensor,
struct HurrayBufferList **out_buffers);
HurrayStatus hurray_tensor_destroy(struct HurrayTensor **tensor);
hurray_tensor_descriptor and hurray_tensor_buffers hand out borrowed pointers,
valid until the destroy and never to be destroyed individually — the discipline
hurray_buffer_list_get already follows. A tensor produced by a stream or file read
reports HURRAY_C_ABI_VERSION and carries no owner; one produced by
hurray_tensor_new reports what its producer passed.
ADR-034's §§ 2–4 move here unchanged in substance: the handle is opaque with accessors
rather than a repr(C) struct, the owner travels behind void * so no Python type
reaches the ABI, and the version is read before anything else is trusted (§ 5 below).
2. HurrayTensorContext is deleted
It exists only to be a capsule's context. Once HurrayTensor carries what it carried,
keeping it is surface with no capability behind it — and worse, a second way to say the
same thing. The deletion is what makes this an ABI break rather than an addition, and
it is the point of the exercise.
3. The capsule pointer is a HurrayTensor *
A capsule carries one pointer and no context. The pairing becomes structural: a
consumer that has the pointer has the descriptor and the buffers, because they are the
same object. ADR-030 § 2's buffer-list requirement is narrowed to what it was actually
protecting — a C consumer reaching the buffers through hurray-ffi accessors without
linking PyO3, which hurray_tensor_buffers preserves — and §§ 1, 3 and the rest of
ADR-030 stand untouched.
4. HurrayBufferList survives as the builder
A producer still accumulates buffers with hurray_buffer_list_new and
hurray_buffer_list_push; hurray_tensor_new consumes the finished list and takes
ownership of it. The list builds, the tensor is the finished product — which is already
how the reader path works internally.
5. The capsule is renamed once, to hurray_tensor_handle
| Name | |
|---|---|
| on creation | "hurray_tensor_handle" |
| after consumption | "used_hurray_tensor_handle" |
Without a rename, a pre-break and a post-break capsule share the name "hurray_tensor"
while pointing at different types. A stale consumer would PyCapsule_GetPointer a
HurrayTensor * and treat it as a HurrayBufferList *. The in-tree consumer happens to
survive — it null-checks the now-absent context and raises BufferError — but that is
luck, not a contract; a binding that reads the pointer before the context gets undefined
behaviour where it should get an error. A capsule name is a wire contract precisely so
that this is a hard TypeError in every consumer, in every language, with no
cooperation required.
This is one rename, not a counter that advances with the ABI. Two things make that true, and both are normative:
hurray_tensor_abi_versionis the stable first accessor. It MUST be callable on anyHurrayTensor *produced by any version of this ABI, and a consumer MUST call it before any other accessor and before trusting anything else about the handle. Its signature MUST NOT change. This is ADR-034 § 4's rule, re-targeted: it is what lets every future ABI version be dereferenced safely by any build, so additive growth never needs a new capsule name.- The break window closes at 1.0. After the compatibility contract opens, a change that invalidated the promise above would be a major-version matter.
The suffix is _handle rather than _v2 because a number would track nothing: the
capsule would read v2 while the ABI reads 10, two counters that never correspond
again. _handle names what the pointer is, which is the thing that changed, and
ADR-033 § 2's rule that no version suffix is added therefore still holds — the version
still travels in the payload where it can be checked and reported, now one dereference
deeper.
6. The descriptor is decoded at construction, and only the decoded form is kept
hurray_tensor_new decodes descriptor_bytes into the HurrayDescriptor the handle
owns, and does not retain the bytes. Three things follow.
- Construction validates. A producer can no longer emit a tensor whose descriptor
does not parse;
hurray_tensor_newreturns the decode error instead. Today that failure is discovered by the consumer, one process or one machine later. - A null or zero-length descriptor is refused with
HURRAY_ERR_NULL_POINTER.hurray_tensor_context_newpermits both today, and a doc example relies on it. A capsule with no descriptor is useless, and eager decoding rules it out anyway. - The encoded bytes are no longer reachable from C. This honours ADR-039 § 8's
promise to expose a borrowed
const HurrayDescriptor *rather than a second accessor family, and it keeps one shape for both paths: a tensor from a stream read has no descriptor bytes to offer, because the sans-io cores decode out of their staging buffer and drop it. Every descriptor field stays readable through ADR-039 and ADR-040. What goes is verbatim byte forwarding — see Consequences.
7. One destroy
hurray_tensor_destroy releases the descriptor, every buffer the list owns, and the
owner reference, then nulls the caller's pointer. The two-call teardown and its required
order disappear: a consumer holds one object and ends it once.
A capsule consumer therefore keeps the handle for as long as it reads the buffers, rather than taking the halves out and dropping the carrier.
8. C ABI version 9 → 10
HURRAY_C_ABI_VERSION becomes 10. The native protocol's version check is exact
equality, so every capsule from a version-9 build is refused by a version-10 consumer
and vice versa — the rename in § 5 is for consumers that look at the pointer before the
version, not a substitute for the check.
Alternatives Considered
Keep the pair; document the pairing more loudly. The status quo, and defensible until ADR-041 shipped a second spelling of the same idea. Rejected now because the cost is no longer a missing convenience but two types for one concept, each with its own teardown rule, in an ABI whose compatibility contract is about to freeze.
Keep the capsule name "hurray_tensor" and rely on the version check. Tempting:
ADR-030 changed the capsule pointer's type at ABI 3 without renaming, and nothing broke.
That worked because the version then lived in the context, separate from the pointer,
so a consumer could check it before dereferencing. This change removes the context, so
the version now lives inside the thing whose type changed and a consumer must
dereference to read it. Rejected: the one protection ADR-030 had is exactly what this
change takes away.
Rename to "hurray_tensor_v2". Rejected under § 5: a counter that corresponds to no
other version in the project, and an implied sequence that § 5's stability promise is
designed to prevent.
Keep the encoded descriptor bytes in the handle, beside the decoded form. Preserves
verbatim forwarding, at the cost of two sources of truth in one handle — and of plumbing
the bytes out of hurray-io's sans-io cores, which decode from a staging buffer they
own, so that a read-produced tensor could offer them too. Rejected: the capability lost
is narrow, and the honest fix for it is an encode entry point on the descriptor handle,
which is additive and can land whenever it is actually wanted (Open Questions).
Give streaming its own context-shaped type instead. The mirror image of this ADR, and the thing ADR-034's second question warned about. Rejected on sight: it doubles the duplication rather than removing it.
Consequences
Positive
- One type for one concept. A binding author learns
HurrayTensoronce, and every path that produces a tensor — capsule, stream read, file read — produces it. - The pairing is structural. A capsule cannot carry buffers without their description, because there is no second handle to forget.
- Teardown has no order to get wrong, and no second call to omit on an error path.
- Producers fail at construction rather than exporting a descriptor that does not parse.
hurray-python's producer path stops round-tripping through encoded bytes: a Rust constructor takes theTensorDescriptorit already holds.
Negative
- An ABI break, with a rebuild required of every C consumer and a compatibility matrix entry. This is the known cost, scheduled rather than discovered.
- Verbatim descriptor-byte forwarding is gone. A C consumer that today copies a capsule's descriptor bytes into a container of its own must read fields instead. Nothing in tree does this, and no consumer outside the tree is known; the Open Question below is the route back if one appears.
- Zero-length descriptors stop working, breaking a call pattern that the ADR-034 doc example itself uses.
- A consumer must keep the handle alive while it reads buffers, rather than taking
ownership of a buffer list. For
hurray-pythonthis is no change in practice — the consumer already takes its own Python reference to the source object rather than relying onowner_releasefor liveness.
Required Documentation Amendments
docs/impl/c-ffi.md— § Tensor Context becomes § Tensor Handle; the opaque-handle table dropsHurrayTensorContext*; the ABI version table gains rows9(the composite surface, missing today) and10.docs/impl/python-bindings.md— § Native Interchange Protocol (the capsule-pointer MUST), § Multi-buffer tensors, § Capsule lifetime (both names), § The capsule context (replaced), § ABI versioning (the accessor named in the ordering rule).docs/adr/ADR-030-*.md§ 2 — amendment note: the capsule pointer is aHurrayTensor *; the accessor guarantee it protected survives.docs/adr/ADR-033-*.md§ 2 — amendment note: the capsule is"hurray_tensor_handle"; the no-version-suffix rule stands.docs/adr/ADR-034-*.md— Status gains the supersession of § 1 and the note that §§ 2–4 move ontoHurrayTensor; the first deferred question is marked resolved here, the second resolved by ADR-041 step 3 plus § 3 above.docs/cookbook/layer-7-c-ffi.md— the capsule recipe and the read-the-version-first note, in both language tabs.hurray-python/COMPAT-MATRIX.md— minimumHURRAY_C_ABI_VERSION10, and the native protocol note (which today says4in its table,5in its prose, against a constant of9).docs/SUMMARY.md,website/coverage-matrix.toml,CHANGELOG.md(breaking).
Open Questions Deferred
- An encode entry point on the descriptor handle —
hurray_descriptor_encode(const HurrayDescriptor *, …)— which would restore verbatim forwarding uniformly for every tensor, whatever produced it, and would be the first piece of a C writer surface. Additive, so it needs no break and no decision now: deferred until a consumer wants it. Decoding is strict enough (length mismatch and reserved flag bits are rejected) that a decoded descriptor re-encodes faithfully. - Whether a capsule carrying a composite group keeps this name or introduces a
sibling — ADR-033's second deferred question, still open.
HurrayItemis how a composite crosses the ABI today, and no capsule carries one. - The C writer surface (Compliance Level 2) is unaffected by this ADR and remains deferred pending the async bridge design.