2026年8月17日
队列变长之前,GPU 才真正有用
当按需服务器留有余量时,AMD Vulkan 路径可以降低渐进式 JPEGli 的延迟和能耗;随着机器接近满载,这项优势会逐渐消失。
图像缩放请求不会以规整的批次到达。有人上传图片、打开网页,或者没有命中缓存时,请求就来了。
一台健康的按需服务器,大部分时间只会用到一小部分容量。这不是浪费。正是这些余量,才不会让突发流量变成队列,再让队列变成延迟。
正是基于这个想法,我构思了这项实验。
我为 JPEGli 增加了一条 AMD Vulkan 路径,用于彼此不重叠的渐进式编码。在一台带 Radeon 780M 核显的 Ryzen 7 PRO 8700GE 上,以质量 75、渐进式 4:2:0 编码 30 张 CLIC 2025 测试图像,耗时从 21.9 ms 降到了 15.8 ms。p95 从 32.0 ms 降到了 20.8 ms。
生成的字节流保持不变。我不想通过修改扫描脚本,或者生成存储和传输成本更高的文件,来换取一个更快的编码器。每一组配对的 CPU 和 GPU 测试都生成了相同的字节数和校验和。
这也是为什么我特意使用 JPEGli,而不只是处理 JPEG 语法。Bruse 等人发现,在文件大小相同的情况下,用户更喜欢 JPEGli,而不是 libjpeg-turbo 或 MozJPEG 的图像 [1]。同样的字节数能换来更好的输出,因此这个编码器值得使用。本项目要降低它的计算成本,而不改变用户更喜欢的那些字节。
CPU 和 GPU 各做什么
CPU 负责输入转换、颜色变换、色度下采样、自适应决策、JPEGli 中必须串行的状态、Huffman 优化、熵编码和最后的位打包。
GPU 接手后面的块处理:电平移位、8×8 DCT、量化、Z 字形排序和渐进式 token 生成。
随后,CPU 合并剩余的跨块状态,并写出与没有 GPU 时完全相同的熵编码数据流。
这是为实际部署的 AMD APU 直接编写的 Vulkan 后端。它使用 wave64 操作和映射后保持一致的系统内存。CPU 和核显访问同一块内存,不需要通过 PCIe 把数据复制到独立显卡。我也没有在延迟路径里塞进一层跨厂商抽象。
这个接口是同步的。一个请求到达,GPU 协助编码,然后这个请求完成。它不等待凑批,也不等待后面的另一张图片。代码里另有一个给积压任务使用的流水线接口,但本次测试没有使用它。
此前的解码研究给出了两种值得参考的 CPU/GPU 分工。Sodsong 等人把串行 Huffman 解码留在 CPU 上,再根据图像熵、宽度和高度,把 IDCT、上采样和颜色转换分配给 CPU 与 GPU [2]。Weißenberger 和 Schmidt 更进一步:他们利用 Huffman 编码的自同步特性,从任意比特位置开始后重新找到正确边界,从而把一张 JPEG 的比特流拆成细粒度 GPU 工作 [3]。
第二种设计不只是把一批图片分别交给不同的 GPU 线程,而是真正处理一张图像内部看似串行的工作。不过,它只实现了 JPEG 的一个子集,测试对象也是 V100 和 A100 上每批 200 到 2400 张图像。我们在较小的 780M 上做精确渐进式解码时,GPU 仍然输给了 CPU。这里把解码留在 CPU 上,是因为实测更快,不是因为解码器的熵处理绝对无法并行。
服务器有余量时,GPU 才有帮助
接着,我分别让 1、2、3,直到全部 16 个工作进程持续繁忙。每个进程都编码同一组常驻内存的 30 张图像,配置为质量 75、渐进式 4:2:0,并优化 Huffman 表。每个工作进程数都运行三轮,图中使用中位数。
前 8 个工作进程分别绑定到 8 个物理核心。第 9 到第 16 个使用每个核心的第二个硬件线程。
只有一个活跃工作进程时,GPU 路径的请求处理能力提高 37.4%,单请求能耗降低 35.1%。4 个进程时,两项收益仍有 24.7% 和 19.7%。8 个进程时则是 10.8% 和 9.7%。
15 个进程时,两条路径已经相同。到了 16 个进程,纯 CPU 反而快 1.2%,单请求能耗低 1.3%。
功耗数据解释了这一点。只有一两个活跃进程时,GPU 路径不但完成得更快,封装功耗本身也更低。从 3 个进程开始,两种设计的大部分测试时间都接近 APU 共享的 42 W 封装功率上限。GPU 路径仍能通过更早完成来节省能耗,但随着 CPU 和 GPU 在同一封装里争抢资源,这个空间会越来越小。
为了确认质量 75 不是特殊情况,我把单工作进程测试扩展到质量 25 至 100,步长为 5。结果并没有改变。整个测试中,CPU JPEGli 的封装功耗为 27.4–28.9 W;GPU 路径为 23.4–24.5 W,在每个质量下都低 10.6–16.1%。
功耗不等于能耗。GPU 路径完成每组固定工作量的时间还缩短了 17.6–38.3%,因此同一质量测试中每张图像的封装能耗降低了 28.7–46.0%。
这些能耗数据来自 Linux 的处理器封装计数器,不是插座功率计。
Zhang 等人在 AMD 和 Intel 核显系统上测试了 42 个程序,也发现了同一种陷阱。只有 8 个程序在 CPU 与 GPU 同时运行时最快。架构差异和共享内存带宽让大多数程序只用 CPU 或只用 GPU 反而更好。在他们的 AMD 协同运行案例中,不同任务分配比例下的功耗变化不大,因此能耗主要由完成时间决定 [4]。这不能证明我们的 JPEG 曲线具体被哪项共享资源压平,却说明了为什么不能把单工作进程的收益直接乘以 16。
所以它不适合讲批量处理的故事,却很适合讲请求延迟。
余量让请求不必排队
服务器不会因为有 16 个逻辑 CPU,就从空闲突然变成永远同时运行 16 次编码。请求独立到达,有时会重叠,随后机器又会安静下来。
Dean 和 Barroso 把共享 CPU、缓存、内存和网络资源的争用列为延迟波动的来源,并说明多层队列会继续放大这种波动。他们给出的一条实用规则是让底层队列保持短小 [5]。他们讨论的是规模大得多的分布式服务,但这条规则同样适用于一台机器:空余容量能阻止突发流量变成等待。
我用每个活跃工作进程数下的实测完成速率建立了一个简单的队列模型。请求按泊松过程到达。最多可以同时编码 16 个请求,后来的请求需要等待。这是根据 1 到 16 个工作进程的实测曲线得到的估算,不是另一轮流量发生器测试。
| 每秒到达请求数 | CPU 活跃数 | GPU 活跃数 | CPU 平均响应 | GPU 平均响应 | 平均等待数,CPU / GPU |
|---|---|---|---|---|---|
| 50 | 1.15 | 0.85 | 23.0 ms | 17.0 ms | 0.000 / 0.000 |
| 100 | 2.42 | 1.82 | 24.2 ms | 18.2 ms | 0.000 / 0.000 |
| 200 | 5.65 | 4.45 | 28.3 ms | 22.3 ms | 0.005 / 0.002 |
| 250 | 8.00 | 6.58 | 32.4 ms | 26.6 ms | 0.100 / 0.062 |
| 300 | 11.17 | 9.96 | 41.3 ms | 36.8 ms | 1.229 / 1.069 |
| 330 | 13.47 | 12.89 | 58.7 ms | 59.1 ms | 5.909 / 6.604 |
每秒到达 200 个请求时,纯 CPU 平均有 5.65 次编码处于活跃状态,GPU 路径为 4.45 次。两边的队列实际上都是空的,而 GPU 路径把平均响应从 28.3 ms 降到了 22.3 ms。
每秒 300 个请求时,突发流量会让全部 16 个工作进程同时繁忙:CPU 路径约有 25% 的时间如此,GPU 路径约为 20%。GPU 仍能节省 4.5 ms,但平均响应已经上升到 37–41 ms。
接近每秒 330 个请求时,两种设计超过一半的时间都处于满载,平均各有大约 6 个请求在等待,平均响应也在 59 ms 左右相交。到了每秒 340 个请求,纯 CPU 会胜出,因为它在 16 个工作进程下的吞吐上限略高。
如果一台按需图像缩放服务器经常有 15 或 16 次编码同时运行,不应该庆祝利用率很高。队列已经开始把几毫秒的服务时间变成更长的响应时间。应该在这种状态成为常态之前增加实例。
分派规则
交叉点取决于图像会产生多少系数工作。对于噪声较多、系数密集的合成图像,图中质量 50 和质量 90 的交叉点分别是 151×151 和 131×131。对于简单渐变图像,则是 480×480 和 481×481。
简单渐变图的像素变化缓慢。经过 DCT 和量化后,大部分 AC 系数都是零,因此排序和渐进式 token 工作很少;对于小图,CPU 在 GPU 的启动成本收回之前就已经完成。噪声图处处都有剧烈变化,每个块会留下更多非零系数,因此尺寸小得多时就能为 GPU 提供足够的并行工作。这两种合成图像代表工作量的两个端点;生产环境根据实测系数密度分派,而不是给图片贴上“渐变”或“噪声”标签。
尺寸测试使用质量 50 和质量 90 的正方形合成图像,编码方式为渐进式 4:2:0,每个点编码 11 次。图中数值是 CPU 中位耗时除以 GPU 中位耗时,因此高于 1.0× 代表 GPU 更快。
这条分派规则可以看作 Sodsong 等人模型的编码端版本 [2]。图像尺寸估算有多少个块,量化系数密度估算这些块会产生多少渐进式 token 工作。两者都要看,因为尺寸相同的两张图片可能给 GPU 带来完全不同的工作量。
我目前使用的规则是:
渐进式 4:2:0 输出:
系数密集,且至少为 256x256 -> 同步 GPU 路径
内容简单,且至少为 512x512 -> 同步 GPU 路径
更小 -> CPU JPEGli
基线 JPEG -> CPU JPEGli
解码和缩放 -> CPU
生产环境可以使用 JPEGli 已经得到的量化系数密度来判断内容。它不应该为了凑批而延迟请求。
这项工作的结论刻意保持在一个很小的范围内:在这块 Radeon 780M 上,GPU 能从典型 2–4 MP 按需请求的渐进式编码阶段减少约 6 ms 和三分之一的封装能耗,同时生成完全相同的 JPEG 字节。随着活跃任务接近机器上限,优势会逐渐消失。这没有关系,因为对延迟敏感的服务本来就应该在长期进入这个区域之前横向扩容。
这个后端也能识别 Ryzen AI Max+ 395 与 Radeon 8060S,但我还没有在那套硬件上测量。代码位于 amd-vulkan-progressive-encode 分支。
参考文献
- Martin Bruse、Luca Versari、Zoltan Szabadka 和 Jyrki Alakuijala。“Users Prefer Jpegli over Same-sized libjpeg-turbo or MozJPEG.” arXiv:2403.18589,2024。
- Wasuwee Sodsong、Jingun Hong、Seongwook Chung、Yeong-Kyu Lim、Shin-Dug Kim 和 Bernd Burgstaller。“Dynamic Partitioning-based JPEG Decompression on Heterogeneous Multicore Architectures.” arXiv:1311.5304,2013。
- André Weißenberger 和 Bertil Schmidt。“Accelerating JPEG Decompression on GPUs.” 2021 IEEE 28th International Conference on High Performance Computing, Data, and Analytics (HiPC),121–130,2021。
- Feng Zhang、Jidong Zhai、Bingsheng He、Shuhao Zhang 和 Wenguang Chen。“Understanding Co-Running Behaviors on Integrated CPU/GPU Architectures.” IEEE Transactions on Parallel and Distributed Systems 28(3),905–918,2017。DOI: 10.1109/TPDS.2016.2586074。
- Jeffrey Dean 和 Luiz André Barroso。“The Tail at Scale.” Communications of the ACM 56(2),74–80,2013。DOI: 10.1145/2408776.2408794。