大模型推理尾部延迟波动的排查方法

推理服务偶尔出现尾部延迟抖动时,单看处理器占用率和普通响应时间往往不够。先把请求长度分布、排队时间、显存水位、首个词元耗时和错误类型放进同一时间窗,再判断问题是否与显存分配或批处理策略有关。

1. 抓 Nsight Systems 与 Torch CUDA Event 现场

为了搞清楚 GPU 内部到底在忙什么,我们在测试环境复现了 800 QPS 的长文本混合并发请求,并通过 nvidia-smi dmon -s u -i 0 配合 nsys profile 抓取底层的 CUDA 调度轨迹。

# 使用 nsys 抓取 30 秒内的 CUDA Kernel 调度与 Memory 拷贝事件
nsys profile --trace=cuda,nvtx,osrt --output=vllm_p99_latency_dump python3 benchmark_serving.py --request-rate 800

打开 Nsight Systems 生成的 Timeline 轨迹图后,线索终于清晰起来:

  1. CUDA Kernel 频繁启动等待:在 Prefill 阶段(首 Token 生成),标准 Attention 算子触发了大量的 cudaMalloc 与临时 Tensor 申请。
  2. 显存碎片化严重:由于长文本 Prompt 的 Length 跨度极大(从 128 到 8192 不等),传统的动态 Tensor 拼接导致 PyTorch 的 CachingAllocator 频繁陷入内存整理。
  3. KVCache 申请卡顿:当请求并发达到峰值时,新来的 Request 无法立刻在 GPU 显存中分到连续的 Block,只能停留在 CPU 端等待 Cache 换出。

2. 确定性显存防线与 Continuous Batching 改造

找到了显存分配和 Attention 矩阵计算膨胀的根因后,靠简单的“增加 GPU 卡数”显然无法从根本解决问题。我们必须在代码层面建立确定性的显存预分配机制,并引入 FlashAttention-2 算子来消除 $O(N^2)$ 的中间显存占用。

以下是我们重构后的 PyTorch/C++ 扩展调度防线代码。代码中加入了显存水位的阈值强拦截,防止非预期的超长 Prompt 拖垮整个 KV Cache 池:

import torch
import typing
from dataclasses import dataclass

@dataclass
class InferenceConfig:
    max_batch_size: int = 64
    max_context_len: int = 8192
    gpu_memory_utilization: float = 0.90
    block_size: int = 16  # PagedAttention 块大小

class HardenedKVCacheManager:
    """
    确定性 KV Cache 块状显存管理器
    防止超长上下文引发 GPU CachingAllocator STW
    """
    def __init__(self, config: InferenceConfig, num_gpu_blocks: int):
        self.config = config
        self.num_gpu_blocks = num_gpu_blocks
        self.free_blocks: list[int] = list(range(num_gpu_blocks))
        self.used_blocks: dict[str, list[int]] = {}
        
        # 预先分配 CUDA 物理张量池,严禁运行期动态 cudaMalloc
        self.kv_buffer = torch.empty(
            (num_gpu_blocks, 2, config.block_size, 32, 128),
            dtype=torch.float16,
            device="cuda"
        )

    def allocate_blocks_for_request(self, request_id: str, prompt_tokens: int) -> list[int]:
        # 边界校验 1:校验请求单体长度,拒绝超大 Context 攻击
        if prompt_tokens > self.config.max_context_len:
            raise ValueError(f"Request {request_id} 超出最大允许上下文长度: {prompt_tokens} > {self.config.max_context_len}")
        
        needed_blocks = (prompt_tokens + self.config.block_size - 1) // self.config.block_size
        
        # 边界校验 2:水位防线,保留 10% 块作为 Decode 预留缓冲,严禁耗尽
        safety_watermark = int(self.num_gpu_blocks * 0.1)
        if len(self.free_blocks) - needed_blocks < safety_watermark:
            raise RuntimeError(f"GPU KV 显存水位警报:剩余块 {len(self.free_blocks)} 无法满足需求 {needed_blocks} (安全水位 {safety_watermark})")
            
        allocated = []
        for _ in range(needed_blocks):
            allocated.append(self.free_blocks.pop())
            
        self.used_blocks[request_id] = allocated
        return allocated

    def free_request(self, request_id: str):
        """优雅回收显存块,避免显存泄漏"""
        if request_id in self.used_blocks:
            blocks_to_free = self.used_blocks.pop(request_id)
            self.free_blocks.extend(blocks_to_free)

