eager AD
主线位置:eager 主线全程。
EagerRuntime::new()加一个调用方持有的CpuEngine→variable_from建可求导变量 →with_eager_session(&mut engine, |s| ...)里每个运算立即执行并记录 →loss.backward(&mut engine)→x.grad()/clear_grad()。和 traced AD 不同,前向已经算完了;但反向用的是同一个语义程序变换和同一组 primitive 规则。
sequenceDiagram participant U as 用户代码 participant R as EagerRuntime participant S as EagerSession participant V as semantic VJP U->>R: variable_from(t) R-->>U: x 叶子 带梯度槽 U->>R: with_eager_session(engine) R->>S: 借出 engine U->>S: s.mul(x, c) S->>S: 立即执行 追加 trace 留残差 S-->>U: 结果 EagerTensor U->>V: loss.backward(engine) V->>R: 取活着的梯度槽 V->>V: 一次变换 prepare run V->>R: store_grads 累加 U->>R: x.grad()
代码在 tenferro-ad:eager.rs(EagerRuntime、EagerSession、EagerTensor、反向)、eager_ops.rs(运算和记录)、eager/residuals.rs(残差)、eager/composite.rs(激活、softmax、归一化)。eager_exec.rs 负责把一个算子落到 engine 上执行(含 dtype 提升),和记录无关,本页不展开。实验在 exp-tenferro8/ad-exp:eager_ad、ad_errors、nn_ad(debug)和 eager_loop(release),CPU,CpuEngine::serial()(单线程),M2 Max、macOS 27、rustc 1.99.0。
EagerRuntime:只剩 AD 的状态
EagerRuntime 是所有 eager 张量共享的上下文:
pub struct EagerRuntime {
id: ContextId,
/// Prepared-program runtime. Engine kinds are registered on first use;
/// the runtime never stores an engine.
runtime: Runtime,
/// Serializes runtime reconfiguration (engine-kind registration and
/// extension-module installation).
extension_install_lock: Mutex<()>,
semantic_extension_rules: SemanticExtensionRuleSet,
grad_slots: Mutex<HashMap<ValueKey<StdTensorOp>, WeakGradSlot>>,
value_records: Mutex<HashMap<ValueKey<StdTensorOp>, Weak<EagerTensorRecord>>>,
ad_transform_cache: Arc<AdTransformCache>,
/// S2: prepared derivative programs keyed by semantic structure, wrt input,
/// and concrete bound input metadata. Avoids re-running freeze+AD
/// transform+compile_frozen on warm structure hits.
prepared_derivative_cache: Mutex<PreparedDerivativeCache>,
}
- 没有 engine 字段。
runtime是一个Runtime,用来prepare/run反向的导数程序;但它只登记 engine 的「种类」。第一次拿某种 engine 进来时,ensure_engine按engine.target()查表,没有就reconfigure把这种 engine 注册进去;同一个 id 已经注册成别的 engine 类型时报Unsupported。engine 本身(CPU 的线程池、缓冲池,见 CPU engine)由调用方持有,每次用&mut借进来。 semantic_extension_rules:运算族的求导规则。new()给的是空集,要对线性代数、FFT 求导,得用with_ad_context(&ad)传进一个带规则的AdContext(见 semantic AD 与扩展)。grad_slots和value_records:两张以值键为键的表,值都是Weak。运行时不拥有张量,张量拥有自己的记录,运行时只是能找到它们。- 两个缓存:
ad_transform_cache缓存变换(和AdContext是同一种),prepared_derivative_cache缓存编译好、准备好的导数程序。
PR #2209(2026-10-20 合并,首次发布于 v0.8.0)之前,EagerRuntime 有一个字段 backend: Mutex<EagerBackend>,new() 就是 Self::with_cpu_backend(CpuBackend::new()):运行时自己建一个 CPU 后端,每个运算先锁住它再执行,另有 with_cpu_backend_and_ad_context 这类按后端区分的构造函数。PR 里的提交 e9906dd8 写「EagerRuntime keeps only prepared/AD state (runtime, grad slots, value records, AD transform and prepared-derivative caches). Engine kinds are registered with the runtime on first use; every execution API takes the caller's engine」,删掉的清单里有 EagerBackend、lock_backend and its reentry checks、「the backend-specific constructors」。release notes 给的收益是单线程下每个运算的固定开销降了 15% 到 40%(「Caller-owned engines remove the session entry from every operation」)。现在 with_ad_context 取代了 with_cpu_backend_and_ad_context,engine 由调用方建、调用方借。
with_eager_session:一次借一个 engine
运算不再是 EagerTensor 上的方法,而是 EagerSession 上的方法。session 只能从 with_eager_session 的回调里拿到:
pub fn with_eager_session<T, E: From<Error>>(
self: &Arc<Self>,
engine: &mut dyn ExecutionEngine,
f: impl FnOnce(&mut EagerSession<'_>) -> std::result::Result<T, E>,
) -> std::result::Result<T, E> {
self.ensure_engine(engine).map_err(E::from)?;
f(&mut EagerSession {
runtime: self,
engine,
})
}
EagerSession<'a> 里就两样东西:&'a Arc<EagerRuntime> 和 &'a mut dyn ExecutionEngine。借用的生命周期把 session 关在回调里,上游在 EagerSession 的文档里放了一个 compile_fail 例子,证明 |session| session 把它带出去编译不过。一次 with_eager_session 就是一段「这个 runtime 在这个 engine 上跑」的边界;同一个 runtime 换一个 engine 再开一段完全可以,下面的实验就用两个 engine 对同一个 loss 做了反向。
回调的错误类型是泛型 E: From<tenferro_ad::Error>,engine 注册失败也以 E::from(..) 的形式出来,所以外面只要一个 ?。这在混用运算族时会碰到类型推断的坑。tenferro_ad::Error 就是 tenferro_runtime::Error;einsum 的 s.einsum(..) 返回的是 tenferro_einsum::Error。回调里先 einsum 再 reduce_sum、最后一个表达式返回 reduce_sum 的结果时,E 被推成 tenferro_runtime::Error,einsum 那个 ? 转不过去(ad-exp 的 semantic_ad,debug):
error[E0277]: `?` couldn't convert the error to `tenferro_runtime::Error`
--> src/bin/semantic_ad.rs:88:56
|
88 | let out = s.einsum(&[&a, &b, &c], "ij,jk,kl->il")?;
| -------------------------------------^ the trait `From<tenferro_einsum::Error>` is not implemented for `tenferro_runtime::Error`
| |
| this can't be annotated with `?` because it has type `Result<_, tenferro_einsum::Error>`
转换只有一个方向:tenferro_einsum::Error 有 Runtime(#[from] tenferro_runtime::Error),反过来没有。把回调的错误类型定成较宽的那个就行,例如最后一行写 Ok::<_, tenferro_einsum::Error>(s.reduce_sum(&out, None)?)。上游迁移指南给的写法是 Ok::<_, tenferro_ad::Error>(..),那只对只用核心运算的回调成立。
传进来的张量必须属于这个 runtime。实验 ad_errors 在一个 runtime 的 session 里用另一个 runtime 的变量:
mixing runtimes: Err(ContextMismatch { lhs: ContextId(1), rhs: ContextId(2) })
叶子:variable_from 与 constant_from
EagerRuntime::variable_from 和 constant_from 都调 new_leaf,差一个 requires_grad。它们不拿 engine:主机上自有存储的张量直接在主机上物化成叶子;需要 engine 才能物化的输入(视图、设备上的张量)会被拒绝,提示改用 session 上的同名方法 s.variable_from(..) / s.constant_from(..)。每个 eager 叶子同时是一个 traced 叶子:semantic_trace 是一个带数据、形状记为符号的 TracedTensor。requires_grad 为真时,from_parts 新建一个梯度槽 Arc<Mutex<Option<..>>>,把它的 Weak 登记进 grad_slots。
每个运算:立即执行,顺手记录
nary_op_in_session 是 s.mul、s.exp、s.reduce_sum 等核心运算的共同入口。先判断要不要记录:
let any_requires_grad = profile_eager_op_section("nary_op.requires_grad_scan", || {
eager_grad_recording_enabled()
&& (eager_capture_active() || tensors.iter().any(|tensor| tensor.requires_grad))
});
if !any_requires_grad {
// ... 在借来的 engine 上执行,返回一个不带 trace 的结果
return result;
}
没有一个输入要梯度(或者在 no_grad() 守卫里),就只执行、不记录,结果不带任何 trace。否则执行之后调 record_eager_outputs_in_session,结果带两样东西:
- semantic trace。
record_semantic_eager_outputs把同一个算子追加到输入的TracedTensor后面,只追加节点、不做形状分析,分析推迟到第一次求导。不带 trace 的输入(比如常数)在这里临时补成一个常量叶子;执行时做过 dtype 提升的,trace 里补一个cast节点。于是每个被跟踪的 eager 结果都挂着一张 traced 图,它就是这个值的计算过程。 - 残差节点。
EagerTrace是一个Arc<ResidualNode>,记下父节点和这一步要留的值。finish_residuals按算子的ResidualSpec挑:
let mask = match op {
StdTensorOp::Extension(op) => {
let rules = &first.ctx.semantic_extension_rules;
if let Some(rule) = rules.lookup_primal_vjp(op.family_id()) {
rule.residual_mask()
} else if let Some(rule) = rules.lookup_linear_transpose(op.family_id()) {
rule.residual_mask()
} else {
ResidualSpec::none()
}
}
_ => crate::semantic_transform::eager_core_residual_spec(op),
};
// ... 对每个被声明的输入 / 输出:
// Leaf values are already bound by the semantic source.
if semantic.input_key().is_some() {
continue;
}
saved.push(SavedValue {
trace: semantic.clone(),
value: Arc::clone(&tensor._record.value),
});
留下的是前向已经算好的张量(Arc 共享,不拷贝)。叶子不用留,它们的数据已经绑在 trace 的输入上。核心算子的掩码来自 primitive 规则表,exp / tanh 改为留输出。finish_residuals 上面的注释说明了只共享张量值、不共享 EagerTensor 记录的原因:记录拥有运行时和梯度槽;只共享值,残差在调用方丢掉中间结果之后仍然活着,也不用拷贝缓冲区。
被跟踪的中间结果自己也登记梯度槽。实验 eager_ad 第 1 段,x 是变量,c 是常数,在一个 session 里做 h = x*c、p = h*x、loss = sum(p):
== 1. leaves
x.tracks_grad=true c.tracks_grad=false grad_slots_len=1 value_records_len=2
h.tracks_grad=true after 3 ops: grad_slots_len=4 value_records_len=5
三次运算之后槽位从 1 个变成 4 个:h、p、loss 的 requires_grad 都是真(它们依赖 x),各有一个槽,只是反向不会往里写(见下)。
组合运算:不跟踪时才走融合内核
s.sigmoid、s.gelu、s.softmax 这些组合运算(见 primitive AD 规则)在 eager 上多一道判断。fused_activation 的注释:「Returns Ok(None) when the operands are tracked, semantic capture is active, or the backend declines the fused form; the caller then records the shared composite formulation instead.」判断用的是 untracked_fast_path_allowed,和 nary_op_in_session 的条件一样:
pub(crate) fn untracked_fast_path_allowed(tensors: &[&EagerTensor]) -> bool {
!eager_grad_recording_enabled()
|| (!eager_capture_active() && !tensors.iter().any(|tensor| tensor.requires_grad))
}
不跟踪时,engine 可以用一个融合内核一次算完(CPU 的融合内核见 神经网络运算),结果是一个没有 trace 的张量;跟踪时一律按 composite.rs 的分解逐个执行、逐个记录,反向才有 primitive 可套。实验 nn_ad 第 3 段对同一个输入各算一次:
gelu untracked=[-0.10021080190328704, -0.0841480581121794, 0.0, 0.6305156811332827]
gelu tracked =[-0.10021080190328704, -0.0841480581121794, 0.0, 0.6305156811332827]
max diff=0.0e0
这组输入上两条路的结果逐位相同。融合内核只是前向的捷径,它没有对应的 AD 规则;要梯度就得付分解的代价。
backward:一次变换,累加进槽位
backward 要求输出是标量,用借来的 engine 造一个同形状的 1 作种子,然后进 backward_from_seed:
let candidate_keys = {
let mut slots = self.ctx.lock_grad_slots()?;
let mut keys = Vec::new();
slots.retain(|key, slot| {
if slot.upgrade().is_some() {
keys.push(key.clone());
true
} else {
false
}
});
keys
};
// ... 每个候选键找回它的 EagerTensor
let wrts = targets.iter().map(|(_, wrt)| wrt).collect::<Vec<_>>();
let gradients = semantic_eager_vjp_many(&self.ctx, engine, self, &wrts, &cotangent)?;
// ... 把每个 Some(grad) 取成张量放进 cotangents
self.ctx.store_grads(&cotangents, session)?;
候选不是「从 loss 能走到的变量」,而是运行时里所有还活着的梯度槽;顺手把已经死掉的槽从表里清掉。所有候选一起交给 semantic_eager_vjp_many,它做的事和 traced 的 vjp_many 一样,多了几处 eager 特有的安排:
let output_trace = analyze_deferred_semantic_trace(raw_output_trace)?;
let saved = output
.trace
.as_ref()
.map(EagerTrace::collect)
.unwrap_or_default();
let mut source_outputs = vec![&output_trace];
source_outputs.extend(saved.iter().map(|value| &value.trace));
// Residual roots keep the semantic producers available to differentiation;
// only the execution-only derivative replaces their numerical values.
let mut compiler = GraphCompiler::new();
let source =
tenferro_runtime::ad_support::compile_ad_source_many(&mut compiler, &source_outputs)?;
- 只有叶子能拿到梯度。 候选在程序里的位置靠
semantic_trace.input_key()找,中间结果的 trace 不是程序输入,input_key()是None,它们的槽永远不会被写。 - 前向不重算。
EagerTrace::collect沿父节点收集所有残差,作为额外的输出根一起编译。导数程序有两份:一份保留残差的生产者,结果挂在返回的梯度上,供高阶导数用;另一份「execution-only」由semantic_vjp_with_saved_outputs生成,把残差当作输入,执行时直接喂前向留下的张量。traced 的导数程序自带前向重算(见 traced AD),eager 这里省掉了。 - 准备好的程序缓存,按 engine 目标分。 缓存键
PreparedDerivativeCacheKey的第一个字段是target: EngineTarget(注释:「Engines with the same target share prepared derivative programs.」),然后是源程序的语义指纹、运行时的配置版本(epoch)、活跃掩码、带具体形状的输入元数据;命中时跳过变换、编译和准备,直接在借来的 engine 上run。
最后 store_grads 写槽位:槽里已有值就在 engine 上 add_read 相加,没有就放一份连续的拷贝。反向不消耗任何东西:trace、残差都还挂在 loss 上。
semantic_eager_vjp_many 开头还有一个开关:环境变量 TENFERRO_EAGER_SEMANTIC_VJP=0 时直接返回全 None。0.8.0 的 eager 反向只有这一条路,关掉它不会退回别的实现:用这个环境变量跑 eager_ad,backward 返回 Ok,但 Gradients.len() = 0、x.grad = None,函数式 grad 报 grad is inactive。
loss 再调一次 backward(&mut engine),中间不 clear_grad,x.grad() 是多少?再换一个新建的 CpuEngine::serial() 调一次(之前先 clear_grad),准备好的导数程序会重新准备吗?loss = sum(x * c * x),x = [1, 2, 3],c = [10, 20, 30],第一次 backward 之后 x.grad() = [20, 80, 180]。看 store_grads 遇到槽里已有值时做什么,再看缓存键里的 target。
实验第 2 段是第一次 backward 之后各个张量的梯度:
== 2. backward once
Gradients.len() = 1
x.grad = Some([20.0, 80.0, 180.0])
c.grad = None
h.grad = None
loss.grad = None
cache: CacheStats { entries: 1, retained_bytes: 1474, hits: 0, misses: 1, evictions: 0, clears: 0 }
只有 x 有梯度 2·c·x;常数 c 没有槽;中间结果 h 和 loss 有槽但没被写。返回的 Gradients 只有一项。准备好的导数程序进了缓存。
函数式的 grad、vjp、jvp
EagerRuntime 上还有一组函数式接口:grad、vjp、jvp 和 _optional 版本,第一个参数都是 engine。它们返回普通的 EagerTensor,不碰槽位。实验第 4 到 7 段:
x.clear_grad()?;
let dx = rt.grad(&mut engine, &loss, &x)?;
// ...
let s = rt.variable_from(Tensor::from_vec_col_major(vec![], vec![2.5_f64])?)?;
let cube = rt.with_eager_session(&mut engine, |ss| {
let sq = ss.mul(&s, &s)?;
ss.mul(&sq, &s)
})?;
let d1 = rt.grad(&mut engine, &cube, &s)?;
let d2 = rt.grad(&mut engine, &d1, &s)?;
let d3 = rt.grad(&mut engine, &d2, &s)?;
// ...
let jv = rt.jvp(&mut engine, &sq, &x, &t)?;
let y = {
let _guard = rt.no_grad();
rt.with_eager_session(&mut engine, |ss| ss.mul(&x, &x))?
};
let d = x.detach(&mut engine)?;
== 4. clear_grad, then functional grad
rt.grad = [20.0, 80.0, 180.0]
x.grad = None (slot untouched)
dx.tracks_grad = true
== 5. second order through the functional grad
f' = [18.75], f'' = [15.0], f''' = [6.0]
== 6. eager jvp
jvp = [-7.8]
== 7. no_grad and detach
no_grad result tracks_grad=false, detach tracks_grad=false
函数式 grad 的结果带着导数程序的 trace(tracks_grad = true),所以能再求导:x³ 在 2.5 处的二阶导数 15、三阶导数 6,和 traced 那边 grad 套三次的结果一样。jvp 也走语义程序(semantic_eager_jvp_optional),sum(x²) 在方向 [0.1, 1, -2] 上是 2·(0.1 + 2 - 6) = -7.8。no_grad() 是一个线程局部的计数守卫,守卫内的运算走 nary_op_in_session 的不记录分支;detach(&mut engine) 共享原来的值,另用 engine 拷一份做新的常量 trace,不带残差节点、不登记槽位。
误用时的报错(实验 ad_errors):
eager grad wrt intermediate: Err(Validation { op: "grad", phase: GraphBuild, source: InvalidArgument { argument: "wrt", message: "grad is inactive for Input(User { id: 7 }); use grad_optional to observe the inactive state" } })
eager backward of non-scalar: Err(NonScalarGrad { shape: [3] })
eager grad wrt constant: Err(Validation { op: "grad", phase: GraphBuild, source: InvalidArgument { argument: "wrt", message: "grad is inactive for Input(User { id: 10 }); use grad_optional to observe the inactive state" } })
eager grad_optional constant: Ok(false)
对中间结果求导,traced 在建图时就以「expected traced leaf input」拒绝,eager 则走完一遍发现它不是程序输入,和「对常数求导」报的是同一种 Validation:「wrt 与输出无关」。0.7.1 对中间结果报的是 Internal。要区分「不是叶子」和「无关」,只能自己看 tracks_grad()。
记录什么时候释放
记录全部挂在张量上:结果 EagerTensor 持有它的 EagerTrace(Arc,经父节点链住所有残差)和 semantic_trace(Arc<Graph> 链)。最后一个引用这条链的张量被丢掉,链就释放;backward 不释放它们,所以才能调第二次。这和 burn 不同:burn 的反向会消耗沿途的步骤,第二次 backward 直接 panic(见 backward 与 Gradients)。
运行时那两张表里只有 Weak,死条目的清理靠两处:clear_grads 和 backward 对 grad_slots 做 retain;两张表在插入时都走 insert_pruning_dead:
fn insert_pruning_dead<K: std::hash::Hash + Eq, V>(
map: &mut HashMap<K, Weak<V>>,
key: K,
value: Weak<V>,
) {
if map.len() == map.capacity() {
map.retain(|_, entry| entry.strong_count() > 0);
}
map.insert(key, value);
}
只在表即将扩容(长度等于容量)时扫一遍,均摊 O(1)。实验第 8 段在丢掉所有中间结果前后各看一次:
== 8. what the runtime keeps alive
before drop: grad_slots_len=14 value_records_len=16
after drop: grad_slots_len=14 value_records_len=16
after clear_grads: grad_slots_len=2 value_records_len=16
丢掉张量不会立刻让条目变少;clear_grads 之后 grad_slots 只剩两个活着的变量 x、s,value_records 要等下一次扩容前的清扫。实验 eager_loop(release)模拟训练循环:64×64 的 w,每步在一个 session 里算 y = tanh(xs·w)、loss = sum(y·y),然后 backward、clear_grad,共 1001 步。连跑五次,负载均值约 3,下面是最后一次:
step 0: grad_slots_len= 2 value_records_len= 5 forward= 275.1us backward= 550.6us
step 1: grad_slots_len= 2 value_records_len= 5 forward= 77.1us backward= 140.9us
step 10: grad_slots_len= 2 value_records_len= 5 forward= 63.0us backward= 96.5us
step 100: grad_slots_len= 2 value_records_len= 5 forward= 74.5us backward= 91.6us
step 1000: grad_slots_len= 2 value_records_len= 5 forward= 62.8us backward= 86.9us
n=64: first backward 550.6us; warm backward min 81.6us median 88.2us; forward min 58.6us median 62.3us
max value_records_len over the run = 5
CacheStats { entries: 1, retained_bytes: 1538, hits: 1000, misses: 1, evictions: 0, clears: 0 }
每一步之后都量了一次,value_records 整个过程不超过 5 条。0.7.1 在同样的循环里每步多 6 条、从不回落;修复是 PR #1944 里的提交 8a6dbc65(「sweep dead eager registry entries at the table growth point」,首次发布于 v0.8.0),说明里写旧的条目「were removed only when their own key was looked up again, so a long-running eager runtime kept one entry per value it ever created」。
同一组数据也给出了 eager 反向的开销:第一次 backward 0.55 ms(变换、编译、准备导数程序;五次里在 0.55 到 0.66 ms 之间,进程第一次冷启动那一次是 4.4 ms),之后 1000 次全部命中准备好的程序,五次的中位数都在 87 到 88 µs,约为前向(61 到 62 µs)的 1.4 倍。这些都是单线程 engine 上的数。
和另两个库对照:candle 的 backward() 每次返回一张新的 GradStore,可以调两次、两次的结果不累加,非 Var 的普通输入也会拿到梯度(见 backward 与 GradStore);burn 的梯度放在 backward() 返回的容器里,不存回张量。tenferro 和 PyTorch 一样把梯度累加进张量的槽位,所以训练循环里要自己 clear_grad() 或 EagerRuntime::clear_grads()。
PR #713(「make eager tensors Send+Sync and switch backward() to grad accumulation」,2026-04-13 合并,首次发布于 v0.1.0)之前,backward 一开始先调 self.ctx.clear_grads(),再由 store_grads 把每个登记的槽覆盖成这一次的余切(没有余切的槽写成 None)。PR 里的设计文档(docs/plans/2026-04-13-tenferro-eager-grad-accumulation-design.md,v0.8.0 的 tag 上还在)写「Removing self.ctx.clear_grads() is necessary but not sufficient」,因为覆盖逻辑本身也得改:现在的 store_grads 只碰收到余切的槽,已有值就相加。同一个 PR 的另一半把 Rc / RefCell 换成 Arc / Mutex,让 eager 张量可以跨线程,这就是现在 grad_slots、梯度槽都是 Mutex 的由来。
动脑筋答案
== 3. backward again on the same loss (no clear_grad)
x.grad = Some([40.0, 160.0, 360.0])
cache: CacheStats { entries: 1, retained_bytes: 1474, hits: 1, misses: 1, evictions: 0, clears: 0 }
== 3b. backward on a second engine
x.grad = Some([20.0, 80.0, 180.0])
cache: CacheStats { entries: 1, retained_bytes: 1474, hits: 2, misses: 1, evictions: 0, clears: 0 }
[40, 160, 360],正好是两倍。第二次backward算出的余切还是[20, 80, 180],store_grads看到槽里已有值,相加。第二次能跑,是因为backward拿的是&self,trace 和残差都还在;它直接命中准备好的导数程序(hits: 1)。- 换一个新建的
CpuEngine::serial()不会重新准备:hits变成 2,条目还是 1 条。两个串行 CPU engine 的target()相同,缓存键只认目标,不认是哪一个 engine 实例。 - 多次
backward想要独立的结果,在两次之间x.clear_grad();只想要一个梯度张量而不碰槽位,用函数式的rt.grad(&mut engine, &loss, &x)。
小结
EagerRuntime不持有 engine,只有 AD 的状态:一个登记 engine 种类的Runtime、两张Weak表(梯度槽、值记录)和两层缓存;new()的运算族规则集是空的,带规则用with_ad_context。- 运算在
with_eager_session(&mut engine, |s| ...)的回调里调用;回调错误类型E: From<tenferro_ad::Error>,混用 einsum 时要把E定成tenferro_einsum::Error。 - 每个被跟踪的运算立即执行,同时把同一个算子追加到 semantic trace,并按
ResidualSpec留住前向结果;没有输入要梯度或在no_grad()里时不记录,组合运算这时才能走融合内核。 backward(&mut engine)把所有活着的梯度槽当候选,对 trace 做一次语义 VJP;只有叶子能拿到梯度;导数程序把残差当输入,不重算前向;准备好的程序按 engine 目标和结构缓存,单线程 64×64 暖态反向约 88 µs。- 梯度累加进槽位:
backward两次结果翻倍,要clear_grad();常数和中间结果的grad()是None;函数式grad/vjp/jvp不碰槽位,结果还能再求导。 - 记录随张量释放,
backward不消耗它们;value_records的死条目在扩容前清扫,训练循环里不再增长。