Tensor 是 PyTorch 的数据基座:shape/stride/dtype/device 四要素描述「如何解释数据」,Storage 决定「数据实际放在哪里」。理解视图与存储共享,是掌握广播、NumPy 互操作与显存优化的前提。

在 PyTorch 架构总览 的分层设计中,Tensor 与 Storage 处于最底层的数据与计算层,是 nn、autograd 等上层的共同地基。

为什么值得单独深挖这一层?因为它解释了一连串日常会撞见的现象:为什么 t.t() 在亿级张量上是 O(1) 操作;为什么一句 reshape 能让显存悄悄翻倍;为什么 NumPy 数组改一个元素,对应的 Tensor 跟着变;为什么布尔掩码筛选之后改结果不影响原张量。这些问题的答案都是同一个:Tensor 只是指针加元数据,数据另有居所。

四要素:Tensor 的元数据

Python 里的 t 看着像「多维数组」,本体却轻得惊人——C++ 侧是一个 TensorImpl 结构,装的全是元信息,真正的数据放在独立的 Storage 里,Tensor 只持有一个指针。元数据回答四个问题:多少数据(shape)、怎么排布(stride)、什么类型(dtype)、放在哪(device)。

import torch
 
t = torch.arange(12, dtype=torch.float32).reshape(3, 4)
t.shape    # torch.Size([3, 4]),各维长度,元素总数 numel() = shape 连乘
t.stride() # (4, 1),沿各维移动一步跨越的元素数
t.dtype    # torch.float32,元素类型
t.device   # cpu,数据所在设备

shape 是 torch.Size(tuple 子类),元素总数由各维连乘得到:。改 shape 不等于改数据——reshape 只是把同一块内存换一种切分方式去读,这一点贯穿全文。

stride 是四要素中最不被熟悉、却最有解释力的一个。多维索引如何落到一维内存,由一条寻址公式决定:

其中 是 storage_offset(起点元素在 Storage 中的下标), 是第 维的索引, 是第 维的 stride——沿该维移动一步跨越的元素数。多维索引 代入公式,得到的便是该元素在 Storage 一维数组中的落点。本篇所有「零拷贝」现象都是这个公式的推论:凡只改动公式参数(offset/shape/stride)而不动 Storage 的操作,都是免费的。公式逐维展开与手动执行见 Tensor Stride 与视图。

dtype 决定每个元素占几位、如何解读比特。默认 float32;深度学习工作流里真正高频的是这几种:

dtype位宽典型用途
float3232默认训练与推理精度
float16 / bfloat1616AMP 混合精度,省一半显存与带宽
int6464类别标签,CrossEntropyLoss 的 target 要求此类型
bool8掩码(padding mask、注意力掩码)

device 记录数据在 cpu 还是 cuda:N。参与同一运算的张量必须同设备,混用直接报 Expected all tensors to be on the same device——报错信息会列出两个张量各自的位置,定位不难,难的是养成数据流「在哪个设备上走完全程」的意识。

除四要素外,Tensor 头部还有两个后面会用的字段:storage_offset(寻址公式的起点,视图操作的常客)和 requires_grad(是否记录求导历史,见文末)。

Storage 与视图:数据另有居所

Storage 是一段一维连续内存,不携带任何形状语义——它不知道自己是 3×4 还是 2×6,形状是 Tensor 头强加上去的解读。完整的等式是:

也就是说,一个 Tensor 由「指向真实内存的指针」加一组描述如何解读这块内存的元数据构成。如此分离设计的好处显而易见:切片、转置、unsqueeze 这些高频操作只需新建一个几十字节的 Tensor 头,完全不碰几百 MB 的数据本体。你可以这样验证:

a = torch.arange(6).reshape(2, 3)
b = a.t()          # 转置:stride 从 (3,1) 变 (1,3),共享同一 Storage
b.is_contiguous()  # False
a[0, 0] = 99
b[0, 0]            # 99,内存共享的直接证据
a.untyped_storage().data_ptr() == b.untyped_storage().data_ptr()  # True,同一块内存

Note

is_contiguous() 判断标准:当沿着最后一维(最内层维度)增加索引时,内存地址必须连续递增 1 个元素;且所有外层维度的 stride,必须等于内层所有维度大小的乘积。更多阅读Tensor 内存连续性。

data_ptr() 返回 Storage 的起始地址,两个视图地址相同即共享。日常排查「改了这边那边也变」的诡异 bug 时,这一行是最直接的取证手段。

