ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高并发模型推理架构设计与实战:从串行瓶颈到动态批处理

高并发模型推理架构设计与实战:从串行瓶颈到动态批处理 前段时间帮朋友团队处理一个线上事故他们做的图像识别服务平时压测数据非常漂亮结果一到业务高峰直接雪崩九百多个请求卡在队列里等 GPU 干活用户端一直转圈最后超时重试把服务彻底打挂。我过去一看架构是经典的“单机 Flask 接口直连模型”一个请求进来就做一次完整推理推理期间其他请求全部排队等锁显卡利用率却只有五成不到。后来我们把整个链路改成带请求队列、动态批处理和实例水平扩展的并发推理架构同样一张卡吞吐量翻了五倍不止。这事让我意识到一个核心问题很多人对“模型能推理”和“模型能稳定扛住高并发推理”之间的鸿沟没有概念而连接这条鸿沟的桥就是并发推理架构设计。这篇文章我会把模型推理服务在并发场景下的设计思路、排队和批处理的参数计算、框架选型以及实际踩坑记录一次性倒出来。适合刚接触模型部署、正被并发问题折磨的算法工程师也适合准备做推理服务选型的技术负责人。1. 并发推理到底在解决什么问题1.1 串行推理为什么一定会挂先讲一个最基本的数字概念。假设你的模型处理一个请求平均需要 300 毫秒那么一个实例在完全串行的情况下每秒最多只能处理 3.3 个请求也就是理论 QPS 上限只有 3.3。一旦真实流量超过这个值比如每秒钟进来 20 个请求那么新请求到达的速度远远大于服务处理的速度队列长度就会不断累积就像超市只有一个收银台但顾客源源不断涌进来队伍只会越排越长。更糟的是长尾请求又把“平均耗时的分母”拉高了用户看到的不是 300 毫秒而是排队 30 秒后依然没结果。很多团队此时的第一反应是“模型太慢换更强的卡”但真相往往是模型计算单元根本没跑满。单张 A100 处理一个 batch 的耗时可能只比处理单个请求慢 20%但你能拿到的吞吐量却是原来的 8 倍、16 倍。这种量级提升靠换硬件是换不出来的。串行推理还有另一个容易被忽略的问题GPU 的上下午时间白白浪费了。模型推理被分成规整的计算步骤每一步之间需要等待前一步完成单请求跑的时候计算单元大量时间处于饥饿状态。只有把多个请求合并成一个 batch 同时送入 GPU 计算才能把硬件资源真正压干榨净。这就是并发推理架构要解决的第一个问题让 GPU 一直有活干。1.2 并发推理架构必须盯紧的三个核心指标做架构设计之前先把目标定义清楚。模型推理服务有三大核心指标缺一个都会让设计跑偏。第一个是延迟。延迟要拆开看不能只看“总耗时”。对大语言模型类的服务来说首 token 延迟TTFTTime To First Token代表用户从发出请求到看到第一个字的时间这个指标直接决定交互体感总生成时间则决定用户等待完整结果的时间。对传统 CV 模型来说单张图片从进去到出来就是完整延迟。并发架构一定会牺牲一部分延迟来换吞吐关键是把这种牺牲控制在业务可接受范围内。第二个是吞吐量。常见说法是 QPS每秒查询数更准确的是每秒钟处理完成的请求数。对生成式模型来说还要关注每秒生成的 token 数TPS。吞吐量是衡量系统容量最直观的指标也是并发架构优化空间最大的地方。动态批处理就是冲着这个数字去的。第三个是显存占用。所有并发请求共享 GPU 显存模型权重、KV Cache、激活值、中间结果都住在里面。显存不是无限的它能装下的同时运行请求数量直接决定 batch size 上限进而决定吞吐量的天花板。所以显存管理是整个并发架构的物理约束设计时必须把它当作第一优先级来算。这三个指标互相牵制batch 调大吞吐上去了显存占用跟着涨单个请求的尾延迟也可能变高batch 调小延迟好看了但吞吐就下来了。架构设计本质上是在这三个维度之间找到符合业务需求的最优点而不是单纯追求某一个数字好看。2. 从单卡到多卡把并行路线彻底搞明白2.1 动态批处理单卡吞吐的最大杠杆很多刚开始做推理服务的人会问为什么不直接在代码里把多个请求拼成一个 batch 然后丢给模型答案是静态批处理在真实流量下根本等不到“凑满一批”的机会。假设你将 batch size 定为 8那么第 1 个请求来了之后要干等后面 7 个请求到齐才开始计算早到的请求延迟被无限拉长。实际线上流量又不是均匀到达的很多时候你等了半天只凑到三五个请求批处理带来的吞吐提升远抵不上等待造成的延迟损失。正确的解法是动态批处理Dynamic Batching也叫连续批处理。它的核心思想是让请求在一个很小的等待窗口内到达窗口一到不管当前 batch 里有多少请求立刻送进 GPU 计算计算完成后先处理完的请求先返回不会因为 batch 里其他请求拖后腿而卡住。对于生成式模型每个请求在不同的解码阶段动态批处理会把多个请求的 KV Cache 合并在一起在一次内核调用里同时推进多个序列的解码最大化 GPU 计算单元和显存带宽的利用率。用大白话解释就是你不再是让顾客凑满一桌再开饭而是流水线式的只要有顾客到了窗口就上菜同时每张桌子坐几个人都安排好厨房每一秒都不闲着。vLLM 里的 PagedAttention 就是在这个思路基础上进一步做了显存管理将 KV Cache 分页存储彻底解决了显存碎片和预分配浪费的问题。用一个简化版的调度代码来说明动态批处理的运作逻辑你就能很直观地理解它的设计思路class DynamicBatcher: def __init__(self, max_batch_size, max_wait_ms): self.queue asyncio.Queue() self.max_batch_size max_batch_size self.max_wait_ms max_wait_ms async def schedule_loop(self): while True: # 1. 在一个时间窗口内尽量收集请求但到了 deadline 立刻开始计算 batch [] deadline time.monotonic() self.max_wait_ms / 1000 while len(batch) self.max_batch_size: remain deadline - time.monotonic() if remain 0: break try: req await asyncio.wait_for(self.queue.get(), timeoutremain) batch.append(req) except asyncio.TimeoutError: break # 2. 凑到的 batch 不管多大统一交给推理引擎 if batch: await self.run_inference(batch) async def run_inference(self, batch): # 这里做 pad、stack、前向、后处理 # 生成式模型还需要把每个请求的 KV Cache 单独维护好 outputs await model.generate([req.input for req in batch]) for req, output in zip(batch, outputs): await req.response_queue.put(output)实际生产里不需要你自己实现动态批处理vLLM、TensorRT-LLM、Triton 这些框架都有成熟方案但理解这层原理对你后续调参数极其重要。你至少要知道max_batch_size和等待窗口这两个参数是两个互相拉扯的旋钮一个管容量一个管延迟配不好的时候系统会很别扭。2.2 水平扩展从单实例到多实例的负载均衡动态批处理再猛单卡的物理天花板还是存在的。单张 A100 能同时处理的并发请求数受显存和算力双重限制你总不能把几十个请求全部塞进一个 batch。当单实例吞吐到顶就需要在架构上进行水平扩展也就是部署多个模型实例每个实例拥有一份模型副本通过负载均衡把流量分散到不同实例上。水平扩展的架构不复杂模型推理服务做成无状态服务每个实例独立监听端口Nginx、Envoy 或者云上负载均衡作为流量入口把请求分发到下游实例。关键点是“无状态”这三个字。如果你的推理服务在进程内存里保存了用户会话、中间结果之类的东西那一旦请求被负载均衡转发到另一个实例整个推理过程就断掉了。所以所有请求数据都要随着请求本身传进来推理完成后即时返回不在服务端保留业务状态。在多实例部署时还有一个容易被忽略的细节如果下游实例的算力不一样比如一半是 A100一半是 T4负载均衡默认的轮询策略会把流量平均分配结果就是 T4 实例被压垮、A100 实例闲着。这时必须给负载均衡配置加权策略按实例的吞吐能力分配流量比例。更细致的做法是按当前实例的队列长度做动态路由哪个实例空闲就往哪个实例发但这需要自己开发网关逻辑复杂度会高一些。2.3 多卡并行路线数据并行、张量并行、流水线并行怎么选多实例水平扩展解决的是“并发请求量大”的问题但还有一个独立的问题模型本身太大单张显卡装不下。模型参数动辄 70B 甚至更大单卡显存根本放不下这时候就要把模型切到多张卡上这叫多卡并行。多卡并行有三种常见路线。第一种是数据并行Data Parallelism就是每个 GPU 上复制一份完整的模型不同 GPU 处理不同的请求。这种方式主要用于提高吞吐和水平扩展是同一思路的硬件尺度版本但要求模型能完整塞进单卡显存。第二种是张量并行Tensor Parallelism把每一层矩阵的参数按行或列切成多块分别放在不同 GPU 上计算时通过 GPU 之间的高速通信NVLink 或 InfiniBand拼接结果。张量并行解决的是“单卡放不下”的问题特别适合大模型。但它也有代价每做一次矩阵乘都要跨卡通信通信开销会抵消一部分算力提升而且对硬件要求高卡间通信带宽不够的话性能直接崩盘。第三种是流水线并行Pipeline Parallelism把模型按层切成几段每段放在一张卡上数据像流水线一样依次流过各段。这种思路适合层数特别深的超大模型但它有一个著名的问题叫“流水线气泡”前向计算时后面的卡在等待反向或推理时前面的卡在闲置导致平均利用率上不去。推理场景下流水线并行用得不多更多是配合张量并行一起作为超大模型的最后手段。选择哪种路线核心看模型大小和单卡显存的关系。模型权重KV Cache 能塞进单卡的优先用数据并行/水平扩展简单可靠单卡放不下的先用张量并行切模型张量并行通信开销大到不可接受或者切不开时再考虑流水线并行做补充。不要一上来就堆技术简单的方案往往最稳。3. 调度策略与关键参数的计算逻辑3.1 请求调度不是先来先服务那么简单模型推理服务的请求调度有点像银行柜台叫号。最朴素的想法是先来先服务FCFS但这个策略在生成式模型场景下有个致命问题长请求会堵死短请求。一个需要生成 2000 个 token 的请求如果被一个 batch 里的其他短请求拖住它自己耗时长没什么但要命的是它会持续占据 batch 里的一个坑位导致后面大量短请求排不进来。生产环境我更建议引入优先级概念。比如交互式对话产品的请求优先级高于离线批处理任务研发人员调试用的低优先级请求要能让位于线上真实流量。具体实现时可以准备多条优先级队列调度器每次优先从高优先级队列取请求只有当高优先级队列空时才取低优先级的。还有一个进阶思路长度感知调度。如果模型能预估当前请求剩余需要的解码步数调度器就能合理搭配 batch 里的请求组合——把还剩 10 步和还剩 50 步的请求放在一起而不是把两个都剩 200 步的长请求堆叠在同一个 batch 里。这能有效降低尾延迟但实现复杂度更高需要模型在推理早期输出一个步数预测。多数团队用不到这个级别明白前两点就够了。3.2 用公式算明白容量规划Littles Law 和排队模型很多工程师在配并发参数时是纯靠感觉的“先设 8 试试不行改成 16。”这种做法费时费力。其实有一条简单的公式可以帮你做容量规划那就是 Littles Law系统内平均请求数 L 等于请求到达率 λ 乘以平均服务耗时 W也就是 L λ × W。举个例子你的业务目标是 50 QPS模型平均处理一个请求要 250 毫秒那么在任意瞬间系统内同时在处理的请求数大约需要 50 × 0.25 12.5 个。这意味着你的服务至少要让 12 个以上的请求同时处于处理状态否则 50 QPS 就是海市蜃楼。这 12 个请求可能分布在多个实例上也可能在一个 batch 里但总量必须达标。再配合利用率的概念。系统的利用率 ρ 等于到达率除以服务能力 μ。当 ρ 接近 1 时排队等待时间会呈指数级增长。假设服务一个请求耗时 250 毫秒你的服务能力是每秒 4 个请求如果每秒实际到达 3.96 个请求利用率已经高达 99%这时后面来的请求平均要排队等非常久。这个结论告诉你一个反直觉的事实把服务跑到 100% 利用率的系统用户体验往往是灾难性的。设计容量时务必留出 30% 到 50% 的余量让利用率控制在 50% 到 70% 之间换取稳定的低延迟。在实际环境里请求到达分布和耗时都不是固定值这些公式只能给出工程近似值。但作为初步估算它远比拍脑袋靠谱得多。我每次设计推理服务前都会先用这个公式做一遍数学推演确认“目标 QPS、目标延迟、需要的并发实例数”三者是否自洽然后再去调具体参数。3.3 参数落地实战vLLM 和 Triton 的关键配置先看 vLLM它是目前部署开源大模型最主流的框架之一自带 PagedAttention 和连续批处理。一个典型的启动命令长这样python -m vllm.entrypoints.openai.api_server \ --model /data/models/llama-2-7b-chat \ --tensor-parallel-size 1 \ --max-model-len 8192 \ --max-num-seqs 16 \ --gpu-memory-utilization 0.9 \ --port 8000里面三个参数至关重要。--max-num-seqs控制最大并发序列数它就是动态批处理的 batch 上限--max-model-len控制上下文长度上限它直接决定单请求 KV Cache 的上限--gpu-memory-utilization控制框架能使用的显存比例剩下的显存留给模型加载和动态分配。这三个参数配不好系统不是 OOM 就是吞吐上不去。举个具体配置例子。假设你在 48GB 显存的 L40S 上部署 Llama 2 7BFP16 权重约 14GBgpu-memory-utilization设为 0.9 意味着框架最多使用约 43GB 显存。扣掉 14GB 权重还剩约 29GB 给 KV Cache。Llama 2 7B 每个 token 的 KV Cache 大约需要 0.5MB如果上下文长度上限设为 8192一个打满上下文的请求就要消耗约 4GB。按最坏情况算29GB 除以 4GB 约等于 7那max-num-seqs设为 8 以内才算安全设 16 就可能 OOM。这里要特别强调max-model-len 不要盲目追求长。上下文开得越长每个请求平均占用的显存就越高能同时跑的请求数就越少。很多团队把上下文开到 32K结果 QPS 掉了一半因为所有显存都被长上下文的 KV Cache 吃掉了。满足业务需求够用就好长上下文只在对应场景下开。再看 Triton Inference Server 的动态批处理配置。Triton 是 NVIDIA 出的通用推理服务框架支持 PyTorch、TensorRT、ONNX 等多种后端。在模型的 config.pbtxt 里可以配置动态批处理器name: my_model platform: pytorch_libtorch max_batch_size: 16 input [ { name: INPUT data_type: TYPE_FP32 dims: [ -1, 768 ] } ] dynamic_batching { preferred_batch_size: [ 4, 8, 16 ] max_queue_delay_microseconds: 100 }preferred_batch_size告诉 Triton 优先凑满这几个规模max_queue_delay_microseconds是最大等待时间100 微秒的意思是等不到更多请求宁可少批量也要开工。这两个参数组合起来就是前面动态批处理里max_wait_ms和max_batch_size的生产级实现。实际调优时把等待时间从 100 微秒逐步往上加观察吞吐和延迟的拐点就能找到当前流量的最优平衡点。4. 框架选型别被热闹的榜单带偏4.1 主流推理框架横向对比模型推理服务框架这两年层出不穷我自己的经验是不要追新先看场景再选型。下面是几个主流方案的横向对比方案核心优势主要短板适用场景vLLM吞吐高、PagedAttention 显存管理优秀、兼容 OpenAI API对模型架构有要求部分自定义模型不支持LLM 文本生成、对话服务TensorRT-LLM延迟低、算子深度优化、可定制性强编译时间长、工程复杂度高、绑定 NVIDIA GPU延迟敏感的商业化在线服务Triton Inference Server多后端支持、内置动态批处理、模型版本管理配置复杂、学习曲线陡峭多模型混合、异构框架统一入口Ray Serve弹性扩缩容、多模型编排、Python 原生引入分布式系统复杂度性能上限依赖底层引擎需要弹性伸缩的中型团队FastAPI 自研推理原型最快、可控性最强、调试方便并发瓶颈多、动态批处理难以做对、维护成本高实验、Demo、内部小流量vLLM 最大的优势是工程上手简单一条命令起服务自带 OpenAI 兼容的 HTTP 接口前端可以直接用现成 SDK 接入。内存管理做得极好一张 48GB 的卡能同时跑的请求数非常惊人。代价是它更擅长标准的 decoder-only 架构模型如果你用的是冷门架构或者自己魔改过的模型可能跑不起来。这种情况下你被迫转向 Triton 或自研。TensorRT-LLM 的思路则完全不同。它把模型编译成高度优化的 TensorRT 引擎推理延迟能做到极低。我见过把同样模型从 vLLM 切到 TensorRT-LLM 后首 token 延迟降了 40% 的案例。但代价也明显模型转换和编译过程繁琐每改一个小结构都要重新编译一次调试成本不小。如果你的业务对延迟极度敏感而且团队有专门的推理优化工程师TensorRT-LLM 值得投入如果是快速迭代的业务模型很容易陷在编译流程里出不来。Triton 则更像一个“瑞士军刀”本身不绑定推理引擎而是提供前端和后端之间的标准接口。同一套服务管理平台可以同时加载 PyTorch 模型、TensorRT 引擎和 ONNX 模型还能做模型版本管理和灰度发布。它适合多模型混合的场景比如一个服务又要跑 CV 模型又要跑 LLM。缺点是配置项太多新手容易迷失在 config.pbtxt 和各种参数里。4.2 选型决策路径和我的建议我的选型逻辑可以简化成三步。第一步先问自己模型能不能放进单卡显存不能先确定张量并行方案再看框架对张量并行的支持成熟度TensorRT-LLM 和 vLLM 都不错Triton 要谨慎一点。第二步问对延迟的要求有多高如果首 token 延迟必须小于 500 毫秒TensorRT-LLM 是优先选项如果能接受 1 到 2 秒vLLM 默认配置就能满足没必要给自己找麻烦。第三步问团队未来要维护多少个模型只有一个模型在用选 vLLM 或 TensorRT-LLM 即可未来可能接入多模型混合服务Triton 或 Ray Serve 的统一管理能力会更省心。还有一条重要经验先在单机上用小流量验证不要一上来就追求大规模集群。大部分团队的瓶颈根本不在框架选型而是连基本的动态批处理和并发参数都没配对。用 vLLM 跑通一条完整链路把吞吐和延迟数据测出来再谈要不要用 TensorRT-LLM 优化那百分之几十的提升。架构演进应该是渐进式的不是一步登天式的。5. 实战调优与故障排查踩坑笔记全公开5.1 定位性能瓶颈的三板斧并发推理服务的性能问题千奇百怪但定位思路是一致的。第一板斧先压测。用 wrk、ghz 这类工具对服务持续加压把流量打到目标 QPS 的 1.5 倍以上观察系统表现。压测时一定要用接近真实业务分布的请求数据包括请求大小、上下文长度、生成长度的分布比例否则压测结果参考意义不大。第二板斧盯指标。压测的同时记录 GPU 利用率、显存占用、请求队列长度、延迟分位数P50、P95、P99。这里有一个非常实用的判断逻辑如果 QPS 上不去但 GPU 利用率只有三成说明瓶颈不在算力而在调度或数据链路多半是动态批处理没有生效、batch size 设太小或者前处理/后处理卡了 CPU如果 GPU 利用率已经打满但 QPS 依然不达标说明算力确实到顶需要扩容或者换更高级的卡如果显存先爆了说明 batch 设置太大或者上下文开太长。第三板斧看排队。打开服务的队列监控如果队列长度持续增长且没有回落迹象问题从“性能”变成了“容量”需要紧急扩容或限流。很多时候系统没有崩溃只是所有请求都在排队等资源用户体验已经完全不可接受。队列长度这个指标就是雪崩前最直接的预警信号。5.2 高频问题速查表现象、原因、解法问题现象可能原因解决思路启动后直接 OOMmax-num-seqs 设置过大或 gpu-memory-utilization 过高按模型权重KV Cache 峰值估算先调小再逐步增加GPU 利用率低但 QPS 上不去动态批处理未生效请求等待窗口过长/过短检查框架日志确认 batch 是否在合并调整 max_queue_delay首 token 延迟极高prefill 阶段计算量大上下文过长开启 prefix caching缩减 max-model-len同一批请求里长请求拖垮短请求调度策略未区分长短任务引入优先级队列或长度感知调度多实例部署后某张卡显存爆了负载均衡未按实例权重分配流量按实例算力配置加权路由并发一高Python 进程 CPU 打满前处理/后处理走了 Python 多线程受 GIL 限制tokenizer、图像解码等操作移出主线程或使用多进程模型推理正常但接口超时客户端连接池太小等待服务响应调大客户端连接池或调整服务端最大并发连接数5.3 几个容易被忽略的实战经验压测和上线之间还差一个步骤预热。很多推理框架都有显存预分配机制服务刚启动时显存还没占满模型虽然加载了但没有进入工作状态。如果不做预热上线后第一批请求的延迟会高得离谱直接触发健康检查失败把实例宕掉。我一般会用一小批真实请求在服务启动后立刻打一遍让框架把显存分配好、算子缓存加载好再摘掉“未就绪”标记。监控指标只盯着 CPU 和内存远远不够。推理服务必须重点盯这几个队列长度queue_len、GPU 利用率、显存使用率、KV Cache 使用率、P95/P99 延迟。KV Cache 使用率特别重要它和显存使用率不是一回事KV Cache 占满意味着即使显存还有冗余也无法再容纳新请求的解码状态这和 OOM 同样致命。压测数据不要只用短请求。很多团队压测时让模型生成 200 个 token然后推测线上 2000 个 token 也没问题。实际上长序列生成的 KV Cache 呈现线性增长解码阶段每个 token 的内存带宽消耗会显著拉长单请求耗时。我建议把压测请求的长度分布和线上历史数据对齐或者干脆录一段真实流量做回放这种压力测试结果才真正有说服力。最后再分享一个小技巧上线前给服务配置客户端超时时间时不要把超时设成固定的 30 秒或 60 秒。根据延迟分位数来设置动态超时会好很多比如 P95 延迟的三倍作为超时阈值既不会频繁误杀正常请求又能及时止损掉真正卡死的请求。这几个经验是我在多个推理服务项目里反复验证过的很多看起来玄学的并发故障追根溯源都是这类细节出了问题。
返回列表