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. |
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). |
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).
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. Insert0x00padding to the nextdata_buffer_alignmentboundary. d. Record the current byte offset asdata_offset. e. Write each data buffer; insert padding between buffers and after the last one to maintaindata_buffer_alignment. f. Insert0x00padding to the next 8-byte boundary. g. Recorddescriptor_lengthanddata_lengthfor the index. - 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 | ✅ | ✅ | ◐ (a) |
| Level 2 — Writer | ✅ | ✅ | ❌ |
| Level 3 — Network transport | 📄 (b) | 📄 (b) | 📄 (b) |
- (a)
hurray_descriptor_decodeparses and validates a descriptor throughhurray-core, so the reading itself is complete, but the C surface exposes only the type tag, layout tag, rank, shape, byte offset, and buffer table. Layout-specific payload fields — CSRnnz, tile shape, the block table — have no accessor, so a C caller cannot yet interpret every Tier 1 layout it can decode. - (b) 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.
| 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
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.
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 4:
| 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 now wraps a list rather than a single HurrayBuffer (ADR-030). See Buffer Lists. |
4 | HurrayTensorContext, so a consumer in any language can read a capsule's descriptor and ABI version (ADR-034). See Tensor Context. |
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 |
HurrayTensorContext* | A capsule's descriptor bytes, ABI version, and owner reference |
HurrayReader* | A streaming tensor reader |
HurrayWriter* | A streaming tensor writer |
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 that carrier (ADR-030).
| 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 Context
A native-protocol capsule carries two things: a HurrayBufferList as its pointer,
and a HurrayTensorContext as its context (ADR-034). The list holds the bytes; the
context holds the encoded tensor descriptor that says what those bytes are, plus
the ABI version of the build that produced them.
Before ADR-034 the context was a structure private to hurray-python, so the buffers
crossed the language boundary and the descriptor did not. A consumer in any other
language received element bytes with no element type, shape, layout, or quantization.
HurrayStatus hurray_tensor_context_new(uint32_t abi_version,
const uint8_t *descriptor_bytes,
uint64_t descriptor_len,
void *owner,
HurrayOwnerReleaseFn owner_release,
struct HurrayTensorContext **out_ctx);
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);
HurrayStatus hurray_tensor_context_destroy(struct HurrayTensorContext **ctx);
- A consumer MUST call
hurray_tensor_context_abi_versionfirst and compare the result against its ownHURRAY_C_ABI_VERSIONbefore calling any other accessor. That ordering is what allows later ABI versions to add accessors without breaking older consumers: a consumer that checked the version knows which ones exist. hurray_tensor_context_descriptorreturns a borrow owned by the context, valid until the context is destroyed. A caller that needs it longer MUST copy it. An empty descriptor reports a null pointer and a zero length.- The context owns a copy of the descriptor bytes; a borrow would tie its validity to a buffer the producer may drop.
ownerandowner_releaseare opaque and never interpreted. They exist so a producer can keep whatever owns the tensor's memory alive for the capsule's lifetime —hurray-pythonparks a Python object reference there, which is how a Python type is kept out of the C ABI entirely.owner_releaseis invoked exactly once, duringhurray_tensor_context_destroy.- Destroying a null handle is a no-op returning
HURRAY_OK, so cleanup paths may call it unconditionally. Destroy nulls the caller's pointer.
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
/* ... */
Functions MUST return HURRAY_ERR_NULL_POINTER for any required pointer argument
that is NULL, without invoking undefined behaviour.
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"wrapping aHurrayBufferListpointer fromhurray-ffi. 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
HurrayBufferListowning oneHurrayBufferper buffer. A single-buffer tensor is theN = 1case, not a separate path. - Element
iof the 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 list length 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". - Capsule name after consumption:
"used_hurray_tensor". 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", the destructor MUST callhurray_buffer_list_destroyon the wrapped pointer, which destroys every handle the list owns. If the capsule has been consumed (renamed), the consumer MUST callhurray_buffer_list_destroyexactly once. Handles obtained fromhurray_buffer_list_getare 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.
The capsule context
The capsule context MUST be a HurrayTensorContext from hurray-ffi (ADR-034),
carrying the encoded TensorDescriptor and the producing build's
HURRAY_C_ABI_VERSION. It MUST NOT be a structure private to hurray-python: the
protocol serves consumers in other languages, and a private structure leaves them the
buffers without the descriptor that says what the buffers are.
The strong reference that keeps the source tensor alive MUST travel as the context'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_context_abi_version before any other accessor and before
dereferencing the buffer list; a mismatch MUST raise hurray.UnsupportedError. See
C FFI § Tensor Context 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 a buffer list and one
descriptor; 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 a tensor as
soon as its descriptor and buffers have arrived, and a writer MUST emit each tensor
as it is given one.
with hurray.StreamWriter(destination) as writer:
writer.write(tensor)
for tensor in hurray.StreamReader(source):
...
Blocking
Both classes are blocking. Each owns a tokio runtime for its lifetime and MUST
release the GIL around every call into it, so other Python threads run while a stream
waits on its transport. An asyncio surface is deliberately absent; see ADR-035 § 1.
hurray.StreamReader
- MUST implement
__iter__returning itself and__next__yielding ahurray.Tensor, 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: 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. - MUST raise
hurray.UnsupportedError, naming the composite, when the stream contains one. It MUST NOT skip it.
hurray.StreamWriter
- MUST accept a
hurray.Tensorinwrite, transmitting every buffer its descriptor references, in descriptor order. - 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 |
| the stream contains a composite | hurray.UnsupportedError |
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.
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"), or otherwise invalid.hurray.UnsupportedErrorif theHURRAY_C_ABI_VERSIONembedded in the capsule 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 hurray. - 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. In
Python today the equivalent producer→consumer hand-off is the file format (save in one
process, load in another); the incremental streaming API is Rust / C-FFI.
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();
assert(v == 2 && "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 a HurrayBufferList as its pointer and a
HurrayTensorContext as its context. The list holds the bytes; the context holds the
descriptor that says what those bytes are (ADR-034). Without it a consumer gets
element data with no element type, shape, or layout — which is what every non-Python
consumer got before ADR-034.
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:
uint32_t abi_version = 0;
if (hurray_tensor_context_abi_version(ctx, &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 and decode it:
const uint8_t *bytes = NULL;
uint64_t len = 0;
if (hurray_tensor_context_descriptor(ctx, &bytes, &len) != HURRAY_OK) return -1;
HurrayDescriptor *descriptor = NULL;
if (hurray_descriptor_decode(bytes, (uintptr_t)len, &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);
The pointer bytes is borrowed — owned by the context, valid until the context is
destroyed. Copy it if you need it longer. An empty descriptor reports a null pointer
and a zero length.
Now the buffers mean something, because the descriptor said what they hold:
uint64_t count = 0;
hurray_buffer_list_len(list, &count);
for (uint64_t i = 0; i < count; i++) {
HurrayBuffer *borrowed = NULL; /* owned by the list — do not destroy */
hurray_buffer_list_get(list, i, &borrowed);
void *ptr = NULL;
uint64_t size = 0;
hurray_buffer_data_ptr(borrowed, &ptr);
hurray_buffer_byte_size(borrowed, &size);
}
Destroying the context runs its owner_release callback, which is how the producer
learns it can let the tensor go:
hurray_descriptor_destroy(descriptor);
hurray_tensor_context_destroy(&ctx); /* runs owner_release exactly once */
hurray_buffer_list_destroy(&list); /* destroys every handle it owns */
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_context
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. 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
HurrayTensorContextthis is normative, not advisory: it is what lets later versions add accessors without breaking consumers compiled against an earlier header.
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
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)
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 |
| Every descriptor flat, composition-agnostic | next_tensor → StreamTensor |
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.
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
See hurray-io/examples/composite_stream.rs and
hurray-python/examples/composites.py for the full programs.
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})
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"]
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.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
See hurray-io/examples/composite_file.rs and hurray-python/examples/composites.py for
the full programs.
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". The capsule
holds a HurrayBuffer pointer (from hurray-ffi) and a strong Python reference to
the source Tensor.
hurray.from_hurray() renames the capsule to "used_hurray_tensor" 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() |
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
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 hurray, 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". - The capsule pointer is a
HurrayBufferList— build it withhurray_buffer_list_newand onehurray_buffer_list_pushper buffer, in descriptor buffer-table order. - The capsule context carries your encoded
TensorDescriptorandHURRAY_C_ABI_VERSION. - Consumers rename the capsule to
"used_hurray_tensor"on consumption; your destructor callshurray_buffer_list_destroyif it was never consumed.
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 a HurrayBufferList 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' package: pip install hurray"
) 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". 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.
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". 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.
Amended by ADR-033: the protocol is named
__hurray__/from_hurray, and the capsule is named"hurray_tensor"/"used_hurray_tensor". 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
Proposed (2026-08-06)
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
Proposed (2026-08-11)
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
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
Proposed (2026-08-15). § 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
Proposed (2026-08-18)
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
Proposed (2026-08-20)
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
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
Proposed (2026-08-20)
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
- 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
Proposed (2026-08-22)
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
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.
- 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
Proposed (2026-08-22)
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.