PyTorch 训练框架内核深度解析

第3章 c10 核心抽象:Device、DType、Layout、intrusive_ptr

作者 杨艺韬 · 11,002 字 · 发布于 · 更新于

"intrusive_ptr<T> is an alternative to shared_ptr<T> that has better performance because it does the refcounting intrusively (i.e. in a member of the object itself)."

—— c10/util/intrusive_ptr.h:118-120 的类文档第一句

本章要点

  • Device 只有 2 字节 —— (DeviceType: int8, DeviceIndex: int8),对应的 DeviceType 枚举共 21 个取值(0–20)
  • ScalarType 是 int8_t 枚举,TensorImpl 里存的却是 16-bit 的 TypeMeta 索引 —— TypeMeta 这层类型擦除架构既支持 POD 数值类型,也保留了对老式 caffe2 对象张量的兼容
  • Layout 是 PyTorch 张量"形状语义"的开关:Strided / Sparse / SparseCsr / Mkldnn / SparseCsc / SparseBsr / SparseBsc / Jagged 共八种值
  • DispatchKeySet 是个 64-bit bitmap:低 16 位是 BackendComponent(CPU/CUDA/HIP/XLA/MPS/...),往上 47 位是 Functionality 维度(Dense/Quantized/Sparse/Autograd/...,Undefined 不占位),最高位 63 空着 —— 二维笛卡尔积压成一维
  • intrusive_ptr 是 PyTorch 替代 std::shared_ptr 的自研智能指针:refcount 嵌入对象本身、单原子 64-bit 同时管 strong/weak/PyObject 标志、专门为 GIL 与跨语言生命周期设计
  • 这一章的所有抽象都不是"为了好看",而是为了 高频的张量创建 / 调度 / 析构 这条热路径的性能预算服务

3.1 c10 是什么:PyTorch 的"原子层"

打开 PyTorch 仓库,你会看到三个并列的目录:

pytorch/
├── c10/        ← 最底层,几乎不依赖外部
├── aten/       ← ATen 算子库,依赖 c10
└── torch/      ← Python 端 + 用户接口,依赖 aten + c10

c10 是 PyTorch 的"原子层":它定义了那些"如果改了,半个 PyTorch 都要重新编"的核心抽象 —— Tensor 的指针类型、张量元数据、设备类型、dtype、内存分配器接口、引用计数。

c10 这个名字,按核心维护者 Edward Yang 在博客《PyTorch internals》(2019)里的说法,是 Caffe2 与 A"Ten" 的双关("Caffe 10"),他也承认这个名字有意留了几种读法;c10/ 目录本身建于 2018 年 6 月 Caffe2 与 PyTorch 合并的过程中(commit b03ba9023e,"Set up a c10 source folder")。今天它已经成长为一个独立的"基础设施层",目标是 不依赖任何深度学习概念(autograd / 算子等都不在 c10 里),只关心"张量需要什么样的容器、生命周期管理、跨平台兼容性"。

这一章我们把 c10 里最关键的五块抽象逐一拆开。第 2 章我们看了 Storage 与 TensorImpl;这一章下沉到它们底下的"零件供应商"。

graph TB
    subgraph TI[TensorImpl 字段]
        S1[Storage]
        S2[DispatchKeySet]
        S3[TypeMeta]
        S4[Device]
        S5[intrusive_ptr_target]
    end
    subgraph C10[c10 核心抽象]
        D1[Device + DeviceType]
        D2[ScalarType + TypeMeta]
        D3[Layout]
        D4[DispatchKey + DispatchKeySet]
        D5[intrusive_ptr / intrusive_ptr_target]
    end
    S1 --> D5
    S2 --> D4
    S3 --> D2
    S4 --> D1
    S5 --> D5
    style C10 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
    style TI fill:#fef3c7,stroke:#f59e0b,stroke-width:2px

3.2 Device:两个字节的硬件地理学

打开 c10/core/Device.h:31:

struct C10_API Device final {
  using Type = DeviceType;
  ...
 private:
  DeviceType type_;          // 1 byte
  DeviceIndex index_ = -1;   // 1 byte (using DeviceIndex = int8_t)
};

——Device 总大小只有 2 字节。但就是这 2 字节,要表达 PyTorch 支持的所有硬件后端:CPU、NVIDIA CUDA、AMD HIP、Intel XPU、Apple MPS、Google TPU/XLA、Graphcore IPU、Habana(Intel Gaudi)HPU、NEC SX-Aurora VE、Meta MTIA、Microsoft MAIA、Lazy Tensor、Meta Tensor、Vulkan、Metal —— 还有一个留给厂商扩展的 PrivateUse1。

完整列表在 torch/headeronly/core/DeviceType.h:35:

enum class DeviceType : int8_t {
  CPU = 0,
  CUDA = 1,
  MKLDNN = 2,    // 源码注释:Reserved for explicit MKLDNN
  OPENGL = 3,
  OPENCL = 4,
  IDEEP = 5,
  HIP = 6,       // AMD ROCm
  FPGA = 7,
  MAIA = 8,      // ONNX Runtime / Microsoft 自家
  XLA = 9,       // Google TPU
  Vulkan = 10,
  Metal = 11,    // Apple Metal
  XPU = 12,      // Intel
  MPS = 13,      // Apple Metal Performance Shaders
  Meta = 14,     // 仅元数据张量,无实际数据
  HPU = 15,      // Habana Labs / Intel Gaudi
  VE = 16,       // NEC SX-Aurora
  Lazy = 17,     // Lazy Tensor (用于 PyTorch/XLA 的 tracer)
  IPU = 18,      // Graphcore
  MTIA = 19,     // Meta 自家 inference/training accelerator
  PrivateUse1 = 20,
  COMPILE_TIME_MAX_DEVICE_TYPES = 21,
};

注意几个细节:

  • Meta 是一个特殊设备:上面的张量"没有真正的数据",只有 sizes/strides/dtype。它用于"假执行" —— 第 12 章 TorchDynamo 做 graph capture 时用的 FakeTensor,内部包的就是 meta 张量(torch/_subclasses/fake_tensor.py 要求 elem.device.type == "meta")
  • Lazy 也是一个特殊设备:它把每次操作记录成一个 IR 节点而不是真做,用于 XLA 后端
  • PrivateUse1 是给厂商扩展的"占位符":当国内厂商(华为昇腾、寒武纪、壁仞)想接进 PyTorch 时,用 PrivateUse1 在树外接入,不必改 PyTorch 本体。注意 DeviceType 这一层只有 PrivateUse1;BackendComponent(§3.5)那一层才有 PrivateUse1/2/3 三个槽位

3.2.1 为什么 Device 这么紧凑

DeviceType 是 int8_t、DeviceIndex 也是 int8_t。整个 Device 2 字节。这种紧凑不是装饰,而是因为 Device 嵌入到 TensorImpl 里(见第 2 章),每个张量都要带一份。

std::hash<Device> 也设计得很有意思(c10/core/Device.h:194-215):把 type 和 index 拼成一个 32-bit 整数再 hash。注释里特别警告"小心 sign extension" —— 因为 DeviceIndex = int8_t 有可能是 -1("当前设备"哨兵值),如果不小心把它直接强转 uint32_t,符号扩展会让上半部分变成全 1,hash 退化。这种"细节决定正确性"的注释在 c10 源码里随处可见,是 PyTorch 工程文化的体现。

3.2.2 DeviceIndex = -1 的"当前设备"语义

