TensorPlay
三层内存栈:字节、容器、类型化张量

Tensor 简单来讲就是「一块内存,附带形状、步长、设备等属性」。

Tensor 系统的核心思想是:底层字节必须共享,但视图必须各自独立。b = a[0] 可以是同一块内存,不一样的形状;反过来 a + 1 就是新内存。视图+克隆,这两件事很难在「张量类」同时兼顾。

TensorPlay 设计了三层表示模型。

为什么是三层

假设只有一层:Tensor 类直接持有 malloc 获取的指针,以及形状、步长、设备。很难兼顾以下情形:

  • b = a[0] 共享字节、但形状不同。
  • CPU 上内存来自缓存分配器,CUDA 上来自 cudaMalloc,还有包外部 buffer 的场合。释放逻辑异构。
  • 自动微分的梯度历史,拷贝时丢弃。一个张量类管理逻辑复杂。

三层模型:Storage -> TensorImpl -> Tensor

先看最底下的 DataPtr:

class DataPtr {                        // move-only 句柄
  void*        data_;                  // 交给调用者的数据指针
  void*        ctx_;                   // 传给删除器的上下文(未必等于 data_)
  DeleterFnPtr deleter_;               // void(*)(void*):裸函数指针,无捕获
  Device       device_;                // 设备随分配一起走

  DataPtr(const DataPtr&) = delete;            // 拷贝被显式删除
  DataPtr& operator=(const DataPtr&) = delete;
  DataPtr(DataPtr&& other) noexcept : DataPtr() { swap(other); }
  ~DataPtr() { clear(); }
  void clear() { if (deleter_) deleter_(ctx_);
                 data_ = ctx_ = nullptr; deleter_ = nullptr; }
  void* release() { void* p = data_;          // 调用者接管所有权
                    data_ = ctx_ = nullptr; deleter_ = nullptr; return p; }
};

删除器:同一段字节,malloc 来的要 free,new[] 来的要 delete[],cudaMalloc 来的要交回显存的释放接口,外部传进来的内存则根本不能动。销毁方式和分配相关,所以做成 deleter_,装进同一个句柄,跟 data_ 一起移动、一起消失;ctx_ 是销毁时传给它的参数,大多数时候就是数据指针本身,需要附带额外状态时再单独传。

拷贝:删除器带着所有权,拷贝一次就是 double free 一次。要转移就 release();包外部内存,就挂一个什么都不做的删除器。张量可以被随便分享,但销毁责任只有一份,而且它挂在 DataPtr 上。

分配器。训练时的内存形态极其单调:分配、释放、再来一块一样大的。直接 malloc/free 的话,尺寸复用完全交给系统库,无法满足「释放后立刻再要同尺寸」高频模式。所以分配器自己维护一张空闲表:每块多切出 64 字节头部记尺寸,释放时把块按尺寸还回去,下次同尺寸请求直接拿走。

class CachingAllocator : public Allocator {
  struct Header { size_t size; };                    // 记录整块分配的尺寸
  static constexpr size_t HEADER_SIZE = 64;          // 头部 64 字节,保住对齐
  std::unordered_map<size_t, std::vector<void*>> free_blocks_;  // 尺寸桶 -> 空闲块

  DataPtr allocate(size_t nbytes) const override {
    size_t total = (nbytes + HEADER_SIZE + 63) & ~63; // 请求+头部,取整到 64 的倍数
    void* ptr = nullptr;
    auto it = free_blocks_.find(total);               // 先查桶
    if (it != free_blocks_.end() && !it->second.empty()) {
      ptr = it->second.back();                       // 有块:拿最近还回来的一块
      it->second.pop_back();
    }
    if (!ptr) ptr = alloc_aligned(total);             // 桶空:才向系统要一块
    reinterpret_cast<Header*>(ptr)->size = total;     // 实际尺寸写进头部
    return DataPtr(static_cast<char*>(ptr) + HEADER_SIZE,
                   deleter, Device(DeviceType::CPU)); // 数据指针 = 头部地址 + 64
  }

  static void deleter(void* ptr) {                    // 释放侧:不用查任何表
    char*  raw  = static_cast<char*>(ptr) - HEADER_SIZE;
    size_t size = reinterpret_cast<Header*>(raw)->size;
    instance()->free_blocks_[size].push_back(raw);    // 直接回桶,不碰系统调用
  }
};

