“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 有十几个方法,我们要写十几个”一行转发”。视觉上重复 |
| 必须实现整个 trait | Rust 不允许”实现 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 升级自动同步。
下次设计接口时遇到”要不要继承”的犹豫,记住这个例子。