Rust in Everything
基于 tenferro-runtime 0.8.0

TracedTensor

主线位置:traced 主线的第一步。(&x * &x)?.reduce_sum(..)? 这样的代码不计算任何东西,只往图里加节点;本页讲加节点的过程、为什么每一步都返回 Result,以及形状在什么时候才确定。图的底座见 computegraph,图怎么编成程序见编译。

sequenceDiagram
  participant U as 用户代码
  participant T as TracedTensor
  participant B as GraphBuilder
  participant M as 全局元数据表
  U->>T: x.mul(y)
  T->>T: broadcast_binary 查形状
  T->>T: 推断输出 dtype
  T->>B: 新图 挂两个父图
  B-->>T: 一个 Mul 节点
  T->>M: 登记输出的 dtype 和形状
  T-->>U: Result 新的 TracedTensor

代码在 tenferro-runtime/src/traced.rs,约 3500 行,大部分是每个运算一个方法加文档;softmax、layer_norm 这些组合运算在 traced/composite_ops.rs(见激活、softmax 与归一化)。本页实验在 exp-tenferro8/rt-graph,release,M2 Max、macOS 27、rustc 1.99.0,执行用 CpuEngine::serial()。

一个值,加上它需要的上下文

TracedTensor:

#[derive(Clone)]
pub struct TracedTensor {
    pub id: TracedTensorId,
    pub rank: usize,
    pub dtype: DType,
    pub(crate) graph: Arc<Graph<StdTensorOp>>,
    pub val: LocalValueId,
    pub(crate) data: Option<Arc<RetainedValue>>,
    pub(crate) shape_hint: Option<Vec<SymDim>>,
    pub(crate) inputs_map: Arc<TracedInputMap>,
    // ...
    pub(crate) leaf_metas: Arc<HashMap<TensorInputKey, TensorMeta>>,
    pub(crate) extra_roots: Vec<Arc<Graph<StdTensorOp>>>,
    pub(crate) checkpoint_chain: Option<Arc<CheckpointNode>>,
    pub(crate) metadata_scopes: MetadataScopeChain,
    pub(crate) constraint_scopes: ConstraintScopeChain,
}

核心只有两个字段:graph 和 val,「graph 这张图里的第 val 个值」。其余的都是这个值上路时要带着的东西:

  • rank、dtype 总是已知的;shape_hint 是形状,可能每一维都是常数,也可能是 None(符号形状)。
  • data 只有叶子才有:用真实数据建的叶子把张量挂在这里。inputs_map 是从所有叶子收集来的「输入键 → 默认张量」表,一路合并到最终输出,编译时用它给程序的输入绑上默认值,所以 runtime.prepare(&program, &[], ..) 和 runtime.run(.., &[]) 不传输入也能跑。
  • metadata_scopes 持有全局元数据表里登记项的所有权(下文);constraint_scopes 是扩展运算声明的形状等式;leaf_metas、extra_roots 和 checkpoint_chain 给求导用,见 traced AD。

所有大字段都在 Arc 里,clone 很便宜;TracedTensor 本身不可变,运算总是产生新值。

每个运算一张新图

二元运算最后都落到 apply_binary_with_output_dtype:

    let lhs_ref = ValueRef::External(lhs.graph.values()[lhs.val].key.clone());
    let rhs_ref = ValueRef::External(rhs.graph.values()[rhs.val].key.clone());

    let mut builder = GraphBuilder::new();
    builder.add_parent(lhs.graph.clone());
    builder.add_parent(rhs.graph.clone());
    let outputs = builder.add_operation(op, vec![lhs_ref, rhs_ref], OperationRole::Primary);
    builder.set_outputs(outputs.clone());
    let graph = Arc::new(builder.build());
    let metadata_scope =
        register_single_output_metadata(graph.as_ref(), outputs[0], out_dtype, &out_shape_hint)?;

