三份无法比较的压测报告
「回声」准备给推理服务扩容,三个小组各交了一份压测报告。A 组说峰值 42 QPS,B 组说 P95 只有 180ms,C 组说 8 个 worker 足够。三份报告都用了真实模型,也都跑了两小时,但放在一起就无法比较:A 组的请求平均输出 20 token,B 组全是短问答,C 组的到达间隔是固定 200ms,没有任何突发。
结论不是“谁的压测不准”,而是“负载模型没有写清楚”。没有固定的请求构成、到达过程和输出长度,任何性能数字都只是一次观测,不能当基线,更不能当回归门禁。
本节给出一种可复现的做法:确定性 mock 推理器 + 三种固定负载 + 一份不含时间戳的基线报告。同一份代码在任何机器上都会得到同一组数字与同一个指纹。文中数字来自 ai-lab 的示例样本运行结果,不是生产实测,也不代表任何真实模型的性能。
可复现基线的四个条件
| 条件 | 做法 | 不满足时的后果 |
|---|---|---|
| 服务时间可复算 | 由 token 数与固定参数算出,不读随机数 | 同一份代码跑出两组数字,无法判断是代码还是环境变了 |
| 到达过程固定 | 用显式时间表构造,不用随机泊松流 | 突发与长尾不可重复,问题无法稳定复现 |
| 预热策略明确 | 基线默认预热,冷启动单独度量 | 一次性冷启动污染 P95,容量结论失真 |
| 报告可指纹化 | 不含时间戳,按字段序列化后取哈希 | 无法用一行命令证明“这次和上次是同一份基线” |
这四条里最容易忽略的是预热。60 条样本里如果有 3 条冷启动,P95 只取第 57 个值,冷启动可能完全不出现在 P95 里,却真实影响了排队。把冷启动混进容量基线,会让“稳态容量”这个结论既不干净也不可复现。因此本节基线默认预热,冷启动由 1.1 的时间线单独度量。
三种负载各自压什么
负载不是越多越好,而是要覆盖三种不同的失败模式。
| 负载 | 构造 | 主要压什么 |
|---|---|---|
| 稳定 | 60 个请求,每 120ms 到达,输出 24 到 40 token | 容量余量与稳态排队 |
| 突发 | 前 15 个每 200ms,随后 30 个每 25ms,最后 15 个每 200ms | 队列积压与恢复速度 |
| 长尾 | 60 个请求每 100ms,每十条有一条 400 token 输出 | 长请求占住 worker 的尾部延迟 |
稳定负载回答“平时够不够用”;突发负载回答“尖峰来了会排多久”;长尾负载回答“少数重请求会不会拖垮大多数轻请求”。三种负载的吞吐可能很接近,但延迟与 goodput 会差很多,这正是只看吞吐会误判的原因。
mock 推理器:服务时间只由 token 数决定
mock 推理器不做真实矩阵运算,只按下面的公式生成时间线:
预填充 = baseMs + round(inputTokens × prefillMsPerToken)
首 token = 就绪时刻 + 预填充
第 i 个 token = 首 token + i × round(decodeMsPerToken)
完成时刻 = 最后一个 token 的时刻
每个 worker 按 FIFO 接单,取最早空闲的 worker;请求到达时若所有 worker 都忙,就排队到最早空闲时刻。预热开启时,worker 直接进入可服务状态;关闭时,每个 worker 的第一条请求多付一次冷启动。
这个模型故意很简单。它不模拟 KV cache、连续批处理或 GPU 利用率曲线,因此不能用来预测真实模型的绝对吞吐;它的用途是固定变量,让“代码或配置变了”与“环境变了”可以被区分开。绝对性能要用真实模型压测,回归比较用这份确定性基线。
报告字段与口径
| 字段 | 口径 | 用途 |
|---|---|---|
| windowMs | 最早到达到最后完成 | 吞吐与 goodput 的共同窗口 |
| throughputRps | 完成数除以窗口秒数 | 容量视角 |
| ttftP50/P95/Max | 接单到首 token | 首字体验与罕见冷启动 |
| e2eP50/P95/Max | 接单到末 token | 整体体验 |
| queueP95Ms | 分配 worker 前的等待 | 排队是否成为瓶颈 |
| goodputRatio | 满足 SLO 的完成数除以总数 | 有效容量 |
| coldRequests | 冷启动请求数 | 预热是否生效 |
| fingerprint | 配置与三份负载结果的 SHA-256 前 16 位 | 可复现证据 |
P95 与 Max 要同时报。示例样本只有 60 条,P95 取第 57 个值,最多只能漏掉 3 条尾部样本;一次罕见的冷启动或一次长请求,很可能只出现在 Max 里。只报 P95 会让“偶发但致命”的问题消失,只报 Max 又会让单次抖动掩盖整体分布。
示例样本上的运行结果
在实验工程根目录执行测试后,三种负载得到:
| 负载 | 窗口 | 吞吐 | TTFT P50/P95/Max | E2E P50/P95/Max | 排队 P95 | goodput |
|---|---|---|---|---|---|---|
| 稳定 | 7329ms | 8.19 req/s | 93 / 111 / 111ms | 217 / 255 / 267ms | 0ms | 100.00% |
| 突发 | 6799ms | 8.82 req/s | 376 / 1337 / 1489ms | 508 / 1471 / 1581ms | 1256ms | 71.67% |
| 长尾 | 7437ms | 8.07 req/s | 507 / 1054 / 1073ms | 779 / 1957 / 2437ms | 965ms | 70.00% |
这组数字最值得注意的是:三种负载的吞吐都在 8 req/s 左右,彼此只差 9%,但 goodput 从 100% 掉到 70%,E2E P95 从 255ms 涨到 1957ms。如果报告中只写吞吐,三种负载看起来“差不多”;加上排队与 goodput,才能看出突发负载输在队列,长尾负载输在少数重请求占住 worker。
稳定负载的排队 P95 是 0ms,说明预热后容量有余量;突发负载的排队 P95 是 1256ms,说明尖峰期间请求主要在等 worker,而不是在等模型;长尾负载的 P95 排队是 965ms,E2E Max 2437ms,说明一条 400 token 的请求会把后面的轻请求一起拖慢。
这份示例报告的指纹是 80e439eadce01bb4。同一份配置重复运行会得到完全相同的报告与指纹;改 worker 数或改 SLO,指纹就会变化。
基线怎么当门禁
基线本身不是结论,而是一把尺子。要让它变成门禁,需要把“可复现”与“可接受”分开:指纹负责前者,阈值负责后者。
ADR-1.3:以确定性基线做回归门禁,不用它预测绝对容量
- 决策:每次改动调度、批处理或输出长度后,重跑三种负载;指纹必须与登记的基线一致,稳定负载 goodput ≥ 95%,突发与长尾 ≥ 65%,且 E2E P95 相对基线漂移不超过 10%。
- 备选一:用真实模型压测做门禁。被否,因为模型版本、GPU 温度与并发噪声会让结果不可复现,回归无法归因。
- 备选二:只比吞吐。被否,因为示例中三种负载吞吐只差 9%,goodput 却差 30 个百分点。
- 后果:mock 参数必须与真实模型的关键比例保持同向;换模型或换硬件后要重新登记基线,不能拿旧指纹当永久标准。
阈值不是从示例样本反推出来的“刚好通过”。示例中突发 71.67%、长尾 70.00%,阈值定在 65% 是留出波动空间;如果团队认为 70% 不可接受,应当先改负载或改容量,而不是把阈值调到 70.01%。
实现
下面的模块把负载构造、mock 推理、报告汇总与指纹计算收在一起。它只依赖 node:crypto 与前两节的模块,不访问网络。
// file: src/perf/load-model.ts
// tier: L1
// verify: node --test test/perf/load-model.test.ts
import { createHash } from "node:crypto";
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
import { decomposeTimeline, summarizeLatency } from "./metrics.ts";
import type { LatencyBreakdown, StreamTimeline } from "./metrics.ts";
import { summarizeRun } from "./goodput.ts";
import type { Completion } from "./goodput.ts";
export type LoadKind = "steady" | "burst" | "longtail";
/** 确定性 mock 推理器:服务时间只由 token 数与固定参数决定,不读随机数。 */
export interface MockModelConfig {
workers: number;
baseMs: number;
/** 预填充每 token 的毫秒数,落库前取整。 */
prefillMsPerToken: number;
/** 解码每 token 的毫秒数,落库前取整。 */
decodeMsPerToken: number;
coldStartMs: number;
/** 基线默认预热:冷启动由 1.1 单独度量,不要混进容量基线。 */
warmup: boolean;
}
export const DEFAULT_MOCK_CONFIG: MockModelConfig = {
workers: 3, baseMs: 15, prefillMsPerToken: 0.15,
decodeMsPerToken: 4, coldStartMs: 1200, warmup: true,
};
export interface MockRequest {
requestId: string; arriveAtMs: number; inputTokens: number; outputTokens: number;
}
export interface MockResult {
requestId: string; enqueuedAtMs: number; admittedAtMs: number; readyAtMs: number;
tokenAtMs: number[]; doneAtMs: number; cold: boolean;
}
function fail(message: string): never {
throw new AiError("invalid-request", `load-model: ${message}`);
}
function pad(index: number): string {
return String(index).padStart(3, "0");
}
function standardRequest(prefix: string, index: number, arriveAtMs: number): MockRequest {
return {
requestId: `${prefix}-${pad(index)}`,
arriveAtMs,
inputTokens: 400 + (index % 7) * 40,
outputTokens: 24 + (index % 5) * 4,
};
}
/** 三种负载都是固定构造,不依赖随机数:任何机器都会得到同一组到达时刻。 */
export function buildLoad(kind: LoadKind): MockRequest[] {
if (kind === "steady") {
return Array.from({ length: 60 }, (_, index) => standardRequest("steady", index, index * 120));
}
if (kind === "burst") {
return Array.from({ length: 60 }, (_, index) => {
const arriveAtMs =
index < 15 ? index * 200 : index < 45 ? 3000 + (index - 15) * 25 : 3750 + (index - 45) * 200;
return standardRequest("burst", index, arriveAtMs);
});
}
if (kind === "longtail") {
return Array.from({ length: 60 }, (_, index) => ({
requestId: `longtail-${pad(index)}`,
arriveAtMs: index * 100,
inputTokens: 400 + (index % 4) * 30,
// 每十条里有一条长输出,模拟摘要、代码生成这类重请求。
outputTokens: index % 10 === 0 ? 400 : 24 + (index % 3) * 8,
}));
}
fail(`未知负载 ${String(kind)}`);
}
function validateConfig(config: MockModelConfig): void {
if (!Number.isInteger(config.workers) || config.workers < 1) fail("workers 必须是正整数");
if (typeof config.warmup !== "boolean") fail("warmup 必须是布尔值");
for (const [label, value] of [
["baseMs", config.baseMs], ["prefillMsPerToken", config.prefillMsPerToken],
["decodeMsPerToken", config.decodeMsPerToken], ["coldStartMs", config.coldStartMs],
] as const) {
if (!Number.isFinite(value) || value < 0) fail(`${label} 必须是非负有限数`);
}
}
/**
* 固定 worker 数的 FIFO 模拟。每个 worker 的第一条请求支付冷启动;
* 其余请求的服务时间 = 预填充 + 解码,全部由 token 数算出。
*/
export function simulateMockInference(
requests: readonly MockRequest[],
config: MockModelConfig = DEFAULT_MOCK_CONFIG,
): MockResult[] {
validateConfig(config);
if (requests.length === 0) return [];
for (const request of requests) {
if (!Number.isFinite(request.arriveAtMs) || request.arriveAtMs < 0) {
fail(`${request.requestId}: arriveAtMs 必须是非负有限数`);
}
if (!Number.isInteger(request.inputTokens) || request.inputTokens < 1) {
fail(`${request.requestId}: inputTokens 必须是正整数`);
}
if (!Number.isInteger(request.outputTokens) || request.outputTokens < 1) {
fail(`${request.requestId}: outputTokens 必须是正整数`);
}
}
const pending = [...requests].sort(
(left, right) => left.arriveAtMs - right.arriveAtMs || left.requestId.localeCompare(right.requestId),
);
const workerFreeAt = Array.from({ length: config.workers }, () => 0);
const coldLeft = Array.from({ length: config.workers }, () => (config.warmup ? 0 : 1));
const results: MockResult[] = [];
for (const request of pending) {
let worker = 0;
for (let index = 1; index < workerFreeAt.length; index += 1) {
if (workerFreeAt[index] < workerFreeAt[worker]) worker = index;
}
const admittedAtMs = Math.max(request.arriveAtMs, workerFreeAt[worker]);
const cold = coldLeft[worker] > 0;
if (cold) coldLeft[worker] -= 1;
const readyAtMs = admittedAtMs + (cold ? config.coldStartMs : 0);
const prefillMs = config.baseMs + Math.round(request.inputTokens * config.prefillMsPerToken);
const firstTokenAtMs = readyAtMs + prefillMs;
const tokenAtMs = Array.from(
{ length: request.outputTokens },
(_, index) => firstTokenAtMs + index * Math.round(config.decodeMsPerToken),
);
const doneAtMs = tokenAtMs[tokenAtMs.length - 1];
workerFreeAt[worker] = doneAtMs;
results.push({ requestId: request.requestId, enqueuedAtMs: request.arriveAtMs, admittedAtMs, readyAtMs, tokenAtMs, doneAtMs, cold });
}
return results;
}
export interface LoadReport {
load: LoadKind; requests: number; workers: number; windowMs: number;
throughputRps: number; tokenThroughput: number;
ttftP50Ms: number; ttftP95Ms: number; ttftMaxMs: number; tpotP95Ms: number;
e2eP50Ms: number; e2eP95Ms: number; e2eMaxMs: number; queueP95Ms: number;
metSlo: number; goodputRps: number; goodputRatio: number; coldRequests: number;
}
function toTimeline(result: MockResult): StreamTimeline {
return {
requestId: result.requestId, enqueuedAtMs: result.enqueuedAtMs,
admittedAtMs: result.admittedAtMs, readyAtMs: result.readyAtMs, tokenAtMs: result.tokenAtMs,
};
}
function toCompletion(result: MockResult): Completion {
return {
requestId: result.requestId, arriveAtMs: result.enqueuedAtMs,
startAtMs: result.admittedAtMs, doneAtMs: result.doneAtMs,
latencyMs: result.doneAtMs - result.enqueuedAtMs,
outputTokens: result.tokenAtMs.length, batchSize: 1,
};
}
/** 单次负载的基线行。数字全部由 mock 推理器算出,可直接逐条复算。 */
export function runLoad(
kind: LoadKind,
config: MockModelConfig = DEFAULT_MOCK_CONFIG,
sloMs = 1000,
): LoadReport {
const results = simulateMockInference(buildLoad(kind), config);
const breakdowns: LatencyBreakdown[] = results.map((result) => decomposeTimeline(toTimeline(result)));
const latency = summarizeLatency(breakdowns);
const run = summarizeRun(results.map(toCompletion), sloMs);
return {
load: kind, requests: results.length, workers: config.workers, windowMs: run.windowMs,
throughputRps: run.throughputRps, tokenThroughput: run.tokenThroughput,
ttftP50Ms: latency.ttftP50Ms, ttftP95Ms: latency.ttftP95Ms,
ttftMaxMs: Math.max(...breakdowns.map((item) => item.firstToken.ttftMs)),
tpotP95Ms: latency.tpotP95Ms, e2eP50Ms: latency.e2eP50Ms, e2eP95Ms: latency.e2eP95Ms,
e2eMaxMs: Math.max(...breakdowns.map((item) => item.e2eMs)),
queueP95Ms: percentile(breakdowns.map((item) => item.firstToken.queueMs), 0.95),
metSlo: run.metSlo, goodputRps: run.goodputRps, goodputRatio: run.goodputRatio,
coldRequests: results.filter((item) => item.cold).length,
};
}
export interface BaselineReport {
config: MockModelConfig; sloMs: number; loads: LoadReport[];
/** 相同输入必须得到相同指纹;它是“可复现”的机器可读证据。 */
fingerprint: string;
}
export function fingerprintOf(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
}
/** 一次生成三份负载基线;报告不含时间戳,因此可以逐字段比对。 */
export function buildBaselineReport(
config: MockModelConfig = DEFAULT_MOCK_CONFIG,
sloMs = 1000,
): BaselineReport {
const body = {
config, sloMs,
loads: [runLoad("steady", config, sloMs), runLoad("burst", config, sloMs), runLoad("longtail", config, sloMs)],
};
return { ...body, fingerprint: fingerprintOf(body) };
}
buildLoad 是唯一的负载入口,三种负载都由显式时间表构造,没有任何随机数。simulateMockInference 用最早空闲 worker 接单,保证同一输入得到逐条一致的完成时刻。runLoad 把结果同时喂给 1.1 的时间线拆分与 1.2 的 goodput 汇总,避免两套口径。buildBaselineReport 不含时间戳,因此可以直接逐字段 deepEqual。
测试与示例样本结果
测试覆盖负载构造、确定性、冷启动、三种负载的尾部关系、报告可复现与非法参数。执行:
node --test test/perf/load-model.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 179.885833
测试中有三条关键断言:突发负载的排队 P95 必须高于稳定负载;长尾负载的 E2E P95 必须高于稳定负载;同一配置两次生成的报告必须 deepEqual 且指纹相同。前两条保证负载确实压到了不同的失败模式,最后一条保证基线真的可复现。
小结
可复现基线需要四个条件:服务时间可复算、到达过程固定、预热策略明确、报告可指纹化。三种负载分别压容量余量、队列积压与长尾阻塞;三种负载的吞吐可能几乎一样,goodput 与 P95 却能差出一个量级。报告要同时给出 P95 与 Max,并用指纹证明“这次和上次是同一份基线”。
第一章到此结束。下一章进入调度:当并发超过容量时,队列、优先级与准入如何决定哪些请求先被服务,以及哪些请求应该被明确拒绝。
阅读导航:上一节:1.2 吞吐、并发与 goodput · 下一节:2.1 队列、优先级与准入 。
常见错误与边界
- 压测报告不写负载构成。输出长度与到达过程一变,所有数字都不可比。
- 用随机泊松流做基线。同一份代码每次生成不同请求序列,回归无法归因。
- 把冷启动混进容量基线。一次冷启动造成的排队会污染 P95。
- 只报 P95 不报 Max。样本少时,罕见冷启动可能完全不出现在 P95 里。
- 只比吞吐不比 goodput。示例中吞吐只差 9%,goodput 差 30 个百分点。
- 用真实模型做逐次门禁。模型版本与硬件噪声会让结果无法复现。
- 换模型后沿用旧基线指纹。参数比例已经变了,旧基线不再可比。
- 阈值定成“刚好通过示例”。门禁会退化成装饰,失去拦截能力。
- 把 mock 基线当绝对容量结论。它只用于回归比较,绝对性能要真实压测。
- 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。