Skip to content

Add paged / block-table KV cache export (--paged-cache) - #395

Draft
justinchuby wants to merge 2 commits into
mainfrom
feat/paged-cache
Draft

Add paged / block-table KV cache export (--paged-cache)#395
justinchuby wants to merge 2 commits into
mainfrom
feat/paged-cache

Conversation

@justinchuby

Copy link
Copy Markdown
Member

What this adds

A paged / block-table KV cache export mode for causal LMs — a variant of the
existing --static-cache that emits models whose attention reads KV from
non-contiguous pages via a page pool + block_table + slot_mapping.

This is the vLLM PagedAttention layout, and because sequences can list the
same physical page in their block_table, the identical graph also expresses
SGLang RadixAttention (shared-prefix pages) with no change. It implements
onnx-genai docs/DESIGN.md §39.4 Option C ("ONNX Scatter/GatherElements in
Graph") using only standard ONNX ops (no custom op).

Why

The onnx-genai runtime needs models that operate directly on a paged KV pool so
it can do vLLM-style paged attention and SGLang-style prefix sharing without
gathering pages into a contiguous buffer in the runtime on every step.

How it works (per attention layer)

1. ScatterND(pool_flat, slot_mapping, new_kv)      # write new tokens to physical slots
2. Gather(updated_pool, block_table, axis=0)       # assemble this sequence's pages…
   → Reshape [1, num_blocks*page_size, kv_hidden]  # …into a contiguous KV sequence
3. Attention(query, K_gathered, V_gathered,
             nonpad_kv_seqlen, is_causal=1)         # opset-24 external-cache Attention

Step 3 is the same op contract as --static-cache (Attention with
is_causal=1 + nonpad_kv_seqlen input #6, no attention_mask), so it reuses
the existing opset-24 retention logic (_graph_requires_opset24 already detects
Attention with a non-empty input #6) and the same DecoderLayer/MoEDecoderLayer
dispatch pattern as the static cache.

Ops used: Reshape, Shape, Unsqueeze, ScatterND (write), Gather
(page assemble), Attention. All opset-24 compatible.

New flag

mobius build <model> --paged-cache [--page-size N] [--num-pages N]

Also available programmatically: CausalLMTask(paged_cache=True, page_size=16, num_pages=None).
Mutually exclusive with --static-cache; requires DecoderLayer / MoEDecoderLayer models.

ONNX I/O contract (what onnx-genai must drive)

Single active sequence (batch == 1), per layer i, kv_hidden = num_kv_heads * head_dim:

Inputs

name shape dtype
input_ids [batch, seq_len] int64
position_ids [batch, seq_len] int64
key_pool.{i} / value_pool.{i} [num_pages, page_size, kv_hidden] model dtype
block_table [num_blocks] int64 (physical page ids, logical order)
slot_mapping [seq_len] int64 (page_id*page_size + offset per new token)
nonpad_kv_seqlen [batch] int64 (write_start + valid_token_count)

Outputs

name shape
logits [batch, seq_len, vocab]
updated_key_pool.{i} / updated_value_pool.{i} [num_pages, page_size, kv_hidden]

No attention_mask — causal + padding masking is derived from is_causal=1 +
nonpad_kv_seqlen. RoPE is baked into the keys before they are written to the
pool. RadixAttention: point multiple sequences' block_table at the same
physical page — the graph gathers it identically for each.

Complete vs TODO

Complete

  • Paged attention body (ScatterND write + Gather assemble + Attention) in components/_attention.py
  • PagedCacheState dispatch in DecoderLayer and MoEDecoderLayer (backward-compatible: paged_cache only forwarded to custom self_attn modules when set)
  • CausalLMTask(paged_cache=…) + input/output wiring, mutual-exclusion with static cache
  • CLI flags --paged-cache / --page-size / --num-pages with validation
  • Graph builds, onnx.checker passes, shape inference yields correct pool shapes
  • Standard-Attention (dense) and MoE model paths

TODO (documented in code)

  • Multi-sequence batching (2-D block_table / per-row gather); current impl targets batch == 1
  • End-to-end paged execution parity: the Attention op with nonpad_kv_seqlen is CUDA-only until onnxruntime#28958 ships (same constraint as --static-cache), so full-model run parity is deferred to the shared static-cache Flash probe. The paging ops themselves are validated on CPU.

Validation

  • tests/build_graph_test.py::TestBuildPagedCacheGraph — 12 graph-level tests
    (inputs/outputs, pool shapes, dynamic num_pages, ScatterND/Gather/Attention
    presence, is_causal=1, nonpad_kv_seqlen wiring, MoE, mutual exclusion, shape inference).
  • tests/paged_cache_test.py — CPU numerical parity for the scatter+gather paging
    ops vs a NumPy reference, including a RadixAttention shared-page case, plus an
    end-to-end onnx.checker + shape-inference build check.
  • Full TestBuildGraph / TestBuildStaticCacheGraph / cli_test suites pass (no regressions).

Draft: single-sequence paged cache is complete and validated; multi-sequence batching remains a TODO.

Add a paged (block-table) KV cache variant of the static cache, emitting
models whose attention reads KV from non-contiguous pages via a page pool +
block_table + slot_mapping — the vLLM PagedAttention layout, which also
expresses SGLang RadixAttention (shared prefix pages) with no graph change.
Implements onnx-genai DESIGN §39.4 Option C using only standard ONNX ops.

Attention writes new K/V into the per-layer page pool with ScatterND, gathers
the sequence's physical pages contiguously with Gather(pool, block_table),
then runs the opset-24 Attention op with nonpad_kv_seqlen (input #6) — the
same op contract as --static-cache, but over paged KV.

- PagedCacheState + _apply_paged_attention in components/_attention.py
- PagedCacheState dispatch in DecoderLayer / MoEDecoderLayer (backward
  compatible: paged_cache only forwarded to self_attn when set)
- CausalLMTask(paged_cache=True, page_size=, num_pages=) with
  _make_paged_cache_inputs / _register_paged_cache_outputs
- CLI flags --paged-cache / --page-size / --num-pages with validation
- Graph-level tests (TestBuildPagedCacheGraph) + CPU numerical parity for the
  scatter/gather paging ops incl. a RadixAttention shared-page case

I/O contract (batch == 1):
  inputs:  input_ids, position_ids,
           key_pool.{i}/value_pool.{i} [num_pages, page_size, kv_hidden],
           block_table [num_blocks] i64, slot_mapping [seq_len] i64,
           nonpad_kv_seqlen [batch] i64
  outputs: logits, updated_key_pool.{i}/updated_value_pool.{i}

Multi-sequence batching is a documented TODO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing da921702c14472

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mobius/__main__.py 17.64% 7 Missing and 7 partials ⚠️
src/mobius/tasks/_causal_lm.py 90.00% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing da921702c14472

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new causal-LM export mode that emits a paged / block-table KV cache graph (vLLM PagedAttention / SGLang RadixAttention layout) driven by block_table + slot_mapping, using standard ONNX ScatterND + Gather + opset-24 Attention with nonpad_kv_seqlen.

Changes:

  • Introduces PagedCacheState and paged-cache attention path (ScatterND write + Gather assemble + opset-24 Attention) in the attention component.
  • Extends CausalLMTask and the CLI with paged_cache/--paged-cache plus page_size/--page-size and num_pages/--num-pages, including validation and IO wiring.
  • Adds graph-level build tests and CPU parity tests for the paging subgraph.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/paged_cache_test.py New CPU parity tests for ScatterND+Gather paging ops and a task build validity check.
tests/build_graph_test.py New TestBuildPagedCacheGraph to assert IO contract and key ops presence for paged-cache builds.
src/mobius/tasks/_causal_lm.py Adds paged_cache option to CausalLMTask, paged-cache IO creation, and output registration.
src/mobius/models/moe.py Extends MoE decoder layer dispatch to route PagedCacheState into attention.
src/mobius/components/_decoder.py Extends decoder layer dispatch to route PagedCacheState into attention.
src/mobius/components/_attention.py Implements PagedCacheState and paged cache attention body; wires into Attention.forward.
src/mobius/main.py Adds --paged-cache, --page-size, --num-pages CLI args and validation.
CHANGELOG.md Documents the new paged/block-table cache export feature and CLI flags.

Comment thread tests/paged_cache_test.py
Comment on lines +207 to +212
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from _test_configs import _base_config
from onnx_ir.passes.common import CheckerPass
Comment on lines +518 to +533
if paged_cache is not None:
# Paged (block-table) KV cache: scatter new K/V into the physical
# page pool, gather this sequence's pages contiguously, then attend.
# present_* here are the UPDATED page pools (registered as outputs).
attn_output, present_key, present_value = _apply_paged_attention(
op,
query_states,
key_states,
value_states,
paged_cache,
num_attention_heads=self.num_attention_heads,
num_key_value_heads=self.num_key_value_heads,
head_dim=self.head_dim,
scale=self.scaling,
softcap=self._softcap,
)
Comment thread src/mobius/__main__.py
Comment on lines +568 to +571
help="Use a paged / block-table KV cache (vLLM PagedAttention / SGLang "
"RadixAttention layout): a page pool + block_table + slot_mapping, with "
"GatherElements-style page assembly and ScatterND writes (onnx-genai "
"DESIGN §39.4 Option C). Requires DecoderLayer or MoEDecoderLayer models.",
Comment on lines +161 to +163
# Paged cache reuses the same DecoderLayer/MoEDecoderLayer dispatch
# as the static cache (both flow their state through past_key_value).
_validate_static_cache_support(module)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants