Skip to content

[Draft] kv paged kernels - #93

Draft
i-chaochen wants to merge 10 commits into
mainfrom
chao/kv_paged_kernels
Draft

[Draft] kv paged kernels#93
i-chaochen wants to merge 10 commits into
mainfrom
chao/kv_paged_kernels

Conversation

@i-chaochen

@i-chaochen i-chaochen commented Aug 26, 2026

Copy link
Copy Markdown

Description

toy model result

image

https://github.com/AMD-ROCm-Internal/rocm-jax-xla-architecture/pull/1

it's still WIP

llama3.1-8b result

python3 -m maxtext.checkpoint_conversion.to_maxtext src/maxtext/configs/base.yml \
  model_name=llama3.1-8b-Instruct --hf_model_path=/root/hf_models/llama31-8b-instruct \
  base_output_directory=/root/maxtext_ckpt scan_layers=false hardware=cpu \
  skip_jax_distributed_system=True --save_dtype=bfloat16 --lazy_load_tensors=True
batch step (ms) tok/s achieved GB/s % of 5.3 TB/s
1 27.56 36.3 583 11.0%
2 11.20 178.7 1437 27.1%
4 9.62 415.7 1674 31.6%
8 9.68 826.3 1669 31.5%
16 10.65 1502.3 1527 28.8%

Step time is flat from batch 2 to 16 — 11.20 ms to 10.65 ms — while throughput rises 41× (1502/36.3) in one gpu setup.

Prefix cache

cache off cache on
prefill tokens run 15525 7333
prefill tokens avoided 0 8192 (52.8%)
TTFT p50 7.74 ms 4.27 ms
wall clock 0.73 s 0.32 s

i-chaochen and others added 3 commits August 20, 2026 16:17
Introduces maxtext/inference/kv_common/, the kernel-neutral types a paged KV
control plane is built on: KvStorageLayoutV1 for pool geometry and KvPageTableV1
for one step's page bookkeeping.

The layer is deliberately narrow. It carries no strides, no packing and no
vendor shapes, because those belong to whichever kernel backend is in use, and a
control plane that knew them could not host a second backend. It is also pure
host numpy, since allocation, free lists, refcounts and prefix matching are
data-dependent irregular logic that cannot live inside a traced computation.

An import rule follows from that and is enforced statically rather than by
convention: these modules may import only the standard library and numpy, never
jax, jax_aiter or the rest of maxtext. The test parses the sources and checks it,
because a rule nobody can verify erodes, and this one is what keeps the layer
CPU-testable today and mechanically extractable later.

Two details worth knowing:

Element sizes are tabulated rather than taken from numpy. numpy has neither
bfloat16 nor the fp8 variants, and those are precisely the dtypes that matter
for KV, so np.dtype("bfloat16") raises. Sizing a pool is too load-bearing to
depend on ml_dtypes being installed.

Sharding distinguishes three regimes rather than assuming divisibility. When the
shard count exceeds the KV head count, which is where GQA at high TP and MQA
land, heads are replicated rather than partitioned, so the footprint is
multiplied by TP / num_kv_heads instead of divided. replication_factor() and
total_pool_bytes() expose that, and a configuration where neither divides the
other is rejected at construction rather than mis-sharded silently.

Tests run without an accelerator and without MaxText's own dependencies. They
load the modules directly from their files, which is what actually demonstrates
the isolation property: importing through the package path executes
maxtext/__init__.py and pulls in the full config stack, so the modules are clean
but the package path is not.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds attention='gpu_paged', which attends over an external paged KV pool instead
of the dense per-layer cache. The pool is a pair of NHD arrays carried as the
layer's kv_cache; one step writes the new K/V into it and then attends, so
prefill and decode read exactly the pages the append wrote.

Nearly all of it lives in layers/gpu_paged_attention.py, leaving one method and
one elif in attentions.py. Only a single leaf function knows which kernel
provider is in use, so flashinfer on NVIDIA slots in beside aiter on ROCm
without a second path through MaxText.

Metadata is duck-typed rather than imported. A neutral KvPageTableV1 is used
directly; a vLLM-shaped object with block_tables/seq_lens/query_start_loc is
converted, packing the row-padded 2D table into the contiguous 1D page list the
kernels want, in jnp and shape-static so it survives jit. Importing either
producer would defeat the neutrality this exists for, and tpu_inference cannot
be installed on an AMD box at all.

Two guards. The dense KVCache_0 is not allocated on this path, which would
otherwise waste gigabytes beside the pool. And scan_layers is refused outright
rather than silently overridden, because stacking the per-layer caches into the
scan carry copies the whole pool every step and shows up as a slowdown rather
than an error.

