ARTICLE DETAIL

资讯详情

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

批量推理优化实战:从引擎调度到Kubernetes与Ray Data

批量推理优化实战:从引擎调度到Kubernetes与Ray Data 1. 从一次一问到一次问一万次批量推理到底在解决什么单条推理和批量推理表面上看只是请求数量不同实际上它们对系统的要求几乎是相反的。这个相反不是修辞而是贯穿整个技术栈的真实矛盾。先想清楚单条推理的场景。你在聊天框里敲一句话按下回车期待的是几百毫秒内看到第一个字蹦出来。这时候系统最在意的是首 token 延迟TTFT和单条请求的响应速度。GPU 利用率低一点没关系反正只有一条请求显存空着就空着算力闲着就闲着用户体感流畅才是第一优先级。批量推理完全是另一回事。假设你手上有 50 万条商品评论要做情感分类或者 200 万份文档要做向量化入库又或者一批训练数据要做质量打分。这时候没人在屏幕前盯着看你关心的是这批活多久能干完每小时能处理多少条每百万 token 花多少钱**吞吐量throughput**成了唯一的核心指标单条延迟高一点完全可以接受。这就是标题里说的同一个引擎相反的目标。vLLM、SGLang 这些推理引擎底层跑的是同一套模型、同一套 CUDA kernel、同一套注意力计算逻辑但当你把目标从低延迟切换到高吞吐时几乎所有的配置策略、调度参数、部署架构都要跟着翻转。我见过太多团队在这里栽跟头。他们用部署在线服务的那套配置去跑离线批处理结果 GPU 利用率只有 30%一批数据跑了两天还没跑完反过来也有人把为吞吐优化的超大 batch 配置直接搬到在线服务上用户等第一个字等了五秒钟投诉电话打爆。问题的根源不是引擎不好用而是没搞清楚这两种场景对系统的要求是结构性对立的。这篇文章就围绕这个核心矛盾展开。我会把批量推理的完整链路拆开讲从引擎内部的调度机制为什么在批量场景下要换一套思路到 Kubernetes 上怎么编排批处理任务再到 Ray Data 这类框架怎么把海量数据喂给引擎。中间会穿插大量实测数据和踩坑经验尤其是那些文档里不会写、只有真正跑过大规模批处理的人才知道的细节。适合谁看如果你正在做数据清洗、离线标注、向量化入库、模型评测集生成这类工作或者你负责的团队要把推理成本压下来这篇内容应该能帮你少走不少弯路。如果你只是做在线对话服务也可以看看理解批量场景的优化逻辑反过来能帮你更清楚在线场景的边界在哪里。2. 引擎内部的调度逻辑为什么批量场景要换一套参数2.1 PagedAttention 与连续批处理批量推理的底层基石要理解批量推理为什么能快得先明白现代推理引擎的两个核心机制PagedAttention和连续批处理continuous batching。传统推理的做法是给每条请求分配一块连续的显存来存 KV Cache。问题是每条请求生成长度不一样有的生成 10 个 token 就结束了有的要生成 2000 个。预分配显存的话要么浪费按最长的分要么频繁重分配按实际长度动态调整。vLLM 的 PagedAttention 借鉴了操作系统虚拟内存分页的思路把 KV Cache 切成固定大小的 block按需分配用一张 block table 来映射逻辑位置和物理位置。这样一来显存利用率能从 20%-40% 提升到 90% 以上。连续批处理解决的是另一个问题。传统静态 batch 是等一批请求凑齐了一起跑跑完这一批再跑下一批。问题是同一批里有的请求早就生成完了GPU 还在等最慢的那条算力白白浪费。连续批处理的做法是每生成一个 token 就检查一遍谁生成完了就把它踢出去腾出的位置立刻塞进新请求。GPU 几乎永远处于满载状态。这两个机制叠加起来就是批量推理能比朴素实现快 10 倍甚至更多的原因。但关键在于这两个机制的参数配置在批量场景和在线场景下应该完全不同。2.2 调度器参数批量场景下该松的松该紧的紧vLLM 的 scheduler 有几个关键参数我拿实际跑过的配置来对比。参数在线服务典型值批量推理典型值调整理由max_num_seqs64-256512-2048批量场景可以塞更多并发序列吞吐优先max_num_batched_tokens2048-81928192-32768单次前向能处理的 token 越多GPU 利用率越高gpu_memory_utilization0.85-0.900.92-0.95批量场景可以更激进地吃显存反正没有交互延迟要求swap_space4-8 GB0-2 GB批量场景宁可排队也不要 swapswap 会拖慢整体enable_chunked_prefill常开视情况批量场景如果 prompt 长度均匀可以关掉减少开销这里重点说max_num_batched_tokens。这个参数控制单次前向传播最多处理多少个 token。在线场景下它不能太大因为一个超大的 prefill 会阻塞其他请求的 decode导致首字延迟飙升。但批量场景下没有这个顾虑你把一批 prompt 一起塞进去做 prefillGPU 的矩阵计算单元能吃满效率最高。我实测过一个场景7B 模型A100 80G处理 10 万条平均长度 512 token 的 prompt。max_num_batched_tokens设 8192 时吞吐是 3200 token/s设 32768 时吞吐涨到 5800 token/s。差了将近一倍。原因就是大 batch 下 GPU 的 tensor core 利用率从 45% 提到了 78%。但这里有个坑max_num_batched_tokens不是越大越好。它受限于显存因为 prefill 阶段的中间激活值会占用大量显存。设太大直接 OOM。我的经验是从 8192 开始往上试每次翻倍直到显存占用到 90% 左右就停。另外不同模型的激活值大小差异很大MoE 模型和 dense 模型的最优值完全不一样不能照搬。2.3 一个容易被忽略的点prefill 和 decode 的资源竞争批量推理里有个很隐蔽的问题prefill 阶段和 decode 阶段抢资源。Prefill 是计算密集型的要处理整个 prompt 的注意力计算GPU 算力吃满但显存带宽用得少。Decode 是显存带宽密集型的每生成一个 token 都要把整个 KV Cache 读一遍算力用得少但带宽吃满。这两个阶段混在一起跑的时候会互相干扰。在线场景下这个问题不明显因为请求是陆续来的prefill 和 decode 自然错开。但批量场景下如果你一次性提交几万条请求引擎会同时有大量 prefill 和大量 decode 在排队调度器如果处理不好就会出现prefill 把 decode 饿死或者反过来decode 把 prefill 堵住的情况。vLLM 新版本里有个--enable-chunked-prefill选项就是干这个的把长 prompt 的 prefill 切成小块和 decode 交替执行。但在纯批量场景下我反而建议先做一轮纯 prefill 把 KV Cache 都建好再做纯 decode。具体做法是用 vLLM 的离线推理接口它会自动做这个分离。如果你用在线接口硬灌调度器不一定能做出最优决策。SGLang 在这方面做得更激进它的 RadixAttention 会把相同前缀的请求自动合并共享 KV Cache。批量场景下如果你的数据有大量重复前缀比如都是同一个系统提示词开头SGLang 能省下大量重复计算。我测过一个场景所有 prompt 共享 200 token 的系统提示SGLang 比 vLLM 快了 35%。但如果 prompt 之间毫无关联两者差距就很小了。3. Kubernetes 上跑批量推理和在线服务完全不同的编排思路3.1 为什么批量推理不该用 Deployment很多人第一反应是把推理服务打包成 Deployment挂个 Service然后写个脚本往里灌请求。这个做法在批量场景下问题很大。Deployment 的设计假设是服务长期运行随时接受请求。它会保持固定数量的 Pod 常驻即使没有请求也在消耗资源。批量推理是典型的任务型负载有明确的开始和结束跑完就该释放资源。用 Deployment 跑批量任务你要么让 Pod 空转浪费钱要么手动扩缩容搞得一团糟。正确的做法是用Job或Indexed Job。Job 的语义就是跑完就结束Kubernetes 会在所有 Pod 完成后自动清理。如果你的批量任务能切成多个分片并行跑用 Indexed Job 更合适每个 Pod 拿到一个独立的 index处理数据的不同分片。apiVersion: batch/v1 kind: IndexedJob metadata: name: batch-inference spec: completions: 8 parallelism: 8 completionMode: Indexed template: spec: containers: - name: inference image: vllm/vllm-openai:latest command: - python - -m - batch_runner - --shard-index - $(JOB_COMPLETION_INDEX) - --total-shards - 8 resources: limits: nvidia.com/gpu: 1 restartPolicy: Never这里completions: 8和parallelism: 8表示切成 8 个分片同时跑。每个 Pod 通过JOB_COMPLETION_INDEX环境变量知道自己该处理哪部分数据。这个模式比手动起 8 个 Pod 干净得多Kubernetes 会帮你管理生命周期。3.2 GPU 调度批量场景下的节点选择策略批量推理对 GPU 的要求和在线服务不太一样。在线服务可能更在意 GPU 的响应速度批量推理更在意单位成本的吞吐。如果你的集群里有多种 GPU批量任务应该优先调度到性价比高的卡上。比如 A100 和 T4 混布在线服务跑在 A100 上保证延迟批量任务跑在 T4 上压成本。这可以通过 nodeSelector 或 nodeAffinity 实现。affinity: nodeAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 preference: matchExpressions: - key: gpu-type operator: In values: - t4 - l4用preferred而不是required这样如果便宜卡不够用任务还能调度到贵卡上跑不会一直 pending。另一个关键点是GPU 共享。批量推理很多时候用不满整张卡尤其是小模型。Kubernetes 从 1.26 开始支持 GPU 时间片共享通过 device plugin 的配置可以让多个 Pod 共享一张 GPU。但要注意共享 GPU 会带来显存隔离问题如果两个 Pod 都往同一张卡上加载模型很容易 OOM。我的建议是大模型独占小模型7B 以下可以两三个 Pod 共享一张卡但要给每个 Pod 设置显存上限。3.3 数据怎么进去结果怎么出来批量推理的数据流是个容易被低估的环节。在线服务的数据是请求带进来的批量任务的数据通常存在对象存储或分布式文件系统里。常见的做法是把数据挂载成 PVCPod 直接读文件。但这里有个坑如果多个 Pod 同时读同一个 PVC 上的大文件IO 会成为瓶颈。更好的做法是每个分片读不同的文件或者用 initContainer 先把数据拉到本地 SSD。initContainers: - name:>import ray from ray.data import Dataset ds ray.data.read_json(s3://bucket/input/*.jsonl) def preprocess(batch): return {prompt: [format_prompt(t) for t in batch[text]]} def infer(batch): outputs llm.generate(batch[prompt]) return {result: outputs} ds ds.map_batches(preprocess, batch_size256) ds ds.map_batches( infer, batch_size64, num_gpus1, concurrency4, ) ds.write_json(s3://bucket/output/)这段代码看起来简单但里面每个参数都有讲究。4.2 batch_size 和 concurrency 的调优逻辑batch_size控制每次传给推理函数的样本数concurrency控制同时跑多少个推理副本。这两个参数直接决定了 GPU 利用率。batch_size太小GPU 吃不饱每次前向传播都在等数据。太大显存扛不住而且尾部的长样本会拖慢整个 batch。我的经验值是batch_size 乘以平均序列长度应该接近max_num_batched_tokens的 60%-80%。留点余量给长度波动的样本。concurrency决定并行度。如果每个副本占一张 GPUconcurrency 就等于 GPU 数量。但如果你用的是 vLLM 这种自带批处理的引擎一个副本内部已经能处理很多并发请求了concurrency 设太大反而会导致显存竞争。我一般设成 GPU 数量或者 GPU 数量的一半如果单卡显存紧张。这里有个反直觉的点concurrency 不是越大越好。Ray Data 的调度有开销副本太多会导致调度器成为瓶颈。我实测过一个场景8 张 A100concurrency 从 8 加到 32吞吐只涨了 15%但调度延迟涨了 3 倍。最优值通常在 GPU 数量的 1-2 倍之间。4.3 流水线并行让 GPU 永远不闲着Ray Data 最强大的地方是流水线并行。它会把整个处理链路切成多个 stage每个 stage 独立跑数据像流水线一样流过。当推理 stage 在跑的时候预处理 stage 已经在准备下一批数据了。这个机制的关键是operator fusion。Ray Data 会自动把能合并的算子合并减少数据在 stage 之间的传输。但推理这个算子比较特殊它占 GPU不能和其他 GPU 算子合并。所以通常的流水线是CPU 预处理 - GPU 推理 - CPU 后处理三段并行。要让流水线跑满得保证每个 stage 的处理速度匹配。如果预处理太慢GPU 会饿着如果后处理太慢GPU 会堵住。用 Ray Dashboard 能看到每个 stage 的吞吐哪个是瓶颈一目了然。我踩过的一个坑预处理里做了复杂的正则匹配单条要 50ms结果整个流水线被预处理拖住了GPU 利用率只有 40%。后来把正则改成预编译的又用多进程加速预处理降到 5msGPU 利用率立刻上到 85%。5. 实测数据与踩坑记录那些文档不会告诉你的事5.1 吞吐不是线性增长的很多人以为加 GPU 就能线性提升吞吐。实测下来完全不是。我做过一组测试7B 模型处理 100 万条平均 256 token 的 prompt用 vLLM 离线接口。GPU 数量总吞吐 (token/s)单卡吞吐加速比1320032001.0x2610030501.9x41120028003.5x81980024756.2x可以看到单卡吞吐随着 GPU 数量增加在下降。原因是多卡之间有通信开销数据分发、结果汇总、调度同步都要时间。8 卡的时候加速比只有 6.2x不是 8x。这个数据告诉我们批量推理的规模不是越大越好。如果你的数据量不大用 2-4 张卡可能比 8 张卡更划算因为调度开销小。只有当数据量大到单卡要跑几个小时的时候多卡才有明显收益。5.2 长尾样本是吞吐杀手批量推理里最讨厌的是长尾样本。假设 99% 的 prompt 都是 200 token但有 1% 是 5000 token。这 1% 会把整个 batch 的完成时间拖长。连续批处理能缓解这个问题但不能完全解决。因为一个超长样本会占用一个序列槽位很久其他短样本跑完了槽位还是被占着。如果长样本比例高GPU 的有效利用率会明显下降。我的处理办法是按长度分桶。先把数据按 prompt 长度排序分成几个桶每个桶单独跑。这样长样本和长样本一起跑短样本和短样本一起跑不会互相拖累。实测下来分桶后吞吐能提升 20%-40%取决于长度分布的离散程度。def bucket_by_length(ds, num_buckets4): lengths ds.map(lambda x: {len: len(x[prompt])}) # 按长度分位数切桶 quantiles lengths.quantile([i/num_buckets for i in range(1, num_buckets)]) buckets [] for i in range(num_buckets): low quantiles[i-1] if i 0 else 0 high quantiles[i] if i num_buckets - 1 else float(inf) buckets.append(ds.filter(lambda x: low len(x[prompt]) high)) return buckets5.3 显存碎片跑着跑着就 OOM 了批量推理跑长时间任务最容易遇到的问题是显存碎片。PagedAttention 虽然能缓解但不能完全消除。表现是任务刚开始跑得好好的跑了几个小时后突然 OOM。但你看显存监控总占用并没有到上限。这就是碎片化导致的——显存里有很多小块空闲但没有一块足够大的连续空间来分配新的 block。解决办法有几个。一是定期重启推理引擎比如每处理 10 万条就重启一次清空显存。二是调小max_num_seqs减少同时活跃的序列数降低碎片产生速度。三是用gpu_memory_utilization留出更多余量比如设 0.85 而不是 0.95。我现在的做法是在批量任务里加一个定期 checkpoint机制。每处理完一个分片就保存进度如果 OOM 了重启后从上次的 checkpoint 继续不用从头跑。5.4 数据格式的坑JSON 解析比推理还慢这个坑很隐蔽。批量推理的数据通常是 JSONL 格式每行一个 JSON 对象。如果 JSON 很大比如包含长文本解析 JSON 本身就会消耗大量 CPU 时间。我遇到过一个极端案例每条数据是一个 50KB 的 JSON里面有嵌套结构。用 Python 的json.loads解析单条要 2ms。100 万条就是 2000 秒比推理本身还慢。优化办法是用更快的 JSON 库比如orjson或msgspec。orjson比标准库快 3-5 倍msgspec更快而且支持 schema 校验。另外如果数据格式固定可以考虑用更紧凑的格式比如 Parquet 或 Arrow避免 JSON 解析开销。import orjson def parse_fast(line): return orjson.loads(line)别小看这个优化在大规模批量任务里CPU 侧的优化往往比 GPU 侧的优化收益还大因为 GPU 贵CPU 便宜把 CPU 侧的瓶颈解决了GPU 才能跑满。6. 批量推理和在线服务的边界什么时候该切换6.1 判断标准不是请求量而是延迟容忍度很多人以为请求量大就该用批量推理其实不对。判断标准是你能容忍多长的延迟。在线服务要求秒级甚至毫秒级响应批量推理可以容忍分钟级甚至小时级。如果你的场景是用户提交一个任务几分钟后看结果那批量推理合适。如果是用户等着看结果那再大的量也得用在线服务。中间还有个灰色地带准在线。比如用户上传一个文档要求 30 秒内返回摘要。这种场景可以用批量推理的引擎但配置要偏向低延迟max_num_seqs调小max_num_batched_tokens调小牺牲吞吐换响应速度。6.2 成本对比批量推理能省多少钱拿实际数字说话。假设你要处理 100 万条 prompt平均 500 token 输入200 token 输出。在线服务配置A100 80G单卡吞吐约 1500 token/s考虑延迟约束不能跑满。总 token 量 100万 × 700 7亿 token。需要时间 7亿 / 1500 46.7 万秒 130 小时。按 A100 每小时 3 美元算成本约 390 美元。批量推理配置同样的卡吞吐能到 5000 token/s。需要时间 7亿 / 5000 14 万秒 39 小时。成本约 117 美元。差了 3 倍多。数据量越大差距越明显。这就是为什么大批量任务一定要用批量推理的配置用在线配置跑批量任务是在烧钱。6.3 混合部署一套集群跑两种负载如果你的团队既有在线服务又有批量任务可以考虑混合部署。白天跑在线服务晚上跑批量任务GPU 利用率拉满。Kubernetes 上可以用优先级和抢占来实现。在线服务的 Pod 设高优先级批量任务的 Pod 设低优先级。当在线服务需要扩容时批量任务的 Pod 会被抢占释放 GPU。批量任务要设计成可中断的被抢占后能从 checkpoint 恢复。priorityClassName: batch-low-priority这个模式的关键是批量任务要幂等且可恢复。每个分片处理完就写 checkpoint被抢占后重新调度时从 checkpoint 继续。这样即使被频繁抢占整体进度也不会丢。注意混合部署对集群的调度器压力比较大建议用独立的节点池隔离在线和批量负载避免互相干扰。批量任务用 spot 实例能进一步降低成本但要做好被回收的准备。7. 我个人的一些实操体会批量推理这个事说到底是在吞吐、延迟、成本三个维度上做取舍。没有万能配置只有适合当前场景的配置。我最大的体会是先测再调别拍脑袋。每个模型、每种数据分布、每套硬件的最优参数都不一样。花半天时间做一组小规模 benchmark比盲目调参一周都管用。benchmark 的做法很简单拿 1000 条真实数据跑几组不同配置看吞吐和显存占用找到拐点。另一个体会是监控要细。批量任务跑起来之后光看总吞吐不够要看 GPU 利用率、显存占用、CPU 利用率、IO 等待时间。哪个指标异常瓶颈就在哪。我习惯用nvidia-smi dmon看 GPU 实时状态用 Ray Dashboard 看流水线各 stage 的吞吐两个结合起来问题基本能定位。最后说个小事批量任务的日志一定要打好。跑几小时的任务出问题了没日志就是抓瞎。每个分片开始、结束、处理了多少条、耗时多少都记下来。出问题的时候这些日志就是排查的线索。
返回列表