《互联网产品的 AI 架构·推理性能与成本工程》1.2 吞吐、并发与 goodput

看板写着 40 QPS,用户却在超时:吞吐、并发与有效吞吐回答的是三个不同问题。本文给出 goodput 的 SLO 与窗口口径,用确定性批调度器展示并发饱和、批处理对 p95 的影响,以及吞吐几乎不变而 goodput 从 100% 跌到 28% 的取舍。

40 QPS 的看板与一百个超时用户

「回声」的推荐文案服务把压测报告发到群里:峰值吞吐 40 QPS,平均延迟 300ms,看起来可以上线。上线第二天,客服收到一百多条“点了没反应”。值班同学把并发从 4 调到 8,吞吐只从 33 涨到 37,P95 反而没降。

问题不在压测造假,而在压测只回答了“系统每秒能完成多少请求”,没有回答“其中多少请求在用户能接受的时间内完成”。40 QPS 里可以有 30 个请求等了两秒,用户看到的就是超时。吞吐是容量视角,goodput 才是体验视角。

本节把三个概念分开:并发是瞬时在途数,吞吐是单位时间完成数,goodput 是在延迟 SLO 内完成的请求数。然后证明两件事:并发增加带来的吞吐增量会递减;批处理可以让吞吐几乎不变,却让 goodput 大幅下降。文中数字来自 ai-lab 的确定性调度器,标注为“示例样本上的运行结果”,不是生产实测。

三个概念:并发、吞吐与 goodput

概念定义单位回答的问题
并发某一时刻在途的请求数个系统同时压了多少活
吞吐统计窗口内完成的请求数除以窗口秒数req/s系统每秒能交多少活
goodput统计窗口内满足延迟 SLO 的完成数除以窗口秒数req/s每秒有多少活是“可用”的
goodput 比例满足 SLO 的完成数除以总完成数%交付里有多少比例达标

goodput 必须同时带两个参数:SLO 和统计窗口。脱离 SLO 的“goodput 95%”没有意义,因为把 SLO 从 300ms 改成 3 秒,同一个系统可以从 28% 变成 100%。脱离窗口的吞吐也没有意义,因为把窗口拉长到一天,任何系统都能算出漂亮的平均值。

吞吐与并发之间有一个恒等关系,可以直接当一致性检查:

并发 ≈ 吞吐 × 平均延迟

如果看板显示 100 个并发、40 req/s,那么隐含的平均延迟是 2.5 秒。此时若另一个面板说平均延迟 300ms,两个数字必有一个口径错了:要么并发是连接数不是在途请求,要么吞吐窗口与延迟窗口不是同一段。

并发增加,吞吐并不会线性增长

直觉上,并发翻倍,吞吐也翻倍。真实系统里,这个关系很快就会弯掉。原因不神秘:请求共享同一批 worker、同一块 GPU、同一条下游连接。并发低时,加并发能把空闲资源用起来;并发高时,多出来的请求只能排队,吞吐趋近服务能力上限,延迟继续上涨。

用固定到达序列做一次扫描,可以把这个弯点找出来。示例样本是 60 个请求、每 25ms 到达一个、每个请求输出 24 token,服务参数固定,SLO 取 300ms。结果如下:

workers吞吐 req/sP50P95峰值并发goodput 比例
18.622755ms5212ms485.00%
217.121040ms1964ms3610.00%
433.06228ms340ms1280.00%
837.71116ms116ms5100.00%

从 1 到 2,吞吐翻了近一倍;从 4 到 8,只涨了 14%。8 个 worker 时吞吐已经接近到达率上限(每 25ms 一个请求 = 40 req/s),再加 worker 也不会更快,只会让空闲更多。这就是“吞吐与并发的非线性”:容量规划要看拐点,而不是看单次翻倍的斜率。

注意 goodput 比例的变化比吞吐更剧烈:1 个 worker 时只有 5% 的请求在 300ms 内完成,8 个 worker 时才到 100%。如果只看吞吐,2 个 worker 的 17 req/s 似乎“也有产出”;从体验看,其中九成请求已经不可用。

批处理:吞吐先升,p95 后升