桶怎么分:取整到 64 的倍数。请求 100 字节和 124 字节进同一个桶,尺寸相近的分配共享同一批块;不取整,每种尺寸一个桶,空闲表又大又散,碎片多。桶只给实际用过的尺寸开,所以是 unordered_map 而非定长数组。

拿块的方向:back(),后进先出。刚还回来的块大概率还热在 CPU 缓存里,再分配时拿走它最划算,push_back/pop_back 都是 O(1)。

分配和释放不对称的:分配要查表(find(total)),释放完全不查——块头上写的就是取整后的尺寸,减 64 读出来,直接回桶。

分配策略是按设备的。核心代码不写死分配器,按设备类型取分配器的入口:CPU 是上面的缓存分配器单例,CUDA 构建下换成自己的实现,另有 pinned memory 分配器可取。上层(Storage 构造、克隆、拷贝)一律写 getAllocator(device.type()),换分配器实现不用碰核心;跨设备拷贝也有统一入口,目标侧分配、按双方设备选拷贝路径。

class Allocator {
 public:
  virtual DataPtr allocate(size_t nbytes) const = 0;
  virtual DataPtr allocate(size_t nbytes, const Device& device) const {
    (void)device; return allocate(nbytes);      // 单设备后端的默认实现
  }
};
Allocator* getAllocator(DeviceType t);   // 按设备类型分发
Allocator* getCPUAllocator();            // CPU 单例

设备「键」:分配按它选策略,后面调度器还会按它选内核。同一个字段,两层用途。

Storage :数据指针、字节数、分配器、可变长标志,套一层自研的侵入式引用计数指针 intrusive_ptr<StorageImpl>(计数嵌在 StorageImpl 里),就「可以被多个 TensorImpl 安全地共享」。判断两个 Storage 是不是同一个,就是比 StorageImpl 指针。

intrusive_ptr 是引用计数指针的一种,它的计数不放在指针旁边,而是嵌在被指的对象自己身上。StorageImpl 继承一个基类,这个基类里就存着计数。于是句柄本身只有一个机器字——一个裸指针;拷贝一次,就是对嵌在对象里的计数做一次松弛原子自增,不用像 std::shared_ptr 那样再带一个指向「控制块」的指针、多一次间接跳转。强引用和弱引用合在同一个 64 位原子字里,高位是弱计数、低位是强计数,一次原子操作能同时看到两者;计数归零时对象自己 delete,所以对象必须是堆分配的,用 make_intrusive 创建。TensorPlay 没用现成的库,而是自己造了一份——因为框架里到处都要对 TensorImpl、StorageImpl 做引用计数,这份东西值得自己写。

注意区分:主所有权链(Tensor → TensorImpl → Storage → StorageImpl)用的是这份自研指针;而 autograd_meta_、quantizer_ 这类辅助元数据用的仍是 std::shared_ptr。原因很简单:这些元数据不是「被共享的字节」,而是一段可选的额外状态,生命周期短、从不参与视图别名,用现成的控制块指针省心,不必为它们再把计数嵌进对象里。

TensorImpl:扁平、内联

SparseTensor、QuantizedTensor 这些变体,做成子类还是做成字段?

继承方案面向对象,实际是挖坑:每种变体一套虚函数,内核代码里到处 dynamic_cast;头文件层次一旦立起来,改一个字段牵动整棵继承树重编;而绝大多数张量根本不是稀疏也不是量化。TensorPlay 选扁平类——所有状态在一个 struct 里,稀疏、量化、vmap 都做成可空字段,非稀疏张量为这些能力只付几个空指针的开销。

class TensorImpl {                          // 一个扁平类,八组字段
  size_t          storage_offset_;          // ① 元素级偏移(视图从中间开始看)
  SizesAndStrides sizes_and_strides_;       // ② 形状与步长
  DType dtype_; Device device_;             // ③ 类型与放置
  VariableVersion version_counter_;         // ④ 原子版本计数器
  bool      inference_tensor_;              //    推理张量:整体禁用计数
  bool      is_contiguous_;
  MemoryFormat memory_format_;              // ⑤ 布局标签
  std::shared_ptr<AutogradMetaBase> autograd_meta_;  // ⑥ 梯度历史(从不拷贝)
  bool      is_view_;
  Storage   storage_;                       // ⑦ 引用计数的字节
  intrusive_ptr<TensorImpl> transform_value_;  // ⑧ vmap 批处理包装(自研指针)
  std::shared_ptr<Quantizer>  quantizer_;   //    量化器(普通共享指针)
};

