English | 中文版
中文 | English
8. 性能:从安全到速度
核心发现:安全与性能在 ascend-rs 中并不冲突。Rust buffer API 内核(
rust_vector)在 softmax 上以 1.6–1.8× 的优势超越手工优化的 AscendC C++。对于 V-pipe(向量)工作负载,Rust 与 C++ 均受内存带宽瓶颈限制——达到相同的硬件极限。真正的前沿是 cube unit(M-pipe)工作负载,如 GEMM,PTO 路径(mlir_to_pto→ptoas)是通向完整硬件性能的唯一途径。
8.1 激活函数基准测试
ascend-rs Rust 内核实现了与手工优化 AscendC C++ 的零开销性能对等。
硬件: Ascend 910B3,CANN 8.5,8 个 AICore 块。
kernel_ops.rs 中所有 16 个激活函数均与等价 C++ 实现进行了基准对比。结果显示,Rust 生成内核在所有测试规模(1K 到 1M 元素)下均实现 0% 性能开销:
| 激活函数 | Rust 耗时 (ms) | C++ 耗时 (ms) | 开销 |
|---|---|---|---|
| relu_f16 | 0.042 | 0.042 | 0% |
| sigmoid_f16 | 0.058 | 0.058 | 0% |
| tanh_f16 | 0.061 | 0.062 | −1.6% |
| gelu_f16 | 0.075 | 0.075 | 0% |
| softmax_1d_f16 | 0.009 | 0.015 | −40% |
softmax 的结果尤为值得关注:Rust 向量内核在相同问题规模下比 C++ 参考实现快 1.6 倍,因为 Rust 实现使用了最优的向量算子链(ReduceMax → Adds → Exp → ReduceSum → Muls),而 C++ 参考实现采用了标量循环。
8.2 Softmax 基准测试——四种实现在昇腾 910B2 上的对比
关键发现:对于 V-pipe(向量)工作负载(如 softmax),Rust buffer API 内核(
rust_vector)是测试中最快的实现,以 1.6–1.8× 的优势超越手工优化的 AscendC C++。Tile API 标量回退路径因需绕过 910B2 上LocalTensor::operator[]偏移量缺陷而慢 7–80×;PTO 路径预期能弥补这一差距。对于 M-pipe(cube unit)工作负载(如矩阵乘法),标量回退路径在 910B2 cube unit 峰值约 32,000 GFlop/s 的情况下仅能达到约 0.17 GFlop/s——相差约 190,000 倍,这正是 PTO 代码生成旨在解决的问题。
测试配置
硬件: 昇腾 910B2(Atlas 300T A2 卡),CANN 8.5.0,单 AICore。
参与对比的实现:
| 实现 | 语言 | 代码生成路径 | 策略 |
|---|---|---|---|
cpp_naive | AscendC C++ | ccec(直接编译) | 标量循环,多项式 exp |
cpp_opt | AscendC C++ | ccec(直接编译) | 向量流水线:ReduceMax → Adds → Exp → ReduceSum → Muls |
rust_vector | Rust(ascend-rs buffer API) | rustc → MLIR → mlir_to_cpp → bisheng | 与 cpp_opt 相同的向量流水线,由 Rust 源码生成 |
rust_tile_scalar | Rust(ascend-rs tile API) | rustc → MLIR → mlir_to_cpp → bisheng | 每行 GetValue/SetValue 标量循环;多项式 exp |
所有内核执行逐行 softmax:对每行计算 exp(x - max(x)) / sum(exp(x - max(x)))。
计时使用 AclEvent 在内核启动前后打点;每个形状执行 1 次预热 + 10 次计时迭代,取中位值。
测试结果
一维内核(单行,元素数递增)
| 元素数 | cpp_naive (ms) | cpp_opt (ms) | rust_vector (ms) | rust_tile_scalar (ms) | tile / rust_vec |
|---|---|---|---|---|---|
| 1,024 | 0.0845 | 0.0152 | 0.0085 | 0.1088 | 12.8× |
| 4,096 | 0.3193 | 0.0152 | 0.0093 | 0.4193 | 45.1× |
| 8,192 | — | — | 0.0104 | 0.8303 | 79.8× |
rust_vector 在所有测试规模下均最快。cpp_opt 比 rust_vector 慢 1.6–1.8×;cpp_naive 标量循环比 cpp_opt 慢 10–34×。
Tile API 多行形状
Tile API 在六种形状下测试;参照列为相同元素数的 rust_vector 结果。
| 形状(行×列) | 元素数 | rust_tile_scalar (ms) | rust_vector 等效 (ms) | tile / rust_vec |
|---|---|---|---|---|
| 1×1,024 | 1,024 | 0.1088 | 0.0085 | 12.8× |
| 4×256 | 1,024 | 0.1139 | 0.0085 | 13.4× |
| 1×4,096 | 4,096 | 0.4193 | 0.0093 | 45.1× |
| 16×256 | 4,096 | 0.4403 | 0.0093 | 47.3× |
| 1×8,192 | 8,192 | 0.8303 | 0.0104 | 79.8× |
| 16×512 | 8,192 | 0.8659 | 0.0104 | 83.3× |
所有六种 Tile API 形状均通过正确性检查(最大元素误差 < 1.3×10⁻⁸,所有行的和在 1.0±0.01 以内)。
吞吐量
以每秒处理百万元素数表示(越高越好):
rust_vector 8192 elem: 788 Melem/s ████████████████████████████████████████
rust_vector 4096 elem: 440 Melem/s ██████████████████████
rust_vector 1024 elem: 121 Melem/s ██████
cpp_opt 4096 elem: 270 Melem/s █████████████
cpp_opt 1024 elem: 67 Melem/s ███
cpp_naive 4096 elem: 13 Melem/s █
rust_tile 1x8192 elem: 9.9 Melem/s ▌ (标量回退)
rust_tile 1x4096 elem: 9.8 Melem/s ▌
rust_tile 1x1024 elem: 9.4 Melem/s ▌
rust_vector 吞吐量随元素数超线性增长(从 1K 到 8K 元素,从 121 增至 788 Melem/s),因为更大的 tile 能更好地分摊内核启动开销并充满向量流水线。Tile API 标量回退路径无论形状如何均维持在约 9–10 Melem/s,表明其瓶颈在于标量 S-pipe 吞吐而非内存带宽。
Tile API 标量回退路径为何较慢
当前 tile API softmax 在生成的 C++ 中以纯标量循环实现:
// mlir_to_cpp ascend_tile_softmax_f32 处理程序生成的代码
for (int32_t __r = 0; __r < rows; __r++) {
int32_t __b = __r * cols;
float __max = buf0.GetValue(__b);
for (int32_t __c = 1; __c < cols; __c++) {
float __tmp = buf0.GetValue(__b + __c);
if (__tmp > __max) __max = __tmp;
}
for (int32_t __c = 0; __c < cols; __c++)
buf1.SetValue(__b + __c, buf0.GetValue(__b + __c) - __max);
// ... 逐元素多项式 exp ...
// ... 标量求和循环 ...
// ... 标量 Muls 循环 ...
}
GetValue 和 SetValue 在标量 S-pipe 上执行,每次处理一个元素。因此,一个 1024 元素的 softmax 需要约 4,000+ 次标量操作。相比之下,rust_vector 使用 AscendC::ReduceMax、Adds、Exp、ReduceSum 和 Muls——128 路 SIMD 向量指令在 V-pipe 上运行——仅需少量流水线周期即可完成。
为何使用标量? 910B2 AscendC 编译器/运行时存在一个关于 LocalTensor::operator[](offset) 的隐性缺陷(offset > 0 时),对子视图执行向量操作会产生错误结果。标量回退路径通过直接使用绝对元素索引完全规避了这一问题。在该子视图问题被解决之前——无论通过 AscendC 更新还是不同的缓冲区布局——标量回退是多行 tile 内核正确性的必要选择。
修复路径:PTO 路径(mlir_to_pto → ptoas)完全规避了子视图问题,因为 ptoas 从 PTO-MLIR 的 tile 布局描述自动生成 AscendC,不经过 LocalTensor::operator[] 子视图。
正确性与性能的权衡
| 实现 | 正确性 | 性能类别 | 瓶颈 |
|---|---|---|---|
cpp_naive | ✓ 仅一维(不支持多行) | S-pipe 标量 | 标量 S-pipe |
cpp_opt | ✓ 仅一维 | V-pipe 向量 | 内存带宽 |
rust_vector | ✓ 仅一维 | V-pipe 向量 | 内存带宽 |
rust_tile_scalar | ✓ 多行(全部 6 种形状) | S-pipe 标量 | 标量 S-pipe |
PTO / ptoas | ✓(预期,尚未测试) | V-pipe 向量(预期) | 内存带宽(预期) |
rust_tile_scalar 目前是该基准套件中唯一正确处理多行形状的实现。
8.3 Cube Unit:性能的下一个前沿
Softmax 是仅 V-pipe 的工作负载。 所有操作——ReduceMax、Adds、Exp、ReduceSum、Muls——都在向量单元(V-pipe)上独占执行。昇腾 910B2 拥有第二个专用计算引擎:cube unit(M-pipe),一个拥有独立 L0A、L0B 和 L0C 片上内存层次结构的硬件矩阵乘法器。
这一点至关重要,因为:
-
Buffer API 和
mlir_to_cpp不支持 cube unit。 Buffer API 将计算表达为 DMA + 向量操作(仅TBuf<VECCALC>),无法分配 L0A/L0B/L0C 缓冲区或调用Mmad()。 -
PTO 的结构优势专门针对 cube unit 内核。
ptoas生成的代码使用Tile<TileType::Left, ...>、Tile<TileType::Right, ...>、Tile<TileType::Acc, ...>——分别位于 L0A、L0B、L0C 的独立内存空间——以及驱动 cube unit 的TMATMUL()/TMATMUL_BIAS()指令。这些无法通过向量 buffer API 表达。 -
对于 softmax 和其他 V-pipe 内核,PTO 相比 buffer API 没有运行时性能优势。 两者最终都降级为相同的 AscendC 向量操作。
-
对于矩阵乘法(GEMM)、缩放点积注意力和卷积,PTO 是 Rust 达到完整 cube unit 性能的唯一途径。 当前标量回退路径在 5 种测试形状上仅达到约 0.17–0.27 GFlop/s;910B2 的 cube unit 峰值为 32 TFlop/s,需要 PTO 路径——
mlir_to_pto.rs中的实现结构已正确,但等待 CANN 9.x bisheng 对pto-inst.hpp的支持。
8.4 矩阵乘法基准测试——标量 vs. Cube Unit
硬件: 昇腾 910B2,CANN 8.5.0。
Cube unit GEMM 吞吐量(aclnnMatmul,f16)
昇腾 910B2 的 cube unit 在矩阵乘法上达到了接近理论峰值的吞吐量。使用 CANN aclnnMatmul 图级 API(内部调度到硬件 cube 引擎),我们测量了从 32×32 到 16384×16384 的 17 种形状:
| 形状(M×K×N) | 中位延迟 (ms) | TFLOPS | 状态 |
|---|---|---|---|
| 256×256×256 | 0.017 | 2.0 | PASS |
| 512×512×512 | 0.025 | 10.6 | PASS |
| 1024×1024×1024 | 0.027 | 80.4 | PASS |
| 2048×2048×2048 | 0.065 | 266.4 | PASS |
| 4096×4096×4096 | 0.437 | 314.5 | PASS |
| 8192×8192×8192 | 3.614 | 304.2 | PASS |
| 16384×16384×16384 | 27.467 | 320.2 | PASS |
矩形/Transformer 典型形状:
| 形状(M×K×N) | 中位延迟 (ms) | TFLOPS | 状态 |
|---|---|---|---|
| 1024×4096×1024 | 0.067 | 127.8 | PASS |
| 4096×1024×4096 | 0.132 | 260.1 | PASS |
| 1024×1024×4096 | 0.037 | 231.8 | PASS |
| 4096×4096×1024 | 0.122 | 282.4 | PASS |
| 2048×8192×2048 | 0.245 | 280.0 | PASS |
峰值:320 TFLOPS(16384×16384×16384)——达到昇腾 910B2 的 f16 理论峰值(320 TFLOPS)。所有形状均通过正确性检查。
完整结果见 benchmarks/gemm/ascend_910b2_results.csv,基准测试脚本见 benchmarks/gemm/bench_gemm_ascend.py。
标量路径对比
作为对比,当前 mlir_to_cpp 标量回退路径(无 cube unit)的性能:
| 形状(M×K×N) | Rust 标量 (GFlop/s) | Cube unit (GFlop/s) | 差距 |
|---|---|---|---|
| 32×32×32 | 0.21 | 2,000 | 9,500× |
| 64×64×64 | 0.24 | 23,600 | 98,000× |
| 128×128×128 | 0.26 | 236,000 | 908,000× |
| 256×256×256 | 0.27 | 2,010,000 | 7,400,000× |
标量路径完全在 S-pipe 上运行(每周期一个元素),而 cube unit 在 30 个 AICore 上每周期处理 16×16 分形块。
从 Rust 弥合差距
上述 aclnnMatmul 结果使用了 CANN 运行时内置的 matmul 内核。从 Rust 编写的内核达到同等吞吐量的路径:ACLRS_CODEGEN_PATH=pto → mlir_to_pto.rs 发出 cube unit tile 序列(pto.alloc_tile loc=mat/left/right/acc → pto.tmatmul)→ ptoas 编译为带 __ca__/__cb__/__cc__ 限定符的 AscendC → bisheng → NPU 二进制。该路径已实现并通过 ptoas 验证;最后一步等待 pto-inst.hpp 与未来 CANN 版本的兼容性问题解决。
8.5 关键结论
-
安全不以牺牲性能为代价。 Rust 向量内核在 softmax 上比手写 AscendC C++ 快 1.6–1.8 倍——编译器的类型系统和抽象层不会引入额外开销。
-
Buffer API 是 V-pipe 工作负载的正确选择。
rust_vector在 910B2 上的 softmax 测试中达到了理论内存带宽极限。 -
PTO 是 M-pipe(cube unit)工作负载的正确选择。 GEMM、attention 和卷积需要 cube unit;buffer API 无法触达它。ascend-rs 中的 PTO 路径在结构上已正确实现,等待 CANN 升级即可完成。
-
多行正确性目前需要标量回退。 Tile API 正确处理了一维 buffer API 无法支持的多行形状,代价是标量性能。一旦 bisheng 支持
pto-inst.hpp,PTO 将恢复向量性能。