性能指标

分析evalscope中有关perf的代码,梳理清楚 TTFT/ TPOT 等性能指标的定义和计算标准

TTFT #

定义:TTFT(time to first token),从用户输入提示词(Prompt)到模型生成第一个输出 Token 的时间,仅统计流式响应。

代码实现:通过异步读取流式 HTTP 响应,在收到首个有效响应 chunk 时用客户端计时器计算 当前时间 - 请求起始时间

# 单请求TTFT的定义: 
# `evalscope/evalscope/perf/utils/benchmark_util.py` 中的 `first_chunk_latency` 

# 平均TTFT= 所有**成功**请求的 `first_chunk_latency` 之和 / 成功请求数量:
self.total_first_chunk_latency += data.first_chunk_latency
avg_first_chunk_latency = _safe_div(self.total_first_chunk_latency, n)

只要首个 SSE JSON 中存在非空 choices,即使内容只是如下也会被记录为 TTFT

{
  "choices": [
    {
      "delta": {
        "role": "assistant"
      }
    }
  ]
}

相关代码:

if choices := data.get('choices'):
	if data.get('object') == 'text_completion':
  	content = choices[0].get('text') or ''
  else:
    delta = choices[0].get('delta', {})
    content = (delta.get('content') or '') + (delta.get('reasoning_content') or '')
  # First token
  if ttft == 0.0:
     ttft = timestamp - st
     output.first_chunk_latency = ttft

如何区分每个请求的TTFT的

class DefaultApiPlugin(ApiPluginBase):
    """Default implementation of API plugin with common HTTP handling methods."""

    def __init__(self, param: Arguments):
        super().__init__(param)

    async def process_request(
        self, client_session: aiohttp.ClientSession, url: str, headers: Dict, body: Dict
    ) -> BenchmarkData:
        """Process the HTTP request and handle the response.

        Args:
            client_session: The aiohttp client session
            url: The request URL
            headers: The request headers
            body: The request body

        Returns:
            BenchmarkData: Aggregated benchmarking data for the request/response.
        """
        headers = {'Content-Type': 'application/json', **headers}
        data = json.dumps(body, ensure_ascii=False)  # serialize to JSON

        output = BenchmarkData() # 当前请求独有
        ttft = 0.0 # 当前请求独有
        generated_text = ''
        st = time.perf_counter() # 当前请求的开始时间
        output.start_time = st
        output.request = data
        most_recent_timestamp = st
        try:
            async with client_session.post(url=url, data=data, headers=headers) as response:
                content_type = response.headers.get('Content-Type', '')
                if response.status == 200:
                    # Handle streaming responses (SSE)
                    if 'text/event-stream' in content_type:
                        handler = StreamedResponseHandler() # 当前响应独有
                        async for chunk_bytes in response.content.iter_any():

                            if not chunk_bytes:
                                continue

                            messages = handler.add_chunk(chunk_bytes)
                            for message in messages:
                                # NOTE: SSE comments (often used as pings) start with
                                # a colon. These are not JSON data payload and should
                                # be skipped.
                                if message.startswith(':'):
                                    continue

                                chunk = message.removeprefix('data:').strip()

                                if chunk != '[DONE]':
                                    timestamp = time.perf_counter()
                                    data = json.loads(chunk)

                                    if choices := data.get('choices'):
                                        if data.get('object') == 'text_completion':
                                            content = choices[0].get('text') or ''
                                        else:
                                            delta = choices[0].get('delta', {})
                                            content = (delta.get('content')
                                                       or '') + (delta.get('reasoning_content') or '')
                                        # First token
                                        if ttft == 0.0:
                                            ttft = timestamp - st
                                            output.first_chunk_latency = ttft

即使并发度是 100,逻辑上也是:

共享一个 ClientSession
├── 请求 A → response A → TTFT A
├── 请求 B → response B → TTFT B
├── 请求 C → response C → TTFT C
└── ...

不会出现请求 A 的首个 SSE chunk 被记到请求 B 上。aiohttp 会把每条 HTTP 响应流交给各自的 ClientResponse 对象。共享 ClientSession 不代表共享响应流,共享ClientSession负责TCP连接池等。EvalScope 的 TTFT 计算通常并不依赖模型返回的:{"id": "chatcmpl-xxx"},也不靠 conversation_idsession_id 来匹配响应。关联关系是在客户端调用栈中天然建立的。

TPOT #

定义:TPOT(time per output token),解码(Decoder)阶段生成每个输出 Token 的平均时间(不包括首个 Token),每个输出 Token 的平均耗时,仅统计流式响应。