Device(CUDA) 和 Device(CUDA, 0) 是不同的:

  • Device(CUDA) 的 index_ 是 -1,表示当前设备(cudaGetDevice 的返回值)
  • Device(CUDA, 0) 显式指定 GPU 0

这种"两态"设计让用户可以写 tensor.to('cuda') 而不强制指定卡号 —— PyTorch 在执行时再读当前设备。但它也带来一个真实坑:如果你在多 GPU 训练里没有显式指定 device index,张量可能会跑在不同的卡上,最终某个 op 拿到 device 不一致的输入而抛错。生产代码里推荐总是显式 Device('cuda', rank),把 -1 留给临时实验。

源码上 validate() 函数(c10/core/Device.h:172-185)有一段特别的注释——原文折成两行,先是 "Removing these checks in release builds noticeably improves",下一行接 "performance in micro-benchmarks."。它在 release 编译里直接删掉了 device index 合法性检查,只留 DEBUG_ONLY 断言。注释紧接着给出放心删的理由:真正用到 DeviceIndex 的后端,在切换到该设备时还会再检查一次。这种"在 release 里相信调用者"的工程取舍,是 c10 性能哲学的鲜明体现。

3.3 ScalarType 与 TypeMeta:dtype 的双层表达

PyTorch 的 dtype 有两层 C++ 表示:

  • ScalarType(定义在 torch/headeronly/core/ScalarType.h,命名空间本来就是 c10;c10/core/ScalarType.h include 它,再补上 elementSize 等辅助函数):一个简单的枚举,列出所有合法 dtype
  • TypeMeta(c10/util/typeid.h):一个 16-bit 索引 + 类型元信息(size、构造、析构)

简单 dtype 用 ScalarType 就够了:

// torch/headeronly/core/ScalarType.h:258 中通过宏批量定义的 enum
// (枚举本体已搬到 headeronly,但仍定义在 namespace c10 里;
//   c10/core/ScalarType.h include 它,并补充 elementSize、isIntegralType 等辅助函数)
enum class ScalarType : int8_t {
  Byte,        // uint8_t
  Char,        // int8_t
  Short,       // int16_t
  Int,         // int32_t
  Long,        // int64_t
  Half,        // at::Half (fp16)
  Float,       // float (fp32)
  Double,      // double (fp64)
  ComplexHalf,
  ComplexFloat,
  ComplexDouble,
  Bool,
  QInt8,       // 量化 int8
  QUInt8,
  QInt32,
  BFloat16,
  ...
  NumOptions,
};

但 TensorImpl::data_type_ 字段存的不是 ScalarType,而是 TypeMeta。为什么?

3.3.1 历史包袱:从"对象张量"到"数值张量"

TypeMeta 源自 Caffe2(2015 年随 Caffe2 最早的提交出现在 Caffe2 的 core 目录里,2018 年 11 月移到 c10/util/typeid.h)。Caffe2 的张量允许存任意类型的对象(不仅是数值),比如 std::string 这种带构造函数和析构函数的类型。这要求张量元数据保留"如何构造、如何析构"这一类类型擦除信息。

TypeMeta 就是这个抽象。它内部其实只是一个 16-bit ID(指向全局类型表),但通过这个 ID 可以查到:

  • itemsize() —— 单个元素多大字节
  • placementNew() —— 怎么"原地构造"一个新对象
  • placementDelete() —— 怎么"原地析构"
  • copy() —— 怎么复制
  • name() —— 类型的字符串名

今天的 PyTorch 已经把支持的 dtype 收敛到一组 POD 数值类型(fp32/fp16/bf16/int* 等),不再支持任意对象张量。但 TypeMeta 这个类型擦除接口还保留着,作为历史遗产。

源码里的兼容办法:TypeMeta 的内部表示就是一个 uint16_t index_,所有 POD dtype 在初始化时被注册到全局表的前几十个槽位。需要 ScalarType 时,TypeMeta 直接转成 ScalarType(一次整数强转)。

// 简化版的 TypeMeta → ScalarType 转换
ScalarType TypeMeta::toScalarType() const {
    return ScalarType(index_);
}

为什么不直接用 ScalarType 而要包一层?因为:

  • 历史 ABI 兼容:早期源码大量使用 TypeMeta,全部改成 ScalarType 会破坏太多代码
  • 将来扩展空间:如果 PyTorch 重新需要"用户自定义 dtype"(如新硬件支持一种独有的 fp4 类型),TypeMeta 比硬编码的枚举好扩展

理解这种"双层表达",你看源码里 data_type_ 与 scalar_type() 来回转的代码就不会迷惑。

3.3.2 几个看似冗余但有意义的 dtype

PyTorch 支持的 dtype 列表里有几个看起来"冗余"的:

  • Half(fp16)vs BFloat16:精度都是 16 位但指数位不同。Half 用 5 位指数 + 10 位尾数(IEEE 754 半精度),BFloat16 用 8 位指数 + 7 位尾数(与 fp32 同指数,更适合训练时的梯度)。NVIDIA Tensor Core 早期主推 Half,Google TPU 主推 BFloat16,今天 H100 / GB200 都原生支持两种
  • ComplexHalf / ComplexFloat / ComplexDouble:复数类型,用于信号处理与量子模拟。每个元素是两个浮点数,PyTorch 通过 strides + 巧妙的内存布局让复数张量能直接调用底层数学库
  • QInt8 / QUInt8 / QInt32:量化整数类型,底层分别是有符号 int8、无符号 uint8 和 int32(后者常用于偏置 / 累加)。第 20 章会详细讲量化

注意 dtype 不是 dispatch key:dispatcher 的算子表不随 dtype 增长,按 dtype 分派发生在 kernel 内部(aten/src/ATen/Dispatch.h 的 AT_DISPATCH_* 宏)。每把一个 dtype 加进这些宏,相关 kernel 就要多实例化一份模板代码,编译时间和二进制体积随之增长。v2.11 的枚举里已经有 Float8_e5m2、Float8_e4m3fn 等 fp8 变体和 Float4_e2m1fn_x2。

3.4 Layout:除了 strides 还有谁

第 2 章我们说了 PyTorch 张量是 "strides + offset" 模型。但这只是默认情况 —— Layout::Strided。PyTorch 实际支持多种张量布局,枚举本体在 torch/headeronly/core/Layout.h:11(c10/core/Layout.h 转 include 它):

Layout 含义 内部表示
Strided 第 2 章讨论的标准 strides 模型 sizes + strides + offset
Sparse 稀疏 COO 张量 indices + values 两个张量
SparseCsr 稀疏 CSR (Compressed Sparse Row) crow_indices + col_indices + values
SparseCsc 稀疏 CSC (列优先版本) ccol_indices + row_indices + values
SparseBsr / SparseBsc 块稀疏 (Block Sparse) 类似 CSR/CSC,但 values 是块矩阵
Mkldnn Intel MKL-DNN 私有格式 不透明字节缓冲 + MKL 元信息
Jagged 嵌套张量,每行长度可变 用于 RNN 变长输入、graph batching

不少 Layout 有专门的 TensorImpl 子类(如 SparseTensorImpl、SparseCsrTensorImpl,见第 2 章 §2.12),Jagged 则是 Python 端的张量子类(见 §3.4.2)。Layout 字段本身只是个标签,决定后续算子怎么解释这块"数据"。

3.4.1 为什么 Sparse 需要单独的 Layout

考虑一个稀疏矩阵:100 万行 × 100 万列,但只有 1000 个非零元素。如果用普通 strided 张量,需要 4 TB 显存;用 COO 表示,只需要 2 × 1000 个 int64 indices(16 KB)+ 1000 个 fp32 values(4 KB)= 20 KB。