按照对 Storage 的处理策略,可以把常见操作分成三类:

  1. 共享 Storage(零拷贝):切片等基本索引、t()、view、squeeze/unsqueeze、expand、detach
  2. 新建 Storage(复制数据):out-of-place 运算结果、clone、高级索引(布尔掩码/张量索引)、无法共享时的 reshape、跨设备/dtype 的 to
  3. 原地改写:以 _ 结尾的 in-place 运算(add_、relu_、copy_),不新建 Tensor,直接写自身 Storage

clone() 与 detach() 是清单里一对容易混淆的对照:前者复制数据、切断共享;后者不复制数据、只切断计算图——detach() 出来的张量与原张量仍共享同一块 Storage。

view 与 reshape:两种改形状的哲学

改 shape 的 API 只有两个,都想零拷贝地共享 Storage,分歧在「做不到时怎么办」——view 报错拒绝,reshape 复制兜底:

操作前提零拷贝不可行时
view新 shape 能由现有 stride 直接读出抛 RuntimeError,把问题亮给你
reshape无先 contiguous() 复制再 view,把代价藏起来

view 的前提翻译成大白话:不搬一个元素,现有内存排布直接套进新 shape。contiguous 张量必然满足——怎么改都行;转置、步长切片后的张量常常不满足——t.t() 之后 stride 变成 (1, 3),行与行在内存里不再首尾相接,view(-1) 要求从头到尾顺序扫过,套不上去:

b = torch.arange(6).reshape(2, 3).t()
b.view(-1)     # RuntimeError: view size is not compatible with input tensor's size and stride
b.reshape(-1)  # 正常返回,但内部已静默 contiguous() 复制

reshape 的语义即「能 view 就 view,不能就先复制再 view」——永不报错,代价也永不示人。

reshape 的静默复制

大张量上这类兜底是隐性显存翻倍的常见来源:一个 (B, C, H, W) 的特征图 transpose 后 reshape,峰值显存瞬间多一份。读代码时对每处 reshape 多问一句「输入 contiguous 吗」,x.is_contiguous() 一行即可验证。

contiguous() 本身不改 shape,是两者的幕后补给:把非连续布局实打实复制成 C 连续,为 view 铺路;对已连续的张量直接返回自身,不复制——链式调用 x.t().contiguous() 在结果恰好连续时是免费的。连续性为什么还影响访存性能、哪些 API 会隐式要求它,见 Tensor 内存连续性;stride 层面的判定与优化见 Tensor Stride 与视图。

广播:shape 的对齐协议

二元运算要求 shape 一致,或满足广播规则(从右对齐逐维检查):

  1. 两维 size 相等 → 匹配
  2. 一方为 1 → 沿该维虚拟复制至相等
  3. 维度数不同 → 小 shape 左侧补 1
x = torch.randn(4, 3, 2)
y = torch.randn(3, 1)
(x + y).shape  # (4, 3, 2)

广播是虚拟的:不真正复制数据,由 stride 置 0 实现——被扩张维的 stride 写成 0,寻址公式里该维下标走多远都停在原地,这是「Tensor 只是元数据」的又一推论。读代码时先心算广播结果 shape,可避免绝大多数 silent bug。规则细节、显式 API 与陷阱见 Tensor 广播机制。

运算与索引:三类策略各有一个坑

分类清单回答了「数据动不动」,风险却是另一回事——三类策略恰好各有一个坑。

原地类的坑:版本计数。 in-place 运算以 _ 结尾(add_、relu_、masked_fill_),不新建 Tensor、直接改写自身内存(不带 _ 的常规运算是 out-of-place,结果落进新建类),省去一次分配,对推理阶段的中间激活是净收益。但训练图上,autograd 用版本计数(每次 in-place 修改自动 +1)检测「前向时保存的张量事后被改」,一旦命中就报 one of the variables needed for gradient computation has been modified by an inplace operation。原则:需要求导的张量不做 in-place;确要更新,让 optimizer 统一做(见 torch.optim)。

新建类的坑:高级索引的隐性显存。 索引的内存分界画在基本索引与高级索引之间:

a = torch.arange(12).reshape(3, 4)
v = a[1:]          # 基本索引(切片/整数/None/…)→ 视图,共享类
c = a[a > 5]       # 高级索引(布尔掩码/张量索引)→ 复制,新建类
v[0, 0] = -1       # a[1,0] 变 -1,视图写穿
c[0] = -1          # a 不受影响,c 是独立副本

