记图与 jit
主线位置:变换主线的「写函数 → 记图」和「编译、准备 → 执行」两步,函数是
f(x) = Σ sin(x)·x²。
版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB jf["jit(f)(x)"] --> tr["追踪出 jaxpr"] tr --> xc["XLA 编译"] xc --> jr["执行"] jr -->|"新形状"| tr rs["Rust 代码建图"] --> gc["GraphCompiler"] gc --> pr["prepare"] pr --> rr["run"] rr -->|"新形状"| err["报错 要重建"]
JAX 的 jit 把三件事藏在第一次调用里:用抽象值(tracer)调用 Python 函数、记下 jaxpr,交给 XLA 编译,再执行。tenferro 把它们拆成三个显式的调用。本页对照这三步,以及换形状、闭包常量、控制流时两边的差别。tenferro 内部怎么建图、编译、准备,见 TracedTensor、编译、执行与缓存。
实验代码:tenferro 一侧是 bin jit_shapes(release,CpuEngine::serial()),JAX 一侧是 jit_shapes.py。M2 Max、macOS 27、rustc 1.99.0;同机有别的任务在跑,负载均值 4–6。本页的输入取 x_i = 2i/(n−1)(i = 0..n−1),不对称,f 不为 0。
记图:jaxpr 和 TracedTensor 图
def f(x):
return jnp.sum(jnp.sin(x) * x**2)
print(jax.make_jaxpr(f)(x1000))
fn f(x: &TracedTensor) -> R<TracedTensor> {
Ok((&x.sin()? * &(x * x)?)?.reduce_sum(None)?)
}
let x = TracedTensor::input_concrete_shape(DType::F64, &[1000])?;
let y = f(&x)?;
let program = GraphCompiler::new().compile_with_input_specs(&y, &[(&x, DType::F64, &[1000][..])])?;
println!("inputs={} ops={}: {}", program.input_count(), op_names(&program).len(), op_names(&program).join(" "));
# JAX
{ lambda ; a:f64[1000]. let
b:f64[1000] = sin a
c:f64[1000] = integer_pow[y=2] a
d:f64[1000] = mul b c
e:f64[] = reduce_sum[axes=(0,) out_sharding=None] d
in (e,) }
# tenferro
inputs=1 ops=4: Sin Mul Mul ReduceSum
(op_names 是实验代码里的小函数,把编译后语义程序的每个算子打成名字。)
两边记下的是同一个东西:一个输入、四个算子的程序。x**2 在 jaxpr 里是 integer_pow,tenferro 里写成 x * x,就是一个 Mul。
不同在于谁去「记」。JAX 拿 tracer 调用 Python 函数,f 本身只是普通的数值代码,jit、grad 从外面包住它。tenferro 没有追踪这一步:TracedTensor 上的每个运算本来就只往图里加节点,f(&x) 返回的已经是图。所以 tenferro 的 f 必须写成 TracedTensor 上的函数,不能拿一个算数组的函数再「包一层 jit」。
值对得上:JAX 1235.325872458312688,和 math.fsum 给出的正确舍入结果相同(下面输出里的 |.-exact|=0.0e+00);tenferro 1235.325872458312915,差 2.3e-13,正好是 1235 附近的一个 ulp,多半是 1000 项求和的顺序不同。
参数:占位符
jit 的参数在 tenferro 里是占位符:TracedTensor::input_concrete_shape 建一个只有 dtype 和形状、没有数据的叶子,编译时用 compile_with_input_specs 列出它,程序的输入顺序就是这个列表的顺序。数据到 prepare 和 run 时才给。直接 compile 一个含占位符的图会报 has no runtime input binding,见 has no runtime input binding。
| JAX | tenferro |
|---|---|
jit 的数组参数(每次追踪按具体形状) | input_concrete_shape 加 compile_with_input_specs |
| 闭包里捕获的数组(jaxpr 的常量) | 带数据的叶子 from_vec_col_major,是带默认值的输入(下文) |
static_argnums、Python 整数 | 建图时的 Rust 值(TRG 里的 d、k) |
jit 没有对应 | from_tensor_symbolic_shape:带一份示例数据、形状是符号的叶子 |
第一次调用和之后
jf = jax.jit(f)
v = jax.block_until_ready(jf(x1000)) # 第一次:追踪 + 编译 + 执行
# 之后 200 次取最小值;再用一个新函数对象单独量 lower() 和 compile()
let p = prepare(&rt, &eng, &program, &[&x1000])?; // rt.prepare(&program, &reads, &[eng.target()])
let v = run1(&rt, &mut eng, &p, &[&x1000])?; // rt.run(&mut [&mut eng], &p, &reads)
# JAX
value=1235.325872458312688 |.-exact|=0.0e+00
jit first call 19012 us, steady 7.5 us, traces so far 1
AOT on a copy: lower 1112 us, compile 16928 us, steady 9.3 us
# tenferro(进程里第一个程序)
build 1212.1 us, compile 534.0 us, prepare 1673.3 us, first run 4415.8 us, steady 6.5 us
prepare again (cache) 52.9 us; ...
(最后一行后面是 cache_stats() 的完整输出,省略。)
- JAX 第一次调用 19 ms,其中 XLA 编译约 17 ms,追踪和降级(
lower)约 1 ms。之后每次 7.5 µs。 - tenferro 的四步加起来约 7.8 ms,但这是进程里的第一个程序:建图、准备、执行各自带着一次性的初始化。同一个进程里后面的程序便宜得多,下一节为长度 2000 重建一遍,建图加编译加准备一共 85 µs。稳态 6.5 µs,和 JAX 同一量级。
- 同一个程序再
prepare一次是 53 µs:准备缓存命中(hits: 2),但每次prepare仍要先把语义程序降级一遍才能算缓存键,见 执行与缓存。所以prepare一次、run多次,别在循环里每次prepare。
问: tenferro 准备一个程序不到 0.1 ms,XLA 编译要 17 ms,是不是 tenferro 的「jit」快两百倍?
答: 两者做的不是同一件事。XLA 为这个形状生成并优化机器码,sin、乘法和求和可以融合进一个循环。tenferro 的 GraphCompiler 只把图压平成语义程序,prepare 降级、为每个算子选提供者、规划逐元素区,执行时调用现成的内核,不生成代码(编译)。准备便宜,换来的是稳态时间取决于现成内核的好坏;这个例子里两边都在 7 µs 上下,别的程序未必。
换一个形状
v2 = jf(x2000) # 同一个 jit 函数,长度 2000
jf(x1000) # 再回到 1000
compiled(x2000) # AOT 编译出来的可执行文件
run1(&rt, &mut eng, &p, &[&x2000]) // 为 [1000] 准备的 Prepared
prepare(&rt, &eng, &program, &[&x2000]) // 为 [1000] 编译的程序
// 重建:新的占位符 [2000]、建图、编译、准备
# JAX
first call at 2000: 19597 us, value=2470.067398026252249 |.-exact|=0.0e+00, traces 2, cache size 2
back to 1000: 46 us, traces 2, cache size 2
AOT compiled(x2000): TypeError: Argument types differ from the types for which this computation was compiled. Perhaps you are calling the compiled executable with a different enable_x64 mode than when it was AOT compiled? The mismatches are:
# tenferro
run: Err: binding shape mismatch for concrete-shape placeholder: expected [1000], got [2000]
prepare: Err: binding shape mismatch for concrete-shape placeholder: expected [1000], got [2000]
rebuild for 2000: build+compile+prepare 85.2 us, value=2470.067398026252704
jit 对新形状静默地重新追踪、重新编译(又是 20 ms),两个形状各占一条缓存。tenferro 的具体形状占位符把形状写进了程序,prepare 和 run 都拒绝 [2000],没有「自动重编译」:要换形状就重新建图。好在重建只要 85 µs,在 Rust 里写一个「按形状缓存程序」的 HashMap 就相当于 jit 的缓存。AOT 编译出来的 JAX 可执行文件和 tenferro 一样,换形状直接报错。
tenferro 还有一条 JAX 的 jit 没有的路:带数据的符号形状叶子。程序编译一次,不同长度都能跑:
let xs_leaf = TracedTensor::from_tensor_symbolic_shape(Tensor::from_vec_col_major(vec![1000], xs(1000))?)?;
let ps = GraphCompiler::new().compile(&f(&xs_leaf)?)?;
let pp = prepare(&rt, &eng, &ps, &[&x1000])?;
// 同一个 pp 跑 1000 和 2000;再把求和换成 dot_general(sin(x), x*x) 试一遍
elementwise, prepared for 1000, run 1000: 1235.325872458312915
elementwise, prepared for 1000, run 2000: 2470.067398026252704
dot_general version ops: Sin Mul DotGeneral
dot_general, run 1000: 1235.325872458312915
dot_general, prepared for 1000, run 2000: Err: Runtime::run (Execution): runtime state failure: provider contract violation: prepared specialization projection mismatch
dot_general, prepare again for 2000 (32.9 us), run 2000: 2470.067398026252704
逐元素加规约的程序,Prepared 只按 dtype 和秩特化,为 1000 准备的拿去跑 2000 照样行。含 dot_general 的程序,CPU 提供者要按每一维的具体长度特化(minimum_specialization_requirements),换长度就要重新 prepare,报错的措辞像库的 bug,其实不是,见 prepared specialization projection mismatch。重新 prepare 只要 33 µs,不用重新建图和编译。符号形状把形状错误推迟到运行时,见 add: incompatible shapes。
闭包里的常量
JAX 函数里引用外面的数组,它会变成 jaxpr 的常量:
w = jnp.full(1000, 2.0)
g = lambda x: jnp.sum(jnp.sin(x) * w)
print(jax.make_jaxpr(g)(x1000))
{ lambda a:f64[1000]; b:f64[1000]. let
c:f64[1000] = sin b
d:f64[1000] = convert_element_type[new_dtype=float64 weak_type=False] a
e:f64[1000] = mul c d
f:f64[] = reduce_sum[axes=(0,) out_sharding=None] e
in (f,) }
分号前的 a 是常量,分号后的 b 才是参数。tenferro 里同样的写法是一个带数据的叶子:
let w = TracedTensor::from_vec_col_major(vec![1000], vec![2.0_f64; 1000])?;
let xc = TracedTensor::input_concrete_shape(DType::F64, &[1000])?;
let yc = (&xc.sin()? * &w)?.reduce_sum(None)?;
let pc_prog = GraphCompiler::new().compile_with_input_specs(&yc, &[(&xc, DType::F64, &[1000][..])])?;
inputs=2 (x index Some(0), w index Some(1))
run with [x]: 1415.639514615922508
w 不是常量,是程序的第 1 个输入,只不过带着一份默认数据。run 只给 x 时,w 用默认值;JAX 的结果逐位相同。
Prepared,run 时传 [x, w'],w' 全是 3.0,输出是什么?还是 1415.64(w 是常量),报错(多给了一个输入),还是别的?平台同上。
这件事在循环里有实际影响。JAX 每轮新建一个捕获不同 w 的 jit(lambda),每轮都重新追踪、编译;把 w 改成参数,只有第一轮编译:
iteration 0: first call 17218 us, value 1415.639514615922508
iteration 1: first call 17242 us, value 2123.459271923883534
iteration 2: first call 18732 us, value 2831.279029231845016
w as an argument, iteration 0: 17617 us
w as an argument, iteration 1: 32 us
w as an argument, iteration 2: 24 us
tenferro 每轮新建一张结构相同、w 不同的图,重建加编译加准备约 47 µs,准备缓存命中两次(cache_stats() 的输出很长,只留了命中和未命中两项):
cache before ... hits: 4, misses: 12, ...
cache after ... hits: 6, misses: 12, ...
准备缓存的键是程序的结构,不含数据(执行与缓存),所以「换常量重建」在 tenferro 里是便宜的,在 JAX 里要付一次 XLA 编译。
控制流
jax.jit(lambda x: x if jnp.sum(x) > 0 else -x)(x1000)
TracerBoolConversionError: Attempted boolean conversion of traced array with shape bool[].
JAX 追踪时 x 是抽象值,Python 的 if 没法对它求真假,要改用 jnp.where、lax.cond。tenferro 建图时同样没有数:
println!("x placeholder attached_value: {}", x.attached_value().is_some());
println!("w data leaf attached_value: {}", w.attached_value().is_some());
println!("y = f(x) attached_value: {}", y.attached_value().is_some());
println!("w * w (derived from data only) attached_value: {}", (&w * &w)?.attached_value().is_some());
x placeholder attached_value: false
w data leaf attached_value: true
y = f(x) attached_value: false
w * w (derived from data only) attached_value: false
只有带数据的叶子挂着数据(attached_value),任何运算的结果都没有,连只由常量算出的 w * w 也没有:建图阶段不做常量折叠。所以 Rust 的 if、for 只能作用在 Rust 值上(形状、步数、截断数),相当于 JAX 里的静态参数,循环在建图时展开。tenferro 的算子表 StdTensorOp 里没有 cond、while、scan 这类控制流算子;按数据选值用 select / where_select,两边都算再挑,和 jnp.where 一样。没有 scan 意味着 20 步的 TRG 会展开成一张大图,这正是 分段 要处理的问题。
动脑筋答案
run with [x, w=3]: 2123.459271923883989
rebuilt with w=3: build+compile+prepare 47.3 us, value 2123.459271923883989
- 输出是 2123.46,正好是 1415.64 的 1.5 倍:
w'覆盖了默认的w。和用w = 3重建的程序逐位相同;和 JAX 第二轮的 2123.459271923883534 差 4.5e-13,是 2123 附近的一个 ulp。 - 原因在
resolve_input_reads:给的输入个数等于程序的输入个数时,全部用给的张量,默认值被忽略;只给 1 个时,按顺序只填没有默认值的占位符。 - 对 JAX 用户这是一个陷阱:在 tenferro 里「常量」可以被调用方换掉,输入顺序又是程序决定的(这里
x在前、w在后)。要确认位置,用input_key_index查。
小结
- jaxpr 由 tracer 调用 Python 函数得到;tenferro 的
TracedTensor运算本身就在建图,f要写成TracedTensor上的函数。同一个f两边都是 4 个算子,值差一个 ulp。 jit的参数对应input_concrete_shape加compile_with_input_specs;第一次调用 =compile+prepare+run。JAX 首次 19 ms(XLA 编译 17 ms),tenferro 进程里第一个程序约 8 ms,之后的程序准备不到 0.1 ms;稳态都约 7 µs。jit遇到新形状静默重编译;tenferro 的具体形状程序直接报binding shape mismatch,要自己按形状缓存。符号形状的叶子可以一个程序跑多个长度,含dot_general时每个长度重新prepare。- 闭包常量在 jaxpr 里是常量,在 tenferro 里是带默认值、可以被覆盖的输入;结构相同的图重建时准备缓存命中,换常量不贵。
- 建图时没有数,Rust 的控制流只作用在形状和步数上;没有
cond/while/scan,按数据选值用select。