形状步长再单独拆出一个 SizesAndStrides,省掉 new 的代价。new 的代价大部分不在系统调用上——小块分配走分配器快路径,几十纳秒的事;真正吃亏的是后面:每次分配要进分配器的地址簿找块、摘块、记一笔账;新块是冷的,跟旁边的对象不挨着,之后遍历、比较、拷贝 sizes 时大概率缓存未命中;再加上两次分配、两次析构。所以秩不超过 5 就用两个定长数组内联在对象里,构造、拷贝、销毁全部绕开堆——多出来的代价只是 TensorImpl 本体大了 80 字节;超过 5 才落堆,缩回去自动释放。

class SizesAndStrides {
  static constexpr size_t kInlineSize = 5;   // 秩 ≤5 全内联
  size_t  size_ = 0;                          // 秩
  int64_t inline_sizes_[5]   = {};            // 定长内联数组
  int64_t inline_strides_[5] = {};
  std::unique_ptr<int64_t[]> heap_sizes_, heap_strides_;  // 超过 5 维才落堆

  const int64_t* sizes_data() const {
    return is_heap() ? heap_sizes_.get() : inline_sizes_;  // 一次分支
  }
};

连续步长的生成公式就一行:strides[i] = strides[i+1] * max(size[i+1], 1),末维步长为 1。形状 (2,0) 算出来是 (1,1),不会除零。

Tensor 本体很薄:唯一的数据成员是自研的 intrusive_ptr<TensorImpl>,外加代码生成的方法。

版本计数器

张量为什么需要知道「自己被改过几次」?

因为 backward :反向传播要用前向时的中间值。y = x.exp() 的反向需要重放 exp 的输出,这个输出被保存下来。如果用户在 backward 之前原地改了它,框架只有两个选择:用错值,静默算出错的梯度;或者拒绝。TensorPlay 选后者——保存时记下版本号,解包时对不上就抛异常。。

版本守卫时间线:SavedVariable 如何拦下静默错误

struct VersionCounter {
  std::atomic<uint32_t> version_{0};
  void    bump()             noexcept { version_.fetch_add(1, std::memory_order_relaxed); }
  uint32_t current_version() const noexcept { return version_.load(std::memory_order_relaxed); }
};

class VariableVersion {                      // 别名之间共享同一个计数器对象
  std::shared_ptr<VersionCounter> counter_;
  bool enabled_ = true;
public:
  VariableVersion()                          // 急切分配:构造即建计数器
    : counter_(std::make_shared<VersionCounter>()) {}
  void bump() { if (!enabled_) return; counter_->bump(); }
  bool shares_counter(const VariableVersion& o) const {
    return counter_ && counter_ == o.counter_;             // 指针同一性
  }
};

容易觉得构造函数里的 make_shared 多此一举——等第一次修改再建不行吗?不行,问题出在视图。视图必须和基张量共享计数器(改视图就是改基张量),而共享靠的是两个 VariableVersion 里的 shared_ptr 指向同一个对象。懒分配的话,先创建的视图会各拿各的计数器,之后原地改基张量,视图那边版本纹丝不动。守卫还在,但已经守不住任何东西了——这类错误不会崩,只会偶尔给出错梯度,是最难查的一类。所以计数器宁可白付一次分配,也要保证「创建即同源」。

推理模式下计数器整体禁用,省掉原子操作;但在推理模式外改推理张量会直接抛异常。

跑一下这个守卫:

import tensorplay as tp

x = tp.Tensor([1.0, 2.0], requires_grad=True)
h = x.exp()         # 反向传播需要重放 h 的前向输出
print(h._version)   # 0
h.add_(1.0)         # 原地修改,版本 0 -> 1
y = h.sum()
y.backward()
# RuntimeError: one of the variables needed for gradient computation
# has been modified by an inplace operation