The parity test compares against MaxText's own dense path and agrees to within
one ulp. It earned that bound during development by catching a double-scaling
bug: MaxText folds the depth scaling into the query projection's initializer, so
the query arriving at a serve path is already scaled and the kernel must be
passed 1.0, exactly as forward_serve_vllm does. It is marked gpu_only
deliberately -- conftest auto-marks unmarked tests cpu_only and skips those on
accelerators, so an unmarked test would report green while never running.

14 tests pass.
… and serving benchmark (M4, M4.5)

Adds maxtext/inference/kv_control/ and kv_execution/: the host page allocator,
request-to-page map, per-step metadata builder, shape bucketing and
continuous-batching driver, plus MaxEngine sibling entry points that run a model
on the pool and a serving harness that measures it.

kv_control/ is host-only by construction -- stdlib, numpy and kv_common, nothing
else. The static AST check that enforced that for kv_common now covers both
layers and the direction that only matters once kv_execution exists: neither
lower layer may import it, or jax is back in the control plane's dependency
graph. That keeps the control plane testable with no accelerator and makes
extraction a directory move rather than an archaeology exercise.

Reimplemented from sglang-jax's allocator as a reference design rather than
ported, with three deliberate divergences.

The allocator deals in pages, not token indices. KvPageTableV1 already carries
explicit write positions, so the reference's three-part extend fill falls out of
position // tokens_per_page and never needs computing; what remains is the page
count. The page map is then tokens_per_page times smaller than ReqToTokenPool,
2 MB against 33 MB at 256 requests and 32k context.

A double free is diagnosed rather than deduplicated. An allocation bitmap
separates the same page appearing twice in one call, which is just token-index
deduplication and is fine, from freeing a page that is not currently allocated,
which is a use-after-free. Stale request handles are caught the same way, by an
epoch on each page-map row.

And recycled pages are tracked. A freed page holds the previous occupant's KV
until something overwrites it, and neither sglang-jax nor vLLM zeroes them, so
this is net-new rather than inherited. A page is dirty from the moment it is
freed until a caller confirms it was overwritten, and build_page_table refuses to
describe a dirty page. That fixes one order per step -- reserve, scrub, confirm,
build, run -- and makes a missed scrub an exception instead of one request
reading another's KV and producing plausible tokens. Confirmation is a separate
call because it is the single point where the guarantee can be broken.

Bucketing pads batch, tokens and the gather table to power-of-two ladders so a
churning batch traces a fixed set of shapes; 24 mixed-length requests present
21+ raw shapes and compile 7. Two refinements on the design: the gather table is
derived from batch and length rather than given its own ladder, which removes a
dimension from the cross product instead of adding one, and max_seqlen_k needs a
ladder that was not anticipated, because the kernels take it as a static
configuration value.

Decode running out of pages preempts the newest request by recomputation. Absent
from the design and required by it: stable under churn cannot hold if the loop
can deadlock, and with a full pool and every live request needing a page there is
otherwise no way forward.

MaxEngine gets a parallel surface, not a rewrite: init_paged_runtime,
prefill_paged, generate_paged. init_decode_state, _insert_jit and generate are
specific to the dense two-region cache, and rebuilding them around pages is a
much larger change than it reads as. release(handle) becomes the real API with
release_pages(slot) reduced to a shim, because a slot encodes the dense model's
assumption that a request owns one fixed reservation.

Wiring a whole model through it exposed two bugs in the M3 code that a
layer-level test cannot reach. Transformer.__call__ returned kv_caches only for
the vLLM modes, so on the gpu_paged path the aliased pool handles were dropped
and the caller was left holding a deleted array. And _nnx_run_model, the default
pure_nnx path, had no way to carry a pool at all.

Parity is teacher-forced rather than a trajectory comparison. Logits are computed
in bfloat16, so the top-two gap is quantised and exact ties occur -- not at
reproducible places, since bf16 quantises an accumulation whose order XLA may
vary between processes. At a tied step argmax is decided by tie-breaking, so two
correct implementations diverge and every later token follows the coin flip.
Replaying the paged path's own tokens through cacheless forward passes and
asserting each was an argmax is tie-tolerant, never diverges, and is the stronger
claim. It holds across a 53-token context spanning four pages.

