ARTICLE DETAIL

资讯详情

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

GPU实时JPEG压缩:Compute Shader并行化实践与性能优化

GPU实时JPEG压缩:Compute Shader并行化实践与性能优化 我最初是在找图像压缩相关的资料时看到GPU Pro 4第6.2章的标题写着“Real-Time JPEG Compression Using DirectCompute”当时第一反应是JPEG压缩这种老掉牙的格式CPU上随便压一张图也就是几毫秒的事至于专门放到GPU上去做吗但仔细往下想游戏截图、高速连拍、视频帧序列、远程传输这类场景里压缩的对象不是一张图而是一秒钟几十帧的连续画面累计起来的数据量就完全不是一个量级了。读完那一章又自己上手复现了一遍之后我才意识到这章内容的价值不止在于“把JPEG搬到GPU上跑”更在于它把Compute Shader的并行思想完整地套进了一个非常经典的串行算法链条里中间的取舍和踩坑相当有代表性。这篇文章就当作我的实践笔记来写先梳理为什么需要GPU做JPEG压缩再把算法每个阶段的并行化思路拆开讲清楚最后落地到工程实现、性能数据和优化建议。适合对DirectCompute有一定基础、想了解真实图形API在图像编解码上怎么发力的开发者也适合正在做实时图像传输或录屏工具的工程师。1. 先想明白实时JPEG压缩为什么非要动GPU1.1 CPU单帧压缩很快但连续帧场景才是真正的瓶颈先说一个容易被忽略的事实CPU做JPEG压缩本身并不慢。用libjpeg-turbo这类SIMD优化过的库一张1920x1080的图片在主流桌面CPU上压缩到中等质量耗时通常在5到15毫秒之间。如果是偶尔截个图、存个图这点时间几乎感知不到。但问题出在“实时”和“连续”这两个词上。想象一个录制4K60帧游戏画面的工具每帧约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纹理资源能直接被计算着色器绑定为输入。相比CUDADirectCompute的驱动门槛也更均匀。CUDA只适用于NVIDIA硬件而DirectCompute在NVIDIA、AMD、Intel的显卡上都有完整支持。对于需要覆盖多品牌显卡的录屏软件或图像处理工具来说这是非常现实的优势。虽然DirectCompute在纯计算性能上可能比不过针对特定硬件深度优化的CUDA库但对JPEG压缩这种以访存和整数运算为主的工作负载差距并不明显。还有一个不太容易被注意到的点DirectCompute可以直接操作D3D纹理。你渲染出来的游戏画面本就在GPU的显存里如果用CPU压缩必须先回读到内存像素格式还得从RGBA转成压缩库要求的格式这一来一回的时间足以吃掉所有余量。而DirectCompute方案可以直接把渲染目标纹理绑定为SRVShader 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阶段使用整数运算用short16位有符号整数表示像素差值和变换后的系数。原因有三一是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 30708GB显存驱动版本比较新。图像素材用了一张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、APP0JFIF标识、DQT量化表定义、SOF0基线帧头、DHTHuffman表定义、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的调度模型、内存模型、原子性能瓶颈的理解就能上一个台阶。希望这篇笔记能把那些看原文时容易忽略的工程细节补清楚少走几步我当时走过的弯路。
返回列表