系列: 与 AI 一起学编程

那个找不到 dxcompiler.dll 的测试

把一个硬性的 DLL 依赖变成一个优雅、可观测的兜底方案。

Boot Without the DLL Load-time dependency → observable fallback 0xC0000135 STATUS_DLL_NOT_FOUND LoadLibrary DXC present → full SPIR-V / DXIL D3DCompiler → reduced (DXIL only) none → magenta placeholder + WARN 71% → 89% tests passing
运行时能启动,能力是可选启用的,失效模式是响亮而可见的。

一个只要缺了一个可选 DLL 就会崩溃的运行时,已经悄悄把自己局限在了一种机器上。真正的可移植性意味着到处都能启动,并且在退化时大声说出来——而这正是合作伙伴要部署的东西。

周六早上,CI 仪表盘上八个测试是红的。全部八个都失败于同一个 Windows 错误码:0xc0000135。任何在 Windows 原生开发上待过一段时间的人,一眼就能认出这个。STATUS_DLL_NOT_FOUND。进程试图加载一个它需要的 DLL,而这个 DLL 不在系统里。

出问题的 DLL 是 dxcompiler.dll,微软的 DirectX 着色器编译器运行时。CI 机器上没有它。生产环境的用户机器上也可能没有它。在开发者那台配置齐全的工作站之外的任何地方,运行时都在悄悄假设 dxcompiler.dll 是存在的,一旦不存在就会硬崩溃。

到周六结束时,三个问题得到了修复,测试套件的通过率从 71% 升到了 89%。这篇文章要讲的就是这三个问题各自是什么。

为什么这个依赖一开始是硬性的

着色器交叉编译路径使用微软的 DXC 编译器,把 HLSL 着色器源码转换成 SPIR-V 或 DXIL 字节码。引擎发布的大多数着色器都是预编译的。运行时中有几条代码路径可以在运行期编译新的着色器:开发期间的着色器排列组合热重载、编辑器里的动态材质编辑,以及少数几条调试路径。

最初的实现在着色器交叉编译源码里用一条 #pragma comment(lib, "dxcompiler.lib") 指令链接了 dxcompiler.lib。这产生了一个硬性的加载期依赖。Windows 加载器会在进程启动时解析加载期依赖;如果 DLL 缺失,进程根本走不到 main()。运行时可执行文件如果系统里没有这个 DLL,就无法启动。

对于安装了完整 DXC SDK 的开发者来说,这是正确答案。但对 CI 运行器、用户机器,或者任何其他没有 DXC 的地方来说,这就是错误答案。运行时应该能启动,热重载功能应该报告「不可用」,其他一切都应该照常运作。

修复方案是什么样的

三处改动,每一处都很小,每一处都很精准。

着色器交叉编译源码从加载期加载改为运行期加载 DLL。#pragma comment(lib, ...) 指令替换成了显式的 LoadLibraryGetProcAddress 调用。DLL 在首次使用时才被查找,而不是在进程启动时。如果 LoadLibrary 失败,这条代码路径会返回一个清晰的错误。

为 DXC 缺失的情况添加了兜底路径。dxcompiler.dll 不存在时,着色器交叉编译子系统会退回到两条路径之一。如果较老的 D3DCompiler 可用,就退回到它,功能有所减少(没有 SPIR-V 输出,只有 DXIL)。如果连 D3DCompiler 都缺失,就退回到一个占位符 SPIR-V 数据块,产出一个显眼的纯品红色片元着色器。这个占位符确保了在没有任何着色器编译器可用的环境中,运行时依然能端到端地正常工作;而这个视觉信号能让人一眼看出自己正运行在占位符模式下。

优雅失败路径会记录日志并发出遥测数据。 每一次兜底都会以 WARNING 级别记录日志,注明具体是哪个子系统触发了兜底,以及原因。遥测数据会记录这次兜底,供运维观测。一个在本地运行、没有 DXC 的开发者,会在控制台里看到这条警告,从而知道如果需要真正的着色器编译,就该安装 DXC。而一个假定一切都已预编译好的用户在运行这个运行时时,则完全看不到任何警告,因为预编译好的着色器在没有 DXC 的情况下也能正常工作。

这才是这个依赖该有的正确形状:运行时是可移植的,能力根据安装情况可选启用,失效模式是可观测的。

同一次排查中顺带发现的两个相邻 bug

当我打开着色器加载代码的盖子时,发现另外两个测试也以相邻的方式失败了,于是我在同一轮里把它们也修好了。