不往已有的图里追加,而是新建一张只有一个节点的图,两个操作数的图挂成父图,用 External 键引用它们的值。所有 traced 张量合起来是一张由 Arc<Graph> 串起来的不可变 DAG:旧值永远不会被改,x 可以同时被几个不相关的表达式使用。代价是一次运算一次小分配,以及编译时要靠 resolve 把父链重新串起来(见 computegraph)。

实验 traced_structure 第 1 段:

let x = TracedTensor::from_vec_col_major(vec![3], vec![1.0_f64, 2.0, 3.0])?;
let sq = (&x * &x)?;
let y = sq.reduce_sum(Some(&[0]))?;
x: graph values=1 ops=0 parents=0 has_data=true
x*x: graph values=1 ops=1 parents=2 has_data=false
sum: graph values=1 ops=1 parents=1 has_data=false

x*x 的两个父图是同一张 x 的图(add_parent 调了两次,没有去重;resolve 按指针去重)。每张图的 values 只有 1:输入是外部引用,不算本图的值。

形状和 dtype 不存在图里。computegraph 的 StdTensorOp 只是运算本身(Operand = ()),每个值的 TensorMeta(dtype、每一维的范围)登记在一张以 ValueKey 为键、加互斥锁的全局表上。登记返回一个作用域对象,TracedTensor 通过 metadata_scopes 持有它;最后一个持有者被丢弃时,登记项从表里撤掉。编译和求导都从这张表里按键查形状。

为什么每个运算都返回 Result

impl Add for &TracedTensor 的 Output 是 Result<TracedTensor>,所以写法是 (&a + &b)?;Neg 也一样。失败有三个来源,都在建图这一步:

  1. 形状校验。 广播、轴号、置换、reshape 的元素数、切片和 dynamic_slice 的窗口,能在建图时查的都查。错误带阶段标签 GraphBuild(ErrorPhase 的三个值是 GraphBuild、Compile、Execution)。
  2. dtype 推断。 例如对不支持的 dtype 求输出类型失败。
  3. 全局元数据表。 登记要拿一把全局锁,锁中毒时返回 RuntimeStateSource。
let z = TracedTensor::from_vec_col_major(vec![4], vec![0.0_f64; 4])?;
match &x + &z { /* ... */ }
x + z -> Err: broadcast_binary (GraphBuild): incompatible shapes: lhs=[3], rhs=[4]

和 eager 计算比,这里报错的位置更早:数据一个都没碰,x + z 这一行就失败了,而不是等到编译或执行。

📝NOTE
冷知识:-x 以前不返回 Result

PR #1367(一批小修复,其中的提交「fix(runtime): propagate traced metadata errors」,首次发布于 v0.3.0)之前,register_single_output_metadata 返回的是 GlobalMetadataScope,登记失败时调用 .expect("fresh traced graph output metadata registration failed");apply_binary 返回 TracedTensor,add 再用 Ok(..) 包一层;impl Neg for &TracedTensor 的 Output 是 TracedTensor。也就是说签名是 Result,里面最可能出错的那一步却是 panic。这次提交把登记改成 ? 传播,apply_binary 改成返回 Result,Neg::Output 改成 Result<TracedTensor>,同一个 PR 里的「fix(workspace): migrate fallible traced operations」和「docs(runtime): migrate fallible neg examples」两个提交把工作区代码和文档示例跟着改掉,例如文档里的 compiler.compile(&x.neg()) 变成先 let y = x.neg().unwrap(); 再编译 &y。

四种叶子

构造函数带数据形状用途
from_vec_col_major / from_tensor_concrete_shape是每维常数最常见,教程用的就是它
from_tensor_symbolic_shape是每维符号带默认数据,但程序不绑死形状
input_concrete_shape否每维常数占位符,执行时必须给输入
input_symbolic_shape否只有秩占位符,只知道秩

每个叶子从全局计数器拿一个新的 TensorInputKey::User { id },建一张只有一个输入的图。带数据的叶子把张量放进 data 和 inputs_map;from_vec_col_major 就是先建一个 Tensor,再调 from_tensor_concrete_shape,数据要求已经是列主序(行主序数据用 Tensor::from_vec_row_major 先转,见列主序与视图)。