批处理是推理服务最常用的吞吐手段:把多个请求凑成一批,一次前向算完。它能提高吞吐,因为固定开销被摊薄;但它也会抬高每个请求的等待与单步耗时。用同一个调度器扫描批大小,服务参数为 4 个 worker、400ms 攒批窗口、SLO 300ms:

maxBatch吞吐 req/sP50P95goodput req/sgoodput 比例
133.06228ms340ms26.4580.00%
237.57122ms147ms37.57100.00%
437.29159ms209ms37.29100.00%
837.29233ms333ms28.5976.67%
1636.21381ms556ms10.2628.33%

这张表最值得看的是最后两行。批大小从 8 到 16,吞吐只从 37.29 降到 36.21,降幅 2.9%,看起来“几乎没变”;goodput 却从 76.67% 跌到 28.33%,可用吞吐从 28.59 掉到 10.26 req/s,跌了 64%。如果容量评审只看吞吐,会得出“16 也能用”的结论;从用户体验看,七成请求已经超时。

拐点在批大小 2:吞吐从 33.06 升到 37.57,P95 反而从 340ms 降到 147ms。原因是适度攒批提高了服务能力,把排队消化掉了。越过拐点后,批内等待与单步耗时开始主导,P95 单调上升。批处理不是“越大越好”,而是“先还排队债,再欠延迟债”。

与卷一的衔接

卷一 12.1 的观测字段是单请求粒度:每次请求记录耗时分段、用量与结果。本节把这些记录聚合成系统指标,新增的是 goodput 口径与并发扫描,不重复讲解观测字段本身。

卷一 13.3 的基线用成功率、P95、单次成本等五项指标判断“能不能上线”。本节补上它没有覆盖的一维:同样 100% 成功,系统可能只有 28% 的请求在 SLO 内。成功率回答“有没有结果”,goodput 回答“结果是否及时”。

实现

下面的模块包含三部分:确定性批调度器、goodput 汇总、峰值并发计算。输入是固定请求序列与固定服务参数,输出可以逐条复算,不依赖随机数或真实模型。

// file: src/perf/goodput.ts
// tier: L1
// verify: node --test test/perf/goodput.test.ts

import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";

/** 压测侧的一条请求:到达时刻与输出长度都由负载模型决定。 */
export interface LoadRequest {
  requestId: string;
  arriveAtMs: number;
  outputTokens: number;
}

export interface ServerConfig {
  workers: number;
  /** 每个请求的固定开销(调度、序列化、连接)。 */
  fixedMs: number;
  /** 每输出一个 token 的基础解码耗时。 */
  perTokenMs: number;
  /** 同一批里每多一个请求增加的单步耗时,模拟带宽与注意力竞争。 */
  perSiblingMs: number;
}

export interface BatchPolicy {
  maxBatch: number;
  /** 攒批窗口:从第一个待处理请求起,最多等这么久。 */
  batchWindowMs: number;
}

export interface Completion {
  requestId: string;
  arriveAtMs: number;
  startAtMs: number;
  doneAtMs: number;
  latencyMs: number;
  outputTokens: number;
  batchSize: number;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `goodput: ${message}`);
}

function checkInteger(value: number, label: string, minimum: number): void {
  if (!Number.isInteger(value) || value < minimum) fail(`${label} 必须是不小于 ${minimum} 的整数`);
}

function validateConfig(config: ServerConfig): void {
  checkInteger(config.workers, "workers", 1);
  for (const [label, value] of [
    ["fixedMs", config.fixedMs],
    ["perTokenMs", config.perTokenMs],
    ["perSiblingMs", config.perSiblingMs],
  ] as const) {
    if (!Number.isFinite(value) || value < 0) fail(`${label} 必须是非负有限数`);
  }
}

/**
 * 批服务耗时由三部分组成:固定开销、最长序列的解码、
 * 以及同一批内并发请求带来的额外单步耗时。
 * 用 max 而不是 sum,是因为解码要等最长的那条序列结束。
 */
export function batchServiceMs(
  config: ServerConfig,
  batch: readonly LoadRequest[],
): number {
  if (batch.length === 0) fail("空批次没有服务耗时");
  const longest = Math.max(...batch.map((item) => item.outputTokens));
  return config.fixedMs + config.perTokenMs * longest + config.perSiblingMs * (batch.length - 1);
}