PyTorch 用 Layout 让 同一个张量类型 (torch.Tensor) 可以表达稠密和稀疏数据。算子根据 Layout 走不同实现:add(strided_tensor, sparse_tensor) 里 Sparse 这一 functionality 的优先级高于 Dense,dispatcher 会把它路由到 SparseCPU 注册的 add_sparse(内部再走 add_out_dense_sparse_cpu),而不是稠密的 add.out 结构化 kernel。

第 1 章 §1.4 我们提过 dispatcher 的多分派 —— 现在你看到 dispatcher 实际上是根据 (Backend, Functionality) 两个维度的笛卡尔积选择 kernel 的,Layout 通过 Sparse / SparseCsr 等 functionality 体现在后一个轴上;dtype 不参与这一步,而是在 kernel 内部再分派。

3.4.2 Jagged Layout:一个值得停下来看的新成员

Jagged 是 Layout 枚举里最新的一个成员(排在末尾)。它的核心场景是 变长序列张量,典型的就是 NLP 里"一个 batch 包含 8 个句子,每句长度不同"。

传统做法是 padding:把所有句子补到最长长度,padding 位置写 0。代价是 padding 的部分占内存、占算力。

Jagged 张量直接把变长这件事 first-class:

sizes = [batch=4, var=*, dim=128]
       ↑              ↑
   有限维度        变长维度(每行长度不同)

底层是一个 values 张量(所有有效元素拼一起)+ 一个 offsets 数组(每行起止位置)。算子在 Jagged 上可以只对有效元素计算,跳过 padding —— 省下来的算力正比于 batch 里的 padding 比例,序列长度越参差不齐收益越大。

layout=torch.jagged 的 NestedTensor 不是 C++ 的 TensorImpl 子类,而是 Python 端的 wrapper 子类 torch.nested._internal.nested_tensor.NestedTensor;aten/src/ATen/NestedTensorImpl.h 里的 NestedTensorImpl 服务的是老的 torch.strided 布局 NestedTensor(实跑 v2.11 时后者会警告自己处于 prototype 阶段,建议改用 layout=torch.jagged)。这是 Layout 维度作为"数据组织语义开关"的最新例子。

3.5 DispatchKey 与 DispatchKeySet:64 位的多维身份证

到第 5 章我们会详细拆 dispatcher 的查找逻辑。这一章只需要知道一件事:DispatchKeySet 是一个 64-bit bitmap,但不是"每一位代表一个 DispatchKey" —— 它由后端位(BackendComponent)和功能位(functionality)两段组成,CPU、SparseCUDA、AutogradCPU 这类运行时 key 是"一个功能位 + 一个后端位"的组合。

打开 c10/core/DispatchKey.h:136:

enum class DispatchKey : uint16_t {
  Undefined = 0,
  CatchAll = Undefined,   // Undefined 的别名

  // ~~~~~ Functionality Keys ~~~~~ 从这里到 EndOfFunctionalityKeys
  Dense,                  // 可按后端定制
  FPGA, Vulkan, Metal,    // 非可扩展后端,直接占一个 functionality 位
  Quantized,              // 可按后端定制
  CustomRNGKeyId, MkldnnCPU,
  Sparse, SparseCsr, NestedTensor,   // 可按后端定制
  BackendSelect, Python, Fake,
  FuncTorchDynamicLayerBackMode, Functionalize, Named,
  Conjugate, Negative, ZeroTensor, ADInplaceOrView,
  AutogradOther, AutogradFunctionality, AutogradNestedTensor,
  Tracer,
  AutocastCPU, AutocastMTIA, /* ... */ AutocastCUDA, AutocastPrivateUse1,
  FuncTorchBatched, BatchedNestedTensor, FuncTorchVmapMode,
  Batched, VmapMode, FuncTorchGradWrapper, DeferredInit,
  PythonTLSSnapshot, FuncTorchDynamicLayerFrontMode,
  TESTING_ONLY_GenericWrapper, TESTING_ONLY_GenericMode,
  PreDispatch, PythonDispatcher,

  EndOfFunctionalityKeys,   // = 48:含 Undefined 共 48 个 functionality 值,Undefined 不占位

  // 下面的 CPU / CUDA / SparseCPU / AutogradCUDA … 不是手写的,
  // 而是由 DEFINE_PER_BACKEND_KEYS 宏用
  // (可定制 functionality) × (16 个 BackendComponent) 展开出来的"运行时键"
  ...
};

真正的运行时 key 数量是 num_runtime_entries = 48 + 6 × (16 - 1) = 138(c10/core/DispatchKey.h:585:num_functionality_keys 48 个,再加 6 个可按后端定制的 functionality 各多出 15 个槽位)。而 DispatchKeySet 只有 64-bit —— 装不下 138 个 key。怎么办?

PyTorch 用了一个聪明的设计:两段拼接。

3.5.1 BackendComponent + Functionality 的笛卡尔积压一维

graph LR
    Naive["朴素方案<br/>每个运行时 key 一位<br/>需要 138 bits"] -.装不下.-> Fail[超出 64 位]
    Smart["PyTorch 方案<br/>16 BackendComponent + 47 Functionality 位<br/>拼进 64 位,两段各自取最高位再合成"] --> Fit[64 bits 装得下]
    style Fail fill:#fee2e2
    style Fit fill:#dcfce7

DispatchKeySet 的 64 位被这样切分:

位 63     位 62 .. 16            位 15 .. 0
[ 空 ]   [ Functionality bits ] [ BackendComponent bits ]
          47 个功能键(Dense=16 … PythonDispatcher=62)  16 个后端键
  • BackendComponent(低 16 位):C10_FORALL_BACKEND_COMPONENTS(c10/core/DispatchKey.h:36)里列得清清楚楚,恰好 16 个 —— CPU、CUDA、HIP、XLA、MPS、IPU、XPU、HPU、VE、Lazy、MTIA、MAIA、PrivateUse1、PrivateUse2、PrivateUse3、Meta
  • Functionality(位 16–62):Dense、Quantized、Sparse、SparseCsr、NestedTensor、Autograd 等正交维度。EndOfFunctionalityKeys = 48,但其中 Undefined(0)不占位,所以实际是 47 位:functionality k 放在第 15 + k 位(c10/core/DispatchKeySet.h 里 num_backends + k - 1)

c10/core/DispatchKey.h:519-524 有一句 static_assert 把这件事钉死:BackendComponent 数量 + EndOfFunctionalityKeys 必须 ≤ 64。现在 16 + 48 = 64 正好顶到上限;换成实际占位算,是 16 + 47 = 63 位,最高位 63 空着(实跑 torch._C.DispatchKeySet(torch._C.DispatchKey.PythonDispatcher).raw_repr() 得 2**62)。

一个普通的 CUDA 稠密张量(不在 inference mode 下创建),它的 DispatchKeySet 会同时点亮:

  • BackendComponent: CUDA
  • Functionality: Dense(与 CUDA 位合起来就是运行时 key CUDA)
  • Functionality: ADInplaceOrView
  • Functionality: AutogradFunctionality(与 CUDA 位合起来就是 AutogradCUDA)
  • Functionality: AutocastCUDA

注意 Autograd 与 Autocast 相关的位跟 requires_grad、是否在 autocast 上下文都无关 —— TensorImpl 构造函数(c10/core/TensorImpl.cpp:153、:168)无条件把它们加进去;autocast key 平时靠 thread-local 的默认排除集(c10::default_excluded_set)屏蔽掉。CPU 上实跑 torch._C._dispatch_keys(torch.randn(2)) 得到 DispatchKeySet(CPU, ADInplaceOrView, AutogradCPU, AutocastCPU),requires_grad=True 的张量结果相同。