「具体」和「符号」的差别不在数据,而在登记进元数据表的形状:具体叶子登记 [3],符号叶子登记「这个张量第 0 维的长度」这样的符号维度(SymDim)。编译时前者变成 DimExpr::Const(3),后者变成 DimExpr::InputDim { input_idx, axis },即「第几个程序输入的第几维」(执行与缓存的实验第 4 段打印过:input metadata shape = [Exact(InputDim { input_idx: 0, axis: 0 })])。

形状在什么时候确定

上游设计文档 dynamic-symbolic-shapes.md 的分层和代码一致:值的每一维是一个 ShapeExtent,Exact(expr)(就是这么长)、UpperBound(expr)(不超过这么长,dynamic_truncate 这种由运行时标量决定长度的运算用)或 Unknown;表达式是 DimExpr,常数、输入的某一维、以及它们的加减乘除和 max/min。文档的设计目标里有一条「Keep backend kernels concrete」:内核拿到的永远是 usize,符号只活在图和编译器里,执行时代入真实输入的形状求值。

形状检查发生在哪一步,取决于这时形状知道多少。二元运算由 broadcast_binary 决定:

pub(crate) fn broadcast_binary(
    a: &TracedTensor,
    b: &TracedTensor,
) -> Result<(TracedTensor, TracedTensor)> {
    if a.shape_hint == b.shape_hint && a.rank == b.rank {
        return Ok((a.clone(), b.clone()));
    }
    if (try_concrete_shape(a).is_none() || try_concrete_shape(b).is_none()) && a.rank == b.rank {
        return Ok((a.clone(), b.clone()));
    }
    let a_shape = concrete_shape(a)?;
    let b_shape = concrete_shape(b)?;
    let target = broadcast_shape(&a_shape, &b_shape)
        .map_err(|err| graph_broadcast_error("broadcast_binary", err))?;
    Ok((broadcast_to(a, &target)?, broadcast_to(b, &target)?))
}

只要有一边不是具体形状、而两边秩相同,就直接放行,既不检查也不插入 BroadcastInDim:符号形状的操作数之间没有 NumPy 式广播,默认两边一样长。秩不同时会走到 concrete_shape,符号形状的一边会得到 InvalidArgument(「missing shape hint」或「symbolic dimension in shape hint」)。

索引类运算走另一条路:slice、gather、dynamic_slice 都先调 infer_traced_single_output_shape,用和编译期相同的形状推断查一遍窗口。dynamic_slice 的文档写明:窗口超出具体维度在建图时拒绝;维度还是符号时,检查推迟到「提供这个维度的那个阶段」,错误的 ErrorPhase 会说明是哪一段。

实验 placeholders 把几种组合各试一遍(每一行先 prepare 再 run,标出是哪一步失败):

== 1. data-less symbolic placeholder
p: concrete shape = None, sym_shape = None
compile(sum(p*p)): Err: placeholder User { id: 0 } has no runtime input binding
compile_with_input_specs(p: F64 [3]): ok
  inputs &[]: prepare Err: placeholder semantic input 0 has no runtime input binding
  inputs [3]: -> [14.0]
  inputs [4]: prepare Err: binding shape mismatch for concrete-shape placeholder: expected [3], got [4]
== 3. two symbolic leaves
a + b: ok
  a+b sym_shape = None
  compile: ok
  [3],[3]: -> [2.0, 4.0, 6.0]
  [3],[4]: run Err: add: incompatible shapes: lhs=[3], rhs=[4]
  prepared on [3],[3], run [3],[4]: Err: add: incompatible shapes: lhs=[3], rhs=[4]