音频总线效果的 ABI 不匹配。 raku_audio_bus_add_effectraku_audio_bus_remove_effect 这两个 C API 函数最初是按 (handle, struct*) 签名编写的,但测试套件是按 (handle, handle) 调用它们的,因为音频 API 的其余部分都是这样用的。测试出现了段错误,因为结构体指针解引用读取的是第二个句柄的比特位「碰巧」指向的那块内存地址上恰好存在的任何东西。这是一个 ABI 不匹配问题。

修复方式是把 C API 改成和音频模块其余部分一致的形式:(handle, handle)。这才是该 API 正确的形状,因为音频总线里的效果链是运行时会追踪的一等对象。结构体指针版本是早期 API 设计留下的遗留物,没能在后续重构中存活下来。测试套件是对的,实现是过时的。

内存泄漏测试桩覆盖了真实实现。 资产流式加载子系统有一个内存泄漏测试,用来检验流式资产上的引用计数。这个测试之所以失败,是因为测试夹具(fixture)里留下了一个 AssetStreamingManager 的桩实现,覆盖了运行时 DLL 里的真实实现。测试实际检验的是那个桩,而不是真正的代码。桩有内存泄漏,真实实现没有。测试对「存在泄漏」这个判断是对的,只是搞错了这个泄漏是谁的。

修复方式是从测试夹具里移除这个桩,并在真实实现上添加 RAKU_STREAMING_API 导出,让测试能干净地链接到它。一旦正确链接,测试就针对真实代码通过了。

这三处修复共同解锁了什么

dxcompiler 的修复让八个测试变绿,音频总线的修复让两个测试变绿,内存泄漏的修复让三个测试变绿。通过的测试总数从 39/55 爬升到 49/55。89% 这个数字值得庆祝,但更深层的里程碑是:运行时现在不依赖 DXC 也能工作,这意味着它将能在我尚未预料到的环境中运行。

我学到了什么

三件事。

硬性的加载期依赖是一种可移植性缺陷。 只要你的运行时对某个并非普遍存在的 DLL 或共享库存在加载期依赖,运行时就把自己局限在了包含那个 DLL 的环境里。刻意做出这个限制是可以的,不小心做出这个限制就不好了。「我们的运行时在加载期到底需要什么」这个审计,值得跑一遍。

0xc0000135 是最常见的 Windows DLL 错误码,而这条消息本身什么都没透露。 这个错误告诉你缺了一个 DLL,却不告诉你缺的是哪一个。用来搞清楚是哪一个的诊断工具(Process MonitorDependencies.exe、新的 Windows ETW 追踪)都能用,但它们都要求你在失败发生之前就知道它们的存在并把它们配置好。现在,当一次优雅兜底被触发时,运行时会给出一条清晰的错误消息,点名是哪个 DLL 缺失了。未来的我会感谢现在的我。

测试和实现之间的 ABI 不匹配,通常是测试那一边是对的。 这些测试是针对代码库其余部分暴露的 API 编写的。当测试和实现出现分歧时,测试是对的。这和通常的直觉正相反——通常的直觉是「去修测试」。正确的直觉是去看更广泛的 API 表面,问一问是哪一边才是那个异类。

合作伙伴和构建者应该从中得到什么

如果你正在评估一个引擎是否值得合作,而你要部署到的环境里开发者的机器和生产环境是不同的(几乎所有部署都是如此),去问问那个团队他们有哪些硬性的加载期依赖。正确的答案是一份简短的清单,每一项都有充分理由。错误的答案是一份很长的清单,其中一半连团队自己都忘了。

如果你是一位 Windows 原生开发者,还没有对任何非普遍存在的 DLL 使用 LoadLibraryGetProcAddress,这是一个温和的提醒。这个模式很小,可移植性上的收益却很大。

如果你是一家 AI 实验室,你的编码智能体会写 Windows 代码,那么需要在智能体编写的代码里留意的模式是:本该是运行期加载、却写成了 #pragma comment(lib, ...) 指令,以及与代码库其余部分产生偏移的 ABI 签名。这两者都能被静态分析标记出来,也都该列入智能体的评审清单。

周末收尾。运行时现在能在没有 DXC 的机器上干净地启动。两天里十一个测试变绿。下一次审计已经排上了日历。

回去继续构建。

一个能在你硬件所在之处运行的运行时

RakuAI 是为智能眼镜和杂乱的真实世界打造的空间运行时——可移植、在依赖缺失时能优雅应对、在退化时可被观测。看看要在任何地方部署,需要具备什么。

← 所有文章