[Feature] Add fixed-token prefill scoring (#54335)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

aoshen02 committed Sep 28, 2026 at 22:50 UTC b721a4c709da44551b614e2562fcfa7f4cbd1352
36 files changed +768 -8
docs/training/prompt_token_id_logprobs.md new
+64
@@ -0,0 +1,64 @@
1 +# Prompt Token ID Logprobs (Teacher Scoring)
2 +
3 +In on-policy distillation, the student records its top-K candidate tokens at
4 +every response position, and the teacher provides the log probabilities of
5 +exactly those candidates as the distillation target. The caller passes the
6 +candidates as `prompt_logprob_token_ids`; the teacher runs a single prefill
7 +over the prompt plus response and gathers their log probabilities on the GPU at
8 +every scored row, starting from `prompt_logprob_start` (typically the prompt
9 +length minus one, so only response positions are scored). The result is a
10 +`[rows, K]` matrix whose row `i` scores prompt token
11 +`prompt_logprob_start + i + 1` and whose columns follow the candidate order, so
12 +it lines up position by position with the student's top-K.
13 +
14 +## Quick start
15 +
16 +```python
17 +from vllm import LLM, SamplingParams
18 +from vllm.inputs import TokensPrompt
19 +
20 +llm = LLM(model)
21 +output = llm.generate(
22 + TokensPrompt(prompt_token_ids=prompt_ids + response_ids),
23 + SamplingParams(
24 + max_tokens=1,
25 + prompt_logprob_token_ids=candidate_ids,
26 + prompt_logprob_start=len(prompt_ids) - 1,
27 + ),
28 +)
29 +scores = output[0].prompt_token_id_logprobs
30 +# scores.shape == (len(response_ids), len(candidate_ids))
31 +# scores[i, j] = log p(candidate_ids[j] | prompt_ids + response_ids[:i])
32 +```
33 +
34 +The same parameters are accepted by the `/inference/v1/generate` HTTP endpoint
35 +(Python and Rust frontends), which returns the matrix as a base64-encoded
36 +`.npy` float32 array:
37 +
38 +```python
39 +import base64, io
40 +import numpy as np
41 +
42 +scores = np.load(io.BytesIO(base64.b64decode(response["prompt_token_id_logprobs"])))
43 +```
44 +
45 +The matrix has `max(prompt_len - 1 - prompt_logprob_start, 0)` rows. With a
46 +logits `--logprobs-mode`, it holds logits instead of log probabilities.
47 +
48 +## Requirements
49 +
50 +- The V2 model runner (`VLLM_USE_V2_MODEL_RUNNER=1`).
51 +- `len(prompt_logprob_token_ids)` at most `--max-logprobs`.
52 +- No `--kv-sharing-fast-prefill`.
53 +
54 +The request skips reading the prefix cache (local and KV connector), since
55 +cached rows have no logits to score.
56 +
57 +## Limitations
58 +
59 +- One candidate set per request, shared by every scored row; per-position
60 + candidates are planned ([#56860](https://github.com/vllm-project/vllm/issues/56860)).
61 +- Non-streaming only; `stream=true` requests are rejected.
62 +- Not exposed through the OpenAI-compatible endpoints.
63 +- If a prefill starts past the first scored row (e.g. `skip_reading_prefix_cache=False`
64 + with a cache hit), the result is `None` rather than a partial matrix.
rust/Cargo.lock
+1
@@ -5968,6 +5968,7 @@ dependencies = [
5968 "asynk-strim-attr",
5969 "auto_enums",
5970 "axum",
5971 + "base64 0.22.1",
5972 "bytes",
5973 "clap",
5974 "educe",
rust/src/engine-core-client/src/protocol/output.rs
+11
@@ -17,6 +17,7 @@ use crate::error::{Error, Result, ext_value_decode};
17 use crate::protocol::logprobs::MaybeWireLogprobs;
18 use crate::protocol::sampling_mask::MaybeWireSamplingMask;
19 use crate::protocol::stats::{PrefillStats, SchedulerStats};
20 +use crate::protocol::tensor::WireNdArray;
21 use crate::protocol::{OpaqueValue, decode_msgpack};
22
23 /// The stop reason associated with a finished output.
@@ -133,6 +134,10 @@ pub struct EngineCoreOutput {
134 /// output when `--per-request-spec-decode-metrics` is enabled.
135 #[serde(default)]
136 pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
137 + /// Log probabilities of `SamplingParams.prompt_logprob_token_ids`, set on
138 + /// the first output of a request that asked for them.
139 + #[serde(default)]
140 + pub prompt_token_id_logprobs: Option<WireNdArray>,
141 }
142
143 /// Raw per-sequence speculative-decoding accumulator.
@@ -180,6 +185,11 @@ impl EngineCoreOutput {
185 self.new_sampling_mask = (self.new_sampling_mask.take())
186 .map(|value| value.resolve(frames, "new_sampling_mask"))
187 .transpose()?;
188 + if let Some(value) = self.prompt_token_id_logprobs.as_mut() {
189 + value
190 + .resolve_aux_frame(frames)
191 + .map_err(|message| ext_value_decode!("prompt_token_id_logprobs: {message}"))?;
192 + }
193 Ok(())
194 }
195 }
@@ -531,6 +541,7 @@ mod tests {
541 mm_cache_miss_hashes: None,
542 new_sampling_mask: None,
543 spec_decode_metrics: None,
544 + prompt_token_id_logprobs: None,
545 },
546 ],
547 scheduler_stats: None,
rust/src/engine-core-client/src/protocol/sampling.rs
+7
@@ -101,6 +101,11 @@ pub struct EngineCoreSamplingParams {
101 ///
102 /// `None` disables prompt logprobs. `-1` requests the full vocabulary.
103 pub prompt_logprobs: Option<i32>,
104 + /// Candidate token IDs scored at every scored causal prompt row, where row
105 + /// `i` scores them as predictions of prompt token `i + 1`.
106 + pub prompt_logprob_token_ids: Option<Vec<u32>>,
107 + /// First causal prompt row to score; `None` scores from the first row.
108 + pub prompt_logprob_start: Option<u32>,
109 /// Minimum probability threshold for token sampling.
110 pub min_p: f32,
111 /// Frequency penalty applied by the sampler.
@@ -171,6 +176,8 @@ impl EngineCoreSamplingParams {
176 thinking_token_budget: None,
177 logprobs: None,
178 prompt_logprobs: None,
179 + prompt_logprob_token_ids: None,
180 + prompt_logprob_start: None,
181 min_p: 0.0,
182 frequency_penalty: 0.0,
183 presence_penalty: 0.0,
rust/src/engine-core-client/src/tests/client.rs
+3
@@ -2684,6 +2684,8 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2684 thinking_token_budget: None,
2685 logprobs: None,
2686 prompt_logprobs: None,
2687 + prompt_logprob_token_ids: None,
2688 + prompt_logprob_start: None,
2689 min_p: 0.0,
2690 frequency_penalty: 0.0,
2691 presence_penalty: 0.0,
@@ -2770,6 +2772,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
2772 mm_cache_miss_hashes: None,
2773 new_sampling_mask: None,
2774 spec_decode_metrics: None,
2775 + prompt_token_id_logprobs: None,
2776 },
2777 ],
2778 scheduler_stats: None,
rust/src/engine-core-client/src/tests/python_compat.py
+1
@@ -116,6 +116,7 @@ class EngineCoreOutput(
116 mm_cache_miss_hashes: list[str] | None = None
117 new_sampling_mask: object | None = None
118 spec_decode_metrics: object | None = None
119 + prompt_token_id_logprobs: object | None = None
120
121
122 class ExtendedEngineCoreOutput(EngineCoreOutput):
rust/src/llm/src/output.rs
+16
@@ -15,6 +15,7 @@ use vllm_engine_core_client::protocol::output::{
15 EngineCoreFinishReason, RequestSpecDecodeMetrics, StopReason,
16 };
17 use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
18 +use vllm_engine_core_client::protocol::tensor::WireNdArray;
19 use vllm_engine_core_client::{AbortCause, EngineCoreOutputStream};
20
21 use crate::error::Result;
@@ -38,6 +39,7 @@ pub struct CollectedGenerateOutput {
39 pub request_id: String,
40 pub prompt_token_ids: Vec<u32>,
41 pub prompt_logprobs: Option<Logprobs>,
42 + pub prompt_token_id_logprobs: Option<WireNdArray>,
43 pub token_ids: Vec<u32>,
44 pub logprobs: Option<Logprobs>,
45 pub finish_reason: FinishReason,
@@ -62,6 +64,8 @@ pub struct GeneratePromptInfo {
64 /// Prompt logprobs returned by engine-core for scored prompt positions,
65 /// when requested.
66 pub prompt_logprobs: Option<Logprobs>,
67 + /// Log probabilities of `prompt_logprob_token_ids`, when requested.
68 + pub prompt_token_id_logprobs: Option<WireNdArray>,
69 }
70
71 /// The reason a request finished.
@@ -206,6 +210,7 @@ impl GenerateOutput {
210 prompt_info: prompt_token_ids.map(|ids| GeneratePromptInfo {
211 prompt_token_ids: ids,
212 prompt_logprobs: None,
213 + prompt_token_id_logprobs: None,
214 }),
215 token_ids,
216 logprobs: None,
@@ -247,6 +252,7 @@ impl GenerateOutputStream {
252 pending_prompt_info: Some(GeneratePromptInfo {
253 prompt_token_ids,
254 prompt_logprobs: None,
255 + prompt_token_id_logprobs: None,
256 }),
257 raw_stream,
258 request_metrics,
@@ -282,6 +288,11 @@ impl Stream for GenerateOutputStream {
288 info.prompt_logprobs =
289 raw.new_prompt_logprobs_tensors.map(|value| value.into_direct().unwrap());
290 }
291 + if let Some(info) = &mut self.pending_prompt_info
292 + && info.prompt_token_id_logprobs.is_none()
293 + {
294 + info.prompt_token_id_logprobs = raw.prompt_token_id_logprobs;
295 + }
296
297 let logprobs = raw.new_logprobs.map(|value| value.into_direct().unwrap());
298 let sampling_mask = raw.new_sampling_mask.map(|value| value.into_direct().unwrap());
@@ -361,6 +372,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
372 pin_mut!(stream);
373 let mut prompt_token_ids = None;
374 let mut prompt_logprobs = None;
375 + let mut prompt_token_id_logprobs = None;
376 let mut cached_token_count = 0;
377 let mut collected: Option<CollectedGenerateOutput> = None;
378
@@ -374,6 +386,9 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
386 if prompt_logprobs.is_none() {
387 prompt_logprobs = info.prompt_logprobs;
388 }
389 + if prompt_token_id_logprobs.is_none() {
390 + prompt_token_id_logprobs = info.prompt_token_id_logprobs;
391 + }
392 }
393
394 if let Some(existing) = collected.as_mut() {
@@ -393,6 +408,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
408 request_id: output.request_id,
409 prompt_token_ids: prompt_token_ids.take().unwrap_or_default(),
410 prompt_logprobs: prompt_logprobs.take(),
411 + prompt_token_id_logprobs: prompt_token_id_logprobs.take(),
412 token_ids: output.token_ids,
413 logprobs: output.logprobs,
414 finish_reason: FinishReason::Error,
rust/src/llm/tests/generate.rs
+2
@@ -277,6 +277,7 @@ async fn generate_streams_outputs() {
277 Some(GeneratePromptInfo {
278 prompt_token_ids: vec![11, 22].into(),
279 prompt_logprobs: Some(prompt_logprobs()),
280 + prompt_token_id_logprobs: None,
281 })
282 );
283 assert_eq!(first.token_ids, vec![1, 2]);
@@ -405,6 +406,7 @@ async fn collect_output_rejects_partial_sampling_mask() {
406 prompt_info: Some(GeneratePromptInfo {
407 prompt_token_ids: Arc::from([11_u32, 22]),
408 prompt_logprobs: None,
409 + prompt_token_id_logprobs: None,
410 }),
411 token_ids: vec![33, 44],
412 logprobs: None,
rust/src/server/Cargo.toml
+1
@@ -9,6 +9,7 @@ anyhow.workspace = true
9 asynk-strim-attr.workspace = true
10 auto_enums.workspace = true
11 axum.workspace = true
12 +base64.workspace = true
13 educe.workspace = true
14 futures.workspace = true
15 http-body.workspace = true
rust/src/server/src/routes/inference/generate.rs
+48
@@ -16,12 +16,15 @@ use axum::extract::State;
16 use axum::http::HeaderMap;
17 use axum::response::sse::Event;
18 use axum::response::{IntoResponse, Response};
19 +use base64::Engine as _;
20 use futures::{Stream, StreamExt as _, pin_mut};
21 use thiserror_ext::AsReport as _;
22 use tracing::{error, info, trace};
23 use tracing_futures::Instrument as _;
24 +use vllm_engine_core_client::protocol::dtype::TensorDtype;
25 use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs};
26 use vllm_engine_core_client::protocol::multimodal::MmFeatureSpec;
27 +use vllm_engine_core_client::protocol::tensor::WireNdArray;
28 use vllm_llm::{
29 CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, TokenUsage,
30 };
@@ -330,6 +333,11 @@ fn collect_generate(
333 token_ids: collected.token_ids,
334 }],
335 prompt_logprobs,
336 + prompt_token_id_logprobs: collected
337 + .prompt_token_id_logprobs
338 + .as_ref()
339 + .map(npy_base64)
340 + .transpose()?,
341 prompt_token_ids: return_token_ids.then_some(collected.prompt_token_ids),
342 mm_placeholders: return_token_ids.then_some(mm_placeholders).flatten(),
343 kv_transfer_params: collected.kv_transfer_params,
@@ -432,6 +440,29 @@ fn position_to_logprob_map(position: &PositionLogprobs) -> HashMap<u32, Generate
440 .collect()
441 }
442
443 +/// Encode a float32 matrix as Python `numpy2base64` does.
444 +fn npy_base64(array: &WireNdArray) -> Result<String, ApiError> {
445 + let (TensorDtype::F32, Some(data), [rows, cols]) = (
446 + array.dtype.scalar,
447 + array.data.as_raw_view(),
448 + array.shape.as_slice(),
449 + ) else {
450 + bail_server_error!("prompt_token_id_logprobs: expected a float32 matrix");
451 + };
452 + let mut header =
453 + format!("{{'descr': '<f4', 'fortran_order': False, 'shape': ({rows}, {cols}), }}");
454 + header.push_str(&" ".repeat(21 - rows.to_string().len()));
455 + header.push_str(&" ".repeat(64 - (11 + header.len()) % 64));
456 + header.push('\n');
457 +
458 + let mut npy = Vec::with_capacity(10 + header.len() + data.len());
459 + npy.extend_from_slice(b"\x93NUMPY\x01\x00");
460 + npy.extend_from_slice(&(header.len() as u16).to_le_bytes());
461 + npy.extend_from_slice(header.as_bytes());
462 + npy.extend_from_slice(data);
463 + Ok(base64::engine::general_purpose::STANDARD.encode(npy))
464 +}
465 +
466 fn format_token_id(token_id: u32) -> String {
467 format!("token_id:{token_id}")
468 }
@@ -509,6 +540,7 @@ mod tests {
540 prompt_info: Some(GeneratePromptInfo {
541 prompt_token_ids: Arc::from([11_u32, 22_u32]),
542 prompt_logprobs: None,
543 + prompt_token_id_logprobs: None,
544 }),
545 token_ids: vec![33],
546 logprobs: None,
@@ -592,6 +624,7 @@ mod tests {
624 prompt_info: prompt_token_ids.map(|ids| GeneratePromptInfo {
625 prompt_token_ids: Arc::from(ids),
626 prompt_logprobs: None,
627 + prompt_token_id_logprobs: None,
628 }),
629 token_ids,
630 logprobs: None,
@@ -747,6 +780,7 @@ mod tests {
780 let output = CollectedGenerateOutput {
781 request_id: "raw-1".to_string(),
782 prompt_logprobs: None,
783 + prompt_token_id_logprobs: None,
784 token_ids: vec![30],
785 logprobs: None,
786 finish_reason: FinishReason::stop_eos(),
@@ -781,6 +815,17 @@ mod tests {
815 assert!(response.mm_placeholders.is_none());
816 }
817
818 + #[test]
819 + fn prompt_token_id_logprobs_match_numpy2base64() {
820 + let scores =
821 + WireNdArray::from_f32(vec![2, 3], vec![-0.5, -1.5, -2.5, -3.5, -4.5, -5.5]).unwrap();
822 + // numpy2base64(np.array([[-0.5, -1.5, -2.5], [-3.5, -4.5, -5.5]], np.float32))
823 + assert_eq!(
824 + npy_base64(&scores).unwrap(),
825 + "k05VTVBZAQB2AHsnZGVzY3InOiAnPGY0JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDIsIDMpLCB9ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAoAAAC/AADAvwAAIMAAAGDAAACQwAAAsMA="
826 + );
827 + }
828 +
829 #[test]
830 fn collect_generate_returns_spec_decode_metrics() {
831 let metrics = RequestSpecDecodeMetrics {
@@ -792,6 +837,7 @@ mod tests {
837 let output = CollectedGenerateOutput {
838 request_id: "raw-metrics".to_string(),
839 prompt_logprobs: None,
840 + prompt_token_id_logprobs: None,
841 token_ids: vec![30],
842 logprobs: None,
843 finish_reason: FinishReason::stop_eos(),
@@ -879,6 +925,7 @@ mod tests {
925 let output = CollectedGenerateOutput {
926 request_id: "raw-1".to_string(),
927 prompt_logprobs: None,
928 + prompt_token_id_logprobs: None,
929 token_ids: vec![30],
930 logprobs: None,
931 finish_reason: FinishReason::stop_eos(),
@@ -914,6 +961,7 @@ mod tests {
961 let output_without_payload = |prompt_token_ids: Vec<u32>| CollectedGenerateOutput {
962 request_id: "raw-1".to_string(),
963 prompt_logprobs: None,
964 + prompt_token_id_logprobs: None,
965 token_ids: vec![3],
966 logprobs: None,
967 finish_reason: FinishReason::stop_eos(),
rust/src/server/src/routes/inference/generate/types.rs
+2
@@ -94,6 +94,8 @@ pub(super) struct GenerateResponse {
94 pub request_id: String,
95 pub choices: Vec<GenerateResponseChoice>,
96 pub prompt_logprobs: Option<Vec<Option<HashMap<u32, GenerateLogprob>>>>,
97 + /// Base64 `.npy` float32 array, as Python `numpy2base64` encodes it.
98 + pub prompt_token_id_logprobs: Option<String>,
99 pub prompt_token_ids: Option<Vec<u32>>,
100 pub mm_placeholders: Option<MultiModalPlaceholders>,
101 pub kv_transfer_params: Option<Value>,
rust/src/server/src/routes/inference/generate/validate.rs
+18
@@ -57,6 +57,13 @@ pub(crate) fn validate_request_compat(
57 }
58 }
59
60 + if request.stream && request.sampling_params.inner.prompt_logprob_token_ids.is_some() {
61 + bail_invalid_request!(
62 + param = "sampling_params",
63 + "`prompt_logprob_token_ids` are not available when `stream=true`."
64 + );
65 + }
66 +
67 Ok(())
68 }
69
@@ -169,6 +176,17 @@ mod tests {
176 assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err());
177 }
178
179 + #[test]
180 + fn validate_request_compat_rejects_streaming_prompt_logprob_token_ids() {
181 + let request: GenerateRequest = serde_json::from_value(json!({
182 + "token_ids": [11, 22],
183 + "stream": true,
184 + "sampling_params": {"prompt_logprob_token_ids": [1, 2]}
185 + }))
186 + .expect("parse request");
187 + assert!(validate_request_compat(&request, &served(&["test-model"])).is_err());
188 + }
189 +
190 #[test]
191 fn validate_request_compat_accepts_non_stream_prompt_logprobs() {
192 let request: GenerateRequest = serde_json::from_value(json!({
rust/src/server/src/routes/openai/chat_completions/convert.rs
+2
@@ -159,6 +159,8 @@ pub(super) fn prepare_chat_request(
159 thinking_token_budget: request.thinking_token_budget,
160 logprobs: request.logprobs.then_some(top_logprobs),
161 prompt_logprobs,
162 + prompt_logprob_token_ids: None,
163 + prompt_logprob_start: None,
164 min_p: request.min_p,
165 frequency_penalty: request.frequency_penalty,
166 presence_penalty: request.presence_penalty,
rust/src/server/src/routes/openai/completions/convert.rs
+2
@@ -124,6 +124,8 @@ pub(super) fn prepare_completion_request(
124 thinking_token_budget: request.thinking_token_budget,
125 logprobs,
126 prompt_logprobs,
127 + prompt_logprob_token_ids: None,
128 + prompt_logprob_start: None,
129 min_p: request.min_p,
130 frequency_penalty: request.frequency_penalty,
131 presence_penalty: request.presence_penalty,
rust/src/text/src/lower.rs
+56
@@ -103,6 +103,8 @@ pub fn lower_sampling_params(
103 thinking_token_budget,
104 logprobs,
105 prompt_logprobs,
106 + prompt_logprob_token_ids,
107 + prompt_logprob_start,
108 min_p,
109 frequency_penalty,
110 presence_penalty,
@@ -123,6 +125,8 @@ pub fn lower_sampling_params(
125 logprobs,
126 prompt_logprobs,
127 logprob_token_ids.as_deref(),
128 + prompt_logprob_token_ids.as_deref(),
129 + prompt_logprob_start,
130 sampling_limits,
131 )?;
132 validate_repetition_detection(repetition_detection.as_ref())?;
@@ -176,6 +180,8 @@ pub fn lower_sampling_params(
180 thinking_token_budget,
181 logprobs,
182 prompt_logprobs,
183 + prompt_logprob_token_ids,
184 + prompt_logprob_start,
185 min_p,
186 frequency_penalty,
187 presence_penalty,
@@ -646,6 +652,8 @@ mod tests {
652 thinking_token_budget: None,
653 logprobs: None,
654 prompt_logprobs: None,
655 + prompt_logprob_token_ids: None,
656 + prompt_logprob_start: None,
657 min_p: 0.0,
658 frequency_penalty: 0.0,
659 presence_penalty: 0.0,
@@ -701,6 +709,8 @@ mod tests {
709 thinking_token_budget: None,
710 logprobs: None,
711 prompt_logprobs: None,
712 + prompt_logprob_token_ids: None,
713 + prompt_logprob_start: None,
714 min_p: 0.0,
715 frequency_penalty: 0.0,
716 presence_penalty: 0.0,
@@ -870,6 +880,8 @@ mod tests {
880 thinking_token_budget: None,
881 logprobs: None,
882 prompt_logprobs: None,
883 + prompt_logprob_token_ids: None,
884 + prompt_logprob_start: None,
885 min_p: 0.0,
886 frequency_penalty: 0.0,
887 presence_penalty: 0.0,
@@ -935,6 +947,8 @@ mod tests {
947 thinking_token_budget: None,
948 logprobs: None,
949 prompt_logprobs: None,
950 + prompt_logprob_token_ids: None,
951 + prompt_logprob_start: None,
952 min_p: 0.0,
953 frequency_penalty: 0.0,
954 presence_penalty: 0.0,
@@ -1008,6 +1022,8 @@ mod tests {
1022 thinking_token_budget: None,
1023 logprobs: None,
1024 prompt_logprobs: None,
1025 + prompt_logprob_token_ids: None,
1026 + prompt_logprob_start: None,
1027 min_p: 0.1,
1028 frequency_penalty: 0.0,
1029 presence_penalty: 0.0,
@@ -1122,6 +1138,44 @@ mod tests {
1138 ));
1139 }
1140
1141 + #[test]
1142 + fn lower_sampling_params_validates_prompt_logprob_token_ids() {
1143 + let lower = |ids: Option<Vec<u32>>, start: Option<u32>| {
1144 + lower_sampling_params_with_limits(
1145 + SamplingParams {
1146 + prompt_logprob_token_ids: ids,
1147 + prompt_logprob_start: start,
1148 + ..Default::default()
1149 + },
1150 + sample_sampling_limits(),
1151 + )
1152 + };
1153 + let rejects = |ids, start| match lower(ids, start) {
1154 + Err(error) => error.to_string(),
1155 + Ok(_) => panic!("expected rejection"),
1156 + };
1157 +
1158 + let params = lower(Some(vec![1, 2]), Some(1)).unwrap();
1159 + assert_eq!(params.prompt_logprob_token_ids, Some(vec![1, 2]));
1160 + assert_eq!(params.prompt_logprob_start, Some(1));
1161 + expect_test::expect![[r#"
1162 + [
1163 + "prompt_logprob_token_ids must not be empty.",
1164 + "requested prompt_logprob_token_ids of 21, which is greater than max allowed: 20",
1165 + "token_id(s) [1000] in prompt_logprob_token_ids are out of vocabulary. Vocabulary size: 1000",
1166 + "prompt_logprob_token_ids must not contain duplicates.",
1167 + "prompt_logprob_start requires prompt_logprob_token_ids.",
1168 + ]
1169 + "#]]
1170 + .assert_debug_eq(&[
1171 + rejects(Some(vec![]), None),
1172 + rejects(Some((0..21).collect()), None),
1173 + rejects(Some(vec![1000]), None),
1174 + rejects(Some(vec![1, 1]), None),
1175 + rejects(None, Some(0)),
1176 + ]);
1177 + }
1178 +
1179 #[test]
1180 fn lower_sampling_params_rejects_out_of_vocab_stop_token_ids() {
1181 let error = lower_sampling_params_with_limits(
@@ -1259,6 +1313,8 @@ mod tests {
1313 thinking_token_budget: None,
1314 logprobs: None,
1315 prompt_logprobs: None,
1316 + prompt_logprob_token_ids: None,
1317 + prompt_logprob_start: None,
1318 min_p: 0.1,
1319 frequency_penalty: 0.0,
1320 presence_penalty: 0.0,
rust/src/text/src/lower/logprobs.rs
+28
@@ -6,6 +6,7 @@
6 //! `-1` is expanded only for bounds checks. The original request values are
7 //! passed through to engine-core.
8
9 +use itertools::Itertools as _;
10 use thiserror::Error;
11
12 use crate::backend::SamplingLimits;
@@ -35,6 +36,12 @@ pub enum LogprobsError {
36 len(logprob_token_ids). Got logprobs={logprobs}, len(logprob_token_ids)={num_token_ids}."
37 )]
38 TokenIdsMismatch { logprobs: i32, num_token_ids: usize },
39 + #[error("prompt_logprob_token_ids must not be empty.")]
40 + EmptyPromptLogprobTokenIds,
41 + #[error("prompt_logprob_token_ids must not contain duplicates.")]
42 + DuplicatePromptLogprobTokenIds,
43 + #[error("prompt_logprob_start requires prompt_logprob_token_ids.")]
44 + PromptLogprobStartWithoutTokenIds,
45 }
46
47 /// Validate logprobs count sampling parameters.
@@ -42,6 +49,8 @@ pub(super) fn validate_logprobs(
49 logprobs: Option<i32>,
50 prompt_logprobs: Option<i32>,
51 logprob_token_ids: Option<&[u32]>,
52 + prompt_logprob_token_ids: Option<&[u32]>,
53 + prompt_logprob_start: Option<u32>,
54 sampling_limits: SamplingLimits,
55 ) -> Result<(), LogprobsError> {
56 let vocab_size = sampling_limits.model_vocab_size;
@@ -50,9 +59,28 @@ pub(super) fn validate_logprobs(
59
60 validate_logprobs_count(logprobs, max_logprobs, vocab_size, "logprobs")?;
61 validate_logprobs_count(prompt_logprobs, max_logprobs, vocab_size, "prompt_logprobs")?;
62 + validate_logprobs_count(
63 + prompt_logprob_token_ids.map(|ids| ids.len() as i32),
64 + max_logprobs,
65 + vocab_size,
66 + "prompt_logprob_token_ids",
67 + )?;
68 + validate_prompt_logprob_token_ids(prompt_logprob_token_ids, prompt_logprob_start)?;
69 validate_logprob_token_ids(logprobs, logprob_token_ids)
70 }
71
72 +fn validate_prompt_logprob_token_ids(
73 + token_ids: Option<&[u32]>,
74 + start: Option<u32>,
75 +) -> Result<(), LogprobsError> {
76 + match token_ids {
77 + Some([]) => Err(LogprobsError::EmptyPromptLogprobTokenIds),
78 + Some(ids) if !ids.iter().all_unique() => Err(LogprobsError::DuplicatePromptLogprobTokenIds),
79 + None if start.is_some() => Err(LogprobsError::PromptLogprobStartWithoutTokenIds),
80 + _ => Ok(()),
81 + }
82 +}
83 +
84 fn validate_logprobs_count(
85 requested: Option<i32>,
86 max_logprobs: usize,
rust/src/text/src/lower/token_ids.rs
+8
@@ -95,6 +95,14 @@ pub(crate) fn validate_vocab_range(
95 )?;
96 }
97
98 + if let Some(token_ids) = params.prompt_logprob_token_ids.as_deref() {
99 + validate_param(
100 + "prompt_logprob_token_ids",
101 + token_ids.iter().copied(),
102 + limits.model_vocab_size,
103 + )?;
104 + }
105 +
106 if let Some(bad_words_token_ids) = params.bad_words_token_ids.as_deref() {
107 validate_param(
108 "bad_words",
rust/src/text/src/request.rs
+7
@@ -97,6 +97,11 @@ pub struct SamplingParams {
97 ///
98 /// `None` disables prompt logprobs. `-1` requests the full vocabulary.
99 pub prompt_logprobs: Option<i32>,
100 + /// Candidate token IDs scored at every scored causal prompt row, where row
101 + /// `i` scores them as predictions of prompt token `i + 1`.
102 + pub prompt_logprob_token_ids: Option<Vec<u32>>,
103 + /// First causal prompt row to score; `None` scores from the first row.
104 + pub prompt_logprob_start: Option<u32>,
105 /// Minimum probability threshold for token sampling. `None` means no
106 /// explicit user override.
107 pub min_p: Option<f32>,
@@ -156,6 +161,8 @@ impl Default for SamplingParams {
161 thinking_token_budget: None,
162 logprobs: None,
163 prompt_logprobs: None,
164 + prompt_logprob_token_ids: None,
165 + prompt_logprob_start: None,
166 min_p: None,
167 frequency_penalty: None,
168 presence_penalty: None,
tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py
+49
@@ -1,9 +1,12 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import io
5 import json
6
7 import httpx
8 +import numpy as np
9 +import pybase64 as base64
10 import pytest
11 import pytest_asyncio
12 from transformers import AutoTokenizer
@@ -311,6 +314,52 @@ async def test_generate_logprobs(client, logprobs_value):
314 assert len(entry["top_logprobs"]) == max(logprobs_value, 1)
315
316
317 +@pytest.mark.asyncio
318 +async def test_generate_prompt_token_id_logprobs(client):
319 + token_ids = [11, 22, 33, 44, 55]
320 + candidates = [22, 33, 44, 55]
321 + sampling_params = {
322 + "max_tokens": 1,
323 + "prompt_logprobs": 0,
324 + "prompt_logprob_token_ids": candidates,
325 + "prompt_logprob_start": 1,
326 + }
327 + payload = {
328 + "model": MODEL_NAME,
329 + "token_ids": token_ids,
330 + "sampling_params": sampling_params,
331 + }
332 + resp = await client.post(GEN_ENDPOINT, json=payload)
333 + resp.raise_for_status()
334 + data = resp.json()
335 +
336 + scores = np.load(io.BytesIO(base64.b64decode(data["prompt_token_id_logprobs"])))
337 + assert scores.shape == (len(token_ids) - 2, len(candidates))
338 + for row, target in enumerate(token_ids[2:]):
339 + expected = data["prompt_logprobs"][row + 2][str(target)]["logprob"]
340 + assert scores[row, candidates.index(target)] == pytest.approx(
341 + expected, abs=1e-3
342 + )
343 +
344 + stream_only_ids = {"prompt_logprob_token_ids": candidates}
345 + resp = await client.post(
346 + GEN_ENDPOINT,
347 + json={**payload, "sampling_params": stream_only_ids, "stream": True},
348 + )
349 + assert resp.status_code == 400
350 + assert "prompt_logprob_token_ids" in resp.text
351 +
352 + past_end = {**sampling_params, "prompt_logprob_start": len(token_ids)}
353 + resp = await client.post(
354 + GEN_ENDPOINT, json={**payload, "sampling_params": past_end}
355 + )
356 + resp.raise_for_status()
357 + empty = np.load(
358 + io.BytesIO(base64.b64decode(resp.json()["prompt_token_id_logprobs"]))
359 + )
360 + assert empty.shape == (0, len(candidates))
361 +
362 +
363 @pytest.mark.asyncio
364 async def test_same_response_as_chat_completions(client, tokenizer, messages):
365 token_ids = tokenizer.apply_chat_template(
tests/v1/engine/test_logprobs_processor.py
+23
@@ -11,8 +11,10 @@ row), the trailing positions are populated with sentinel values
11 """
12
13 import numpy as np
14 +import torch
15
16 from vllm.logprobs import create_sample_logprobs
17 +from vllm.v1.engine import EngineCoreOutput
18 from vllm.v1.engine.logprobs import LogprobsProcessor
19 from vllm.v1.outputs import LogprobsLists
20
@@ -64,3 +66,24 @@ def test_accepts_exactly_sized_row():
66
67 pos = processor.logprobs[0]
68 assert set(pos.keys()) == {7, 11, 13}
69 +
70 +
71 +def test_prompt_token_id_logprobs_are_popped_once():
72 + """DELTA outputs carry fixed-ID prompt scores exactly once."""
73 + processor = _make_processor(num_logprobs=1)
74 + assert processor.pop_prompt_token_id_logprobs() is None
75 +
76 + processor.update_from_output(
77 + EngineCoreOutput(
78 + request_id="req-0",
79 + new_token_ids=[],
80 + prompt_token_id_logprobs=torch.tensor(
81 + [[-0.5, -1.5], [-2.5, -3.5]], dtype=torch.float32
82 + ),
83 + )
84 + )
85 +
86 + scores = processor.pop_prompt_token_id_logprobs()
87 + assert scores is not None
88 + assert scores.tolist() == [[-0.5, -1.5], [-2.5, -3.5]]
89 + assert processor.pop_prompt_token_id_logprobs() is None
tests/v1/sample/test_logprobs.py
+177 -2
@@ -6,7 +6,9 @@ import math
6 from collections.abc import Generator
7 from types import SimpleNamespace
8 from typing import get_args
9 +from unittest.mock import patch
10
11 +import numpy as np
12 import pytest
13 import torch
14
@@ -23,6 +25,7 @@ from vllm.config.model import LogprobsMode
25 from vllm.distributed import cleanup_dist_env_and_memory
26 from vllm.exceptions import VLLMValidationError
27 from vllm.platforms import current_platform
28 +from vllm.v1.engine.input_processor import InputProcessor
29
30 from ...conftest import HfRunner, VllmRunner
31
@@ -81,9 +84,9 @@ def hf_model(hf_runner) -> Generator[HfRunner, None, None]:
84 yield hf_model
85
86
84 -def _model_config(vocab_size: int = 10):
87 +def _model_config(vocab_size: int = 10, max_logprobs: int = 20):
88 return SimpleNamespace(
86 - max_logprobs=20,
89 + max_logprobs=max_logprobs,
90 logits_processors=None,
91 is_diffusion=False,
92 get_vocab_size=lambda: vocab_size,
@@ -432,6 +435,57 @@ def test_logprob_token_ids_validate_vocab_bounds_invalid(token_ids: list[int]):
435 )
436
437
438 +def test_prompt_logprob_token_ids_bounded_by_max_logprobs():
439 + """The candidate count is bounded by max_logprobs."""
440 + model_config = _model_config(vocab_size=100, max_logprobs=4)
441 +
442 + def verify(**kwargs):
443 + SamplingParams(**kwargs).verify(
444 + model_config,
445 + speculative_config=None,
446 + structured_outputs_config=None,
447 + tokenizer=None,
448 + )
449 +
450 + verify(prompt_logprob_token_ids=[1, 2, 3, 4])
451 +
452 + # The error names the value to raise max_logprobs to.
453 + with pytest.raises(VLLMValidationError, match=r"max_logprobs.*at least 5"):
454 + verify(prompt_logprob_token_ids=[1, 2, 3, 4, 5])
455 +
456 + # prompt_logprob_start alone is a caller mistake, not a silent no-op.
457 + with pytest.raises(VLLMValidationError, match="requires prompt_logprob_token_ids"):
458 + verify(prompt_logprob_start=3)
459 +
460 +
461 +def test_prompt_logprob_token_ids_require_v2_model_runner():
462 + """Only the V2 runner scores them; the V1 runner would return None, and
463 + kv-sharing fast prefill would score rows the cross-decoder never ran."""
464 + processor = SimpleNamespace(
465 + model_config=SimpleNamespace(
466 + return_sampling_mask=False, enable_trace_replay=False, is_diffusion=False
467 + ),
468 + vllm_config=SimpleNamespace(
469 + reasoning_config=None,
470 + use_v2_model_runner=False,
471 + cache_config=SimpleNamespace(kv_sharing_fast_prefill=False),
472 + ),
473 + speculative_config=None,
474 + structured_outputs_config=None,
475 + tokenizer=None,
476 + validate_logits_processors_params=lambda params: None,
477 + )
478 + params = SamplingParams(prompt_logprob_token_ids=[1, 2])
479 + with patch.object(SamplingParams, "verify"):
480 + with pytest.raises(VLLMValidationError, match="V2 model runner"):
481 + InputProcessor._validate_params(processor, params, ("generate",))
482 + processor.vllm_config.use_v2_model_runner = True
483 + InputProcessor._validate_params(processor, params, ("generate",))
484 + processor.vllm_config.cache_config.kv_sharing_fast_prefill = True
485 + with pytest.raises(VLLMValidationError, match="fast-prefill"):
486 + InputProcessor._validate_params(processor, params, ("generate",))
487 +
488 +
489 def test_none_logprobs(vllm_model, example_prompts):
490 """Engine should return `logprobs` and `prompt_logprobs` as `None`.
491
@@ -1298,6 +1352,127 @@ def test_prompt_logprobs_with_chunking_and_preemption():
1352 print(f"Test passed with {preemptions} preemptions")
1353
1354
1355 +def test_prompt_logprob_token_ids_with_chunking_and_preemption(monkeypatch):
1356 + """Fixed-ID scores stay row-aligned across chunked prefill and preemption."""
1357 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
1358 +
1359 + prompts = [
1360 + "The following numbers of the sequence "
1361 + + ", ".join(str(i) for i in range(10))
1362 + + " are:",
1363 + "In one word, the capital of France is ",
1364 + ] + [f"Tell me about the number {i}: " for i in range(32)]
1365 +
1366 + start = 2
1367 + candidate_ids = [10, 100, 1000, 10000]
1368 + sampling_params = SamplingParams(
1369 + temperature=0.0,
1370 + max_tokens=40,
1371 + min_tokens=20,
1372 + prompt_logprob_token_ids=candidate_ids,
1373 + prompt_logprob_start=start,
1374 + )
1375 +
1376 + with VllmRunner(
1377 + "Qwen/Qwen3-0.6B",
1378 + max_model_len=512,
1379 + enable_chunked_prefill=True,
1380 + max_num_batched_tokens=48, # Force prefill chunking
1381 + num_gpu_blocks_override=33, # Force preemptions (32 usable + null block)
1382 + disable_log_stats=False,
1383 + gpu_memory_utilization=0.25,
1384 + ) as vllm_model:
1385 + metrics_before = vllm_model.llm.get_metrics()
1386 + outputs = vllm_model.llm.generate(prompts, sampling_params)
1387 +
1388 + for i, output in enumerate(outputs):
1389 + scores = output.prompt_token_id_logprobs
1390 + assert scores is not None, f"Output {i} missing fixed-ID scores"
1391 + expected_shape = (
1392 + len(output.prompt_token_ids) - 1 - start,
1393 + len(candidate_ids),
1394 + )
1395 + assert scores.shape == expected_shape, (
1396 + f"Output {i} scored {scores.shape}, expected {expected_shape}"
1397 + )
1398 + assert math.isfinite(float(scores.min()))
1399 + assert float(scores.max()) <= 1e-3, "logprobs must be <= 0"
1400 +
1401 + metrics_after = vllm_model.llm.get_metrics()
1402 + preemptions_before = next(
1403 + (m.value for m in metrics_before if m.name == "vllm:num_preemptions"), 0
1404 + )
1405 + preemptions_after = next(
1406 + (m.value for m in metrics_after if m.name == "vllm:num_preemptions"), 0
1407 + )
1408 + assert preemptions_after - preemptions_before > 0, (
1409 + "Test did not trigger any preemptions"
1410 + )
1411 +
1412 + # Row alignment is numerical: chunked and preempted scores must match an
1413 + # unchunked, unpreempted run of the same requests. Batch composition moves
1414 + # bf16 tail logprobs by a few percent; a misaligned row differs by nats.
1415 + with VllmRunner(
1416 + "Qwen/Qwen3-0.6B",
1417 + max_model_len=512,
1418 + max_logprobs=64,
1419 + gpu_memory_utilization=0.25,
1420 + ) as reference:
1421 + reference_outputs = reference.llm.generate(prompts, sampling_params)
1422 + own_ids = [list(dict.fromkeys(o.prompt_token_ids)) for o in reference_outputs]
1423 + self_scored = reference.llm.generate(
1424 + prompts,
1425 + [
1426 + SamplingParams(
1427 + max_tokens=1, prompt_logprobs=0, prompt_logprob_token_ids=ids
1428 + )
1429 + for ids in own_ids
1430 + ],
1431 + )
1432 + for output, ref in zip(outputs, reference_outputs):
1433 + np.testing.assert_allclose(
1434 + output.prompt_token_id_logprobs, ref.prompt_token_id_logprobs, rtol=0.1
1435 + )
1436 + for output, ids in zip(self_scored, own_ids):
1437 + scores = output.prompt_token_id_logprobs
1438 + for row, target in enumerate(output.prompt_token_ids[1:]):
1439 + expected = output.prompt_logprobs[row + 1][target].logprob
1440 + assert scores[row, ids.index(target)] == pytest.approx(expected, abs=1e-3)
1441 +
1442 +
1443 +def test_prompt_logprob_token_ids_drop_partially_scored_prefills(monkeypatch):
1444 + """A prefill that starts past the first scored row returns no scores.
1445 +
1446 + Such a prefill (here a prefix-cache hit the caller opted back into) never
1447 + computes the leading rows, so emitting the buffer would return values the
1448 + model never produced for them.
1449 + """
1450 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
1451 +
1452 + prompt = "The capital of France is Paris. " * 20
1453 + sampling_params = SamplingParams(
1454 + temperature=0.0,
1455 + max_tokens=1,
1456 + prompt_logprob_token_ids=[10, 100, 1000],
1457 + skip_reading_prefix_cache=False,
1458 + )
1459 +
1460 + with VllmRunner(
1461 + "Qwen/Qwen3-0.6B",
1462 + max_model_len=512,
1463 + enable_prefix_caching=True,
1464 + gpu_memory_utilization=0.25,
1465 + ) as vllm_model:
1466 + first = vllm_model.llm.generate([prompt], sampling_params)[0]
1467 + assert first.prompt_token_id_logprobs is not None
1468 + assert math.isfinite(float(first.prompt_token_id_logprobs.min()))
1469 +
1470 + # The re-send is served from the prefix cache, so its prefill starts
1471 + # past row 0 and the leading rows are never computed.
1472 + second = vllm_model.llm.generate([prompt], sampling_params)[0]
1473 + assert second.prompt_token_id_logprobs is None
1474 +
1475 +
1476 @large_gpu_mark(min_gb=24)
1477 def test_token_logprobs_large_batch_int64_row_offset():
1478 """Regression: logprob kernel row offset (row * vocab_size) must use int64.
tests/v1/sample/test_trace_replay_params.py
+8
@@ -41,6 +41,14 @@ def test_sampling_params_trace_field_rejects_empty_list():
41 )
42
43
44 +def test_sampling_params_trace_field_rejects_prompt_scoring():
45 + params = SamplingParams(trace_decode_token_ids=[1], prompt_logprob_token_ids=[2, 3])
46 + with pytest.raises(ValueError, match="prompt_logprob_token_ids"):
47 + params._validate_trace_replay(
48 + _make_model_config(vocab_size=100), speculative_config=None
49 + )
50 +
51 +
52 def test_sampling_params_trace_field_requires_single_output():
53 params = SamplingParams(n=2, trace_decode_token_ids=[1])
54 with pytest.raises(ValueError, match="requires n=1"):
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+1
@@ -381,6 +381,7 @@ class GenerateResponse(BaseModel):
381 choices: list[GenerateResponseChoice]
382 usage: UsageInfo | None = Field(default=None)
383 prompt_logprobs: list[dict[int, Logprob] | None] | None = None
384 + prompt_token_id_logprobs: str | None = None
385 prompt_token_ids: list[int] | None = None
386 mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
387 metrics: PerRequestMetrics | None = None
vllm/entrypoints/scale_out/token_in_token_out/serving.py
+10
@@ -138,6 +138,11 @@ class ServingTokens(GenerateBaseServing):
138 f"sampling_params.n must be at most the server's max_num_seqs "
139 f"({max_num_seqs}), got {sampling_params.n}."
140 )
141 + # The stream schema has no field for the scores.
142 + if request.stream and sampling_params.prompt_logprob_token_ids is not None:
143 + return self.create_error_response(
144 + "prompt_logprob_token_ids are not available when stream=true."
145 + )
146 if self.force_no_detokenize and sampling_params.stop:
147 # SamplingParams rejects stop with detokenize=False at request
148 # validation, but this server forces detokenize=False afterwards,
@@ -389,6 +394,11 @@ class ServingTokens(GenerateBaseServing):
394 choices=choices,
395 usage=usage,
396 prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs),
397 + prompt_token_id_logprobs=(
398 + numpy2base64(final_res.prompt_token_id_logprobs)
399 + if final_res.prompt_token_id_logprobs is not None
400 + else None
401 + ),
402 prompt_token_ids=(
403 final_res.prompt_token_ids if request.return_token_ids else None
404 ),
vllm/outputs.py
+7
@@ -117,6 +117,10 @@ class RequestOutput:
117 For encoder/decoder models, this is the
118 decoder input prompt token ids.
119 prompt_logprobs: The log probabilities to return per prompt token.
120 + prompt_token_id_logprobs: Logprobs of prompt_logprob_token_ids, shaped
121 + [max(prompt_len - 1 - prompt_logprob_start, 0),
122 + len(prompt_logprob_token_ids)]; row i scores them as predictions of
123 + prompt token prompt_logprob_start + i + 1.
124 outputs: The output sequences of the request.
125 finished: Whether the whole request is finished.
126 metrics: Metrics associated with the request.
@@ -148,6 +152,7 @@ class RequestOutput:
152 num_cached_tokens: int | None = None,
153 num_cache_creation_tokens: int | None = None,
154 *,
155 + prompt_token_id_logprobs: np.ndarray | None = None,
156 kv_transfer_params: dict[str, Any] | None = None,
157 ec_transfer_params: dict[str, Any] | None = None,
158 # Forward compatibility, code that uses args added in new release can
@@ -162,6 +167,7 @@ class RequestOutput:
167 self.prompt = prompt
168 self.prompt_token_ids = prompt_token_ids
169 self.prompt_logprobs = prompt_logprobs
170 + self.prompt_token_id_logprobs = prompt_token_id_logprobs
171 self.outputs = outputs
172 self.finished = finished
173 self.metrics = metrics
@@ -215,6 +221,7 @@ class RequestOutput:
221 f"encoder_prompt={self.encoder_prompt!r}, "
222 f"encoder_prompt_token_ids={self.encoder_prompt_token_ids}, "
223 f"prompt_logprobs={self.prompt_logprobs}, "
224 + f"prompt_token_id_logprobs={self.prompt_token_id_logprobs}, "
225 f"outputs={self.outputs}, "
226 f"finished={self.finished}, "
227 f"metrics={self.metrics}, "
vllm/sampling_params.py
+68 -1
@@ -289,6 +289,12 @@ class SamplingParams(
289 prompt_logprobs: int | None = None
290 """Number of log probabilities to return per prompt token.
291 When set to -1, return all `vocab_size` log probabilities."""
292 + prompt_logprob_token_ids: list[int] | None = None
293 + """Token IDs to score at each scored causal prompt row, where row i scores
294 + them as predictions of prompt token i + 1. The last prompt row is excluded,
295 + so the result has `max(prompt_len - 1 - prompt_logprob_start, 0)` rows."""
296 + prompt_logprob_start: int | None = None
297 + """First causal prompt row to score; defaults to the first row."""
298 logprob_token_ids: list[int] | None = None
299 """Specific token IDs to return logprobs for. More efficient than
300 logprobs=-1 when you only need logprobs for a small set of tokens.
@@ -398,6 +404,8 @@ class SamplingParams(
404 min_tokens: int = 0,
405 logprobs: int | None = None,
406 prompt_logprobs: int | None = None,
407 + prompt_logprob_token_ids: list[int] | None = None,
408 + prompt_logprob_start: int | None = None,
409 detokenize: bool = True,
410 skip_special_tokens: bool = True,
411 spaces_between_special_tokens: bool = True,
@@ -464,6 +472,8 @@ class SamplingParams(
472 min_tokens=min_tokens,
473 logprobs=logprobs,
474 prompt_logprobs=prompt_logprobs,
475 + prompt_logprob_token_ids=prompt_logprob_token_ids,
476 + prompt_logprob_start=prompt_logprob_start,
477 logprob_token_ids=logprob_token_ids,
478 detokenize=detokenize,
479 skip_special_tokens=skip_special_tokens,
@@ -550,7 +560,10 @@ class SamplingParams(
560 # If prefix caching is enabled,
561 # the output of prompt logprobs may less than n_prompt_tokens,
562 # we need to skip reading cache at this request.
553 - self.skip_reading_prefix_cache = self.prompt_logprobs is not None
563 + self.skip_reading_prefix_cache = (
564 + self.prompt_logprobs is not None
565 + or self.prompt_logprob_token_ids is not None
566 + )
567
568 def _verify_args(self) -> None:
569 _verify_num_sequences(self.n, "n")
@@ -902,6 +915,56 @@ class SamplingParams(
915 value=num_prompt_logprobs,
916 )
917
918 + # Validate prompt_logprob_token_ids.
919 + if self.prompt_logprob_token_ids is not None:
920 + n = len(self.prompt_logprob_token_ids)
921 + if n == 0:
922 + raise VLLMValidationError(
923 + "prompt_logprob_token_ids must not be empty.",
924 + parameter="prompt_logprob_token_ids",
925 + value=n,
926 + )
927 + if n > max_logprobs:
928 + raise VLLMValidationError(
929 + f"Requested prompt_logprob_token_ids of length {n}, "
930 + f"which is greater than max allowed: {max_logprobs}. "
931 + f"Set max_logprobs (--max-logprobs) to at least {n}.",
932 + parameter="prompt_logprob_token_ids",
933 + value=n,
934 + )
935 + vocab_size = model_config.get_vocab_size()
936 + invalid_token_ids = [
937 + token_id
938 + for token_id in self.prompt_logprob_token_ids
939 + if token_id < 0 or token_id >= vocab_size
940 + ]
941 + if invalid_token_ids:
942 + raise VLLMValidationError(
943 + f"token_id(s) {invalid_token_ids} in "
944 + f"prompt_logprob_token_ids contain out-of-vocab token ids. "
945 + f"Vocabulary size: {vocab_size}",
946 + parameter="prompt_logprob_token_ids",
947 + value=invalid_token_ids,
948 + )
949 + if len(set(self.prompt_logprob_token_ids)) != n:
950 + raise VLLMValidationError(
951 + "prompt_logprob_token_ids must not contain duplicates.",
952 + parameter="prompt_logprob_token_ids",
953 + value=self.prompt_logprob_token_ids,
954 + )
955 + if self.prompt_logprob_start is not None and self.prompt_logprob_start < 0:
956 + raise VLLMValidationError(
957 + "prompt_logprob_start must be non-negative.",
958 + parameter="prompt_logprob_start",
959 + value=self.prompt_logprob_start,
960 + )
961 + elif self.prompt_logprob_start is not None:
962 + raise VLLMValidationError(
963 + "prompt_logprob_start requires prompt_logprob_token_ids.",
964 + parameter="prompt_logprob_start",
965 + value=self.prompt_logprob_start,
966 + )
967 +
968 def _validate_stop_token_ids(self, model_config: ModelConfig) -> None:
969 """Validate stop_token_ids are within vocabulary range."""
970 if not self.stop_token_ids:
@@ -969,6 +1032,10 @@ class SamplingParams(
1032 raise ValueError(
1033 "trace_decode_token_ids is not supported with prompt_logprobs."
1034 )
1035 + if self.prompt_logprob_token_ids is not None:
1036 + raise ValueError(
1037 + "trace_decode_token_ids is not supported with prompt_logprob_token_ids."
1038 + )
1039 if speculative_config is not None:
1040 raise ValueError(
1041 "trace_decode_token_ids is not supported with speculative decoding."
vllm/v1/core/sched/scheduler.py
+6
@@ -1972,6 +1972,9 @@ class Scheduler(SchedulerInterface):
1972 sampled_token_ids = model_runner_output.sampled_token_ids
1973 logprobs = model_runner_output.logprobs
1974 prompt_logprobs_dict = model_runner_output.prompt_logprobs_dict
1975 + prompt_token_id_logprobs_dict = (
1976 + model_runner_output.prompt_token_id_logprobs_dict
1977 + )
1978 num_scheduled_tokens = scheduler_output.num_scheduled_tokens
1979 pooler_outputs = model_runner_output.pooler_output
1980 num_nans_in_logits = model_runner_output.num_nans_in_logits
@@ -2194,6 +2197,7 @@ class Scheduler(SchedulerInterface):
2197
2198 # Get prompt logprobs for this request.
2199 prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
2200 + prompt_token_id_logprobs = prompt_token_id_logprobs_dict.get(req_id)
2201 if should_emit_output:
2202 # Add EngineCoreOutput for this Request.
2203 outputs[request.client_index].append(
@@ -2204,6 +2208,7 @@ class Scheduler(SchedulerInterface):
2208 new_logprobs=new_logprobs,
2209 new_sampling_mask=new_sampling_mask,
2210 new_prompt_logprobs_tensors=prompt_logprobs_tensors,
2211 + prompt_token_id_logprobs=prompt_token_id_logprobs,
2212 pooling_output=pooler_output,
2213 stop_reason=request.stop_reason,
2214 events=request.take_events(),
@@ -2223,6 +2228,7 @@ class Scheduler(SchedulerInterface):
2228 else:
2229 # Invariant: EngineCore returns no partial prefill outputs.
2230 assert not prompt_logprobs_tensors
2231 + assert prompt_token_id_logprobs is None
2232
2233 # Remove the stopped requests from the running and waiting queues.
2234 if stopped_running_reqs:
vllm/v1/engine/__init__.py
+3
@@ -236,6 +236,9 @@ class EngineCoreOutput(
236 # Appended last so `array_like` positional serialization stays compatible.
237 spec_decode_metrics: RequestSpecDecodeMetrics | None = None
238
239 + # [num_scored_rows, num_token_ids]; appended last for array_like compatibility.
240 + prompt_token_id_logprobs: torch.Tensor | None = None
241 +
242 @property
243 def finished(self) -> bool:
244 return self.finish_reason is not None
vllm/v1/engine/input_processor.py
+13
@@ -131,6 +131,19 @@ class InputProcessor:
131 self.structured_outputs_config,
132 self.tokenizer,
133 )
134 + if params.prompt_logprob_token_ids is not None:
135 + if not self.vllm_config.use_v2_model_runner:
136 + raise VLLMValidationError(
137 + "prompt_logprob_token_ids requires the V2 model runner "
138 + "(VLLM_USE_V2_MODEL_RUNNER=1).",
139 + parameter="prompt_logprob_token_ids",
140 + )
141 + if self.vllm_config.cache_config.kv_sharing_fast_prefill:
142 + raise VLLMValidationError(
143 + "prompt_logprob_token_ids is incorrect with "
144 + "--kv-sharing-fast-prefill; disable it for scoring.",
145 + parameter="prompt_logprob_token_ids",
146 + )
147
148 self.validate_logits_processors_params(params)
149
vllm/v1/engine/logprobs.py
+12
@@ -5,6 +5,8 @@ import itertools
5 from collections.abc import Iterable
6 from dataclasses import dataclass
7
8 +import numpy as np
9 +
10 from vllm.logger import init_logger
11 from vllm.logprobs import (
12 FlatLogprobs,
@@ -38,6 +40,8 @@ class LogprobsProcessor:
40 cumulative_logprob: float | None
41 num_logprobs: int | None
42 num_prompt_logprobs: int | None
43 + # [num_scored_rows, num_token_ids], set once on the final prefill chunk.
44 + prompt_token_id_logprobs: np.ndarray | None = None
45
46 @classmethod
47 def from_new_request(
@@ -204,6 +208,12 @@ class LogprobsProcessor:
208 self.prompt_logprobs = []
209 return plp
210
211 + def pop_prompt_token_id_logprobs(self) -> np.ndarray | None:
212 + """Pop and return the fixed-ID prompt scores."""
213 + scores = self.prompt_token_id_logprobs
214 + self.prompt_token_id_logprobs = None
215 + return scores
216 +
217 @staticmethod
218 def _get_sampled_context_ids(
219 logprobs_source: SampleLogprobs | PromptLogprobs | None,
@@ -352,3 +362,5 @@ class LogprobsProcessor:
362 self._update_sample_logprobs(output.new_logprobs)
363 if output.new_prompt_logprobs_tensors is not None:
364 self._update_prompt_logprobs(output.new_prompt_logprobs_tensors)
365 + if output.prompt_token_id_logprobs is not None:
366 + self.prompt_token_id_logprobs = output.prompt_token_id_logprobs.numpy()
vllm/v1/engine/output_processor.py
+5
@@ -390,8 +390,12 @@ class RequestState:
390 if self.output_kind == RequestOutputKind.DELTA:
391 # Side effect: logprobs processor forgets prompt logprobs
392 prompt_logprobs = self.logprobs_processor.pop_prompt_logprobs()
393 + prompt_token_id_logprobs = (
394 + self.logprobs_processor.pop_prompt_token_id_logprobs()
395 + )
396 else:
397 prompt_logprobs = self.logprobs_processor.prompt_logprobs
398 + prompt_token_id_logprobs = self.logprobs_processor.prompt_token_id_logprobs
399
400 return RequestOutput(
401 request_id=external_req_id, # request_id is what was provided externally
@@ -399,6 +403,7 @@ class RequestState:
403 prompt=self.prompt,
404 prompt_token_ids=prompt_token_ids,
405 prompt_logprobs=prompt_logprobs,
406 + prompt_token_id_logprobs=prompt_token_id_logprobs,
407 outputs=cast(list[CompletionOutput], outputs),
408 finished=finished,
409 kv_transfer_params=kv_transfer_params,
vllm/v1/outputs.py
+3
@@ -283,6 +283,9 @@ class ModelRunnerOutput:
283 default_factory=dict
284 )
285
286 + # req_id -> [num_scored_rows, num_token_ids] prompt_logprob_token_ids scores.
287 + prompt_token_id_logprobs_dict: dict[str, torch.Tensor] = field(default_factory=dict)
288 +
289 # [num_reqs, hidden_size]
290 pooler_output: list[torch.Tensor | None] | None = None
291
vllm/v1/worker/gpu/async_utils.py
+7
@@ -156,6 +156,10 @@ class AsyncOutput(AsyncModelRunnerOutput):
156 k: v.to_cpu_nonblocking() if v is not None else None
157 for k, v in self.model_runner_output.prompt_logprobs_dict.items()
158 }
159 + token_id_logprobs = self.model_runner_output.prompt_token_id_logprobs_dict
160 + self.prompt_token_id_logprobs_dict = {
161 + k: v.to("cpu", non_blocking=True) for k, v in token_id_logprobs.items()
162 + }
163 if self.pending_aux_output is not None:
164 self.pending_aux_output.enqueue_cpu_copy(
165 num_sampled=self.num_sampled_tokens_np,
@@ -194,6 +198,9 @@ class AsyncOutput(AsyncModelRunnerOutput):
198 if self.logprobs_tensors is not None:
199 self.model_runner_output.logprobs = self.logprobs_tensors.tolists()
200 self.model_runner_output.prompt_logprobs_dict = self.prompt_logprobs_dict
201 + self.model_runner_output.prompt_token_id_logprobs_dict = (
202 + self.prompt_token_id_logprobs_dict
203 + )
204 if self.pending_aux_output is not None:
205 self.model_runner_output.aux_output_connector_output = (
206 self.pending_aux_output.process_output()
vllm/v1/worker/gpu/model_runner.py
+12 -1
@@ -494,7 +494,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
494 ),
495 )
496 self.prompt_logprobs_worker = PromptLogprobsWorker(
497 - self.max_num_reqs, logprobs_mode=self.model_config.logprobs_mode
497 + self.max_num_reqs,
498 + self.device,
499 + logprobs_mode=self.model_config.logprobs_mode,
500 )
501 self.structured_outputs_worker = StructuredOutputsWorker(
502 max_num_logits=self.max_num_reqs * self.decode_query_len,
@@ -2063,6 +2065,14 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2065 self.req_states.num_computed_tokens.gpu,
2066 self.req_states.prompt_len.np,
2067 )
2068 + prompt_token_id_logprobs_dict = (
2069 + self.prompt_logprobs_worker.compute_prompt_token_id_logprobs(
2070 + self.model.compute_logits,
2071 + hidden_states,
2072 + input_batch,
2073 + self.req_states.prompt_len.np,
2074 + )
2075 + )
2076
2077 # Prepare the model runner output.
2078 model_runner_output = ModelRunnerOutput(
@@ -2072,6 +2082,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
2082 req_id_to_index={req_id: i for i, req_id in enumerate(input_batch.req_ids)},
2083 sampled_token_ids=None, # type: ignore
2084 prompt_logprobs_dict=prompt_logprobs_dict, # type: ignore[arg-type]
2085 + prompt_token_id_logprobs_dict=prompt_token_id_logprobs_dict,
2086 cudagraph_stats=cudagraph_stats,
2087 )
2088 pending_aux_output = None
vllm/v1/worker/gpu/sample/logprob.py
+3 -1
@@ -19,6 +19,7 @@ def _topk_log_softmax_kernel(
19 logits_ptr,
20 logits_stride,
21 topk_ids_ptr,
22 + topk_ids_stride,
23 topk,
24 vocab_size,
25 BLOCK_SIZE: tl.constexpr,
@@ -49,7 +50,7 @@ def _topk_log_softmax_kernel(
50 k_offset = j + tl.arange(0, TOPK_BLOCK_SIZE)
51 k_mask = k_offset < topk
52 topk_ids = tl.load(
52 - topk_ids_ptr + req_idx * topk + k_offset, mask=k_mask, other=0
53 + topk_ids_ptr + req_idx * topk_ids_stride + k_offset, mask=k_mask, other=0
54 )
55 logits = tl.load(row_ptr + topk_ids, mask=k_mask)
56 logits = logits.to(tl.float32)
@@ -98,6 +99,7 @@ def compute_token_logprobs(
99 logits,
100 logits.stride(0),
101 token_ids,
102 + token_ids.stride(0),
103 num_logprobs,
104 vocab_size,
105 BLOCK_SIZE=1024, # type: ignore
vllm/v1/worker/gpu/sample/prompt_logprob.py
+84 -3
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections.abc import Callable
4 +from dataclasses import dataclass
5
6 import numpy as np
7 import torch
@@ -8,20 +9,41 @@ import torch
9 from vllm.config.model import LogprobsMode
10 from vllm.sampling_params import SamplingParams
11 from vllm.triton_utils import tl, triton
12 +from vllm.utils.torch_utils import async_tensor_h2d
13 from vllm.v1.outputs import LogprobsTensors
14 from vllm.v1.worker.gpu.input_batch import InputBatch
13 -from vllm.v1.worker.gpu.sample.logprob import compute_topk_scores
15 +from vllm.v1.worker.gpu.sample.logprob import (
16 + compute_token_logprobs,
17 + compute_topk_scores,
18 +)
19 +
20 +CHUNK_SIZE = 1024
21 +
22 +
23 +@dataclass
24 +class _TokenIdScores:
25 + token_ids: torch.Tensor # [num_ids], on device
26 + start: int # first prompt row to score
27 + scores: torch.Tensor | None = None # [num_rows, num_ids], filled per chunk
28
29
30 class PromptLogprobsWorker:
17 - def __init__(self, max_num_reqs: int, logprobs_mode: LogprobsMode = "raw_logprobs"):
31 + def __init__(
32 + self,
33 + max_num_reqs: int,
34 + device: torch.device,
35 + logprobs_mode: LogprobsMode = "raw_logprobs",
36 + ):
37 self.max_num_reqs = max_num_reqs
38 + self.device = device
39 self.logprobs_mode = logprobs_mode
40
41 self.uses_prompt_logprobs = np.zeros(self.max_num_reqs, dtype=bool)
42 self.num_prompt_logprobs = np.zeros(self.max_num_reqs, dtype=np.int32)
43 # req_idx -> list of in-progress LogprobsTensors
44 self.in_progress_prompt_logprobs: dict[str, list[LogprobsTensors]] = {}
45 + # req_id -> fixed-ID scoring state
46 + self.token_id_scores: dict[str, _TokenIdScores] = {}
47
48 def add_request(self, req_id: str, req_idx: int, sampling_params: SamplingParams):
49 uses_prompt_logprobs = sampling_params.prompt_logprobs is not None
@@ -29,9 +51,69 @@ class PromptLogprobsWorker:
51 self.num_prompt_logprobs[req_idx] = sampling_params.prompt_logprobs or 0
52 if uses_prompt_logprobs:
53 self.in_progress_prompt_logprobs[req_id] = []
54 + if sampling_params.prompt_logprob_token_ids is not None:
55 + self.token_id_scores[req_id] = _TokenIdScores(
56 + async_tensor_h2d(
57 + sampling_params.prompt_logprob_token_ids, self.device, torch.int64
58 + ),
59 + sampling_params.prompt_logprob_start or 0,
60 + )
61
62 def remove_request(self, req_id: str) -> None:
63 self.in_progress_prompt_logprobs.pop(req_id, None)
64 + self.token_id_scores.pop(req_id, None)
65 +
66 + def compute_prompt_token_id_logprobs(
67 + self,
68 + logits_fn: Callable[[torch.Tensor], torch.Tensor],
69 + hidden_states: torch.Tensor,
70 + input_batch: InputBatch,
71 + prompt_lens: np.ndarray,
72 + ) -> dict[str, torch.Tensor]:
73 + """Fill scores row (prompt row - req.start) per chunk; emit on the last."""
74 + if not self.token_id_scores:
75 + return {}
76 + logits_mode = self.logprobs_mode in ("raw_logits", "processed_logits")
77 + out: dict[str, torch.Tensor] = {}
78 + for i, req_id in enumerate(input_batch.req_ids):
79 + req = self.token_id_scores.get(req_id)
80 + if req is None:
81 + continue
82 + prompt_len = int(prompt_lens[input_batch.idx_mapping_np[i]])
83 + chunk_start = int(input_batch.num_computed_prefill_tokens_np[i])
84 + chunk_end = chunk_start + int(input_batch.num_scheduled_tokens[i])
85 + # Skip decode steps and, as compute_prompt_logprobs does, requests
86 + # resumed after preemption: their scores were already emitted.
87 + if chunk_start >= prompt_len or prompt_len < input_batch.prefill_len_np[i]:
88 + continue
89 + if req.scores is None:
90 + if chunk_start > req.start:
91 + # This prefill starts past the first scored row, so those
92 + # rows will never be written; drop the request instead of
93 + # emitting a buffer with unwritten rows.
94 + self.token_id_scores.pop(req_id, None)
95 + continue
96 + req.scores = hidden_states.new_empty(
97 + (max(prompt_len - 1 - req.start, 0), len(req.token_ids)),
98 + dtype=torch.float32,
99 + )
100 + # The last prompt row predicts the first decode token; skip it.
101 + lo = max(req.start, chunk_start)
102 + hi = min(chunk_end, prompt_len - 1)
103 + base = int(input_batch.query_start_loc_np[i]) - chunk_start
104 + for a in range(lo, hi, CHUNK_SIZE):
105 + b = min(a + CHUNK_SIZE, hi)
106 + logits = logits_fn(hidden_states[base + a : base + b])
107 + ids = req.token_ids.expand(b - a, -1)
108 + req.scores[a - req.start : b - req.start] = (
109 + logits.gather(-1, ids)
110 + if logits_mode
111 + else compute_token_logprobs(logits, ids)
112 + )
113 + if chunk_end >= prompt_len:
114 + out[req_id] = req.scores
115 + req.scores = None
116 + return out
117
118 def compute_prompt_logprobs(
119 self,
@@ -205,7 +287,6 @@ def compute_prompt_logprobs_with_chunking(
287 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
288 # Since materializing the full prompt logits can take too much memory,
289 # we compute it in chunks.
208 - CHUNK_SIZE = 1024
290 token_ids = []
291 scores = []
292 ranks = []