拷贝构造

TensorImpl 的拷贝构造函数,是整个类里信息密度最高的十几行。它要回答一个问题:TensorImpl b = a,哪些东西是「数据本身」要跟着走,哪些是「这段数据的来历」不能跟着走?

答案全在初始化列表的取舍里:

TensorImpl::TensorImpl(const TensorImpl& other)
    : storage_offset_(other.storage_offset_),
      sizes_and_strides_(other.sizes_and_strides_),   // 形状步长:拷贝
      dtype_(other.dtype_), device_(other.device_),
      version_counter_(other.version_counter_),        // 计数器:共享同一对象
      is_contiguous_(other.is_contiguous_),
      memory_format_(other.memory_format_),
      storage_(other.storage_),                        // 存储:共享(拷贝即别名)
      sparse_state_(other.sparse_state_),              // 稀疏组件:共享
      transform_value_(other.transform_value_),        // 批处理包装:共享
      quantizer_(other.quantizer_) {}                  // 量化器:共享
// autograd_meta_ 刻意不在列表里 —— 拷贝从“零历史”开始

存储被共享了,所以 b = a 是别名,改 b 的数据就是改 a 的数据。版本计数器也被共享了。

而 autograd_meta_ 不在列表里:拷贝出来的张量没有梯度历史,因为历史描述的是「这个值怎么来的」。设想它被顺走了——b = a 之后 b.backward() 会把梯度灌进 a 的父节点,等于在用户看不见的地方替他消费了 a,而且不报任何错。detach() 被做成「拷贝 + 清空历史」而不是一种新视图,也是同一个判断:共享 TensorImpl 就会共享不该共享的东西。

视图

视图语义有三条不变式:复用基张量的 Storage、别名它的版本计数器、有量化器就顺路带上。麻烦在于视图操作有一大家子——view、transpose、select、slice、permute、跨 dtype 视图。不变式要是写在六七个算子里,每个各写一遍,总有一天有人加新视图时漏掉一条——比如忘了共享计数器,版本守卫从此对这种视图静默失效,而这在测试里几乎不会暴露。

TensorPlay 的做法是把所有别名操作收口到一个 as_strided,不变式只写一次:

Tensor Tensor::as_strided(const std::vector<int64_t>& size,
                          const std::vector<int64_t>& stride,
                          std::optional<int64_t> storage_offset) const {
  const int64_t offset =
      storage_offset.value_or(impl_->storage_offset());   // 缺省沿用基偏移
  if (impl_->device().is_vulkan()) {
    /* Vulkan 无步长寻址:唯一走内核物化的分支 */
  }
  Tensor out = Tensor(impl_->storage(), size, stride,
                      impl_->dtype(), offset);            // ① 复用基张量 Storage
  out.unsafeGetTensorImpl()->share_version_counter(*impl_); // ② 别名版本计数器
  if (impl_->has_quantizer())
    out.unsafeGetTensorImpl()->set_quantizer(impl_->quantizer()); // ③ 携带量化器
  return out;
}

于是每个具体视图薄到只剩偏移算术,算完交给 as_strided:

  • select(dim, i):新偏移 = storage_offset + i * stride[dim],删掉一个维度
  • slice(start, step):偏移前进 start * stride[dim],步长缩放 step
  • view:推断 -1 维,计算兼容步长,块结构对不上就报错

视图零拷贝:两个标签,一个仓库,共享版本

Vulkan 是唯一绕开这条路的:shader 里的数据没有步长寻址,视图只能物化成真实拷贝。

克隆是视图的对照面,它的不变式只有两条:字节全新、版本从零。默认策略下,不重叠且稠密的输入保留原步长(转置克隆出来还是转置布局),否则退回行优先;CPU 上两端都行主时直接 memcpy。最容易漏看的一行:结尾的 reset_version。内部那次 copy_ 会碰目标计数器,而克隆出来的是个从未被改过的新值,版本必须归零——不然第一次对账就会误报。