/**
 * 确定性批调度器:固定请求序列 + 固定 worker 数 => 固定完成时刻。
 * 同一个输入重复运行会得到逐条一致的 Completion,便于做可复现基线。
 */
export function simulateBatchScheduler(
  requests: readonly LoadRequest[],
  config: ServerConfig,
  policy: BatchPolicy,
): Completion[] {
  validateConfig(config);
  checkInteger(policy.maxBatch, "maxBatch", 1);
  if (!Number.isFinite(policy.batchWindowMs) || policy.batchWindowMs < 0) {
    fail("batchWindowMs 必须是非负有限数");
  }
  if (requests.length === 0) return [];
  for (const request of requests) {
    if (!Number.isFinite(request.arriveAtMs) || request.arriveAtMs < 0) {
      fail(`${request.requestId}: arriveAtMs 必须是非负有限数`);
    }
    checkInteger(request.outputTokens, `${request.requestId}.outputTokens`, 1);
  }

  const pending = [...requests].sort(
    (left, right) => left.arriveAtMs - right.arriveAtMs || left.requestId.localeCompare(right.requestId),
  );
  const workerFreeAt = Array.from({ length: config.workers }, () => 0);
  const completions: Completion[] = [];
  let cursor = 0;

  while (cursor < pending.length) {
    const openAt = pending[cursor].arriveAtMs;
    const deadline = openAt + policy.batchWindowMs;
    const batch: LoadRequest[] = [];
    while (
      cursor < pending.length &&
      batch.length < policy.maxBatch &&
      (batch.length === 0 || pending[cursor].arriveAtMs <= deadline)
    ) {
      batch.push(pending[cursor]);
      cursor += 1;
    }

    let worker = 0;
    for (let index = 1; index < workerFreeAt.length; index += 1) {
      if (workerFreeAt[index] < workerFreeAt[worker]) worker = index;
    }
    const closeAt = batch[batch.length - 1].arriveAtMs;
    const startAt = Math.max(closeAt, workerFreeAt[worker]);
    const doneAt = startAt + batchServiceMs(config, batch);
    workerFreeAt[worker] = doneAt;

    for (const request of batch) {
      completions.push({
        requestId: request.requestId,
        arriveAtMs: request.arriveAtMs,
        startAtMs: startAt,
        doneAtMs: doneAt,
        latencyMs: doneAt - request.arriveAtMs,
        outputTokens: request.outputTokens,
        batchSize: batch.length,
      });
    }
  }

  return completions.sort(
    (left, right) => left.arriveAtMs - right.arriveAtMs || left.requestId.localeCompare(right.requestId),
  );
}

export interface RunSummary {
  completed: number;
  windowMs: number;
  throughputRps: number;
  tokenThroughput: number;
  metSlo: number;
  goodputRps: number;
  /** 满足延迟 SLO 的请求比例,这才是“有效吞吐”的可比口径。 */
  goodputRatio: number;
  p50LatencyMs: number;
  p95LatencyMs: number;
  peakConcurrency: number;
}

/** 峰值并发:到达 +1、完成 -1;同一时刻先算完成,避免把交接算成两倍。 */
export function peakConcurrency(completions: readonly Completion[]): number {
  const events: Array<{ atMs: number; delta: number }> = [];
  for (const item of completions) {
    events.push({ atMs: item.arriveAtMs, delta: 1 });
    events.push({ atMs: item.doneAtMs, delta: -1 });
  }
  events.sort((left, right) => left.atMs - right.atMs || left.delta - right.delta);
  let current = 0;
  let peak = 0;
  for (const event of events) {
    current += event.delta;
    peak = Math.max(peak, current);
  }
  return peak;
}

/**
 * 统计窗口取「最早到达」到「最晚完成」。
 * 吞吐与 goodput 必须共享同一个窗口,否则两个数字不可比。
 */
