首页 / 资讯中心 / 文章详情

vLLM高并发推理实战:PagedAttention与KV Cache显存优化

vLLM高并发推理实战:PagedAttention与KV Cache显存优化 ★ FEATURED ARTICLE
vLLM这个推理引擎给我的第一印象不是快而是不浪费。当我在本地搭高并发推理服务时算力早就不是最稀缺的资源了显存里的KV Cache才是。vLLM的高吞吐思路说到底就是围绕这几百GB的显存做精细化管理把每一块显存碎片都利用起来。这篇文章就是我从选型、部署、调参到压测的完整记录写给那些手上有GPU、想让大模型同时服务几十上百路请求、又不想动不动OOM掉线的开发者。文章核心围绕vLLM连续批处理、PagedAttention原理即KV Cache分页管理、本地推理服务部署、高并发压测、生产化配置。没有复杂术语堆砌全部是实操参数和踩坑经验不同基础的读者都能跟着跑一遍。1. 高并发请求进来时模型服务通常先卡在哪很多人以为推理服务卡在计算上实际上显卡的计算单元往往是闲着的。推理分两个阶段第一个阶段是预填充把整段Prompt并行算一遍第二个阶段是解码每次只生成一个Token而且一次只能算一个位置。这个过程很像流水线作业真正拖慢速度的是每一轮生成都要去显存里读取历史Token的KV Cache。1.1 一张GPU卡为何跑不满算力与访存的拆解我拿手头一台双卡机器做测试单卡显存约24GB。启动一个13B模型后把单条请求丢进去显卡利用率经常只有30%到50%。不是算不了而是访存速度赶不上计算速度。推理的瓶颈更多在显存带宽模型权重从显存搬到计算单元需要时间KV Cache来回读取也需要时间真正在CUDA Core上做矩阵乘的时间占比反而不高。打个比方显卡就像一个加工车间计算单元是机床显存是仓库模型权重是经常要用的原料。推理速度取决于从仓库搬料到机床的速度而不是机床本身有多快。单条请求时料搬得太慢机床一直等并发请求上来时如果每条请求都要各自搬料仓库通道就会拥挤更乱。vLLM的核心思路就是让一批请求的KV Cache尽量连续分布、复用显存块减少搬料冲突和等待。这也是它声称高吞吐的底层逻辑不是让单次推理更快而是让单位时间内整个系统能处理的Token总量更高。1.2 KV Cache显存碎片才是隐形杀手KV Cache是自回归模型必然产生的中间产物。每条请求的Prompt长度不同输出长度也不同所以KV Cache大小在运行时是不可预测的。传统推理框架通常按最大长度一次性预留显存比如max_seq_len设为4096每条请求都按4096个Token去占位哪怕实际只生成了20个Token显存也不会回收给别人。这样并发一高显存马上爆掉而真实利用率可能只有一半。vLLM引入的PagedAttention思想类似操作系统的虚拟内存分页。它把KV Cache切成一页一页固定大小的块请求需要多少就分配多少块不需要的部分立即还给全局池。这样批处理时显存碎片被拼凑起来同一条请求的页可以不必连续通过块表映射找到就行。简单讲过去是每人发一间固定大房现在是几百个小隔间按需分配住多少天就扣多少钱。这一改动带来的直接收益是显存利用率上升即单位显存能容纳的并发请求数量更多。并发数上去以后连续批处理调度才有足够的货源来做吞吐优化所以PagedAttention不是锦上添花而是高并发的基石。经验之谈部署vLLM时不要只盯着模型权重大小KV Cache预留空间更重要。很多人第4个并发请求就OOM多半不是因为模型放不下而是KV Cache的默认预留被额外请求吃光。这时调整gpu_memory_utilization比换显卡更见效。2. 部署第一道关版本、平台与模型权重的加载链路vLLM的部署并不复杂但版本组合很容易踩坑。我跑过从0.4.x到较新版本的多个迭代安装方式、Python环境、CUDA版本、PyTorch版本都必须对齐否则模型加载时会出现莫名其妙的算子编译错误。2.1 从源码安装还是直接用预编译包官方推荐用pip install vllm安装预编译版本前提是你本机的torch、CUDA runtime和它的编译环境兼容。我一开始图新鲜从源码编译结果编译过程持续了二十分钟到半小时期间还要拉取大量第三方依赖。后来发现大部分场景直接用预编译包就够了还能避免GCC版本冲突。如果你有定制算子需求比如改调度策略才需要源码编译。常规服务化部署我的建议是# 创建一个干净的环境 python -m venv vllm_env source vllm_env/bin/activate # 安装vLLM这里根据你本机的CUDA版本选择对应版本 pip install vllm安装完成后可以跑一条简单命令确认能否启动python -c import vllm; print(vllm.__version__)接着下载本地权重。如果是离线环境记得先配置好HF_HUB_OFFLINE1否则启动时会尝试联网检查。这里不多讲网络代理只提醒一点模型权重目录如果是符号链接有些版本会解析出错最好用真实路径挂载。2.2 最小启动配置从命令行看懂每个参数vLLM提供了vllm serve命令一条命令就能拉起一个兼容OpenAI风格的API服务。我常用的最小配置是vllm serve /data/models/llama-style-13b \ --served-model-name infer-model \ --max-model-len 8192 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 64 \ --tensor-parallel-size 1 \ --enable-prefix-caching逐个说下关键参数的含义。--max-model-len决定模型最长能处理多长的上下文它同时决定了KV Cache的页表大小和单条请求的上限。设得越大能容纳的页越多但单条请求占用的显存也越大并发数会被压缩。设得过小长文档请求直接报错。没有绝对正确答案一般按业务需求选聊天场景8192足够代码库分析可能要16384。--gpu-memory-utilization控制显存利用比例默认值通常是0.9。别把它理解为显卡风扇转速它指的是vLLM进程可以占用的显存最高比例。调高这个值留给KV Cache的空间更多能同时处理的请求数更大但如果你还要在同一张卡上跑别的程序就要调小。生产环境建议单独跑服务和训练任务分开否则你会体验到什么叫显存撕裂。--max-num-seqs控制单批次最多容纳多少个序列。它像一个闸门限制并发序列数。不要无脑调大后面我专门讲。--tensor-parallel-size控制张量并行。单卡就设为1多卡可以按卡数设置比如2或4。多卡时模型权重会切分到不同GPU适合参数量很大的模型但对网络带宽要求高机器里如果走PCIe而不是NVLink性能提升有限。2.3 量化模型的导入路径显存变小之后在高并发场景下模型量化几乎成了必选。7B模型FP16权重占14GBAWQ 4bit量化后占约4GB省下的显存全部能让给KV Cache。vLLM对GPTQ、AWQ、FP8的支持都很成熟加载方式只是在模型路径上指定量化后的权重目录。我对比过一次同型号模型的量化前后表现FP16权重时并发32条请求显存就吃紧换成AWQ 4bit后同样显存可以跑到并发128条吞吐量提升非常明显。量化带来一个容易被忽略的效果模型权重变小后解码阶段搬权重的时间变短单Token延迟也略有下降。换句话说量化不只是省显存还可能顺带提速。需要注意量化模型必须保证tokenizer文件完整。很多量化权重包只放了权重文件丢三落四加载时vLLM会报找不到tokenizer_config.json或vocab.json。此时不用重新量化直接从原始模型目录复制对应文件到量化目录即可。3. 吞吐性能的四个关键旋钮全部实测对比部署层面跑通只是第一步真正决定服务能扛多少并发的是调度层的参数配置。我调试了一段时间发现vLLM最有价值的设计是连续批处理一个解码步骤结束后立刻有新的请求或新生成的Token顶进来整个批次的组成是动态变化的不再像传统框架那样一条请求从头跑到尾。3.1 连续批处理为什么比固定批处理吞吐高传统批处理的做法是按批次提交若干请求批次内的长请求会拖慢整批速度新来的请求只能傻等下一个批次。vLLM把一个批次拆成步每一步做完后重新排队输出完成的请求离队新请求入队还在生成的请求继续。整个过程像自助餐厅的旋转餐台每时每刻都有人取菜也有人补菜而不是大堂经理喊一桌才开一桌。这个机制最大的受益者是长短请求混合的场景。比如一批请求里有10条短输出、2条长输出传统框架的时间由2条长输出决定连续批处理则让10条短请求先完成、先释放显存长请求继续走自己的节奏。整体吞吐自然上升。连续批处理不像一个需要开关的参数而是引擎内建的调度策略。它真正要调的是多长时间让一批请求进来和最多同时容纳多少序列。--max-num-seqs就是限制同时处理序列数量的阀门。3.2max_num_seqs的平衡点实验记录我用同一份模型和压测脚本分别把max-num-seqs设为16、32、64、128固定输入长度256个Token输出长度128个Token并发请求数64结果如下max-num-seqs单请求平均首Token延迟单请求平均生成Token间隔总吞吐Token/s是否出现长尾161.2s35ms约2200较明显320.9s33ms约4100稳定640.7s38ms约3900偶发波动1280.6s46ms约3600明显波动直观结论是不是设置越大越好。64是一个甜点再往上走显存里同时放太多序列每个序列分到的计算机会变小生成速度变慢总吞吐反而下降。如果业务追求单请求的生成速度可以把数值调低一些如果追求总吞吐调到能稳定跑满显存即可。不同显存容量的甜点位置不同。我的建议是先小步调试每次翻倍观察总吞吐的变化趋势如果翻倍后吞吐没有同步提升反而时间指标恶化就退回来。3.3 Prefix Caching系统提示词场景的加速奇效vLLM的--enable-prefix-caching是一个很容易被忽略的参数。它的原理是如果多条请求的Prompt前缀相同计算出来的KV Cache可以直接复用不用重新走预填充阶段。典型的场景就是系统提示词相同、多轮对话共享历史消息、Agent场景中多轮调用使用固定的工具描述。我做一个对比实验固定输入长度128中前半部分50个Token完全相同总请求数200开启前缀缓存后首Token延迟中位数从1.1s降到0.6s总吞吐提升约20%。多轮对话场景收益更明显因为每轮相同的上下文越长省下的计算越多。不过要留意前缀缓存是按哈希匹配的不同Prompt只要前缀不同就没法命中。它省的是预填充阶段的计算量对解码阶段帮助不大。如果你的场景是每次Prompt都不一样开不开这个参数影响很小。3.4 Swap空间与显存不足时的行为vLLM还有一层显存不足时的兜底机制把不活跃的KV Cache页面Swap到CPU内存。这个机制能防止极端情况下OOM崩溃但在实际使用中一旦触发Swap服务延迟会瞬间暴涨因为CPU内存和GPU显存之间的数据搬移比显存内访问慢好几个数量级。监控日志里如果看到大量Swap相关的调度记录说明gpu-memory-utilization设得太低或者并发序列数开得太高。正确做法是提高显存利用率、适当降低并发序列数而不是依赖Swap兜底。我把这个机制理解为最后一道保险而不是性能方案。4. 用压力测试验证吞吐压测工具、配置与服务化代码能跑起来不代表能扛住流量。我在部署过程中最重要的环节是压测用数据判断哪些参数是看起来高还是实际高。vLLM官方自带benchmark脚本同时它也暴露了丰富的Prometheus指标可以搭配第三方压测工具来模拟真实并发。4.1 压测工具的选择官方脚本与外部压测对比官方提供的llm类或benchmark_serving.py可以方便地测出吞吐、首Token延迟和端到端平均延迟适合快速对比参数。外部压测工具则更适合模拟真实API行为比如用Python写一个多线程并发客户端或者用现成的HTTP压测工具。我的做法是两套都跑先用官方脚本快速确定参数甜点再用自写的并发客户端做长时间稳定性测试。下面是一个简单的外部压测脚本骨架import json import time import threading import urllib.request def send_request(prompt, results, idx): payload json.dumps({ model: infer-model, prompt: prompt, max_tokens: 128, temperature: 0.0, }).encode(utf-8) req urllib.request.Request( http://127.0.0.1:8000/v1/completions, datapayload, headers{Content-Type: application/json}, ) start time.time() try: with urllib.request.urlopen(req, timeout60) as resp: data json.loads(resp.read().decode(utf-8)) results[idx] time.time() - start except Exception as exc: results[idx] -1 print(frequest {idx} failed: {exc}) threads [] results [None] * 128 for i in range(128): t threading.Thread(targetsend_request, args(f这是第{i}条请求的Prompt内容随意填充。, results, i)) threads.append(t) t.start() for t in threads: t.join() success [r for r in results if r and r 0] print(f成功数: {len(success)}) print(f平均耗时: {sum(success) / len(success) if success else -1:.2f}s) print(f最大耗时: {max(success) if success else -1:.2f}s)这个脚本只用来确认服务是否稳定想更精确测吞吐还是要看vLLM的日志和指标因为网络传输、客户端解析都会干扰数据。4.2 具体压测场景与数据说明了什么我以一批模拟业务需求为背景做压测平均输入长度512 Token平均输出长度256 Token目标并发128个持续请求。单卡某型号专业卡配置下vLLM的表现明显优于同为自建的两种接入方式。后者的固定预分配策略下同样的并发会导致显存OOM而vLLM在同样显存占用下可以稳定跑完。一个容易理解的指标是总吞吐Token/s以及TTFT首Token延迟。压测时同时观察这两个指标很重要只看总吞吐可能牺牲大量首Token延迟只看首Token延迟可能浪费并发潜力。我一般定两条基线首Token延迟P95低于2秒总吞吐尽量最大化。压测过程中最需要留心的现象是看似高吞吐实则排队严重。例如并发超过某阈值后总吞吐还在上涨但单请求平均延迟已经飙升这种状态对线上用户不友好。我会把并发逐步提升观察延迟曲线有没有明显拐点拐点附近就是当前配置的承载上限。4.3 运行期排障遇到的三个真实问题排障这块印象最深的是三类问题每个都在生产环境里真实遇到过一次。第一个是启动时报无法分配KV Cache。这个情况几乎都是显存预留不足解决办法是调高--gpu-memory-utilization或者换更小的模型/量化权重。也有人会把--max-model-len调短KV Cache需要的空间也随之变小。第二个是服务启动后请求超时但日志没有明显错误。原因一般有两个一是--max-num-seqs过大导致请求在调度队列里积压二是并发请求里混入了很长的Prompt单个请求的预填充时间过长。排查时可以看日志里的Waiting和Running数量如果Waiting长期堆积说明闸门开得太窄或吞吐不够。第三个是模型加载报错提示Token长度超过模型最大上下文。这种情况往往是外部请求里带入了超长文本代码层没有做截断。建议在业务接入层设置一个Prompt长度过滤器而不是完全依赖vLLM拦截。5. 服务化与常驻部署从调试到长期运行压测通过之后真正的挑战是如何让服务长期稳定运行。vLLM自带API服务格式兼容OpenAI接口所以接入现有应用非常方便但一个进程服务于一个模型和同时服务多个模型、多租户场景下的配置会完全不同。5.1 OpenAI兼容接口与业务接入方式vllm serve启动后默认监听8000端口提供了/v1/chat/completions和/v1/completions接口。我的业务代码可以直接把请求地址改成http://127.0.0.1:8000/v1几乎不需要改动。这也是vLLM兼容性的一个重要优势。接入时有一个细节要处理如果模型通过--served-model-name指定了名字那么请求体里的model字段必须对应这个名字否则返回错误。很多人在本地测试时只写了一个模型路径却在请求里填了一个不存在的模型名然后困惑为什么报错。流式输出也需要在业务层单独适配。streamTrue时返回的是text/event-stream格式数据客户端必须按SSE解析而不是等整个响应完成。对话类应用如果不做流式用户感知到的首字延迟会变高体验明显差一截。5.2 多模型同时服务的两种路径本地服务有时需要同时挂不同模型比如一个通用对话模型、一个代码专用模型。vLLM的两种做法一是多个模型分别用不同端口启动各自独立进程二是使用--model参数指定多个模型路径让模型加载到一个服务里。第一种隔离性更好某个模型崩溃或升级不影响其他模型第二种节省显存但所有请求共享一套调度器和池化机制故障影响面更大。我更推荐多进程方案。显存够的情况下两个模型各跑一个进程不仅稳定性更高调参也灵活。显存紧张时可以给不同进程设置不同的gpu-memory-utilization把主要显存留给高并发模型。5.3 长期运维监控指标与日志轮转vLLM对运维很友好暴露了大量Prometheus指标。我最常看的是vllm:num_requests_running当前正在生成Token的请求数接近上限说明排队压力大。vllm:num_requests_waiting调度队列里的等待数长期不为零就要关注。vllm:time_to_first_token_seconds及vllm:time_per_output_token_seconds平均性能指标。显存使用和GPU利用率需要通过外部监控工具采集。日志方面vLLM日志量不小尤其是压测阶段建议配置log_level并做日志轮转避免磁盘被刷爆。进程管理建议使用systemd或容器方案并加入自动重启策略。我习惯在启动命令里增加--disable-log-requests参数否则每条请求都会打印一行日志在高并发时日志反而成为性能负担。5.4 一条稳妥的生产启动命令综合前面所有讨论一台显存中等的机器上我会用类似下面的启动配置vllm serve /data/models/code-model-awq \ --served-model-name code-svc \ --max-model-len 8192 \ --gpu-memory-utilization 0.92 \ --max-num-seqs 64 \ --tensor-parallel-size 1 \ --enable-prefix-caching \ --disable-log-requests \ --host 0.0.0.0 \ --port 8010--host 0.0.0.0允许局域网内其他机器调用服务方便多机联调。如果服务只给本机用更稳妥的配置是监听127.0.0.1减少暴露面。这些看似细小的习惯在长期运行中能避免不少麻烦。写在最后几条值得记住的经验我测试vLLM时发现它最值得称道的并不只是PagedAttention而是整个推理流程围绕让显存里同时有更多可调度的请求这一目标做的系统设计。连续批处理、前缀缓存、动态KV Cache分配最终都是为了让GPU忙碌而不空转。根据我自己的实际操作体会有几点建议供参考第一先跑基准测试再调参不要凭感觉设定并发数第二参数之间是联动的改gpu-memory-utilization一般要重新验证max-num-seqs第三压测环境要和真实业务匹配尤其是输入长度分布否则测出来的吞吐没有参考意义第四长期运行要盯Waiting数量和延迟指标比盯GPU利用率更有效。如果以后再扩展我可能会尝试多机推理部署和更细粒度的请求优先级调度。不过对于绝大多数本地高并发推理服务来说把单卡或单机的资源用透已经能解决大部分实际问题。
阅读完成 · 觉得有帮助?
咨询建站