每次进 dispatcher,PyTorch 计算最高优先级的 functionality 与最高优先级的 backend,再用 offsetsAndMasks() 表把二者换算成算子表里的槽位(getDispatchTableIndexForDispatchKeySet) —— 这是为什么 100+ keys 能塞进 64 bits 的秘密。

3.5.2 这个设计的代价与收益

代价:dispatcher 的查找逻辑比"单一 enum 查表"要复杂,每次 lookup 多几次位操作。

收益:

  1. 新增一个 backend(如某国产芯片)只要扩 BackendComponent 一项,不用改 functionality 维度
  2. 新增一个 functionality(如 vmap 这种 mode)只要扩 functionality 维度,自动支持所有 backend
  3. DispatchKeySet 的位操作(OR、AND、highest_bit)天然 O(1),吞吐高

第 5 章会讲 dispatcher 的具体 lookup 算法。这一章你只需要把 DispatchKeySet 当作"张量身份证"理解就好。

3.5.3 优先级与 highestPriorityTypeId

DispatchKeySet 提供一个核心方法 highestPriorityTypeId()(c10/core/DispatchKeySet.h:434),它做的是两段各取最高位再合成:

DispatchKey highestPriorityTypeId() const {
  auto functionality_k = highestFunctionalityKey();
  if (isPerBackendFunctionalityKey(functionality_k)) {
    return toRuntimePerBackendFunctionalityKey(
        functionality_k, highestBackendKey());
  }
  return functionality_k;
}

"取最高位"在硬件上就是一条 BSR / CLZ 指令,复杂度严格 O(1);只有当选中的 functionality 是"可按后端定制"的那 6 个之一时,才需要再取一次最高的 backend 位、查表合成出运行时 key。

PyTorch 编排 DispatchKey 的"位序"的方式特别讲究:

  • 越高位 = 越高优先级 —— autograd-related keys、Mode keys 在最高位,因为它们必须先于普通 backend 命中
  • 正交维度合理隔开 —— functionality keys 与 backend keys 在不同段位,避免互相干扰

c10/core/DispatchKey.h 全文 745 行,其中 enum class DispatchKey 从第 136 行排到第 424 行的 EndOfFunctionalityKeys —— 这中间几乎每个 key 上面都压着一段注释,解释它是什么、为什么排在这个位置。这份注释是"PyTorch 设计的活档案" —— 第 5 章我们会逐段拆。

实际值得记住的优先级直觉:

高 → 低
PythonDispatcher (47,绕过 C++ dispatcher 的旁路)
FuncTorchDynamicLayerFrontMode (43,vmap/grad 等函数变换)
AutocastXxx (25-34,混合精度)
AutogradXxx (21-23,反向图记录)
ADInplaceOrView (20,视图与原地记账)
Python (12,__torch_dispatch__ Mode)
BackendSpecific (Dense=1 等,CUDA/CPU/...)
Undefined (0,最低)

括号里是枚举值 —— 位越高优先级越高,所以这份顺序直接就是 enum class DispatchKey 的排列顺序。两个容易记反的地方:Autocast 排在 Autograd 之上(先把输入 cast 成 fp16/bf16,再由 autograd 记录这次 cast,否则反向拿不到正确的类型转换节点);Python(__torch_dispatch__ 所用的 Mode key)排在 Autograd 之下,所以 TorchDispatchMode 看到的是"已经过了 autograd"的算子流。

3.6 intrusive_ptr:PyTorch 自研的智能指针

终于到本章的重头戏。PyTorch 不用 std::shared_ptr,而是自己造了一套 c10::intrusive_ptr —— 这是写过 C++ 的人最容易困惑的设计决定之一。让我们彻底拆开。

3.6.1 std::shared_ptr 的"控制块"设计

回顾一下 std::shared_ptr<T> 的内存结构:

graph TB
    SP[shared_ptr&lt;T&gt;<br/>16 字节: 数据指针 + 控制块指针]
    SP --> CB[控制块<br/>──────────<br/>strong refcount<br/>weak refcount<br/>deleter<br/>allocator]
    SP --> OBJ[T 对象]
    CB -. 析构时调用 deleter .-> OBJ
    style CB fill:#fee2e2,stroke:#ef4444
    style OBJ fill:#dbeafe,stroke:#3b82f6

shared_ptr<T> 把引用计数放在一个独立的"控制块"对象里,控制块和 T 对象是两块分离的堆内存。这种设计的问题:

  1. 额外一次堆分配:除非用 make_shared 把对象和控制块合并,否则每次 new T + shared_ptr<T>(p) 是两次 malloc
  2. 额外一个指针:shared_ptr 自身除了数据指针还要存一个控制块指针,所以是 16 字节
  3. Cache 不友好:T 对象和它的 refcount 在内存里不相邻,每次 incref / decref 都打散一条 cache line

对一个 普通 C++ 项目,这些代价可以忽略。但 PyTorch 张量的创建 / 析构是高频热路径,这类开销会被放大。

3.6.2 intrusive_ptr 的"内嵌"设计

c10::intrusive_ptr<T> 反过来把 refcount 嵌到 T 对象自己身上:

graph TB
    IP[intrusive_ptr&lt;T&gt;<br/>8 字节: 数据指针]
    IP --> OBJ["T 对象 (继承 intrusive_ptr_target)<br/>──────────<br/>std::atomic&lt;uint64_t&gt; combined_refcount_<br/>(strong + weak + PyObject 标志)<br/>──────────<br/>用户字段..."]
    style OBJ fill:#dcfce7,stroke:#22c55e,stroke-width:2px

要点:

  1. 指针只有 8 字节(不是 16)—— intrusive_ptr<T> 内部就是一个原始指针
  2. 没有控制块 —— refcount 直接嵌在 T 里
  3. 少一次堆分配 —— make_intrusive<T>(...) 一次 malloc 拿到对象 + refcount
  4. Cache 友好 —— 访问 T 字段时 refcount 也在同一个 cache line 上

代价是 T 必须继承 intrusive_ptr_target,引入了侵入式约束。但对 PyTorch 这种"所有共享对象都已经是大对象"的场景,这个约束几乎零代价。

源码里这段哲学的注释(c10/util/intrusive_ptr.h:117-120)只有一句话:

intrusive_ptr<T> is an alternative to shared_ptr<T> that has better performance because it does the refcounting intrusively.

简洁、直接,没有废话。

3.6.3 单原子 64-bit 同时管 strong / weak / PyObject

打开 c10/util/intrusive_ptr.h:188:

mutable std::atomic<uint64_t> combined_refcount_;
static_assert(sizeof(std::atomic<uint64_t>) == 8);
static_assert(alignof(std::atomic<uint64_t>) == 8);
static_assert(std::atomic<uint64_t>::is_always_lock_free);

一个 atomic uint64 同时存 strong refcount + weak refcount + PyObject 标志位。布局:

位 63              位 32-62          位 0-31
[ kHasPyObject 1bit ] [ weakcount 31bits ] [ strong refcount 32bits ]

代码里的常量定义(c10/util/intrusive_ptr.h:33-41):

constexpr uint64_t kImpracticallyHugeReferenceCount = 0x0FFFFFFF;
constexpr uint64_t kImpracticallyHugeWeakReferenceCount =
    (kImpracticallyHugeReferenceCount << 32);