== 4. two concrete leaves
c3 + c4: Err: broadcast_binary (GraphBuild): incompatible shapes: lhs=[3], rhs=[4]
  • 不带数据的占位符(input_symbolic_shape)不能直接 compile:compile 要给每个输入定一个描述,既没有默认张量也没有说明时返回 UnboundPlaceholder。报错文字说的是「runtime input binding」,但这是编译期的错误。要编译它得用 compile_with_input_specs 给出 dtype 和形状,而给出的形状会被当作具体形状:之后喂 [4],prepare 就拒绝。
  • 两个符号叶子建图、编译、prepare 都通过(prepare 拿到了 [3] 和 [4] 两个输入也不拒绝:逐元素运算只按 dtype 和秩准备),形状不一致只有在 run 真的执行到加法那条指令时才由 CPU 内核发现。
  • 两个具体叶子在 + 这一行就失败(前一节)。

同一个错误在三个阶段出现,看得最清楚的是切片。第 7、8 段对长度 3 的向量取 1..5、或取长度 4 的 dynamic_slice 窗口:

== 7. dynamic_slice window larger than the operand
x[3].dynamic_slice(starts, [4]): Err: TracedTensor::dynamic_slice (GraphBuild): shape mismatch: expected=[3], actual=[4]
xs[n].dynamic_slice(starts, [4]): ok
  compile with xs: [3]: Err: shape_infer (Compile): shape mismatch: expected=[3], actual=[4]
== 8. slice limit beyond the extent
x[3].slice(1..5): Err: TracedTensor::slice (GraphBuild): invalid slice bounds: start=1, end=5, axis_len=3
xs[n].slice(1..5): ok
  compile with xs: [3]: Err: shape_infer (Compile): invalid slice bounds: start=1, end=5, axis_len=3
  symbolic leaf with 3 elements: compile: ok
  run defaults: run Err: slice: invalid argument configuration: limit 5 on axis 0 exceeds dimension size 3
操作数什么时候知道长度是 3报错阶段报错者
input_concrete_shape(F64, &[3])建图GraphBuildTracedTensor::slice
input_symbolic_shape + compile_with_input_specs(.., &[3])编译Compileshape_infer
from_tensor_symbolic_shape(3 个元素)执行运行时CPU 的 slice 内核

符号叶子的长度从来不进入编译器(它是 InputDim),所以只能由内核发现。

📝NOTE
冷知识:多声明一个占位符,张量会被绑到别的输入上

compile_with_input_specs 收一串 (占位符, dtype, 形状),按这个顺序排程序的输入。PR #2014(首次发布于 v0.8.0)之前,输出用不到的占位符在压平时被丢掉,而调用方以为它还占着一个输入位置。提交 f3a8c1a2 的说明写道:「compile_with_input_specs silently dropped a declared placeholder the output does not use, so a tensor passed for it at run time was rejected or, when counts matched, bound to a retained constant.」也就是说,个数恰好对上时,本该给 q 的张量会悄悄顶替程序里另一个输入。现在的做法是编译完后逐个查 program.input_key_index(key),有一个查不到就返回编译期的 InvalidArgument。实验第 2 段,y 只用到 p,多声明一个 q:

compile_with_input_specs(y, [p, q]): Err: GraphCompiler::compile_with_input_specs (Compile): invalid argument bindings: binding 1 declares a placeholder the output does not depend on; remove it from the bindings

另一个 0.8.0 的修复也和符号维度有关:TracedTensor::concatenate 拼两个不同的叶子,以前不给输入说明就编不过。PR #2157 里的提交说明给了原因:编译期的形状守卫检查用 DimExpr::InputDim 表示输入的维度,而 concatenate_shape 把任何结构上的不同都当成具体维度不匹配。现在只拒绝能静态算出的非拼接轴不匹配,其余留给内核。实验第 6 段,[2, 3] 和 [2, 4] 两个具体叶子沿第 1 轴拼接,compile 通过,结果形状 [2, 7]。

所以「编译一次、喂不同长度的输入」要用带数据的符号叶子 from_tensor_symbolic_shape,或者自己搭语义程序。这样编出来的程序的输入形状是 InputDim,执行时每次都按真实输入代入(实验见执行与缓存:同一个程序先后喂 3、5、1000 个元素)。代价是形状错误要到运行时才看得见。traced 的 reshape 接受 impl IntoShapeVec,数组、Vec 都行,秩在运行时检查(上游的 rank-aware-shape-inputs.md 把编译期检查秩的 IntoRankShape 只用在 TypedTensor 的构造函数上)。

