Repository navigation
[Rust Frontend] Support n > 1 choices - #95
richard-for-bugen[bot] wants to merge 3 commits into
Conversation
| // A failed submission drops the earlier streams, which aborts them. | ||
| streams.push( | ||
| self.submit( | ||
| child, | ||
| &prompt_token_ids, | ||
| remote_prefill_cached_tokens, | ||
| Some(parallel_sampling.clone()), | ||
| ) | ||
| .await?, | ||
| ); |
There was a problem hiding this comment.
Is it valuable to do this concurrently?
There was a problem hiding this comment.
I don't think so. submit → EngineCoreClient::call only registers the output stream and pushes the request onto the engine socket; there is no engine round trip to overlap, so submitting the children concurrently would save nothing measurable. Doing it sequentially also keeps the children's arrival order deterministic, and Python's AsyncLLM adds them the same way, in a plain for idx in range(n) loop (v1/engine/async_llm.py:558).
| /// Upper bound on `n`, configurable like Python's `VLLM_MAX_N_SEQUENCES`. | ||
| fn max_n_sequences() -> u32 { | ||
| std::env::var("VLLM_MAX_N_SEQUENCES") | ||
| .ok() | ||
| .and_then(|value| value.parse().ok()) | ||
| .unwrap_or(16384) | ||
| } |
There was a problem hiding this comment.
Shall we cache it or make it a hidden clap arg (like some other configs)
There was a problem hiding this comment.
Done in 4841673: VLLM_MAX_N_SEQUENCES is now a hidden --max-n-sequences arg bound to the env var (and read in the --args-json env fallbacks), parsed once at startup like Python's int() (an invalid value fails startup), carried in ApiServerOptions / RenderConfig, and checked by the chat, completions and render handlers.
Chat and completions rejected `n > 1` with "Only n=1 is supported.".
Support it like Python's parallel sampling: render and tokenize the
prompt once, then submit one engine request per sample from `vllm-llm`
(child IDs `{index}_{request_id}`, `seed + index` when seeded, the
caller's external ID kept so an abort reaches every sample), observe
the request-level `n` and max-generation-tokens histograms once per
request, parse each sample with its own output processor, and merge
the per-choice responses in the server: choices carry their index,
usage counts the prompt once and sums completion and reasoning tokens,
and per-request metrics are reported only for `n = 1`.
`n` is checked like Python's `SamplingParams`: at least 1, at most
`VLLM_MAX_N_SEQUENCES` (16384), and 1 under greedy sampling. The render
routes forward `n` into the generate request, whose route still accepts
only `n = 1`.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: richard-for-bugen[bot] <342439978+richard-for-bugen[bot]@users.noreply.github.com>
Bind `VLLM_MAX_N_SEQUENCES` to a hidden `--max-n-sequences` arg (and the `--args-json` env fallbacks), parsed like Python's `int()`, instead of reading the env var on every request. The limit travels in `ApiServerOptions` and `RenderConfig`, and the chat, completions and render handlers check `n` against it. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: richard-for-bugen[bot] <342439978+richard-for-bugen[bot]@users.noreply.github.com>
5573733 to
4841673
Compare
| /// Resolve the number of choices to generate, checked like Python's | ||
| /// `SamplingParams` checks `n`. | ||
| pub(crate) fn resolve_n(n: Option<u32>) -> Result<NonZeroU32, ApiError> { | ||
| let n = n.unwrap_or(1); | ||
| let Some(resolved) = NonZeroU32::new(n) else { | ||
| bail_invalid_request!(param = "n", "n must be at least 1, got {n}."); | ||
| }; | ||
| Ok(resolved) | ||
| } | ||
|
|
||
| /// Reject more choices than the server's `VLLM_MAX_N_SEQUENCES` allows, like | ||
| /// Python's `SamplingParams` checks `n`. | ||
| pub(crate) fn validate_n_limit(n: u32, max_n_sequences: i64) -> Result<(), ApiError> { | ||
| if i64::from(n) > max_n_sequences { | ||
| bail_invalid_request!( | ||
| param = "n", | ||
| "n must be at most {max_n_sequences}, got {n}. To increase this limit, set the \ | ||
| VLLM_MAX_N_SEQUENCES environment variable." | ||
| ); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
There was a problem hiding this comment.
Can we merge them or do you think it's okay to leave them apart
There was a problem hiding this comment.
I'd leave them apart. resolve_n runs inside the request lowering (prepare_chat_request / prepare_completion_request, also used by the render routes), which is pure: it sees only the request, never the server config, and it has to produce the NonZeroU32 that the rest of the lowering uses. The upper bound needs max_n_sequences from ApiServerOptions / RenderState, so it sits in the handler right after lowering, next to validate_max_output_tokens, the other config-dependent check placed the same way. Merging would mean threading the server limit into every lowering call (and the tests that call them) just for this check.
Resolve the `vllm-text` conflict with vllm-project#64697 (`--skip-tokenizer-init`): its `TextLlm::submit` rejects structured outputs without a tokenizer before engine submission. `submit` now takes `n` and calls `Llm::generate_n`, and `generate_prepared_n` submits through it, so the check also covers `n > 1`; `generate_raw` submits one sample. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: richard-for-bugen[bot] <342439978+richard-for-bugen[bot]@users.noreply.github.com>
|
Promoted as vllm-project#65625. |
Motivation
Python serves
n > 1on/v1/chat/completionsand/v1/completionsthrough parallel sampling:AsyncLLMwraps the request in aParentRequest(vllm/v1/engine/parallel_sampling.py), renders and tokenizes the prompt once, and submits one engine request per sample with the child ID{index}_{request_id}and, when seeded,seed + index. The serving layer gives each choice its own parser, streams chunks tagged with the choiceindex, and reports usage with the prompt counted once and completion (and reasoning) tokens summed. Per-request metrics are suppressed forn > 1, since they describe one sample.The Rust frontend rejected any
n > 1with400 Only n=1 is supported.(and the chat DTO cappednat 10, where Python allows up toVLLM_MAX_N_SEQUENCES, 16384 by default).n > 1is a common OpenAI parameter (best-of style sampling, RL rollouts), and it blocks thencases oftest_chat.pyandtest_completion.py::test_parallel_*under the Rust frontend.Supersedes inactive vllm-project#58914, which fans out by calling
chat()/generate()ntimes in the server and so renders the prompt (multimodal processing included)ntimes.What this PR does
The prompt is rendered and tokenized once; the fan-out happens where Python does it, and each layer above returns one stream per sample instead of pushing a choice index through every stream type:
vllm-llm:Llm::generate_n(request, n)submits thenchild engine requests likeParentRequest: internal IDs{i}_{request_id},seed + iwhen seeded (wrapping;-1, Python's unseeded sentinel, stays-1on every child, which the engine reads as unseeded, like Python normalizing it toNonebefore the fan-out), the caller's external ID kept on every child, so an abort by that ID, or dropping the streams on client disconnect, reaches every sample. A sharedParallelSamplingStatsobservesvllm:request_params_nandvllm:request_max_num_generation_tokensonce per request, when the last child finishes (max over the children), while the other request histograms stay per child, as in Python.n = 1submits the request unchanged;generateisgenerate_n(.., 1).vllm-text:TextLlm::generate_nlowers once and decodes each child stream. It owns Python'sSamplingParamsrule that greedy sampling needsn = 1, checked against the resolved temperature (after generation-config defaults):400 n must be 1 when using greedy sampling, got N.Python first raises a positive temperature below 0.01 to 0.01, so onlytemperature = 0is greedy.vllm-chat:ChatLlm::chat_nrenders once and builds one output processor per sample (parsers are stateful); the output grammar is built once and applied to the shared request.vllm-server: the routes resolvenlike Python's_verify_num_sequences(n must be at least 1, got 0.;n must be at most {VLLM_MAX_N_SEQUENCES}, ...; the limit is read once at startup through a hidden--max-n-sequencesarg bound toVLLM_MAX_N_SEQUENCESand the--args-jsonenv fallbacks, parsed like Python'sint()so an invalid value fails startup where Python fails each request, and carried inApiServerOptionsandRenderConfig), run the existing per-choice chunk stream or collector for each sample, and merge: streamed chunks interleave as the samples produce them, stamped with their choice index; the per-choice final usage chunks fold into one (Usage::add_choice); non-streaming responses append the choices in index order with prompt-level fields from the first choice. Per-request metrics are kept only forn = 1.n = 1takes the same path and is unchanged on the wire.ninto the rendered generate request, whose route still accepts onlyn = 1, son > 1keeps failing there explicitly instead of being dropped.Ownership and layering: the fan-out (IDs, seeds, request-level metrics) sits in
vllm-llm, the engine-client facade that mirrorsAsyncLLM; the greedy rule sits invllm-text, which ownsSamplingParamssemantics; per-sample parsing invllm-chat; wire DTO checks and response merging invllm-server.nis passed as an argument rather than added to the shared request types, since only the fan-out and the greedy rule read it.Behavior notes
index, so this is not a wire contract./inference/v1/generateand gRPC still accept onlyn = 1(unchanged).test_completion.py::test_parallel_streamingasserts at mostmax_tokenschunks per choice; Rust sends the finish reason in a separate final chunk (the recordedtest_completion.pyrelax-test divergence), so a length-limited choice gets one extra chunk.test_parallel_no_streamingand the chatntests intest_chat.pyshould pass; not run on GPU in this PR.Validation
rust/src/server/src/routes/tests.rs):chat_completions_n_returns_one_choice_per_seeded_sample(child IDs and seeds7/8under one external ID, choiceshi/stopandyo/lengthat indices 0/1, usage 22 + 5, request-level histograms observed once withn = 2and max 3, per-sample generation histogram twice),chat_completions_stream_n_indexes_chunks_and_sends_one_usage_chunk,completions_n_returns_one_choice_per_sample(withseed: -1, both children unseeded),completions_stream_n_indexes_chunks_and_sends_one_usage_chunk; the two stream tests and the completions test sendtemperature: 0.000001, which is not greedy,n_is_checked_like_python_sampling_params(Python's messages forn = 0,n > 16384, greedy),render_forwards_n_to_the_generate_request_check.n > 1rejected); the render test passes on the base too (its oldOnly n=1check) and pins that removing that check keeps the explicit error. With targeted reverts: droppingseed + indexfails the seeded test, offsetting-1fails the completions test, yielding every per-choice usage chunk fails both stream tests, dropping the greedy check fails the validation test, a< 1e-5greedy threshold fails the two tiny-temperature tests, observing the request-level histograms per child fails the metrics assertions (2.0vs1.0), and droppingnin render fails the render test.run-rust-frontend-cargo-ci.shstyle-clippyandtest): fmt,cargo sort,cargo deny check bans, workspace clippy-D warnings,cargo nextest run --workspace --all-features --locked: all green, 2435 passed, 13 skipped.39dbf51839(thevllm-text/vllm-chatconflict with [Rust Frontend] Add the stateless Responses API (streaming and non-streaming) vllm-project/vllm#63902'sgenerate_preparedresolved asgenerate_prepared_n), plus the review commit that movesVLLM_MAX_N_SEQUENCESinto a hidden startup arg (n_limit_follows_max_n_sequences_optionfor chat, completions and both render routes; CLI default, env,--args-jsonand invalid-value cases). Same CI set on the head: all green, 2517 passed, 13 skipped.Codex review
gpt-6.1-sol, high effort: changes needed, then fixed.temperature = 0is greedy, matching Python's 0.01 floor; regression on both routes).seed: -1offset into seeded children: fixed (the sentinel stays unseeded on every child; regression on completions).🤖 Generated with Claude Code
https://claude.ai/code/session_01CJckC9t9pN9Pi3BkC8tpAv