十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

K8s中部署vLLM推理服务:GPU利用率与吞吐优化实战

K8s中部署vLLM推理服务:GPU利用率与吞吐优化实战 跑过K8s里AI服务的人应该都有这种感觉模型在本地GPU上推理明明挺快一放进Kubernetes集群QPS上不去、GPU利用率不到两成、POD动不动就重启最后业务方一句为什么线上比线下慢这么多抛过来你连锅都甩不出去。这个项目踩的坑基本就在这条线上。把一套基于Transformer的文本生成模型完整部署到K8s集群从模型文件准备、镜像构建、GPU资源调度到vLLM推理服务落地、QPS弹性伸缩整个过程做完单节点吞吐从最初不到12 QPS干到稳定140 QPS左右尾延迟也从2.8秒压到800毫秒内。这篇文章不谈虚的只把我实际跑通的做法、关键参数和踩过的几个大坑完整记录下来做个参考。1. 推理性能瓶颈拆解算力、显存、调度谁在拖后腿1.1 推理和训练的性能模型不是一回事很多人拿着训练场景的思路去做推理优化上来就堆GPU。但推理服务和训练任务在资源画像上完全是两种物种。训练是吞吐密集型的离线长任务可以忍受几小时甚至几天的批处理等待系统设计的重心是算力利用率推理则是时延敏感型的在线服务一个用户的请求从发出到拿到第一个token中间任何环节多出几十毫秒都会被明显感知。更关键的是推理阶段的瓶颈往往不是FLOPS算力而是显存带宽和访存延迟。文本模型在推理时是一个自回归过程每生成一个token都要把完整的模型权重从显存搬运到计算单元上跑一遍。这意味着推理的耗时跟模型参数量基本成正比而跟输入输出长度的关系反而更复杂。参数量14B的模型在FP16精度下权重就有约28GB哪怕一张A100的卡有80GB显存实际做单次推理时计算单元大部分时间也浪费在等待权重搬运上这就是所谓的memory-bound。抓住这个本质之后后面选择显存复用、批量调度、连续批处理这些优化手段的原因就顺理成章了。1.2 K8s在推理场景中引入的三层额外开销在裸机上跑推理进程直接使用GPU资源是独占的。但一旦上K8s整个系统会多出三个层面的开销需要算进预算里。第一层是网络开销。服务化之后每次请求都要经过从客户端到Ingress、再到Service、最后到Pod内部的完整链路中间还有多次代理转发和HTTP序列化反序列化。实测下来一个纯文本推理请求在K8s内部的网络链路耗时居然能占到端到端延迟的8%到12%这是很多人忽略的隐性成本。第二层是调度和资源隔离开销。K8s默认的调度器不会感知GPU的拓扑结构也不会优先考虑已经在某个节点上的显存状态。如果Pod被调度到不同NUMA节点甚至经过PCIe交换机跨节点通信GPU之间或者GPU与CPU之间的数据拷贝时延会成倍增加。第三层是生命周期管理的开销。K8s的滚动更新、探针检测、健康检查都会周期性消耗Pod所在节点的CPU和网络资源。当线上服务处于高负载时Kubelet的和容器运行时自身的资源占用会被放大。这就要求在部署推理服务时必须给系统预留至少1个CPU核和1GB内存的冗余否则到了流量高峰节点资源耗尽K8s会错误地把进程OOM归因为应用异常然后不断重启Pod。1.3 加速思路的整体框架经过上面的拆解推理加速的任务就变成了四条线并行减少单次请求的显存访问量也就是模型精简和量化提高显存的有效利用率让一次权重搬运服务尽可能多的请求也就是批处理优化让每个Pod尽量待在最合适的节点上减少跨拓扑访问也就是调度优化让服务跟着真实流量变化弹性伸缩避免低峰期空转、高峰期排队也就是资源弹性这篇博文的主体内容也围绕这四块展开每一块落地到K8s上都有对应的具体配置和参数。2. 模型与镜像的准备阶段从源头上减少等待2.1 模型文件获取的路径选择与下载策略在准备阶段最容易翻车的就是模型文件的获取。文本生成模型的参数文件动辄几十GB从海外平台直接拉取经常遇到网络不稳定、速度只有几百KB/s或者直接中断的情况。我在这个项目里先把模型上传到国内可访问的模型社区再通过内网下载整个过程大概十几分钟就把14B参数量的权重文件完整拉下来了。如果你的情况不方便这么操作也可以先在一台有完整网络的机器上下载再通过内部的文件分发通道同步到GPU节点。重点是别在集群构建的关键路径上赌外网稳定性否则一次断流就能卡住整个部署流程。对于所有模型文件务必用sha256sum核对一遍完整性。模型下载工具通常自带校验逻辑但如果是通过脚本或手动方式下载的缺一个分片就可能导致推理时出现诡异的输出结果。我遇到过一次加载后模型生成的内容全部是乱码排查了两个小时最后发现是第二组分片文件不完整。2.2 镜像瘦身与多阶段构建基础镜像不要选带完整CUDA工具链的全家桶镜像动辄5GB以上。推理服务只需要运行时环境不是开发环境。用多阶段构建把编译工具和应用依赖分开第一阶段装好Python编译依赖和推理框架的wheel包第二阶段只拷贝编译后的产物复制到干净的基础镜像上。最终我用的镜像各部分构成大概是这样组件版本策略体积影响基础系统精简版Linux发行版不带桌面和开发工具减少约40%CUDA运行时只保留满足推理框架最低要求的版本不装nvvp等工具减少约700MBPython依赖固定版本列表用pip的约束文件锁定避免体积膨胀和依赖漂移推理框架vLLM的预编译wheel包保持与CUDA版本严格匹配这样处理之后镜像体积控制在2.1GB左右配合nodes上的镜像预热策略Pod冷启动时间从70秒降到了6秒内。如果你的集群规模大、节点多建议在集群里部署一套镜像缓存组件避免滚动更新时所有节点同时去仓库拉取同一个镜像把带宽打满。2.3 模型热加载与探针配合模型文件到位之后推理服务的启动过程通常包含模型加载、权重初始化、预热推理几个阶段。对于一个14B的模型在GPU上从冷启动到完成预热平均需要30到50秒。这个时间窗口内如果K8s把Pod标记为Ready并开始转发流量请求必然超时。处理办法是把存活探针和就绪探针拆分开来做存活探针检测进程是否还活着用pgrep或者进程内部暴露的ping接口就绪探针检测模型是否完成加载我直接在应用里加了一个/ready接口只有模型全部加载并完成一次推理预热之后才返回200在此之前K8s不会把Pod加入Service的端点列表这样配置文件里的探针延迟时间可以拉长到120秒但实际就绪时间完全由模型加载进度决定既不会提前转发流量也不会因为启动慢被误杀。3. GPU资源分配K8s里的显存博弈3.1 device-plugin的工作机制与显存碎片化K8s本身不感知GPU需要通过NVIDIA的device-plugin组件把nvidia.com/gpu这种扩展资源注册到节点上Kubelet才能把它分配给Pod。这个机制本身很稳定问题出现在资源粒度和显存碎片上。默认情况下device-plugin暴露的是一个GPU卡作为最小分配单位。也就是说如果一个模型的权重加KV Cache只需要24GB显存而你有一张80GB的A100或者48GB的L40SPod声明1个GPU的时候剩下的显存就被白白浪费了。更麻烦的是集群里如果部署了不同显存需求的推理服务大卡被小请求占住之后后面排队的Pod只能继续等。解决办法有两种一是给GPU节点池划分不同规格大显存节点跑大模型中显存节点跑小模型避免互相挤占二是使用支持MIG多实例GPU模式的卡比如A100或H100在物理层面切出多个实例每个实例有独立的显存和计算单元K8s通过device-plugin的子模式把每个实例作为一个可分配的GPU资源暴露出来。这两种方案我都试过。实际使用下来MIG模式适合显存需求固定的场景比如同一份模型权重在这个显存规格下刚好跑满不会造成碎片如果推理负载的动态性很强不同时间段的请求长短差异很大还是用整卡节点池划分更灵活。3.2 显存预留与KV Cache的调优在部署推理服务时--gpu-memory-utilization这个参数特别关键它决定了vLLM能够使用多大比例的GPU显存用于KV Cache缓存。我一开始取的是默认值0.9也就是vLLM会尝试使用90%的GPU显存。在只有单一推理服务独占GPU卡的场景下这样没问题。但在需要使用CUDA的其它计算组件比如同时跑一个embedding模型或向量检索进程时两方会争抢显存导致其中一方OOM。后面我把这个参数调到0.7并且用--max-model-len显式限制输入输出序列的最大长度避免极端长请求把KV Cache耗尽。显式声明之后显存分配变得可以预期Pod的稳定性大幅提高。3.3 请求级显存隔离的适用场景如果要做到让多个模型安全地共享同一张物理GPU卡同时又不想引入MIG这种硬件层面的切分可以让vLLM分别启动两个服务再靠CUDA的虚拟显存机制控制每个服务占用的最大显存。这个方案可以让一张80GB的卡同时跑一个14B的大模型和两三个小的embedding模型资源利用率提升比较明显。不过需要提醒的是这种方式在显存分配上不是完全隔离的一旦某个服务的显存使用超过预设上限CUDA会报错并可能拖垮同卡上的其他服务。所以我个人还是偏好用更稳妥的方式大模型独占大卡小模型打包在几张小卡上不同安全级别的服务尽量不要混部。4. 推理框架选型与核心参数vLLM落地K8s的完整配置4.1 为什么生产环境选vLLM现在可选的推理框架不少比如HuggingFace的原生transformers pipeline、英伟达的TensorRT-LLM、以及FasterTransformer等。但我在K8s场景下最终选择了vLLM核心原因是它对批处理和显存管理有一套成熟机制能够在不改模型结构的前提下直接提升吞吐。vLLM的招牌是PagedAttention这个思路借鉴了操作系统的虚拟内存分页管理。传统推理框架在显存里为每条请求预分配一整块连续空间来存放KV Cache这个空间大小按最大序列长度来预留导致大多数请求实际用不到的部分全部浪费。vLLM则把KV Cache切分成固定大小的块按需分配给不同请求需要多少分配多少释放之后还能继续复用。配合内置的continuous batching也就是不用等一个batch里的所有序列都完成生成再收下一批请求而是每完成一条序列就立马腾出位置插入新的请求显存利用率和吞吐因此大幅提升。4.2 Kubernetes部署清单Deployment Service 探针给出一个可以直接用的部署配置这个配置在我的测试环境里验证过。apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference-server namespace: ai-production spec: replicas: 2 selector: matchLabels: app: llm-inference-server template: metadata: labels: app: llm-inference-server spec: nodeSelector: gpu-type: a100-80g containers: - name: vllm-engine image: registry.example.com/ai/vllm-server:14b-latest imagePullPolicy: IfNotPresent ports: - containerPort: 8000 name: http-api env: - name: MODEL_NAME value: qwen14b resources: requests: cpu: 8 memory: 32Gi nvidia.com/gpu: 1 limits: cpu: 8 memory: 32Gi nvidia.com/gpu: 1 startupProbe: httpGet: path: /ready port: http-api periodSeconds: 10 failureThreshold: 30 readinessProbe: httpGet: path: /health port: http-api periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /health port: http-api periodSeconds: 15 failureThreshold: 3关键点解释一下nodeSelector把Pod绑定到带A100 80G卡的节点避免调度到无卡节点上一直Pendingresources.limits里显式声明nvidia.com/gpu: 1这样device-plugin才会把GPU卡真正分配到这个Pod同时避免多个Pod争抢requests.cpu设置到8核是因为vLLM在批处理时会同时使用多个CPU线程做tokenization、sampling和预处理CPU给太少会成为瓶颈4.3 核心启动参数max-num-seqs、tensor-parallel-sizevLLM的启动参数里面--max-num-seqs和--tensor-parallel-size对性能和稳定性的影响最大。--max-num-seqs决定了一个batch内最多同时处理多少条请求。这个值调大可以提高GPU利用率和吞吐但会增大每个请求排队等待的时间推高P99延迟。我压测时发现保持默认值通常为256或取决于显存容量时显存利用率很低把比值从每请求平均占用的KV Cache块数量反推异常后才意识到在显存足够的前提下应该把批大小尽量拉高让请求尽量在batch内完成计算。最终这个项目里根据显存带宽和平均请求长度把它从默认值调整到512左右吞吐直接翻了一倍。--tensor-parallel-size则是在多卡场景下的模型并行度。对于14B模型在40GB显存的卡上需要2卡才能放得下但如果用A100 80G单卡就够了。这里的原则是能用单卡跑就别开多卡并行。张量并行会引入卡间通信开销即使是通过NVLink连接也可能有10%到20%的性能损失。只有当单卡显存放不下模型权重时再考虑多卡。4.4 更进一步的吞吐优化参数如果请求长度波动大可以打开--enable-prefix-caching。多轮对话场景中用户的历史消息在每轮请求里都会重新计算一遍KV Cache这个优化能直接跳过这些重复计算效果非常明显。我观测到长多轮对话场景下的Prompt处理延迟从40ms左右降到低于5ms。另外在服务上还可以启用--enforce-eager参数来关闭CUDA图模式的编译。默认情况下vLLM会把计算图编译成CUDA graph来加速但编译期间会额外占用显存来存储编译产物。对于显存紧张的小型推理任务关闭这个功能可以释放一部分显存给KV Cache但对模型的动态shape请求会有一些性能损失是否需要根据实际情况权衡。5. 弹性伸缩与调度策略性能之外的加速5.1 HPA为什么不够用K8s内置的HPA默认支持CPU和内存作为伸缩指标但推理服务恰恰不是普通的CPU密集型业务。一个文本生成模型的GPU利用率可以跑到95%以上但CPU可能只有20%内存更是远远用不满。这种情况下按CPU阈值去扩容永远都不会触发服务就出现了流量暴涨但副本不增加的状况。更合理的伸缩指标应该是推理队列长度、GPU利用率、token生成速率或者最直接的每副本QPS。这些指标K8s默认的metrics-server是不提供的需要自己把这些业务指标暴露给Prometheus再通过KEDA这类组件把它们连接到HPA的伸缩逻辑里。5.2 KEDA接入QPS伸缩的实操KEDA可以监听外部指标源并动态调整Deployment的副本数不需要改业务代码只需要在K8s里部署一个ScaledObject对象。下面是这个项目中使用的配置apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: llm-inference-scaledobject namespace: ai-production spec: scaleTargetRef: name: llm-inference-server minReplicaCount: 1 maxReplicaCount: 8 triggers: - type: prometheus metadata: serverAddress: http://prometheus-server.monitoring.svc.cluster.local:9090 query: | sum(rate(vllm_requests_total{namespaceai-production}[1m])) threshold: 30这里的核心思想是当整个服务的QPS超过30时KEDA会逐步增加副本低于这个值时会先把多余的副本缩掉只保留最小副本数。需要提醒的是QPS阈值一定要结合单副本的最大承载能力来设定。我这套部署的单副本大概能支撑70到80 QPS所以把扩容阈值设成30留出了约两倍的缓冲区间避免高峰期扩容还没完成流量已经打到现有副本上的情况出现。5.3 GPU节点维度的自动扩缩容K8s的HPA和KEDA只能调整应用层的副本数如果整个集群没有空的GPU节点新扩容出来的Pod会一直处于Pending状态。解决这个问题需要配合集群层的自动扩缩容组件比如在云环境里用的Cluster Autoscaler。为了让Cluster Autoscaler高效工作需要给不同的GPU型号划分独立的节点池并为每个节点池加上对应的标签和污点比如nvidia.com/gpupresent:NoSchedule。这样调度器只会把需要GPU的Pod调度到带对应GPU标签的节点上。节点池的自动扩缩容策略需要设置合理的扩容阈值和缩容保护时间。我建议缩容保护时间至少设置10分钟否则流量一旦抖动节点一会儿被缩走一会儿又要重新创建GPU节点的初始化时间动辄3到5分钟用户体验会非常糟糕。5.4 调度策略里藏着20%的性能差异默认情况下K8s调度器按资源的Request值做筛选但不会考虑Pod与GPU之间的NUMA拓扑距离。当一个Pod被调度到某张GPU卡上但对应的CPU和内存跟这张卡不在同一个NUMA节点时跨NUMA访问会显著增加时延。这里可以使用CPU管理策略和拓扑管理策略来优化。把Kubelet的--cpu-manager-policy设置为static把--topology-manager-policy设置为single-numa-node可以保证Pod的CPU、内存和GPU都落在同一个NUMA节点上。在我的测试里这个配置能让P99延迟降低约15%到20%吞吐提升约10%——而代价仅仅是全局调度时可能会多等几秒钟这笔账怎么算都划算。另一个容易被忽略的是调度器的装箱策略。K8s默认的调度器倾向于把Pod打散到不同节点上以分散故障风险但对于GPU这种昂贵资源来说有时候反而是把尽量多的Pod集中在一台节点上更划算。通过配置调度器的过滤器开关可以改成更紧实的装箱策略尤其是当Pod数量不多、但每个Pod都需要整卡时Binpack策略能显著减少空转GPU节点的数量节省成本。6. 实测与踩坑从压测数据到问题排查6.1 压测方法不要只看平均延迟压测推理服务不能只用一个并发连接反复打那样测不出系统的真实上限。文本生成场景的输入输出长度变化非常大请求之间互相影响也很大因此压测需要模拟混合长度的请求流。我用ghz配合自定义的请求体做了面向文本服务的压测。压测参数分为三组场景并发数输入token长度输出token长度预期指标轻载464128P99延迟 500ms中载16256512P99延迟 1s重载6410241024吞吐 80 QPS每组压测持续至少5分钟不能只跑几十秒就下结论。生成模型的显存使用是一个动态过程短时间压测根本看不到KV Cache的累积效应。压测中重点观察三个指标吞吐量、P99延迟、GPU利用率。如果GPU利用率已经到98%但吞吐还是很低说明模型权重访存是瓶颈需要从模型量化方向优化如果GPU利用率只有30%、但CPU已经打满说明在数据预处理阶段有瓶颈需要检查tokenizer和请求解析部分的并发能力。6.2 踩过的三个真实大坑第一个坑是探针导致的流量雪崩。上线第二天发现服务每隔几分钟就会出现一批500错误查看日志发现是就绪探针的/health接口在服务高负载时返回超时K8s把所有副本标记为未就绪然后开始重启Pod。重启过程中Service把所有流量打到剩下还没被标记的Pod上导致这些Pod也超时最终整个服务短暂不可用。排查后解决方法是把就绪探针改成更轻量的接口不再参与模型状态的实时检查只判断进程是否还活着真正的模型加载状态检查交给启动探针去处理。同时在探针配置里把periodSeconds从2秒改成5秒避免频繁探活本身成为额外负载。第二个坑是显存碎片化导致的Pod调度失败。某个时间点开始新扩容出来的Pod一直Pending但看GPU节点的显存明明有剩余。后来发现是device-plugin报告的剩余显存是节点级聚合计但实际上整块显存已经被切分成了很多不连续的小块单个Pod至少要20GB连续显存而剩余的都是十几GB的碎片。这种情况没法靠调度器解决只能调整部署策略把碎片租给自己的小模型服务而不是全部留给大模型。第三个坑是滚动更新时新旧版本同时加载模型导致的显存超分。K8s的滚动更新策略默认先创建新Pod、等就绪后再销毁旧Pod但在推理服务场景下新版本Pod启动就会占用整卡显存如果部署时没有留出足够的空闲节点新Pod启动会直接把同节点的其它Pod挤到OOM。解决方法是把strategy.type改成Recreate或者使用原地升级的机制至少保证新旧Pod不会同时出现在同一张GPU卡上。6.3 优化前后的数据对比整套优化完成之后我做了一次完整压测对比指标优化前优化后提升幅度单副本吞吐12 QPS142 QPS约10.8倍P99延迟2.8s780ms降低约72%GPU显存利用率35%78%提升约1.2倍Pod冷启动时间70s6s降低约91%高峰期5xx错误率3.7%0%消除吞吐提升的核心来源是连续批处理和PagedAttention带来的batch效率提升延迟优化的主要来源是拓扑调度与探针配置的修正而服务规模的弹性伸缩则让整个系统在业务方突然猛拉流量的情况下能自动兜底不用半夜爬起来手动扩容。7. 几个可以继续做下去的优化方向这套部署体系跑通之后后续的优化空间仍然很大。如果你的业务对时延要求更高可以考虑把模型量化为INT8或INT4显存占用直接减半推理速度还能再提升30%到50%如果集群里有多个推理服务可以考虑部署GPU共享组件把节点上被单个Pod占用的整卡资源精细化切分给多个服务如果推理日志和指标需要接入内部的可观测平台可以在vLLM服务上挂载Prometheus指标暴露端口再通过Grafana把GPU利用率、QPS、队列长度做成实时监控大屏。我在实际使用中的体会是K8s上的推理加速不是某一个操作带来的单点提升而是一个从模型准备、资源调度、框架参数到弹性策略的系统工程。每一步看起来只提升了10%到20%串起来之后才会出现量级的跃升。上面这些配置和参数都是我在自己环境里验证过的不同的GPU型号、模型大小和业务负载会有差异但你照着这套思路去做至少不会走弯路。
返回列表