The benchmark reports paged at 8.5x dense throughput and 20x better p50 TTFT at
an equal KV budget, with 1.14x page fragmentation and no leaks. Getting there
needed two corrections worth recording. Counting compiled shapes certified a run
that was three-quarters compile time, because padding a variable-length array
with jnp compiles once per length and no shape bucket changes; per-call arrays
are now built in numpy, and reportability is decided by repeating the workload
and comparing passes, which cannot miss a category. And warmup must sweep the
sequence-length ladder as well as batch and tokens, since a context growing past
a rung presents a new program at an already-compiled batch width.

147 CPU tests and 28 GPU tests, the CPU ones runnable with no accelerator.

Co-authored-by: Cursor <cursoragent@cursor.com>
@i-chaochen i-chaochen changed the title Chao/kv paged kernels [Draft] kv paged kernels Aug 26, 2026
@i-chaochen
i-chaochen marked this pull request as draft August 26, 2026 01:19
i-chaochen and others added 2 commits August 26, 2026 14:27
… prefix (M5)

Adds kv_control/prefix_index.py and kv_common/namespace.py: a page trie mapping
token prefixes to the pages already holding their K/V, so a request whose prompt
starts with tokens someone else already computed reads those pages instead of
recomputing them. Off by default, behind paged_enable_prefix_cache.

On a trace of 24 requests sharing a 512-token prefix, 52.8% of prefill tokens are
not recomputed and TTFT p50 goes from 7.74ms to 4.27ms. Read the token count as
the result and the latency as an indication: tokens avoided is arithmetic and
scale-free, while the 1.81x is sub-linear in the 52.8% of work removed because
this model is small enough for fixed per-step cost to still matter.

Reimplemented from sglang-jax's radix cache as a reference design rather than
ported, with three divergences that page granularity permits.

A node is one page, not a variable-length token run. Node splitting disappears
entirely, which is most of the reference's complexity, and costs nothing here
because only whole pages are ever published; the reference needs splitting
because it matches at token granularity within a page.

The cache namespace is folded into the hash chain rather than compared beside
it. Each node's key is a hash chained from its parent, and the chain starts at
the namespace digest instead of a constant, so two configurations do not share a
root and a mismatch is structurally unable to hit. The reference compares
extra_key and dp_rank as a side check, and a check is a thing that can be
forgotten.

That namespace covers everything which changes the K/V for identical token ids:
weights fingerprint and revision, tokenizer, adapter, tenant, RoPE, KV dtype and
quantisation, layout, sharding, prompt embeddings, multimodal inputs. Its digest
enumerates its own dataclass fields rather than listing them, and the negative
test is generated the same way. A hand-written digest is a second place to
remember every field, and the one occasion someone adds a field and forgets is
the occasion two incompatible configurations share K/V.

And recency is a monotonic counter, not time.monotonic(). A clock ties at its
resolution when many nodes are touched in one step, which makes eviction order
depend on how the heap broke the tie; a counter cannot tie, so the order is
reproducible and a test can assert against it.

A match deliberately stops one page short even when the whole prompt is cached,
because a request with nothing left to run has no query token to produce a logit
from.

The failure this has to rule out is arithmetic rather than bookkeeping. After a
hit the step runs the prompt's suffix, which sits at absolute positions
cached..prompt_len, and RoPE encodes absolute position -- so running that suffix
from position zero yields K/V rotated as though it began the sequence. Pages
laid out correctly, page table correct, nothing leaked, output wrong, and no
host-side test can see it. gpu_paged_prefix_cache_test.py asserts a warm rollout
is token-identical to a cold one, which is the only form of the claim that
catches it.

Two page-lifetime bugs that only exist once pages outlive requests are fixed
here and will need re-checking when vLLM owns allocation. Poison-on-free now
follows what release actually freed rather than everything the request held,
since poisoning a page the index just adopted destroys K/V about to be read as
valid. And preemption no longer publishes: it exists to reclaim pages, and the
cache retains what it adopts. Symmetrically, admission budgets against free plus
evictable pages, because reservation evicts on shortfall -- budgeting against
the free list alone stalls a loop that could still progress, which would turn an
optimisation into a reason requests stop being served.

175 CPU tests and 4 GPU tests, including a generated negative test per namespace
field confirming that varying one alone defeats the match.

run_prefix_cache_benchmark.py measures two arms of the same engine on the same
trace, warming with a discarded pass over that trace rather than a shape sweep.
That is not only simpler: warmup_paged currently fails in this container with an
aiter allocation error which reproduces on the pre-M5 default configuration, so
it is unrelated to prefix caching but does need diagnosing separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
…enchmark harness

