PyTorch Tensor 与 TensorImpl 机制解读

1. 为什么要把 Tensor 和 TensorImpl 分成两层

初次接触 PyTorch 源码的人常有一个疑惑:Python 里 torch.Tensor 明明是一个对象,为什么 C++ 里又有 at::Tensor 和 c10::TensorImpl 两个类?

答案是值语义 vs 数据本体的分离:

  • at::Tensor:一个轻量的"引用/handle",本身几乎不存储数据,可以随意拷贝、传参、放进容器,开销只是一次引用计数的加减。
  • c10::TensorImpl:真正持有 sizes/strides/dtype/device/storage 等元信息的对象,在堆上只有一份。

多个 at::Tensor 可以指向同一个 TensorImpl(比如 view 操作、赋值拷贝),它们共享同一份元信息和底层数据,改一个,另一个也能看到变化——这正是 PyTorch “view 语义"和"就地修改语义"的根基。

at::Tensor a;          at::Tensor b = a;   // 只拷贝了指针,refcount+1
     │                        │
     └──────────┬─────────────┘
                ▼
        c10::TensorImpl (堆上唯一一份)
        ├── sizes/strides
        ├── storage_ ──────────► StorageImpl(真正的数据 buffer)
        ├── dtype / device
        └── ...

这种设计在 C++ 里通过 c10::intrusive_ptr<TensorImpl> 实现:at::Tensor 内部只有一个 intrusive_ptr<TensorImpl> 成员,拷贝 Tensor 就是拷贝这个智能指针。

2. intrusive_ptr:为什么不用 std::shared_ptr

PyTorch 没有直接用 std::shared_ptr<TensorImpl>,而是自己实现了 c10::intrusive_ptr。核心原因是性能:

  • std::shared_ptr 需要额外分配一个控制块(refcount),对象和控制块是两次内存分配,且引用计数的加减要过一层间接寻址。
  • intrusive_ptr 要求目标类型(TensorImpl)自己继承 c10::intrusive_ptr_target,把 refcount 直接嵌入对象本身。这样创建 TensorImpl 时只需一次分配,Tensor 的拷贝/析构只是对这个内嵌计数器做原子加减,没有额外的控制块开销。

Tensor 在 PyTorch 里是被极高频次拷贝、传参的对象(几乎每个 op 调用都会拷贝若干个 Tensor 参数),这个优化在整体性能上是必要的。

3. TensorImpl 里到底存了什么

抛开各种为兼容历史遗留和特殊场景(quantized/sparse/nested 等)而存在的字段,TensorImpl 的核心成员大致是:

// c10/core/TensorImpl.h(节选,做了简化)
class C10_API TensorImpl : public c10::intrusive_ptr_target {
 protected:
  Storage storage_;                    // 指向底层数据 buffer

 private:
  std::unique_ptr<AutogradMetaInterface> autograd_meta_;  // autograd 相关(grad/grad_fn等),默认懒分配
  std::unique_ptr<ExtraMeta> extra_meta_;                  // named tensor 等不常用元信息,同样懒分配

 protected:
  c10::VariableVersion version_counter_;   // 版本号,用于 autograd 的就地修改检测
  impl::PyObjectSlot pyobj_slot_;          // 与 Python 端对象的关联(PyObject* 缓存)
  c10::impl::SizesAndStrides sizes_and_strides_;  // shape 与 stride

  int64_t storage_offset_ = 0;   // 相对 storage 起始地址的偏移(以元素计)
  int64_t numel_ = 1;            // 元素总数
  caffe2::TypeMeta data_type_;   // dtype 元信息
  std::optional<c10::Device> device_opt_;  // 设备信息

  DispatchKeySet key_set_;       // 决定这个 tensor 该走哪些 dispatch 分支
  ...
};

几个关键点:

  • storage_ 是分离的一层:TensorImpl 不直接管内存,而是持有一个 Storage(对 StorageImpl 的引用计数包装)。这就是为什么 x.view(...)、x[1:3]、x.t() 这些 view 操作可以做到"零拷贝”——它们创建一个新的 TensorImpl,改一下 sizes_and_strides_ 和 storage_offset_,但 storage_ 指向同一份底层数据。
  • sizes_and_strides_ 是 shape/stride 的核心存储,这也是 tensor 支持任意维度、任意 stride(包括负 stride、broadcast 用的 0 stride)的地方。
  • autograd_meta_ / extra_meta_ 都是懒分配的 unique_ptr:绝大多数 tensor 不需要 autograd 或额外元信息,用指针而不是内嵌成员可以让"不需要这些特性的 tensor"不为它们付出内存代价。
  • key_set_(DispatchKeySet)是分发机制的关键:每个 tensor 携带一组 dispatch key(如 CPU、CUDA、Autograd、Sparse 等),调用一个 op 时,PyTorch 的 dispatcher 根据参与运算的所有 tensor 的 key_set_ 的并集,决定这次调用具体落到哪个 backend 的 kernel 实现、要不要先经过 autograd 层记录计算图。这也是 PyTorch 能同时支持 CPU/CUDA/autograd/量化等多套机制正交组合的基础。

