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()。
一个值,加上它需要的上下文
#[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 也一样。失败有三个来源,都在建图这一步:
- 形状校验。 广播、轴号、置换、reshape 的元素数、切片和
dynamic_slice的窗口,能在建图时查的都查。错误带阶段标签GraphBuild(ErrorPhase的三个值是GraphBuild、Compile、Execution)。 - dtype 推断。 例如对不支持的 dtype 求输出类型失败。
- 全局元数据表。 登记要拿一把全局锁,锁中毒时返回
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 这一行就失败了,而不是等到编译或执行。
-x 以前不返回 ResultPR #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]) | 建图 | GraphBuild | TracedTensor::slice |
input_symbolic_shape + compile_with_input_specs(.., &[3]) | 编译 | Compile | shape_infer |
from_tensor_symbolic_shape(3 个元素) | 执行 | 运行时 | CPU 的 slice 内核 |
符号叶子的长度从来不进入编译器(它是 InputDim),所以只能由内核发现。
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 的构造函数上)。
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表达式树上。