def execute_flash_attention_decode(
    query: torch.Tensor,
    key_cache: torch.Tensor,
    value_cache: torch.Tensor,
    block_tables: torch.Tensor,
    context_lens: torch.Tensor
) -> torch.Tensor:
    """
    基于 FlashAttention-2 算子的确定性 Decode 执行入口
    """
    try:
        # 在此处调用已编译的 flash_attn_paged_kv_complete_bound 算子
        # 消除注意力矩阵 $Q K^T$ 在 HBM 上的中间落地
        output = torch.ops.vllm.paged_attention_v2(
            query,
            key_cache,
            value_cache,
            block_tables,
            context_lens,
            max_s=8192
        )
        return output
    except Exception as e:
        # 防线降级:抛出可捕获的工程异常,由上层调度器执行 Request 抢占与重试
        raise RuntimeError(f"FlashAttention Kernel 执行异常: {str(e)}") from e

3. 压测对比与金丝雀灰度

完成算子或显存池改造后,应在隔离环境用与目标请求结构相近的样本逐步加压,并记录模型版本、显卡规格、并发上限和数据集来源。下列命令只展示测试入口,不代表推荐的固定参数。

# 模拟 800 QPS 混合长短 Prompt 阶梯式压测
python3 benchmark_serving.py \
  --backend vllm \
  --dataset sharegpt.json \
  --request-rate 800 \
  --max-concurrency 256 \
  --num-prompts 10000

调优前后各项关键工程指标的对比情况如下表所示:

评估指标 改动前记录 改动后记录
中位响应耗时 填入同一负载下的实测值 填入同一负载下的实测值
尾部响应耗时 记录分位数和样本量 记录分位数和样本量
显存水位 记录峰值和分配失败次数 记录峰值和分配失败次数
可接受并发 写明请求分布和错误阈值 写明请求分布和错误阈值
设备利用率 与吞吐、排队时长一并记录 与吞吐、排队时长一并记录

若要采用这组配置,应在灰度环境持续观察尾部延迟、错误率和显存水位;验收阈值需按模型、请求分布和服务等级另行确定。

解决大模型推理性能瓶颈,重点绝不在于盲目堆叠硬件。设计一套能够预估显存水位、用确定性的 Memory Pool 治理动态 Context 的调度架构,才是保障高并发下推理服务稳定的核心。

使用与验证

把失败现场还原

这篇讨论的是高并发与系统性能里的“大模型推理尾部延迟波动的排查方法”。判断不能只靠某一次顺利的结果,需要把请求队列、连接池、线程栈、慢查询和性能剖析放回同一段执行过程里看。先固定失败请求的输入、版本和时间窗口,再把日志按一次执行链串起来。只看最后一条报错常会误判;同一症状可能来自配置漂移、依赖变化或并发触发。记录时把已经排除的条件写出来,下一次复现不必从猜测开始。

实际处理时,我会先选一个普通请求和一个边界请求,分别记下开始时间、关键输入与最终结果。若两者差异很大,就继续向下拆分,而不是马上把问题归因给某个工具。这里的目标不是把记录做得漂亮,而是让后来接手的人能够复走当时的路径。

交付前留下什么

对于这次“大模型推理尾部延迟波动的排查方法”,先把可变条件列成两三项即可,例如版本、输入规模或权限状态。每次试验只调整其中一项,并保存前后的差异。这样即使结论是否定的,也能知道否定的是哪一种假设。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