Tensor detail::clone_impl(const Tensor& self, std::optional<MemoryFormat> fmt) {
  const auto format  = fmt.value_or(MemoryFormat::Preserve);
  const auto sizes_v = static_cast<std::vector<int64_t>>(self.shape());
  std::vector<int64_t> strides;
  if (format == Preserve &&
      SizesAndStrides::is_non_overlapping_and_dense(sizes_v, self.strides()))
    strides = static_cast<std::vector<int64_t>>(self.strides()); // 转置克隆出转置
  else
    strides = SizesAndStrides::compute_contiguous_strides(sizes_v); // 落回行优先

  Storage storage(numel(self) * itemsize(self),          // 全新字节
                  getAllocator(self.device().type()), self.device());
  Tensor t(std::make_shared<TensorImpl>(storage, sizes_v, strides, self.dtype(), 0));

  if (self.device().is_cpu() && row_major(strides) && row_major(self.strides()))
    std::memcpy(t.data_ptr(), self.data_ptr(), numel(self) * itemsize(self)); // 快路径
  else
    t.copy_(self);                                       // 感知布局的拷贝

  t.unsafeGetTensorImpl()->reset_version();              // 克隆从“未被修改”开始
  return t;
}

这些行为实测:

import tensorplay as tp

a = tp.Tensor([[1., 2., 3.], [4., 5., 6.]])
v = a[0]                                # 视图
print(a.data_ptr() == v.data_ptr())     # True —— 同一块存储
c = a.clone()                           # 克隆
print(a.data_ptr() == c.data_ptr())     # False —— 新存储
v[0] = 99.0                             # 改视图,a 跟着变
c[0] = -1.0                             # 改克隆,a 不动
print(a)
# tensor([[99.,  2.,  3.],
#         [ 4.,  5.,  6.]])

绑定层:Python 对象怎么变成 C++ Tensor

先澄清一件事:开头那行 b = a,在 Python 里其实什么都没发生——b 和 a 是同一个 PyObject,引用计数加一,C++ 层连拷贝构造都没被惊动。前面四个问题问的是 C++ 层 Tensor b = a 的拷贝构造;Python 里真正穿过绑定层的,是 a[0]、a.clone() 这些操作,参数拆进去、结果包回来,都在这一层。

回包(C++ → Python):tpx_py_wrap 拿 TensorImpl 指针当键查一张缓存表,命中就返回已有的那个 Python 对象——同一个 impl 永远不会被包成两个对象,Python 身份的同一性和 C++ 身份的同一性一一对应。包装身上挂着一个胶囊属性,Python 端垃圾回收把它收走时,胶囊的析构函数顺手抹掉缓存条目,不留脏引用。

拆包(Python → C++):tpx_py_tensor 系按值、按引用、按可变引用三种:按引用省一对引用计数操作,原地算子必须走可变引用,不然写的是副本。进内核前还有一次整体探测,版本号和 requires_grad 一次调用直接从 C++ 侧读出来,不是合法包装就返回 -1,不抛异常。

入口:双路径。pybind11 桥+代码生成的 FASTCALL 入口——参数数组直达,不搭临时 tuple;生成表注册时先查重,pybind11 已经装好的名字直接跳过。参数解析在生成的入口里做:多重重载先用不抛异常的类型探测挑候选(16 种类型加一个 optional 标志位),命中后再做正式检查。内核执行期间 GIL 是放开的,大矩阵运算不占着解释器。

这些取舍的落点

上面每道选择题都在同一个地方收口:引用计数嵌在被指对象里,代价是自己造一个指针类型,换来句柄只有一个机器字。

  • 扁平实现类:TensorImpl 不走继承树。稀疏、量化不是子类,而是同一类上的几个空指针——布局独立的稀疏张量与量化张量只在用到时才付代价,稠密路径上多出来的只是一次指针判空。
  • 计数合并进一个字:intrusive_ptr 把强计数放低位、弱计数放高位合成一个 64 位原子字,一次原子操作同时看到两者。句柄拷贝是一次松弛原子自增,句柄本身一个机器字,没有「另放一份计数」的第二个机器字,也没有控制块的额外间接跳转。代价是 make_intrusive 要求堆分配,这是 p10/include/IntrusivePtr.h 里写下来的交换。
  • 绑定层:Python 入口叠在 pybind11 上,但热路径走生成的 FASTCALL 入口——参数数组直达,回包有身份缓存,拆包有不抛异常的类型探测,尽量不在 Python 层造中间对象。

参考资料