Skip to content

Commit d8786bd

Browse files
Statistics for reasoning and prefix-cached tokens (#413)
Co-authored-by: Venkat Raman <vraman2811@gmail.com>
1 parent f3b890b commit d8786bd

4 files changed

Lines changed: 231 additions & 4 deletions

File tree

docs/prefix_cache.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,30 @@ Prefix cache is block-granular. If a shared prefix ends in the middle of a block
1717

1818
If `--prefix-cache-max-tokens` is omitted, the cache defaults to roughly 25% of GPU KV blocks in this project.
1919

20+
## Usage Reporting
21+
22+
OpenAI-compatible chat responses include prefix-cache and reasoning token details when they are non-zero:
23+
24+
```json
25+
{
26+
"usage": {
27+
"prompt_tokens": 128,
28+
"completion_tokens": 64,
29+
"total_tokens": 192,
30+
"prompt_time_costs": 4,
31+
"completion_time_costs": 250,
32+
"prompt_tokens_details": {
33+
"cached_tokens": 64
34+
},
35+
"completion_tokens_details": {
36+
"reasoning_tokens": 32
37+
}
38+
}
39+
}
40+
```
41+
42+
`prompt_tokens_details.cached_tokens` reports the number of prompt tokens reused from the prefix cache. `completion_tokens_details.reasoning_tokens` reports generated tokens inside reasoning blocks such as `<think>...</think>`. Both detail objects are omitted when their count is zero.
43+
2044
## Hybrid Mamba snapshot stride
2145

2246
For hybrid Mamba models, prefix reuse also needs compatible snapshot boundaries.

src/openai/pipelines/llm_engine.rs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use crate::openai::streaming::ChatResponse;
1616
use crate::openai::TaskData;
1717
use crate::scheduler::Scheduler;
1818
use crate::tools::stream_parser::{
19-
BufferedFinalizeResult, ParserState, StreamResult, StreamToolParser,
19+
extract_reasoning_content, strip_reasoning_markers, BufferedFinalizeResult, ParserState,
20+
StreamResult, StreamToolParser,
2021
};
2122
#[cfg(feature = "flashinfer")]
2223
use crate::FlashInferKvParams;
@@ -27,8 +28,8 @@ use crate::{
2728
multimodal::ImageData,
2829
responses::{
2930
ChatChoice, ChatChoiceData, ChatCompletionChunk, ChatCompletionUsageResponse, Choice,
30-
ChoiceData, EmbeddingData, EmbeddingOutput, EmbeddingResponse, EmbeddingUsage,
31-
WrapperLogprobs,
31+
ChoiceData, CompletionTokensDetails, EmbeddingData, EmbeddingOutput, EmbeddingResponse,
32+
EmbeddingUsage, PromptTokensDetails, WrapperLogprobs,
3233
},
3334
sampling_params::Logprobs,
3435
sampling_params::SamplingParams,
@@ -860,6 +861,16 @@ impl LLMEngine {
860861
}
861862

862863
//chat completion statistics
864+
let cached_tokens_total = result
865+
.values()
866+
.filter_map(|(_, usage)| usage.prompt_tokens_details.as_ref())
867+
.map(|details| details.cached_tokens)
868+
.sum();
869+
let reasoning_tokens_total = result
870+
.values()
871+
.filter_map(|(_, usage)| usage.completion_tokens_details.as_ref())
872+
.map(|details| details.reasoning_tokens)
873+
.sum();
863874
let overall_usage = ChatCompletionUsageResponse {
864875
request_id: "".to_string(),
865876
created: 0,
@@ -878,6 +889,15 @@ impl LLMEngine {
878889
.map(|(_, usage)| usage.completion_time_costs)
879890
.max()
880891
.unwrap_or(0),
892+
prompt_tokens_details: (cached_tokens_total > 0)
893+
.then_some(PromptTokensDetails {
894+
cached_tokens: cached_tokens_total,
895+
}),
896+
completion_tokens_details: (reasoning_tokens_total > 0).then_some(
897+
CompletionTokensDetails {
898+
reasoning_tokens: reasoning_tokens_total,
899+
},
900+
),
881901
};
882902

883903
let prompt_tps : f32 = result.values().map(|(_, usage)| {
@@ -1270,6 +1290,66 @@ impl LLMEngine {
12701290
self.scheduler.has_unfinished_sequences()
12711291
}
12721292

1293+
fn count_text_tokens(pipeline: &DefaultPipeline, text: &str) -> usize {
1294+
if text.is_empty() {
1295+
return 0;
1296+
}
1297+
pipeline
1298+
.tokenizer()
1299+
.encode(text, false)
1300+
.map(|encoding| encoding.get_ids().len())
1301+
.unwrap_or(0)
1302+
}
1303+
1304+
fn reasoning_token_count_for_sequence(
1305+
&self,
1306+
pipeline: &DefaultPipeline,
1307+
group: &SequenceGroup,
1308+
seq: &Arc<Sequence>,
1309+
) -> usize {
1310+
let outputs = seq.deref().get_output_tokens();
1311+
if outputs.is_empty() {
1312+
return 0;
1313+
}
1314+
1315+
let raw_output = outputs.iter().map(|x| x.bytes.as_str()).collect::<String>();
1316+
if let Some((reasoning, _)) = extract_reasoning_content(&raw_output) {
1317+
return Self::count_text_tokens(pipeline, &reasoning);
1318+
}
1319+
1320+
let should_parse_tools = group.sampling_params.mcp_mode.is_some();
1321+
if !(crate::stream_as_reasoning_content() && should_parse_tools) {
1322+
return 0;
1323+
}
1324+
1325+
let mut parser = StreamToolParser::new_with_config(
1326+
&pipeline.tool_model_type,
1327+
pipeline.tool_parser_model_id.clone(),
1328+
pipeline.tool_config.clone(),
1329+
group.tools.clone(),
1330+
pipeline.enforce_parser.clone(),
1331+
);
1332+
if group.active_reasoning_end.is_some() {
1333+
parser.set_initial_reasoning_end_marker(group.active_reasoning_end.clone());
1334+
}
1335+
parser.set_detect_tools_in_reasoning(true);
1336+
1337+
let mut reasoning_text = String::new();
1338+
for output in outputs {
1339+
let was_in_reasoning = parser.in_reasoning();
1340+
match parser.process_token(output.token, &output.bytes) {
1341+
StreamResult::Content(text) | StreamResult::FlushBuffer(text) => {
1342+
if was_in_reasoning {
1343+
reasoning_text.push_str(&strip_reasoning_markers(&text));
1344+
}
1345+
}
1346+
StreamResult::Buffering | StreamResult::ToolCalls(_) => {}
1347+
}
1348+
}
1349+
1350+
Self::count_text_tokens(pipeline, &reasoning_text)
1351+
}
1352+
12731353
fn schedule_current_batch(&mut self, rank: usize) -> Result<()> {
12741354
let scheduler_outputs = self.scheduler.schedule();
12751355
if !scheduler_outputs.ignored_seq_groups.is_empty() {
@@ -1704,9 +1784,9 @@ impl LLMEngine {
17041784

17051785
let mut choices = Vec::new();
17061786
let do_sync_response = allow_sync_response && group.sender.is_none();
1787+
let pipeline = self.get_pipeline(0usize).unwrap().0.as_ref();
17071788

17081789
if do_sync_response {
1709-
let pipeline = self.get_pipeline(0usize).unwrap().0.as_ref();
17101790
for (index, seq) in top_n.iter().enumerate() {
17111791
let outputs = seq.deref_mut().get_output_tokens();
17121792
let should_parse_tools = group.sampling_params.mcp_mode.is_some();
@@ -1929,6 +2009,14 @@ impl LLMEngine {
19292009
.duration_since(group.created_time)
19302010
.unwrap()
19312011
.as_millis();
2012+
let cached_tokens = top_n
2013+
.first()
2014+
.and_then(|seq| self.get_num_cached_tokens_for_seq(seq.deref().get_id()))
2015+
.unwrap_or_else(|| top_n.first().unwrap().deref().get_num_cached_tokens());
2016+
let reasoning_tokens = top_n
2017+
.iter()
2018+
.map(|seq| self.reasoning_token_count_for_sequence(pipeline, group, seq))
2019+
.sum::<usize>();
19322020

19332021
let usage = ChatCompletionUsageResponse {
19342022
request_id: group.request_id.clone(),
@@ -1938,6 +2026,10 @@ impl LLMEngine {
19382026
total_tokens: completion_tokens + prompt_tokens,
19392027
prompt_time_costs: prompt_time_costs as usize,
19402028
completion_time_costs: completion_time_costs as usize,
2029+
prompt_tokens_details: (cached_tokens > 0)
2030+
.then_some(PromptTokensDetails { cached_tokens }),
2031+
completion_tokens_details: (reasoning_tokens > 0)
2032+
.then_some(CompletionTokensDetails { reasoning_tokens }),
19412033
};
19422034

19432035
responses.insert(group.request_id.clone(), (choices, usage.clone()));
@@ -2105,4 +2197,8 @@ impl LLMEngine {
21052197
pub fn query_prefix_cache_match_tokens(&mut self, tokens: &[u32]) -> usize {
21062198
self.scheduler.query_prefix_cache_match_tokens(tokens)
21072199
}
2200+
2201+
pub fn get_num_cached_tokens_for_seq(&self, seq_id: usize) -> Option<usize> {
2202+
self.scheduler.get_num_cached_tokens_for_seq(seq_id)
2203+
}
21082204
}

src/openai/responses.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ macro_rules! try_api {
3939
};
4040
}
4141

42+
#[derive(Debug, Clone, Serialize, Deserialize)]
43+
pub struct PromptTokensDetails {
44+
pub cached_tokens: usize,
45+
}
46+
47+
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
pub struct CompletionTokensDetails {
49+
pub reasoning_tokens: usize,
50+
}
51+
4252
#[derive(Debug, Clone, Serialize, Deserialize)]
4353
pub struct ChatCompletionUsageResponse {
4454
pub request_id: String,
@@ -48,6 +58,10 @@ pub struct ChatCompletionUsageResponse {
4858
pub total_tokens: usize,
4959
pub prompt_time_costs: usize, //milliseconds
5060
pub completion_time_costs: usize, //milliseconds
61+
#[serde(skip_serializing_if = "Option::is_none")]
62+
pub prompt_tokens_details: Option<PromptTokensDetails>,
63+
#[serde(skip_serializing_if = "Option::is_none")]
64+
pub completion_tokens_details: Option<CompletionTokensDetails>,
5165
}
5266

5367
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -188,3 +202,66 @@ pub struct EmbeddingResponse {
188202
pub model: String,
189203
pub usage: EmbeddingUsage,
190204
}
205+
206+
#[cfg(test)]
207+
mod tests {
208+
use super::{ChatCompletionUsageResponse, CompletionTokensDetails, PromptTokensDetails};
209+
210+
fn usage(
211+
prompt_tokens_details: Option<PromptTokensDetails>,
212+
completion_tokens_details: Option<CompletionTokensDetails>,
213+
) -> ChatCompletionUsageResponse {
214+
ChatCompletionUsageResponse {
215+
request_id: "request-id".to_string(),
216+
created: 0,
217+
completion_tokens: 50,
218+
prompt_tokens: 100,
219+
total_tokens: 150,
220+
prompt_time_costs: 1,
221+
completion_time_costs: 1,
222+
prompt_tokens_details,
223+
completion_tokens_details,
224+
}
225+
}
226+
227+
#[test]
228+
fn usage_omits_token_details_when_none() {
229+
let value = serde_json::to_value(usage(None, None)).expect("serialize usage");
230+
let object = value.as_object().expect("usage is a JSON object");
231+
232+
assert!(!object.contains_key("prompt_tokens_details"));
233+
assert!(!object.contains_key("completion_tokens_details"));
234+
}
235+
236+
#[test]
237+
fn usage_includes_prompt_tokens_details_when_some() {
238+
let value =
239+
serde_json::to_value(usage(Some(PromptTokensDetails { cached_tokens: 64 }), None))
240+
.expect("serialize usage");
241+
242+
assert_eq!(
243+
value
244+
.pointer("/prompt_tokens_details/cached_tokens")
245+
.and_then(|v| v.as_u64()),
246+
Some(64)
247+
);
248+
}
249+
250+
#[test]
251+
fn usage_includes_completion_tokens_details_when_some() {
252+
let value = serde_json::to_value(usage(
253+
None,
254+
Some(CompletionTokensDetails {
255+
reasoning_tokens: 32,
256+
}),
257+
))
258+
.expect("serialize usage");
259+
260+
assert_eq!(
261+
value
262+
.pointer("/completion_tokens_details/reasoning_tokens")
263+
.and_then(|v| v.as_u64()),
264+
Some(32)
265+
);
266+
}
267+
}

src/scheduler/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use self::{
3434
};
3535

3636
const PREFIX_CACHE_PRESSURE_EVICT_PERCENT: f32 = 0.1; // evict 10% of prefix cache when under pressure
37+
const FINISHED_CACHED_TOKENS_MAX: usize = 16_384;
3738

3839
pub struct SchedulerOutput {
3940
pub scheduled: Arc<VecDeque<Arc<SequenceGroup>>>,
@@ -57,6 +58,7 @@ pub struct Scheduler {
5758
mamba_state: MambaState,
5859
is_last_prefill: bool,
5960
prefill_chunk_size: usize,
61+
finished_cached_tokens: HashMap<usize, usize>,
6062
}
6163

6264
impl Scheduler {
@@ -84,6 +86,7 @@ impl Scheduler {
8486
mamba_state: MambaState::default(),
8587
is_last_prefill: false,
8688
prefill_chunk_size,
89+
finished_cached_tokens: HashMap::new(),
8790
}
8891
}
8992

@@ -282,6 +285,7 @@ impl Scheduler {
282285
for group in to_free {
283286
for seq in group.get_seqs().values() {
284287
let seq_id = seq.deref().get_id();
288+
self.remember_finished_cached_tokens(seq_id, seq.deref().get_num_cached_tokens());
285289
let full_blocks = seq.deref().get_len() / self.block_engine.get_block_size();
286290
let block_id = self
287291
.block_engine
@@ -297,6 +301,32 @@ impl Scheduler {
297301
released_ids
298302
}
299303

304+
pub fn get_num_cached_tokens_for_seq(&self, seq_id: usize) -> Option<usize> {
305+
self.running
306+
.iter()
307+
.chain(self.waiting.iter())
308+
.chain(self.swapped_out.iter())
309+
.find_map(|group| {
310+
group
311+
.get_seqs()
312+
.values()
313+
.find(|seq| seq.deref().get_id() == seq_id)
314+
.map(|seq| seq.deref().get_num_cached_tokens())
315+
})
316+
.or_else(|| self.finished_cached_tokens.get(&seq_id).copied())
317+
}
318+
319+
fn remember_finished_cached_tokens(&mut self, seq_id: usize, num_cached_tokens: usize) {
320+
self.finished_cached_tokens
321+
.insert(seq_id, num_cached_tokens);
322+
while self.finished_cached_tokens.len() > FINISHED_CACHED_TOKENS_MAX {
323+
let Some(oldest_seq_id) = self.finished_cached_tokens.keys().min().copied() else {
324+
break;
325+
};
326+
self.finished_cached_tokens.remove(&oldest_seq_id);
327+
}
328+
}
329+
300330
pub fn prefix_cache_enabled(&self) -> bool {
301331
self.block_engine.prefix_cache_enabled()
302332
}

0 commit comments

Comments
 (0)