代码实现:TPOT = E2E Latency − TTFT / CompletionTokens − 1,分母减 1,是因为首 Token 的耗时已经计入 TTFT,不再算作后续解码 Token。是一个平均值。

# 单请求TPOP的定义:`evalscope/evalscope/perf/utils/benchmark_util.py` 中的 `time_per_output_token` 

    def finalize(self, api_plugin) -> None:
        """Parse token counts and compute all derived timing metrics.

        Must be called after the response is fully received.  Idempotent:
        token counts already present will not be re-parsed.
        """
        if self.prompt_tokens is None or self.completion_tokens is None:
            self.prompt_tokens, self.completion_tokens = api_plugin.parse_responses(
                self.response_messages, request=self.request
            )

        # tpot = (latency - ttft) / (output_len - 1)
        if self.completion_tokens and self.completion_tokens > 1:
            self.time_per_output_token = ((self.query_latency - self.first_chunk_latency) /
                                          (self.completion_tokens - 1))

output.completed_time = time.perf_counter()
output.query_latency = output.completed_time - output.start_time

# 然后可以进一步算个平均

EvalScope 记录 TTFT 的条件并不是“收到第一个真实Token”,而是第一个chunk,如果空 chunk 与首 Token 之间有明显间隔,EvalScope 的 TPOT 会被轻微高估。如果第一个chunk包含了多个token,EvalScope 的 TPOT 会偏小。

completion_tokens 有两种来源,优先使用服务端返回的 usage.completion_tokens;服务端不返回时,才用本地 tokenizer 对完整输出文本重新分词统计。

OTPS #

定义:OTPS(Output Tokens/s),每秒输出 Token 数(Output Tokens/s),只计算生成内容。

代码实现:OTPS = 成功请求的 completion_tokens 总和 / (最晚请求开始时间 - 最早请求完成时间)

def to_result(self) -> 'BenchmarkMetrics':
    t = self.wall_time

    def _safe_div(numerator, denominator, default=-1):
        return numerator / denominator if denominator else default

    try:
        ...
        avg_output_token_throughput = _safe_div(self.total_completion_tokens, t)

OTPS / Output Token Throughput 计算是全压测窗口的聚合吞吐,不是逐请求 output_tokens / latency 后再求平均

wall_time,表示 现实世界中钟表经过的时间(墙上时钟实际走过了多久),英文叫 wall-clock time,简称 wall_time

wall_time要取整个压测中所有请求共同覆盖的完整时间区间min(start_time)第一个正式请求开始的时刻max(completed_time)最后一个请求完成的时刻


单请求 OTPS ≈ 1 / TPOT

TPS #

定义:TPM(tokens per second),模型在一秒内能够处理的 Token 总数。服务整体吞吐量指标,会随并发数增加而变化。

代码实现:分为input/output/total,代码中对应的是 Token Throughput,然后除以时间单位,得到TPS,单位为 tok/s

# 单请求`token throughput`的定义`total_prompt_tokens `/`total_completion_tokens`

def update(self, data: BenchmarkData, api_plugin) -> None:
        """Ingest one completed request and update all running totals."""
        if data.is_warmup:
            return  # Warmup requests are excluded from all metrics

        self.n_total += 1

        if data.success:
            self.n_success += 1
            data.finalize(api_plugin)

            self.total_latency += data.query_latency
            self.total_first_chunk_latency += data.first_chunk_latency
            self.total_prompt_tokens += data.prompt_tokens
            self.total_completion_tokens += data.completion_tokens

def _update_wall_time(self, data: BenchmarkData) -> None:
        """Expand the wall-clock window to cover *data*'s lifecycle."""
        if self._wall_start is None:
            self._wall_start = data.start_time
        else:
            self._wall_start = min(self._wall_start, data.start_time)

        if self._wall_end is None:
            self._wall_end = data.completed_time
        else:
            self._wall_end = max(self._wall_end, data.completed_time)

@property
def wall_time(self) -> float:
    if self._wall_start is not None and self._wall_end is not None:
        return max(self._wall_end - self._wall_start, 0.0) # 这里的 max 是防御性保护,保证 wall_time 不会是负数
    return 1.0

avg_input_token_throughput = _safe_div(self.total_prompt_tokens, self.total_first_chunk_latency)
avg_output_token_throughput = _safe_div(self.total_completion_tokens, t)
avg_total_token_throughput = _safe_div(self.total_prompt_tokens + self.total_completion_tokens, t)
Warmup 请求就是正式统计前,先发给服务端的一小批预热请求,把冷启动性能稳定运行性能分开