# DCVC 底层熵编码精读与完成核查

本轮补完上一轮留下的 C++ 熵编码部分。至此，**DCVC 2021 的论文正文、补充材料、Python 推理主链，以及真实码流所依赖的 CDF 构表和 rANS 主调用链，均已完成讲解**。训练和性能复现仍属于实验工作，不计作未读论文段落。

本轮沿用固定提交 `cbdae87a5445114cdc7f48816da63ea80bdeac40`，已取得并逐文件核对官方 `DCVC-family/DCVC` 完整子目录的 31 个文件；其中包含此前未收集的 C++、构建配置和真实写流说明。[完整子目录来源清单](E:/Projects/papers/iVc/DCVC_2021_源码/SOURCE_MANIFEST_完整子目录.json)

**1. 补齐最后一层：模型输出概率之后发生什么**

此前已讲清网络怎样得到整数符号、均值、尺度和表索引。底层把它们连成下面这条链：

```text
预测分布 → 各整数符号的概率质量 PMF
         → 量化后的整数累计分布 CDF
整数符号 + CDF 索引 → rANS 状态更新 → 字节串
字节串 + 相同 CDF 索引 → 整数符号 → 加回均值 → 重建潜变量
```

Python 调用的两个扩展职责不同：`MLCodec_CXX` 负责 `pmf_to_quantized_cdf`；`MLCodec_rans` 提供缓冲编码器、编码器和解码器。前者准备编码表，后者使用这些表编码，并不替代神经网络预测概率。

对应源码是 [ops.cpp](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/ops/ops.cpp:26) 和 [rans_interface.cpp](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/rans/rans_interface.cpp:97)。原始版本也可在[官方固定提交](https://github.com/microsoft/DCVC/tree/cbdae87a5445114cdc7f48816da63ea80bdeac40/DCVC-family/DCVC/src/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()` 的操作顺序是：

1. 将各概率乘以 M 并取整，得到初步频数。
2. 按初步频数总和重新归一化，用整数除法缩放到目标总量。
3. 做前缀和，并把最后一个累计值设为 M。
4. 检查相邻累计值是否相等；相等意味着该符号频数为零。
5. 从频数大于 1 的符号中挑选频数最小的一个，转移一个计数，修复零宽区间。
6. 检查 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`。[底层状态函数](E:/Projects/papers/iVc/DCVC_2021_源码/外部依赖/ryg_rans/rans64.h:77)

该头文件后半部还有预计算倒数、用乘法替代除法的优化接口；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()`。阅读代码应以循环和状态更新为准。[收集与刷新](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/rans/rans_interface.cpp:164)

**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 数据。

因此，**有限概率表不等于把潜变量硬截断到有限范围**。超范围符号会付出逃逸与旁路的比特成本，但整数值被保留。[逃逸编码](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/rans/rans_interface.cpp:113) · [对应解码](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/rans/rans_interface.cpp:245)

**7. `decode_stream()` 为什么能分多次调用**

真实自回归解码并不是一次性已知所有表索引：后面位置的概率依赖前面位置恢复的值。因此 `set_stream()` 把输入字节串保存在对象内，初始化 `_rans` 状态和 `_ptr` 读指针；每次 `decode_stream(indexes)` 只恢复当前需要的一组符号，并保留更新后的状态和指针。

Python 在一个空间位置调用一次，取回该位置的通道向量，写回潜变量缓冲区，然后才计算下一个位置的均值和尺度。若每次调用都重新初始化 rANS，就会不断读回流的开头，无法推进。[持续解码状态](E:/Projects/papers/iVc/DCVC_2021_源码/src/cpp/rans/rans_interface.cpp:277)

这也解释了速度瓶颈：卷积运算可以部分并行，空间上的因果依赖、CPU 编码接口调用、符号表查找及设备数据转换仍有成本。代码中查找 CDF 区间使用 `std::find_if`；不能仅从“用了 C++”推断这一段已经消除全部串行开销。

**8. 构建入口与训练代码的边界也已核清**

[write_stream_readme.md](E:/Projects/papers/iVc/DCVC_2021_源码/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 手算例子。[核查结果](E:/Projects/papers/iVc/DCVC_代码核查/底层熵编码核查结果.json)

这是**根据源码机制编写的独立参考验证**，没有加载官方 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 复现，应作为实验任务另记，不再混入精读完成状态。
