Rust in Everything
基于 tenferro-runtime 0.8.0

记图与 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。

JAXtenferro
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。
📝NOTE
没有蠢问题

问: 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 的结果逐位相同。

❗IMPORTANT
动脑筋:同一个 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。

💬 关联讨论

发起讨论
加载中...