Rust in Everything
基于 tenferro-runtime 0.8.0

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 缓存编译好、准备好的导数程序。
📝NOTE
冷知识:EagerRuntime 原来自己持有后端

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。

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

📝NOTE
冷知识:backward 原来是覆盖,不是累加

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 的死条目在扩容前清扫,训练循环里不再增长。

💬 关联讨论

发起讨论
加载中...