❗IMPORTANT
动脑筋:符号叶子加具体叶子,错在哪一步?

a 是 from_tensor_symbolic_shape 建的、带 3 个元素的叶子,c4 是 from_vec_col_major(vec![4], ..)。&a + &c4 会在建图、编译、prepare、run 四步里的哪一步报错?报什么?

建图的开销

符号形状不是免费的。实验 build_scaling 用同一个叶子 x(16 个 f64)反复做 acc = acc * x + x,每 400 个运算记一次平均每个运算的建图时间(不编译、不执行;负载约 4):

leaf: from_tensor_symbolic_shape
ops    1.. 400:   56.9µs per op (total   22.8ms)
ops  401.. 800:  172.4µs per op (total   91.7ms)
ops  801..1200:  292.6µs per op (total  208.8ms)
ops 1201..1600:  421.6µs per op (total  377.4ms)
leaf: from_tensor_concrete_shape
ops    1.. 400:    1.2µs per op (total  494.2µs)
ops  401.. 800:    1.2µs per op (total  989.8µs)
ops  801..1200:    1.2µs per op (total    1.5ms)
ops 1201..1600:    1.1µs per op (total    1.9ms)

具体形状的叶子每个运算约 1.2 µs,和链有多长无关。符号叶子每个运算的时间随链长线性增长,建 1600 个运算的总时间是平方级的 377 ms。两条路径的差别在输出元数据:具体形状时 shape_hint 就是答案,直接登记;符号形状时 shape_hint 是 None,走 register_scoped_graph_metadata 现场推断输出的 TensorMeta。用 macOS 的 sample 对一条 3200 个运算的符号链采样 2 秒:最热的帧是 tenferro_ops::sym_dim::RawSymDim 的 clone 和 drop(RawSymDim 是 Box 嵌套的表达式树,Add、Max 等变体各持两个 Box<RawSymDim>),其次是 malloc / free,调用链经过 register_single_output_metadata → register_scoped_graph_metadata → register_scoped_graph_analysis,drop 的调用栈嵌套了十几层。每步的时间随链长增长,说明每步克隆的表达式随链长变大;具体是哪一处让表达式变大,本页没有再追。长链式的符号程序(例如几百步的迭代)要注意这一点。

动脑筋答案

== 5. symbolic + concrete
a + c4: ok
  sym_shape = None
  compile: ok
  defaults (a has 3 elements): run Err: add: incompatible shapes: lhs=[3], rhs=[4]

建图、编译、prepare 都通过,run 时才报错。a 不是具体形状、两边秩都是 1,broadcast_binary 直接放行;编译器把 a 的长度当作 InputDim,c4 的当作 Const(4),推断不出矛盾;prepare 对逐元素运算只看 dtype 和秩。执行时 a 的默认数据是 3 个元素,CPU 的加法内核发现 [3] 和 [4] 对不上。换成两个具体叶子,同样的错误在 + 那一行就出现了。

小结

  • TracedTensor 是「某张图里的某个值」加上叶子数据、默认输入表和元数据作用域;全部在 Arc 里,不可变,clone 便宜。
  • 每个运算新建一张单节点图,把操作数的图挂成父图;形状和 dtype 登记在以 ValueKey 为键的全局元数据表里。
  • 运算返回 Result:形状校验、dtype 推断和全局表加锁都可能失败;v0.3.0 之前登记失败是 panic。
  • 形状错误在知道形状的那一步报:具体叶子在建图时,compile_with_input_specs 给的形状在编译时,符号叶子在 run 时由内核报;符号叶子在秩相同时一律放行、不广播。
  • compile_with_input_specs 会把形状定死,并且从 0.8.0 起拒绝输出用不到的占位符。
  • 想一个程序跑多种长度,用 from_tensor_symbolic_shape;但符号叶子的长链建图实测是平方级的,时间花在克隆和释放 RawSymDim 表达式树上。

💬 关联讨论

发起讨论
加载中...