技术博客

DDC 派生数据缓存:GetDerivedData 的哈希键怎么构造

第 82 / 100 篇 · 2025-07-30 · ue5 · 资产管线DDC源码分析

同一个项目全量 Cook 几十分钟,日常迭代 Cook 几分钟,差距几乎全来自 DDC(Derived Data Cache,派生数据缓存)。但 DDC 不是"开了就灵"的开关:很多人遇到过"清一下缓存全好了"和"清了缓存反而更慢"两种截然相反的体验,根源都在键的构造上。接口主体在 Engine/Source/Runtime/DerivedDataCache,UE5.3 还有 Zen 后端(见第 9050 篇)。

成立前提:确定性重计算

DDC 缓存的是"派生数据"——纹理压缩后的 BC7 块、shader 编译出的平台字节码、导航网格这类可以从源数据重算的产物。它能当缓存用,靠的是这个性质:

$$\text{同一份源数据} + \text{同一套配置} \;\Longrightarrow\; \text{逐字节相同的输出}$$

注意"配置"三个字也在前提里。压缩格式、压缩级别、目标平台、shader 宏集合,全是输入的一部分。很多人 DDC miss 查了半天发现是 .ini 里改过一个参数——参数变了,键就变了,旧缓存和新键对不上,这不是 bug,是前提的自然推论。

键的三段拼法

一次 DDC 查询的键由三段拼成(FDerivedDataCacheInterface 系列接口,键最终是一个字符串):

  1. 类名:谁负责生成,比如 TEXTURE 段对应纹理压缩器。同名数据的生成器换了实现,必须换键,否则拿旧实现的结果当新实现的用;
  2. 版本号:生成器内部的格式版本。实现没变但输出格式变了(比如加了一个 mip 的元数据字段),靠它 invalidate;
  3. 对象后缀:具体到哪个对象、哪个变体。纹理就是"哪个包里的哪个纹理 + 目标格式 + mip 设置"的组合,shader 是"shader 源哈希 + 宏组合 + 平台"。

三段拼成一个字符串再哈希。前两段保证"生成逻辑变了必然换键",第三段保证"不同对象不串味"。写自定义 DDC 数据时(FDerivedDataCacheInterface::GetSynchronous / Put),最容易犯的错是后缀里漏了影响输出的参数——比如自己加了个压缩级别参数却拼进键里,结果就是缓存互相污染,症状是"改了参数结果没变"。

后端链:逐级短路

DDC 后端按序组成一条链,查询逐级下钻,命中即返回:

  • Memory:进程内哈希表,同一次 Cook 会话内重复查询秒回;
  • Local:本地文件系统(项目 DerivedDataCache 目录),跨会话有效,换台机器就没了;
  • Remote / Zen:HTTP 共享缓存,CI 和整个团队共用,见第 9050 篇;
  • 全 miss:现场重算,然后按 ECachePolicy 决定回写哪些层(QueryLocal/QueryRemote/StoreLocal/StoreRemote)。

写路径同理:重算结果默认回写本地,配了远端就一并回写共享层——这也是为什么 CI 跑过一次全量 Cook 之后,开发机的增量 Cook 会明显变快:纹理和 shader 的派生数据早被 CI 灌进了共享层。

DDC 查询路径:键命中即返回,miss 才问下一级 类名(哪个生成器负责) 版本号(输出格式换了必须变) 对象后缀(哪个纹理 / 哪条 shader) 拼键 → 哈希 → 内容寻址查询 Memory:进程内秒回 Local:项目 DerivedDataCache Remote / Zen:HTTP 共享缓存 全 miss:现场重算 + 回写 命中率模型:Cook 时间 T(h) = T_base + (1-h) · T_derived,T_derived 是纹理压缩+shader 编译的重计算总量

命中率为什么是线性的

把 Cook 时间拆成两部分:与缓存无关的固定骨架 $T_{base}$(包遍历、元数据、写出),和可被缓存的重计算 $T_{derived}$(纹理压缩、shader 编译、光照构建的中间产物)。设命中率为 $h$,则

$$T(h) = T_{base} + (1 - h)\, T_{derived}$$

这是个线性模型,有两个直接推论。第一,边际收益恒定:命中率从 50% 提到 60% 和从 90% 提到 100%(后者还做不到)省的时间一样多,不存在"越往后越赚"的复利。第二,收益上限被 $T_{derived}$ 钉死:如果项目里纹理和 shader 占比不大,DDC 调得再好也快不了多少;反过来纹理重的项目,首次 Cook 和命中 Cook 的差距能到 10 倍。想判断 DDC 值不值得折腾,先看 LogDerivedDataCache 里 miss 的分布,而不是盲目清缓存。

什么时候会集体失效

几条常见的"全库 miss"触发路径:

  • 引擎升级:生成器版本号 bump,所有键换血,等于缓存清零——这是设计行为;
  • 切平台 / 切 Cook 配置:平台名和配置项在键里,跨平台 Cook 天然不共享;
  • 分支差异:CustomVersion 和引擎版本号不同,键不同,两个分支的缓存各是一套;
  • 手动清缓存:DerivedDataCache 目录删掉重来,第一次 Cook 回到全量。

所以"共享 DDC 给全团队用"的前提是团队在同一条 release 分支上干活;多分支并行时,要么给每个分支配独立缓存命名空间,要么接受频繁的全量 miss。Zen 时代的共享缓存也是同样的规则,只是存储从"文件夹"换成了"内容寻址服务"(第 9050 篇展开)。

调试手段

  • Cook 日志里开 LogDerivedDataCache Verbose,能看到每次查询的键和命中层级;miss 异常多时先怀疑键拼接,而不是怀疑后端坏了;
  • 自己做派生数据的,把"拼键用的全部参数"打进键里这一条当铁律,漏一个参数就是潜伏的缓存污染;
  • 磁盘占用爆炸时查本地缓存目录大小,纹理重的项目几个 TB 不稀奇,定期清或搬到共享 Zen 上是常规运维。