设计取舍
主线位置:三条主线和 einsum 线的汇总。直接计算主线上的取舍是「只有列主序」「调用方持有的 engine」「建在一串单独发布的库上」;traced 主线上的是「运行期形状的编译程序」;traced 和 eager 两条主线共有「一套语义规则」和「运算族在核心之外」;GPU 一侧是「只留一条 CUDA 路径」。每一条只说收益和代价,机制在链接指向的页面里。
flowchart TB col["只有列主序"] --> faer["交给 faer 不拷贝"] nofa["无门面 crate"] --> feat["feature 不可加"] eng["调用方持有 engine"] --> thr["线程归调用方"] api["四种张量 API"] --> rules["一套语义规则"] rules --> comp["组合运算靠分解"] rules --> fam["运算族在核心外"] api --> sym["运行期形状程序"] sym --> lower["每次 prepare 降级"] deps["一串单独发布的库"] --> step["整串齐步升级"] gpu["只留 CUDA"] --> mac["macOS 没有 GPU"]
每一节回答三个问题:tenferro 选了什么,换来了什么,付出了什么。依据只有三种:本卷各页和本页跑出来的数,tenferro-rs v0.8.0 和下层仓库在锁定 commit 上的源码与提交记录,burn 卷、candle 卷的页面和数字(burn v0.22.1、candle 0.11.0)。第 12 节是本页补的实验:同一台机器、同一组 CPU 小负载,三个库各跑一遍。实验平台 M2 Max(8 个性能核 + 4 个能效核)、macOS 27、rustc 1.99.0,全部 release;同机有别的程序在跑,每组数字旁边写了当时 uptime 的负载。本机只能跑 CPU:0.8.0 没有 WebGPU 后端了,CUDA 和 XLA / PJRT 只读源码;第 10 节那一组 Metal 数字是在 0.7.1 上重跑的。
1. 只有列主序
选择。 拥有型张量只有一种布局:紧凑列主序,步长 [1, d0, d0·d1, ...],主构造函数叫 from_vec_col_major。转置、切片、反转只换布局,不复制(列主序与视图)。candle 是行主序(布局与视图)。
换来。
- 和 LAPACK、Fortran、Julia、faer 同一种存储。CPU 收缩把张量按视图、带步长交给 tcontract 和 t4a-faer,大矩阵乘的时间全在 faer 里(一次 matmul);Fortran 序的 ndarray 可以零复制借进来。
- 批维放在最右,每个批次是一段连续内存。einsum 的两两收缩排成
[left, contracted, batch]和[contracted, right, batch],就是一串连续的矩阵乘(写一个迷你 einsum)。
付出。
- 行主序的缓冲区交给
from_vec_col_major仍然不报错,元素被按列重新解释,matmul 静默算错(列主序与视图)。0.8.0 加了from_vec_row_major,这是「有入口可用」,不是「误用会被发现」。 - 桥接要复制一次。1024² 的 f64:engine 的
to_contiguous单线程约 1 ms、8 线程 0.17 ms;不经 engine 的from_vec_row_major约 3.5 ms、to_col_major约 6.5 ms,都是单线程(同一页)。和第 12 节同尺寸 f64 matmul 的 43.8 ms(串行)、6.5–7.6 ms(8 线程)比,走 engine 的那一次复制只占 2%–3%,走from_vec_row_major就是同尺寸 8 线程 matmul 的一半。 - 非紧凑视图不能
as_slice、不能duplicate,报view is not slice-contiguous;要先to_contiguous或在 engine 上transpose(同一页)。
2. 没有门面 crate,feature 仍不可加
选择。 工作区是 16 个 tenferro-* crate,没有名叫 tenferro 的包。门面是 v0.1.0 之前删掉的,理由是 Cargo 的 feature 统一(怎么读这一卷的冷知识引了那份设计文档)。
换来。 用多少依赖多少:只做直接计算,tenferro-runtime 加 tenferro-cpu 就够;要求导再加 tenferro-ad;einsum、线性代数、FFT 的 AD 规则在各自 crate 的 autodiff feature 后面(tenferro 解决什么问题)。
付出。
- 拆开没有让依赖变少:只依赖
tenferro-runtime和tenferro-cpu就有 85 个包(源码地图)。 - 删掉门面没有消除 feature 统一的问题,只是把它挪到了 crate 之间。
native和blas互斥:依赖图里一个 crate 留着默认的native、另一个开blas-accelerate,就是 9 条编译错误,mutually exclusive那条排第三;两个都不开反而能编,matmul 在运行期报Unsupported;发布出去的tenferro-runtime上的 CPU feature 是空列表(怎么读这一卷、provider:native 与 BLAS)。 - GPU 一侧同样不可加:
EagerExtensionBackendKind的Cuda变体跟着tenferro-ad的cuda走,只给tenferro-fft开cuda时,einsum 和 linalg 报E0004 non-exhaustive patterns(同一页)。0.7.1 的webgpu是同样的陷阱,随 WebGPU 一起删了(第 10 节)。
3. 调用方持有的 engine
选择。 每个运算的最后一个参数是 &mut engine。CpuEngine 是一个值,不是句柄:持有缓冲池、计划缓存、批处理策略和线程放置;线程来自调用方建的 threadteam::Pool,tenferro 自己不建线程、不读 RAYON_NUM_THREADS。eager 的 EagerRuntime 不持有 engine,with_eager_session、backward 都借它;traced 的 Runtime 只存注册描述,run 时借 &mut [&mut engine](engine 与 threadteam 池、执行与缓存)。0.7.1 的后端句柄、会话和全进程仲裁器为什么整套拆掉,见 engine 那一页的冷知识。
对照:burn 0.22 的后端是张量所在的 Device 值,每个 op 经 Dispatch 按设备 match,两个张量在不同设备上是 panic(Dispatch);candle 的 Device 是三变体枚举,设备不一致返回 Err(Storage 与后端 trait)。两者的 CPU 线程池都是进程全局的:candle 默认用性能核数(本机 get_num_threads=8),burn 的 Flex 用 rayon 的全局池。
换来。
- 每个运算的固定开销小,而且宽度不再拖累小运算。串行 engine 上 1000 个 f64 的
add0.71 µs;team engine 只有工作量够大才分派,不值得分派的小运算多付约 0.45 µs(engine 与 threadteam 池)。第 12 节的小梯度在 8 线程、12 线程、串行 engine 上每步是 125–136 µs 对 117–120 µs。把 0.7.1 的同一个程序(design-bench071)重编重跑,默认 12 线程后端每步 631–657 µs,RAYON_NUM_THREADS=1下 116 µs,慢 5 倍多。 - 线程预算一眼可见,并发就建多个 engine:四个串行 engine 在四个线程上同时做 512² matmul,墙钟只比一个多 11%(同一页)。
- 没有隐式搬运:数据在哪个 engine、谁来算,读签名就知道;CUDA 不支持的运算报
Unsupported,不会悄悄落回 CPU(CUDA)。
付出。
- engine 要一路传下去:自己的函数要多一个
&mut CpuEngine参数,into_tensor、backward也要它;同一时刻一个 engine 只能给一个计算用。 - 宽度选错没人提醒。第 12 节 1024² 的 f32 matmul,12 线程 engine 是 6.7–7.2 ms,8 线程 3.4–3.9 ms:12 个成员的 team 要等能效核(一次 matmul)。burn 的 rayon 全局池同样是 12 个线程,同一负载 3.8–4.1 ms。
- 超订也没人管:四个 3 线程 engine 一共 12 个线程,比一个慢一倍,tenferro 不排队、不降宽度;team 预留失败要重试 10 秒才退成串行,只能靠
has_team()发现;预留的成员运算后自旋,8 线程时约 240 ms CPU(engine 与 threadteam 池)。 - 新建 engine 的钱看池怎么建:默认设置的池有 threadteam 的缓存,之后建 8 线程 engine 约 7 µs;设了栈大小等参数的专用池每次约 0.3 ms(一次 matmul)。
4. 四种张量 API
选择。 TypedTensor<T, R, D>(编译期 dtype、可选的静态秩、主机或设备表示)、Tensor(运行期 dtype)、EagerTensor(立即执行并记录)、TracedTensor(只记图)。四种是「静态还是动态 dtype」和「直接、eager、traced」两根轴的组合,最后都借同一种 engine(tenferro 解决什么问题、数据模型)。burn 和 candle 各只有一种 Tensor,autodiff 在 burn 是设备标记、在 candle 是 Tensor 里的 op 字段。
换来。 用刚好够用的那一层。不求导的代码停在 TypedTensor,没有记录、没有图;要 JAX 式的变换就用 TracedTensor,jvp(grad(f)) 得 HVP,grad 可以套三次(traced AD)。值可以在几层之间直接传:tenferro_ad::Tensor 就是 tenferro_runtime::Tensor。
付出。
- 同一个错误在四层里行为不同:f64 + f32 在
TypedTensor编不过,在Tensor报DTypeMismatch,在 eager 和 traced 被提升成 f64 算完(tenferro 解决什么问题)。 - 每层的写法不一样。traced 运算返回
Result,&a + &b + &c是E0369(怎么读这一卷);eager 运算要写在with_eager_session的回调里,回调里混用 einsum 的?是E0277,要把错误类型定成tenferro_einsum::Error(eager AD)。candle 给Result<Tensor>也实现了运算符,一条链只写一个?。 - 默认的
TypedTensor(Dynamic表示)和Tensor没有Clone,只有Host表示可以clone和下标,复制要写出duplicate()之类的名字;TypedTensor<f64>280 字节,Tensor312 字节(数据模型、存储与所有权)。candle 的clone是一次原子加。
5. traced 和 eager 共用一套语义规则,组合运算靠分解求导
选择。 两种自动微分都是 tenferro-ad 里对语义程序的变换。traced 先把输出编成 FrozenProgram,再 semantic_vjp 倒着走一遍(traced AD);eager 每个运算立即执行,同时往 semantic trace 里追加同一个算子、按 ResidualSpec 留住残差,backward 对整条 trace 做一次语义 VJP(eager AD)。核心算子的规则是 linearize_core / vjp_core。0.8.0 加的 sigmoid、gelu、softmax、layer_norm 等十三个运算没有自己的规则:它们在 composite.rs 里写在一个小原语词汇 CompositeOps 上,求导就是对分解出来的核心算子逐个套规则;唯一新增带规则的 primitive 是 erf(primitive AD 规则、激活、softmax 与归一化)。burn 的 autodiff 是一个装饰器、candle 的写在 Tensor 里,都只有 eager 一种(Autodiff 装饰器、backward 与 GradStore)。
换来。
- 一条规则写一遍,两种模式都能用:
sum(inv(A))的梯度 eager 和 traced 逐位相同,与有限差分差 4.7e-11(semantic AD 与扩展);solve、Cholesky、QR、eigh、det、SVD 同样逐位相同(线性代数)。 - 十三个新运算只多写了一条
erf规则,三个面的公式和边界策略完全一样;九个组合运算的梯度与有限差分差 1e-10 量级(primitive AD 规则)。 - 融合内核只是前向捷径:eager 只在没有输入被跟踪时才走 SIMD 融合版本,跟踪梯度时总是走组合,不会像 candle 的
rms_norm那样把梯度静默断掉(激活、softmax 与归一化、candle 卷的 RmsNorm 之后梯度是 None)。 - 导数还是程序,能再求导;eager 的函数式
grad/vjp/jvp也能嵌套。
付出。
- 核心规则实际有两份:
tenferro-internal-ops/src/ad/还有一张按PrimitiveOpKind登记的图级规则表,grad、vjp、jvp都不走它,上游架构文档却说规则在那里(primitive AD 规则)。 - 共用规则也共用 bug:einsum 在长度 1 广播下的梯度形状错,eager 和 traced 逐位相同地错(semantic AD 与扩展、einsum)。两边的报错倒不一样:同一个缺规则,traced 是
UnsupportedAdRule,eager 是RuntimeStateSource(同一页)。 - 组合运算的导数程序大:一维
softmax前向 11 个算子,梯度程序 45 个(primitive AD 规则);跟踪梯度的 eager 比融合内核慢 2 到 7 倍;traced 的组合运算要求具体形状,符号形状在建图时报missing shape hint(激活、softmax 与归一化)。 - eager 每步都要记录、再做一次反向,而反向的一半不是计算:第 12 节的一步在串行 engine 上前向 20.5 µs、反向 92.2 µs,同一个梯度的 traced 准备好的程序整步 44 µs。用 macOS 的
sample对 4 万步的循环采样 4 秒,backward_from_seed下 1246 个样本里,执行导数程序的run_prepared_on占 612 个;其余是每一步都重做的簿记:把 trace 整理成程序(analyze_deferred_semantic_trace约 96、compile_ad_source_many约 114),以及derivative_trace_from_frozen_program把导数程序搬回图、逐个登记元数据(243)。准备好的导数程序命中了缓存,这些步骤没有被缓存掉。 - traced 的导数程序是独立的,自带前向重算(traced AD)。第 12 节的
sum(tanh(W·x)),梯度程序 9 个运算(design-bench的grad_ops),前两个非广播的运算DotGeneral和Tanh就是前向,1 - tanh²写成Mul、Sub、Mul:
BroadcastInDim { shape: [Const(256), Const(1)], dims: [] }
Constant { dtype: F32, bytes: [0, 0, 128, 63] }
BroadcastInDim { shape: [Const(256), Const(1)], dims: [] }
DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [1], rhs_contracting_dims: [0], lhs_batch_dims: [], rhs_batch_dims: [] } }
Tanh
Mul
Sub
Mul
DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [1], rhs_contracting_dims: [1], lhs_batch_dims: [], rhs_batch_dims: [] } }
6. 运算族在核心之外
选择。 einsum、线性代数、FFT 不在核心算子表里。一个运算族是三份独立的东西:ExtensionOp 载荷、define_extension_runtime! 生成的执行模块(装进 Runtime)、手写的求导规则(装进 AdContext),三者只靠 <crate>.<op>.v<N> 形式的 family id 连起来,没有全局注册表(扩展算子、semantic AD 与扩展)。0.8.0 的激活、softmax、归一化走的是另一条路,是核心算子拼成的组合运算,不是扩展算子(第 5 节)。candle 的扩展点是 CustomOp1/2/3,反向和前向写在同一个 trait 里(自定义算子)。
换来。
- 外部 crate 只依赖
tenferro_runtime::extension就能加运算族,甚至换一种代数(ext/tropical是热带半环)。规则只发核心算子时自动有二阶导:扩展算子页自己写的 softplus,一份linearize加primal_vjp同时服务 eagerbackward、tracedgrad和jvp(扩展算子)。 - 分解的 AD 规则可以按运算族的需要写:SVD 用
gap / (gap² + ε²)处理简并,traced 程序只用奇异值时换成更便宜的SvdVals(线性代数)。
付出。 三份东西分开注册,漏一份不是编译错误,而是运行期的、时机各不相同的错误:
- 漏规则:
TracedTensorAdExt用空规则集,对线性代数求导报unsupported vjp AD rule for tenferro-linalg.linalg.v1;rfft根本没有反向规则;traced 的报错不说缺哪个角色(semantic AD 与扩展、FFT)。 - 漏声明:
ExtensionOp::semantic_effects默认是Undeclared,用默认值的算子连前向都编译不了(扩展算子)。 - 漏模块:traced 在
run时报missing extension family "tenferro.einsum.v1"(同一页)。 - 第 11 节表里的两处上游 bug 都在运算族的反向规则里。einsum 那一处,加入广播的 PR 只测了前向的广播(einsum 的冷知识)。
7. 运行期形状的编译程序
选择。 用 from_tensor_symbolic_shape 建的叶子,形状在程序里是符号,同一个编译好的程序可以先后喂不同长度(TracedTensor)。GraphCompiler::compile 只产出与 engine 无关的语义程序;降级、优化 pass 在 Runtime::prepare 里做,而且在查缓存之前(编译、执行与缓存)。对照:tenferro 自己的 XLA 路径只收精确的静态形状,每次执行都重新编译(XLA);burn 和 candle 没有「编译一个程序」这一步。
换来。 秩、截断阈值、迭代次数可以在执行时才定,这是 README「Shapes are only known at runtime」那一条(tenferro 解决什么问题)。准备好的计划按语义指纹、engine 目标、输入特化缓存,不同数据、同样结构的程序共享条目(执行与缓存)。
付出。
- 每次
prepare都重新降级:41 个运算约 89 µs,而run只要 22 µs(执行与缓存)。第 12 节的 9 个运算,串行 engine 上每步prepare+run86.6–89.8 µs,只run43.9–44.3 µs:准备一次的钱和执行一次一样多。要省就把prepare的结果留着。 - 形状错误推迟:两个符号叶子长度不同,建图、编译都通过,执行到那条指令才报
add: incompatible shapes: lhs=[3], rhs=[4];compile_with_input_specs会把形状定死(TracedTensor)。 - 符号叶子的长链建图是平方级的,时间花在克隆和释放
RawSymDim表达式树上;具体形状的叶子每个运算稳定在 1.2 µs(同一页)。
前面各页没有量过求导和编译随程序变长怎么变。实验 design-bench 的 ad_scaling(release,uptime 负载约 3)沿用 TracedTensor 的链:16 个 f64 的叶子 x,acc = acc * x + x 重复 depth 次再 reduce_sum,分别量建图、编译前向、grad(TracedTensorAdExt)、编译梯度图,取最短(200 层取 2 次,50、100 层 5 次,其余 20 次):
for (label, x) in [
("symbolic", TracedTensor::from_tensor_symbolic_shape(data())?),
("concrete", TracedTensor::from_tensor_concrete_shape(data())?),
] {
for depth in [5usize, 20, 50, 100, 200] {
let y = build(&x, depth);
let g = y.grad(&x)?;
let mut compiler = GraphCompiler::new();
compiler.compile(&g)?;
// ...
}
}
symbolic depth= 5 ops= 11: build= 40.1µs compile(y)= 17.4µs grad= 181.3µs compile(grad)= 76.0µs grad ops= 28
symbolic depth= 20 ops= 41: build= 259.9µs compile(y)= 54.2µs grad= 3.7ms compile(grad)= 1.5ms grad ops=118
symbolic depth= 50 ops=101: build= 1.5ms compile(y)= 121.8µs grad= 54.8ms compile(grad)= 19.6ms grad ops=298
symbolic depth=100 ops=201: build= 6.3ms compile(y)= 231.6µs grad= 447.3ms compile(grad)= 161.1ms grad ops=598
symbolic depth=200 ops=401: build= 25.4ms compile(y)= 493.5µs grad= 3.7s compile(grad)= 1.3s grad ops=1198
concrete depth= 5 ops= 11: build= 10.4µs compile(y)= 15.1µs grad= 91.8µs compile(grad)= 47.6µs grad ops= 28
concrete depth= 20 ops= 41: build= 39.9µs compile(y)= 48.6µs grad= 334.1µs compile(grad)= 627.2µs grad ops=118
concrete depth= 50 ops=101: build= 101.6µs compile(y)= 116.2µs grad= 878.6µs compile(grad)= 7.3ms grad ops=298
concrete depth=100 ops=201: build= 203.8µs compile(y)= 229.5µs grad= 2.0ms compile(grad)= 60.4ms grad ops=598
concrete depth=200 ops=401: build= 432.9µs compile(y)= 484.2µs grad= 6.0ms compile(grad)= 487.0ms grad ops=1198
- 编译前向是线性的:401 个运算约 0.49 ms,两种叶子一样。
- 符号叶子上的
grad每加倍一次长度慢 8 倍左右,是立方级的:200 层要 3.7 s。具体形状的叶子只要 6.0 ms,增长慢得多。 - 编译梯度图两种叶子都是立方级:具体形状、200 层,梯度图 1198 个运算,编译 487 ms,是编译前向(401 个运算,0.48 ms)的一千倍。用
sample对反复编译这张图的进程采样 3 秒,compile_many_with_descriptors下 1764 个样本,几乎全在compile_materialized_semantic_program里析构Vec<DimExpr>:DimExpr是Box嵌套的形状表达式,调用栈里是几百层递归的drop_glue;栈顶排在前面的是DimExpr的析构(287)、DimExpr::clone(274)和malloc/free。梯度图里的形状表达式一层套一层,被反复复制和释放。这些数和 0.7.1 时几乎一样,0.8.0 没有动这一块。
训练循环里结构不变时,这笔钱可以只付一次:AdContext 的变换缓存(traced AD)加上留着的 prepare 结果。几百步的迭代展开成一张图再求导,就会碰上上面的曲线。
8. 建在一串单独发布的库上
选择。 0.8.0 的 CPU 计算几乎都不在 tenferro 仓库里:二元收缩在 tcontract,N 元收缩和计划在 cpueinsum,线代在 tlinalg(BLAS 构建是 tlinalg-blas),遍历和逐元素内核在 strided-rs,线程在 threadteam,GEMM 和分解在 tensor4all 自己维护的 faer 分叉 t4a-faer;图在 computegraph,退火规划在 omeco(源码地图、provider:native 与 BLAS)。它们各有仓库、各自发布到 crates.io,版本号各走各的。burn 自己写 CPU 后端 Flex 和 GPU 语言 cubecl;candle 的 CPU 用 gemm crate,GPU 内核手写。
换来。
- 数值和速度直接继承下层库。第 12 节单线程的 1024² f32 matmul,tenferro(t4a-faer)21.87–21.94 ms,candle 21.58–21.92 ms,burn 21.51–21.57 ms,差别在 2% 以内;换成
blas-accelerate构建,同一个 matmul 走 Accelerate 只要 0.83–0.85 ms,和 candle 开accelerate的 0.85–0.97 ms 一样,切换只是换一个编译期 feature(provider:native 与 BLAS)。 - 下层库不依赖 tenferro 的张量类型,接口是步长视图加
threadteam::Exec令牌,别的项目可以直接用;tenferro 自己只管数据模型、engine、编译、运行时和 AD(engine 与 threadteam 池)。
付出。
- 整串要齐步走。工作区清单在
Cargo.toml第 68 行写了原因:来源一分为二(git 和 crates.io 各一份,或者两条不兼容的版本线),就会有「twoStridedViewtypes that cannot meet at a crate boundary, and twothreadteam::Exectypes that cannot meet at a kernel boundary」。所以 v0.7.1 到 v0.8.0 的 838 个提交里,51 个改了根Cargo.toml;按提交顺序看strided-basic的要求,37 天里换了 20 次:先是 5 个 git rev,然后 0.4.8,又回到 2 个 git rev,再是 0.5.0 到 0.5.4,0.6.0、0.6.2、0.6.3、0.6.4,0.7.0、0.8.0、0.9.0;faer从上游 0.24 换成 t4a-faer,经 0.24.5 到 0.24.8、0.25.0,最后是 0.27.0。最后一次整串升级 #2328 是发布当天合并的。 - 下游跟着齐步走。release notes 的迁移一节写:直接用这些库的下游 crate「must move to the same versions, because
tenferro_cpu::faerre-exports t4a-faer andthreadteam::Exec/threadteam::Poolappear in the public API」,而且「upstreamfaertypes no longer match」。0.7.1 时直接依赖上游 faer 0.24 的代码,升级到 0.8.0 要换成tenferro_cpu::faer或 t4a-faer 0.27。 - 锁文件和新项目解析到的版本不一样:tenferro 仓库的
Cargo.lock锁 strided-view 0.7.0、strided-perm 0.9.0、elem-simd-math 0.1.1,从 crates.io 新建的项目拿到 0.7.1、0.9.1、0.1.2(怎么读这一卷)。本卷的实验都是后者。 - 二进制大。只做一次 2×2 CPU matmul 的程序 30.3 MB,strip 后 19.7 MB,
strided_basic名下 7.5 MB、threadteam1.4 MB;candle 的同类程序 0.92 MB(tenferro 解决什么问题)。LSA 程序 39.4 MiB、92 个包,只用 faer 1.82 MiB(用 tenferro 做潜在语义分析)。 - 下层的行为也继承下来:tlinalg 对较小一维不小于 128 的 SVD 做一次残差检查,比直接调 faer 慢约 10%;705 × 52 120 的矩阵上,残差矩阵加上
Vt的转置复制多占 577 MiB(线性代数、用 tenferro 做潜在语义分析);CPU 上三个以上输入的 einsum 整条交给 cpueinsum,它不做长度 1 广播,于是两操作数能算、三操作数报错(einsum)。
strided-basic 0.5.1 在 strided-rs 那边发布后,tenferro 没法直接用。提交 6f048995(2026-10-14,首次发布于 v0.8.0)的说明写:「It could not be taken here directly: the published tcontract 0.2.0 and tcontract-exec 0.2.0 required strided-basic =0.5.0, so the middle of the stack had to move first.」等 tcontract 和 cpueinsum 都以 0.2.1 重新发布、把要求改成 =0.5.1,tenferro 才把自己的要求挪过去,整条链解析成「tenferro-cpu -> cpueinsum 0.2.1 -> tcontract 0.2.1 -> strided-basic 0.5.1」。同一个提交还放宽了一条 CI 检查:它原来断言六个 strided crate 的版本要求字符串完全相同,说明里的理由是「it does not hold for a family that publishes independent patches」,改成只要求同一条版本线。那时的要求还是 = 精确版本;到 v0.8.0,清单换成了普通的 caret 要求,faer 那一行的注释写着「Later patch fixes need only a targeted lock update, not another manifest-bump cascade」(Cargo.toml 第 98 行)。发布当天的 PR #2328(首次发布于 v0.8.0)一次把六个库升到现在的版本,说明是「Moves the CPU provider stack to its current release train in one step, so threadteam::Exec, StridedView and faer stay single types」,并注明「No source change」。
9. 并行与否由代价模型决定
选择。 并行与否由 threadteam 的代价模型在运行期决定:一个并行区域的估计串行时间不少于 3 倍分派代价才分派,分派代价按主机类别查表(engine 与 threadteam 池、逐元素、SIMD 与融合)。批处理策略 Auto 也是一张工作量模型。
换来。 调用方只给一个宽度,不用给每个运算写线程数;小运算自动留在调用线程上,这正是第 3 节里宽度不再拖累小梯度的原因。
付出。 表是在别的机器上量的:本机属于 aarch64-apple,注释说那一行是在 M5 Max 上量的;超越函数被按普通 Map 计价,tanh 到 262144 个元素才并行;Auto 在 64 个 64³ 的批量收缩上选错,比 Outer 慢一倍(同两页)。形状固定的热循环,值得自己量一下再钉住策略。
10. 只留一条 CUDA 路径
选择。 0.8.0 的 GPU 只有 CUDA:kernel-catalogue engine,内核是 t4a-cuda-kernels 里的 CUDA C++ 源码,运行期用 NVRTC 编译;0.7.1 建在 CubeCL 分叉上的 CUDA 和 WebGPU(macOS 上经 wgpu 走 Metal)两个后端,连同 webgpu feature 和 Apple 共享内存一起删掉了(#2291,GPU 与 XLA、CUDA)。burn 的整个张量 API 都能落到 CubeCL 的 wgpu / Metal 上(wgpu 与 Metal),candle 有直接调 Metal 的后端(Metal 后端)。
换来。 一条 GPU 代码路径、一份内核源码,不再跟着 CubeCL 分叉迁移;提交 d8ddd68d 说新 engine 在 A100 上每一项对照都比被替换的 CubeCL engine 快(怎么读这一卷的冷知识)。webgpu 那个 feature 陷阱也随之消失(第 2 节)。
付出。 在 macOS 上,tenferro 0.8.0 没有任何 GPU 后端,也没有主机与设备共享内存的 AppleContext;本卷的读者在 Mac 上只能跑 CPU,GPU 和 XLA 两页只能读源码。上游把 Metal 列为以后的方向(「(ROCm/HIP, then Metal)」),tag 上没有代码。
丢掉的那条路有多快?本卷 0.7.1 版有一页讲 WebGPU,里面有一个实验:同一个 f32 矩阵乘在 12 线程 CpuBackend(faer)和 WebGpuBackend 上各跑一遍。本页把那个 crate(design-webgpu071,依赖 tenferro 0.7.1 的 webgpu)原样重编、重跑一次,每个尺寸 10 次,uptime 负载约 2:
...
== n = 1024 (f32, 1024x1024 @ 1024x1024)
cpu first 4.414 ms later min 3.243 ms median 3.591 ms
upload a,b 4.093 ms (4194304 bytes each)
webgpu first 42.408 ms (enqueue returned after 0.062 ms)
webgpu later min 9.173 ms median 10.689 ms (enqueue only: min 0.019 ms median 0.027 ms)
...
== n = 2048 (f32, 2048x2048 @ 2048x2048)
cpu first 27.035 ms later min 23.461 ms median 25.409 ms
upload a,b 9.485 ms (16777216 bytes each)
webgpu first 96.237 ms (enqueue returned after 0.047 ms)
webgpu later min 75.759 ms median 77.315 ms (enqueue only: min 0.035 ms median 0.066 ms)
...
- 那条 Metal 路径的矩阵乘比同机的 CPU 慢 3 倍(1024² 中位数 10.7 ms 对 3.6 ms,2048² 77 ms 对 25 ms);256² 时 1.6 ms 对 0.16 ms。
- 同一块 GPU 上,candle 的 Metal 后端做 1024² f32 matmul 的中位数是 1.15–1.38 ms,burn 的 Metal 1.30–1.85 ms(candle 卷的设计取舍);tenferro 0.8.0 的
blas-accelerate构建在 CPU 上就是 0.83–0.85 ms(第 12 节)。 - 所以在 Mac 上丢掉的主要不是速度,而是「张量可以在 GPU 上」这件事本身:要在 Apple GPU 上训练或推理,tenferro 0.8.0 没有路,只能换 burn 或 candle;只要 CPU 上快,Accelerate 构建比 0.7.1 的 Metal 路径快一个数量级。
11. 正确性与文档
本卷各页在 0.8.0 上实际复现过的「给出错误或可疑结果而不报错」:
| 现象 | 条件 | 性质 | 页面 |
|---|---|---|---|
| matmul 结果错 | 行主序数据交给 from_vec_col_major | 设计如此(README 的约定) | 列主序与视图 |
einsum 的梯度是广播后的形状(a[2,1] 得到 [2,3]) | 长度 1 的轴被广播;eager、traced 都错 | 上游 bug,反向没有沿广播轴求和 | semantic AD 与扩展 |
SVD 的梯度整块 NaN | 非方阵,恰好有一个为 0 的奇异值 | 上游 bug,m ≠ n 的投影修正直接除以 s | 线性代数 |
| SVD 的梯度 1e9 量级 | 舍入造成的近简并 | 设计如此(gap / (gap² + ε²)) | 线性代数 |
| 越界下标取到边上的值 | take_along_axis 的索引超出范围 | 实现把索引夹住 | 激活、softmax 与归一化 |
backward 返回 Ok,梯度全空 | TENFERRO_EAGER_SEMANTIC_VJP=0 | 开关关掉唯一的反向路径 | eager AD |
0.7.1 那张表里的两行在 0.8.0 不成立了:默认 einsum 路径换成 tenferro 自己的确定性贪心,同一个式子不再末位差 1–2 ulp(只有开退火时的 omeco TreeSA 还会变,einsum);长方形矩阵的 det 以前给出一个看似合理的数,0.8.0 在四个面上都报错(线性代数)。前向的直接计算仍然没有发现静默算错。
文档跟不上代码。0.8.0 是一次大改(v0.7.1 之后 37 天 838 个提交,2026-10 一个月 606 个),tag 上 docs/ 有 764 个 Markdown 文件,其中 worklogs/ 340 个、plans/ 170 个、design/ 91 个。本卷各页核对出的不符,挑几条:
| 文档 | 说法 | 代码 | 页面 |
|---|---|---|---|
README、devices-and-gpu.md | 「experimental WebGPU backend」;features = ["webgpu"] 的快速上手 | 没有 WebGPU;照写解析失败 | GPU 与 XLA |
| release notes | 标准批量 matmul 改走 cuBLAS;CudaEngine::open(ordinal) | dot_general 只走 cuTENSOR;参数是 CudaDeviceId | CUDA |
| 用户指南 | 一次 synchronize 覆盖一串运算;flush | 每个直接运算返回前都同步;没有 flush | CUDA |
| release notes | 48² SVD 每次 3 次分配 | x86 上的数;aarch64 上 33 次 | 线性代数 |
execution-engine-ownership.md、Engine 注释 | faer 用 Par::rayon(width);「a CPU engine owns a Rayon pool」 | Par::Team;engine 持有 threadteam 的 team | engine 与 threadteam 池 |
storage-ownership.md | 列着 WebGpuBackend、AppleContext | 两个命名空间都不存在 | 存储与所有权 |
架构文档、primitive-ad.md | 核心规则在 tenferro-internal-ops | grad 走 tenferro-ad 的另一份 | primitive AD 规则 |
linalg.md | 组合运算清单和层次图 | 与代码不符 | 线性代数 |
| API 迁移指南 | eager 回调写 Ok::<_, tenferro_ad::Error>(..) | 混用 einsum 时编不过,要 tenferro_einsum::Error | eager AD |
本卷的规矩不变:上游文档当资料读,每一条核对 tag 上的源码或跑一遍。
12. 实测:同一组 CPU 小负载,tenferro、candle、burn
实验 crate 都在 exp-tenferro8,全部 release:design-bench(tenferro 0.8.0,默认 native);design-bench-blas(同一份代码,default-features = false 加 blas-accelerate);design-candle(candle-core 0.11.0,只用 CPU,跑两种:不开 feature 走 gemm crate,开 accelerate 走 Accelerate 的 sgemm);design-burn(burn 0.22.1,default-features = false,开 std、flex、autodiff、rayon、simd)。candle 和 burn 两个 crate 与 0.7.1 时完全相同,只是在本页重新编译、重新跑;tenferro 一侧按 0.8.0 的接口改写。三边的程序逐行对应,输入由同一组公式生成,每个程序打印一个元素和全体之和用来对数。
- dtype:三边在 CPU 上都支持的是 f32(burn 的 Flex 默认浮点就是 f32),以 f32 为准;tenferro 和 candle 另跑一遍 f64。
- matmul:1024×1024,第一次单独计时,再跑 20 次取最短和中位数;tenferro 每个宽度建一个 engine,构造后断言
has_team();burn 每次sync。 - 梯度:
W256×256、x256×1,求sum(tanh(W·x))对W的梯度,每步包括前向、反向、把整个梯度读成Vec<f32>;第一步单独计时,再跑 50 步取最短和中位数。tenferro 的 eager 每步之后clear_grad;traced 只建图、求导、编译一次,再分三种量:每步prepare+run,prepare一次之后每步只run,每步从建图开始全部重做。 - 线程:candle 默认 8(性能核数),burn 的 Flex 用 rayon 全局池(12);tenferro 没有默认,跑 8 线程和 12 线程两个 team engine。单线程一轮:tenferro 用
CpuEngine::serial(),candle 和 burn 设RAYON_NUM_THREADS=1 CANDLE_NUM_THREADS=1。
tenferro eager 的一步:
let w = rt.variable_from(Tensor::from_vec_col_major(vec![N, N], w_col_major())?)?;
let x = rt.constant_from(Tensor::from_vec_col_major(vec![N, 1], x_vec())?)?;
let step = |eng: &mut CpuEngine| -> R<Vec<f32>> {
let loss = rt.with_eager_session(eng, |s| {
let m = s.matmul(&w, &x)?;
let t = s.tanh(&m)?;
s.reduce_sum(&t, None)
})?;
loss.backward(eng)?;
let g = w.grad()?.unwrap().as_slice::<f32>().unwrap().to_vec();
w.clear_grad()?;
Ok(g)
};
candle 一侧是 w.matmul(&x)?.tanh()?.sum_all()? 加 loss.backward()? 和 grads.get(&w),burn 一侧是 w.clone().matmul(x.clone()).tanh().sum() 加 loss.backward() 和 w.grad(&grads)。
CpuEngine::serial() 上,哪个快?差多少?W·x 是 256×256 乘 256×1,只有 6.5 万次乘加。0.7.1 的默认 12 线程后端上,这一步比单线程慢 5 倍多(第 3 节)。先猜 0.8.0,再看文末。
测量时 uptime 的 1 分钟负载在 6.3 到 6.9 之间,一个脚本依次跑完三个库(默认线程三轮、单线程两轮)。原始输出(第 1 轮的大部分和单线程第 1 轮;对数字段三边一致,后面几行截掉):
--- round 1 (6.84 16.57 12.34)
tenferro engine w=8: built 0.333 ms, has_team=true, num_threads=8, provider=tenferro.cpu.faer
tenferro w= 8 f32 1024x1024: first 5.258 ms later min 3.338 ms median 3.416 ms (629 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
tenferro w= 8 f64 1024x1024: first 7.720 ms later min 6.515 ms median 6.741 ms (319 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
tenferro engine w=12: built 0.395 ms, has_team=true, num_threads=12, provider=tenferro.cpu.faer
tenferro w=12 f32 1024x1024: first 6.166 ms later min 5.508 ms median 7.072 ms (304 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
tenferro w=12 f64 1024x1024: first 14.837 ms later min 9.134 ms median 12.137 ms (177 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
tenferro engine w=8: built 0.213 ms, has_team=true, num_threads=8, provider=tenferro.cpu.blas
tenferro w= 8 f32 1024x1024: first 2.411 ms later min 0.809 ms median 1.229 ms (1748 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
tenferro w= 8 f64 1024x1024: first 5.181 ms later min 3.081 ms median 3.211 ms (669 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
candle get_num_threads=8
candle(gemm) F32 1024x1024: first 3.954 ms later min 3.212 ms median 3.480 ms (617 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
candle(gemm) F64 1024x1024: first 8.291 ms later min 6.125 ms median 7.197 ms (298 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
candle get_num_threads=8
candle(accelerate) F32 1024x1024: first 1.326 ms later min 0.825 ms median 0.847 ms (2537 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
candle(accelerate) F64 1024x1024: first 4.913 ms later min 3.186 ms median 3.268 ms (657 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
burn flex F32 1024x1024: first 5.680 ms later min 3.511 ms median 3.813 ms (563 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
analytic f64: g[3,5]=0.1922982 sum=-2692.14739
tenferro w=8 eager: first 1126.9 us later min 123.1 us median 125.3 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=8 traced: build 65.6 us, grad 61.3 us, compile 27.8 us, ops=9
tenferro w=8 traced prepare+run: first 161.3 us later min 93.1 us median 96.5 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=8 traced run (prepared once): first 161.3 us later min 48.7 us median 49.3 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=8 traced rebuild every step: first 292.8 us later min 214.2 us median 230.9 us g[3,5]=0.1922982 sum=-2692.14740
...
candle(gemm) eager: first 1104.0 us later min 36.3 us median 36.6 us g[3,5]=0.1922982 sum=-2692.14739
candle(accelerate) eager: first 1675.8 us later min 35.8 us median 41.4 us g[3,5]=0.1922982 sum=-2692.14739
burn flex+autodiff eager: first 1901.0 us later min 39.7 us median 43.5 us g[3,5]=0.1922982 sum=-2692.14739
...
--- 1 thread round 1 (6.93 16.43 12.31)
tenferro engine w=1: built 0.028 ms, has_team=false, num_threads=1, provider=tenferro.cpu.faer
tenferro w= 1 f32 1024x1024: first 22.598 ms later min 21.405 ms median 21.938 ms (98 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
tenferro w= 1 f64 1024x1024: first 44.151 ms later min 42.973 ms median 43.885 ms (49 GFLOP/s) c[3,5]=1.241709 sum=25802.9338
tenferro engine w=1: built 0.024 ms, has_team=false, num_threads=1, provider=tenferro.cpu.blas
tenferro w= 1 f32 1024x1024: first 1.681 ms later min 0.803 ms median 0.850 ms (2527 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
...
candle get_num_threads=1
candle(gemm) F32 1024x1024: first 22.519 ms later min 21.282 ms median 21.915 ms (98 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
...
burn flex F32 1024x1024: first 21.762 ms later min 21.097 ms median 21.565 ms (100 GFLOP/s) c[3,5]=1.241709 sum=25802.9775
analytic f64: g[3,5]=0.1922982 sum=-2692.14739
tenferro w=1 eager: first 928.5 us later min 111.7 us median 116.5 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=1 traced: build 72.2 us, grad 58.4 us, compile 26.8 us, ops=9
tenferro w=1 traced prepare+run: first 155.9 us later min 85.3 us median 86.6 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=1 traced run (prepared once): first 155.9 us later min 43.4 us median 43.9 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=1 traced rebuild every step: first 257.7 us later min 207.9 us median 217.7 us g[3,5]=0.1922982 sum=-2692.14740
candle(gemm) eager: first 117.2 us later min 33.5 us median 34.2 us g[3,5]=0.1922982 sum=-2692.14739
burn flex+autodiff eager: first 198.4 us later min 38.0 us median 39.8 us g[3,5]=0.1922982 sum=-2692.14739
汇总(范围是各轮的中位数;「第一次」是进程里的第一次):
| 负载 | tenferro | candle | burn | 说明 |
|---|---|---|---|---|
| 1024² f32 matmul,之后每次 | 3.42–3.89 ms(8 线程);6.72–7.17 ms(12 线程) | 3.25–3.48 ms(gemm,8 线程) | 3.79–4.07 ms(gemm,12 线程) | 8 线程时三边同一量级 |
| 同上,Accelerate | 0.93–1.23 ms(8 线程 engine);0.83–0.85 ms(串行 engine) | 0.85–0.97 ms | — | 线程归 Accelerate 自己 |
| 1024² f32 matmul,单线程 | 21.87–21.94 ms | 21.58–21.92 ms | 21.51–21.57 ms | 内核打平,差别在 2% 以内 |
| 1024² f64 matmul | 6.54–7.56 ms(8 线程);43.80–43.89 ms(单线程) | 6.48–7.20 ms;43.49–43.74 ms | — | |
| 1024² f32 matmul,第一次 | 4.05–5.26 ms | 3.67–3.95 ms | 4.53–5.68 ms | 都没有编译和调优 |
| 梯度一步,eager | 125–129 µs(8 线程);124–136 µs(12 线程) | 36.6–37.2 µs;36.2–41.4 µs(Accelerate) | 42.4–43.5 µs | tenferro 慢 3.4 倍上下 |
| 梯度一步,eager,单线程 | 117–120 µs | 33.9–34.2 µs | 39.8–45.8 µs | |
梯度,traced 每步 prepare + run | 93.6–96.5 µs;单线程 86.6–89.8 µs | — | — | 程序 9 个运算,含前向 |
梯度,traced 只 run | 49.1–50.0 µs;单线程 43.9–44.3 µs | — | — | candle 的 1.3 倍 |
| 梯度,traced 每步重建 | 224–231 µs;单线程 218–220 µs | — | — | 建图 + grad + 编译 + prepare + run |
| 梯度,进程里第一步 | eager 0.89–1.13 ms;traced 建图、求导、编译约 0.16 ms 再加第一次执行 0.16–0.18 ms | 第 1 轮 1104 µs,之后 127–132 µs | 第 1 轮 1901 µs,之后 197–211 µs |
- 大矩阵乘上三边差不多,因为干活的是同一类库:t4a-faer 和
gemmcrate 单线程都是 21.5–21.9 ms。多线程时宽度比库要紧:tenferro 的 8 线程 engine 和 candle 默认的 8 线程三轮里从快 2% 到慢 17%,12 线程 engine 慢一倍(第 3 节)。Accelerate 快 4 倍左右,两边都是一个编译期 feature,不是框架本身。 - 小梯度上 tenferro 仍落后,但原因换了。0.7.1 时主要是进出多线程执行域;0.8.0 上宽度几乎不影响(8 线程 125–129 µs,串行 117–120 µs),差距在 eager 自己的簿记:前向只要约 20 µs,反向 92 µs 里有一半在每步重做「trace → 程序 → 导数图」(第 5 节的采样)。
- traced 的长处在重复执行:建图、求导、编译一次约 0.16 ms,之后单线程每步只
run44 µs,比 eager 的 117 µs 少六成;每步都prepare就是 87 µs,每步全部重建就回到 218 µs。 - 三边的梯度都和解析解(f64 算的
sum=-2692.14739)一致。tenferro 在所有宽度上都是-2692.14740,末位差 1,是 f32 求和顺序的差别;0.7.1 时它随线程数变,现在这么小的规约不分派,宽度不再改变结果。 - 本页没有在 GPU 上比:tenferro 0.8.0 在这台机器上没有 GPU 后端(第 10 节)。这些数只对这台机器、这几个尺寸、这个负载有效。
总表
| 取舍 | 换来的 | 付出的 | 证据 |
|---|---|---|---|
| 1. 只有列主序 | 与 LAPACK、Julia、faer 同布局;批维连续 | 行主序数据静默算错;桥接要复制一次 | 列主序与视图 |
| 2. 没有门面 crate | 按 crate 选能力,autodiff 可选 | native / blas 互斥、cuda 要几个 crate 一起开,feature 不可加 | 怎么读这一卷 |
| 3. 调用方持有的 engine | 每运算开销小;宽度不拖累小运算;并发就建多个 engine | engine 要一路传;宽度、超订、预留失败都归调用方,12 线程比 8 线程慢一倍 | engine 与 threadteam 池、本页实验 |
| 4. 四种张量 API | 用刚好够用的一层;值在层间直接传 | 同一错误四种行为;写法不一致;默认张量无 Clone | tenferro 解决什么问题 |
| 5. 两套 AD 一套规则 | eager 与 traced 逐位相同;十三个组合运算只多一条 erf 规则;融合不丢梯度 | 规则两份;bug 共用;组合导数程序大;eager 每步重做簿记 | primitive AD 规则、激活、softmax 与归一化 |
| 6. 运算族在核心外 | 外部 crate 能加运算族和代数;自动有高阶导 | 三份东西只靠 family id 连;漏一份是运行期错误 | 扩展算子 |
| 7. 运行期形状的程序 | 编译一次,喂不同长度 | 每次 prepare 重新降级;符号建图平方级;grad 和梯度图编译立方级(200 层 3.7 s / 487 ms) | 执行与缓存、本页实验 |
| 8. 一串单独发布的库 | 单线程 matmul 与 candle、burn 打平;一个 feature 换 Accelerate;下层库可复用 | 整串齐步升级(37 天 strided-basic 换 20 次),下游也得跟;30.3 MB 二进制 | 源码地图、本页第 8 节 |
| 9. 运行期代价模型 | 只给宽度,小运算自动不分派 | 表来自别的机器;tanh 计价、Auto 有选错 | 逐元素、SIMD 与融合 |
| 10. 只留 CUDA | 一条 GPU 路径、一份内核源码 | macOS 上没有任何 GPU 后端(丢掉的 Metal matmul 本来就比 CPU 慢 3 倍) | GPU 与 XLA、本页实验 |
| 11. 正确性与文档 | — | 2 处上游 bug 静默算错,都在反向规则里;文档多处落后 | 本页第 11 节 |
| 12. 同机实测 | 大 matmul 与 candle、burn 同一量级,单线程打平 | 小梯度 eager 慢 3 到 3.5 倍,主要是 eager 的簿记 | 本页实验 |
动脑筋答案
几乎一样,串行略快:8 线程 team engine 上每步的中位数 125–129 µs,串行 engine 上 117–120 µs,差约 7%。
tenferro w=8 eager: first 1126.9 us later min 123.1 us median 125.3 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=1 eager: first 928.5 us later min 111.7 us median 116.5 us g[3,5]=0.1922982 sum=-2692.14740
按阶段拆开(grad_phases,同一个负载,50 步的中位数,负载约 6):
tenferro eager phases (median of 50, width=8): forward 23.6 us, backward 96.5 us, read grad 4.5 us
tenferro eager phases (median of 50, width=12): forward 23.6 us, backward 96.2 us, read grad 4.5 us
tenferro eager phases (median of 50, width=1): forward 20.5 us, backward 92.2 us, read grad 4.6 us
- 前向只有
matmul、tanh、reduce_sum三个运算,8 线程比串行多 3 µs;反向 96 µs 对 92 µs。宽度 8 和 12 的两行完全一样:这么小的运算达不到「3 倍分派代价」的门槛,都在调用线程上做完,team engine 多付的只是每个运算铸令牌的那一点(engine 与 threadteam 池量到约 0.45 µs 一次)。 - 0.7.1 慢 5 倍,是因为每个运算都要向仲裁器申请许可、进 rayon 池;0.8.0 把这一层删了,「不值得并行就不碰工作线程」由代价模型保证。同一时段重跑的两个版本(
design-bench071,负载约 6.7):
tenferro eager (EagerRuntime::new): first 1424.0 us later min 516.8 us median 656.5 us g[3,5]=0.1922982 sum=-2692.14739
tenferro eager (EagerRuntime::new): first 808.5 us later min 110.7 us median 116.5 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=8 eager: first 914.3 us later min 123.2 us median 125.7 us g[3,5]=0.1922982 sum=-2692.14740
tenferro w=1 eager: first 717.1 us later min 111.0 us median 113.7 us g[3,5]=0.1922982 sum=-2692.14740
前两行是 0.7.1 的默认 12 线程和 RAYON_NUM_THREADS=1,后两行是 0.8.0 的 8 线程和串行 engine。单线程这一步两个版本一样快,0.8.0 省掉的只是多线程的进出。
- 小模型上用 team engine 不再有明显的惩罚,但也拿不到任何加速:剩下的 117 µs 里,大头是第 5 节说的 eager 簿记。
小结
- 只有列主序:和 LAPACK、faer 同布局,交给 faer 不拷贝;行主序数据仍静默算错,
from_vec_row_major只是多了一个显式入口。 - 没有门面 crate:按 crate 选能力;feature 统一的问题还在,
native/blas互斥,cuda要几个 crate 一起开。 - 调用方持有的 engine:线程归调用方,小运算不再被宽度拖累;代价是 engine 要一路传,宽度和超订都要自己管,本机 12 线程比 8 线程慢一倍。
- 四种张量 API 和一套语义规则:eager 与 traced 的梯度逐位相同,新的激活、softmax、归一化靠分解求导,融合内核不丢梯度;代价是 bug 共用、组合导数程序大、eager 每步重做簿记。
- 运算族在核心之外:能从外部加运算族和代数;三份注册靠 family id 连,漏一份是运行期错误。
- 运行期形状的程序:编译一次、喂不同长度;代价是每次
prepare降级,grad和梯度图编译仍是立方级。 - 建在 tcontract、cpueinsum、tlinalg、threadteam、strided-rs、t4a-faer 上:单线程大矩阵乘和 candle、burn 打平,一个 feature 换成 Accelerate;代价是整串齐步升级、下游跟着升、30 MB 的二进制。
- 只留 CUDA:macOS 上没有 GPU 后端;被删的 Metal 矩阵乘本来就比 CPU 慢 3 倍,Accelerate 构建比它快一个数量级。
- 本卷在 0.8.0 上复现的两处上游静默算错都在运算族的反向规则里;上游文档多处落后于代码,当资料读、逐条核对。