Repository navigation
[Frontend] Tokenize the /v1/systemone state once per decision and let big decisions take turns - #61251
Conversation
|
This pull request has merge conflicts that must be resolved before it can be |
4440158 to
a2de129
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
a2de129 to
807d872
Compare
807d872 to
d5aa557
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
/v1/systemone rendered the chat template and tokenized the whole state once per question, in two renderer pool jobs each. A 64-question decision on a 27k-token state spent about 2 s of CPU there and queued every other request behind it on the single-worker pool. For more than one question, NextTokenStrategy now renders a probe with a marker after the last "\n\n" that every question's user turn shares, which ends the state. It tokenizes the text before the marker once and builds each prompt as those ids plus the ids of the rest of its prompt. That takes three render pool jobs per decision instead of two per question: the probe render, the prefix tokenization, and one job that renders every question to check it and tokenizes the short question windows. The two template jobs run under the chat template render timeout; the prefix tokenization is not timed, as in preprocess_chat. A single question keeps the per-question render, which is cheaper there. The prefix goes through the renderer's tokenize path with the request's tokenize params, so an oversized state fails the character check before tokenization, as before. The joined prompts then go through preprocess_cmpl for the token length check and cache_salt. When the prefix fits but the whole prompt does not, the token length error reports the exact count instead of "at least". The read falls back to the per-question render when the request has images, the renderer is not HfRenderer, the user turns share no "\n\n", the marker is not found exactly once, the text before it does not end with the "\n\n" separator, the template changes any question's text, or the last 64 characters of the prefix tokenize differently with a question after them. That window check is a heuristic backstop for tokenizers whose split depends on the next characters, such as GPT-2. OnlineRenderer.build_chat_params is shared with preprocess_chat, and HfRenderer.run_chat_template_job_async runs one template job on the render pool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Veitner <sveitner@redhat.com>
With the state tokenized once, every decision in a burst sends all its reads to the engine within a second or two. The engine admits them all and serves them in arrival order, so a 1-question decision that arrives during a burst of long decisions waits for the whole burst. A decision with more than one question whose prompts hold more tokens than max_num_batched_tokens, counting the shared state once as the prefix cache does, needs more than one engine step of prefill. Such decisions now take turns: at most two have reads in the engine at a time, and the others wait in FIFO order. Each admitted decision still sends all its reads at once, so its state stays in the prefix cache. Shorter decisions and 1-question decisions, which are a single engine request, skip the wait. Under --scheduling-policy priority the gate is off, since the engine orders requests by priority itself. Qwen3-4B on one H200, 64 clients, each with its own state, 2 bursts per cell (128 decisions), a 1-question bystander sent every second. Parent vs this change, run side by side: | workload | bystander p50 / p95 | decision p50 | questions/s | |-----------|------------------------------|----------------|--------------| | 64q x 27k | 34.7 / 65.6 -> 2.0 / 5.4 s | 42.2 -> 33.1 s | 58 -> 65 | | 8q x 27k | 21.3 / 40.8 -> 1.1 / 1.5 s | 20.3 -> 19.5 s | 12 -> 12 | | 16q x 175 | n=2, burst under 1 s | 0.43 -> 0.41 s | 1448 -> 1516 | | 1q x 27k | 20.0 / 37.2 -> 19.3 / 37.0 s | 17.0 -> 17.6 s | 2 -> 2 | Throughput holds for these workloads. 16q x 175 stays under the budget, and so does 64q x 175 (14.7k prompt tokens, 3.3k with the state counted once): about 1,850-1,900 questions/s either way, run side by side. The rows above were measured with the state counted per question, which gates each of them the same way; one burst each of 64q x 27k and 8q x 27k on this revision gave a bystander 2.1 / 6.2 s and 1.1 / 1.6 s. 1q x 27k is not gated either, and a bystander there waits as it would behind 64 long completions. With --scheduling-policy priority and the bystander sent at priority -1 (gate off), 64q x 27k gives a bystander 1.9 / 16.5 s and 60 questions/s. Limits: - The gate is per API server process, so N API servers admit up to 2N long decisions; api_server_count defaults to the data-parallel size. - --max-num-queued-reqs/tokens do not count decisions waiting at the gate, so a waiting decision can still get a 503 when its reads are added. - DiffusionGemma inherits read() and is gated the same way, on its prompt tokens. Clef-Omni reads a whole decision as one pooling request, so it is not gated. Generative scoring (generative_scoring/serving.py) fans out its reads the same way; that is left for a separate change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Veitner <sveitner@redhat.com>
A question whose text after the split point (the last blank line the questions share) begins with whitespace can extend a newline run that starts before the 64-character head window. The head check then passes while the run tokenizes differently in the full prompt, so the spliced ids differ from the per-question render. This showed up with byte-level BPE tokenizers. Render such decisions per question instead. Signed-off-by: Simon Veitner <sveitner@redhat.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Render each question's prompt with render_messages_async, one render pool job at a time as the per-question path does, and split the rendered texts after the last blank line they all share. This drops the marker probe, the checks of the probe against the full renders, the HfRenderer check and the render pool helper in hf.py. A template that edits the question no longer forces a fallback. The splice gives up at the first render that returns token ids instead of text. The tests of the removed guards go too. Signed-off-by: Simon Veitner <sveitner@redhat.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The decision server fixture and the state init test now build their engines with a vllm_config, which every strategy reads for the big decision gate. The server fixture renders through the real Qwen3 OnlineRenderer instead of a stub, so multi-question reads on both /v1/systemone and /v1/decisions take the spliced path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Veitner <sveitner@redhat.com>
D3 reads now wait at the same big-decision gate as next-token reads, and tokenize the start their prompts share once. The gate counts a shared state once only when the prefix cache is on, since D3 runs without it and prefills the state for every question. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Veitner <sveitner@redhat.com>
The strategy constructor no longer reads the engine client's config, so a strategy can be built from a context that has no engine yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Simon Veitner <sveitner@redhat.com>
d5aa557 to
811d101
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
chaunceyjiang
left a comment
There was a problem hiding this comment.
@simon-veitner-redhat Thanks for your work! I think this is a great idea.
However, I think we could put this PR on hold for now and stick with the current simpler implementation to keep development moving quickly.
We can revisit this PR once things are more mature. What do you think?
agree. will convert to draft |
Overview
Fixes #61249. The decision endpoints,
/v1/systemoneand/v1/decisions, now tokenize the shared state once per decision instead of once per question. A 64-question decision on a 27k-token state now runs at about the speed of its engine work. Big decisions also take turns in the engine, so a burst of them does not make small decisions wait for the whole burst. This applies to next-token models and to Decision 3.0 (D3) models.The branch is on main 7020600.
Claims
/v1/decisionsshows the same gains: 0.87 s instead of 2.74 s, and 7.65 s instead of 43.8 s.VLLM_BATCH_INVARIANT=1, all 634 Qwen3-4B answers on both endpoints are bit-identical to main.vllm-sr/d3-mini), a bystander waits 5.53 / 7.17 s instead of 58.7 / 109.7 s during a burst of 32 big decisions, at the same throughput.Validation
Every run compares this branch with main 7020600. Each tree runs on its own H200. The server flags are
--max-model-len 32768and--gpu-memory-utilization 0.90, with the default--renderer-num-workers 1. For Qwen3-4B this gives amax_num_batched_tokensof 8192.Each concurrent client has its own state, and every state is warmed once before the timed rounds. Every decision has 64 questions unless a row says otherwise.
/v1/completionswithmax_tokens=1on the same 64 prompts as token ids, so it measures the engine work alone.No run had errors or preemptions.
Qwen3-4B, 27k-token state
/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/decisions/v1/decisions/v1/decisions/v1/decisions/v1/decisions/v1/decisionsCells at 1 and 16 decisions ran 2 rounds; cells at 64 and 128 ran 1 round. The prefix cache hit rate was 0.98 or higher everywhere.
A decision can beat the floor because the floor sends 64 separate completion requests per decision.
Qwen3-4B, other shapes
These are ungated decisions and 1-question decisions, which keep the per-question render.
/v1/systemone/v1/decisions/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/systemone/v1/decisions/v1/systemone1-question decisions are unchanged within noise. At 64 concurrent 1 × 27k decisions, the 64 distinct states do not fit in the KV cache, on both trees.
DiffusionGemma
google/diffusiongemma-26B-A4B-iton one H200 is served with--diffusion-config '{"canvas_length": 64}' --max-logprobs 32 --enable-prefix-cachingand the flags above. It uses the 27k-token state, one round per cell, on/v1/systemone.Decision 3.0
vllm-sr/d3-miniis served with the flags above. D3 runs without the prefix cache and without chunked prefill, somax_num_batched_tokensis 32768 and every question prefills the whole state.D3's engine work dominates its latency, so tokenizing once saves only the tokenization: 0.3 s at 4k and 2.2 s at 27k. The gate leaves D3 decision latency and throughput unchanged, and it keeps the bystander fast.
Answers
The fixed set has 28 requests per endpoint and 317 answers per endpoint:
Requests are sent one at a time, and the set is dumped twice per server.
VLLM_BATCH_INVARIANT=1. On both endpoints, 634 of 634 answers are bit-identical between main and this branch, and between two runs of each tree.input_tokensis identical on every request. Main against this branch agrees on the argmax in 316 of 317 answers per endpoint. Main against itself agrees on 317 and 315. The probabilities differ only through batching: with one decision's reads arriving together, they share engine steps differently than on main.input_tokensis identical everywhere./v1/systemoneonly; batch-invariant mode does not support its GDN layers). Argmax agreement is 248 / 248, with max |Δp| 6.2e-3, the same as main against itself.input_tokensis identical everywhere.Test plan
pytest tests/entrypoints/generate/structured_decisions/test_strategies.py \ tests/entrypoints/generate/structured_decisions/test_decisions.py \ tests/entrypoints/generate/structured_decisions/test_question_types.py # need a GPU pytest tests/entrypoints/generate/structured_decisions/test_structured_decisions_e2e.py \ tests/entrypoints/openai/decisions/test_decisions_e2e.pyNew tests in
test_strategies.py:test_spliced_prompts_match_full_renderchecks that spliced prompts equal the full render for Qwen3, Qwen3.5, Qwen3.5-MoE, DiffusionGemma and LiquidAI d1. It covers several states, including ones with paragraphs, with and without instructions.test_token_id_renders_skip_the_splicechecks that a render returning token ids instead of text falls back.test_splice_falls_back_when_tokens_join_across_the_splitcovers a tokenizer that merges characters across the split point.test_splice_matches_full_render_when_newlines_cross_the_splitcovers a run of newlines that crosses the split point and is longer than the head window.test_oversized_state_fails_like_full_renderchecks that an oversized state gives the same error as the full render.test_images_skip_the_splicechecks that a request with images renders each question in full.test_big_decisions_take_the_gatechecks which decisions hold the gate during their reads. A decision over the token budget does. A decision under the budget, a 1-question decision and any decision under the priority policy do not. It also checks that the slot is free again afterwards.test_d3_spliced_prompts_match_full_encodechecks that D3 prompts equal the full encode for the Qwen3 and Qwen3.5 tokenizers, and that a long state is tokenized once per decision.test_big_d3_decisions_take_the_gatechecks that without the prefix cache a D3 decision counts the state once per question, and that with it the state counts once.The decision-server tests now run on the real Qwen3 renderer, so multi-question reads on both endpoints take the spliced path.
Details
Root cause.
NextTokenStrategy.readrendered each question's user turn throughpreprocess_chat, once per question. That is two renderer pool jobs per question: the chat template and a full tokenization. The pool has one worker by default.A 64-question decision on a 27k-token state spent about 2 s there, and every other request queued behind it.
D3Strategy.readencoded each question's full prompt with the tokenizer on the event loop.Tokenize once. For more than one question, the next-token read renders every question's prompt with the chat template, as the per-question path does. It then splits the rendered texts after the last
\n\nthey all share. That point falls right after the state in each strategy's own prompt format, before LiquidAI d1'sQUESTION:header.The read then tokenizes the text before the split once. For each question it tokenizes a short window: the last 64 characters before the split plus the question's own text. A decision costs N template renders, one tokenization of the shared prefix and N + 1 short ones, instead of a full tokenization per question.
Each prompt is the prefix ids plus the ids of its window after the head. The joined prompts still go through
preprocess_cmpl, so the token length check andcache_saltapply as before. The prefix goes through the renderer's tokenize path with the request's tokenize params, so an oversized state still fails the character check before tokenization. A single question keeps the per-question render, which is cheaper there.D3 builds its own prompts, so it applies the same split and head check to its prompt texts, after the media prefix when the request has media.
The read falls back to the full per-question render in these cases:
\n\n;The last check is a heuristic for tokenizers whose split depends on the next characters.
OnlineRenderer.build_chat_paramsis now shared withpreprocess_chat.Why the gate. With tokenization fast, every decision in a burst sends all its reads to the engine within a second or two. The engine serves them in arrival order. So a 1-question decision that arrives during a burst of long decisions waits for the whole burst.
The gate. It lives in the frontend.
max_num_batched_tokens.--scheduling-policy prioritythe gate is off, because the engine already orders requests by priority.One gate for both endpoints.
/v1/systemoneand/v1/decisionsshare one read strategy, so they share one gate. A big decision on either endpoint takes a slot from the other.Coverage. Qwen3, Qwen3.5, Qwen3.5-MoE, DiffusionGemma, LiquidAI d1 and D3 splice and are gated. DiffusionGemma and d1 inherit
read()from the next-token strategy. Clef-Omni and Clef-Flash encode their own prompts and read a whole decision as one pooling request, so they never splice and are not gated.Limits.
api_server_countdefaults to the data-parallel size.--max-num-queued-reqsand--max-num-queued-tokensdo not count decisions waiting at the gate, so a waiting decision can still get a 503 when its reads are added.--scheduling-policy prioritythe gate is off. A bystander then needs a lower priority value than the burst to get through quickly.generative_scoring/serving.py) fans out its reads the same way. I left that for a separate change.Size. +193 / −48 non-test lines, in
strategies.pyandonline_renderer.py.Pull Request Checklist
I used vLLM's
/pr-checklistskill. (Mandatory for agents, optional for humans).AI assistance was used during the creation of this PR.
Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.
Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.
Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.
Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.
AI assistance (Claude Code) was used to investigate, write and benchmark this change. I reviewed the code and the results.
BEFORE SUBMITTING, PLEASE READ https://docs.vllm.ai/en/latest/contributing (anything written below this line will be removed by GitHub Actions)