如果你的工作离不开推理引擎,或者在边缘设备上调试过量化模型,那你大概率体会过这种循环:新模型引入一个新算子,新硬件带出一套新调度,新量化方案又催生几种变体——所有代码在嵌套循环和内存对齐里打转,换一个场景就要重来。我在整理内部推理库的量化算子时,就是被这种局面反复摩擦之后,决定不再给算子打补丁,而是把“量化算子”这个概念拆掉,提炼出一套更底层的执行原语,最终形成一个可迁移的 Rust 执行模型。这篇文章就记录这个从具体到通用的重构过程,包括设计思路、代码落地、踩坑实录和适用范围,希望对正在做算子库、AI 推理引擎或 Rust 重写项目的朋友有参考价值。
1. 为什么要从量化算子开始重构执行模型
先说一个容易被忽视的事实:量化算子是整个推理链路里最适合做抽象验证的试验田。它既有明确的数据变换逻辑,又有严格的类型和内存约束,同时横跨 CPU、GPU、NPU 等多种硬件后端。把量化算子抽象成通用执行原语,相当于用一个足够复杂但边界清晰的问题来证明模型是否成立。
1.1 量化算子的困境:相似但不相通
我最初面对的量化算子大致是这种结构:INT8 权重矩阵、INT8 激活矩阵,乘加后累加成 INT32,再经过 scale 和 zero point 调整回 INT8。听起来很标准,但落到底层就完全不是一回事。
不同的硬件向量宽度不同,AVX2 是 256 位、NEON 是 128 位;内存布局有 NCHW、NHWC、HK 几种格式,有的后端还要求 32 字节对齐;even 更麻烦的是,量化参数本身也有“per-tensor”和“per-channel”之分,scale 是一个数还是一个数组,直接决定内层循环怎么写。
这些差异导致了大量排列组合。每适配一个新的硬件后端,几乎要把所有算子重写一遍;每新增一种量化方案,又要在所有后端上重复改。代码看起来很多,但本质上都是同一个数据变换的无数个副本。问题的核心在于:算子实现里,业务逻辑和硬件细节耦合得太紧,缺少一层“可插拔”的执行抽象。
1.2 从算子视角切换到原语视角
我一直把“算子”理解为某个具体的算法实现,比如int8_gemm、int8_conv。重构时我逼自己换了一个视角:不再看“这是 GEMM”,而是看“这其实是一个数据块经过某种映射变成另一个数据块”。
那个映射,就是执行原语。它不关心这个数据块是不是 INT8,也不关心它是 GEMM 还是卷积,只关心“输入一批 Buffer,经过一个可调用的计算核心,产生一批 Buffer”。算子被拆成了三个独立维度:数据布局、计算逻辑、后处理策略。这样拆开之后,同一个计算核心可以在不同硬件上跑,同一套后处理逻辑可以复用于不同算子,硬件相关的东西全部下沉到 Executor 层。
这个视角的转变不是灵光一闪,而是被工程逼出来的。我记得亲手把同一个量化 GEMM 在 AVX2、NEON、CUDA 上写了三遍之后,终于忍不住画出那张抽象图:上面是计算图,中间是执行原语,下面是硬件后端。一旦这张图画出来,整个重构方向就定了。
2. 执行原语的核心设计:从类型绑定到数据契约
把算子改成原语,不是简单地把函数签名里的i8改成泛型就能解决的。真正要改变的是“接口的契约方式”:算子用具体类型来约束输入输出,而原语只定义数据块之间的拓扑连接关系,类型信息作为元数据挂在 Buffer 上。
这套模型在 Rust 里落地,我把它拆成三个基础抽象:Buffer、Kernel、Pipeline。
2.1 Buffer:抽象数据块,而非具体张量
Buffer是一个不确定元素类型的内存区域。早期的设计我用Vec<u8>加一个DataType枚举,后来发现不够,因为量化算子里除了类型,还需要知道「对齐方式」「shape 布局」「访问权限」。这些信息如果散落在代码各处,最终还是会变成排列组合。
所以Buffer最终长这样:
pub struct Buffer { data: Box<[u8]>, // 底层字节存储 layout: LayoutSpec, // shape、stride、axis 顺序 dtype: DataType, // U8 / I8 / I32 / F32 / F16 align: usize, // 对齐要求,常见 16/32/64 readonly: bool, // 常量权重还是可变激活 }用BufferId是内存池里的一个索引,而不是直接把&[u8]传进 Kernel。为什么?因为 Pipeline 里多个 Kernel 之间存在数据依赖,如果把裸 slice 传进去,就无法在运行时做依赖检查;而使用句柄,让ExecContext统一管理内存访问,既保留灵活性,又能做边界检查。
我在重构初期走过弯路:直接用Vec<Tensor>传递数据,导致每个 Kernel 都要处理Rc<RefCell<...>>或者Arc<Mutex<...>>,不仅烦琐,而且性能开销不小。换成Arena分配池之后,整个模型清爽很多。
2.2 Kernel:计算单元的最小契约
Kerneltrait 是这套执行模型的灵魂。它的定义必须足够宽,能容纳任何数据变换,同时又足够窄,让 Executor 可以批量调度。最终版本是这样的:
pub trait Kernel { fn name(&self) -> &'static str; fn signature(&self) -> Vec<ArgSpec>; fn execute(&self, ctx: &mut ExecContext) -> Result<()>; }ArgSpec描述的是“这个 Kernel 期望哪些参数槽位”,类似函数调用的参数列表。execute从ExecContext里按名取出输入输出BufferId以及额外的标量参数。
这里有一个很重要的设计选择:把参数读取放在execute内部,而不是放在 trait 方法的参数列表里。这样做的好处是,Kernel 本身不需要知道 Pipeline 的具体连接关系,只负责“给我上下文,我自己找参数”。Pipeline 只负责定义拓扑,Executor 只负责准备上下文,各层职责清晰。
2.3 Pipeline:把原语串成可执行的流水线
Pipeline是一组Kernel实例和它们之间的有向连接。它不是一个计算图引擎,小而够用:
pub struct Pipeline { kernels: Vec<Arc<dyn Kernel>>, edges: Vec<Edge>, }Edge表示“某个 Kernel 的输出 Buffer 连接到另一个 Kernel 的输入 Buffer”。为什么不用图数据库级别的东西?因为绝大部分推理模型的前向计算是静态拓扑,不需要动态分配节点。这条限制让执行模型可以做到极简,同时性能可控。
有了这三个抽象,一个量化的 INT8 GEMM 就变成:一个GemmKernel先计算 INT32 累加结果,一个QuantizeKernel再把 INT32 转回 INT8。两者通过 Pipeline 中的 Edge 连接起来,后处理不再硬编码在 GEMM 里。
2.4 为什么这套模型选 Rust
坦白说,这个设计我用 C++ 也能做,但 Rust 的 ownership 模型在工程上帮我拦住了很多低级错误。Kernel 之间共享状态是执行模型里最危险的部分,C++ 里可能悄悄出现数据竞争,在 Rust 里如果你尝试把一个&mut Buffer同时传给两个 Kernel,编译器直接拒绝。
Rust 的 trait object 可以做动态分派,让同一套 Pipeline 跑不同硬件后端;泛型和const generics可以做静态特化,让内层循环在编译期确定 tile 大小。这两种能力的组合,正好匹配“上层通用、下层性能敏感”的执行模型需求。
3. 实操拆解:把一个 INT8 GEMM 改造成执行原语
理论结构说完了,现在进入最实际的部分:怎么把一个传统的量化算子逐步改造为通用执行原语。我用一个简化但完整的例子,展示从函数签名到 Pipeline 的全部过程。
3.1 原始量化算子的痛点清单
假设我们有一个最朴素的量化 GEMM 函数:
fn int8_gemm( a: &[i8], b: &[i8], c: &mut [i32], m: usize, k: usize, n: usize, scale: f32, ) { for i in 0..m { for j in 0..n { let mut acc = 0i32; for kk in 0..k { acc += a[i * k + kk] as i32 * b[kk * n + j] as i32; } c[i * n + j] = acc; } } }它的问题非常明显:类型绑定死(i8),算法绑定死(GEMM),后处理绑定死(scale 是浮点乘法),布局绑定死(行主序)。任何一环变化,都要重新拷贝一遍代码。
关于 scale 是浮点乘法还能不能优化,其实是另一个大话题。symmetric 量化时可以把 scale 吸收到相邻的 op 里,但这个优化属于“算子融合”层面,不是当前执行模型的核心。
3.2 Kernel trait 的落地版本
我开始改造的第一步,是定义一套瘦身版的执行上下文:
pub struct ExecContext { buffers: Arena<Buffer>, args: BTreeMap<String, ArgValue>, } impl ExecContext { pub fn buffer(&self, id: BufferId) -> BufferView<'_> { // 返回只读切片视图 } pub fn buffer_mut(&mut self, id: BufferId) -> BufferViewMut<'_> { // 返回可变切片视图 } pub fn arg<T: FromArgValue>(&self, name: &str) -> Result<T> { ... } }ArgValue可以是一个f32、一个整数、一个张量形状,甚至是一个函数指针,用于表达 epilogue。
然后定义 Kernel trait。为了让示例不涉及太多 DSA 细节,我保持它等于前面章节展示的三方法版本。量化 GEMM 改造后长这样:
struct GemmKernel { layout_a: LayoutSpec, layout_b: LayoutSpec, layout_c: LayoutSpec, fuse_scale: bool, tile_m: usize, tile_n: usize, tile_k: usize, } impl Kernel for GemmKernel { fn name(&self) -> &'static str { "gemm" } fn signature(&self) -> Vec<ArgSpec> { vec![ ArgSpec::buffer("a"), ArgSpec::buffer("b"), ArgSpec::buffer("c"), ArgSpec::scalar("scale", ArgKind::F32), ] } fn execute(&self, ctx: &mut ExecContext) -> Result<()> { let a = ctx.buffer(ctx.arg("a")?)?; let b = ctx.buffer(ctx.arg("b")?)?; let scale = ctx.arg::<f32>("scale")?; let mut c = ctx.buffer_mut(ctx.arg("c")?)?; // 从 buffer 元数据里读取 m/k/n,而不是硬编码 let (m, k, n) = shape_from_layout(&self.layout_c, &c.layout())?; // 核心循环,根据 hardware 特征选择 tiling 参数 for i in (0..m).step_by(self.tile_m) { ... } // epilogue:scale 是否融合到内存循环里 ... Ok(()) } }到这里,GEMM 已经从“算子的具体实现”变成了“一个读取参数、读取 Buffer 元数据、自行决定算法细节的原语”。它不再知道系统的调度逻辑,只关心自己那一块数据变换。
3.3 Pipeline 组装与 Executor 分工
有了多个 Kernel 之后,用 Pipeline 把它们串起来。这里的关键点是想清楚“谁负责执行顺序”。
我的方案是:Pipeline 保存kernels和edges,而 Executor 负责真正运行。Executor 有多个后端实现,比如:
pub trait Executor { fn run(&self, pipe: &Pipeline, inputs: &mut ExecContext) -> Result<()>; } pub struct SingleThreadExecutor; pub struct TilingExecutor; pub struct CudaExecutor;SingleThreadExecutor是最朴素的实现:按拓扑顺序依次调用每个 Kernel 的execute,不做任何状态优化。TilingExecutor则会把 Pipeline 切分成多个阶段,每个阶段内部做 cache 优化,用多线程并行执行无依赖的 Kernel。而CudaExecutor会把 Pipeline 翻译成 CUDA graph 的配置,同样复用同一套Kerneltrait。
这样一来,迁移就变得非常具体了:你在 CPU 上调试好一个量化 GEMM Kernel,不需要改 Pipeline 的定义,只要为这个 Kernel 实现一个 CUDA 版本,并把它注册到 CudaExecutor 的 kernel 工厂里,上层业务完全无感。
这个设计对“算子性能挑战”问题的回应是:性能不再靠堆砌硬编码优化,而是靠把可向量化的循环约束到一个统一的调度框架中,让 Executor 决定 tiling 和并行策略。
3.4 迁移带来的具体收益
把一个量化算子库改成执行模型之后,我统计到的收益有几个方面:
| 维度 | 改造前 | 改造后 | |---|---|---| | 新增后端适配 | 重写全量算子 | 只需新增 Kernel 实例和 Executor | | 新增后处理逻辑 | 修改每个算子实现 | 新增一个 Kernel 并插入 Pipeline | | 单元测试粒度 | 测试算子,容易爆炸 | 测试 Kernel + Pipeline 两级,边界清晰 | | 跨场景复用 | 几乎不能复用 | GEMM Kernel 可用于量化、校验、模拟 |最直观的感受是:当需求从“新增一个量化算法”变成“新增一个 Kernel 并插入拓扑”之后,代码量下降了一个量级,而测试范围反而更明确了。
4. 迁移过程中的常见问题与排查技巧实录
这个执行模型在工程落地过程中,我也踩了不少坑。挑四个最有代表性的写出来,每个都是能直接“抄作业”的经验。
4.1 生命周期问题与 Arc 的使用场景
第一个坑来自 Rust 的 trait object 生命周期。Pipeline 里如果保存的是Box<dyn Kernel>,而 Kernel 内部又引用了外部的一些配置对象,通常需要显式标注生命周期,例如Box<dyn Kernel + 'a>。这样会传染整个 Pipeline,让 Pipeline 变成一个带生命周期参数的泛型类型,使用起来很痛苦。
我的解法是:所有 Kernel 内部不持有借用,一律使用拥有所有权的配置对象,或者使用Arc。这样 trait object 的类型变成Arc<dyn Kernel>,生命周期就变成'static,Pipeline 不再需要泛型生命周期参数。
代价是每一个 Kernel 调用execute时会多一次Arc的引用计数操作。这个开销在算子粒度下几乎可以忽略,因为一次execute通常执行几十万次乘加运算。
4.2 动态分派性能成本的边界
很多人一看到dyn Kernel就紧张,觉得虚函数调用性能差。实测下来,关键在于调用粒度。如果你的 Kernel 一次只负责一个标量的计算,动态分派会成为瓶颈;但如果一个 Kernel 执行一个完整的 GEMM tile(比如 64x64,数千次乘加),那一次动态分派的开销就是微不足道的。
我建议在 profiling 时做两层分析:外层看 Pipeline 的总耗时,里层看 Kernel 内部的热点循环。如果发现execute的 dispatch 占比超过 1%,说明你的 Kernel 粒度太小,应该把细粒度操作提升为原子操作,而不是把抽象层级降低。
4.3 内存对齐与安全的高危地带
量化算子对内存对齐极其敏感,尤其是 AVX 和 NEON 的 load/store 指令,不达标就直接 crash。抽象到执行原语层后,对齐信息很容易丢失。
我把对齐要求纳入Buffer的元数据,并给BufferViewMut暴露align_offset方法。创建 Buffer 时使用#[repr(align(32))]或者手工分配对齐内存。Box<[u8]>默认的对齐是 1,所以必须用自定义 allocator,或者简单的Vec<u8>+ 手动对齐。
这块如果你想简化,可以做一个AlignedBuf类型,内部用alloc_zeroed配合Layout::from_size_align来分配,然后在Buffer构造时就校验对齐参数。
4.4 共享状态与并行执行的冲突
执行模型里最常见的并行冲突是:Kernel 内部需要临时缓冲区,但 Kernel 实例是共享的(放进了Arc)。比如量化 GEMM 在 tiling 时需要一个 temp smem,如果多个线程同时调用同一个 Kernel 实例,临时缓冲区会被互相覆盖。
这里有三条解决路径,按适用场景排列:
- 将临时缓冲区放入
ExecContext,每个执行上下文独立,避免共享; - Kernel 实现内部用
thread_local!缓存池,keyed by thread id; - 如果 Kernel 是纯函数式的,干脆在 execute 内部每次分配小临时区,用 arena 统一回收。
我实际采用第一和第三的组合:Kernel 内部分大块临时区时用 ExecContext 里的 scratch arena,小块数组直接分配。发现性能最好且代码最清晰。
另外还有一个容易踩的坑:Kernel 的实现如果用RefCell来持有状态,而 Executor 是多线程的,会在运行时 panic。所以我的结论是:Kernel 实例应尽可能无内部状态,所有可变状态都放进 ExecContext。
5. 这套模型可以迁移到哪些场景
很多人以为这是 AI 推理专属的抽象,其实不是。执行原语的定义足够通用,它的本质是“把数据变换和调度解耦”。最直接的证明是:我后来把同一套 Pipeline 定义用在了非 AI 领域的小工具里,几乎没有改到核心 trait。
5.1 在 AI 推理之外的执行空间
数据处理流是天然的适配场景。假设你在做流式 ETL,每个阶段是一个 map/filter/aggregate,那么 Kernel trait 恰好能表达这些阶段,Executor 可以控制并行度和 batch size。相比手写一个 stage 管道,Rust 执行模型带来的类型安全和可检测性让我写起来更安心。
物理模拟或游戏引擎的渲染 pass 也可以用类似方式组织:Geometry stage、Transform stage、Rasterize stage 都是一批 Kernel,管线结构固定,后处理逻辑可插拔。这个模式其实就是现代图形 API render pass 的 CPU 版本。
5.2 为什么很多“Rust 重写”卡在中间层
常看到讨论说某大型 C++ 项目要改用 Rust 重写,然后争论焦点在驱动、在 UI、在生态。但根据我重构算子库的经验,真正意义上的硬骨头往往是中间的执行层:上面是业务模型,下面是硬件抽象,它既要足够抽象,又不能丢失性能。
如果一开始把整个系统设计成执行原语模型,重写的工程量会小很多。因为每个具体算法可以被单独拆出来测试,硬件后端可以逐块替换。这也解释了为什么idea 未来会使用 Rust 重写吗这类讨论中,很多人最终发现“重写”不是全能替换,而是先把核心计算链路迁过来。执行原语模型让它变成可能。
5.3 对“大量使用算子对硬件性能的挑战”的另一种回答
搜索词里有一个我很关注的表述:“大量使用算子对硬件性能的挑战”。表象是算子数量太多,调度开销变大,内存搬运变多。实际原因是每个算子都是“独立王国”,无法统一做 tiling 和融合。
把算子抽象成执行原语之后,Executor 拥有了全局信息。它可以看到整个 Pipeline 中哪些 Kernel 之间的数据可以保持在缓存里,哪些计算可以融合成一个更大的 Kernel。这比局部手动 fusion 要系统得多,也是解决算子膨胀问题的主要路径。
从量化算子到通用执行原语,本质上是把“数据变换的契约”和“数据变换的实现”彻底分开。Rust 的类型系统、所有权模型、动态与静态分派组合,让这套分层可以安全、可迁移地落地。
5.1 实际项目中的套用公式
最后分享一个我总结的公式,用来判断一个具体算子是否值得改造成原语:
- 如果这个算子只在一个后端上跑,且不会再变,就不要套抽象,直接写最优实现;
- 如果这个算子的算法逻辑和硬件循环紧耦合,但未来会换后端,那至少先把内存布局提成参数;
- 如果这个算子会出现在多个 Pipeline、多个后端的排列组合里,那就值得实现为独立 Kernel。
我的实际体会是:抽象不是越早越好,而是在你第三次复制粘贴同一段核心计算时,就该动手了。Kernel trait 定义出来后不要急着铺开,先用一个高频的量化融合算子验证整个 Pipeline 跑通,再逐步迁移其他算子。
希望这套执行模型思路能给你一些参考。如果你也在做算子库、推理引擎或者想给自己的 Rust 系统搭一层可迁移的执行层,欢迎交流具体场景。