constexpr uint64_t kReferenceCountOne = 1;
constexpr uint64_t kWeakReferenceCountOne = (kReferenceCountOne << 32);
constexpr uint64_t kUniqueRef = (kReferenceCountOne | kWeakReferenceCountOne);
constexpr uint64_t kHasPyObject = (uint64_t(1) << 63);

这种"单原子打包"的好处:

  • strong 与 weak 可以在同一个原子操作里一起读写 —— 在 std::shared_ptr 里是两个独立的 atomic
  • kUniqueRef 检查("我是唯一持有者吗")只需要一次原子读 —— reset_not_null_ 的析构快路径就靠它:两个计数都是 1 时直接 delete,省掉一次原子减

is_always_lock_free 这个 static assert 保证了 64-bit atomic 在所有目标平台都能用 lock-free 实现(不退化成 mutex)—— 这是 PyTorch 假设的硬件下限。

3.6.4 kImpracticallyHugeReferenceCount 哨兵:防 overflow 的智慧

注意上面常量里有一个 kImpracticallyHugeReferenceCount = 0x0FFFFFFF(约 2.7 亿)。这是干嘛的?

它是给 "这个对象根本不在堆上、不能按常规释放" 准备的哨兵值:用它初始化 refcount 的只有 unsafe_adapt_non_heap_allocated(c10/util/intrusive_ptr.h:704),另外 ~intrusive_ptr_target 的 debug 断言会认它。源码注释解释得很明白:

kImpracticallyHugeReferenceCount is impractically huge for a reference count, while being in no danger of overflowing uint32_t. We actually only need to initialize the refcount to 2 -- we are just doing an unbalanced incref to prevent the non-heap-allocated target from being freed, and we are optimizing that incref by directly initializing the refcounts rather than doing an expensive atomic increment.

也就是说:要把一个不是 new 出来的 intrusive_ptr_target(源码注释点名的用途是 static runtime 里手工在多个张量间共享、放在 arena 里的 StorageImpl)包成 intrusive_ptr,就得让它的 refcount 永远掉不到 0。做法不是加锁、不是特判,而是直接把 refcount 初始化成 0x0FFFFFFF —— 大到无论多少次 decref 都不会归零,又小到绝不会把 uint32_t 撑溢出。选这个值而不是简单的 2,是为了让 ~intrusive_ptr_target 里的 debug 断言能一眼认出"这是个被钉住的对象"。

3.6.5 Note [Stack allocated intrusive_ptr_target safety]

c10/util/intrusive_ptr.h:126-141 有一段名为 Note [Stack allocated intrusive_ptr_target safety] 的注释,解释一个微妙的安全问题:

A well known problem with std::enable_shared_from_this is that it allows you to create a std::shared_ptr from a stack allocated object, which is totally bogus because the object will die once you return from the stack.

std::shared_ptr 允许你这样写:

struct Foo : std::enable_shared_from_this<Foo> { ... };
Foo foo;                              // 栈上分配
auto p = foo.shared_from_this();      // 编译通过,但 p 析构时会 delete foo —— UB!

intrusive_ptr 怎么防?答案是 intrusive_ptr_target 的默认 refcount 是 0,只有经 make_intrusive 创建时才被置成 1。当你用 unsafe_reclaim_from_nonowning 把一个非拥有的原始指针转成 intrusive_ptr(相当于 shared_from_this)时,PyTorch 会断言 refcount > 0(说明对象已被某个 intrusive_ptr 持有,在堆上)。如果你把栈对象传进去,refcount 还是 0,断言挂掉,"no intrusive_ptr for you!"(这是 TORCH_INTERNAL_ASSERT_DEBUG_ONLY,只在 debug 构建里检查。)

这种"主动防御"是 PyTorch 工程文化的另一例。

源码里 intrusive_ptr 还提供 unsafe_steal_from_new 等显式"我知道我在做什么"的接口,给到那些真的需要从原始指针构造的场合(源码注释说它存在的唯一原因是 pybind11 的 holder 类型)。这些 unsafe API 名字里带 unsafe 前缀,逼用户在写代码时就在每一处看到风险提示 —— Rust 风格的不安全标记在 C++ 里也奏效。

3.6.6 make_intrusive 与单次堆分配

make_intrusive<T>(args...) 是 intrusive_ptr 的"标准创建方式",对标 std::make_shared。它的实现简洁:

// c10/util/intrusive_ptr.h:752
template <class TTarget, class NullType = ..., class... Args>
inline intrusive_ptr<TTarget, NullType> make_intrusive(Args&&... args) {
  return intrusive_ptr<TTarget, NullType>::make(std::forward<Args>(args)...);
}

// 而 make 本体(:670)就一行
static intrusive_ptr make(Args&&... args) {
  return intrusive_ptr(new TTarget(std::forward<Args>(args)...));
}

注意它做的事只是 new T(...) 然后包成 intrusive_ptr —— 因为 intrusive_ptr_target 把 refcount 嵌入了 T 自身,没有独立控制块要分配,所以严格只有一次 new。

对比 std::make_shared 虽然也只调一次 operator new,但分配的内存大小是 sizeof(T) + sizeof(ControlBlock),对象布局依赖编译器实现。intrusive_ptr 直接 new T,对象布局完全可控 —— 这对底层调试和性能分析更友好。

3.6.7 实际训练场景里 intrusive_ptr 的影子

当你写训练代码时,intrusive_ptr 的影子无处不在:

  • 每次 tensor.clone() —— 新建 TensorImpl + 新建 StorageImpl,两次 make_intrusive
  • 每次 tensor.view(...) —— 新建 TensorImpl + 复用现有 StorageImpl,一次 make_intrusive + 一次 incref
  • 每次 optimizer.step() 里更新一个参数张量 —— 涉及参数 / grad / momentum / variance 多个张量的 intrusive_ptr 同时操作
  • 每次 backward 释放一个反向图节点 —— 节点本身由 std::shared_ptr<Node> 管理,但它保存的张量随之 decref,可能触发 TensorImpl / StorageImpl 析构

这几条路径走得非常频繁,所以"strong 与 weak 合并进一个原子量"(就是本章讨论的 combined_refcount_)这种看起来只省一条指令的改动,累积起来未必可以忽略。想验证它对你的负载有多大影响,最直接的办法是用第 21 章讲的 torch.profiler 看 self_cpu_time_total 在小算子上的占比 —— 本专栏不给没有出处的百分比。

3.7 PyObject 保活:为 Python 而生的 64 位顶位

combined_refcount_ 的位 63 是 kHasPyObject 标志位 —— 这是 intrusive_ptr 区别于普通 shared_ptr 的最大特性,也是 PyTorch 能让 Python 端的 torch.Tensor 保持身份的核心机制。

3.7.1 问题:Python 包装对象的"双重身份"

考虑一段代码:

import torch

a = torch.randn(3, 3)
a.my_attr = 'hello'        # 给 Python 端的 Tensor 加自定义属性

b = a + 0                  # 触发 C++ 端创建新 TensorImpl,再包成 Python Tensor
print(a.my_attr)           # 'hello' —— 但这是 a 的属性,不是 b 的

但有时候 PyTorch 在 C++ 端会通过某些操作"丢失" Python 端的引用:

del a                      # Python 端 a 被删,但 C++ 端 TensorImpl 可能还在
                           # (某个 grad_fn 还在持有它)
# ...过一会儿,C++ 端通过某种路径又把那个 TensorImpl 暴露回 Python
# 这次包装出来的 Tensor 还能找回 my_attr 吗?