Found by running the shared-prefix path through run_serving_benchmark.py for the
first time, where it reported 86 leaked pages and a 95.2% prefill saving. Both
figures were wrong.

pages_leaked counted the pages the prefix cache is deliberately holding. It
reported a leak on a run that leaked nothing, and an alarm that fires on every
run with sharing enabled is one nobody reads -- which would hide the genuine
leak the metric exists to catch. It now subtracts what the index retains and
reports that separately.

run_repeated now clears the cache between passes. Otherwise a later pass finds
the earlier pass's pages waiting for it, which inflates the saving past what the
trace's own requests share with each other and makes the passes incomparable,
defeating the stability check that is the entire reason for repeating. The
95.2% was almost all cross-pass reuse; the same trace measures 0% once each
pass starts cold, which is correct, because that harness admits every request
at once and nothing can reuse pages nobody has released yet. The run also went
from "NOT reportable" to reportable at a 1.014 spread, which is the change
paying for itself.

And the occupancy ratio now reads as a sharing dividend when it falls below 1.
Pages held can be fewer than the tokens requests collectively address, because
several of them are reading the same pages; calling that "page overhead"
reports the benefit as a cost.

--prefix-cache now says in its help that this harness is the wrong shape to
measure sharing and points at run_prefix_cache_benchmark.py, which staggers
admission through a batch cap below the request count -- that staggering is what
creates anything to share, and is why the dedicated script exists.

No change to the headline result: 52.8% of prefill tokens avoided, unchanged
across runs because it is arithmetic rather than a measurement.

Co-authored-by: Cursor <cursoragent@cursor.com>
i-chaochen and others added 5 commits August 26, 2026 18:24
heads_per_shard() returned the full KV head count whenever kv_head_shards >=
num_kv_heads, which is wrong in both regimes it covers.

At the boundary -- kv_head_shards == num_kv_heads, which is the common case of
TP=8 on a model with 8 KV heads -- the heads partition exactly one per shard.
Returning 8 sized every shard's pool a factor of TP too large, and contradicted
replication_factor(), which correctly reported no replication. A TP=8 smoke test
allocated (513, 16, 8, 128) per device where (513, 16, 1, 128) was called for.

Above the boundary it was wrong in the same direction. Each rank computes a
subset of the query heads and therefore needs exactly the one KV head those map
to, not every head, so the shard holds one head and it is the number of copies
that grows. Counting both the heads and the copies double-counted the footprint
by the replication factor.

Both regimes are now the same expression with a floor of one, and the test for
the replication case asserts the two ways of counting total_pool_bytes agree,
since pool sizing is exactly what a disagreement would corrupt.

Co-authored-by: Cursor <cursoragent@cursor.com>
… until the kernel can follow

Adds kv_pool_sharding(), which decides which KV head lives on which device, and
switches the pool to globally-shaped arrays built directly under that sharding.
MaxEngine.kv_pool_sharding() is no longer a stub.

The tensor-parallel axis has to be split in two, which is why the pool builds its
own mesh rather than reusing MaxText's. A `tensor` axis of width 8 says nothing
about whether eight ranks hold eight distinct KV heads or two heads replicated
four ways; both occur and they need different device assignments. The split is
(kv_head_shard, kv_head_replica), row-major over MaxText's own device order,
because that is the assignment the model already implies: rank i computes query
head i, which reads KV head i // replication_factor, so consecutive ranks share
a KV head. Reversing that would put a rank's KV on another rank's device and
force a gather every step -- a correct but slow run rather than a failure, which
is the hardest kind to notice.

The arrays are built sharded rather than built whole and distributed. device_put
of a locally-created array would materialise the entire pool on one device
first, which at 70B is the difference between allocating a shard and failing.

Verified at TP=8: the pool lands as (513, 16, 8, 128) with one head per device,
which is what it should be.

The step itself still cannot run sharded, so init_paged_runtime now refuses a
sharded mesh outright. The aiter kernels reach the pool through an FFI custom
call and XLA cannot partition one; left alone it neither gathers nor splits and
the step hangs, which is worse to ship than a refusal. Lifting this needs the
forward wrapped in shard_map so each device runs the kernel on its own shard --
the remaining half of M6.

Note this replaces a configuration that appeared to work: before the
heads-per-shard fix, a TP=8 paged run produced correct tokens because the pool
sat unsharded on one device and XLA gathered every device's KV to it each step.
That is precisely the per-step cross-device traffic M6 exists to remove, and it
cannot outgrow one device's memory, so it was never a usable path.

Single-device paged is unchanged and still matches dense token for token.
195 CPU tests and 17 GPU tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
…gated on correctness

Donation survives shard_map's manual mode. That was the open question gating the
whole approach, and it is answered: on 8 devices the pool keeps every shard's
buffer address across an aliased append_kv, JAX raises no donation warning, the
alias appears in the lowered HLO, and the write lands per shard. A single-device
control ran alongside it and correctly read BROKEN until donate_argnums was
added, so the measurement is trustworthy rather than vacuously green.

So the kernels can be entered in manual mode, which is what XLA needs: it cannot
partition an FFI custom call, and handed a sharded pool it neither gathers nor
splits -- the step simply hangs. paged_attention_step_sharded wraps the step so
every device runs the same code against its local shard and the kernel sees a
narrower pool. No jax-aiter change was needed after all: its KV ops carry no
custom_partitioning, which is the thing that would have conflicted.

The plan arrays are replicated, and that is the point rather than a detail. Page
ids, slot offsets and last-page occupancies describe pages, and pages are not
sharded -- every device holds the same pages and differs only in which heads it
stores. So a device can compute its slice with no knowledge of any other, which
is what makes zero per-step cross-device KV traffic reachable.

kv_pool_sharding now uses the model's own mesh in the clean-partition case,
because shard_map needs the pool and the activations on one mesh and a second
mesh over the same devices does not qualify.

Not yet correct, and still refused at startup. At TP=8 the step runs without
hanging but the first sampled token already differs from the single-device paged
path and the TP=8 dense path, which agree with each other. The fault is
therefore in how the sharded operands are described to the kernels rather than
in the pool's sharding, and prefill rather than decode. A wrong answer that runs
is worse than a refusal, so init_paged_runtime continues to reject a sharded
mesh until this reproduces the single-device result.

Also fixes an indentation slip introduced in this change that left
paged_attention_step returning None on the decode path.

195 CPU tests and 17 GPU tests pass; single-device paged is unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
shard_map makes every mesh axis manual unless told which ones to take, and
MaxText's mesh carries a dozen. The region was therefore entered under a
different set of manual axes than the computation around it, which is not what
the specs describe. A single-axis mesh cannot expose that, which is how the
isolated parity test stays bit-exact while the model does not.

This is a correctness fix on its own terms rather than a guess, but it does not
resolve the TP divergence: dense and paged at TP=8 on one set of weights still
disagree, so the sharded path stays refused at startup.

Recorded for whoever picks it up, since the search space is now much smaller
than it was. append_kv is correct -- at TP=1 and TP=8 the prefill receives
identical query, key and value, and the pool afterwards is identical to the
digit. The divergence is in the attention read. paged_attention_step_sharded is
bit-exact in isolation across six cases including the model's exact plan shape,
so the function is not at fault either. And TP=2 produces the same wrong tokens
as TP=8, which points away from head slicing: a mis-sliced head axis would be
wrong differently at different widths.

Co-authored-by: Cursor <cursoragent@cursor.com>
Multi-GPU paged attention produced wrong output. The cause was not the
sharding: `forward_serve_gpu_paged` spelled out its kernel arguments
separately for its two branches, and the sharded one omitted `scale=1.0`.
The aiter kernel then applied its own 1/sqrt(head_dim) on top of a query
MaxText has already scaled through the projection initializer and
`query_pre_attn_scalar`, flattening the softmax towards uniform.

That explains the symptoms that resisted a sharding explanation: all heads
wrong and uniformly lower rather than permuted, and TP=2 and TP=8 wrong
identically, because the defect does not depend on shard count.

No sharded-against-single-device test can see this, since it hands the same
scale to both sides and the error cancels; the existing dense comparison
runs unsharded and never reaches the branch. The same mistake had already
been made once on the single-device path. So the arguments are now built
once and shared, and the new test asserts on the call site rather than the
numbers, confirmed to fail when the bug is reintroduced.

Paged is now token-identical to dense at TP=2, 4 and 8, with no collective
between AppendKvJA and the attention call consuming it, and pool donation
intact. `init_paged_runtime` therefore accepts the clean partition and
refuses only replication, which still lacks a shared mesh for the pool.

Verified: 195 CPU tests (kv_common, kv_control, kv_execution,
kv_prefix_cache, kv_import_rule), 17 GPU tests (kv_paged_runtime,
gpu_paged_decode_parity, gpu_paged_prefix_cache), and the three gpu_paged
cases in attention_test.py. This is a correctness result on a two-layer toy
model only; M6 remains open on the replicated regime and on all of its
measurements, which are blocked on the 70B checkpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
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