我最初是在找图像压缩相关的资料时看到GPU Pro 4第6.2章的,标题写着“Real-Time JPEG Compression Using DirectCompute”,当时第一反应是:JPEG压缩这种老掉牙的格式,CPU上随便压一张图也就是几毫秒的事,至于专门放到GPU上去做吗?但仔细往下想,游戏截图、高速连拍、视频帧序列、远程传输这类场景里,压缩的对象不是一张图,而是一秒钟几十帧的连续画面,累计起来的数据量就完全不是一个量级了。读完那一章又自己上手复现了一遍之后,我才意识到这章内容的价值不止在于“把JPEG搬到GPU上跑”,更在于它把Compute Shader的并行思想完整地套进了一个非常经典的串行算法链条里,中间的取舍和踩坑相当有代表性。
这篇文章就当作我的实践笔记来写:先梳理为什么需要GPU做JPEG压缩,再把算法每个阶段的并行化思路拆开讲清楚,最后落地到工程实现、性能数据和优化建议。适合对DirectCompute有一定基础、想了解真实图形API在图像编解码上怎么发力的开发者,也适合正在做实时图像传输或录屏工具的工程师。
1. 先想明白:实时JPEG压缩为什么非要动GPU
1.1 CPU单帧压缩很快,但连续帧场景才是真正的瓶颈
先说一个容易被忽略的事实:CPU做JPEG压缩本身并不慢。用libjpeg-turbo这类SIMD优化过的库,一张1920x1080的图片在主流桌面CPU上压缩到中等质量,耗时通常在5到15毫秒之间。如果是偶尔截个图、存个图,这点时间几乎感知不到。但问题出在“实时”和“连续”这两个词上。
想象一个录制4K@60帧游戏画面的工具:每帧约830万像素,60帧就是每秒约5亿像素需要处理。假设每帧压缩耗时10毫秒,CPU光是做压缩就占掉60%以上的时间,这还没算色彩转换、格式封装、磁盘写入的开销。再比如工业检测相机或者医学内窥镜,每秒输出几十帧高分辨率Raw图像,CPU根本没有余量同时处理其他业务逻辑。这个时候,压缩就不再是一个“偶尔跑一次的工具”,而是一个必须与数据生产速度保持同步的流水线环节。
另一个更现实的问题(出现过很普遍)是CPU占用率。即便压缩耗时勉强能接受,压缩模块把CPU核心吃满,也会拖累同机运行的游戏主逻辑、编码器线程或者网络服务。把这些像素级的重复劳动搬到GPU上,CPU就能腾出来处理真正复杂的控制逻辑。
1.2 DirectCompute在这个场景里比OpenCL和CUDA更顺手
既然要上GPU,接下来的问题就是选什么API。DirectCompute的优势首先在于它和D3D生态的天然亲和。如果程序本身就是一个D3D应用(游戏、渲染器、播放器),用DirectCompute不需要引入额外的运行时,也不需要在OpenCL和图形上下文之间做显式的共享内存互操作。同一个ID3D11Device既可以创建渲染管线,也可以创建Compute Shader,纹理资源能直接被计算着色器绑定为输入。
相比CUDA,DirectCompute的驱动门槛也更均匀。CUDA只适用于NVIDIA硬件,而DirectCompute在NVIDIA、AMD、Intel的显卡上都有完整支持。对于需要覆盖多品牌显卡的录屏软件或图像处理工具来说,这是非常现实的优势。虽然DirectCompute在纯计算性能上可能比不过针对特定硬件深度优化的CUDA库,但对JPEG压缩这种以访存和整数运算为主的工作负载,差距并不明显。
还有一个不太容易被注意到的点:DirectCompute可以直接操作D3D纹理。你渲染出来的游戏画面本就在GPU的显存里,如果用CPU压缩,必须先回读到内存,像素格式还得从RGBA转成压缩库要求的格式,这一来一回的时间足以吃掉所有余量。而DirectCompute方案可以直接把渲染目标纹理绑定为SRV(Shader Resource View)当成输入,压缩整个流程都留在GPU上,只有最终压缩好的JPEG字节流需要拷贝回CPU。
1.3 真正需要这种方案的场景画像
结合我自己的实践,适合用GPU JPEG压缩的核心场景大致有三类:录屏与串流工具(需要实时编码正在渲染的画面而且不能影响帧率)、机器视觉与工业检测(高分辨率、高帧率、低延迟图像流需要快速落盘)、以及医学影像/卫星影像这类超高分大图的批量处理。它们的共同特征是:数据产生速度极高,压缩模块不能占用太多CPU,且多帧之间的压缩质量要求相对稳定。
2. 把JPEG算法的每个阶段逐个摊到GPU面前看并行机会
JPEG压缩的完整流程就是一条固定流水线:色彩空间转换、色度下采样、8x8分块、DCT(离散余弦变换)、量化、熵编码(Huffman编码)。每条流程在CPU上都是串行处理的。要在GPU上重写这套流程,先得搞清楚每个阶段的天生并行度,这也是GPU Pro 4那一章最开始分析的东西。
2.1 RGB到YCbCr转换与色度下采样:数据并行度最高的部分
RGB转YCbCr本质是对每个像素做一次矩阵运算,像素与像素之间没有任何依赖。这种逐像素操作是GPU最喜欢的负载,每个线程处理一个或几个像素,直接从全局内存或纹理读取RGBA值,算出Y、Cb、Cr三个分量后写入目标缓冲。这一阶段在GPU上的吞吐量几乎可以达到显存带宽的上限。
色度下采样稍复杂一些,因为存在相邻像素之间的聚合关系。常见的4:2:0格式里,每2x2像素块共享一组Cb、Cr分量,相当于要做一次小规模的高斯池化。实现上可以用每个线程处理一个2x2块的做法,读取4个像素后计算平均或加权平均,这样共享内存完全没有压力,也避免了对全局内存的重复访问。
这里有一个容易被忽略的细节:Y分量必须保留全分辨率,而Cb、Cr分量降采样后只有四分之一大小。如果按平面格式存储这三个通道,需要注意内存布局的连续性。GPU上比较推荐的布局是直接把Y和Cb、Cr分成三块连续缓冲区,后续DCT阶段可以分别绑定处理,避免交错排列带来的寻址开销。
2.2 DCT与量化:一个块处理问题,天然适合一线程一块
JPEG标准里,DCT是对一个个8x8像素块独立进行的。这种分块特性与GPU的线程组织方式几乎是完美匹配:每个线程独立处理一个8x8块,线程之间不共享任何数据,不需要同步,也不会产生访存冲突。在GPU上进行二维DCT时,通常的做法是先对块内8行分别做一维DCT,再对8列做一维DCT。这个过程可以拆成两个Pass,也可以在一个内核里通过共享内存完成行列变换。
在实际编码时,如果选择“一个线程负责一个块”的策略,寄存器里需要放一个8x8的float数组(或者short数组),再加上量化表系数,压力不算大。每个块的处理流程是:从全局内存读入64个像素值,减去128进行直流电平位移,执行两次一维DCT,与量化表做除法(或乘法倒数),得到64个量化后的系数,再排序写入全局内存供后续熵编码阶段使用。
量化阶段最大的优化空间在于查表。JPEG标准允许自定义量化表,但一组量化表在整帧压缩过程中是固定不变的。可以把量化表预先转换成倒数形式,把除法变成乘法,提高速度。DCT本身用浮点还是整数实现也是值得考量的点。浮点DCT精度高,但在某些GPU架构上FP32吞吐有限;整数近似DCT用位运算和整数乘加更快,JPEG解码端有对应的近似版本可以匹配,质量损失通常在可接受范围内。
2.3 量化表的加载策略与精度控制
量化表在压缩过程中是被所有线程共享读取的常量数据。最理想的加载策略是把它放到Constant Buffer里,或者更优地放到Group Shared Memory里让整个线程组共享一份拷贝。千万不要让每个线程都从全局内存读取量化表,尽管纹理缓存可能命中,但无谓的带宽占用在优化时很容易成为瓶颈。
另一个需要关注的点是量化表缩放逻辑。JPEG编码器通常通过“质量因子”动态调整量化表:质量越高,量化步长越小,压缩率越低但画质越好。我见到的实现里有直接在Shader里按质量因子对量化表做线性缩放的,也有预计算多档量化表然后切换绑定的。前者更灵活但每次切换质量参数时要重新计算一遍表;后者实现简单但固定档位不够细腻。综合来看,预计算几个档位(比如90、75、50)在绝大多数实际场景中已经够用。
2.4 熵编码:唯一不适合简单并行的阶段
如果JPEG压缩里有一个环节让GPU优化无从下手,那一定是Huffman熵编码。JPEG的熵编码有两种模式:Huffman编码和算术编码。算术编码在软件里都很少用,GPU上更是几乎没人这么干;绝大多数实现都选Huffman编码。
可Huffman编码对并行不友好。原因有二:一是码长可变,前一个符号需要多少比特只有编码完才知道,后人确定写入位置;二是JPEG标准里还规定了在遇到连续零值系数时要用游程编码(RLE)做中间表示,生成(CATEGORY, RUNLENGTH)这样的符号对,再对符号对做Huffman查表,最后还要把DC系数做差分编码(Delta),因为相邻块DC系数的比特流之间有依赖关系。
这三个特性叠加在一起,导致GPU实现Huffman编码时不能像DCT那样一个线程独立完成任务。处理不好,熵编码阶段就会从“并行瓶颈”变成“全流程石乐志”,甚至比直接在CPU上做更慢。
3. 章节里的核心工程方案:怎么把Huffman编码塞进Compute Shader
第6.2章最核心的贡献之一,就是针对熵编码的并行化给出了一套可落地的方案。它的思路不是去并行化单个符号的编码,而是把整帧图像切分成大量独立的编码任务,任务内部仍然保持串行Huffman编码逻辑,任务之间并行执行。这个思路现在看仍然是最务实的。
3.1 把图像切成“条带”,以条带为基本并行单元
所谓任务切分,就是先把整帧图像按水平方向切成多组8像素高的水平条带(strip),每组条带包含若干完整8x8块。条带与条带之间在标准JPEG里本来就可以独立编码,前提是每一条带都插入独立的Huffman表(或者说允许它们使用独立的Huffman表),且DC差分预测在条带边界处重新置零。
这样做的直接好处是:不同条带之间没有任何数据依赖,可以被不同的线程组并行处理。每条带内部的任务量足够大,能保证GPU利用率和吞吐量,又避免了把粒度切得太碎导致启动开销过大。每个线程组负责一个条带,内部再用多个线程协同完成该条带内各个块的编号。
实际操作中块的顺序很关键。JPEG规定块按光栅扫描顺序编码,一个条带里通常是几十到上百个块。把这些块编码后得到的比特流按顺序拼接在一起。多个线程并行写同一个输出缓冲时需要使用原子操作来分配写入偏移。比较常见的做法是每个线程组先统计自己编码块的总比特数,然后通过原子加法为整个条带在输出缓冲区中预留连续空间。这样组与组之间互不干扰,组内再把条带空间拆成多个子区间,每个线程负责其中一个块区间的写入,用原子操作解决并发写偏移分配。
3.2 Huffman查表不是拿来主义,需要定制符号缓冲
标准Huffman编码器的查表流程是先基于M系数和游程长度映射出一个符号索引,再查表得到码长和码字。CPU实现可以直接对整个16位查表结构做索引,但GPU上直接照搬会面临两个问题:一是表太大,放不进共享内存;二是不同符号的出现概率差异非常大,直接查全量表会造成严重的缓存浪费。
更好的做法是拆成两级查询:先用系数和游程长度联合计算一个中间键,把中间键对应的码长和码字分别放入两个紧凑的查表数组里。这种紧凑格式可以减少缓存行占用,而且可以针对DC和AC系数分别建表。GPU纹理的线性过滤模式在查表时也能派上用场,可以通过纹理单元的内置缓存加速高频符号的随机访问。
我之前自己实现的时候就没有注意这一点,最开始直接拿CPU版本的表结构改成纹理绑定,结果性能比预期差了很多。后来把大表改成紧凑表,用16位整数存储码字、8位整数存储码长,性能才恢复正常,缓存命中率明显提升。
3.3 DC差分编码依赖怎么处理
DC差分编码是Huffman阶段最讨厌的串行依赖。每个8x8块的DC系数不是直接编码,而是编码它与前一个块的DC系数之间的差值。这个前后依赖在块与块之间形成一条链,如果严格按标准处理,完全无法并行。
常见的解决方案有两种。第一种是投机预测。因为自然图像中相邻块的DC系数通常比较接近,大部分情况差值落在很小的范围内,可以先假定差值为0执行编码,编码完再检查是否预测正确,如果不正确则回退重编码。这个方案在GPU上的成功率很高,因为典型图像的DC差值分布集中于0附近,投机成功的概率往往能达到90%以上。
第二种是接受一定程度的格式偏差,即打破JPEG标准中的DC差分链,让每个块的DC系数都独立编码。这不是标准的JPEG解码器都能接受的,但确实有很多优化工具采用这样的方式。把DC系数直接当作“无预测的绝对编码”处理,可以彻底解除串行依赖。代价是压缩率略微下降,通常只影响几个百分点。对这一章里提到的方案来说,它们采用的是投机加校验与剪裁相结合的方式,在保持兼容性的前提下获得尽可能高的并行度。
3.4 条带之间与光栅顺序的取舍
还有一个工程细节值得记录:条带切分与最终输出的字节流顺序。JPEG标准规定扫描数据里每个MCU按光栅顺序排列,任何解码器都必须按顺序处理MCU。如果并行编码多个条带,每个条带编码完成的时间不一致,输出字节流中条带数据的顺序就会乱。
解决方法是让每个条带把编码结果写入输出缓冲中自己独有的区段,区段位置由预先分配好的条带索引决定,条带内部按光栅顺序写入。由于条带之间的数据在逻辑上完全独立,只要保留条带的组织头信息,解码器依然能按标准顺序逐条带解析。实际实现中,把整帧划分为N个条带后,只需要在JPEG头里标记扫描参数以及必要的重初始化标记(比如DRI/RSTn标记),就能让解码器正确处理各条带的边界。这也是第6.2章采用的方案,支持在保持标准兼容的同时并行编码所有条带。
4. 工程实现层面的关键设计:线程组织、资源绑定与数据回读
把算法阶段拆清楚了,接下来就是落地成DirectCompute代码时躲不开的几个实际问题。这一节把我复现时觉得值得注意的设计点集中梳理一遍。
4.1 线程组规模的选择
每个线程负责一个8x8块的关键决策在于线程组大小。从DirectCompute硬件特性来看,线程组大小的上限是1024个线程。选择256线程限制了实际代码中每个组最多可以同时处理256个块,对应大约16x16个MCU或者说512x512像素。
实际选择时并不是越大越好,还要考虑寄存器压力和共享内存开销。一个线程需要保存块内64个像素数据(如果用float就是256字节),加上DCT中间结果和查找表需要共享内存辅助。如果每个线程还额外占用一堆局部数组,256线程已经让寄存器使用到一个非常高的水平,超过256线程时有些GPU架构会出现严重的寄存器溢出。我实测下来256线程是一个稳妥的起点,128线程在某些老显卡上表现更好,256线程在高性能显卡上能维持更高的缓存重利用。
4.2 使用有符号整数中间格式减少带宽
JPEG压缩过程中大量数据是8位像素、16位DCT系数。在GPU里用float做全部运算最简单,但带宽消耗大。我在实现中选择在DCT阶段使用整数运算,用short(16位有符号整数)表示像素差值和变换后的系数。原因有三:一是RGB转YCbCr、减直流偏移这些操作在8位到16位范围内不会损失精度;二是整数运算在多数GPU上的吞吐率与FP32相当或更高;三是后续量化权衡用整数乘法加移位远比浮点除法高效。
不过需要注意,JPEG标准解码端期望的DCT方程是浮点定义,全整数近似方案需要保证精度足够,避免出现可见的块效应。实践中16位精度在几乎所有质量档位下都足够,但如果没有特殊原因,第一次实现用浮点数跑通整体流程,再优化成整数是更稳妥的做法。
4.3 资源绑定与三缓冲回读
JPEG整个流程里,唯一必须从GPU回读到CPU的数据就是压缩结果——JPEG字节流。但如果每一帧都同步等待GPU完成再回读,GPU流水线就会被彻底拖死,DirectCompute也就失去了意义。
我的做法是使用三缓冲:维护三个输出缓冲区,GPU写其中一个的同时,上一帧的缓冲区可以异步回读到CPU,更早一个帧的缓冲区则已经回读完成,可以直接交给文件系统或网络。用ID3D11DeviceContext的Map配合D3D11_MAP_FLAG_DO_NOT_WAIT做异步回读,不会阻塞GPU执行。配合帧数据中的时间戳与回读完成事件队列,形成一条完整的多级流水线。
这一步是GPU JPEG压缩方案能否真正跑成实时链路的关键。如果省了这一步,主线程每一帧都等GPU回读,帧率会跌到惨不忍睹的水平,跟CPU压缩相比就完全失去优势了。
4.4 输入资源的格式处理
DirectCompute的输入通常是D3D纹理,多数游戏的渲染目标是DXGI_FORMAT_R8G8B8A8_UNORM或者B8G8R8A8_UNORM。直接把这个纹理绑定给Compute Shader当SRV使用,在Shader内通过Sample或Load读出RGBA值,再换算成YCbCr。这里要注意纹理坐标的原点约定(左上角还是左下角)和行对齐,否则会出现整张图错位或颜色偏移的错误。
另外,如果输入纹理是多采样纹理(MSAA),需要先解析成普通纹理再交给压缩Shader。我一开始忘了这一步,压出来的图像自带锯齿和色偏,排查了很久才发现是Multisample Texture不能直接以普通方式读取造成的。这个问题在实际接入渲染管线时几乎必然遇到,提前处理好能省掉后面一大轮调试时间。
5. 实际跑起来的性能数据与优化调整
5.1 我的测试环境与基准方法
我的复现测试平台是一台Windows 11台式机,CPU是AMD Ryzen 7 5800X,显卡是NVIDIA RTX 3070(8GB显存),驱动版本比较新。图像素材用了一张4096x2160的4K渲染帧和一段1920x1080的视频帧序列,统一压缩到质量因子约85的JPEG。CPU参考实现用了libjpeg-turbo 2.1.x的默认设置,GPU方案则是我按照前面思路自己写的DirectCompute实现。
基准测试的方法是:连续压缩100帧,统计整帧从输入纹理到输出JPEG字节流的平均耗时。CPU方案的数据包含图像加载、格式转换、编码、写入内存的全部时间;GPU方案的数据包含Dispatch耗时、回读等待与实际拷贝时间,但不包含最终磁盘IO。
5.2 几个有代表性的对比结果
在1920x1080分辨率、质量因子85的情况下,CPU端libjpeg-turbo单线程平均耗时约9毫秒每帧,多线程(8线程)可以压到约4毫秒。我的GPU方案首次实现版本平均耗时约6毫秒,当时有点失望——比单线程CPU快,但比多线程CPU慢。
分析后发现瓶颈有两个:一是输入纹理需要从渲染目标的B8G8R8A8格式做一次手动转换,占了不少开销;二是Huffman编码阶段原子操作竞争非常严重。针对这两点做了优化后,GPU方案降到约2.5毫秒每帧。再进一步,在4K分辨率下,CPU多线程耗时约18毫秒,GPU方案约8毫秒,优势更加明显。
不过坦白地说,如果只做单帧压缩,GPU的优势不在延迟,而在于不占用CPU核心。这个优势在连续帧压缩和录屏场景下才能真正放大:CPU几乎可以完全空闲出来,GPU以空闲计算单元执行压缩,整机吞吐明显提升。
5.3 压缩率与画质的变化趋势
GPU方案和CPU方案在相同质量因子下,JPEG文件大小差得不多。我测的几百张图里,GPU方案平均只比libjpeg-turbo大1%到3%,个别纹理复杂的图最多大5%,边缘场景基本不会超过10%。画质上,PNSR和SSIM指标在绝大多数图上几乎一致,偶有亮度通道细微差异属于整数DCT与浮点DCT的正常差别。
做这套方案的时候需要想明白一件事:GPU JPEG压缩如果不追求与CPU压缩完全比特一致(bit-exact),对绝大多数业务没影响。真正要紧的是“生成的文件能被常规JPEG解码器正确解码”,而这一点只要严格按标准组织头信息和扫描数据就能做到。
5.4 我优化过程中最有效的一轮调整
从最初的6毫秒降到2.5毫秒,帮助最大的三个改动按贡献排序是:第一,把输入纹理的不必要格式转换去掉,改为直接在Shader里以B8G8R8A8为源做像素读取,省掉了一次全帧的拷贝和一次转换;第二,把Huffman编码阶段的原子操作从“每个符号原子加”改成“每个线程组先统计再一次性原子预留”,大幅降低原子竞争;第三,把多个小Buffer合并成大Buffer,用offset区分不同通道,减少资源绑定与状态切换。
如果你要自己复现,建议也按这个顺序做优化。先砍数据拷贝,再改原子操作,然后再考虑更底层的寄存器级优化,这样收益最直接。
6. 几个不太被注意但非常影响实际效果的小细节
6.1 头信息与编码参数要按标准逐字节核对
JPEG文件能打开不代表头信息完全正确。很多实现能生成解码器能勉强解析的文件,但遇到严格校验的库(特别是医学影像和法证工具)就会拒收。写码阶段要重点检查SOI、APP0(JFIF标识)、DQT(量化表定义)、SOF0(基线帧头)、DHT(Huffman表定义)、SOS(扫描开始)这几个标记段的排列顺序和字节对齐。漏掉任何一个填充字节都会导致解码错位。
6.2 颜色空间转换系数要对应JFIF标准
JFIF标准的RGB到YCbCr转换系数和电视广播标准的系数不一样。很多JPEG库(包括libjpeg)默认使用JFIF系数,但如果你用BT.601或者BT.709的系数去转换,再写出JFIF标记的JPEG,解码端会按JFIF系数反变换回来,结果就是颜色整体偏差。这个坑非常隐蔽,第一次实现时一定要确认系数矩阵与标识段一致。
6.3 折叠尾帧与分辨率对齐
JPEG的MCU大小与色度采样方式有关。4:2:0采样时,MCU实际是16x16像素;如果图像宽高不是16的倍数,末尾的块需要用边缘像素填充。这个填充逻辑不处理好,解码端会出现很深的锯齿条带。同时,帧的最后一组条带如果不满一个MCU,也需要特殊处理,不能简单丢弃像素。
6.4 性能热点在不同GPU上有差异
我自己的3070上,DCT和量化大约占GPU总耗时的40%,Huffman编码占35%,色彩转换和拷贝占25%。但在AMD的显卡上(我借了一块RX 6600测试),Huffman阶段的占比会更高,色彩转换的占比更低,整体耗时差别不大但热点分布明显不同。做跨平台时,Shader里的分支和原子操作要避免依赖特定硬件的行为,尽量写保守一些。
写这篇笔记的时候我把GPU Pro 4第6.2章的思路重新落到代码里又跑了一遍,最大的感受是:JPEG这个格式虽然老,但它的算法结构里同时包含了“高度并行的矩阵运算”和“难缠的串行熵编码”两种极端,是最适合用来磨练GPU计算思维的案例之一。你不需要真的做一个生产级的GPU JPEG压缩库,只需把这条流水线完整跑通一遍,对Compute Shader的调度模型、内存模型、原子性能瓶颈的理解就能上一个台阶。希望这篇笔记能把那些看原文时容易忽略的工程细节补清楚,少走几步我当时走过的弯路。