什么时候用 tenferro
主线位置:TRG 主线的「分段求导得内能」,加上变换主线的「编译、准备 → 执行」。本页比较同样的任务两边多快、交付出去有多大、启动要多久,再列出该留在 JAX 的情况。
版本:JAX 0.12.0.1(CPU,x64)、PyTorch 2.15.0(CPU,1 线程)、tenferro-rs 0.8.0(CPU,native)
flowchart TB task["同一个任务"] --> jx["JAX jit"] task --> tf["tenferro"] jx --> jfast["稳态快约 2 倍"] jx --> jbig["环境约 409 MiB"] tf --> tsmall["二进制 21.3 MiB"] tf --> tstart["启动 0.5 ms"] tf --> tstage["长图要手工分段"] jx --> jhas["vmap scan 随机数"]
这一页只写在本机测得到的东西。平台 M2 Max(8 个性能核、4 个能效核)、macOS 27、rustc 1.99.0,tenferro 全部 release 构建。计时时机器上还有别的任务,每组数字旁边写了当时 uptime 的 1 分钟负载。JAX 每个函数先调用一次(含追踪和编译),再计稳态,每次都 block_until_ready()。
TRG:20 步,求值加梯度
任务是这一卷的主线:二维 Ising 模型,χ = 16,20 步,五个 β,求 lnZ/N 和 d(lnZ/N)/dβ。JAX 一侧把整个 20 步的函数交给 jit(value_and_grad):
ks = schedule(16, 20, "rank") # [2, 4, 4, 16, 16, ..., 16]
f = make_trg(ks, SVD["lorentz"]) # beta -> lnZ/N,Python 循环展开 20 步
fv = jax.jit(f)
fvg = jax.jit(jax.value_and_grad(f))
make_trg 的全文和 SVD 求导规则见动手:Ising TRG。tenferro 不能这样做:对整张 20 步的图求导,变换本身的时间随步数指数增长(下面「求导变换本身的代价」一节)。可行的写法是按每一步的形状 (d, k) 各建一个小 traced 程序,五种形状,20 步和所有 β 共用;反向在步与步之间手工串起来:
for &k in ks {
if !steps.contains_key(&(d, k)) {
let t = TracedTensor::input_concrete_shape(DType::F64, &[d, d, d, d])?;
let ct = TracedTensor::input_concrete_shape(DType::F64, &[k, k, k, k])?;
let c = TracedTensor::input_concrete_shape(DType::F64, &[])?;
let (tn, ln) = trg_step(&t, d, k, None)?;
let l = (&(&ct * &tn)?.reduce_sum(None)? + &(&c * &ln)?)?;
let g = ad.grad(&l, &t)?;
steps.insert((d, k), StepProgs {
fwd: mk(rt, eng, &tn, &[(&t, vec![d, d, d, d])])?,
lognorm: mk(rt, eng, &ln, &[(&t, vec![d, d, d, d])])?,
bwd: mk(rt, eng, &g, &[(&t, vec![d, d, d, d]), (&ct, vec![k, k, k, k]), (&c, vec![])])?,
});
}
d = k;
}
// ...
for i in (0..n).rev() {
let (d, k) = (dims[i], dims[i + 1]);
let c = scalar(2f64.powi((n - i) as i32) * scale)?;
ct = run(rt, eng, &s.steps[&(d, k)].bwd, &[&ts[i], &ct, &c])?;
}
mk 是编译加 prepare,run 是 runtime.run 加读回。这相当于 lax.scan 加逐步的 VJP,写法和取舍见分段。
两边的结果对得上:lnZ/N 最大差 5.6e-16,梯度最大差 2.4e-12,和 tenferro 自己换线程数的差同一量级(怎么读这一卷)。每个 β 的稳态时间,β 之间的范围(JAX 每个 β 取 10 次最小,tenferro 取 5 次最小):
| 实现 | 求值 | 求值加梯度 | 准备 | 负载 |
|---|---|---|---|---|
JAX jit,整张 20 步的图 | 160.9–190.3 ms | 222.5–253.0 ms | 第一次调用:求值 376 ms,求值加梯度 1220 ms | 5.9 |
tenferro 分段,CpuEngine::serial() | 332.8–354.5 ms | 913.2–948.3 ms | 五种步的程序建图、求导、编译、准备共 18.5 ms | 4.5–7.0 |
| tenferro 分段,8 线程 | 176.6–188.8 ms | 440.5–471.9 ms | 21.5 ms | 4.5–7.0 |
| PyTorch eager,1 线程 | 前向 181.6–209.1 ms | 反向另加 66.6–67.7 ms,梯度是 NaN | — | 4.2 |
几点说明:
- JAX 不是单线程。 同一段稳态循环里进程的 CPU 时间除以墙钟时间,求值是 1.22–1.26,求值加梯度是 2.10–2.24。设
XLA_FLAGS="--xla_cpu_multi_thread_eigen=false intra_op_parallelism_threads=1"再跑,时间和这个比例都没变(160.0–192.1 / 223.8–252.5 ms,比例 1.22 / 2.09–2.22),本页没有找到让它只用一个核的开关。所以拿它和 tenferro 的单线程、8 线程两行都比一比:求值和 8 线程相当,求值加梯度快约 2 倍。 - tenferro 的求值加梯度是求值的 2.5–2.7 倍,JAX 是 1.3–1.4 倍。 分段写法里每一步的反向程序
bwd以这一步的输入 T 为参数,自己重算这一步的前向,两次 SVD 也重算;相当于每一步都做了一次 checkpoint。JAX 对整张图求导,前向的中间结果直接留给反向。 - 准备的代价正好反过来。 JAX 对 20 步的图追踪、求导、编译,第一次调用 1.2 秒;tenferro 只编五个小程序,18.5 ms,之后所有 β 复用(β 是占位符)。
- PyTorch 的梯度是 NaN。
torch.linalg.svd的反向在这条链上给出 NaN;JAX 内置的jnp.linalg.svd在这条链上同样给出 NaN,上表的 JAX 一行是用custom_jvp补上了和 tenferro 默认一样的展宽规则。tenferro 不用补(简并奇异值)。
小函数的稳态
变换主线的 f(x) = Σ sin(x)·x²,n = 1000,每个程序跑 200 次取最小值,负载约 3.7:
JAX jit | tenferro 单线程 | tenferro 8 线程 | |
|---|---|---|---|
| grad | 10.4 µs | 15.0 µs | 21.3 µs |
| jvp | 12.7 µs | 14.4 µs | 19.8 µs |
hvp(jvp(grad)) | 10.6 µs | 23.6 µs | 35.8 µs |
eager 方式(每个运算立即执行,再反向):tenferro with_eager_session 加 backward 73.5 µs,PyTorch 1 线程 29.4 µs,不加 jit 的 jax.grad 970.5 µs。
1000 个元素太小,8 线程比单线程慢:分活和等待的开销比计算本身大(12 个线程比 8 个慢)。小函数上 JAX jit 快 1.1 到 2.2 倍;eager 上 PyTorch 快约 2.5 倍。怎么计时、为什么要分开第一次调用,见计时。
求导变换本身的代价
上面 tenferro 要分段,是因为求导变换在长图上随深度指数增长。直接对 TRG 的前 n 步整体建图,单独计 ad.grad 这一步(只是变换,不含编译和执行),负载 3.7:
tenferro 0.8.0 traced chi=16 steps=2 schedule=rank width=1 ks=[2, 4]
ad.grad 8.43 ms
tenferro 0.8.0 traced chi=16 steps=3 schedule=rank width=1 ks=[2, 4, 4]
ad.grad 89.73 ms
tenferro 0.8.0 traced chi=16 steps=4 schedule=rank width=1 ks=[2, 4, 4, 16]
ad.grad 2077.58 ms
每多一步,约 10 到 23 倍。把它缩到最小:3×3 矩阵上反复做 (u, s, vt) = svd(y); y = u·s + vt,三个输出都被用到,n 层:
svd3 n= 8 value compile 0.14 ms (57 ops) ad.grad 2.93 ms grad compile 1.40 ms (460 ops)
svd3 n=10 value compile 0.18 ms (71 ops) ad.grad 12.45 ms grad compile 1.72 ms (574 ops)
svd3 n=11 value compile 0.25 ms (78 ops) ad.grad 32.95 ms grad compile 1.79 ms (631 ops)
svd3 n=12 value compile 0.18 ms (85 ops) ad.grad 93.31 ms grad compile 1.72 ms (688 ops)
svd3 n=13 value compile 0.21 ms (92 ops) ad.grad 275.46 ms grad compile 2.12 ms (745 ops)
导数程序的大小(ops)随层数线性增长,ad.grad 的时间每层乘 2.6 到 3。实验只跑到 13 层,再往上每层要多等几倍。JAX 对整张 20 步的图做同样的事,加上 XLA 编译,第一次调用一共 1.2 秒。这个代价从哪来、分段之外还能怎么绕,见求导变换的代价。
交付出去有多大
JAX 一侧:Python 解释器,加 jax、jaxlib 和它们声明的依赖(ml_dtypes、NumPy、SciPy、opt_einsum)。按 du -sk -I __pycache__ 量,1 MiB = 1024 KiB:
| 部分 | 大小 |
|---|---|
| Python 3.14.10(Homebrew 的 framework 构建) | 73.0 MiB |
jaxlib | 227.2 MiB |
其中 jaxlib/libjax_common.dylib | 218.4 MiB |
scipy / numpy / jax | 70.3 / 21.7 / 14.3 MiB |
ml_dtypes / opt_einsum | 2.5 / 0.3 MiB |
| 合计 | 409.4 MiB |
这是 macOS 上只带 CPU 的 wheel。不算 PyTorch(它在同一个虚拟环境里,另占约 565 MiB)。
tenferro 一侧是一个可执行文件。上面那个 grad 程序只依赖 tenferro-runtime、tenferro-cpu、tenferro-ad;TRG 的 trg_staged 依赖全套(加 linalg、einsum、fft):
| 程序 | release | strip 之后 | 动态链接 |
|---|---|---|---|
| grad 程序 | 32.1 MiB(33 697 280 字节) | 21.3 MiB(22 357 688 字节) | libSystem、libiconv |
trg_staged | 41.8 MiB(43 867 008 字节) | 28.5 MiB(29 887 040 字节) | 同上 |
strip 之后的 grad 程序照样输出同样的梯度。409.4 MiB 对 21.3 MiB,差约 19 倍;不像 candle 那样能小到 1 MiB 以下,tenferro 的二进制大在 strided-rs、threadteam 和 GEMM 微内核按 dtype、布局展开出来的代码(tenferro 卷的 tenferro 解决什么问题量过只做一次 matmul 的 30.3 MB)。
启动到第一个梯度
同一个 grad 任务做成一次性的进程:启动、建数据、第一次求梯度、打印、退出。JAX 一侧进程里第一行就开始计时:
import time
T0 = time.perf_counter()
import sys
import jax
T_IMPORT = time.perf_counter()
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
N = 1000
x = jnp.array([-2 + 4 * i / (N - 1) for i in range(N)])
g = jax.block_until_ready(jax.jit(jax.grad(lambda x: jnp.sum(jnp.sin(x) * x**2)))(x))
T_GRAD = time.perf_counter()
# ... 打印 g,把两段时间写到 stderr
tenferro 一侧就是怎么读这一卷里那个程序,在 main 第一行取 Instant::now()。各跑 10 次,负载 2.5 到 2.7:
| 程序 | 整个进程 | 进程内到第一个梯度 | 峰值 RSS |
|---|---|---|---|
| JAX | 0.36–0.41 s,中位数 0.375 s | 273–310 ms,其中 import jax 239–274 ms | 178.2–180.9 MiB |
| tenferro | 3.2–3.8 ms | 0.42–0.60 ms | 6.4–6.5 MiB |
参照:python -I -c pass | 0.01 s | — | 12.6 MiB |
JAX 一行的整个进程时间来自 /usr/bin/time -l(分辨率 10 ms)。tenferro 在它下面显示 0.00 s,所以改用一个 Python 的 subprocess 计时器量;同一个计时器跑 /usr/bin/true 是 1.8 ms,这是它自己的底。(这个计时器量 Python 子进程会多出约 50 ms,python -c pass 也一样,原因没有追查,所以 JAX 一行不用它。)
JAX 的 0.28 秒里,import jax 占了大半,jit 第一次编译这个小函数约 35 ms。tenferro 在进程内从建图、求导、编译、准备到执行完一共不到 1 ms。按请求起进程、做命令行工具的场合,差别几乎全在这里;在一个长期运行的进程里反复求值,这笔一次性的账就摊薄了,比的是上面的稳态。
strip,然后第一次运行这个新文件。进程内到第一个梯度要多久?之后每次运行都在 0.42–0.60 ms。先猜,再看文末。GPU
tenferro 0.8.0 唯一的 GPU 后端是 CUDA:tenferro-gpu 的 feature 只有 cuda(和一个空的 rocm),见 tenferro 卷的 CUDA。另有一个 tenferro-xla,把编译好的图翻成 StableHLO,交给外部的 PJRT 插件执行;本机没有插件,tenferro 卷也只读了源码(XLA)。所以在 Mac 上,tenferro 只有 CPU。
JAX 一侧,本卷的虚拟环境里 jax.devices() 只有 CpuDevice(id=0),没有装任何 GPU 插件。这一卷两边都只在 CPU 上比。
0.7.1 的 tenferro-gpu 还有一个 webgpu feature,经 CubeCL 的 wgpu 运行时,在 macOS 上走 Metal。0.8.0 发布前三天的提交「feat(gpu)!: one CUDA path — the kernel-catalogue engine replaces CubeCL」把它删了,随 PR #2291 合并,首次发布于 v0.8.0。提交说明开头写「the tenferro CUDA backend now has a single engine, the one over the kernel catalogue, and the CubeCL engine, its kernels and the WebGPU/Metal lane are gone」,删除一节列出「the WebGPU/Metal lane (webgpu, apple, its tests, tutorial binaries and examples)」和「the webgpu/cubecl-interop features」。之前 GPU 有 CUDA 和 WebGPU 两条路,都建在 CubeCL 上;之后只剩一个直接建在 CUDA 内核目录上的 engine。
JAX 有、tenferro 0.8.0 没有的
逐条在 tag v0.8.0 的源码上核对过:
- 没有
vmap。crates/下搜不到vmap,traced 张量上的变换只有grad、vjp、jvp和checkpoint(TracedTensorAdExt)。批量要显式写进形状,比如dot_general的 batch 维。 - 没有
lax.scan、fori_loop、while_loop、cond。 核心算子表StdTensorOp里是算术、超越函数、DotGeneral、Reshape、Gather、Scatter、DynamicSlice、Pad这类张量算子,加上Extension,没有带子图的控制流算子。Rust 的循环在建图时展开,逐元素的选择用select/where_select(相当于jnp.where)。TRG 那种长链要像上面那样手工分段(分段)。 - 没有
jax.random。TracedTensor的方法和核心算子表里都没有随机数;要随机输入,在 Rust 里生成好数据再建张量。 - 没有 pytree。 求导的对象是一个个
TracedTensor:y.grad(&x)对一个输入;多个输入用AdContext::vjp_many一次求出(tenferro 卷的 traced AD)。参数的嵌套结构要自己摊平。 - 没有函数级的
custom_jvp/custom_vjp。 不能给一段 traced 代码直接挂一条求导规则;自定义导数要把它写成扩展算子,再给这个算子族一条 semantic AD 规则(扩展算子、semantic AD 与扩展)。反过来,JAX 里要手写custom_jvp才有的 SVD 展宽规则,tenferro 是默认的。 - 长图求导的代价。 上面量过:JAX 对 20 步的图一次求导,tenferro 到 4 步就要 2 秒。
tenferro 这一侧也有 JAX 不同的地方,各页细讲:同一个库里既有 traced 变换也有 PyTorch 式的 backward(eager);复数求导的约定和 PyTorch 一样,和 JAX 差一个共轭(复数求导);张量是列主序(布局与 reshape)。
什么时候用哪个
- 用 tenferro:求导要放进 Rust 程序里交付(命令行工具、嵌进 Rust 服务、按请求起进程),在意启动时间、内存和部署体积:本页的数字是体积差约 19 倍,进程内到第一个梯度差约 500 倍(整个进程约 100 倍),峰值 RSS 差约 28 倍。用到 SVD、eigh 这类分解的梯度又有简并时,tenferro 默认就给出有限的梯度。数据本来就是列主序(Fortran、Julia、LAPACK)时也顺手。
- 留在 JAX:要
vmap、lax.scan、随机数、pytree 形式的参数;想对一条长计算链整体求导而不想手工分段;看重稳态速度(本页的 TRG 上,JAX 求值加梯度比 tenferro 8 线程快约 2 倍,小函数上快 1.1 到 2.2 倍);在 notebook 里交互试验;要 GPU。
动脑筋答案
first exec of the stripped copy:
grad[0] = 1.972602361114157, sum(grad) = 1818.745425877291154
trace+grad+compile 6.104 ms, +runtime+prepare 9.722 ms, +first run 16.388 ms
16.4 ms,是之后的 30 多倍。
- 慢的不只是执行,三段都慢:建图、求导、编译 6.1 ms(之后 0.23 ms 左右)。这说明代码本身第一次被用到时有额外开销,不是 tenferro 的哪个缓存还没热。
- 实验里重新构建之后第一次运行也是这样(15.7 到 17.5 ms),隔了几分钟再跑一个旧的二进制也慢过一次(
ad_vec,第一次run6.9 ms,之后 0.1 ms 左右)。本页没有追查是 macOS 的哪一步;可以确定的是第二次运行同一个文件就回到 0.5 ms 左右。 - 所以量冷启动时要说清楚是哪一种「冷」:新装的文件、久未运行的文件,还是刚运行过的文件。上面表里的 10 次都是第二次以后。
小结
- 20 步 TRG(χ = 16):JAX
jit求值 161–190 ms、求值加梯度 223–253 ms;tenferro 必须分段,8 线程 177–189 / 441–472 ms,单线程 333–355 / 913–948 ms。JAX 的进程用了 1.2 到 2.2 个核。tenferro 的准备只要 18.5 ms,JAX 第一次调用 1.2 秒。 - 小函数(n = 1000):JAX
jit的 grad、jvp、hvp 10–13 µs,tenferro 单线程 14–24 µs;eager 上 PyTorch 29 µs,tenferro 74 µs。 - tenferro 的求导变换在长图上随深度指数增长:TRG 2、3、4 步 8 ms、90 ms、2.1 s;3×3 的 SVD 链每层 ×2.6 到 ×3。
- 交付:JAX 加 Python 约 409 MiB,tenferro 的 grad 程序 strip 后 21.3 MiB,只链接系统库。到第一个梯度:JAX 进程 0.36 s 起,
import jax占大半;tenferro 进程 3.2 ms 起,进程内不到 1 ms;峰值 RSS 178 MiB 对 6.4 MiB。新文件第一次运行要 16 ms。 - tenferro 0.8.0 的 GPU 只有 CUDA,Mac 上只有 CPU;Metal 路径随 PR #2291 删掉了。
- 没有
vmap、控制流算子、随机数、pytree、函数级custom_jvp;有默认的 SVD 展宽规则、eagerbackward和 PyTorch 式的复数约定。