给一个客户开了批量摘要接口:几千份合同一次提交,max_tokens=1200,并发 8。第三天,另一个客户的交互问答 P99 从 1.8 s 涨到 5.2 s。第一反应是加卡,加了四张 A100,P99 降到 4.6 s——问题没解决,只是变贵了。
监控当时很干净:GPU 平均利用率从 61 % 涨到 66 %。这个数字骗了两天——"整卡有 66 % 的时间在忙",不等于"在为交互请求忙"。一个 32k 输入的 prefill 跑 400 ms,这 400 ms 里 decode 队列全部在等,而卡是 100 % 忙的。
噪声邻居在推理服务里不是"谁占的 CPU 多",而是**"谁让别人的尾延迟变长、自己的账单上看不出来"**。
1. 先分清"忙"和"被挡住"
判断实例有没有被噪声邻居压住,看"请求在队列里待了多久",不看利用率。利用率回答"卡有没有空",队列等待回答"你的请求有没有被排到"——两者可以朝相反方向走。
| 指标 | 回答的问题 | 答不了的问题 |
|---|---|---|
| GPU 平均利用率 | 卡有多少时间在跑核 | 跑的是谁的工作 |
| 批内平均 token 数 | 每步处理多少 token | 这些 token 属于谁 |
| 队列等待 P99 | 排队多久才被执行 | 排队是因为算力还是显存 |
| TTFT / TPOT P99 | 首 token 多快、间隔稳不稳 | 抖动来自调度还是采样 |
最容易被平均骗的是批大小:批量任务请求少、每个大,交互任务请求多、每个小。平均批大小被前者拉高,"吞吐更好看了",而交互侧的每 token 间隔被同一次调度推后。同一个数字,两个租户读出相反结论。
// 队列等待按租户分开,这是唯一能直接指认噪声邻居的指标
histogram_quantile(0.99,
sum by (tenant, le) (rate(inference_queue_wait_seconds_bucket[5m])))
// chat 从 0.05s 涨到 1.9s,batch 稳定在 0.1s → 受害方与加害方都明确
2. 共享队列里,谁在给谁让路
主流引擎用连续批处理:每一步重新组批,谁有新 token 就进批。吞吐因此涨一个量级,代价是每一步的批内容都在变,于是"一个请求进来"会影响"其他所有请求的这一步"。
prefill 和 decode 的资源形状不同:prefill 计算密集,时间与输入长度近似线性;decode 显存带宽密集,一次只吐一个 token。把新请求的 prefill 插进 decode 步之间,一次 32k 的 prefill 就让后续几十次 decode 整体推后。
| 请求形状 | 主要吃的是 | 对同批的影响 | 典型时长(8B / A100-80G) |
|---|---|---|---|
| 短问答 300 token | 调度开销 | 几乎无 | prefill 15–25 ms |
| 长文档 32k token | SM 算力 | 整批 decode 推后一次 prefill | prefill 350–600 ms |
| 长输出 2000 token | 显存带宽 | 后续每步都被拉长 | 每步 +2–4 ms |
| 200 QPS 小请求 | 批调度与显存 | 批被填满,排队变长 | 单步 25–40 ms |
// 一条时间线,看清"让路"发生在哪一步
t=0 chat#1 prefill(800tok) 60ms → decode 批 [chat#1..#9],每步 28ms
t=88 batch#7 prefill(32k) 到达 → 抢占整卡 420ms
t=88 decode 批全部挂起 → 9 个请求一起等 420ms
t=508 batch#7 第一步 decode → 批里只剩它,但每步从此被拉长
长请求不是"占满一个批就完了",它会在后续每一次 decode 步里占着槽位,直到输出完 max_tokens。1200 个输出 token 意味着 1200 步里交互请求都多一个邻居,padding 与缓存管理一起变贵。
3. 三档隔离,各自买的是什么
隔离不是开关,是价格曲线:买"确定性",付"利用率"。选错档的代价是钱花了、P99 只降一点点。
| 档位 | 机制 | 买到的 | 付掉的 | 适用判据 |
|---|---|---|---|---|
| 共享队列 | 同实例同批 | 最高吞吐、最低单价 | 尾延迟无承诺 | 单租户、形状同质 |
| 软配额 | 限并发 / token 速率 | 挡住无限灌入 | 挡不住单请求过大 | 批量占比 < 15 % |
| 权重调度 | 每步按租户分预算 | 尾延迟有界 | 引擎需支持、要调参 | 混跑且双方都有 SLA |
| 独占实例 | 物理分离 | 互不影响、独立扩缩 | 利用率腰斩、成本翻倍 | 交互 P99 写进合同 |
判据要写死成数字,否则每次讨论都回到"要不要加卡"。我的口径:交互 P99 超过承诺 1.5 倍、且批量租户请求数占比低于 30 %,就直接上权重调度或独占;超过 30 % 才先试软配额。批量占比高说明它是主业,靠"限主业保副业"最终会以另一种方式反弹。
// 软配额的最小可用配置:按 token 预扣,不按请求数
tenant_limits:
chat: { max_concurrent: 64, token_budget_per_sec: 20000, max_input_tokens: 8000, priority: 10 }
batch: { max_concurrent: 8, token_budget_per_sec: 4000, max_input_tokens: 32768, priority: 1,
queue: offline } // 在线队列优先,批量空闲时借算力
4. 软配额为什么常常不生效
配了并发上限和 token 速率,P99 还是抖。原因几乎总在"配额口径与实际开销口径不是同一个"。
| 配额口径 | 漏掉的量 | 症状 |
|---|---|---|
| 按请求数限并发 | 一个 32k 请求 = 100 个短请求 | 并发没超,显存先爆 |
| 按输入 token 限 | 输出长度不受控 | 长输出把批撑大 |
| 在网关限 | 调度器只看批大小 | 网关不超,队列仍被挤 |
| 按 API key 限 | 异步任务复用同一 key | 夜间批量吃掉在线额度 |
| 只限入口不限缓存 | KV 按 seq_len 占、释放滞后 | 请求结束了显存没回来 |
最隐蔽的是最后一条:并发数达标、被占住的显存超标。监控上 64/64 一切正常,新请求却卡在分配显存那一步——它甚至不在队列里,它在一个分配锁上等。
// 预扣上界、结算实际:把不可预知的开销变成可记账的额度
def admit(tenant, req):
est = req.input_tokens + req.max_tokens // 先按最坏情况扣
if ledger[tenant].remaining < est:
return QUEUE_OFFLINE // 退离线队列,不占在线额度
ledger[tenant].reserve(est); return RUN
def release(tenant, req, actual):
ledger[tenant].settle(req.est, actual) // 差额当日归还
// 反过来(预扣均值、结算上界)就一定超卖
预扣上界显得"额度很紧",但它换来一个确定性质:任何时刻被承诺的显存都不超过物理显存。嫌浪费的正确做法是按用途压 max_tokens(摘要 600、问答 300),而不是把预扣改成均值。
5. KV 缓存:按 token 记账,不按请求
KV = 2 × layers × kv_heads × head_dim × seq_len × bytes。8B 级模型(32 层、8 KV 头、head_dim 128、FP16)每 token 每层 4 KB,合计 128 KB/token。
| 序列长度 | 单请求 KV | 80G 卡可容纳(留 20G 给权重与激活) |
|---|---|---|
| 2k | 256 MB | 约 230 并发 |
| 8k | 1 GB | 约 58 并发 |
| 32k | 4 GB | 约 14 并发 |
| 128k | 16 GB | 约 3 并发 |
// 反推:给定显存预算,最多容纳几个并发
per_token = 2 * 32 * 8 * 128 * 2 // 131072 B = 128 KB/token
def max_conc(seq, budget_gb=60):
return int(budget_gb * 1024**3 / (seq * per_token))
// max_conc(32768)=14 max_conc(8192)=58 max_conc(2048)=234
第三行说明一切:一个租户发 14 个 32k 请求,纯 decode 就能占满一张 80G 卡——与 QPS 无关,与他"一次发多大"有关。而按 QPS 限流时,这个租户的 QPS 可能只有 0.2,永远触发不了阈值。
所以隔离的真正分界线不是请求数,是最长的那条序列。两条规则:给每租户设 max_input_tokens,并按"能容忍几个并发"倒推(想保 20 并发就把 32k 压到 8k);超长请求强制进离线队列,让它的等待成本由自己承担。
6. 排错:五种"看起来像容量不足"的情况
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| P99 涨,利用率只涨几个点 | prefill 抢占,队列让路 | 分池或权重调度 |
| 加副本没改善且负载不均 | 前缀缓存 affinity 把请求粘住 | 按租户一致性哈希 |
| 并发没超但 OOM | KV 按序列长度占 | 改预扣口径 + 压输入上限 |
| 夜间批量结束白天仍慢 | 分页池未回收、缓存碎片 | 重启引擎或触发整理 |
| 改了配额没效果 | tenant 标识没传到调度层 | 网关注入并透传 |
// 现场三步定性:先队列,再显存,最后租户分布
$ nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv
$ curl -s localhost:8000/metrics | grep -E 'queue_wait|kv_cache_usage|num_running'
// kv_cache_usage 高而 num_running 低 → 长序列占位,不是并发不够
// queue_wait 只在 batch 活跃时抬升 → 证据闭环,可以谈隔离
顺序很重要:队列把"算力不足"和"被挡住"分开,KV 使用率把"并发不足"和"长序列占位"分开,最后按租户切分才能指认加害方。跳过前两步直接加卡,就是 5.2 s → 4.6 s 的翻版。
7. 边界:什么情况不必隔离
| 场景 | 为什么不必 | 该做什么 |
|---|---|---|
| 只有一类租户 | 没有邻居 | 调调度参数即可 |
| 形状同质(都 < 1k) | prefill 抢占影响 < 5 % | 保持共享省成本 |
| 批量可放夜间 | 时间窗天然隔开 | 定时 + 离线队列 |
| QPS < 5 且有余量 | 排队概率极低 | 只监控不动架构 |
| 单请求就占满卡(> 64k) | 隔离也救不了 | 拒收或降级到小模型 |
判断口径一句话:当受害方能明确指出"是某类请求让我的尾延迟变长,且那类请求不是自己发的",隔离才值得买;指认不出加害方,要修的是指标口径,不是机器。加卡只会把同一个问题按比例放大到更贵的账单上。
8. 自测
- 为什么"利用率只涨 5 个点"不能排除噪声邻居?哪个指标能直接指认?
- prefill 与 decode 吃的资源有何不同?为什么前者会推后整批后者?
- 三档隔离里,软配额挡得住什么、挡不住什么?
- 按请求数限并发为什么挡不住 32k 租户?正确口径是什么?
- 8B 模型、32k 序列的请求约占多少 KV?据此能推出什么并发上限?
- "加卡没变快"在此语境下的两种典型原因是什么?
9. 一句话总结
噪声邻居的本质是"开销按 token 记、配额按请求记"的口径错位:长 prefill 推后整批 decode,长序列占住 KV,而利用率与并发数看上去都正常。判据只有一条——能指认加害方、受害方的尾延迟又有承诺,才买隔离;指认不出,先补指标。加卡是把问题按比例放大,不是解决它。