Skip to content

[Serve][LLM] Avoid double prompt tokenization in prefill-decode disaggregation - #64049

Merged
eicherseiji merged 7 commits into
ray-project:masterfrom
eicherseiji:pd-tokenize-once
Jun 17, 2026
Merged

[Serve][LLM] Avoid double prompt tokenization in prefill-decode disaggregation#64049
eicherseiji merged 7 commits into
ray-project:masterfrom
eicherseiji:pd-tokenize-once

Conversation

@eicherseiji

@eicherseiji eicherseiji commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

In prefill-decode disaggregation, the same chat prompt is tokenized twice. Each stage drives the engine through vLLM's chat-completions path, which renders and tokenizes messages. Prefill tokenizes the prompt and decode tokenizes it again. For large prompts the tokenizer pass dominates TTFT.

With this change, decode reuses the prompt token ids that prefill already produced and skips the redundant encode. Decode stays on the chat path. Chat-template rendering, tool and reasoning parsing, sampling, and response shaping are unchanged. Only the tokenizer encode is skipped.

The feature is opt-in and off by default. Enable it with experimental_configs["pd_tokenize_once"].

Measured impact

Numbers come from a 4-prefill, 4-decode agentic benchmark with microsoft/Phi-tiny-MoE-instruct, roughly 37k input tokens, and 256 concurrent clients. The off and on runs used the same build. Median client TTFT drops 16%.

Metric Off On
Client TTFT median 989 ms 833 ms
Client TTFT mean 1030 ms 902 ms
Decode tokenize median 164 ms ~0 ms
Decode dispatch to first token median 400 ms 188 ms

Prefill still tokenizes once. TPOT is unaffected.

How it works

vLLM's renderer already skips the tokenizer encode when a rendered prompt arrives with prompt_token_ids set. It still preserves multi_modal_data and runs detokenization, truncation, and validation. So decode does not need a new code path. It needs a way to get prefill's ids to that one spot inside vLLM.

That spot is several layers away. The orchestrator _pd_handle_request holds the ids. Tokenization happens inside vLLM's renderer, reached through super().chat(), the engine, and vLLM's serving layer. The chat request has no field for pre-tokenized input, and the intervening layers are vLLM code, so no signature can thread the ids through.

A contextvars.ContextVar bridges that gap. The value is task-local, so each request's decode reads only the ids its orchestrator set and concurrent requests stay isolated. The orchestrator sets the ids before driving decode, and the tokenize call deep inside vLLM reads them when it runs.

Concretely:

  • The orchestrator sets the existing return_token_ids=True field on the prefill request and reads the echoed ids off the prefill response. Chat carries them top-level as prompt_token_ids. Completions carry them on the first choice as CompletionResponseChoice.prompt_token_ids. The orchestrator then wraps the local decode call in with reuse_prompt_token_ids(ids), which sets the contextvar for that request's decode and clears it after.
  • install() runs once per decode replica from PDDecodeServer.__init__ and wraps BaseRenderer.tokenize_prompts_async. When the contextvar holds ids and the request rendered to a single ordinary text prompt, the wrapper adds prompt_token_ids to that prompt and delegates to the real method, which then skips the encode. Every other case delegates unchanged: no ids set, batched, embeds, encoder-decoder, or already tokenized.
  • The install fails safe. When vLLM's renderer does not match, install() returns False, the feature stays off, and tokenization is untouched.

The wrap lives in ray/llm/_internal/common/patches/vllm/, a package for temporary vLLM patches.

Scope and correctness

Only the two sequential handoff paths reuse tokens. The concurrent_handoff path starts decode before prefill returns the ids, so it does not request the prefill echo.

Correctness relies on decode tokenization running on the orchestrator's async task or a task spawned from it. If a future change breaks that, the contextvar is not visible at tokenize time and decode falls back to normal tokenization. The result is slower and still correct.

The wrap patches a vLLM internal, BaseRenderer.tokenize_prompts_async, because the chat-completions path has no public way to accept pre-tokenized input. The intended end state is native pre-tokenized chat input, which removes this wrap. See vllm-project/vllm#22817 for the upstream direction.

Test plan

test_tokenize_once.py covers the following:

  • Id injection that preserves other prompt fields, and fall-through for batched, embeds, encoder-decoder, already-tokenized, and disabled prompts.
  • Idempotent install, no-op install without vLLM, and install returning False when the renderer method is absent.
  • Cross-context teardown, and concurrent tasks that do not cross-contaminate the reuse contextvar.
  • Application against the real vLLM renderer. The wrap lands on BaseRenderer.tokenize_prompts_async, and an injected prompt_token_ids reaches per-prompt tokenization so the encode is skipped.
  • Orchestrator reuse helpers. _decode_reuse_ids reads ids top-level for chat and from the first choice for completions and returns None when disabled. _request_prefill_token_ids sets return_token_ids only when enabled.

