Skip to content

[PCP] Move the host-side preprocessing into runner/pcp_utils.py - #3514

Draft
bhuvanpkaruturi wants to merge 11 commits into
vllm-project:mainfrom
bhuvanpkaruturi:pcp-preprocessing-module
Draft

[PCP] Move the host-side preprocessing into runner/pcp_utils.py#3514
bhuvanpkaruturi wants to merge 11 commits into
vllm-project:mainfrom
bhuvanpkaruturi:pcp-preprocessing-module

Conversation

@bhuvanpkaruturi

Copy link
Copy Markdown
Collaborator

Stacked on #3425. Only the last commit ([PCP] Move the host-side preprocessing into runner/pcp_utils.py) belongs to this PR; the first seven are #3425 rebased onto current main. Kept as a draft until #3425 merges, then this rebases to a single commit.

Description

Pure code motion, no behaviour change. Follows a review suggestion on #3425 to move the CP preprocessing logic into its own file in the runner folder.

Today the per-step PCP work that _prepare_inputs does inline — validating the batch as prefill-only, zigzag-chunking each request, permuting the token buffers into rank order, building the per-seq attention metadata and logits indices, and assembling PCPMetadata — is one of the largest single blocks in tpu_runner.py, and the pure layout helpers it uses sit in layers/common/attention_metadata.py next to the dataclass they build.

After this PR:

  • New tpu_inference/runner/pcp_utils.py holds all of it. PCPPreprocessor.prepare_inputs is the old inline block; _prepare_inputs reduces to a single call. The layout helpers (pcp_token_layout, pcp_seq_arrays, pcp_cache_page_buckets, round_up_pcp_cache_pages) move here from attention_metadata.py, which keeps only the PCPMetadata dataclass — the layer-side contract stays under layers/, everything that builds it is runner-side. This mirrors how speculative decoding, structured decoding and multimodal preprocessing already live in their own runner modules.
  • Two self-contained numpy loops become pcp_token_permutation and pcp_last_token_slots, and pcp_batch_layout folds the buffer-fit assert together with the single-request chunk override, so each step of the layout can be checked on its own without a runner.
  • PCPPreprocessor is constructed in _init_inputs after the request-count ladder and owns the multi-request log counter. The token-bucket sizing uses pcp_buffer_tokens. The compilation manager and the interface test only change their imports.

git diff --color-moved shows the move; the runner-side call site is the only logic that is new.

Tests

  • New tests/runner/test_pcp_utils.py (39 checks, numpy plus one mesh test): every real token lands in exactly one slot, kv_token_order undoes the permutation on the live rows, the zigzag rank assignment, cu_q_lens / q_pos_offsets / kv_new_starts per seq, the logits slots, and prepare_inputs end to end on a mesh (skipped with fewer devices than pcp_size).
  • On v7x: tests/kernels/rpa_v3_cp/ragged_paged_attention_kernel_cp_test.py (38 passed, 1 skipped) and tests/layers/common/test_pcp_attention_interface.py (25 passed) — identical counts to [PCP] Multi-request prefill context parallelism on the ring cache phase #3425 without this change — plus the new runner suite (39 passed).
  • End-to-end greedy-token diff of a PCP=2 x TP=4 server against TP=8 (Qwen3-8B, 8k prompts, 4k chunk so the cache phase runs, up to 8 requests per step): behaviour is identical with and without this refactor on the same base, and 8/8 prompts match once the chunked-prefill fixes that follow [PCP] Multi-request prefill context parallelism on the ring cache phase #3425 are included.

Checklist

Before submitting this PR, please make sure:

  • I have performed a self-review of my code.
  • I have necessary comments in my code, particularly in hard-to-understand areas.
  • I have made or will make corresponding changes to any relevant documentation.

bhuvanpkaruturi and others added 10 commits August 19, 2026 17:23
Prepares the metadata contract for fusing several prefill requests into
one RPA launch. Three additions, all inert while num_reqs == 1:

- kv_new_starts: base offset of each fused seq's current-KV block inside
  the all-gathered new-KV buffer. With one request every block starts at
  0, which is the kernel's implicit base, so None keeps the old path.
- kv_token_order: permutation taking the all-gathered current K/V from
  rank order to request-major token order. The single-request path lets
  the kernel remap addresses itself via pcp_chunk_size; that remap
  assumes one request, so several requests need the reorder done in JAX.
- num_reqs: static, so the two paths compile separately rather than
  branching on a traced value.

Also adds pcp_token_layout(), the per-request zigzag chunking shared by
the runner and the precompile path. Padding slots get a 1-token chunk
rather than a zero-length one: the cache phase derives its sequence
boundaries from these offsets, and two slots sharing an offset produce a
zero-length sequence, which the kernel does not terminate on.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Two scalar-prefetch refs replace an assumption that only one request is
in flight:

