← 返回博客

工程实践

Serve 模式批量解码:GPU 专属的吞吐量杠杆,单流路径逐字节不变

一个默认关闭的批量解码路径,在 GPU 上成倍提升 serve 模式的聚合吞吐量,同时单请求路径与现有行为逐字节一致。
Serve 模式批量解码:GPU 专属的吞吐量杠杆,单流路径逐字节不变

通过 OPENASR_SERVE_BATCH 环境变量开启批量解码,在单张 GPU 上用 moonshine-tiny 实测 N=8 时达到 4.28 倍吞吐提升,而 N=1 路径与现有解码循环逐字节一致。

解码阶段,单条 ASR 流几乎用不满 GPU。每个 token 的计算是一次 batch=1 的 GEMV——受限于显存带宽而非算力:kernel 把整张权重矩阵从显存读一遍,只为算出一列输出,然后下一个 token 再来一遍。真正的开销不在乘加,而在搬数据。所以单条请求解码时,GPU 大部分时间是闲着的,等带宽而不是等计算单元。至于怎么加速单条流本身,那是另一条线——参见 把 KV cache 搬到 GPU 上

解法是经典思路:把 N 条并发流合并成每步一次批量 GEMM,替代 N 次独立的 GEMV。一次权重读取现在摊到 N 条序列上,正好把闲置的带宽利用起来。在 OpenASR 里,这件事落地为一个 n_seq 参数,贯穿 nn/decoder.rs 中共享的解码接缝,所以 qwen、cohere、moonshine、whisper 四个模型族都从同一处改动继承批量能力,而不是各自 fork 一份。批量 GEMM 和按 n_seq 展宽的算子本身通过 OpenASR 内置的 ggml 分支(third_party/openasr-ggml)执行——就是 llama.cpp / whisper.cpp 使用的那个 MIT 协议 C 库——跑在和单流解码完全相同的底层上。整个特性默认关闭、需要显式开启:不设环境变量,走的就是现有的 N=1 路径,没有任何变化。

实测曲线说了什么

吞吐量随批量宽度变化的 benchmark 是一个手动诊断测试(标记了 #[ignore]),不是 CI 门禁,只跑过一次。在单张 AMD RX 9060 XT 上,使用 moonshine-tiny q8_0 量化包,HIP 后端的聚合解码吞吐在 N=8 时达到 4.28 倍(从 370 tok/s 到 1588 tok/s),整个阶梯近乎单调递增。需要强调:这是相对于同一张 GPU 上 N=1 的批量倍数——不是跟其他引擎的对比,也不是在生产级大模型上的绝对收益。

后端差异很明显。Vulkan 没有同样的缩放行为:N=4 时峰值 2.85 倍,之后进入平台期,N=8 时 2.95 倍。这是一条趋平的曲线,不是线性增长——N=4 之后再加槽位,在这个后端上几乎没有额外收益。

CPU 上,批量反而有害——0.10 到 0.21 倍,也就是比逐条跑还慢。这是一个有价值的反面结论:它印证了这个收益本质上是 GPU 带宽现象。这也是引擎严格限制在 is_gpu_class() 通道、在 CPU 上直接回退的原因。

4.28 倍是在同一张 GPU 上相对于 N=1 的批量倍数——不是跨引擎加速比,也还不是生产级模型上的绝对收益。

—— 摘自 #35 serve 模式批量解码设计文档

默认关闭,安全回退

通过环境变量 OPENASR_SERVE_BATCH 开启,接受 2..8 范围的值,默认 MAX_BATCH 为 8。不设这个变量,serve 的行为和现在完全一样:单请求走退化的 N=1 步,不会被填充到批量宽度。在 CPU 或调度器路径上,批量引擎主动拒绝并回退到现有的单请求解码。

bash
# 开启批量 serve 模式解码
OPENASR_SERVE_BATCH=8 OPENASR_GGML_BACKEND=hip openasr serve --model moonshine-tiny
# 不设 OPENASR_SERVE_BATCH 则保持现有 N=1 路径

因为这个开关是 opt-in 的,且在 N=1 时坍缩为当前的 tensor 形状,所以关闭是安全的默认值,开启是一个有意识的、仅限 GPU 的选择。

N=1 逐字节一致

这个特性能以默认关闭的方式安全落地,关键在于单请求路径可证明地未被触碰。N=1 时,所有展宽的 tensor 都坍缩回现有的精确形状,算子序列和 kernel 选择与现有的 run_step_reused 驱动完全一致——输出是逐字节一致的,不是”近似相等”。

这个不变量是被强制保证的,不只是断言。真实模型包的一致性工作流(serve-batch-parity.yml)对公开的 moonshine-tiny 包在 CPU 后端上运行 6 项测试,作为合入 main 的门禁——而这些测试此前全部标记了 #[ignore],只能手动运行。背后有两个独立的不变量:其一,N=1 批量输出等于单流运行的结果;其二,N 条真正不同的序列一起批量处理,产出的 token 与 N 次串行单流运行完全相同。第二个不变量是单流一致性无法覆盖的——一个 ragged-mask bug 会产出看似合理但实际错误的文本,所以它需要单独的检查。

尚未测量的部分: 4.28 倍曲线是在 moonshine-tiny 上的相对 tok/s。此前有一个 5–7 倍(N=8)的估算值是基于 RTX 3060 基线得出的——那是估算,不是实测结果,而实际测量值低于这个估算。在生产级模型(qwen 或 whisper)上的端到端绝对收益尚未测量;对于这么小的模型,GPU 单流解码受启动开销主导,要看绝对收益需要更大的模型和 GPU 机器。测试框架已经具备跨模型族的移植能力,这个测量是下一步的工作。

诚实的总结

批量 serve 模式解码是一个真实的、默认关闭的吞吐量杠杆,针对的场景很明确:GPU 上对同一模型的并发请求。实测倍数是在单张 GPU、一个极小模型上 N=8 时的 4.28 倍;后端之间有差异(Vulkan 在约 2.9 倍处趋平);CPU 被设计排除在外。与之并行的单流路径逐字节一致,并由 CI 门禁保证。剩下的是需要我们还没指向它的硬件才能回答的部分:生产级模型上的绝对收益。我们宁可把这个限制说清楚,也不愿把它四舍五入掉。

查看完整技术细节

中文版以中文读者更容易理解的方式整理了核心结论。代码、测试条件和完整数据请参考英文原文。

阅读英文原文 →
标签GPU性能