The benchmark above covers performance.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a "tokenize-once" optimization for prefill/decode (P/D) disaggregation in Ray LLM, allowing the decode stage to reuse prompt token IDs generated during the prefill stage and bypass redundant tokenization. The feedback focuses on improving the robustness and safety of this optimization. Specifically, it is recommended to wrap the monkey-patching logic in a try-except block with safe attribute access to prevent server startup crashes on incompatible vLLM versions, add a defensive check for non-iterable token IDs, and disable the optimization flag if the hook installation fails.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread python/ray/llm/_internal/serve/_tokenize_once.py Outdated
Comment thread python/ray/llm/_internal/serve/_tokenize_once.py Outdated
Comment thread python/ray/llm/_internal/serve/serving_patterns/prefill_decode/pd_server.py Outdated
In prefill/decode disaggregation the same chat prompt is tokenized twice, once
per stage, because each stage drives the engine through vLLM's chat-completions
path, which renders and tokenizes the messages. For large prompts the tokenizer
pass dominates TTFT.

This lets the decode stage reuse the prompt token ids the prefill stage already
produced, skipping the redundant tokenize. Decode stays fully on the chat path:
template render, tool and reasoning parsing, sampling, and response shaping are
unchanged. Only the tokenizer encode is skipped.

How it works (no vLLM source change, no new request field):

- Prefill echoes its prompt token ids via the existing return_token_ids flag,
  set on sequential-handoff paths only. Concurrent decode runs before prefill
  returns, so it has nothing to reuse.
- A per-async-task contextvar carries the ids across the decode call.
- A wrap around BaseRenderer.tokenize_prompts_async, installed once per decode
  replica, injects the ids into the rendered prompt and delegates to the real
  tokenizer. vLLM skips the encode when prompt_token_ids is already present and
  still preserves multimodal data and runs detokenization and validation, so
  multimodal and encoder-decoder prompts stay correct. Batched, embeds, and
  already-tokenized prompts fall through to normal tokenization.

Opt-in via experimental_configs["pd_tokenize_once"]. Active only if the renderer
wrap installs, so it no-ops on non-vLLM engines. Works for chat and completions.

Adds a unit test for the renderer wrap covering injection, fall-through,
idempotent install, and cross-context-safe teardown.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
install() could raise AttributeError if BaseRenderer.tokenize_prompts_async
were renamed or removed, crashing decode-replica startup and contradicting the
fail-safe contract in the docstring. Wrap the whole body in try/except and use
getattr(BaseRenderer, "tokenize_prompts_async", None) so a missing or
incompatible renderer returns False (feature stays off) instead of raising. Add
a unit test for the missing-method path.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
@eicherseiji eicherseiji added the go add ONLY when ready to merge, run all tests label Jun 12, 2026
@eicherseiji
eicherseiji marked this pull request as ready for review June 12, 2026 09:28
@eicherseiji
eicherseiji requested a review from a team as a code owner June 12, 2026 09:28
@ray-gardener ray-gardener Bot added serve Ray Serve Related Issue llm labels Jun 12, 2026

@kouroshHakha kouroshHakha 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.

this works, but is there a better way that is native to vllm?

_reused_token_ids.set(None)


def install() -> bool:

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.

big idea: can't we use servingtokens implementation for token-in text-out implementation instead of this brittle patching:

https://github.com/vllm-project/vllm/blob/main/vllm/entrypoints/serve/disagg/serving.py

I am looking for some way to natively implement token-in, text-out (or maybe token-out).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah token-in, text-out would be ideal. A similar idea and is being implemented in this series as part of RL support, e.g. /render and /derender with ServingTokens in the middle.

However, this /render + /derender explicitly does not support streaming. After some research, we're going to need some vLLM side patches no matter what.

