2947 字
约 9 分钟
13
S23-A25 · 噪声邻居:一个租户的请求怎么拖慢整台卡

给一个客户开了批量摘要接口:几千份合同一次提交,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. 自测

  1. 为什么"利用率只涨 5 个点"不能排除噪声邻居?哪个指标能直接指认?
  2. prefill 与 decode 吃的资源有何不同?为什么前者会推后整批后者?
  3. 三档隔离里,软配额挡得住什么、挡不住什么?
  4. 按请求数限并发为什么挡不住 32k 租户?正确口径是什么?
  5. 8B 模型、32k 序列的请求约占多少 KV?据此能推出什么并发上限?
  6. "加卡没变快"在此语境下的两种典型原因是什么?

9. 一句话总结

噪声邻居的本质是"开销按 token 记、配额按请求记"的口径错位:长 prefill 推后整批 decode,长序列占住 KV,而利用率与并发数看上去都正常。判据只有一条——能指认加害方、受害方的尾延迟又有承诺,才买隔离;指认不出,先补指标。加卡是把问题按比例放大,不是解决它。

S23-A25 · 噪声邻居:一个租户的请求怎么拖慢整台卡
https://www.clxhxhhr.top/posts/6722/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。