|
| 1 | +# Copyright 2026 The Kubernetes Authors. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | +"""Regression test for issue #364. |
| 15 | +
|
| 16 | +Pre-#410, output_tokens was effectively len(response_chunks): each SSE chunk |
| 17 | +was charged as one token regardless of how many tokens its delta contained. |
| 18 | +For servers that batch multiple tokens per chunk (typical for vLLM under |
| 19 | +load), this undercounted output_tokens by the average chunk size, producing |
| 20 | +the 2x TPOT / 0.5x OTPS gap reported against `vllm bench`. |
| 21 | +
|
| 22 | +This test exercises the full pipeline (raw SSE bytes -> parse_sse_stream -> |
| 23 | +ChatCompletionAPIData.process_response -> summarize_requests) with |
| 24 | +multi-token chunks and asserts that output_tokens reflects actual token |
| 25 | +counts, not chunk counts. |
| 26 | +""" |
| 27 | + |
| 28 | +from typing import AsyncGenerator, List, cast |
| 29 | +from unittest.mock import MagicMock |
| 30 | + |
| 31 | +import pytest |
| 32 | +from aiohttp import ClientResponse |
| 33 | + |
| 34 | +from inference_perf.apis.base import ( |
| 35 | + RequestLifecycleMetric, |
| 36 | + StreamedInferenceResponseInfo, |
| 37 | +) |
| 38 | +from inference_perf.apis.chat import ChatCompletionAPIData, ChatMessage |
| 39 | +from inference_perf.config import APIConfig, APIType |
| 40 | +from inference_perf.reportgen.base import summarize_requests |
| 41 | + |
| 42 | + |
| 43 | +class FakeStreamingResponse: |
| 44 | + """Minimal aiohttp ClientResponse stand-in that yields preset SSE bytes.""" |
| 45 | + |
| 46 | + def __init__(self, chunks: List[bytes]) -> None: |
| 47 | + self.status = 200 |
| 48 | + self.headers = {"content-type": "text/event-stream"} |
| 49 | + self._chunks = chunks |
| 50 | + self.content = self._make_content() |
| 51 | + |
| 52 | + def _make_content(self) -> MagicMock: |
| 53 | + content = MagicMock() |
| 54 | + |
| 55 | + async def iter_any() -> AsyncGenerator[bytes, None]: |
| 56 | + for chunk in self._chunks: |
| 57 | + yield chunk |
| 58 | + |
| 59 | + content.iter_any = iter_any |
| 60 | + return content |
| 61 | + |
| 62 | + |
| 63 | +def _build_sse_stream(chunk_texts: List[str], completion_tokens: int) -> bytes: |
| 64 | + """Build an OpenAI-style SSE byte stream with one delta per chunk and a |
| 65 | + trailing usage chunk (as emitted with stream_options.include_usage=true). |
| 66 | + """ |
| 67 | + parts = [f'data: {{"choices":[{{"delta":{{"content":"{text}"}}}}]}}\n\n'.encode() for text in chunk_texts] |
| 68 | + parts.append(f'data: {{"choices":[],"usage":{{"completion_tokens":{completion_tokens}}}}}\n\n'.encode()) |
| 69 | + parts.append(b"data: [DONE]\n\n") |
| 70 | + return b"".join(parts) |
| 71 | + |
| 72 | + |
| 73 | +@pytest.mark.asyncio |
| 74 | +async def test_multi_token_chunks_count_tokens_not_chunks() -> None: |
| 75 | + """Each chunk's delta carries multiple tokens; output_tokens must equal |
| 76 | + the per-chunk token sum, not the chunk count. |
| 77 | +
|
| 78 | + Pre-#410: output_tokens == len(chunks) == 4. TPOT computed against 4 |
| 79 | + "tokens" overstates per-token latency by ~2x for 2-tokens-per-chunk |
| 80 | + streams. Post-#410 the per-chunk tokenizer count drives output_tokens, |
| 81 | + so output_tokens == 8. |
| 82 | + """ |
| 83 | + chunk_texts = ["aa bb", "cc dd", "ee ff", "gg hh"] |
| 84 | + sse = _build_sse_stream(chunk_texts, completion_tokens=8) |
| 85 | + response = FakeStreamingResponse([sse]) |
| 86 | + |
| 87 | + tokenizer = MagicMock() |
| 88 | + tokenizer.count_tokens = MagicMock(side_effect=lambda text: len(text.split())) |
| 89 | + |
| 90 | + config = APIConfig(type=APIType.Chat, streaming=True) |
| 91 | + data = ChatCompletionAPIData(messages=[ChatMessage(role="user", content="prompt")], max_tokens=100) |
| 92 | + |
| 93 | + info = await data.process_response(cast(ClientResponse, response), config, tokenizer) |
| 94 | + |
| 95 | + assert isinstance(info.response_info, StreamedInferenceResponseInfo) |
| 96 | + assert info.response_info.server_usage == {"completion_tokens": 8} |
| 97 | + assert len(info.response_info.response_chunks) == len(chunk_texts) |
| 98 | + |
| 99 | + metric = RequestLifecycleMetric( |
| 100 | + scheduled_time=0.0, start_time=0.0, end_time=10.0, request_data="prompt", info=info, error=None |
| 101 | + ) |
| 102 | + summarize_requests([metric], [50], tokenizer=tokenizer) |
| 103 | + |
| 104 | + assert isinstance(metric.info.response_info, StreamedInferenceResponseInfo) |
| 105 | + # Sum of per-chunk tokens, NOT chunk count. This is the assertion that |
| 106 | + # would fail under the pre-#410 implementation. |
| 107 | + assert metric.info.response_info.output_tokens == 8 |
| 108 | + assert metric.info.response_info.output_tokens != len(chunk_texts) |
| 109 | + # Per-token timestamps expanded so TPOT/ITL distributions are accurate. |
| 110 | + assert len(metric.info.response_info.output_token_times) == 8 |
| 111 | + |
| 112 | + |
| 113 | +@pytest.mark.asyncio |
| 114 | +async def test_multi_token_chunks_match_server_usage() -> None: |
| 115 | + """When server_usage.completion_tokens is present, the client-derived |
| 116 | + count and the server-reported count should agree for a stream where the |
| 117 | + tokenizer matches the server. This is what eliminates the gap against |
| 118 | + `vllm bench`, which reads completion_tokens directly. |
| 119 | + """ |
| 120 | + # Simulate a long stream of multi-token chunks - the regime where #364 |
| 121 | + # originally surfaced (1k input, 8k output, multi-token chunks). |
| 122 | + tokens_per_chunk = 4 |
| 123 | + num_chunks = 64 |
| 124 | + chunk_texts = [" ".join([f"t{i}_{j}" for j in range(tokens_per_chunk)]) for i in range(num_chunks)] |
| 125 | + expected_total = tokens_per_chunk * num_chunks |
| 126 | + |
| 127 | + sse = _build_sse_stream(chunk_texts, completion_tokens=expected_total) |
| 128 | + response = FakeStreamingResponse([sse]) |
| 129 | + |
| 130 | + tokenizer = MagicMock() |
| 131 | + tokenizer.count_tokens = MagicMock(side_effect=lambda text: len(text.split())) |
| 132 | + |
| 133 | + config = APIConfig(type=APIType.Chat, streaming=True) |
| 134 | + data = ChatCompletionAPIData(messages=[ChatMessage(role="user", content="prompt")], max_tokens=expected_total) |
| 135 | + |
| 136 | + info = await data.process_response(cast(ClientResponse, response), config, tokenizer) |
| 137 | + metric = RequestLifecycleMetric( |
| 138 | + scheduled_time=0.0, start_time=0.0, end_time=10.0, request_data="prompt", info=info, error=None |
| 139 | + ) |
| 140 | + result = summarize_requests([metric], [50], tokenizer=tokenizer) |
| 141 | + |
| 142 | + assert isinstance(metric.info.response_info, StreamedInferenceResponseInfo) |
| 143 | + assert metric.info.response_info.output_tokens == expected_total |
| 144 | + assert metric.info.response_info.server_usage == {"completion_tokens": expected_total} |
| 145 | + |
| 146 | + # No mismatches between client tokenization and server usage when the |
| 147 | + # tokenizer matches; this is the normal-operation expectation. |
| 148 | + assert result is not None |
| 149 | + assert result.successes["token_count_mismatches"] == 0 |
0 commit comments