@eicherseiji eicherseiji self-assigned this Jun 12, 2026
Compress the verbose comments and docstrings added in this PR to terse,
period-separated prose. Drop parentheticals, colons, semicolons, and dashes,
and remove the speculative same-task hedge. Comment-only, no behavior change.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
eicherseiji added a commit to anyscale/llm-direct-streaming-benchmarks that referenced this pull request Jun 16, 2026
Ray Serve LLM P/D uses the public ray.serve.llm.build_pd_openai_app builder
with tokenize-once (ray-project/ray#64049): decode reuses prefill's prompt
token ids instead of re-tokenizing. One config across c64/c128/c256
(kv_producer/kv_consumer, decode max-ongoing unbounded). vLLM-router uses stock
--vllm-pd-disaggregation. Wrapper, launcher, and start.sh default to the public
builder; figure subtitle, MANIFEST, README, and PROVENANCE updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
eicherseiji added a commit to anyscale/llm-direct-streaming-benchmarks that referenced this pull request Jun 16, 2026
launch.py now uses only the public build_pd_openai_app path (303 lines, down
from 702): dropped the session-aware decode-to-prefill routing, direct
decode-engine streaming, direct ASGI orchestration, unique-NIXL-port mixin, and
the USE_PUBLIC_PD_BUILDER / PD_DECODE_DIRECT_ENGINE / PD_DIRECT_ASGI_ORCHESTRATION
knobs (with the ray.llm-internal and Starlette imports they required). start.sh
and the wrapper drop the now-dead env vars. Behavior is unchanged: the benchmark
runs on the public builder with tokenize-once (ray-project/ray#64049).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
eicherseiji added a commit to anyscale/llm-direct-streaming-benchmarks that referenced this pull request Jun 16, 2026
Ray Serve LLM P/D uses the public ray.serve.llm.build_pd_openai_app builder
with tokenize-once (ray-project/ray#64049): decode reuses prefill's prompt
token ids instead of re-tokenizing. One config across c64/c128/c256
(kv_producer/kv_consumer, decode max-ongoing unbounded). vLLM-router uses stock
--vllm-pd-disaggregation. Wrapper, launcher, and start.sh default to the public
builder; figure subtitle, MANIFEST, README, and PROVENANCE updated to match.
eicherseiji added a commit to anyscale/llm-direct-streaming-benchmarks that referenced this pull request Jun 16, 2026
launch.py now uses only the public build_pd_openai_app path (303 lines, down
from 702): dropped the session-aware decode-to-prefill routing, direct
decode-engine streaming, direct ASGI orchestration, unique-NIXL-port mixin, and
the USE_PUBLIC_PD_BUILDER / PD_DECODE_DIRECT_ENGINE / PD_DIRECT_ASGI_ORCHESTRATION
knobs (with the ray.llm-internal and Starlette imports they required). start.sh
and the wrapper drop the now-dead env vars. Behavior is unchanged: the benchmark
runs on the public builder with tokenize-once (ray-project/ray#64049).
eicherseiji and others added 3 commits June 16, 2026 16:55
Confirm the renderer wrap applies against the real vLLM BaseRenderer (injection
reaches per-prompt tokenization so the encode is skipped) and is idempotent,
that concurrent tasks do not cross-contaminate the reuse contextvar, and that
the orchestrator reuse helpers gate correctly: _decode_reuse_ids reads ids
top-level for chat and from the first choice for completions and returns None
when disabled, and _request_prefill_token_ids only sets return_token_ids when
enabled and present.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Relocate the tokenize-once renderer wrap into a dedicated
ray/llm/_internal/common/patches/vllm package that flags it as a temporary vLLM
patch to be upstreamed and removed (per review: isolate patches, drive toward
upstreaming). Behavior unchanged; only the module location and imports move.

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
@eicherseiji eicherseiji changed the title [Serve][LLM] P/D: reuse prefill tokenization on decode (tokenize-once) [Serve][LLM] Avoid double prompt tokenization in prefill-decode disaggregation Jun 17, 2026
@eicherseiji
eicherseiji requested a review from kouroshHakha June 17, 2026 21:17
@kouroshHakha

Copy link
Copy Markdown
Contributor

running release tests to be sure: https://buildkite.com/ray-project/release/builds/97389

@eicherseiji
eicherseiji merged commit 01bc155 into ray-project:master Jun 17, 2026
5 of 6 checks passed
limarkdcunha pushed a commit to limarkdcunha/ray that referenced this pull request Jun 30, 2026
…gregation (ray-project#64049)

## Summary

In prefill-decode disaggregation, the same chat prompt is tokenized
twice. Each stage drives the engine through vLLM's chat-completions
path, which renders and tokenizes `messages`. Prefill tokenizes the
prompt and decode tokenizes it again. For large prompts the tokenizer
pass dominates TTFT.

With this change, decode reuses the prompt token ids that prefill
already produced and skips the redundant encode. Decode stays on the
chat path. Chat-template rendering, tool and reasoning parsing,
sampling, and response shaping are unchanged. Only the tokenizer encode
is skipped.

The feature is opt-in and off by default. Enable it with
`experimental_configs["pd_tokenize_once"]`.

## Measured impact

Numbers come from a 4-prefill, 4-decode agentic benchmark with
`microsoft/Phi-tiny-MoE-instruct`, roughly 37k input tokens, and 256
concurrent clients. The off and on runs used the same build. Median
client TTFT drops 16%.

| Metric | Off | On |
|---|---|---|
| Client TTFT median | 989 ms | 833 ms |
| Client TTFT mean | 1030 ms | 902 ms |
| Decode tokenize median | 164 ms | ~0 ms |
| Decode dispatch to first token median | 400 ms | 188 ms |

Prefill still tokenizes once. TPOT is unaffected.

## How it works

vLLM's renderer already skips the tokenizer encode when a rendered
prompt arrives with `prompt_token_ids` set. It still preserves
`multi_modal_data` and runs detokenization, truncation, and validation.
So decode does not need a new code path. It needs a way to get prefill's
ids to that one spot inside vLLM.

That spot is several layers away. The orchestrator `_pd_handle_request`
holds the ids. Tokenization happens inside vLLM's renderer, reached
through `super().chat()`, the engine, and vLLM's serving layer. The chat
request has no field for pre-tokenized input, and the intervening layers
are vLLM code, so no signature can thread the ids through.

A `contextvars.ContextVar` bridges that gap. The value is task-local, so
each request's decode reads only the ids its orchestrator set and
concurrent requests stay isolated. The orchestrator sets the ids before
driving decode, and the tokenize call deep inside vLLM reads them when
it runs.

Concretely:

- The orchestrator sets the existing `return_token_ids=True` field on
the prefill request and reads the echoed ids off the prefill response.
Chat carries them top-level as `prompt_token_ids`. Completions carry
them on the first choice as `CompletionResponseChoice.prompt_token_ids`.
The orchestrator then wraps the local decode call in `with
reuse_prompt_token_ids(ids)`, which sets the contextvar for that
request's decode and clears it after.
- `install()` runs once per decode replica from
`PDDecodeServer.__init__` and wraps
`BaseRenderer.tokenize_prompts_async`. When the contextvar holds ids and
the request rendered to a single ordinary text prompt, the wrapper adds
`prompt_token_ids` to that prompt and delegates to the real method,
which then skips the encode. Every other case delegates unchanged: no
ids set, batched, embeds, encoder-decoder, or already tokenized.
- The install fails safe. When vLLM's renderer does not match,
`install()` returns False, the feature stays off, and tokenization is
untouched.

The wrap lives in `ray/llm/_internal/common/patches/vllm/`, a package
for temporary vLLM patches.

## Scope and correctness

Only the two sequential handoff paths reuse tokens. The
`concurrent_handoff` path starts decode before prefill returns the ids,
so it does not request the prefill echo.

Correctness relies on decode tokenization running on the orchestrator's
async task or a task spawned from it. If a future change breaks that,
the contextvar is not visible at tokenize time and decode falls back to
normal tokenization. The result is slower and still correct.

The wrap patches a vLLM internal, `BaseRenderer.tokenize_prompts_async`,
because the chat-completions path has no public way to accept
pre-tokenized input. The intended end state is native pre-tokenized chat
input, which removes this wrap. See vllm-project/vllm#22817 for the
upstream direction.

## Test plan

`test_tokenize_once.py` covers the following:

- Id injection that preserves other prompt fields, and fall-through for
batched, embeds, encoder-decoder, already-tokenized, and disabled
prompts.
- Idempotent install, no-op install without vLLM, and install returning
False when the renderer method is absent.
- Cross-context teardown, and concurrent tasks that do not
cross-contaminate the reuse contextvar.
- Application against the real vLLM renderer. The wrap lands on
`BaseRenderer.tokenize_prompts_async`, and an injected
`prompt_token_ids` reaches per-prompt tokenization so the encode is
skipped.
- Orchestrator reuse helpers. `_decode_reuse_ids` reads ids top-level
for chat and from the first choice for completions and returns None when
disabled. `_request_prefill_token_ids` sets `return_token_ids` only when
enabled.

The benchmark above covers performance.

---------

Signed-off-by: Seiji Eicher <seiji@anyscale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go add ONLY when ready to merge, run all tests llm serve Ray Serve Related Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants