来源:papers/iVc/DCVC_答疑02_底层熵编码与精读完成核查.md · 原始 Markdown
DCVC 底层熵编码精读与完成核查
本轮补完上一轮留下的 C++ 熵编码部分。至此,DCVC 2021 的论文正文、补充材料、Python 推理主链,以及真实码流所依赖的 CDF 构表和 rANS 主调用链,均已完成讲解。训练和性能复现仍属于实验工作,不计作未读论文段落。
本轮沿用固定提交 cbdae87a5445114cdc7f48816da63ea80bdeac40,已取得并逐文件核对官方 DCVC-family/DCVC 完整子目录的 31 个文件;其中包含此前未收集的 C++、构建配置和真实写流说明。完整子目录来源清单
1. 补齐最后一层:模型输出概率之后发生什么
此前已讲清网络怎样得到整数符号、均值、尺度和表索引。底层把它们连成下面这条链:
预测分布 → 各整数符号的概率质量 PMF
→ 量化后的整数累计分布 CDF
整数符号 + CDF 索引 → rANS 状态更新 → 字节串
字节串 + 相同 CDF 索引 → 整数符号 → 加回均值 → 重建潜变量
Python 调用的两个扩展职责不同:MLCodec_CXX 负责 pmf_to_quantized_cdf;MLCodec_rans 提供缓冲编码器、编码器和解码器。前者准备编码表,后者使用这些表编码,并不替代神经网络预测概率。
对应源码是 ops.cpp 和 rans_interface.cpp。原始版本也可在官方固定提交核对。
2. 为什么概率必须变成整数 CDF
设第 s 个符号的概率为 p_s,整数频数为 f_s,累计起点为 c_s。编码器需要:
c_0=0,\qquad c_{s+1}=c_s+f_s,\qquad f_s>0,\qquad \sum_s f_s=M.
真实 rANS 后端固定使用 16 位概率精度,因此 M=65536。这个 16 是概率表的分辨率,不是每个符号固定占 16 bit;状态使用 64 位整数,则是另一件事。
pmf_to_quantized_cdf() 的操作顺序是:
- 将各概率乘以 M 并取整,得到初步频数。
- 按初步频数总和重新归一化,用整数除法缩放到目标总量。
- 做前缀和,并把最后一个累计值设为 M。
- 检查相邻累计值是否相等;相等意味着该符号频数为零。
- 从频数大于 1 的符号中挑选频数最小的一个,转移一个计数,修复零宽区间。
- 检查 CDF 从 0 开始、以 M 结束,并且严格递增。
为什么必须修复?因为 rANS 的状态更新包含除以 f_s;频数为零的符号无法编码,即使它只是在浮点概率量化时被舍成零。
【教学示例】为了方便手算,临时用 M=16,输入 PMF [0.001,0.499,0.5]。初步频数为 [0,8,8],CDF 为 [0,0,8,16];给第一个符号补一个计数后,得到 [0,1,8,16],频数为 [1,7,8]。
这里改变的是用于编码的近似分布,不是被编码的整数符号;只要两端使用同一张合法表,整数仍能精确恢复。表量化却可能改变码率,这解释了概率估计与真实码率之间的一部分差异。
还有一个接口约束:Python 类虽然暴露精度参数,当前 C++ rANS 中的 precision 是固定的 16。不能只改 Python 构表精度,就默认整条编码链自动跟着改变。
3. rANS 的核心公式为什么可逆
先忽略有限位宽带来的状态归一化,设当前整数状态为 x,当前符号频数为 f_s、CDF 起点为 c_s,则编码更新为:
x'=M\left\lfloor\frac{x}{f_s}\right\rfloor +(x\bmod f_s)+c_s.
令 x=f_s q+r,其中 0≤r<f_s,那么 x'=Mq+r+c_s。于是:
x'\bmod M=r+c_s\in[c_s,c_s+f_s).
解码器先取 x' 的低位累计频数,再找它落入的 CDF 区间,就知道符号 s。然后计算:
x=f_s\left\lfloor\frac{x'}{M}\right\rfloor +(x'\bmod M)-c_s.
代入前面的 q、r,右边就是 f_s q+r,因此恢复原状态。符号由低位所属区间确定,旧状态由商和区间内偏移恢复。
当前 DCVC 接口调用的是外部固定版本 rans64.h 的 Rans64EncPut 与 Rans64DecAdvance。底层状态函数
该头文件后半部还有预计算倒数、用乘法替代除法的优化接口;DCVC 这里的调用链使用前述直接更新函数,不能把头文件中存在的所有优化都说成本模型已调用。
4. 为什么网络按正序扫描,rANS 却逆序编码
ANS 的状态具有栈式行为:最后压入的符号最先取出。为了让解码器按空间顺序恢复 A、B、C,底层实际把 C、B、A 压入状态。
【教学示例】仍用 M=16,令 A/B/C 的频数为 8/4/4,累计起点为 0/8/12,教学初始状态取 16:
| 编码顺序 | 状态变化 |
|---|---|
| 先压入 C | 16 → 76 |
| 再压入 B | 76 → 312 |
| 最后压入 A | 312 → 624 |
解码时依次观察低位:624 mod 16=0,得到 A 并恢复 312;312 mod 16=8,得到 B 并恢复 76;76 mod 16=12,得到 C 并恢复 16。
这与神经网络的因果顺序没有冲突:
- 编码端先按正常空间顺序预测概率、量化并写回重建缓冲区,收集所有符号和表索引。
- C++ 的
encode_with_indexes()将这些信息放入_syms。 flush()从_syms.back()逆序弹出,完成真正的状态编码。- 解码端按正常顺序恢复符号,及时更新上下文,再预测下一个位置的分布。
源码里有一条注释写着“backward loop”,但紧随其后的收集循环实际是正序;逆序发生在 flush()。阅读代码应以循环和状态更新为准。收集与刷新
5. 状态会不会无限变大,怎样落成字节
真实实现不能使用无限大的整数。rans64.h 的归一化下界是 2^31,状态保存在 64 位容器中,设计上控制在有效范围内。
编码前,如果状态达到与当前频数有关的阈值,就先向输出缓冲区写出低 32 位,并将状态右移 32 位;之后再执行编码更新。解码时做逆操作:状态低于下界,就从流中补进下一个 32 位字。
最终 Rans64EncFlush() 把剩余状态写成两个 32 位字,解码器用它们初始化状态。这些状态字已经包含在四段 payload 的字节长度中;它们与外层文件的 24/16 字节头信息属于不同层次。
外层头由 struct.pack 指定大端顺序,而这份 rANS 接口把 uint32_t 缓冲区直接解释为字节串。不能仅凭外层头是大端,就推断整个 payload 已定义统一的跨平台端序协议;本次不声称验证了跨平台兼容性。
6. 超出概率表范围的整数,为什么仍然能恢复
这是上一轮尚未进入 C++ 时没有展开的关键机制。
对某张 CDF,先用 offset 把原始整数 q 转为内部索引 v=q−offset。设 N 是普通符号的数量,内部索引 0 到 N−1 都可直接编码,额外的索引 N 是逃逸符号。
如果 v<0,编码逃逸符号,并记录 raw=−2v−1;如果 v≥N,仍编码逃逸符号,并记录 raw=2(v−N)。奇偶性区分下侧溢出和上侧溢出。
【教学示例】offset=−2、N=5,普通整数范围为 −2 到 2:
| 原始 q | 内部 v | 编码方式 | raw |
|---|---|---|---|
| 0 | 2 | 普通符号 2 | 不需要 |
| −4 | −2 | 逃逸符号 5 | 3,奇数 |
| 5 | 7 | 逃逸符号 5 | 4,偶数 |
| 3 | 5 | 逃逸符号 5 | 0 |
解码看到逃逸符号后,先恢复 raw。若 raw 为奇数,v=−(raw>>1)−1;若为偶数,v=(raw>>1)+N。最后加回 offset,即恢复 q。
raw 通过每组 4 bit 的旁路编码传输。编码器先写需要多少组,再写每组的值;组数使用 15 作为延续标记。即便 raw=0,也要传递“零组”的数量信息,解码器才能确定没有后续 raw 数据。
因此,有限概率表不等于把潜变量硬截断到有限范围。超范围符号会付出逃逸与旁路的比特成本,但整数值被保留。逃逸编码 · 对应解码
7. decode_stream() 为什么能分多次调用
真实自回归解码并不是一次性已知所有表索引:后面位置的概率依赖前面位置恢复的值。因此 set_stream() 把输入字节串保存在对象内,初始化 _rans 状态和 _ptr 读指针;每次 decode_stream(indexes) 只恢复当前需要的一组符号,并保留更新后的状态和指针。
Python 在一个空间位置调用一次,取回该位置的通道向量,写回潜变量缓冲区,然后才计算下一个位置的均值和尺度。若每次调用都重新初始化 rANS,就会不断读回流的开头,无法推进。持续解码状态
这也解释了速度瓶颈:卷积运算可以部分并行,空间上的因果依赖、CPU 编码接口调用、符号表查找及设备数据转换仍有成本。代码中查找 CDF 区间使用 std::find_if;不能仅从“用了 C++”推断这一段已经消除全部串行开销。
8. 构建入口与训练代码的边界也已核清
write_stream_readme.md 给出真实写流的构建与测试入口。CMake 使用 C++17,拉取 pybind11 v2.6.1 与固定提交的 ryg_rans,分别构建 MLCodec_CXX 和 MLCodec_rans,再把扩展复制到 src/entropy_models/。测试还需开启 --write_stream True。
31 文件清单中还包含一个 I 帧权重下载脚本:它列出四档 PSNR 与四档 MS-SSIM 图像模型并下载,没有实现 P 帧训练。已核对完整的 2021 子目录,里面没有端到端视频训练入口;不能把仓库其他年代模型的训练脚本直接当作本文训练工程。
这一点与上一轮发现的硬取整、缺少训练量化松弛和分阶段优化闭环一致。论文的训练安排已经讲解,原作者未在这份子目录提供的训练工程,不能靠精读凭空补成“已复现”。
9. 本轮验证做到了哪一步
除逐文件核对完整子目录外,本次按已读 C++ 公式编写了独立 Python 参考实现,验证整数 CDF 修复、状态更新、32 位归一化、4 bit 旁路以及多次分组解码。
测试共包含 4106 个整数,覆盖负值、普通范围及上下两侧溢出;其中 4101 个走逃逸路径。分多次解码后全部精确恢复,最终状态回到初始下界。另验证了上面的 CDF 与 A/B/C 手算例子。核查结果
这是根据源码机制编写的独立参考验证,没有加载官方 C++ 扩展,没有运行带权重模型,也没有证明与官方扩展逐字节相同;这些界限保留在结果记录中。
10. 全部精读的完成范围
| 材料 | 完成范围 |
|---|---|
| 正式版论文 12 页 | 标题、摘要、Sections 1—5、正文公式 1—7、7 张图、3 张表、40 项参考文献条目与重点关联分析 |
| 补充材料 7 页 | 全部 6 节、8 张图、1 张训练表、脚注、实验设置与 15 项参考文献条目 |
| Python 实现 | 运动、特征上下文、内容变换、三种先验、量化、四路码流、I 帧接入、文件读写、测试调度与指标统计 |
| 底层熵编码 | PMF→整数 CDF、rANS 编解码、逆序刷新、状态归一化、逃逸/旁路、持续解码状态、构建入口与固定外部状态函数 |
| 科研评价 | 创新边界、消融锚点、训练安排、复现条件、局限与可检验的延伸方向 |
参考文献条目已保留并按与本文的关系分析,不意味着把 55 个条目对应的所有论文也全部全文精读。
当前停止位置:论文与补充材料末尾,以及 DCVC 2021 实际使用的熵编码主调用链末尾。没有待继续的本文精读章节。 后续若开展带权重编解码、训练、计时或 BD-Rate 复现,应作为实验任务另记,不再混入精读完成状态。