4. Storage / StorageImpl:数据本体又是另一层

TensorImpl 里的 storage_ 是 c10::Storage,它是对 c10::StorageImpl 的 intrusive_ptr 包装,StorageImpl 才是真正持有裸内存的对象:

// c10/core/StorageImpl.h(节选)
struct C10_API StorageImpl : public c10::intrusive_ptr_target {
  at::DataPtr data_ptr_;      // 真正的裸内存指针(带自定义 deleter)
  SymInt size_bytes_;         // 字节数
  bool resizable_;
  at::Allocator* allocator_;  // 分配器(不同 device 有不同实现)
  ...
};

为什么又要单独抽一层 Storage?因为"元信息"(shape/stride/dtype)和"数据本体"的生命周期、共享方式并不总是一致:

  • 一个 Tensor 做 view/reshape/切片后,会产生新的 TensorImpl(不同的 shape/stride/offset),但共享同一个 StorageImpl(同一份底层数据)。
  • Storage 这一层还承担了 CPU tensor 与 numpy 数组共享内存、torch.from_blob 包装外部内存等场景,这些场景下"元信息"完全是 PyTorch 自己的,但"数据"来自外部,两层分离让这类需求可以干净地表达。

5. 自定义 TensorImpl:为什么以及怎么做

PyTorch 允许第三方后端(backend)继承 c10::TensorImpl 写自己的子类,这是"外接后端"(out-of-tree backend)机制的核心之一。常见动机是:在标准元信息之外,给 tensor 附加一些只有这个后端才关心的状态,同时复用 PyTorch 整套 dispatcher/autograd/view 机制。

一个最小的自定义 TensorImpl 大致要做这几件事:

class MyTensorImpl : public c10::TensorImpl {
 public:
  MyTensorImpl(c10::Storage&& storage, const caffe2::TypeMeta& dtype)
      : c10::TensorImpl(std::move(storage), key_set_for_my_backend(), dtype) {}

  // 1. shallow_copy_and_detach:detach() 等操作要用到,
  //    要求返回"同类型"的一个新 TensorImpl,共享 storage,但独立的 autograd 身份。
  c10::intrusive_ptr<c10::TensorImpl> shallow_copy_and_detach(
      const c10::VariableVersion& version_counter,
      bool allow_tensor_metadata_change) const override;

  // 2. shallow_copy_from:set_data() 等操作会调用,
  //    把另一个 impl 的元信息浅拷贝到当前对象(storage 指针、sizes/strides 等),
  //    但保留当前对象的 autograd 身份(version_counter 由调用方决定是否替换)。
  void shallow_copy_from(const c10::intrusive_ptr<TensorImpl>& impl) override;

 private:
  // 这里可以放任意自定义状态:缓存标记、后端私有 handle、统计信息……
  MyCustomState state_;
};

shallow_copy_and_detach 和 shallow_copy_from 是必须重写的两个虚函数(PyTorch 源码里专门有一段 NOTE [ TensorImpl Shallow-Copying ] 注释解释语义),原因很直接:PyTorch 内部(尤其 autograd 引擎和 Tensor.detach() / Tensor.set_data())需要在"不知道具体子类是什么"的情况下,也能正确地复制出一个同类型的新实例,而不是意外退化成基类 TensorImpl。如果不重写,子类特有的状态在 detach/set_data 之后就会丢失或类型错乱。

除此之外,创建自定义 TensorImpl 实例要用 at::detail::make_tensor<T>(...) 而不是直接 new:

at::Tensor t = at::detail::make_tensor<MyTensorImpl>(std::move(storage), dtype);

这个工厂函数负责把 T 的实例包进 intrusive_ptr,再包成 at::Tensor,保证引用计数和析构路径正确。

5.1 展开看这一行到底做了什么

make_tensor<T> 不是什么特殊魔法,顺着调用链往下看,其实是三层很朴素的包装(以 TensorBase 为例,Tensor 的逻辑一致):

// aten/src/ATen/core/TensorBase.h
namespace detail {
template <typename T, typename... Args>
TensorBase make_tensor_base(Args&&... args) {
  return TensorBase(c10::make_intrusive<T>(std::forward<Args>(args)...));
}
}

再往下,make_intrusive<T> 只是对 intrusive_ptr<T>::make 的转发:

// c10/util/intrusive_ptr.h
template <class TTarget, class... Args>
static intrusive_ptr<TTarget> make(Args&&... args) {
  return intrusive_ptr(new TTarget(std::forward<Args>(args)...));
}

