cuda-oxide:用组合代替继承——一个真实例子

面向对象书里讲烂了的 Composition over Inheritance,在 cuda-oxide 的 CudaCodegenBackend 上是怎么落地的——为什么我们不去重写一个 LLVM 后端,而是把它包起来。

📚 系列 cuda-oxide · 第 3 篇

“Composition over Inheritance” 是面向对象设计里讲烂了的一条原则。但脱离具体场景看,它就只是一句口号。这篇文章用 cuda-oxide 里 CudaCodegenBackend 的真实例子,把它讲透——为什么我们不去继承/重写 LLVM 后端,而是把它当成一个字段塞进自己的 struct

0. 任务设定

我们要实现一个 rustc 的 codegen backend,把 Rust 代码编译到 NVIDIA GPU。

rustc 提供了 CodegenBackend 这个 trait,自带的 LLVM 后端就实现了它。我们要做的事:

  • device 代码(#[kernel] 标的函数)——走我们自己的管线:MIR → dialect-mir → dialect-llvm → LLVM IR → PTX。
  • host 代码(main、辅助函数、所有不带 #[kernel] 的东西)——和普通 Rust 程序没区别,沿用 LLVM 后端就行

问题来了:CodegenBackend trait 有十几个方法,我们只想拦截其中一个(codegen_crate),其它的全部转发到原生 LLVM 后端。怎么写最干净?

1. 三种思路

思路描述评价
① 全部重写我们自己实现 CodegenBackend 所有方法✗ 等于重写 LLVM 后端的几十万行
② “继承”原生后端CudaCodegenBackend 继承 LlvmCodegenBackend,只覆写 codegen_crate✗ Rust 没有继承——而且即使有,也耦合过重
③ 组合LlvmCodegenBackend 当字段塞进 CudaCodegenBackend,绝大多数方法转发给它✓ 我们的选择

Rust 根本没有继承,所以 ② 在语言层就被否决了——但更深的理由是:即使有继承,也不该用

2. 组合的写法

// crates/rustc-codegen-cuda/src/lib.rs
pub struct CudaCodegenBackend {
    config: CudaCodegenConfig,
    /// 我们组合的 LLVM 后端
    llvm_backend: Box<dyn CodegenBackend>,
}

构造时,把原生 LLVM 后端的实例塞进字段:

// crates/rustc-codegen-cuda/src/lib.rs
#[unsafe(no_mangle)]
pub fn __rustc_codegen_backend() -> Box<dyn CodegenBackend> {
    init_tracing_once();
    let config = CudaCodegenConfig::from_env();
    let llvm_backend = rustc_codegen_llvm::LlvmCodegenBackend::new(); // ← 原装 LLVM 后端
    Box::new(CudaCodegenBackend { config, llvm_backend })
}

然后 trait impl 大量”转发”:

impl CodegenBackend for CudaCodegenBackend {
    fn name(&self) -> &'static str { "cuda" }            // ← 我们自己

    fn init(&self, sess: &Session) {
        self.llvm_backend.init(sess);                     // ← 转发
    }
    fn print_version(&self) {
        self.llvm_backend.print_version();                // ← 转发
    }
    fn target_cpu(&self, sess: &Session) -> String {
        self.llvm_backend.target_cpu(sess)                // ← 转发
    }
    fn target_config(&self, sess: &Session) -> TargetConfig {
        self.llvm_backend.target_config(sess)             // ← 转发
    }
    fn provide(&self, providers: &mut Providers) {
        self.llvm_backend.provide(providers);             // ← 转发
    }

    fn codegen_crate(&self, tcx: TyCtxt<'_>, crate_info: &CrateInfo) -> Box<dyn Any> {
        // ⬇ 唯一真正"自己写"的方法
        // ① 找出有没有 #[kernel],有的话走 cuda-oxide 管线生成 PTX
        // ② host 部分调 self.llvm_backend.codegen_crate(tcx, crate_info)
    }

    fn join_codegen(&self, ongoing: Box<dyn Any>, sess: &Session, outputs: &OutputFilenames) -> _ {
        self.llvm_backend.join_codegen(...)               // ← 几乎转发,只加点 PTX artifact
    }

    // ... 其它方法 ...
}

我们写的代码就一个 codegen_crate 方法的实现,其它都是一行转发。

3. 关键洞察:codegen_crate 里面也是”调用而非替换”

codegen_crate 看,我们的”自己写”也不是从零造一遍:

fn codegen_crate(&self, tcx: TyCtxt<'_>, crate_info: &CrateInfo) -> Box<dyn Any> {
    let mono_partitions = tcx.collect_and_partition_mono_items(());
    let has_device_code = count_kernels(...) > 0;

    if has_device_code {
        // 我们自己的管线,生成 .ptx
        device_codegen::generate_device_code(tcx, ...);
    }

    // host 代码——还是丢给 LLVM 后端
    let host_result = self.llvm_backend.codegen_crate(tcx, crate_info);
    Box::new(CudaOngoingCodegen { host: host_result, ... })
}

核心方法内部依然在调用被组合的对象。device 这条路是新的,host 这条路是借来的。

4. 为什么这个写法是赢家

4.1 不重新发明轮子

rustc_codegen_llvm::LlvmCodegenBackend 是 rustc 团队多年打磨的成熟代码,光支持 x86/ARM/RISC-V/WASM 等目标就够复杂了。我们要做的只是”分叉一条 GPU 路径”,host 路径完全没必要重新实现。

4.2 rustc 升级自动跟上

LLVM 后端每个 nightly 都在变(支持新的 target feature、跟 LLVM API 升级)。我们用组合写法,rustc 升级一下 toolchain,LLVM 后端的能力同步升级——我们一行 host codegen 代码都不用动。

如果用继承(就算有),每次 rustc 改 LLVM 后端的内部状态,我们都得对应改。

4.3 “我的”和”借来的”边界清晰

fn codegen_crate(&self, ...) { /* 我们自己写 */ }
fn name(&self) -> &'static str { "cuda" }    // 我们自己写

// 剩下全部:
fn xxx(&self, ...) { self.llvm_backend.xxx(...) }   // 一目了然是借来的

谁负责什么一目了然。读代码的人一眼看出哪些行为是我们定义的、哪些来自 LLVM 后端。继承会把这种边界模糊掉(子类某个方法可能完全没动、可能只调了 super 一下加了点东西、可能完全重写——读代码时要逐一看)。

4.4 只暴露一个 hook,降低风险

CodegenBackend 有十几个方法,如果我们用继承每个都可能不小心覆写,会引入 bug。组合写法默认行为 100% 等于原生 LLVM 后端,只在我们 explicitly 写覆盖的方法上有差异——风险面被局限在一两个方法里。

5. 类比:这其实就是 GoF 的 Decorator

如果你熟悉设计模式,这写法的本质是 Decorator 模式(装饰器):

LlvmCodegenBackend        ←  原生类型

        │ 包装(组合,不是继承)

CudaCodegenBackend         ←  装饰后的类型
  ├── 额外行为:codegen_crate 里加入 device 路径
  └── 其它行为:全部转发给 inner

Decorator 就是为了在不修改原类型的前提下,给它增加新行为而生的。和我们这里的需求一一对应。

6. 代价

诚实说一下用组合的代价。

代价影响
大量样板转发代码trait 有十几个方法,我们要写十几个”一行转发”。视觉上重复
必须实现整个 traitRust 不允许”实现 trait 的一部分”。组合 + 转发是把这种”部分实现”模拟出来
间接调用开销self.llvm_backend.xxx() 多一层 vtable 跳转——但编译器多半能 devirtualize,实际无差

那一堆样板转发是真痛苦。社区有 delegate / ambassador 等宏库可以缩减,cuda-oxide 没用,保持显式——读源码的人一眼看清”哪些是借来的”。

7. 抽象成原则

回到 “Composition over Inheritance” 这句口号。这次的例子里它具体是:

当你要扩展一个现成的复杂类型时,把它当成字段塞进你的新类型,然后转发大多数方法、只覆盖你真正关心的少数方法——而不是去继承它。

适用场景的快速识别:

  • ✓ 现成类型的行为大部分都是你想要的,只有少数地方需要换
  • ✓ 现成类型未来还会演进,你不想被绑死
  • ✓ 你希望清晰区分”哪些行为是我自己的、哪些是借来的”

cuda-oxide 的 CudaCodegenBackend 三条都中。

8. 一句话总结

CudaCodegenBackend 没有重写 LLVM 后端、也没有继承它——它只是把 LlvmCodegenBackend 当成自己的一个字段,绝大多数 trait 方法直接转发,只在 codegen_crate 这一处分叉处理 device 代码。十几万行 LLVM 后端的能力一行没浪费,我们的代码只关心 GPU 路径,边界清晰、跟 rustc 升级自动同步。

下次设计接口时遇到”要不要继承”的犹豫,记住这个例子。

系列上一篇: cuda-oxide:确认 codegen backend 符号导出

评论区
评论功能即将上线, 敬请期待。