export function summarizeRun(completions: readonly Completion[], sloMs: number): RunSummary {
  if (completions.length === 0) fail("没有完成记录,无法汇总");
  if (!Number.isFinite(sloMs) || sloMs <= 0) fail("sloMs 必须是正有限数");
  const firstArrival = Math.min(...completions.map((item) => item.arriveAtMs));
  const lastDone = Math.max(...completions.map((item) => item.doneAtMs));
  const windowMs = Math.max(1, lastDone - firstArrival);
  const met = completions.filter((item) => item.latencyMs <= sloMs);
  const tokens = completions.reduce((sum, item) => sum + item.outputTokens, 0);
  return {
    completed: completions.length,
    windowMs,
    throughputRps: (completions.length * 1000) / windowMs,
    tokenThroughput: (tokens * 1000) / windowMs,
    metSlo: met.length,
    goodputRps: (met.length * 1000) / windowMs,
    goodputRatio: met.length / completions.length,
    p50LatencyMs: percentile(completions.map((item) => item.latencyMs), 0.5),
    p95LatencyMs: percentile(completions.map((item) => item.latencyMs), 0.95),
    peakConcurrency: peakConcurrency(completions),
  };
}

simulateBatchScheduler 的批关闭条件是“攒满 maxBatch 或到达攒批窗口”,两者取先到。服务耗时用批内最长输出而不是总和,因为解码要等最长的那条序列结束;perSiblingMs 则模拟同一批内并发请求对带宽与注意力的竞争。

summarizeRun 强制吞吐与 goodput 共享同一个窗口。它不提供“只算成功请求”的选项,因为那会把失败请求从分母里删掉,得到一个永远好看的数字。

测试与示例样本结果

测试覆盖批服务耗时、确定性重复、并发饱和、批大小拐点、峰值并发与非法配置。执行:

node --test test/perf/goodput.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 140.059458

并发扫描的断言不是“workers 翻倍吞吐就翻倍”,而是三条更弱但更真实的性质:吞吐不下降、P95 不上升、goodput 比例不下降,以及“第一次翻倍的吞吐增量大于最后一次”。批大小测试则先找到 P95 最低的拐点,再断言拐点之后 P95 不再改善。这样即使换一台机器或换一组参数,测试仍然成立。

ADR-1.2:以 goodput 而不是吞吐作为容量门禁

  • 决策:容量评审的主指标是“在 SLO 内的 goodput 比例”,吞吐只作为辅助;示例样本上选择 maxBatch=2,因为它在 100% goodput 下给出最低 P95。
  • 备选一:以峰值吞吐为门禁。被否,因为 16 批的吞吐只低 2.9%,goodput 却只剩 28.33%。
  • 备选二:以平均延迟为门禁。被否,因为平均值无法约束 p95,也无法表达“多少请求达标”。
  • 后果:压测必须声明 SLO 与统计窗口;报告必须同时给出吞吐与 goodput,缺一不可。

小结

并发是瞬时在途数,吞吐是单位时间完成数,goodput 是单位时间内满足 SLO 的完成数。并发增加带来的吞吐增量会递减;批处理先靠摊薄固定开销提高吞吐,越过拐点后开始用 P95 换吞吐。最危险的区间不是吞吐下降,而是吞吐几乎不变、goodput 已经崩塌。

下一节把这套口径固化成可复现的压测基线:确定性 mock 推理器、三种负载模型,以及一份可以逐字段比对的报告。

阅读导航:上一节:1.1 首字、单 token 与整体延迟 · 下一节:1.3 压测基线与负载模型 。

常见错误与边界

  • 只报吞吐不报 SLO。吞吐回答“能交多少”,不回答“交得及不及时”。
  • goodput 不带统计窗口。窗口一换,数字就换,无法比较。
  • 把连接数当并发。连接可能空闲,在途请求才是真实压力。
  • 认为并发翻倍吞吐就翻倍。超过服务能力后,多出来的并发只会排队。
  • 用平均延迟判断批大小。平均值会把批内长尾抹平,P95 才能看到拐点。
  • 看到吞吐没降就认为批大小安全。goodput 可能已经跌掉一大半。
  • 把失败请求从 goodput 分母里删掉。分母少了,比例自然好看。
  • 用不同窗口分别算吞吐与延迟。两个数字不可比,还会互相矛盾。
  • 在示例样本上选出的参数直接当生产结论。参数要随模型、硬件与流量复测。
  • 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查