[Serve][LLM] Avoid double prompt tokenization in prefill-decode disaggregation - #64049
Conversation
There was a problem hiding this comment.
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.
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>
c6d63e8 to
efb42ff
Compare
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>
kouroshHakha
left a comment
There was a problem hiding this comment.
this works, but is there a better way that is native to vllm?
| _reused_token_ids.set(None) | ||
|
|
||
|
|
||
| def install() -> bool: |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
- [Render] Add reasoning and tool call parsing to
/derendervllm-project/vllm#45045 - [Frontend] Add GPU-less render serving path (
vllm launch render) vllm-project/vllm#36166 - [RFC]: Derender Endpoints to Provide Detokenization for Disaggregated Serving vllm-project/vllm#42729
- [RFC]: Disaggregated Frontend — Separating Online Serving from Engine vllm-project/vllm#34407
- [RFC]: Unified Parser for reasoning, tool calling vllm-project/vllm#32713
However, this /render + /derender explicitly does not support streaming. After some research, we're going to need some vLLM side patches no matter what.
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>
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>
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>
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.
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).
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>
|
running release tests to be sure: https://buildkite.com/ray-project/release/builds/97389 |
…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>
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%.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_idsset. It still preservesmulti_modal_dataand 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_requestholds the ids. Tokenization happens inside vLLM's renderer, reached throughsuper().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.ContextVarbridges 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:
return_token_ids=Truefield on the prefill request and reads the echoed ids off the prefill response. Chat carries them top-level asprompt_token_ids. Completions carry them on the first choice asCompletionResponseChoice.prompt_token_ids. The orchestrator then wraps the local decode call inwith reuse_prompt_token_ids(ids), which sets the contextvar for that request's decode and clears it after.install()runs once per decode replica fromPDDecodeServer.__init__and wrapsBaseRenderer.tokenize_prompts_async. When the contextvar holds ids and the request rendered to a single ordinary text prompt, the wrapper addsprompt_token_idsto 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.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_handoffpath 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.pycovers the following:BaseRenderer.tokenize_prompts_async, and an injectedprompt_token_idsreaches per-prompt tokenization so the encode is skipped._decode_reuse_idsreads ids top-level for chat and from the first choice for completions and returns None when disabled._request_prefill_token_idssetsreturn_token_idsonly when enabled.The benchmark above covers performance.