- kv_new_starts_ref: per-seq base offset into the new-KV buffer. Without
  it every sequence reads its current K/V from offset 0, which is only
  correct when the launch carries a single request.
- kv_write_seq_mask_ref: which sequences perform the cache write. This
  replaces the write_last_seq_only static flag, which could express "the
  last of one request's two fused seqs" but not "the tail seq of each of
  R requests".

write_last_seq_only is removed rather than kept alongside: it is a
special case of the mask, and leaving both would let callers set
contradictory values.

Validation rejects kv_new_starts together with pcp_chunk_size — the
chunk-size remap rewrites addresses on the assumption of one request, so
the two are mutually exclusive by construction.

Tests cover per-request KV writes landing on the right pages exactly
once, and per-request current-phase output against a reference. One test
is skipped by default: it asserts the kernel tolerates a zero-length
sequence, which it does not — it hangs the device rather than raising,
so running it needs PCP_RUN_HANG_REPRO=1 under an external timeout.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Cache phase (in-kernel ring): one seq per REQUEST spanning its head+tail
run.  Q stays local under the ring, so each rank's buffer is its request
slots back to back and the description is just the current-phase arrays
with the head/tail duplication undone: cu_q_lens, kv_lens, kv_cache_lens
and the block-table rows at even indices, and half the seq count from
request_distribution.  No tiling, no output collective.  R == 1 keeps
the ring PR's whole-buffer seq verbatim, which is valid for any
current-phase cu_q_lens including the clipped-tail, per-rank one older
callers build.

The R > 1 construction relies on the ring's lock-step: remote copies and
semaphores are matched by loop position, so every rank must run the
identical (seq, tile, block, round) schedule.  request_distribution is
replicated, cu_q_lens is rank-invariant because the runner gives both
halves the full chunk length, and the block count comes from the
replicated global cache length.

Current phase: the all-gathered K/V arrives in rank order.  With one
request the kernel remaps addresses itself via pcp_chunk_size; that
remap assumes a single request, so several requests reorder into
request-major order in JAX via kv_token_order and pass kv_new_starts
instead.  Each request's tail seq performs the fused KV write via
kv_write_seq_mask.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Lifts the one-request-per-step restriction. Each request is zigzag-
chunked independently, so rank r holds chunk r and chunk 2P-1-r of every
request, and request i's head/tail pair occupies a fixed-width slot in
every rank's region.

The pieces:

- Token permutation from natural order to rank order, and its inverse
  for the K/V reorder. Slots past a request's end stay -1 and are zeroed.
- Attention metadata over 2R fused seqs, request i at seqs 2i and 2i+1.
  Both halves get the full chunk length, so cu_q_lens is rank-invariant
  and only q_pos_offsets varies by rank.
- logits_indices pointing at each request's last real token, wherever
  the zigzag put it.
- Block-table rows duplicated per fused seq, since the KV write is done
  by the tail seq and page addressing is per-seq.

Buffer sizing changes with it: attention seq slots double under PCP, and
the token bucket ladder gains 2*P*max_num_seqs of headroom. Rounding
each chunk up independently means P*S can exceed the scheduler's token
budget, and the scheduler admits by sum(n_i), so without that headroom a
legal batch has no bucket to land in.

A step with exactly ONE request keeps the single-request layout: chunk
t_pad / 2P, head and tail each filling half of every rank's slice, i.e.
exactly the layout upstream's runner produces. pcp_forward's R == 1
current phase (the kernel-side rank-order remap when the chunk is
page-aligned, to_token_order otherwise) derives the chunk from the padded
buffer width -- pcp_chunk_size is a static kernel argument -- so a lone
request packed at ceil(n / 2P) would have its current K/V reordered
against the wrong chunk whenever the token bucket is not exact. Multi-
request steps carry their own permutation (kv_token_order) and do not
depend on the buffer width. The extra tail rows past the request's last
token are padding rows like any other: they attend only real keys,
nothing reads their output, and the KV write is driven by kv_lens -
kv_cache_lens, not by the row count.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Adds num_reqs to the warm-up ladder and builds well-formed dummy PCP
metadata for it.

The dummy metadata has to be a batch the runtime could actually produce.
An all-zero query_start_loc satisfies every shape check but describes
zero-length sequences, which hang the kernel on device during warm-up —
the failure looks like a deadlock at startup with no traceback.

The ladder is capped at {1, max_num_seqs} rather than every power of two,
and combinations the runtime cannot reach are skipped: cache_pages only
matters to the gather-KV path, which multi-request does not take, and a
batch of R requests cannot have fewer than 2*P*R tokens. Without these
the graph count roughly quadrupled and warm-up dominated startup.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
…{0, max}

