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

BufferNameContents
0page_poolThe flat pool of fixed-size pages (num_pages × page_size × num_heads × head_dim elements).
1block_tablePhysical page id of each logical page, concatenated across sequences.
2seq_ptrOffset 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