ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM推理性能优化:从Static到Continuous Batching,GPU利用率提升的关键策略

LLM推理性能优化:从Static到Continuous Batching,GPU利用率提升的关键策略 很多做 LLM 应用的同学都会遇到一个困惑明明买了 A100/H100 显卡推理服务也上线了但 GPU 利用率只能跑到 20%~30%。问运维运维说进程在跑问算法算法说模型没问题。最后一看问题大概率出在“请求是怎么送进显卡的”这件事上。换句话说就是 LLM 推理服务里的 batching 策略没有选对。Batching批处理这个词做深度学习的人都不陌生。训练时要加大 batch size 来提升吞吐这是基本功。但 LLM 推理场景下的 batching 并不是简单地把多个样本堆到一起就行。因为 LLM 生成是一个逐 token 解码的过程请求有长有短有快有慢如果批处理策略不够精细计算资源会在等待中被大量浪费。这篇文章想聊清楚三件事什么是 Static Batching、什么是 Dynamic Batching、什么是 Continuous Batching它们各自解决了什么问题又是如何影响 LLM 推理的延迟和吞吐的。看完之后你会明白为什么现在的 vLLM、TensorRT-LLM、SGLang 这些主流推理框架都在强调 Continuous Batching也会理解为什么有些老牌推理框架在处理大并发时表现不佳。当然更重要的是你自己在部署服务时能根据业务场景选对策略而不是盲目追新。1. LLM 推理为什么需要 Batching先回到最基础的问题LLM 推理时显卡到底在算什么。LLM 的生成过程是一个自回归过程。给定一段输入文本模型先通过 Prefill预填充阶段计算出第一个输出 token然后进入 Decode解码阶段每步生成一个 token把新生成的 token 拼接到输入序列中再继续跑一遍前向计算直到遇到终止符。这个过程的计算特征非常特殊Prefill 阶段是典型的计算密集型任务可以充分利用 GPU 的并行计算能力。输入多长就有多少个 token 可以被同时计算。Decode 阶段是存储带宽密集型任务。每一步只生成一个 token但需要把整个 KV Cache 读出来参与 attention 计算显存带宽成了主要瓶颈。如果只有一个请求独占 GPU那么在 Decode 阶段的大部分时间里GPU 的算力是闲置的。因为每一步只生成一个 token而模型参数量巨大大量计算资源都在等待显存数据返回。多个请求一起送进显卡就能让 GPU 在等待一个请求的同时去计算另一个请求。这就是 Batching 最朴素的动机把多个独立的生成过程合并到一起提高 GPU 的利用率。训练场景里Batching 很直观把一批样本堆成一个 tensor跑一次 forward 和 backward。推理场景里问题就复杂了。因为每个请求的输入长度不同生成的长度也不同而且请求会随时到达。一个请求生成 50 个 token 就结束了另一个请求可能要生成 500 个 token。如果强行把它们绑在同一个 batch 里处理策略稍有不当就会产生大量等待和填充。2. Static Batching最朴素的批处理思路Static Batching静态批处理是早期 LLM 推理服务最常用的方式也是很多人对“批处理”的第一理解。2.1 Static Batching 的原理Static Batching 的核心逻辑是等一批请求凑齐了再一起送进模型计算。整个 batch 的边界一旦确定就不再变化直到这批请求全部生成完毕。实现上通常会把请求按长度进行 padding填充让它们对齐到相同的序列长度。模型计算时batch 内的所有序列共享一次前向传播。# 文件路径examples/static_batching_demo.py # 说明Static Batching 的简化逻辑示意不依赖特定推理框架 import time from typing import List def static_batch_infer(requests: List[str], batch_size: int 4): 静态批处理演示凑齐 batch_size 个请求后统一处理 queued_requests [] results [] for req in requests: queued_requests.append(req) # 只有凑齐 batch_size 个请求才会执行推理 if len(queued_requests) batch_size: print(f[Static Batching] 凑齐 {batch_size} 个请求开始批处理...) batch_results model_generate(queued_requests) results.extend(batch_results) queued_requests [] time.sleep(0.1) # 模拟一次完整生成 # 剩余不足一组的请求等待下一批到达或被超时机制处理 if queued_requests: print(f[Static Batching] 剩余 {len(queued_requests)} 个请求等待下一批...) return results这种方式的优点是实现简单逻辑清晰而且 batch 形状固定算子层面容易做优化。在请求到达模式非常均匀、请求长度差异不大的场景下Static Batching 的表现尚可。2.2 Static Batching 的致命问题Static Batching 在真实生产环境中的表现往往不尽如人意原因有两个。第一是 padding 带来的算力浪费。batch 内所有序列必须对齐到相同长度。假设 batch 里有 4 个请求长度分别为 10、50、100、200padding 之后全部变成 200。模型计算时有大量位置实际上是填充符但这些填充符仍然会参与矩阵乘法白白消耗算力。第二是请求等待问题。Static Batching 需要“凑批”。如果当前 batch 只有 2 个请求而系统要求 batch size 必须达到 4 才执行那么这 2 个请求只能一直等下去。为了缓解这个问题很多服务会设置一个最大等待时间超过时间就强制执行。但这样一来要么牺牲延迟要么牺牲吞吐很难两全。更麻烦的是Static Batching 中batch 是“同生共死”的。所有请求必须一起开始也一起结束。如果 batch 里有一个请求生成了很长的序列其他早就结束的请求也不能释放资源只能占着位置等它。最终表现为GPU 一直在跑但真正有效的计算比例很低。3. Dynamic Batching按请求粒度做调度Dynamic Batching动态批处理的出现是为了解决 Static Batching 中“凑批”和“等长”的问题。3.1 Dynamic Batching 的核心思路Dynamic Batching 放弃了“batch 内请求必须同生共死”的限制改为在请求粒度上做动态调度。核心做法是维护一个请求队列。只要有空闲算力就从队列中取出新的请求加入到当前正在执行的 batch 中。每个请求生成完自己的最后 token 后立即从 batch 中退出释放占用的资源。新请求进入时不需要和 batch 里的旧请求对齐长度。这个策略的调度单元是“请求”。3.2 Dynamic Batching 的实现逻辑# 文件路径examples/dynamic_batching_demo.py # 说明Dynamic Batching 的简化实现示意 from dataclasses import dataclass, field from typing import List, Optional dataclass class RequestState: request_id: str prompt: str current_token: int 0 finished: bool False dataclass class DynamicBatcher: queue: List[RequestState] field(default_factorylist) running: List[RequestState] field(default_factorylist) max_batch_size: int 4 def submit(self, req: RequestState): self.queue.append(req) def step(self): # 请求生成完毕后从 running 中移除释放位置 self.running [r for r in self.running if not r.finished] # 有空位就尝试从队列中补入新请求 while len(self.running) self.max_batch_size and self.queue: new_req self.queue.pop(0) print(f[Dynamic Batching] 请求 {new_req.request_id} 进入 batch) self.running.append(new_req) # 执行一步生成 for req in self.running: req.current_token 1 if req.current_token 100: # 假设最大生成长度 100 req.finished True print(f[Dynamic Batching] 请求 {req.request_id} 生成完毕退出 batch)在这个模型里每个请求到达后进入队列只要有 batch 空位就立刻开始推理。先完成生成请求先退出后到达的请求可以随时补位。相比 Static Batching资源的利用率提升了请求的平均等待时间也明显下降。3.3 Dynamic Batching 的不足Dynamic Batching 看起来解决了静态批处理的问题但它的调度粒度还是太粗根本原因是它依然把“一次生成过程”当作一个不可分割的执行单元。整个 batch 的前向计算仍然以“step”为单位进行。每一步生成时batch 内所有请求都必须完成一次前向计算。如果这一步只为了生成一个很短的续写结果这个请求也要占用和长请求相同的计算资源。更深层的问题是Dynamic Batching 没有解决一个关键矛盾不同请求的生成进度完全不同。有的请求已经生成了 150 个 token有的才刚开始。如果每步前向都必须把整个 batch 整体推一遍GPU 的并行度还是被 batch 内最长序列绑定了。这时真正需要的调度粒度不是“请求”而是“token”。4. Continuous BatchingLLM 推理的关键突破Continuous Batching连续批处理是目前主流 LLM 推理框架普遍采用的策略。它做的事情是把“批处理”的粒度从请求级别进一步细化到 token 级别。4.1 Continuous Batching 的技术本质在 Continuous Batching 的视角下一个请求的整个生成过程被拆成了无数个独立的“迭代步骤”。每一次前向计算一个 step就是一次独立的调度机会。传统 batching 里batch 一旦组成就无法改变Continuous Batching 里每个 step 结束之后batch 都可以重新组合生成结束的请求立即从 batch 中移除。新到达的请求可以在下一个 step 时刻加入 batch。每个请求只需要维护自己的 KV Cache不需要和别的请求对齐长度。这就意味着GPU 的每一分算力都可以被动态分配到当前“最需要的请求”上。4.2 Continuous Batching 的核心调度机制Continuous Batching 的实现依赖于两个重要能力第一分页式 KV Cache 管理。每个请求的 KV Cache 不再是一整块连续显存而是被切分成固定大小的块block可以按需分配。这样不同长度的请求占用的显存可以灵活伸缩不存在大量 padding 造成的浪费。第二iteration-level 调度。调度器在每个迭代步iteration都会重新审视当前所有请求的状态决定哪些请求进入计算、哪些请求等待、哪些请求退出。它不关心请求是“什么时候到达的”只关心“当前这一步谁应该被计算”。# 文件路径examples/continuous_batching_demo.py # 说明Continuous Batching 的迭代级调度示意 from typing import List class ContinuousBatcher: def __init__(self, max_batch_size: int 4): self.max_batch_size max_batch_size self.waiting_queue: List[dict] [] self.running_requests: List[dict] [] def add_request(self, req: dict): self.waiting_queue.append(req) def _schedule(self): iteration-level 调度每个 step 都重新决定 running batch # 1. 移除已经生成完毕的请求 self.running_requests [r for r in self.running_requests if not r[finished]] # 2. 腾出的空间立刻从等待队列补入新请求 free_slots self.max_batch_size - len(self.running_requests) if free_slots 0 and self.waiting_queue: new_requests self.waiting_queue[:free_slots] print(f[Continuous Batching] 从等待队列补入 {len(new_requests)} 个请求) for req in new_requests: req[position] len(self.running_requests) # 分配 KV Cache block self.running_requests.extend(new_requests) self.waiting_queue self.waiting_queue[free_slots:] def step(self): self._schedule() # 只有正在运行中的请求才执行前向计算 for req in self.running_requests: req[decode_step] req.get(decode_step, 0) 1 if req[decode_step] 50: # 假设 50 步结束 req[finished] True print(f[Continuous Batching] 请求 {req[id]} 完成)从代码里可以看到Continuous Batching 和 Dynamic Batching 最直观的区别是Continuous Batching 在每个 step 开始时都会检查“running 列表”是否还有空位只要有空位新请求就能立刻补进来而不是等整个 batch 结束。同时每个请求的 KV Cache 是按块动态分配的不同长度请求之间的干扰被降到了最低。4.3 Continuous Batching 为什么能提升吞吐用数字可以很直观地说明问题。假设 GPU 单次前向计算可以同时处理 4 个序列。现在有 8 个请求到达每个请求平均需要生成 50 个 token。Static Batching 的做法是先等 4 个请求凑齐生成完毕后再处理剩下 4 个。GPU 在第一批结束后到第二批开始的间隙存在明显空闲。实际吞吐约等于“两个完整生成周期处理 8 个请求”。Continuous Batching 的做法是第一个请求到达就开始处理2 个、3 个、4 个请求逐步进入 batch。当第一个请求生成完毕退出时第 5 个请求立刻补位。整个过程流水线化GPU 永远不会空转等请求。在并发请求混合长短文本的场景下Continuous Batching 的吞吐优势非常显著。这也是为什么 vLLM、TensorRT-LLM、SGLang 这些框架都把它作为核心卖点。5. 三种 Batching 策略的对比与选型三种策略放在一起看差异就非常清晰了。对比维度Static BatchingDynamic BatchingContinuous Batching调度粒度请求完整生成过程请求step 级别tokeniteration 级别请求是否需要凑批是必须凑满 batch否有空位就进否有空位就进batch 边界是否可变不可变请求退出后可变每个 step 都可变是否需要 padding需要需要跨请求对齐基本不需要KV Cache 管理预分配预分配逐步释放分页式动态分配对短请求友好度差中等好对长请求友好度差中等好实现复杂度低中等高典型框架TGI 早期版本、自研脚本Triton Inference ServervLLM、TensorRT-LLM、SGLang从选型角度出发可以给一个直接的建议请求量小并发低于 10且请求长度均匀用 Static Batching 就够了。没必要为了追求先进而引入 Continuous Batching 的复杂度。请求并发中等且对延迟不敏感Dynamic Batching 是一个不错的折中方案。它实现简单排障容易。业务请求量大、长短消息混合、对延迟和吞吐都有要求应该直接选择支持 Continuous Batching 的推理框架。这是目前 LLM 推理服务的主流方向。还需要注意一个容易混淆的概念Continuous Batching 本身不一定会降低单请求延迟。它的核心优势是吞吐即在单位时间内完成更多请求。对于单个请求来说如果系统中有其他请求抢占资源它的延迟反而可能比独占 GPU 时更高。但在真实业务场景中我们追求的是一个系统整体的延迟分位数和吞吐之间的平衡而不是单个请求的极致体验。6. 主流推理框架的 Batching 实现理解了三种策略再回来看主流推理框架很多设计选择就容易理解了。6.1 vLLM 与 PagedAttentionvLLM 是目前应用最广的 LLM 推理加速框架之一。它的核心创新是 PagedAttention一种受操作系统虚拟内存分页机制启发的 KV Cache 管理方式。PagedAttention 将 KV Cache 划分为固定大小的块每个块可以存放若干个 token 的 KV 向量。这些块不需要连续存放可以分散在显存的不同位置。请求生成时KV Cache 按需分配新的块请求结束时释放所有块。这种设计深度服务于 Continuous Batching 的调度需求。因为在 Continuous Batching 下请求会随时进出 batchKV Cache 也处于频繁分配和释放的状态。如果每次分配都要一大块连续显存会造成严重的碎片化问题。PagedAttention 的块级管理让显存利用率接近极致也让 vLLM 在高并发场景下的表现远超早期推理方案。6.2 TensorRT-LLM 与 In-flight BatchingNVIDIA 的 TensorRT-LLM 中Continuous Batching 被称为 In-flight BatchingIFB。它允许请求在迭代间隙动态加入或退出 batch配合 TensorRT 的深度算子优化在 H 系列 GPU 上能获得极强的性能表现。不过 TensorRT-LLM 的使用门槛比 vLLM 高不少。它需要把模型编译成 TensorRT Engine过程中要指定 batch size、序列长度等配置。如果业务场景中序列长度波动很大Engine 的配置需要做预留和测试否则会出现显存浪费或无法服务超长请求的问题。6.3 SGLang 与 RadixAttentionSGLang 在 Continuous Batching 的基础上进一步优化了 Prefill 阶段的调度。它提出了 RadixAttention 机制通过复用前缀的 KV Cache让多个请求共享相同的 System Prompt 时不需要重复计算。这个优化和 LLM 应用的场景非常贴合。现在很多 Agent 应用都会使用超长的 System Prompt 或 Function Calling 定义这部分内容对每个请求都是一样的。RadixAttention 让这些公共前缀只计算一次后续请求直接复用启动延迟大幅降低。SGLang 的调度设计也延续了 Continuous Batching 的思路在处理大量小请求时表现非常精准请求前缀越相似加速越明显。6.4 框架如何配置 Continuous BatchingvLLM 中Continuous Batching 是默认开启的CLI 启动时只需要指定最大并发数和最大序列长度。# 文件路径examples/vllm_serve_demo.sh # 说明使用 vLLM 启动一个支持 Continuous Batching 的推理服务 python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --max-num-seqs 64 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9关键参数说明--max-num-seqs最大同时处理的序列数。这个值就是 Continuous Batching 的 batch 上限设置过小会导致 GPU 利用不充分设置过大会导致显存溢出。--max-model-len最大模型输入长度超出会被拒绝。--gpu-memory-utilization允许使用的显存比例会影响 KV Cache 可分配总量。如果用的是 FastAPI 调用 vLLM请求方式与标准 OpenAI 接口完全一致。# 文件路径examples/vllm_http_client.py # 说明使用 HTTP 请求调用 vLLM 推理服务 import requests resp requests.post( http://127.0.0.1:8000/v1/chat/completions, json{ model: Qwen/Qwen2.5-7B-Instruct, messages: [ {role: system, content: 你是一个有用的助手。}, {role: user, content: 请用一句话介绍 Continuous Batching} ], max_tokens: 256, temperature: 0.7, }, timeout60, ) print(resp.json()[choices][0][message][content])在实际项目中max-num-seqs和高并发压测的关系非常紧密。如果压测时发现延迟突然飙升先看一眼是不是这个参数设置得过小而导致了排队。7. Batching 策略调优的常见问题与排查部署 LLM 推理服务时batching 相关的问题通常集中在几个场景里。下面整理了一份排查清单。问题现象可能原因排查方式解决方案GPU 利用率低但并发请求很多max-num-seqs 设置过小查看服务日志中的活跃序列数调大 max-num-seqs观察显存占用启动时报 CUDA OOM显存预留不足查看 GPU 显存大小和模型权重占用调整 gpu-memory-utilization或减小最大 batch长请求延迟极高长请求独占 KV Cache 资源观察请求延迟分位数分布设置 max-model-len限制极端长度请求请求全部排队无 GPU 计算等待队列过长batch 一直满查看调度器队列长度指标增加实例数量或优化请求并发策略压测时吞吐先升后降并发超过系统承载上限观察 P99 延迟和 GPU 利用率拐点找到最佳并发点做限流多个请求共享 System Prompt 但延迟高前缀 KV Cache 未复用查看是否存在统一前缀编码使用支持 RadixAttention 的框架在真实项目中最值得留意的两个指标一个叫 ITLInter-Token Latency含义是生成每个 token 的间隔另一个叫 TTFTTime to First Token含义是收到请求后到返回第一个 token 的时间。Continuous Batching 主要优化的是整体吞吐但 TTFT 反而可能因为排队而升高。如果一个业务是聊天类应用用户很容易感知到“第一次回复变慢”这时候除了加大 batch还要关注排队策略是否合理。8. LLM Batching 的工程实践建议在生产环境中落地 Continuous Batching 时有几个经验值得提前分享。第一不要拍脑袋设置并发数。max-num-seqs 不是越大越好。它受限于两个因素GPU 显存能容纳的 KV Cache 总量以及模型前向计算能承受的并行度。推荐通过压测逐步往上调找到“延迟还在可接受范围内、吞吐不再显著上升”的拐点。第二充分理解前缀复用的价值。如果你的业务场景中所有请求都有一个很长的系统提示词Agent 场景中最常见尽量选择支持前缀 KV Cache 复用的框架。同样的 GPU开启前缀复用后吞吐可以翻倍。第三注意长请求对 batch 的影响。Continuous Batching 解决了长短请求混跑时的等待问题但长请求本身依然占用大量 KV Cache。如果业务中经常出现超长生成建议在应用层设置 max_tokens 上限不要让模型无限生成。第四在服务层做并发控制。推理服务本身有 batch 调度能力但上游仍然需要配合做流量控制。如果一瞬间涌入大量无界请求batching 的调度队列会堆积最终拖垮整体延迟。第五推理框架和 LLM 应用部署的关系要理清。很多人会问类似“LLM 框架是不是必须和业务服务在同一台机器上”的问题。从资源分配角度说推理服务最好独立部署在有 GPU 的机器上业务服务通过 HTTP 或 gRPC 调用。这样可以避免 CPU/内存资源争抢导致的推理延迟抖动。第六监控要落到调度层。不仅仅是 GPU 利用率还要监控等待队列长度、平均 batch 大小、KV Cache 使用率。这些调度层指标往往比 GPU 利用率更早暴露问题。9. 总结与下一步实践方向到这里三种 batching 策略的区别应该已经比较清晰了。Static Batching 是早期方案胜在简单但存在 padding 浪费和请求等待问题Dynamic Batching 将调度粒度细化到请求提升了资源利用率但本质上还是以 step 为单位的整批前向Continuous Batching 把调度粒度推进到 token 层级配合分页式 KV Cache 管理让 GPU 在每一步都能充分发挥并行能力成为当前主流推理框架的标配。在实际选型时不建议“非最新不用”。静态批处理在特定场景请求均匀、并发不高下依然是可用方案。但如果目标是构建高并发、低成本的 LLM 服务直接选择支持 Continuous Batching 的成熟框架会更稳妥。下一步的实践建议是先在本机部署一个 vLLM 服务用 Locust 或 hey 做简单的并发压测观察 GPU 利用率和 TTFT 的变化。再把手上的业务请求长度分布统计出来把短请求和长请求按不同比例混入压测流量观察 P95 延迟的波动。这个过程会让你非常直观地感受到 batching 策略对推理性能的影响有多大。
返回列表