基本索引只改 offset/shape/stride,故而零拷贝;布尔掩码筛选出的元素在内存里不连续,无法用一组元数据描述,只能实打实拷贝。推论:对大张量做掩码筛选必然分配等量级新内存,数据清洗管线估算显存时要计入。gather/scatter/index_select 则是嵌入查表、梯度按索引路由的底层原语——embedding 层前向就是一次 gather(见 torch.nn)。

共享类的坑:detach 写穿。 detach() 只断计算图、不断内存,对 detach 结果做 in-place 写入会直接波及原张量。版本计数器是共享的——原张量已被计算图保存时,这种写入照样触发上面的报错;真正的危险在纯推理场景:没有 backward 把关,写穿无声无息。所以推理代码更稳妥的写法是用 with torch.no_grad() 包住前向,而不是对张量逐个 detach。

设备与类型转换

x = x.to("cuda", torch.bfloat16)  # 一次同时换设备与 dtype
x = x.float()                     # 等价于 .to(torch.float32)
arr = x.cpu().numpy()             # 跨设备必须先回 CPU

.to() 不修改原对象,忘写 x = 是最常见错误——而且不报错,模型默默跑在旧设备/旧精度上。另一个方向的知识点:设备或 dtype 已就位时,.to() 返回自身、不复制;只有真正跨设备或跨类型才搬数据。CUDA 侧的异步执行、non_blocking 与 pin memory 这些性能细节见 torch.cuda 与混合精度。

NumPy 零拷贝互操作

import numpy as np
 
np_arr = np.ones((2, 3), dtype=np.float32)
t = torch.from_numpy(np_arr)  # 共享内存,零拷贝
t[0, 0] = 9.0
np_arr[0, 0]                  # 9.0,双向同步
back = t.numpy()              # 反向同样共享

前提是 CPU 张量且 dtype 兼容(float64/float32/整型族)。共享是双向且持续的:两边任何一处改动都立即可见,因为它们就是同一块内存的两个名字。生命周期也由双方共同持有——del np_arr 之后 t 依然有效,allocator 在最后一个引用消失前不会回收。

from_numpy、as_tensor 与 torch.tensor 是三个不同的东西

torch.from_numpy(np_arr) 与 torch.as_tensor(np_arr) 零拷贝共享;torch.tensor(np_arr) 永远复制。数据处理管线里用错后者,等于每一步白拷一份数据,且后续对 NumPy 侧的修改不会再同步——两种 bug 都极难肉眼发现。

这条通道让 PyTorch 与 NumPy 生态无缝衔接,也是 深度学习框架对比 中 PyTorch 生态亲和性的关键一环;Dataset 与 DataLoader 的 __getitem__ 返回 NumPy 数组再转 Tensor,走的正是这条路。

requires_grad:通往 autograd

requires_grad 本身也是 Tensor 头上的一个元数据位——四要素描述数据,它描述「这段计算要不要留痕」:

w = torch.randn(3, requires_grad=True)  # 叶子张量,开始记录计算历史
y = (w * w).sum()
y.backward()
w.grad  # 2w 的值

requires_grad=True 的 Tensor 参与 torch.autograd 的动态计算图:前向记录算子,backward() 沿图回传梯度写入 .grad。注意 .grad 是累积而非覆盖——连续两次 backward() 梯度会相加,这正是训练循环每步先 optimizer.zero_grad() 的由来(见 torch.optim)。模型参数由 torch.nn 的 nn.Parameter 封装——本质就是默认开启 requires_grad 的 Tensor。

推理时用 torch.no_grad() 上下文停止记录,或 .detach() 剥离计算图。detach() 的产物值得再看一眼:新 Tensor 头、requires_grad=False、无 grad_fn,但 Storage 与原张量是同一块——「断图不断内存」,与本文主线首尾呼应。


一个等式收束全篇:Tensor = 元数据 + Storage 指针。四要素与寻址公式解释了视图为何免费,Storage 共享解释了改一处为何处处变,版本计数解释了 in-place 为何危险。沿这条路继续向下:stride 与 as_strided 的手动实验见 Tensor Stride 与视图,广播如何用 stride=0 实现虚拟复制见 Tensor 广播机制,计算图的完整机制见 torch.autograd。