traced AD
主线位置:traced 主线的中段。TracedTensor 上的运算只记图;本页从
y.grad(&x)讲起:输出怎么变成一个语义程序,变换怎么按 primitive 规则 生成导数程序,导数程序怎么重新包成一个TracedTensor,交给 编译 和 执行。整条主线连起来走一遍见 一次 grad。
sequenceDiagram participant U as 用户代码 participant E as TracedTensorAdExt participant G as GraphCompiler participant S as semantic_vjp participant D as 导数 TracedTensor U->>E: loss.grad(x) E->>E: 种子 = 标量 1 E->>G: compile_ad_source(loss) G-->>E: FrozenProgram E->>S: active_inputs 掩码 S-->>E: SemanticAdProgram E->>D: 转回 computegraph 图 D-->>U: dx 只是图 U->>G: compile(dx) G-->>U: CompiledGraph U->>U: prepare 再 run
代码在 tenferro-ad 的 traced.rs(入口和包装)和 semantic_transform.rs(变换本身)。traced 这一侧在 0.7.1 到 0.8.0 之间改动很小(erf 的规则、maximum / clamp 掩码零的修复、无关 wrt 的错误类型,以及把图求值成叶子的 checkpoint 要显式传 engine):变换不碰 engine,engine 只在最后执行导数程序时才出现(runtime.prepare(&program, &[], &[engine.target()]) 再 runtime.run(&mut [&mut engine], ..),见 执行)。实验在 exp-tenferro8/ad-exp:traced_ad、ad_errors(debug)、traced_cache(release),CPU,CpuEngine::serial(),M2 Max、macOS 27、rustc 1.99.0。
两个入口,同一个变换
用户能调的有两组:扩展 trait TracedTensorAdExt(loss.grad(&x)、y.vjp(&x, &ct)、y.jvp(&x, &dx) 和各自的 _optional 版本),以及 AdContext 上同名的方法,外加 vjp_many / jvp_many。两组最后都进同一个函数,差别只在传进去的规则集和缓存:
impl TracedTensorAdExt for TracedTensor {
fn grad(&self, wrt: &TracedTensor) -> Result<TracedTensor> {
let rules = SemanticExtensionRuleSet::default();
grad_with_optional_rules(self, wrt, &rules, None)
}
// ...
}
扩展 trait 每次新建一个空规则集,缓存传 None。空规则集意味着它只会对核心算子求导,碰到线性代数、FFT 这些扩展运算族就报 UnsupportedAdRule(见 semantic AD 与扩展);sigmoid、softmax 这类组合运算不受影响,它们在图里已经是核心算子(见 primitive AD 规则)。None 意味着同一个结构的图每次都要重新变换(本页最后一节量了它的代价)。
grad 本身就是种子为 1 的 vjp,grad_with_optional_rules:
if output.rank != 0 {
return Err(Error::NonScalarGrad {
shape: error_shape_hint(output),
});
}
let ones = ones_tensor(output.dtype, vec![])?;
let seed = TracedTensor::from_tensor_concrete_shape(ones)?;
let wrt_input_key = leaf_input_key(wrt)?;
vjp_many_with_transform_and_cache(output, &[wrt], &seed, rules, "grad", ad_transform_cache)?
.pop()
.flatten()
.ok_or_else(|| inactive_wrt_error("grad", &wrt_input_key))
三个前置条件对应三种错误。实验 ad_errors:
traced grad wrt intermediate: Err(Validation { op: "ad_support::leaf_input_key", phase: GraphBuild, source: InvalidArgument { argument: "tensor", message: "expected traced leaf input, got Derived { operation: OperationKey { operation: Mul, inputs: [Input(User { id: 0 }), Input(User { id: 0 })], role: Primary, fingerprint: 8852925308592293207 }, output_slot: 0 }" } })
traced grad of non-scalar: Err(NonScalarGrad { shape: [3] })
traced grad, unrelated leaf: Err(Validation { op: "grad", phase: GraphBuild, source: InvalidArgument { argument: "wrt", message: "grad is inactive for User { id: 2 }; use grad_optional to observe the inactive state" } })
traced grad_optional: Ok(false)
wrt 必须是叶子(图的输入),对中间结果 h = x * x 求导在建图阶段就被拒绝;输出必须是标量;wrt 和输出无关时,grad 报 inactive_wrt_error 造的 Validation 错误,grad_optional 返回 None。最后一种在 0.7.1 是 Error::Internal("grad output is inactive for User { id: 2 }"),按 Internal 去 match 的代码升级后会落空(修复见 primitive AD 规则 里 #1961 那个框)。
输出先编成程序,再变换
vjp_many_optional_impl 是反向模式的主体:
let requested_keys = wrts
.iter()
.map(|wrt| leaf_input_key(wrt))
.collect::<Result<Vec<_>>>()?;
let mut compiler = GraphCompiler::new();
let source = compile_ad_source(&mut compiler, output)?;
let source_indices: Vec<_> = requested_keys
.iter()
.map(|key| source.input_key_index(key))
.collect();
let mut active_inputs = vec![false; source.input_count()];
for source_index in source_indices.iter().flatten().copied() {
active_inputs[source_index] = true;
}
if !active_inputs.iter().any(|active| *active) {
return Ok(vec![None; wrts.len()]);
}
let active_outputs = vec![true; source.output_count()];
let derivative = semantic_vjp_with_cache(
source.frozen_program(),
&active_inputs,
&active_outputs,
rules,
ad_transform_cache,
)?;
变换不直接在 computegraph 的图上做。它先用一个新的 GraphCompiler 把输出连同它依赖的整张图编成 CompiledGraph,取出里面的 FrozenProgram:一个 SSA 形式的语义程序,输入有序,每个值带 dtype 和形状元数据,叶子的数据作为绑定(bindings)挂在程序上。wrt 叶子的输入键映射成程序输入的下标,拼成活跃掩码 active_inputs。一个叶子不在程序里(和输出无关)时,它的下标是 None,对应的结果也是 None。
semantic_vjp:倒着走一遍程序
semantic_vjp_with_saved_outputs 先把源程序原样导入一个新的 SemanticProgramBuilder,再给每个活跃输出加一个余切种子输入,然后倒序遍历源程序的算子:
for operation in operations.into_iter().rev() {
let cotangent_outputs: Vec<_> = operation
.outputs()
.iter()
.map(|value| {
cotangents
.get(value)
.copied()
.map_or(AdValue::Absent, AdValue::Value)
})
.collect();
if cotangent_outputs
.iter()
.all(|value| matches!(value, AdValue::Absent))
{
continue;
}
let active_operation_inputs: Vec<_> = operation
.inputs()
.iter()
.map(|value| forward_active.contains(value))
.collect();
if active_operation_inputs.iter().all(|active| !active) {
continue;
}
let cotangent_inputs = match operation.op() {
SemanticOpRef::Extension(op) => {
// ... 扩展运算族:查规则集
}
SemanticOpRef::Core(op) => vjp_core(
op,
&mapped_values(operation.inputs(), &values),
&mapped_values(operation.outputs(), &values),
&cotangent_outputs,
&active_operation_inputs,
&mut builder,
)?,
// ...
};
for (source, cotangent) in operation.inputs().iter().copied().zip(cotangent_inputs) {
if let AdValue::Value(cotangent) = cotangent {
accumulate_cotangent(&mut builder, &mut cotangents, source, cotangent)?;
}
}
}
- 两次剪枝。 输出没有余切的算子跳过(它不通向被求导的输出);输入都不「前向活跃」的算子也跳过(它不依赖任何
wrt)。forward_active由requested_input_reachability从活跃输入顺着程序正向传一遍得到。 - 程序是 SSA,没有拓扑排序。 语义程序的算子本来就按定义先后排列,倒序遍历就是逆拓扑序,不需要 candle 那样先做一遍拓扑排序(见 backward 与 GradStore)。
- 累加也是程序里的节点。 一个值被用了几次,
accumulate_cotangent就往程序里加几个CoreSemanticOp::Add,执行时才真的相加。
最后 finish_derivative 只把活跃输入的余切设为输出,再做两遍清理:prune_dead_derivative_operations 从输入和输出重新导入一次,没被输出用到的算子(比如前向最后那个 ReduceSum)就丢掉;cancel_double_neg_derivative_operations 把相邻的两个 Neg 抵消。返回的 SemanticAdProgram 记下两张映射:源程序第几个输出的种子在导数程序的第几个输入,源程序第几个输入的余切在导数程序的第几个输出。
再包成 TracedTensor
导数程序还不能直接返回给用户,用户手里要的是一个能继续参与运算、能再求导的 TracedTensor。derivative_traces_from_frozen_program 把语义程序逐个算子搬回一张 computegraph 图,关键在输入键:
for (input_index, input) in frozen.program.inputs().iter().copied().enumerate() {
let key = if input_index < source.input_keys().len() {
source.input_keys()[input_index].clone()
} else {
allocate_input_key()
};
let local = builder.add_input(key.clone());
value_map.insert(input, local);
input_keys.push(key);
}
前面那些输入(源程序的 primal 输入)沿用原叶子的输入键,只有种子拿新键。种子的数据和叶子的数据一起放进 inputs_map。所以导数 dx 和 x 在同一个键空间里:对 dx 再求一次关于 x 的导数时,leaf_input_key(x) 仍然能在 dx 的图里找到对应的输入。高阶导数就是这么来的。返回的 TracedTensor 的 data 是 None:它只是图,求值要自己 compile,再 prepare、run。
导数程序有多大
实验 traced_ad 第 1 段,L(A, B) = sum((A·B) ⊙ (A·B)),A 是 2×3,B 是 3×2,打印各个程序编译后的输入数和算子序列:
== 1. program sizes
L inputs=2 ops= 3: DotGeneral Mul ReduceSum
dL/dA inputs=3 ops= 5: BroadcastInDim DotGeneral Mul Add DotGeneral
vjp_many A inputs=3 ops= 5: BroadcastInDim DotGeneral Mul Add DotGeneral
compile_many(dA, dB) ops=6: BroadcastInDim DotGeneral Mul Add DotGeneral DotGeneral
raw vjp_program(A) inputs=3 ops=6: DotGeneral BroadcastInDim Mul Mul Add DotGeneral
- 导数程序自带前向。
dL/dA里第一个DotGeneral重算C = A·B:Mul的反向要用C,而导数程序是独立执行的,前向的结果不会被保存下来给它用。输入从 2 个变成 3 个,多出来的是种子。 - 变换的原始输出比编译后多一个算子。
raw vjp_program是直接调AdContext::vjp_program拿到的变换结果,C ⊙ C的两个操作数是同一个值,vjp_core给两边各发一个Mul(ct, C),再Add;两个Mul完全相同,编译后合成了一个。 - 两个梯度一起编译时共用前向。
dA和dB分开编译各 5 个算子,compile_many一起编译只有 6 个:重算的C、广播的种子、Mul、Add都只算一次,多出来的只是B那一边的DotGeneral。
这和 eager AD 不同:eager 的前向已经执行过,反向程序会把前向留下的残差当输入,而不是重算。
jvp:正着走一遍
前向模式在 semantic_jvp 里,结构和反向对称:给每个活跃输入加一个切向量输入,正序遍历,核心算子走 linearize_core,扩展算子走规则集的 linearize_operation。入口 jvp_many_optional_impl 有两条反向没有的检查:
if !seen.insert(key.clone()) {
return Err(Error::invalid_argument(
"jvp",
ErrorPhase::GraphBuild,
"wrt_tangents",
"wrt_tangents contains duplicate wrt leaves",
));
}
let tangent_data = tangent.attached_value().cloned().ok_or_else(|| {
Error::invalid_argument(
"jvp",
ErrorPhase::GraphBuild,
"tangent",
"jvp tangent must have concrete tensor data",
)
})?;
jvp_many 把所有方向的贡献加成一个方向导数,同一个叶子给两个切向量有歧义,直接拒绝;切向量必须带数据(反向的余切种子也一样,vjp_many_optional_impl 开头就查「vjp cotangent must have concrete tensor data」),因为它会作为绑定挂进导数图的 inputs_map,不能是一个等运行时再喂的占位输入。实验第 3 段的方向导数和中心差分、和 ⟨grad, dA⟩ 都对得上(数字见 primitive AD 规则)。
多个输入:一次变换
AdContext::vjp_many(&l, &[&a, &b], &ct) 对两个叶子一次求出两个余切。上游的设计文档 multi-input-traced-ad.md 写的要求是「Compile output once」「Call semantic_jvp_with_cache or semantic_vjp_with_cache exactly once」,代码就是上面那段:所有 wrt 合成一个 active_inputs 掩码,变换一次。重复的 wrt 在结果里重复出现(output_slots 去重后再展开),无关的叶子是 None。实验里 vjp_many 的 A 那一份和单独 grad 的结果逐位相同,编译出的程序也一样(上面的 vjp_many A 一行)。
单独对 a、b 各调一次 grad,会编译两次源程序、变换两次;vjp_many 编译一次、变换一次,再把两个导数一起 compile_many,前向重算也只做一次。
高阶导数:jvp(grad(f))
因为导数仍是 TracedTensor,而且沿用原叶子的输入键,高阶导数就是变换的组合。上游测试 hvp.rs 主要用前向套反向(forward-over-reverse):先 grad 再 jvp,得到 Hessian-向量积(HVP),和 JAX 的标准写法一样。实验第 4、5 段:
// f(x) = sum(tanh(M.x)); x is 3x1
let f = f_tanh(&m, &x)?;
let g = f.grad(&x)?;
let hv = g.jvp(&x, &v)?;
// 对照:在 x ± 1e-5·v 处各求一次梯度,中心差分
let cube = (&(&s * &s)? * &s)?; // s = 2.5
let d1 = cube.grad(&s)?;
let d2 = d1.grad(&s)?;
let d3 = d2.grad(&s)?;
f inputs=2 ops= 3: DotGeneral Tanh ReduceSum
grad f inputs=3 ops= 9: BroadcastInDim Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul DotGeneral
hvp = [-0.9930593881003207, 1.1987671592011429, -0.2074128493294373]
hvp_fd = [-0.993059387921491, 1.1987671584723358, -0.20741284927350276]
max |hvp - fd| = 7.288e-10
== 5. second derivative of x^3 at 2.5 by grad(grad)
f' = [18.75], f'' = [15.0], f''' = [6.0]
x^3 inputs=1 ops= 2: Mul Mul
f' inputs=2 ops= 6: Mul Mul Mul Mul Add Add
f'' inputs=3 ops=10: Add Mul Mul Mul Mul Add Mul Mul Add Add
f''' inputs=3 ops=10: Add Mul Mul Mul Add Mul Add Mul Mul Add
HVP 与梯度的有限差分差 7.3e-10。grad f 里的 Constant 和 Sub 来自 tanh 的反向系数 1 - tanh²(vjp_core 里 Tanh 一支用 one_like 造常数 1)。grad 套三次得到 x³ 在 2.5 处的三阶导数 6,二阶导数 15 = 6 × 2.5。在这个例子里程序没有随阶数变大:f'' 和 f''' 都是 10 个算子。eager 那边同样能这样组合,见 eager AD。
hv 编译出来有几个输入?比 grad f 多几个算子?grad f 有 3 个输入(M、x、种子)、9 个算子。想一想 jvp 给程序加了什么输入,tanh 的反向系数 1 - tanh² 再线性化一次会多出什么。
变换的代价和缓存
TracedTensorAdExt 不缓存,AdContext 有一个有界的变换缓存(默认上限 128 条、64 MiB,transform_cache.rs)。缓存键是 SemanticAdTransformCacheKey:
pub(crate) struct SemanticAdTransformCacheKey {
kind: SemanticAdTransformKind,
input_fingerprint: SemanticFingerprint,
input_metadata: Box<[ProgramValueMetadata]>,
active_inputs: Box<[bool]>,
active_outputs: Box<[bool]>,
}
键里只有程序的结构指纹、输入元数据和两张掩码,没有数据,也没有 engine:变换的结果与在哪台设备上执行无关。命中时 with_input_prefix_bindings_from(source) 把这一次的叶子数据重新绑上去。所以数据变了、结构没变的图(训练循环里每一步都是这种)能命中。实验 traced_cache(release)每轮新建一个结构相同、数据不同的图:32×32 的 W,五层 matmul + tanh,平方和;每轮先调 loss.grad(&w),再调 ad.grad(&loss, &w),再编译后者;50 轮。连跑三次,负载均值约 7,三次的中位数相差不到 3 µs,下面是第三次:
TracedTensorAdExt::grad first 370.8us later min 156.0us median 175.3us
AdContext::grad first 261.7us later min 77.5us median 86.0us
compile(grad) first 95.0us later min 67.7us median 75.5us
AdContextCacheStats { ad_transforms: CacheStats { entries: 1, retained_bytes: 1155, hits: 49, misses: 1, evictions: 0, clears: 0 } }
第一轮两者都明显更慢,含首次运行的开销(三次里第一轮的 TracedTensorAdExt::grad 在 344 到 795 µs 之间跳动)。之后两者的差就是变换本身:不缓存的中位数约 175 µs,命中缓存约 86 µs。命中时仍要做两步:compile_ad_source 把源图编成程序(拿指纹当键),以及把导数程序转回 TracedTensor。49 次命中、1 次未命中,缓存只占一条、1155 字节:缓存的是程序,不是张量。这些时间都在建图阶段,和执行无关;执行的开销见 执行。
PR #1312(「Finish AD transform cache and DCE」,2026-07-06 合并,首次发布于 v0.3.0)之前,每次 grad / vjp / jvp 都从头做一遍变换。这个 PR 的几个提交依次是「Add shared AD transform cache API」「Share AD transform cache with eager runtimes」「Cache traced AD transforms through AdContext」「Prune unused multi-output AD operation slots」。缓存的所有者定成了 AdContext,而不是一个全局表:上游 ad-pipeline.md 的「Cache Ownership」一节写「direct traced helper methods remain stateless unless the caller supplies an AdContext」,并要求缓存条目「must not retain tensor buffers」。当时的变换还跑在外部 AD 引擎上;三周后 PR #1453 换成语义程序变换,缓存键随之换成上面这个语义指纹,所有权的规定没变。
动脑筋答案
grad f inputs=3 ops= 9: BroadcastInDim Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul DotGeneral
hvp inputs=4 ops=14: DotGeneral Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul Mul Add Neg BroadcastInDim Mul DotGeneral
- 4 个输入:
M、x、grad的种子,加上jvp的切向量v。种子在grad时作为绑定进了inputs_map,到 HVP 里仍是输入,不会被折成常量。 - 多 5 个算子:
DotGeneral从 2 个变成 3 个,多的是M·v,即M·x对x的线性化;Mul从 2 个变成 4 个,又多了一个Add和一个Neg,它们来自系数1 - tanh²的线性化-(dt·t + t·dt)(两个相同的Mul编译后合成一个)。原grad f里「种子 × 系数」的那个Mul只服务于一阶梯度,HVP 用不到,被剪掉。前向的M·x、tanh只算一次,被两条链共用。 - 反向套反向也能求 HVP:上游
hvp.rs的hvp_ror_quadratic_form写的是grad(dot(grad, v), x),和前向套反向的结果对照。
小结
TracedTensorAdExt和AdContext走同一个变换:前者用空规则集、不缓存;后者带运算族规则和有界缓存。grad是种子为 1 的vjp,要求输出是标量、wrt是叶子;wrt与输出无关时报Validation(0.7.1 是Internal)。- 变换的对象是语义程序:先
compile_ad_source把输出编成FrozenProgram,wrt变成活跃掩码,semantic_vjp倒序遍历 SSA 程序、累加也写成Add节点,最后剪掉死代码、抵消双重Neg。变换不碰 engine。 - 导数程序被搬回 computegraph 图,沿用原叶子的输入键,所以导数还能再求导;
jvp(grad(f))的 HVP 与有限差分差 7.3e-10,grad套三次得到三阶导数。 - 导数程序是独立执行的,自带前向重算;多个梯度用
vjp_many一次变换、compile_many一起编译,前向只算一次。 - 变换缓存按结构指纹和掩码做键,不含数据和 engine;结构相同的图命中后变换从约 175 µs 降到约 86 µs。