Both attention phases now iterate the live seq count from
request_distribution, so the slots that pad the request count up to the
static num_reqs bucket never reach the kernel: drop their 1-token chunks
from the layout and the max(1, ...) floor in pcp_token_layout.

With the ring, cache_pages only decides whether the cache phase is
elided (== 0); every nonzero rung compiled the same kernel.  Two rungs
halve the PCP precompile set.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Kernel (tests/kernels/rpa_v3_cp/ragged_paged_attention_kernel_cp_test.py):
- fused current phase with several requests: per-request kv_new_starts,
  kv_write_seq_mask, every request's strided KV share written exactly once,
  ragged lengths, mixed cached/uncached;
- ring cache phase over three seqs in one launch with different cached
  lengths (one zero), row counts and page lists, P = 2 and 4, f32 and bf16,
  against per-seq full-cache reference (per-seq block counts, one
  cross-rank chain across seq boundaries, lock-step).

Interface (tests/layers/common/test_pcp_attention_interface.py):
- pcp_forward with R requests: ragged lengths, mixed cached/uncached, live
  count below the static num_reqs bucket, per-request KV cache write, a
  request spanning several ring query tiles, the batch shape from a 32k
  end-to-end run in the headroom token bucket, and ~1M cached tokens next to
  a short request;
- single-request regression cases unchanged.

All need >= P TPU devices; run through cdk.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
Review follow-ups for vllm-project#3425.

Branching: `kv_new_starts` and `kv_token_order` are now required
fields of PCPMetadata and are built by the runner, the precompile
dummies, the interface tests and the pcp-vs-tp benchmark for every
request count. That removes the optional-argument plumbing in
pcp_forward (`*pcp_extra`, `extra_specs`/`extra_args`, the None guard)
and the `if multi else None` in the runner and precompile. The
single-request `to_token_order`/`inv_row` reshuffle is gone too: the
runner's `kv_token_order` for one request is exactly that permutation,
so the non-page-aligned path is one `jnp.take` for any request count.
The page-aligned single-request fast path (kernel-side remap via
`pcp_chunk_size`) and the single-request cache-phase span are kept as
they were; merging them into the general layout is the follow-up PR.

`cache_pages` becomes `has_cached_kv: bool`. After the in-kernel ring
the value only decided whether the cache phase runs, so the two-rung
page ladder, its bucket constant and the round-up helper are deleted;
precompile loops over {False, True}.

Removed the multi-request batch logger and its counter, which were
debugging aids. Comments cut to what the code does not say itself.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
@bhuvanpkaruturi
bhuvanpkaruturi force-pushed the pcp-preprocessing-module branch from 3beabfc to 230a62f Compare September 4, 2026 22:58
Pure code motion, no behaviour change. The PCP block in `_prepare_inputs`
(prefill-only check, zigzag chunking, token permutation into rank order,
per-seq attention metadata, logits indices, `PCPMetadata` assembly) moves
to `PCPPreprocessor.prepare_inputs` in the new `runner/pcp_utils.py`, along
with the pure layout helpers from `layers/common/attention_metadata.py`,
which keeps only the `PCPMetadata` dataclass.

Why: `tpu_runner.py` is the most contended file in the tree and the PCP
block was one of the largest additions to `_prepare_inputs`; the runner
folder already keeps speculative decoding, structured decoding and
multimodal preprocessing in their own modules. It also makes the layout
logic testable without a runner.

`PCPPreprocessor.metadata_to_device` is the one place that places host
arrays as a `PCPMetadata`; the compilation manager builds its precompile
dummy through it instead of carrying a second copy of the sharding recipe.
The token-bucket headroom uses `pcp_max_buffer_tokens`, the closed-form
bound of `pcp_buffer_tokens`, so the bound lives next to the function it
bounds. The logits indices are read off `kv_token_order` rather than
re-deriving the zigzag formula.

tests/runner/test_pcp_utils.py covers the layout, the permutation and its
inverse, the per-seq arrays, the headroom bound, the logits slots, and
`prepare_inputs` end to end on a mesh (skipped with fewer devices than
pcp_size). The mock-runner DP tests set `pcp_preprocessor = None`: the
runner now gates PCP on that attribute, and a MagicMock auto-attribute is
never None.

Signed-off-by: Bhuvan Karuturi <bkaruturi@google.com>
@bhuvanpkaruturi
bhuvanpkaruturi force-pushed the pcp-preprocessing-module branch from 230a62f to e6cc94b Compare September 4, 2026 23:03
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.

1 participant