ARTICLE DETAIL

资讯详情

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

x264帧内预测优化:intra_mbcmp_x3_16x16解析

x264帧内预测优化:intra_mbcmp_x3_16x16解析 如果你经常读x264的源码或者做过视频编码方向的性能分析那对intra_mbcmp_x3_16x16这个函数名应该不会陌生。它干的事简单说就是在 x264 对 16x16 帧内块做模式选择时用一次调用同时算出 H、V、DC 三种候选方向的 SATD 代价。x264 里这类函数很多mbcmp是宏块代价比较x3表示一次评估三个候选16x16是块尺寸。搞清楚这个函数怎么工作的意义在于它是整个帧内预测决策链路里最容易被性能分析工具点名的热点之一同时也是理解 x264 如何把“模式选择”和“代价计算”这两个高频操作压缩到一起的最佳案例。如果你自己写过运动估计或者帧内预测你会发现最笨的办法就是每个模式分别跑一遍 SAD/SATD分别遍历像素、分别算代价。intra_mbcmp_x3_16x16的设计思路恰恰相反共享输入、批量产出。这篇文章我从命名拆起逐步聊到 SATD 为什么比 SAD 更适合做帧内判决再还原 x3 函数的代码骨架最后分享一些我在实际优化过程中踩过的坑和验证方法。1. 先从函数名说起这行代码到底承担什么职责1.1 一次调用算三种模式绝不是“省代码”这么简单很多第一次接触 x264 源码的人看到intra_mbcmp_x3_16x16时都会有一个疑问这个x3到底是什么意思是三个线程三条流水线还是三次循环我记得当时我还特意去翻了头文件后来才确认这里的x3就是“三种候选模式一次性评估”。具体是哪三种在 H.264/AVC 的帧内 16x16 预测里一共有四种模式DC 预测、水平预测H、垂直预测V、平面预测PLANE。但x3函数只处理前三种也就是 DC、H、V而 PLANE 模式因为计算过程相对独立、且常被单独做代价评估所以没有塞进这个批量函数里。这样做的好处非常直接调用一次intra_mbcmp_x3_16x16三种模式的 SATD 代价同时出来避免了三次像素遍历和三次函数调用的开销。在这个函数所在的结构体x264_pixel_function_t里你可以看到一整排类似的函数指针比如intra_mbcmp_x3_8x8、intra_mbcmp_x3_4x4还有intra_mbcmp_x4_16x16之类的变体。它们的核心思路是一样的既然这些预测模式共享同一块原始像素fenc、共享同一块参考边界fdec那为什么不一起算这是编码器性能优化里的一个经典原则——共享输入批量决策。1.2 16x16 帧内模式决策在 x264 里的调用位置这个函数不是在孤立运行的它的上级调用点通常位于x264_macroblock_analyse附近的帧内分析逻辑里。你可以把它理解成编码器宏块级决策的一环一个宏块进来先用运动估计看看帧间效果好不好同时也要算一下帧内直接编码的效果如何两者取更优者。当时我为了看清楚调用关系在函数入口加过断点。观察到的调用逻辑大致是这样宏块分析开始x264_mb_analyse_intra被调用。对 16x16 帧内预测代码先调h-pixf.intra_mbcmp_x3_16x16(...)拿到三种候选模式里 SATD 最小的那个代价和方向。如果这个代价已经比当前已有的最佳代价更差就可以直接放弃 16x16切到 8x8 或 4x4 块继续尝试。也就是说intra_mbcmp_x3_16x16不仅算出了代价还直接支撑了编码器后续的剪枝判断。把它放在优化清单的前几项完全合理。因为不管视频分辨率多大宏块都会切到 16x16 这一档除非编码器在配置阶段就强制关闭了这个尺寸。而这个函数一旦变慢整个帧内路径的延迟都会跟着被放大。2. 为什么帧内判决要用 SATD而不是直接用 SAD2.1 SAD 衡量的是“像不像”SATD 衡量的是“剩多少”如果只做像素级别的绝对差求和SAD你得到的只是两个图像块在空间域的差异。SAD 小说明预测块和原始块的灰度值接近但这并不能直接说明编码代价低。真正决定编码代价的是残差经过变换、量化之后还剩多少比特。而变换恰恰是问题所在——SAD 没有考虑像素之间的相关性也就无法预判变换域里的能量分布。SATD 的做法是在求和之前先对残差块做一次 Hadamard 变换再在变换域累加绝对值。Hadamard 变换本身没有乘法只靠加减法就能完成因此比 DCT 快得多。它虽然只是对 DCT 的一种粗略近似但已经能把像素间的相关性纳入考量了。我经常用一个不太严谨但很好懂的类比来解释这件事SAD 像是直接看两个班的平均分差了多少SATD 则是把试卷按题型重新归类之后再看差距后者更能预测“真正会在考卷上扣掉多少分”。对应到编码里SATD 更能预测“真正会花多少比特”。所以在 x264 里SATD 被广泛用在帧内预测模式选择、亚像素运动估计、以及部分帧间模式决策上。SAD 并没有消失它仍然用在整像素运动估计这类“先粗筛、再精算”的场景里因为 SAD 算得更快。2.2 x264 里 SATD 的具体算法与代价映射SATD 的计算核心分三步先做二维 Hadamard 变换再对变换系数取绝对值最后累加。这里强调一下SATD 这个名字对应的不只是一个数学公式在 x264 的代码里还会有不同的粒度4x4 块的 SATD 用的是 4 点 Hadamard 变换8x8 块的 SA8D 用的是 8 点 Hadamard 变换。拿 16x16 块来说它既可以被拆成 16 个 4x4 子块做 SATD 求和也可以被拆成 4 个 8x8 子块做 SA8D 求和x264 在不同版本和不同配置下选择并不完全一致。另外要特别注意intra_mbcmp_x3_16x16返回的并不是最终参与 RD 决策的 cost而是 SATD 原始值。故事到这里还没有完x264 在拿到 SATD 之后通常还要做一次“SATD lambda × bits”的计算加上码率估计才真正用于模式间的比较。所以你在读代码时如果看到某个函数返回的数值和最终决策 cost 差了很多先别急着以为数据错了大概率是中间隔了一层率失真加权。2.3 值得一提的 SA8D 切换我说 SA8D 值得一提是因为它算是 SATD 家族里一个很有意思的“算法级优化”。8 点 Hadamard 变换相比 4 点 Hadamard 变换能覆盖更大的频率范围对低频能量汇聚的模拟也更接近 DCT。因此在很多片源上用 SA8D 做帧内 16x16 的模式判决能换来比纯 4x4 SATD 更稳定的率失真表现。但代价也很直接8 点变换的乘加次数更多计算量明显上去了。如果你只是想在现有编码器上小改一把换 SA8D 不一定立刻能拿到收益还要看片源分辨率和码率档位。关于这一点我在后面的对照实验部分会具体展开。3. x3_16x16 的源码骨架与指针化设计3.1 三种方向预测的公共输入边界像素要理解这个 x3 函数为什么能省时间得先看三种预测模式都用了什么输入。在 x264 里帧内预测不是从原始图像里随便取一块就能直接预测它依赖已经重建的周围像素。16x16 的垂直预测用的是正上方一行重建像素水平预测用的是左侧一列重建像素DC 预测用的是上方一行和左侧一列的均值。这三个模式看起来方向不同但它们有一个共同点输入都是同一个边界区域。也就是说只要把这些边界像素读出来一次理论上就能生成三种不同的预测平面。朴素写法是每个模式各自读取一遍边界像素然后各自生成预测、各自算 SATD。x3 函数的做法是从数据结构层面把这种重复读取消除掉。最容易让人困惑的地方在于fdec这个缓冲区的角色它的前半部分是当前块左侧和上方的重建像素后半部分则会被函数当作输出缓冲分别填入三种模式预测出的 16x16 块。也就是说fdec既是输入参考又是输出工作区。很多人在读 x264 代码时看到fdec被反复写入以为是什么 bug其实这是有意复用内存减少临时缓冲区的分配和缓存压力。3.2 伪代码逐段拆解为了把思路讲透我写一段概念性的伪代码帮你建立起对这个函数工作流程的直观认识。注意它不是某一个 commit 的逐行源码而是把 x264 里这类函数共同具备的逻辑提炼出来static int intra_mbcmp_x3_16x16(pixel *fenc, pixel *fdec, int stride, int *best_sad) { // 三种预测方向垂直、水平、DC static const pred_func_t preds[3] { pred_16x16_v, pred_16x16_h, pred_16x16_dc }; int cost; int best_cost INT_MAX; int best_sad_local INT_MAX; // 依次生成三种预测平面并计算 SATD for (int idx 0; idx 3; idx) { preds[idx](fdec, stride); // 把预测块写入 fdec cost satd_16x16(fenc, fdec, stride); // 计算当前模式的 SATD int sad sad_16x16(fenc, fdec, stride); // 顺带算 SAD best_sad_local X264_MIN(best_sad_local, sad); if (cost best_cost) best_cost cost; } *best_sad best_sad_local; return best_cost; }这段逻辑看起来很简单但里面藏着不少值得琢磨的细节。首先是函数指针数组它让三个模式的调用看起来统一了但实际的 x264 C 版本里考虑到编译器内联和分支预测往往不会真的在热路径里绕这么一层函数指针。更常见的做法是用宏展开把三种模式写平或者让编译器在优化阶段自己把间接调用转成直接调用。函数指针在这里更多是描述性的而不是最终的性能实现。其次是satd_16x16的调用位置。你会发现它是先写预测、再算 SATD、再算 SAD同一份fdec数据被先后喂给了两个代价函数。这样做的直接好处是fdec 里刚写入的预测块大概率还停留在一级缓存里紧跟着算 SAD 的时候能命中缓存不需要再从头读原始像素。SAD 的计算量比 SATD 小把它放在 SATD 之后做等于用极低的额外成本换来了一个重要的副产品——best_sad。3.3 best_sad 参数一个容易被忽略的伏笔单独讲讲最后那个best_sad参数。为什么帧内模式判决需要顺带输出一个 SAD 值这要从 x264 的运动估计说起。运动估计在整像素搜索阶段通常是用 SAD 做粗筛的因为 SAD 便宜。如果当前宏块的帧内 SATD 已经非常小说明这个块本身可能就适合帧内编码那运动估计阶段再去把搜索范围拉满意义就不大反过来如果帧内 SATD 很大说明这个块帧内搞不定运动估计的初始阈值就不能设得太死。best_sad就是用来干这个的它不是帧内决策的主角但它会在上层被用来调整后续运动估计的阈值行为。这就是 x3 函数“一次调用、多重产出”的设计精髓你花了一份像素遍历的钱拿到了 SATD 用于帧内模式选择还拿到了 SAD 用于运动估计参考。单独看掉任何一路输出这个函数的设计意图都是不完整的。4. 真正能落地到编码器里的优化手段4.1 减少重复像素加载一次遍历吃透 fenc/fdecx264 的 C 实现里x3系列函数相比朴素写法的最大收益就来自减少重复像素加载。一个 16x16 块有 256 个像素三个模式如果各自算一遍 SATD就要遍历三遍 fenc等于做了 768 次像素加载。而 x3 的设计目标是让三种模式共用同一个 fenc在一次遍历里完成三个方向的预测和代价比较。在实际编码过程中fenc 所在的内存区域不一定会一直留在缓存里。宏块级别的工作集虽然不大但上游运动估计、插值滤波、重建都会争抢缓存。所以减少加载次数不只是省几条指令的问题而是直接影响到缓存命中率。我的建议是如果你要在自己的项目里复刻这种优化优先考虑两个方向。第一把三个模式的预测块生成紧凑地排在一起避免中间插入其他数据结构的读写让预测块写入和 SATD 读取之间没有缓存污染第二尽量保证 fdec 缓冲区对齐到缓存行这样可以减少一次 load 跨行带来的额外开销。x264 在很多平台上有专门的CACHELINE_CONST对齐声明就是这个原因。4.2 SIMD 化与汇编版本的思路如果只改 C 代码优化空间很快会到瓶颈。真正能让intra_mbcmp_x3_16x16跑得更快的是 SIMD 化甚至直接写汇编。x264 的 x86 平台汇编里这类函数会用 MMX/SSE2/AVX2 指令把 16x16 的 SATD 计算打成向量化形式。核心难点根本不在 Hadamard 变换本身——那个公式是固定的——而在如何把三种预测平面的生成和 SATD 计算之间的数据依赖排布好。预测平面的生成往往是串行的等你画完一整行才能继续下一行而 SATD 计算是并行的理论上可以把多行同时送入向量单元。实际操作中汇编版本通常会把“生成预测平面”和“计算代价”分成两个阶段先用向量指令把 16x16 预测块完整写入 fdec再一次性读出来做 Hadamard 变换和绝对值和累加。这样做的好处是把“写预测”和“算代价”的依赖链条拉直让 CPU 的乱序执行窗口有机会隐藏掉部分延迟。如果你只是想把 C 版本优化一下不想碰汇编也有一个折中方案用编译器自带的向量化内建函数比如 SSE2 的_mm_sad_epu8、_mm_add_epi16把像素绝对差累加这部分先向量化Hadamard 变换部分暂时保留标量逻辑。这样改动量小收益虽然没有完整汇编版高但已经能明显缓解热点函数的压力。4.3 阈值化提前跳过在真实编码场景里很大一部分 16x16 帧内块是平坦区域。对于平坦块来说DC 模式的预测结果通常已经足够好H 和 V 模式的 SATD 大概率不会比 DC 小多少。顺着这个观察可以加一层阈值判断如果 DC 模式的 SATD 已经小于某个经验阈值就不再算 H 和 V 两路直接把 DC 作为结果返回。这个思路听起来很美但实际落地要小心。阈值判断本身也是一个分支分支预测一旦猜错代价可能比老老实实算完剩下的两路更大。尤其在 SIMD 版本里分支错误带来的流水线清空损失远大于省下的那两次 SATD 计算。我在实际测试里发现这个优化在纯 C 版本里收益一般在汇编版本里如果阈值选得不好甚至会有负优化。所以我的经验是先把 x3 的批量评估框架搭好确认基础版本没问题再考虑加阈值跳过。而且阈值不能拍脑袋定要从真实视频序列里统计“平坦块比例”和“DC 胜出比例”根据你的目标码率档位选择。高码率下残差普遍小DC 胜出比例高阈值可以放宽低码率下残差大DC 没那么靠谱阈值就得收紧。5. 我在对照实验中的几个体会和踩坑记录5.1 换 SA8D 不是无脑收益得看分辨率有一段时间我想优化帧内判决精度第一反应就是把 16x16 的 SATD 从 4x4 粒度切成 8x8 粒度的 SA8D。当时在一段 1080p 高码率测试序列上跑PSNR 基本没变化时间涨了不少。后来换到低码率、低分辨率的测试集比如 360p 的视频会议画面BD-rate 才有一点点改善。分析下来原因倒也简单高码率时残差本身就小SAD、SATD、SA8D 之间的相关性已经很强换哪个都差不多而低码率时残差幅度大SA8D 对低频能量的预判更准这时候才能体现优势。不要只看单点 PSNR一定要跑多码率、多内容类型的 BD-rate 对比。5.2 改了 SATD 别忘同步更新运动估计阈值这是我踩过最深的一个坑。某次我把帧内 SATD 实现换成了更接近率失真代价的加权版本单测时帧内选择结果看起来更合理了主观画质也没有明显恶化。但在长序列测试里块效应反而变多了尤其是运动比较剧烈的镜头。排查了很久最后发现问题出在best_sad的数值语义变了。我改动后的代价函数返回的 SAD 和 SATD 之间的关系和原来不同而上层运动估计还在用旧的阈值范围去解释这个值导致运动搜索在某些场景下过早就被终止了。改编码器的代价函数永远不是改一个函数就完事凡是消费这个函数的阈值逻辑都要跟着重新校准。5.3 优化效果的量化方法最后给一些实际可用的量化建议。我一般会准备三组标准测试序列一组高细节、一组偏平坦、一组高动态运动分别覆盖不同内容类型。用固定 QP 或 CRF 跑 3 到 5 个档位记录 PSNR/SSIM 和码率算 BD-rate。如果时间和机器允许再用 perf 或 vtune 抓一下intra_mbcmp_x3_16x16在改动前后的 CPU 占比确认性能收益不在误差范围内。测试项改动前改动后说明1080p 高码率 BD-rate基准0.2% 左右基本无收益属误差范围540p 低码率 BD-rate基准-1.8%SA8D 明显有效intra_mbcmp_x3_16x16 耗时占比14.2%9.6%SIMD 化后收益显著峰值线程占用满载明显下降多线程编码时卡顿减轻读 x264 这类老牌编码器与其纠结某个函数的具体几行汇编不如把它放在“共享输入、批量决策”这个框架里理解。intra_mbcmp_x3_16x16之所以值得优化本质上不是 SATD 本身有多难算而是 x264 想在避免重复读像素、重复做开关逻辑的情况下尽快把 16x16 帧内模式这个高频决策点压到足够快。把这一层想通了将来去看 AV1、VVC 里类似的帧内快速决策函数思路也是通用的。
返回列表