也就是说最终确实是 new T(...),并没有绕开 new——真正的关键在**new 出来之后立刻发生了什么**。看 intrusive_ptr 接管这个裸指针时的构造函数:

explicit intrusive_ptr(TTarget* target)
    : intrusive_ptr(target, raw::DontIncreaseRefcount{}) {
  if (target_ != NullType::singleton()) {
    TORCH_INTERNAL_ASSERT_DEBUG_ONLY(
        target_->refcount_ == 0 && target_->weakcount_ == 0,
        "...");
    target_->refcount_.store(1, std::memory_order_relaxed);
    target_->weakcount_.store(1, std::memory_order_relaxed);
  }
}

这里有一个隐含的强约束:新 new 出来的对象,refcount_/weakcount_ 必须是 0(因为 TensorImpl 继承的 intrusive_ptr_target 里这两个计数器默认初始化为 0),构造函数看到之后把它们手动置为 1,代表"现在有且仅有这一个 intrusive_ptr 持有它"。这一步如果不做,或者自己 new 之后随手包一个 intrusive_ptr,计数器初值不对,后续的引用计数加减就全错了——这也是为什么源码专门写了断言防止在自定义构造函数里"意外"提前创建了指向 this 的 intrusive_ptr(注释里点名了这种误用)。

最后一层,TensorBase(intrusive_ptr<TensorImpl, ...> tensor_impl) 构造函数只是把这个已经计数正确的智能指针存进 impl_ 成员,并检查非空:

explicit TensorBase(c10::intrusive_ptr<TensorImpl, UndefinedTensorImpl> tensor_impl)
    : impl_(std::move(tensor_impl)) {
  if (impl_.get() == nullptr) {
    throw std::runtime_error("TensorImpl with nullptr is not supported");
  }
}

串起来就是:

make_tensor<T>(args...)
  └─ make_tensor_base<T>(args...)
       └─ TensorBase( make_intrusive<T>(args...) )
                          └─ intrusive_ptr<T>::make(args...)
                                └─ intrusive_ptr( new T(args...) )   // 唯一一次 new
                                     └─ refcount_ = 1; weakcount_ = 1;

一次 new 分配对象本体,紧接着由 intrusive_ptr 的构造函数把内嵌的计数器初始化为 1,再把这个已经"计数正确"的指针搬进 TensorBase/Tensor 的 impl_ 字段。整个过程只有一次堆分配(对象和计数器是同一块内存,因为计数器就是 intrusive_ptr_target 的成员),这也是前面第 2 节提到 intrusive_ptr 比 shared_ptr 省一次控制块分配的具体落地位置。

6. 自定义 TensorImpl 能挂载什么样的状态

只要不破坏上面两个虚函数的语义,子类里几乎可以放任何自定义成员:布尔标记、缓存指针、指向额外 side-table 的 handle、状态机对象等等。工程上有两个常见考量:

  • ABI 稳定性:如果这个 TensorImpl 子类会被编译进独立的动态库、又被另一个独立编译的模块使用,直接暴露成员变量会导致"新增/删除成员"就破坏二进制兼容。常见解法是 PIMPL(Pointer to IMPLementation)——头文件里只暴露一个 unique_ptr<Impl> 和一组访问函数,真正的成员藏在 .cpp 里的 Impl 结构体中,日后增删这些私有成员不影响已编译好的调用方。
  • 状态跟 tensor 生命周期绑定:把状态放进 TensorImpl 成员,天然享有和 tensor 完全一致的生命周期——tensor 被析构,状态自动跟着释放,不需要额外维护一个"tensor → 状态"的全局哈希表、也不需要担心忘记手动清理导致内存泄漏。这是相比"用外部 map 关联 tensor 和状态"更稳健的一种模式。

7. 小结

  • at::Tensor 是值语义的引用句柄,真正的元信息全部在堆上的 c10::TensorImpl 里,两者靠 c10::intrusive_ptr 关联,拷贝 Tensor 只是引用计数的加减。
  • TensorImpl 本身也不直接持有数据,数据在 Storage/StorageImpl 里,这一层分离是 view/reshape 等零拷贝操作和 tensor 与外部内存互操作的基础。
  • key_set_(DispatchKeySet)是 tensor 参与算子分发的"路由标签",决定一次调用具体落到哪个 backend kernel、是否经过 autograd。
  • 自定义后端可以继承 TensorImpl 附加私有状态,代价是必须正确实现 shallow_copy_and_detach / shallow_copy_from,并通过 at::detail::make_tensor<T> 来创建实例;把状态挂在 TensorImpl 上的核心好处是生命周期自动跟随 tensor,不需要额外的全局注册表。