模糊测试与差分测试#
cuda-oxide 的顺利路径很容易用示例测试:vecadd、gemm、sharedmem 等可以证明已知程序仍然工作。但编译器不仅在已知程序上失败。它们会在那些没有人想到手写的奇怪角落中失败——分支后面的类型转换、调用终止符内的元组、没有人邀请参加派对的整数宽度。
这就是模糊测试介入的地方。
cuda-oxide 使用一个小型基于 rustlantis 的 harness 来生成随机的自定义 MIR 程序,通过普通的 Rust CPU 后端和 cuda-oxide 的 GPU 后端运行它们,并比较中间值的紧凑跟踪。目标不是证明所有 Rust 在 GPU 上都是正确的。目标更适度,也更有用:用我们没有手工编写的程序对 MIR 导入器、降级流水线、LLVM 导出、PTX 生成和运行时执行进行压力测试。
我们在比较什么#
乍一看,"比较 CPU 和 GPU 执行"听起来可疑。CPU 和 GPU 有不同的执行模型:一个标量线程对比数千个 SIMT lane、发散控制流、不同的内存空间、不同的同步规则。通用的 CPU 对 GPU 语义比较将是一种非常华丽的自我欺骗方式。
因此 harness 有意避免了这一点。
它作为标量 GPU 程序运行生成的 MIR:
<<<1, 1>>>
一个 block。一个线程。无线程间通信。无调度问题。GPU 被用作相同标量 MIR 的第二个代码生成目标,而非作为并行编程模型。
比较的内容是:
相同的生成 MIR
-> 正常的 rustc/LLVM CPU 执行
-> cuda-oxide -> LLVM IR -> PTX -> CUDA 执行
-> 比较跟踪哈希
如果哈希匹配,则生成的程序在两个路径上观察到相同的中间值序列。如果它们不同,cuda-oxide 路径中的某些东西值得关注。
备注
跟踪是有意紧凑的。每个 dump_var(...) 调用通过逐字节哈希将值折叠为一个 u64,而非将每个中间值复制回主机。这使得生成的测试运行成本低且易于比较。
可移动的部件#
模糊测试设置分为四个部分:
部件 |
角色 |
|---|---|
|
共享跟踪 API 和 vendored rustlantis |
|
种子到 |
|
批量运行器和产物记录器 |
|
稳定的 CPU/GPU 执行 harness |
crates/fuzzer 是一个普通的工作区 crate,但其库接口是 no_std 的。设备代码从那里导入 trace_reset、trace_finish 和通用的 dump_var。实际的 rustlantis 源码以 vendor 形式位于 crates/fuzzer/rustlantis;它作为外部生成器调用,而非作为 Rust 库依赖使用。
rustlantis-smoke 示例位于 crates/rustc-codegen-cuda/examples/。它拥有主机/GPU 启动逻辑和一个小的手写健全性测试,然后包含一个生成的文件:
crates/rustc-codegen-cuda/examples/rustlantis-smoke/src/generated_case.rs
模糊测试工具为每个种子重写该文件。示例中的其他所有内容保持稳定。这使得 harness 易于审查:如果某个种子失败,生成的 MIR 被隔离在一个地方。
种子流水线#
一个种子经历以下路径:
生成。 rustlantis 接收一个数字种子和一个小配置。相同的种子加上相同的配置意味着相同的 custom-MIR 程序。
提取。
mir_generator.py提取第一个生成的#[custom_mir]函数。适配。 rustlantis 发出类似
dump_var(a, b, c)的调用。适配器将它们重写为元组本地变量和一个泛型的fuzzer::dump_var(...)调用,因为 custom MIR 调用操作数对元组表达式很挑剔。注入。 适配后的函数和一个小的包装器被写入
src/generated_case.rs。运行。
cargo oxide run rustlantis-smoke执行 CPU oracle 和 GPU 内核。分类。
run_seed.py记录种子是通过、不匹配、编译失败还是超出了适配器当前的支持范围。
当前检入的生成案例使用种子 19,因为它展示了我们希望从 harness 中获得的重要属性:多个中间转储,而不仅仅是一个最终值。
__rl_dump0 = (Move(_1), Move(_2), Move(_3), Move(_4));
Call(_9 = dump_var(Move(__rl_dump0)), ReturnTo(bb4), UnwindUnreachable())
__rl_dump1 = (Move(_6),);
Call(_9 = dump_var(Move(__rl_dump1)), ReturnTo(bb5), UnwindUnreachable())
这意味着最终的跟踪哈希包含程序中某个点的多个值和稍后点的另一个值。它仍然紧凑,但不再仅仅是"返回值匹配了吗?"
运行它#
运行一个种子:
python3 crates/fuzzer/tools/run_seed.py --seed 192
运行一批:
python3 crates/fuzzer/tools/run_seed.py --start 0 --count 20 --keep-going
有用的标志:
--keep-going:在失败的种子后继续。--keep-logs:也为通过的种子写入日志。--no-build:复用已构建的 rustlantis 生成器。--append-summary:追加到现有摘要而非替换它。
默认情况下,summary.jsonl 在每次运行开始时被替换。这使得它能够回答明显的问题:"我刚刚完成的运行发生了什么?"如果你想要历史记录,使用 --append-summary 选择加入。
阅读结果#
运行器为每个种子打印一行,然后打印完整的摘要:
results:
seed 0: UNSUPPORTED [adapter] unsupported dumped type for Stage 2 adapter: u128 (...)
seed 1: COMPILE_FAIL [backend] Unsupported construct: Type translation not yet implemented for: RigidTy(Char) (...)
summary: COMPILE_FAIL=1, UNSUPPORTED=1
状态含义:
状态 |
含义 |
|---|---|
|
CPU 和 GPU 跟踪匹配 |
|
CPU 和 GPU 跟踪不同 |
|
适配器产生了案例,但 cuda-oxide 失败了 |
|
rustlantis 生成了 MIR,但适配器拒绝了它 |
MISMATCH 是需要最认真对待的结果。两条路径都编译并运行了,但观察到不同的值。这很可能是一个后端正确性 bug。
COMPILE_FAIL [backend] 表示生成的案例通过了适配器并进入了 cuda-oxide。失败可能仍然是预期的——例如,当前不支持的 MIR 类型——但拒绝它的是后端组件。
UNSUPPORTED [adapter] 表示 rustlantis 生成了程序,但我们的适配器拒绝将其转换为 smoke 案例。例如:
unsupported dumped type for Stage 2 adapter: u128
这通常意味着生成的 MIR 有一个 dump_var(...) 包含我们跟踪 API 尚不知道如何哈希的类型。目前跟踪支持:
bool, i8, i16, i32, i64, u8, u16, u32, u64
它尚不支持 u128、i128、usize、isize 或 char。因此许多适配器级的不支持案例不是"坏的 MIR",也不是 cuda-oxide 的 bug。它们只是模糊测试 harness 还没长大的地方。编译器和人一样,在处理 u128 之前需要吃点零食。
产物#
每个种子的日志位于:
crates/fuzzer/artifacts/
失败日志包括:
种子
状态和阶段
原因
返回码
命令
完整命令输出
生成的
generated_case.rs快照(如果存在)
生成的快照很重要。如果后端失败出现在 CI 或长时间的批量运行中,日志足以看到触发它的准确的 MIR 程序。种子让你能够重新生成它,但快照为你节省了一次往返。
当前限制#
当前配置有意保持小巧。它在第一阶段专注于标量 custom MIR 和后端管道,而非一次覆盖所有 Rust 构造。这就是为什么许多早期种子被分类为 UNSUPPORTED [adapter]。
扩展计划是增量的:
添加对更多标量类型的跟踪支持(
u128、i128、usize、isize)。决定是否以及如何支持 cuda-oxide 类型翻译中的
char。扩展控制流和类型转换的覆盖率。
添加数组、元组,最终添加结构体/枚举。
为失败的种子添加最小化功能。
这个顺序是有意排列的。一个第一天就生成所有内容的模糊测试器主要产生噪音。一个一次扩展一个维度的模糊测试器告诉你什么坏了以及为什么。更加友好。