如果 PyTorch 每次都重新创建 PyObject —— 不行,老的 my_attr、__torch_function__ 注册等一切自定义都丢了。

3.7.2 解法:combined_refcount_ 的位 63

kHasPyObject 位的语义是"这个 C++ 对象当前有一个 Python wrapper"。当 C++ 端的 TensorImpl 第一次被包成 THPVariable(Python 对象),位 63 被置 1。

关键规则(注释在 c10/util/intrusive_ptr.h:171-186):

  • PyObject 持有 C++ 对象的 strong reference
  • 当 C++ 端 refcount 从 1 涨到 2 时,PyTorch 同时给 PyObject 加一次 Python refcount(让 Python 端不释放 wrapper)
  • 当 C++ 端 refcount 从 2 降到 1 时,给 PyObject 减一次 Python refcount

这种"同步引用计数"机制让 C++ 与 Python 两端的对象生命周期保持一致:只要 C++ 还有引用,Python wrapper 就活着;wrapper 上挂的属性、子类、注册都不会丢。

实现上还要应付并发 —— weak_intrusive_ptr::lock() 在对象只剩 Python wrapper 持有(strong refcount 为 1 且带 kHasPyObject)时,会先调 try_incref_pyobject() 抢到 Python 引用,再用 CAS 升级 strong 引用,避免 Python 对象在此期间被并发释放(见 c10/util/intrusive_ptr.h 里 lock() 的实现)。这是 intrusive_ptr 比 shared_ptr 复杂得多的根本原因。

3.7.3 这个机制带来的能力

理解了 PyObject 保活,几个 PyTorch 用法的"魔法感"就消失了:

  • 自定义属性不丢、对象身份不变:g = torch.ones(3); g.foo = 'hi'; a.grad = g; del g 之后,a.grad.foo 仍是 'hi',id(a.grad) 也还是原来那个对象(v2.11 CPU 实跑)
  • Python 子类类型不丢:把一个 torch.Tensor 子类的实例赋给 .grad 再删掉 Python 端引用,取回来的仍是那个子类

注意它只在单进程内起作用:DataLoader worker 把张量传回主进程时,主进程拿到的是重新构造的 Python 对象,挂在原对象上的属性不会跟过来。

3.7.4 incref 用 relaxed、decref 用 acq-rel 的内存序选择

如果你仔细读 c10/util/intrusive_ptr.h:75-101,会看到一段对 atomic 内存序的精彩讨论:

// The only requirement for refcount increment is that it happens-before
// decrement, so no additional memory ordering is needed.
inline uint64_t atomic_combined_refcount_increment(...) {
    return combined_refcount.fetch_add(inc, std::memory_order_relaxed) + inc;
}

// All non-final decrements must synchronize-with the final decrement.
// So all non-final decrements have to store-release while the final
// decrement has to load-acquire ... it's easiest just to have all
// decrements be acq-rel. And it turns out, on modern architectures
// and chips, it's also fastest.
inline uint64_t atomic_combined_refcount_decrement(...) {
    return combined_refcount.fetch_sub(dec, std::memory_order_acq_rel) - dec;
}

这是性能敏感代码里典型的内存序选择:

  • incref 只需 relaxed:因为 incref 只是"我认领一份所有权",不要求看到对象的最新状态。任何线程对对象的修改在 decref 时才需要可见
  • decref 必须 acq-rel:保证最后一次 decref(refcount → 0)能看到其他线程在各自 decref 之前对对象做的所有写,避免析构时读到旧数据

这种"非对称内存序"在 Rust Arc 里是同样的设计(参考 Rust std 文档:increment relaxed、decrement Release,析构前用 Acquire fence)。注释里"on modern architectures and chips, it's also fastest"比较的是两种写法:"所有 decrement 统一用 acq-rel",与"非最终的 decrement 用 release、最终那次再补 acquire fence"(Rust Arc 的做法);c10 选了前者。

如果你写自己的并发引用计数库,这套内存序选择是基本功,照搬即可。

3.7.5 PyObject 保活的反例:什么时候它会失败

kHasPyObject 机制不是万能的。它仅在张量从 Python 进入 C++、再回到 Python 时起作用。如果某个张量纯在 C++ 创建并消费、从未暴露给 Python,PyObject 永远不会创建,位 63 不会被点亮。

考虑一个真实场景:

class MyTensor(torch.Tensor):
    @staticmethod
    def __new__(cls, data, *args, **kwargs):
        return torch.Tensor._make_subclass(cls, data)

a = MyTensor(torch.randn(3, 3))
b = a.matmul(a.t())     # 内部 C++ 计算后回到 Python
print(type(b))          # 期望是 MyTensor 子类

b 的类型确实是 MyTensor,但这不是 PyObject 保活的功劳:b 是算子新建的张量,C++ 端从没有过它的 Python wrapper。把结果转回子类的是 torch.Tensor.__torch_function__ 的默认实现 —— 在 torch._C.DisableTorchFunctionSubclass() 里做同样的运算,结果就是普通的 torch.Tensor(v2.11 CPU 实跑)。所以"子类传染性"靠的是 __torch_function__,PyObject 保活只负责"已经存在的那个 Python 对象"不丢;两者的区别第 5 章 §5.7.2 会再对照。

3.7.6 Allocator 接口:c10 与硬件后端的契约

c10 还有一个重要抽象本章必须提到:c10::Allocator(c10/core/Allocator.h)。它定义了 PyTorch 与任何"分配字节缓冲"的硬件后端之间的契约。

// 简化的 Allocator 接口
struct C10_API Allocator {
  virtual ~Allocator() = default;
  virtual DataPtr allocate(size_t n) = 0;     // 分配 n 字节
  virtual DeleterFnPtr raw_deleter() const;
  ...
};

每个设备有自己的 Allocator 实现:

设备 Allocator 实现 关键文件
CPU DefaultCPUAllocator(走 c10::alloc_cpu 的对齐分配,如 posix_memalign) c10/core/CPUAllocator.cpp
CPU pinned CUDA 侧的 caching host allocator(cudaHostAlloc + 复用池) aten/src/ATen/cuda/CachingHostAllocator.cpp
CUDA CUDACachingAllocator (本专栏第 4 章主角) c10/cuda/CUDACachingAllocator.cpp
MPS MPSAllocator (Apple Metal) aten/src/ATen/mps/MPSAllocator.mm
Meta MetaAllocator(假分配,DataPtr 里是空指针) aten/src/ATen/EmptyTensor.cpp:340

Allocator 通过 c10::SetAllocator(DeviceType, Allocator*, priority) / c10::GetAllocator(DeviceType)(c10/core/Allocator.h:289)注册和查找,宏 REGISTER_ALLOCATOR(t, f) 是常用写法。新增一个硬件后端时,第一件事就是实现 Allocator 并注册到 c10。

这种"分配器即接口"的设计让 PyTorch 后端扩展极其规整 —— 厂商不需要改 c10 核心代码,只在自己的库里实现 Allocator 子类、注册即可。第 22 章 §22.20 讲新硬件接入时会提到这一步(它走 C++ 侧注册,没有统一的 Python 入口)。

Allocator::allocate 返回的不是裸 void*,而是 DataPtr —— 一个带"析构器"的智能指针。这是为什么不同后端的内存释放方式(free、cudaFree、pool_release)都能在 PyTorch 内部以同一接口工作 —— 释放策略嵌在 DataPtr 的 deleter 函数指针里。

3.7.7 一个细节:DispatchKey 是怎么从 Tensor 计算出来的

