Autodiff 装饰器
主线位置:求导主线的起点,也是算子主线上可选的一环。算子主线里
Dispatch按设备match之后,如果设备是.autodiff()的,先进Autodiff<B, C>记一个节点,再调内层后端算;本页讲这层装饰器长什么样、张量上多带了什么、运行期的标记怎么变成类型参数。match本身怎么生成见 Dispatch,算子里登记了什么见一个算子的反向。
flowchart TB
devn["Device flex autodiff"] --> dd["DispatchDevice Autodiff"]
dd --> tens["张量带 Enabled 策略"]
tens --> merge{"合并各输入 context"}
merge -->|Disabled| plain["Flex 直接算"]
merge -->|"Enabled Disabled"| nock["Autodiff Flex NoCheckpointing"]
merge -->|"Enabled Balanced"| bal["Autodiff Flex Balanced"]
nock --> op["Autodiff 的 FloatTensorOps"]
bal --> op
op --> fwd["调 Flex 前向"]
op --> step["登记 Step 到图"]
本页的实验 crate 是 ad-exp(从模板 base 复制,burn =0.22.1,feature 与模板相同),在 Device::flex() 上跑,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0。
Autodiff<B, C>:只换浮点张量
Autodiff 是一个只有两个 PhantomData 的空结构体,第二个类型参数默认是 NoCheckpointing。它在 BackendTypes 里只改了一样东西:
pub struct Autodiff<B, C = NoCheckpointing> {
_b: PhantomData<B>,
_checkpoint_strategy: PhantomData<C>,
}
impl<B: BackendTypes, C: CheckpointStrategy> BackendTypes for Autodiff<B, C> {
type Device = B::Device;
type FloatTensorPrimitive = AutodiffTensor<B>;
type IntTensorPrimitive = B::IntTensorPrimitive;
type BoolTensorPrimitive = B::BoolTensorPrimitive;
type QuantizedTensorPrimitive = B::QuantizedTensorPrimitive;
// A replayed graph would skip re-recording the autodiff tape, so capture
// is not supported under autodiff.
type GraphPrimitive = burn_backend::GraphUnsupported;
}
设备类型照搬内层,整数、布尔、量化张量也照搬:只有浮点张量能求导,其他种类的张量过这一层时原样转手。Backend 的实现几乎全是转发(sync、seed、memory_*、supports_dtype 都直接调 B::),只有 ad_enabled 返回 true,name 在内层名字外面包一层 autodiff<...>。AutodiffBackend 这个 trait 多出 backward、grad、grad_remove、inner / from_inner 几个方法,都是一行,转给 AutodiffTensor 自己的方法。真正的工作分在两处:每个浮点算子的 FloatTensorOps 实现(ops/tensor.rs,下一页),和 backward() 背后的图(runtime/,第三页)。
后端 trait 的整体结构(BackendTypes、按种类拆开的 ops trait、AutodiffBackend)见 Backend trait。
AutodiffTensor:原语、节点、引用计数
AutodiffTensor<B> 有三个字段:
#[derive(Debug, Clone)]
pub struct AutodiffTensor<B: BackendTypes> {
pub(crate) primitive: B::FloatTensorPrimitive,
pub(crate) node: NodeRef,
pub(crate) rc: NodeRefCount,
}
primitive是内层后端的浮点张量,前向的数值全在这里,Autodiff不复制数据。node是Arc<Node>,图的元数据。rc是NodeRefCount,也就是Arc<NodeId>。它不携带信息(id 在node里也有),存在的意义是它的强引用计数:图里登记步骤时会存一份rc,第三页的内存管理靠Arc::strong_count判断「用户手里还有没有指向这个节点的张量」。#[derive(Clone)]让张量的clone只多一份Arc。
Node 的字段:
| 字段 | 类型 | 含义 |
|---|---|---|
parents | Vec<Parent> | 需要梯度的父节点 id,Parent.is_leaf 记父节点是不是叶子 |
order | usize | 深度:叶子是 0,算子输出是父节点最大深度加 1 |
id | NodeId | 全局唯一的 u64 |
requirement | Requirement | 要不要梯度,见下一节 |
properties | ComputingProperty | ComputeBound / MemoryBound { retro_forward } / Ambiguous,给检查点用 |
client | AutodiffClientImpl | 登记步骤、发起 backward 的入口,0.22.1 里是一个零大小的 GraphMutexClient |
distributed_params | Option<DistributedParams> | 多设备训练时标记参数,本卷不展开 |
NodeId::new 是一个全局 static COUNTER: AtomicU64 的 fetch_add,所以 id 在进程内单调递增,和设备、后端都无关。后几页实验里打印出来的 NodeId(5)、NodeId { value: 6 } 就是这个计数器的值。
AutodiffTensor 对外只给出 node()、id()、primitive()、into_primitive()、into_parts() 这几个方法,字段是 pub(crate)。node() 返回的不是 NodeRef,而是同时持有 node 和 rc 的 NodeGuard,原因放在下一页的冷知识里。
问: Autodiff<B, C> 有检查点策略 C,AutodiffTensor<B> 却没有。同一个张量换了策略,类型不就对不上了吗?
答: 策略只在前向登记时起作用:算子用 C::compute_property、C::checkpoint_parents 决定存输出还是存重算方法(见梯度检查点)。登记完以后,图里的步骤是 Box<dyn Step>,backward()、grad() 都不需要知道 C;Gradients 也是一个按 u64 存类型擦除张量的容器。把 C 留在张量类型外面,Dispatch 就能在 BackendTensor::Autodiff(FloatTensor<Autodiff<B>>) 里只存一种张量(这里的 Autodiff<B> 取了默认的 C,但 FloatTensor<Autodiff<B, C>> 对任何 C 都是同一个 AutodiffTensor<B>),策略作为运行期的值另外存,每个算子再临时选 C。
Requirement:叶子、中间结果、常量
Requirement 有三个值:
pub enum Requirement {
/// Operations that require gradients.
Grad,
/// Operations that require gradients only for backprop.
GradInBackward,
/// Operations that don't need gradients, therefore not to be included in the graph.
None,
}
Grad:调过require_grad()的叶子。backward 之后它的梯度留在Gradients里给用户取。GradInBackward:至少有一个输入要梯度的算子输出。backward 途中要用到它的梯度,用完就删(第三页)。None:常量。新建的张量(from_data、zeros、random……都走AutodiffTensor::new)一开始都是None,输入全是None的算子输出也是None。
算子输出的要求由 Requirement::from_nodes 推出来,规则只有一条(infer):所有输入都是 None 才是 None,否则是 GradInBackward。所以算子的输出永远不会是 Grad。张量 API 上的三个查询对应这几个值:
| 方法 | 看什么 | 为真的条件 |
|---|---|---|
is_autodiff() | 设备 | 张量带着 autodiff 标记,和要不要梯度无关 |
is_tracked() | requirement | 不是 None |
is_require_grad() | requirement | 是 Grad |
require_grad() 的实现(AutodiffTensor::require_grad)不改原来的节点(Arc<Node> 是共享的,改不了),而是用同一个 id 新建一个 Grad 节点,再登记一个什么也不做的根步骤 RootStep:
pub fn require_grad(mut self) -> Self {
match self.node.requirement {
Requirement::Grad => self,
Requirement::GradInBackward => {
panic!("Can't convert a non leaf tensor into a tracked tensor")
}
Requirement::None => {
self.node = Node::new(
vec![],
0,
self.node.id,
Requirement::Grad,
self.node.properties.clone(),
self.node.client.clone(),
self.node.distributed_params.clone(),
)
.into();
self.register_root()
}
}
}
GradInBackward 那一支说明:中间结果不能改成要梯度。burn 没有 PyTorch 的 retain_grad,想要一个中间量的梯度,只能把它 detach() 成新的叶子再 require_grad(),那样梯度也就不再往前传了。实验里的几种 panic,原文如下(ad-exp 的 backend,debug):
thread 'main' (3144670) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-autodiff-0.22.1/src/tensor.rs:190:17:
Can't convert a non leaf tensor into a tracked tensor
...
thread 'main' (3144670) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-tensor-0.22.1/src/tensor/api/float.rs:1185:13:
Tensor::require_grad requires autodiff; call Tensor::autodiff first
...
thread 'main' (3144670) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-tensor-0.22.1/src/tensor/api/autodiff.rs:75:9:
Tensor::backward requires a tracked autodiff tensor; call Tensor::autodiff().require_grad() on the source leaf before computing the output
第一条来自 burn-autodiff 本身((w * 2.0).require_grad());第二条是 burn-tensor 在分派之前用 assert! 拦下的(在 Device::flex() 上建的张量调 require_grad());第三条是对一个全是常量算出来的张量调 backward(),同样在 burn-tensor 里拦下。
从 Device::autodiff() 到 Autodiff<Flex, C>
0.22 的用户代码里不出现 Autodiff 这个类型。Device::autodiff 把设备包成 DispatchDevice::Autodiff,里面是 AutodiffDevice:
pub struct AutodiffDevice {
pub(crate) inner: Box<DispatchDevice>,
pub(crate) checkpointing: GradientCheckpointingStrategy,
}
checkpointing 是 GradientCheckpointingStrategy,Balanced 或默认的 Disabled,Device::gradient_checkpointing 把它改成 Balanced。设备相等只比较 inner,autodiff 标记和策略都不参与;Backend::name 也不带策略:
flex Device<Flex(Cpu)> name=dispatch<flex>
ad Device<Autodiff { device: Flex(Cpu), checkpointing: Disabled }> name=dispatch<autodiff<flex>>
ad_ckpt Device<Autodiff { device: Flex(Cpu), checkpointing: Balanced }> name=dispatch<autodiff<flex>>
flex == ad: true, ad == ad_ckpt: true
在这样的设备上建的张量,Dispatch 层的表示是 DispatchTensor 的两个字段:kind 指向具体后端的张量,autodiff 是 DispatchAutodiffContext,Disabled 或 Enabled(策略)。浮点张量和整数、布尔张量的样子不一样:
| 张量 | kind | autodiff |
|---|---|---|
Device::flex() 上的浮点 | Flex(BackendTensor::Float(FlexTensor)) | Disabled |
.autodiff() 设备上的浮点 | Autodiff(Box<Flex(BackendTensor::Autodiff(AutodiffTensor<Flex>))>) | Enabled(策略) |
.autodiff() 设备上的整数 / 布尔 | Flex(BackendTensor::Int(..)) / Bool(..) | Enabled(策略) |
整数和布尔不会被求导,所以不套 AutodiffTensor,只靠 autodiff 字段记住自己属于哪种设备,device() 据此报告 Autodiff { .. }。
每个算子开头把所有输入的 context 用 merge 合并:Disabled 和任何值合并得到对方,两个 Enabled 必须策略相同,否则 assert_eq! 失败。合并的结果决定这个算子走具体后端,还是走 Autodiff<B, C>、C 取哪一个。#[backend_dispatch] 为 Dispatch::float_mul 生成的代码(在实验目录里对 burn-dispatch 0.22.1 只开 flex、autodiff 两个 feature 跑 cargo expand,只留结构):
fn float_mul(lhs: FloatTensor<Self>, rhs: FloatTensor<Self>) -> FloatTensor<Self> {
let __burn_selected_context = lhs.autodiff;
let mut __ad_ctx = crate::DispatchAutodiffContext::Disabled;
__ad_ctx = __ad_ctx.merge(lhs.autodiff);
__ad_ctx = __ad_ctx.merge(rhs.autodiff);
match lhs.kind {
crate::DispatchTensorKind::Flex(__burn_selected) => {
match __ad_ctx {
crate::DispatchAutodiffContext::Disabled => {
type B = crate::backends::Flex;
// ... 取出 rhs 的 Flex 原语,B::float_mul,包回 Flex(Float)
}
crate::DispatchAutodiffContext::Enabled(__strategy) => {
match __strategy {
crate::GradientCheckpointingStrategy::Balanced => {
type B = crate::backends::Autodiff<
crate::backends::Flex,
burn_autodiff::checkpoint::strategy::BalancedCheckpointing,
>;
let lhs = <B as burn_backend::AutodiffBackend>::from_inner(
__burn_selected.float(),
);
// ... rhs 同理,B::float_mul,包成 Autodiff(Flex(Autodiff(..)))
}
crate::GradientCheckpointingStrategy::Disabled => {
// ... 同上,C = NoCheckpointing
}
}
}
}
}
crate::DispatchTensorKind::Autodiff(__inner) => {
match *__inner {
crate::DispatchTensorKind::Flex(__burn_selected) => {
match __ad_ctx {
crate::DispatchAutodiffContext::Disabled => {
// panic: "an autodiff float primitive must have an enabled autodiff context"
}
crate::DispatchAutodiffContext::Enabled(__strategy) => {
// ... 和上面一样按 __strategy 选 C,lhs 取 __burn_selected.autodiff()
}
}
}
// ...
}
}
// ...
}
}
几件事从这里能直接读出来:
- 运行期的策略在这里变成类型参数:每个分支里
type B = Autodiff<Flex, BalancedCheckpointing>或Autodiff<Flex, NoCheckpointing>,然后调B::float_mul。两种策略的Autodiff都被单态化,编进同一个二进制。做这件事的是过程宏里的routing::with_autodiff_backend;burn-dispatch里同名的声明宏with_autodiff_backend!生成同样的match,用在跨设备搬运、量化和分布式这几处手写的分派里。 - 一个没有 autodiff 标记的浮点张量(
kind是Flex(Float))和 autodiff 张量一起参与运算时,合并出来是Enabled,它被from_inner包成一个新的AutodiffTensor。from_inner就是AutodiffTensor::new,Requirement::None,所以它在这次运算里是常量。 - 输出包成
DispatchTensorKind::Autodiff(..),autodiff字段是合并后的 context,所以结果仍在 autodiff 设备上。
Dispatch 一开始只有不带检查点的 autodiffburn-dispatch 是 0.21 开发中由 PR #4508(「[Feat] Global backend Dispatch」,首次进入 v0.21.0-pre.2)加进来的。刚出现时,autodiff 分支写死成 type B = Autodiff<$Backend<f32>>,第二个参数取默认的 NoCheckpointing,AutodiffDevice 只是 Box<DispatchDevice> 的一层包装,没有办法通过 Dispatch 用上 BalancedCheckpointing。PR #4629(「Dispatch autodiff checkpointing strategy support」,首次进入 v0.21.0-pre.3,正式版 0.21.0)给 AutodiffDevice 加上 checkpointing 字段,给 DispatchTensor 加上同名字段,并新增了 with_autodiff_backend!,注释就是今天那一句「Helper to map the runtime strategy to the compile-time Autodiff generic.」。当时的枚举叫 CheckpointingStrategy,两个值是 Balanced 和 None。0.22 开发中,PR #5400(「fix!: address fusion and autodiff edge cases」,首次进入 v0.22.0-pre.3)把它改名为 GradientCheckpointingStrategy,None 改叫 Disabled;PR #5520(「refactor(dispatch): unify routing and strengthen autodiff contract」,首次进入 v0.22.0-pre.4)再把张量上的字段换成现在的 DispatchAutodiffContext。
实验里 c 建在 Device::flex() 上,w 在 .autodiff() 设备上 require_grad,c * w 的结果和梯度:
c*w device: Device<Autodiff { device: Flex(Cpu), checkpointing: Disabled }>
w.grad = Some([2.0, 3.0, 4.0, 5.0])
loss = (a * w + c * w).sum(),w 的梯度是 a + c,a = [[1, 2], [3, 4]]、c 全是 1,结果对。两种策略混用则在 merge 里 panic:
thread 'main' (3144670) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-dispatch-0.22.1/src/tensor.rs:230:17:
assertion `left == right` failed: Gradient checkpointing strategy mismatch: Balanced vs Disabled. Tensors in the same operation must share a strategy.
left: Balanced
right: Disabled
backward() 不经过策略选择。Dispatch 的 AutodiffBackend::backward 只看 kind,取出 AutodiffTensor<Flex> 直接调它的 backward();张量 API 那边,Tensor::backward 返回的 Gradients 是对 burn_autodiff::grads::Gradients 的一层不透明包装。
ad-exp 的 backend 里,a 是 .autodiff() 设备上的普通张量,w 调过 require_grad(),c 建在不带 autodiff 的 Device::flex() 上。a、w、c、a*a、a*w、c*w 六个张量的 is_autodiff / is_tracked / is_require_grad 各是什么?平台 M2 Max、macOS 27,burn 0.22.1,debug 构建。动脑筋答案
a is_autodiff=true is_tracked=false is_require_grad=false strategy=Some(Disabled)
w is_autodiff=true is_tracked=true is_require_grad=true strategy=Some(Disabled)
c(flex) is_autodiff=false is_tracked=false is_require_grad=false strategy=None
a*a is_autodiff=true is_tracked=false is_require_grad=false strategy=Some(Disabled)
a*w is_autodiff=true is_tracked=true is_require_grad=false strategy=Some(Disabled)
c*w is_autodiff=true is_tracked=true is_require_grad=false strategy=Some(Disabled)
a:在 autodiff 设备上,但新建的张量是Requirement::None,不追踪。w:require_grad()之后是Grad,三项都是真。c:普通Flex张量,DispatchAutodiffContext::Disabled,gradient_checkpointing_strategy()返回None。a*a:两个输入都是None,输出也是None。它仍在 autodiff 设备上,也仍然在图里登记了一个空步骤(下一页讲为什么)。a*w:有一个输入要梯度,输出是GradInBackward,所以「被追踪」但「不保留梯度」。c*w:c被当成常量包进来,结果和a*w一样;结果的 autodiff 标记来自w。
小结
Autodiff<B, C>是空结构体,只把浮点张量换成AutodiffTensor<B>,设备、整数、布尔、量化张量都照搬内层后端,Backend方法几乎全是转发。AutodiffTensor= 内层原语 +Arc<Node>+Arc<NodeId>;NodeId来自全局原子计数器,rc的强引用计数是后面释放节点的依据。Requirement:require_grad()的叶子是Grad,有输入要梯度的算子输出是GradInBackward,其余是None;中间结果不能require_grad()。Device::autodiff()只给设备打标记,张量带着DispatchAutodiffContext;生成的分派代码合并各输入的 context,在运行期选出Autodiff<Flex, NoCheckpointing>或Autodiff<Flex, BalancedCheckpointing>。- 不带标记的浮点张量和 autodiff 张量混算时被当成常量;两种检查点策略混算会 panic;
backward()不看策略。