Skip to content

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

Closed
justinchuby wants to merge 5 commits into
mainfrom
feat/paged-cache
Closed

Add paged / block-table KV cache export (--features paged-cache)#395
justinchuby wants to merge 5 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 195087b17c6003

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 431 431 +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 195087b17c6003

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 Outdated
Comment thread src/mobius/components/_attention.py
Comment thread src/mobius/__main__.py Outdated
Comment thread src/mobius/tasks/_causal_lm.py Outdated
justinchuby and others added 3 commits July 12, 2026 19:32
- attention: reject paged_cache combined with a non-None attention_bias
  instead of silently dropping it, so ALiBi / sliding-window models fail
  fast rather than producing wrong results.
- _causal_lm: parametrize _validate_static_cache_support with a `mode`
  label and pass "Paged cache" from the paged path so the raised message
  names the layout the user actually enabled.
- __main__: correct the --paged-cache help text ("Gather-based page
  assembly" — the graph uses ONNX Gather, not GatherElements).
- paged_cache_test: drop the redundant sys.path manipulation and import
  _test_configs directly like the rest of the suite; add coverage for the
  attention_bias fail-fast and the paged validation message wording.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 4, 2026 06:33

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.

🟡 Not ready to approve

There are a few user-facing documentation/error-message inconsistencies and a test input-shape mismatch that should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (5)

src/mobius/tasks/_causal_lm.py:585

  • _validate_static_cache_support() is now used for both static and paged cache validation, but the first docstring line still claims it only checks StaticCacheState. This is confusing when the caller passes mode="Paged cache".
def _validate_static_cache_support(module: nn.Module, mode: str = "Static cache") -> None:
    """Check that the module's decoder layers support StaticCacheState.

src/mobius/tasks/_causal_lm.py:652

  • When validating paged cache support (mode="Paged cache"), the raised TypeError still tells users to "add StaticCacheState dispatch", which is misleading. Custom layers need to dispatch both StaticCacheState and PagedCacheState as appropriate.
            raise TypeError(
                f"{mode} mode requires decoder layers that "
                f"inherit from DecoderLayer or MoEDecoderLayer (or set "
                f"_supports_static_cache=True), but "
                f"{name}[{i}] is {type(layer).__name__}. Either use a "
                f"compatible model or add StaticCacheState dispatch to "
                f"{type(layer).__name__}.forward()."

src/mobius/tasks/_causal_lm.py:80

  • Paged-cache docstring says key_pool/value_pool and updated pools are FLOAT, but the implementation uses the model's dtype (config.dtype) for these tensors. This is misleading for fp16/bf16 exports and for callers building the expected I/O contract.

This issue also appears in the following locations of the same file:

  • line 584
  • line 646
            - key_pool.{i}: [num_pages, page_size, kv_hidden] FLOAT per layer
            - value_pool.{i}: [num_pages, page_size, kv_hidden] FLOAT per layer

src/mobius/components/_attention.py:151

  • The _apply_paged_attention() docstring states that query/key/value projections all have shape [1, seq_len, heads*head_dim], but key/value use num_key_value_heads (kv_num_heads) rather than num_attention_heads. This is an externally visible contract and should be accurate.
        query/key/value: 3-D ``[1, seq_len, heads * head_dim]`` projections.

tests/paged_cache_test.py:278

  • This test builds a PagedCacheState, but the dummy inputs don't match the paged-cache I/O contract (block_table should be 1-D [num_blocks], and pool last dim should be kv_hidden=num_key_value_heads*head_dim). Using contract-shaped inputs makes the test clearer and avoids accidentally normalizing a mismatched shape convention.
    bt = builder.input("block_table", dtype=ir.DataType.INT64, shape=[1, 4])
    sm = builder.input("slot_mapping", dtype=ir.DataType.INT64, shape=[1])
    kp = builder.input("kpool", dtype=config.dtype, shape=[16, 8, config.head_dim])
    vp = builder.input("vpool", dtype=config.dtype, shape=[16, 8, config.head_dim])
    seqlen = builder.input("nonpad_kv_seqlen", dtype=ir.DataType.INT64, shape=[1])
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

@justinchuby

Copy link
Copy Markdown
Member Author

Not chasing in favor of the VMM solution.

@justinchuby justinchuby closed this Aug 4, 2026
…atures

Update from main (brings in the cargo-style --features build option) and
fold the paged / block-table KV cache toggle into it:

- Add 'paged-cache' to _BUILD_FEATURES so 'mobius build --features paged-cache'
  enables the paged KV cache, matching static-cache / fp8-kv-cache / text-only.
- Remove the standalone --paged-cache boolean flag (main dropped the other
  boolean feature flags); --page-size / --num-pages remain as tuning params.
- Reword paged validation errors and docs (README, cli_reference, CHANGELOG)
  to reference --features paged-cache.
- Add a cli_test covering --features paged-cache.

Conflicts resolved in __main__.py, tasks/_causal_lm.py (keep both paged params
and prune_lm_head), and CHANGELOG.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: justinchuby <justinchuby@users.noreply.github.com>
@justinchuby justinchuby reopened this Aug 6, 2026
@justinchuby justinchuby changed the title Add paged / block-table KV cache export (--paged-cache) Add paged / block-table KV cache export (--features paged-cache) Aug 6, 2026
@justinchuby

Copy link
Copy Markdown
Member Author

Updated from main and folded the paged KV-cache toggle into the cargo-style --features option (added on main):

  • mobius build --features paged-cache now enables the paged / block-table KV cache, consistent with static-cache / fp8-kv-cache / prune-lm-head / text-only.
  • Removed the standalone --paged-cache boolean flag (main dropped the other boolean feature flags); --page-size / --num-pages remain as tuning parameters.
  • Reworded validation errors + docs (README, cli_reference, CHANGELOG) to reference --features paged-cache; added a CLI test.
  • Resolved the two prior review threads (attention_bias rejection in paged mode; _validate_static_cache_support mode-aware error text) — both already implemented on the branch.

Merge resolved conflicts in __main__.py, tasks/_causal_lm.py, and CHANGELOG.md; branch is now even with main. Tests: cli/paged/build-graph green.

@justinchuby justinchuby closed this Aug 6, 2026
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