回到第 2 章 §2.4:TensorImpl::key_set_ 字段就是这个张量的 DispatchKeySet。但它是怎么算出来的?

简化的计算逻辑(c10/core/TensorImpl.cpp 的 TensorImpl 构造函数):

  1. 调用方传入基础 key:由设备、布局、是否量化决定,例如 CPU 稠密 → DispatchKey::CPU,CPU 稀疏 → SparseCPU,CPU 量化 → QuantizedCPU
  2. 构造函数补上 Autocast 键:按最高的 backend 加对应的 AutocastXxx
  3. 构造函数补上 Autograd 键:非 inference mode 下无条件加 ADInplaceOrView 和 AutogradXxx,与 requires_grad 无关(源码 TODO 写着"Ideally we only add AutogradBackend key when the tensor requires grad");inference mode 下则两者都不加

之后 key_set_ 基本不变,只有少数操作会改它,比如 _set_conj / _set_neg 增删 Conjugate / Negative,set_python_dispatch 增删 Python 相关键。

这个 "张量的所有元数据共同决定它的 dispatch 身份" 的设计是 PyTorch 多分派系统的精髓。第 5 章会拆 dispatcher 怎么把 key_set_ 与 thread-local 的 mode keys OR 后做 lookup。

3.8 横向对比:shared_ptr / Arc / Caffe2 旧引用计数

把三种方案放在一起:

维度 std::shared_ptr<T> c10::intrusive_ptr<T> Rust Arc<T>
引用计数位置 单独的控制块 嵌入 T 对象 单独的"内部"分配(与 T 一起 malloc)
Refcount 大小 通常 strong + weak 各 32-bit (两个 atomic) 单 atomic 64-bit (打包) strong + weak 两个 atomic
堆分配次数 2 次(除非 make_shared) 1 次 1 次
侵入式 否 是(必须继承 intrusive_ptr_target) 否
Python 集成 无 有(kHasPyObject 位) 无
Cache locality 差(控制块分离) 好 好

PyTorch 选 intrusive_ptr 是 极致性能 + Python 集成 + 侵入式可接受 三个条件叠加的产物。这个选择不是普世正确的 —— 普通 C++ 项目用 shared_ptr 就足够。但对一个张量创建 / 析构处在热路径上的库,这种"卡尺级别"的优化是性能差距的源泉之一。

值得一提的是 Boost 早就提供了 boost::intrusive_ptr<T> —— 思想几乎相同。c10 没有复用 boost,而是自己写了一份。两者的差别在于:boost 是个庞大依赖,而 c10 力求"几乎不依赖外部";boost 也没有 PyObject 保活机制,kHasPyObject 这一类 PyTorch 特有需求只能自己实现。所以 c10 的 intrusive_ptr 是"boost 思想 + PyTorch 需求"的二次创作,不是凭空发明。把它和 boost 放一起读,就能看清"通用智能指针"和"专为深度学习框架优化的智能指针"的区别。

