VTM源码CU划分深度解析:QTMT递归与RD cost计算实战 1. 从一次编码器调试说起为什么要啃CU划分这块硬骨头刚接触VTMVVC Test Model源码那会儿我最直观的感受就是文件多、调用深、变量名长。打开工程目录光source/Lib/CommonLib下面就是几百个文件CodingStructure、CodingUnit、PredictionUnit、TransformUnit这些结构体层层嵌套一个compressCU函数能牵扯出十几个子调用。很多人第一次读VTM代码都是被CU划分这部分劝退的——因为它既是整个编码流程的入口又是后续预测、变换、熵编码的骨架。CU也就是Coding Unit编码单元是VTM里承载预测和变换的基本容器。你可以把它理解成一块地皮编码器拿到一帧图像后先把它切成CTUCoding Tree Unit然后从CTU开始按照四叉树加多类型树QTMTQuadtree with Nested Multi-Type Tree的规则一层层往下切直到切出大小合适的CU。每个CU再决定自己是用帧内还是帧间预测、用多大的变换块、需不需要进一步拆成PU和TU。所以CU划分策略直接决定了编码效率和码率是VVC相比HEVC提升压缩率的核心手段之一。这篇文章面向的是已经有一点C基础、想深入VTM源码但不知道从哪下手的同学也适合那些做视频编码算法、需要改动划分逻辑来验证自己想法的人。我会从CTU的数据结构讲起把compressCTU到compressCU再到xCompressCU这条主链路拆开重点讲清楚划分是怎么递归的、RD cost是怎么算的、QTMT的几种切法在代码里长什么样最后分享几个我调试时踩过的坑和加速定位的技巧。整篇内容基于VTM的通用实现逻辑展开具体版本号可能略有差异但主干思路是相通的。2. CTU与CU的数据结构先搞清楚编码器眼里的块是什么2.1 CodingStructure一帧图像的容器读VTM代码第一个绕不开的类就是CodingStructure。它代表一帧或者一个slice、一个CTU在编码过程中的完整数据结构里面挂着CodingUnit、PredictionUnit、TransformUnit的数组还有各种缓冲区。你可以把它想象成一个仓库管理员所有跟这块图像相关的信息——像素、残差、重建值、运动信息、划分标志——都归它管。关键成员大概有这么几类m_units是CodingUnit的存储区m_pcus、m_tus分别对应预测单元和变换单元m_recoBuf、m_predBuf、m_resiBuf是重建、预测、残差三个缓冲区还有m_isSplit这类标志位记录当前块有没有被继续划分。理解CodingStructure的关键在于搞清楚它的区域概念——Area类定义了当前处理的是图像的哪一块CodingStructure的所有操作都是围绕这个Area展开的。我建议新手在读代码时先在CodingStructure.h里把成员变量过一遍不用记住每个但要知道哦原来重建像素存在这里原来划分标志是这么记的。这样后面看到cs.getRecoBuf(area)这种调用时脑子里能立刻映射到具体的内存区域而不是一团浆糊。2.2 CodingUnit划分结果的载体CodingUnit是划分的直接产物。每切出一个CU编码器就会创建一个CodingUnit对象记录这个块的位置、大小、预测模式、QP、划分深度等信息。它的成员里有几个特别重要blocks记录了CU在图像中的坐标和宽高depth是当前CU在四叉树里的深度qtDepth、btDepth、mtDepth分别对应四叉树深度、二叉树深度和多类型树深度predMode是帧内还是帧间chType是亮度还是色度。这里有个容易混淆的点VTM里CU的深度不是一个单一概念。HEVC时代只有四叉树一个depth就够了到了VVC因为引入了二叉树和三叉树深度被拆成了好几个维度。qtDepth管四叉树那层btDepth管二叉树那层mtDepth是两者的综合。为什么要这么设计因为不同树结构的划分代价和约束不一样分开记录才能在RD比较时准确还原划分路径。我在调试时经常需要打印这些深度值来确认当前块到底是怎么切出来的这个习惯帮我省了很多时间。2.3 Area与UnitArea坐标和尺寸的抽象Area和UnitArea是VTM里表示一块区域的基础类。Area用x、y、width、height四个量描述一个矩形UnitArea则用Position和Size两个对象来组合。别看它们简单VTM里几乎所有的块操作都依赖这两个类。比如cs.getCU(area, chType)就是根据区域去取对应的CU对象currArea.Y()、currArea.X()这种调用随处可见。新手常犯的一个错误是搞混绝对坐标和相对坐标。CTU内部的CU坐标是相对于CTU左上角的而整帧的坐标是绝对的。Area本身不区分这两种全靠调用者自己维护。我在改划分逻辑时就因为把相对坐标当绝对坐标用导致重建图像错位排查了大半天。所以看到坐标相关的代码一定要先确认当前上下文用的是哪种坐标系。3. 主链路拆解从compressCTU到xCompressCU的递归之旅3.1 compressCTU一个CTU的编码入口编码器处理一帧图像时会按光栅扫描顺序遍历所有CTU。每个CTU的编码入口是EncCu::compressCTU不同版本可能叫compressCtu。这个函数做的事情可以概括为初始化当前CTU的CodingStructure调用xCompressCU进行递归划分和模式决策最后把最优结果写回。compressCTU里有个细节值得注意它会先设置好当前CTU的Area然后调用m_pcInterSearch-init、m_pcIntraSearch-init之类的初始化函数把运动估计、帧内预测需要的数据准备好。这些初始化不是可有可无的比如帧间搜索需要参考帧的像素如果没提前准备好后面算RD cost时就会读到脏数据。我见过有人为了优化把初始化挪到循环外面结果编码质量直接崩了就是因为参考数据没更新。3.2 xCompressCU划分决策的核心xCompressCU是整个CU划分的心脏。它的基本逻辑是给定一个Area先尝试把这个块当作一个完整的CU来编码不划分算出一个RD cost然后尝试各种划分方式对每个子块递归调用xCompressCU把子块的RD cost加起来再加上划分本身的比特开销得到划分后的总RD cost最后比较不划分和各种划分的代价选最小的那个作为当前块的最优决策。这个先试整体、再试划分、比RD的框架是理解VTM划分逻辑的钥匙。很多人读代码时被各种if、for绕晕其实只要抓住这条主线其他都是细节。RD cost的公式是D λRD是失真通常用SSE或SAD衡量R是比特数λ是拉格朗日乘子由QP决定。编码器本质上就是在画质和码率之间找平衡而CU划分是影响这个平衡的重要因素——块切得越细预测越精准失真越小但划分信息和预测信息的比特开销越大。3.3 递归的终止条件递归不能无限进行下去xCompressCU有几个终止条件。最直接的是尺寸限制CU最小不能小于MIN_CU_SIZE通常是4x4最大不能超过CTU大小VVC里CTU最大可以到128x128。其次是深度限制qtDepth、btDepth、mtDepth都有上限超过就不再尝试对应类型的划分。还有slice/tile边界限制如果当前块跨越了slice或tile边界某些划分方式会被禁止。这些终止条件在代码里通常表现为一连串的if判断比如if (currArea.width MIN_CU_SIZE ...)。我建议在读的时候把这些条件单独列出来画一张什么情况下允许什么划分的表比在代码里来回跳转要清晰得多。下面这张表是我自己整理的供参考划分类型触发条件尺寸约束深度约束四叉树QT块为正方形宽高均大于MIN_CU_SIZEqtDepth maxQtDepth水平二叉树BT_H允许BT且不越界高大于MIN_CU_SIZEbtDepth maxBtDepth垂直二叉树BT_V允许BT且不越界宽大于MIN_CU_SIZEbtDepth maxBtDepth水平三叉树TT_H允许TT且不越界高满足1:2:1比例mtDepth maxMtDepth垂直三叉树TT_V允许TT且不越界宽满足1:2:1比例mtDepth maxMtDepth4. QTMT划分在代码里的真实模样4.1 四叉树最经典的切法四叉树划分就是把一个正方形块切成四个等大的正方形子块。在xCompressCU里QT的尝试通常是最先进行的代码大致是先检查当前块是不是正方形、尺寸是否允许、深度是否超限如果都满足就调用xCompressCU四次分别处理四个子块然后把四个子块的cost累加再加上QT划分标志的比特开销。QT的划分标志在码流里占1个bitsplit_flag这个开销相对固定。但要注意VTM在算RD cost时划分标志的比特不是简单加1而是通过熵编码器估算的因为上下文模型会影响实际比特数。这就是为什么你在代码里看到的是m_pcEntropyCoder-encodeQtSplitFlag之类的调用而不是直接cost 1。理解这一点很重要否则你会觉得明明只切一刀为什么代价这么大。4.2 二叉树水平还是垂直这是个问题二叉树划分把一个块切成两个等大的子块可以是水平切上下两半或垂直切左右两半。在代码里BT的尝试通常在QT之后逻辑是如果当前块允许BT比如不是某些特殊模式就分别尝试BT_H和BT_V各自递归计算cost取较小的那个。BT的一个关键约束是不能连续同方向切。什么意思如果父块是水平BT切出来的那么子块不能再做水平BT只能做垂直BT或QT。这个约束是为了避免冗余的划分组合减少搜索空间。代码里通过btDepth和划分方向的历史来记录具体实现是在CodingUnit里维护一个btSplit方向标志。我第一次看到这个约束时觉得多此一举后来算了一下搜索空间才明白如果不加约束一个块可以无限水平切下去虽然尺寸会变小但划分组合数会爆炸编码时间根本扛不住。4.3 三叉树1:2:1的非均匀切分三叉树是VVC引入的新划分方式把一个块切成三个子块比例是1:2:1。水平TT切成上中下三块垂直TT切成左中右三块。TT的引入是为了更好地处理图像中中间大、两边小的结构比如一个物体在画面中央两侧是背景用TT切比用BT切更贴合内容。TT在代码里的实现比QT和BT复杂一些因为三个子块的大小不一样中间的块是两边块的两倍。递归调用时要分别处理三个子块而且TT对尺寸有更严格的要求——块的大小必须能被4整除因为1:2:1的比例要求总尺寸是4的倍数。我在改TT相关逻辑时就因为没检查这个整除条件导致中间块尺寸算出来是小数程序直接崩了。所以看到TT的代码第一件事就是确认尺寸约束。4.4 划分方式的组合与搜索顺序VTM在尝试划分时不是把所有方式一股脑全试一遍而是有顺序和剪枝的。通常的顺序是先试不划分整体编码再试QT再试BT_H、BT_V最后试TT_H、TT_V。每试一种如果当前最优cost已经比不划分的cost还大就可能提前终止后续尝试这叫early termination。这个搜索顺序和剪枝策略对编码速度影响巨大。我在做算法验证时曾经把TT的尝试提前到QT之前结果编码时间增加了30%因为TT的搜索空间比QT大提前试会浪费很多计算。后来改回默认顺序时间就正常了。所以如果你要改划分逻辑一定要先搞清楚默认的搜索顺序和剪枝条件不要随意调整。5. RD cost计算划分决策背后的裁判5.1 失真D是怎么算的RD cost里的D在VTM里通常用SSESum of Squared Error或SADSum of Absolute Difference来衡量。SSE是原始像素和重建像素差值的平方和SAD是绝对差值和。SSE对误差更敏感计算量也更大SAD计算快但精度低。VTM在不同阶段会用不同的度量帧内预测的粗略搜索可能用SAD最终决策用SSE。代码里算失真的函数通常在Distortion类里比如distortionSSE、distortionSAD。这些函数会遍历块的每个像素累加误差。别看逻辑简单它是整个编码流程里调用最频繁的函数之一优化空间很大。我见过有人用SIMD指令重写失真计算编码速度直接提升20%以上。如果你对性能优化感兴趣这是一个很好的切入点。5.2 比特R是怎么估的比特R的估算比失真复杂得多因为它依赖熵编码的上下文模型。VTM的做法是在算RD cost时用一个轻量级的熵编码器估算当前决策需要的比特数而不是真的把码流写出来。这个估算器会考虑上下文状态但不会更新上下文否则会影响后续决策。具体到CU划分比特R包括几部分划分标志的比特、预测模式的比特、运动信息的比特、残差系数的比特。其中残差系数占大头尤其是在高QP下。代码里通过m_pcEntropyCoder-encode...系列函数来估算每个函数对应一种语法元素。我建议在读这部分时对照VVC的码流语法表来看搞清楚每个语法元素对应哪个函数这样能建立起代码—码流的映射关系。5.3 λ的选取与QP的关系λ是RD cost里的拉格朗日乘子决定了失真和比特的相对权重。λ越大编码器越倾向于省比特画质差一点没关系λ越小越倾向于保画质码率高一点无所谓。VTM里λ的计算公式通常是λ α * 2^((QP-12)/3)α是一个经验常数不同配置下取值不同。这个公式的来历是率失真理论简单说就是比特每增加1bit失真应该下降多少才划算。QP每增加6λ大约翻倍意味着编码器对码率更敏感。我在调QP相关参数时经常需要同时看λ的变化因为λ变了CU划分的倾向也会变——高QP下编码器更愿意切大块省划分比特低QP下更愿意切小块保画质。理解这个联动关系对调参很有帮助。6. 调试CU划分时我踩过的那些坑6.1 递归深度过深导致栈溢出VTM的CU划分是递归实现的xCompressCU自己调用自己。如果CTU是128x128最小CU是4x4理论上递归深度可以到5层128→64→32→16→8→4。但加上BT和TT实际深度可能更大。我在一次实验里把最小CU尺寸改成了2x2结果递归深度暴增程序直接栈溢出崩溃。这个坑的教训是改尺寸参数前先算一下最大递归深度确认栈空间够不够。VTM默认的栈大小在大多数平台上是够的但如果你改了CTU大小或最小CU尺寸就要重新评估。解决办法要么是限制深度要么是把递归改成迭代但改动量大不推荐。6.2 坐标越界与边界处理CU划分涉及大量的坐标计算尤其是BT和TT子块的坐标要根据父块坐标和划分方向推导。如果推导公式写错子块就可能越界访问到图像外的内存。VTM里有clip函数做边界保护但如果你自己加的代码没做保护就会读到脏数据或者直接段错误。我的经验是任何涉及坐标计算的地方都先手动验算一遍。比如父块在(64, 64)大小32x32水平BT切出来的两个子块坐标应该是(64, 64)和(64, 80)大小都是32x16。这种验算看起来笨但能避免很多低级错误。另外VTM的Area类有clip方法处理边界时优先用它不要自己写。6.3 熵编码上下文状态被意外修改前面说过算RD cost时用的熵编码器是只读的不能更新上下文。但如果你在代码里不小心调用了会更新上下文的函数就会污染后续决策。我遇到过一次在尝试某种划分时为了打印调试信息调用了m_pcEntropyCoder-encode...结果这个函数内部更新了上下文导致后续所有CU的RD cost都算错了编码质量明显下降。排查这个问题花了我很久因为现象是编码质量变差而不是崩溃很难定位。后来通过对比加打印和不加打印的码流才发现是打印语句惹的祸。所以调试时尽量用printf或日志不要调用编码器本身的函数除非你确定它不修改状态。6.4 多线程下的数据竞争VTM支持多线程编码Wavefront Parallel Processing等多个CTU可以并行处理。但CU划分的递归过程中如果多个线程同时访问共享的CodingStructure或熵编码器就会数据竞争。我见过有人为了加速把xCompressCU里的某些操作并行化结果码流随机出错有时对有时错非常难查。这个坑的教训是VTM的并行框架有严格的依赖管理不要随意在递归内部加并行。如果确实需要并行用VTM提供的Barrier、ContextModelling等机制而不是自己开线程。另外调试并行问题时可以先把线程数设为1确认单线程正确后再开多线程这样能快速定位是不是并行导致的。7. 加速定位CU划分问题的几个实用技巧7.1 用条件断点缩小范围VTM代码量大如果直接在xCompressCU里打断点会停无数次。我的做法是加条件断点比如只在currArea.x 64 currArea.y 64 currArea.width 32时停下这样能精准定位到某个特定块的划分过程。大多数IDE都支持条件断点用好了能省大量时间。7.2 打印划分树VTM本身有打印划分信息的功能通常在CodingStructure或EncCu里有printCodingStructure之类的函数。打开这个功能编码器会把每个CTU的划分树打印出来包括每个CU的位置、大小、深度、预测模式。对照这棵树看代码比盲猜要高效得多。如果默认的打印不够详细可以自己加几行printf把关键决策点的cost也打出来。7.3 用最小配置跑通再扩展VTM的配置文件很多全开的话编码一帧要很久。调试CU划分时我建议用最小配置只编一帧、只用一个QP、关掉所有滤波和率失真优化RDO的复杂选项。这样编码速度快问题也更容易暴露。等基本逻辑跑通了再逐步打开其他选项看问题是否复现。这个最小复现的思路在调试任何复杂系统时都适用。7.4 对比不同版本的实现VTM的版本迭代很快不同版本的CU划分逻辑可能有差异。如果你在某个版本上遇到问题可以看看相邻版本的代码有没有变化。有时候问题是某个版本引入的bug下个版本就修了。我习惯在Git上对比xCompressCU函数的改动历史经常能发现一些官方已经踩过的坑。8. 关于CU划分后续可以深入的方向把CU划分的主链路搞清楚之后有几个方向值得继续挖。一是划分的快速算法比如用机器学习预测划分方向跳过一些不必要的RD尝试这在学术和工业界都是热点。二是划分与后续模块的联合优化比如划分时考虑变换块的形状或者考虑环路滤波的影响。三是硬件实现CU划分的递归和RD计算在硬件上怎么流水线化也是一个有意思的问题。我个人在实际操作中的体会是CU划分这部分代码虽然复杂但它的结构其实很规整——递归、RD比较、剪枝三个关键词就能概括。真正难的是细节坐标怎么算、上下文怎么维护、边界怎么处理。这些细节没有捷径只能靠多读、多调、多踩坑。我建议你在读代码时准备一个笔记本把每个函数的输入输出、关键变量、调用关系记下来积累到一定程度整个流程就会在脑子里形成一张清晰的图。到那时候再改划分逻辑或者加新功能就不会觉得无从下手了。