从 git 历史看,intrusive_ptr 是在 Caffe2 与 PyTorch 合并期间加入的:2018 年 8 月先加在 ATen 的 core 目录下(PR #9897,提交说明是 "Add an IntrusivePtr class to do intrusive refcounting with a shared_ptr-like interface"),同年 11 月移到 c10/util/intrusive_ptr.h。

3.9 跨专栏关联

  • 《Tokio 源码深度解析》专栏第 3 章 Waker 机制:Tokio 大量用 Arc<T>,与 PyTorch 的 intrusive_ptr 是不同语言里相同思想的体现。值得对照看的细节是 Rust Arc 的 weak count 实现 —— 它也用了"如果 weak count 是 0 但 strong count > 0 时不可能 race"的 invariant
  • 《vLLM 推理内核深度解析》专栏第 6 章 Worker 与 Executor:vLLM 的 MessageQueue(vllm/distributed/device_communicators/shm_broadcast.py)用 multiprocessing.shared_memory 做共享内存环形缓冲,在进程间广播调度结果;它与 §3.12 讲的 PyTorch 跨进程共享 storage 是两套不同的共享内存生命周期管理,可以对照着看

3.10 一个练习:手写一个 mini-intrusive_ptr

为了内化本章内容,写一个 40 来行的 mini 版本:

#include <atomic>
#include <cassert>
#include <utility>

// mini 版 intrusive_ptr_target
struct MyTarget {
    mutable std::atomic<uint64_t> combined_refcount_{0};

    void incref() const { combined_refcount_.fetch_add(1, std::memory_order_relaxed); }
    bool decref() const {
        // 返回 true 表示这是最后一次 decref,可以析构
        return combined_refcount_.fetch_sub(1, std::memory_order_acq_rel) == 1;
    }

    virtual ~MyTarget() = default;
};

template <class T>
class my_intrusive_ptr {
    T* ptr_ = nullptr;
public:
    my_intrusive_ptr(T* p) : ptr_(p) { if (ptr_) ptr_->incref(); }
    my_intrusive_ptr(const my_intrusive_ptr& o) : ptr_(o.ptr_) { if (ptr_) ptr_->incref(); }
    my_intrusive_ptr& operator=(const my_intrusive_ptr& o) {
        if (this != &o) { reset(); ptr_ = o.ptr_; if (ptr_) ptr_->incref(); }
        return *this;
    }
    void reset() {
        if (ptr_ && ptr_->decref()) delete ptr_;
        ptr_ = nullptr;
    }
    ~my_intrusive_ptr() { reset(); }
    T* get() const { return ptr_; }
    T* operator->() const { return ptr_; }
};

// 使用
struct Foo : MyTarget { int x; Foo(int v) : x(v) {} };

int main() {
    my_intrusive_ptr<Foo> a(new Foo(42));
    {
        auto b = a;                 // refcount 1 → 2
        assert(a->x == b->x);
    }                               // b 析构, refcount 2 → 1
}                                   // a 析构, refcount 1 → 0, delete Foo

这几十行就是 c10::intrusive_ptr 最朴素的样子。把它跑起来、加 weak_intrusive_ptr 试试、再把 strong 和 weak 打包到单 atomic 里 —— 你就把这套设计的主干走了一遍。

3.11 weak_intrusive_ptr 与"我能否复活引用"

intrusive_ptr 还有一个孪生兄弟 weak_intrusive_ptr<T> —— 弱引用版本。语义类似 std::weak_ptr:持有它不阻止对象析构,但可以尝试"升级"成 strong intrusive_ptr。

它的关键 API 是 lock():

intrusive_ptr<T> lock() const noexcept {
    // 原子地尝试把 strong refcount 从 N (N > 0) 增加到 N+1
    // 如果当前 strong refcount 是 0,返回 null(对象已死)
}

实现要点是比较交换循环(CAS loop),下面是简化版(省略了 §3.7.2 讲的 kHasPyObject 分支,原文见 c10/util/intrusive_ptr.h 的 weak_intrusive_ptr::lock()):

auto current = target_->combined_refcount_.load(std::memory_order_relaxed);
do {
    if (refcount(current) == 0) return intrusive_ptr<T>();   // 对象已死,返回空
    // CAS 失败时 current 会被更新成最新值,循环重试
} while (!target_->combined_refcount_.compare_exchange_weak(
    current, current + kReferenceCountOne,
    std::memory_order_acquire, std::memory_order_relaxed));
return intrusive_ptr<T>(target_, raw::DontIncreaseRefcount{});  // 升级成功

CAS loop 是无锁并发编程的标准技巧。它保证 lock() 不会出现"我刚检查 refcount 还是 1,下一刻就掉到 0 然后我还成功 incref" 的 race condition。

弱引用在 autograd 里的真实用法,值得顺手澄清一个常见误传:SavedVariable(torch/csrc/autograd/saved_variable.h)保存前向张量用的是 strong 引用(字段就是 at::Tensor data_),弱引用用在另一处 —— std::weak_ptr<Node> weak_grad_fn_。它只在 is_inplace_on_view = true 时启用,源码注释说是为了防止 inplace view 在 rebase_history() 里泄漏。至于"被保存的张量正好是本次算子的输出"会形成的引用环(节点持有张量、张量的 grad_fn 又指回节点),torch/csrc/autograd/saved_variable.cpp 的做法是不存原张量,改存 variable.tensor_data() 这个不带 grad_fn 的浅拷贝,并把元数据另存,也不是靠弱引用。至于那句经典的 "one of the variables needed for gradient computation has been modified by an inplace operation",它来自第 2 章 §2.15 讲的 version_counter_ 比对,跟弱引用能不能 lock() 成功没有关系。

3.12 一个真实的踩坑:intrusive_ptr 与多进程

intrusive_ptr 在单进程 C++ 里几乎没坑,但跨进程时有趣的问题就出现了。考虑 PyTorch DataLoader 的 worker:

loader = DataLoader(dataset, num_workers=4)
for batch in loader:
    ...   # batch 是从 worker 子进程传过来的

worker 子进程里创建一个 tensor,要传到主进程。底层路径:

  1. worker 创建 TensorImpl + StorageImpl,refcount = 1
  2. 通过 torch.multiprocessing 用 shared memory 把 storage 的字节缓冲共享
  3. 主进程拿到一个新的 TensorImpl,指向同一段共享内存
  4. 主进程消费完,refcount 降到 0,析构

问题:worker 端和主进程端的 TensorImpl 是两个独立对象(不可能跨进程共享 C++ 对象),但底层字节缓冲是共享的。如果 worker 端的 storage 已经先析构了 refcount → 0,再来主进程析构,底层缓冲是不是会被释放两次?

PyTorch 的解法:StorageImpl 的 deleter 在跨进程场景里被换成"共享内存"语义。每个进程的 DataPtr 只负责 munmap 自己这一份映射;共享段本身的生命周期由 torch/multiprocessing/reductions.py 与 torch/csrc/StorageSharing.cpp 里那套引用管理(file_system / file_descriptor 两种共享策略)来决定,最后一个引用消失时才真的把段销毁。这是 data_ptr_ 的 DeleterFnPtr 抽象的真实战场。

如果你写过 torch.multiprocessing 出现 "shared memory leak" 或 "Bad file descriptor" 错误,根源往往是这套机制的某个边界条件没被覆盖 —— 最常见的两个诱因是 worker 异常退出后共享段没人回收,以及 /dev/shm 容量或 file descriptor 上限被打满。第 11 章 DataLoader 会展开讲。

3.13 intrusive_ptr 在调试时怎么用

实战提示:调试某个张量"为什么不被释放"时,可以用以下技巧:

import torch, sys

a = torch.randn(1000, 1000)
print(sys.getrefcount(a))           # Python 端 refcount
print(a._use_count())               # C++ 端 TensorImpl 的强引用计数(私有 API)
print(torch._C._storage_Use_Count(a.untyped_storage()._cdata))  # StorageImpl 的(私有 API;
                                    # 实跑得 2,多出的一份是 untyped_storage() 返回的临时对象)

sys.getrefcount 给的是 Python 端 PyObject 的 refcount,不是 C++ 端 TensorImpl 的 refcount —— 但通过 PyObject preservation 机制,两者紧密相关:Python refcount 触底意味着 C++ refcount 减 1。

如果你想看 C++ 端的 refcount,C++ API 上 Tensor::use_count() 一直可用(它转调 intrusive_ptr::use_count());Python 端没有公开接口,只有上面两个下划线开头的私有入口(torch/csrc/autograd/python_variable.cpp 的 _use_count、torch/csrc/Module.cpp 的 _storage_Use_Count),随时可能变动,只适合调试。

第 21 章 Profiler 那章会讲怎么用 PyTorch 的内存分析工具找出"哪些张量被某个对象 hold 住了"。理解了 intrusive_ptr 的 refcount 模型,那些诊断报告才能读懂。

3.14 c10 抽象的"几条不成文规则"

读完整章,整理一下 PyTorch 团队设计 c10 时反复体现的几条原则:

  1. 热路径决定性能预算 —— 任何加在 TensorImpl / Storage / 引用计数路径上的开销都会被每一次张量创建、析构放大
  2. release 模式相信调用者 —— 大量检查在 DEBUG 模式下断言、release 模式下删除(如 Device::validate)
  3. 历史向前兼容、内部向后演进 —— typed/untyped storage 共存、TypeMeta 保留 caffe2 时代抽象、Variable/Tensor 合并后老命名仍在
  4. 位操作优于查表 —— DispatchKeySet 用 64-bit bitmap、refcount 用单原子打包
  5. 侵入式优于通用 —— intrusive_ptr 牺牲了"任何 T 都能用"换来"零控制块开销"
  6. 全局单例做"空"哨兵 —— UndefinedTensorImpl(TensorBase 里 intrusive_ptr<TensorImpl, UndefinedTensorImpl> 的 NullType)
  7. 注释是设计文档 —— c10 关键文件里成百行的注释是核心维护者留下的设计史,比任何博客都权威

把这七条记在心里,后面再读 aten/、torch/csrc/autograd/、torch/_dynamo/ 时你能预判很多决策的方向。

3.15 一个 C++ 工程的"通用启示"

如果你不写 PyTorch、只是借鉴它的设计思想到自己的项目,本章有四条最值得带走:

第一:侵入式引用计数在性能敏感场景胜过 std::shared_ptr —— 但前提是你能控制基类、能接受"必须继承 X"的耦合。如果你的项目里有"必须高频共享、生命周期复杂、对 cache 敏感"的对象(比如游戏引擎里的 GameObject、数据库引擎里的 Page),照着 c10 的 intrusive_ptr 写一份非常划算。

第二:enum + bitmap 比 enum + std::variant 适合多维 dispatch —— 当你的对象需要按 N 个正交维度的笛卡尔积选择行为,与其用 std::variant<A, B, C, ...> 做 N-级 visit,不如用 N 个一维 enum 拼成一个 bitmap,然后做位操作。这种设计能把"多分派"的运行时开销压到"一次读取 + 几次位运算"。

第三:release 模式下"相信调用者"是可以的工程取舍 —— 但前提是 DEBUG 模式下要充分自检。c10 大量函数都是 DEBUG_ONLY 检查、release 模式删除,这种"双模式编译"的纪律不是偷懒,而是在性能敏感库里平衡安全与速度的标准手法。

第四:用注释而非外部文档承载设计意图 —— c10/core/DispatchKey.h 里贯穿整个枚举的注释、c10/util/intrusive_ptr.h:171-186 那段关于位 63 与 PyObject 保活的注释 —— 这些是 c10 演进过程中沉淀下来的"为什么"。把设计文档放在源码里,新维护者改代码时一定会看到、一定会被提醒。这种"文档贴近代码"的工程纪律比 wiki 上的"设计文档"有效得多。

下一章拆 CUDA Caching Allocator —— PyTorch 在 GPU 显存管理上做的最重要的工程优化之一,也是 torch.cuda.empty_cache() 常常不如预期的根本原因。