PyTorch张量操作
PyTorch 的 Tensor 表面上像一个多维数组:可以切片、转置、相加、矩阵乘、放到
GPU 上计算。真正写模型或调试 shape 问题时,更关键的是另一层问题:这次操作有没有
复制数据,新的 shape 是怎么解释同一段内存的,为什么有些 tensor 明明形状对却不能
view(),为什么 GPU tensor 的 .shape 可以直接打印而 .item() 会让 CPU 等待。
把 Tensor 理解成“数据缓冲区 + 元信息 + 一套索引解释规则”,很多 API 的行为会变得
自然很多。
import torch
torch.manual_seed(0)1. Tensor 内部由什么组成
普通 dense tensor 不是一个嵌套的 Python list,也不是每一行各自分配一块内存。更接近 实际情况的模型是三层:
- Python 侧的
torch.Tensor对象,是用户代码拿到的句柄。 - 底层 C++ 的
TensorImpl记录这个 tensor 的元信息,例如 size、stride、 storage offset、dtype、device、layout,以及 autograd 和版本计数相关状态。 StorageImpl/DataPtr指向实际数据缓冲区,并记录分配器、字节数和释放方式。
对普通 torch.strided dense tensor 来说,数值数据通常是一段扁平连续的 byte buffer。
dtype 决定每个元素占多少字节,shape 和 stride 决定多维下标如何映射到这段
buffer。这里有一个很重要的区分:storage 通常是一段连续分配的底层缓冲区,但 tensor
这个逻辑视图不一定是 contiguous 的。
GPU tensor 也遵循类似的对象模型,只是实际数值 buffer 位于设备内存中。Python 对象、
TensorImpl、shape、stride、dtype、device、layout、引用计数和大部分调度元信息仍由
宿主进程里的 PyTorch 运行时管理。读取 x.shape、x.dtype、x.stride() 不需要把整块
GPU 数据复制回 CPU;但读取真实数值,例如 x.cpu()、x.item()、print(x),就必须让
CPU 观察设备上的数据,通常会触发数据传输或同步。
def show(name, x): print(name) print(" shape:", tuple(x.shape)) print(" stride:", x.stride()) print(" storage_offset:", x.storage_offset()) print(" contiguous:", x.is_contiguous()) print(" dtype/device:", x.dtype, x.device) print(" element_size:", x.element_size()) print(" numel:", x.numel()) print(" storage_nbytes:", x.untyped_storage().nbytes()) print(" data_ptr:", hex(x.data_ptr())) print(" storage_ptr:", hex(x.untyped_storage().data_ptr()))
x = torch.arange(6, dtype=torch.int64).reshape(2, 3)y = x[:, 1:]z = x.t()
show("x", x)show("y = x[:, 1:]", y)show("z = x.t()", z)y 和 z 都没有重新分配一份完整数据。它们复用 x 的 storage,只是记录了不同的
shape、stride 和 storage_offset。data_ptr() 表示当前 tensor 第一个逻辑元素的地址,
切片后可能会变;untyped_storage().data_ptr() 表示底层 storage 的起点,view 之间通常
相同。
2. Storage、shape 和 stride
以 x = torch.arange(6).reshape(2, 3) 为例,底层 storage 可以想象成:
storage: 0 1 2 3 4 5x 的逻辑形状是 (2, 3),stride 是 (3, 1)。访问 x[i, j] 时,PyTorch 用下面的
公式找到 storage 中的元素位置:
storage_index = storage_offset + i * stride[0] + j * stride[1]所以 x[1, 2] 对应 0 + 1 * 3 + 2 * 1 = 5,也就是 storage 中的第 5 号元素。
转置不会移动数据。x.t() 的 shape 变成 (3, 2),stride 变成 (1, 3)。逻辑上的
z[2, 1] 仍然落到 0 + 2 * 1 + 1 * 3 = 5,读到的仍是同一段 storage 里的元素。
这就是 view 操作便宜的原因:很多时候只改元信息,不搬运数据。
contiguous 描述的是逻辑 tensor 按当前 shape 访问时,元素是不是按紧密顺序排列。原始
x 是 contiguous 的,x.t() 通常不是:
x = torch.arange(6).reshape(2, 3)z = x.t()
print(x.shape, x.stride(), x.is_contiguous())print(z.shape, z.stride(), z.is_contiguous())storage 连续不等于 tensor contiguous。一个 non-contiguous view 仍然可以引用一段连续 storage,只是它的逻辑相邻元素在 storage 中不一定相邻。
3. 创建张量
创建 tensor 时要先分清楚:已有数据要变成 tensor,还是只需要按某个形状分配一块新 tensor。
# 从 Python 数据创建,通常会复制出新的 tensor 数据。a = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32)
# 只按形状创建。b = torch.empty(2, 3) # 未初始化,内容不可假设c = torch.zeros(2, 3)d = torch.ones(2, 3)e = torch.full((2, 3), 7)
# 按数列创建。r = torch.arange(0, 10, 2)l = torch.linspace(0, 1, steps=5)
# 沿用已有 tensor 的 dtype/device 等属性。same_shape = torch.zeros_like(a)different_shape = a.new_ones(3, 4)torch.empty 只分配内存,不清零。它适合马上被完整写入的场景,不适合直接参与计算:
x = torch.empty(3)print(x) # 里面是未初始化值,不能当作随机数或 0 使用从 NumPy 或已有对象转 tensor 时,是否共享底层内存也很重要:
import numpy as np
arr = np.array([1, 2, 3], dtype=np.float32)
x = torch.tensor(arr) # 复制y = torch.as_tensor(arr) # 能共享时通常共享z = torch.from_numpy(arr) # 与 NumPy array 共享 CPU 内存
arr[0] = 100print(x) # tensor([1., 2., 3.])print(y) # tensor([100., 2., 3.])print(z) # tensor([100., 2., 3.])旧式构造器 torch.Tensor(...) 容易和 dtype 默认行为混在一起,新代码优先使用
torch.tensor(...)、torch.empty(...)、torch.zeros(...) 这类明确的创建函数。
4. dtype 和 device
每个 tensor 都有 dtype、device 和 layout。最常见的 layout 是 torch.strided。
dtype 决定元素解释方式和单个元素大小,device 决定数据 buffer 分配在哪类设备上。
x = torch.arange(6)print(x.dtype, x.device, x.layout)
xf = x.to(dtype=torch.float32)print(xf.dtype)
if torch.cuda.is_available(): xc = xf.to("cuda") print(xc.device) print(xc.shape, xc.stride()) # 读元信息,不需要复制整块数据回 CPUCPU tensor 和 GPU tensor 不会在普通算子里自动混算:
cpu_x = torch.ones(3)
if torch.cuda.is_available(): gpu_y = torch.ones(3, device="cuda")
# RuntimeError: expected all tensors to be on the same device # cpu_x + gpu_y
z = cpu_x.to(gpu_y.device) + gpu_y print(z.device)不同 dtype 混合时,PyTorch 会做 dtype promotion。结果 dtype 往往能容纳更多信息,但 in-place 写回时仍受左侧 tensor dtype 限制。
i = torch.ones(3, dtype=torch.int32)f = torch.ones(3, dtype=torch.float32) * 0.5
print((i + f).dtype) # torch.float32
try: i.add_(f)except RuntimeError as e: print(type(e).__name__, str(e).splitlines()[0])to() 是 dtype/device 转换的统一入口。x.float()、x.long()、x.cpu()、x.cuda()
只是更短的常用写法。
5. 形状变换
形状变换可以分成三类:只改解释方式、可能复制、一定创建新数据。最容易混淆的是
view() 和 reshape()。
view() 要求当前 tensor 的 stride 能直接按新 shape 解释。contiguous tensor 上通常没
问题:
x = torch.arange(12).reshape(3, 4)y = x.view(2, 6)
print(x.shape, x.stride())print(y.shape, y.stride())转置后的 tensor 通常不是 contiguous,直接 view() 会失败:
x = torch.arange(12).reshape(3, 4)t = x.t()
print(t.shape, t.stride(), t.is_contiguous())
try: t.view(12)except RuntimeError as e: print(type(e).__name__, str(e).splitlines()[0])reshape() 更宽松。它能返回 view 时就返回 view,不能时会复制出一块符合目标 shape 的
新 tensor。代码不应该依赖 reshape() 到底有没有复制。
r = t.reshape(12)print(r.shape, r.is_contiguous())如果确实需要连续布局,可以显式使用 contiguous():
c = t.contiguous()v = c.view(12)
print(c.shape, c.stride(), c.is_contiguous())其他常见形状操作:
x = torch.randn(2, 1, 3)
print(x.squeeze(1).shape) # torch.Size([2, 3])print(x.unsqueeze(0).shape) # torch.Size([1, 2, 1, 3])
y = torch.randn(2, 3, 4)print(y.transpose(1, 2).shape) # torch.Size([2, 4, 3])print(y.permute(2, 0, 1).shape) # torch.Size([4, 2, 3])print(torch.movedim(y, 0, -1).shape)transpose()、permute()、movedim() 通常只是改变维度解释,常常返回 view,并可能制造
non-contiguous tensor。后面如果要调用要求连续布局的操作,就需要检查
is_contiguous()。
6. View、copy 和 in-place
view 共享底层 storage,copy 拥有独立 storage。修改 view 会影响 base tensor,修改 copy 不会。
base = torch.arange(6).reshape(2, 3)
v = base[:, 1:]v.zero_()print(base)
base = torch.arange(6).reshape(2, 3)c = base[:, 1:].clone()c.zero_()print(base)常见操作可以粗略分成三类:
| 类型 | 常见操作 | 判断方式 |
|---|---|---|
| 通常返回 view | basic slicing、view、transpose、permute、squeeze、unsqueeze、expand | 不搬运数据,改元信息 |
| 通常返回 copy | clone、advanced indexing、repeat | 新 storage |
| 视情况而定 | reshape、flatten、contiguous | 能复用就复用,必要时复制 |
带下划线的方法通常表示 in-place mutation:
x = torch.tensor([1.0, 2.0, 3.0])y = x
x.add_(10)print(y) # y 和 x 指向同一个 tensor 对象,能看到修改索引赋值、copy_()、许多带 out= 的函数也会写入已有 tensor:
x = torch.zeros(5)x[1:4] = torch.tensor([1.0, 2.0, 3.0])print(x)
out = torch.empty(3)torch.add(torch.ones(3), 2, out=out)print(out)detach() 和 clone() 解决的是不同问题:
x = torch.ones(3, requires_grad=True)
a = x.detach() # 切断 autograd 历史,但通常仍共享 storageb = x.clone() # 复制数据,仍保留 autograd 关系c = x.detach().clone() # 切断 autograd 历史,并复制数据训练代码里不要随意对需要梯度的中间结果做 in-place 修改。某些中间值会被 autograd 保存 给 backward 使用,原地改掉以后,反向传播可能报版本计数错误,或者让代码语义变得难以 推断。
7. 索引、切片、gather 和 scatter
basic indexing 包括整数索引、切片、...、None。这类索引通常返回 view:
x = torch.arange(12).reshape(3, 4)
v = x[1:, 1:3]v[0, 0] = -1
print(x)advanced indexing 包括整数列表、LongTensor index、bool mask 等。读取时通常返回 copy:
x = torch.arange(12).reshape(3, 4)
y = x[[0, 2], [1, 3]]y[0] = -99
print(y)print(x) # x 不变索引赋值本身是 in-place,不管索引形式是 basic 还是 advanced:
x = torch.arange(12).reshape(3, 4)mask = x % 2 == 0x[mask] = 0print(x)index_select() 适合沿某个维度抽取一组位置:
x = torch.arange(12).reshape(3, 4)idx = torch.tensor([0, 2])
print(torch.index_select(x, dim=0, index=idx))print(torch.index_select(x, dim=1, index=idx))gather() 按 index tensor 从源 tensor 中取值。index 的 shape 决定输出 shape,index 中
的数值表示沿 dim 这一维要取源 tensor 的哪个位置。
scores = torch.tensor([ [0.1, 0.8, 0.1], [0.2, 0.3, 0.5],])
index = torch.tensor([ [1], [2],])
picked = torch.gather(scores, dim=1, index=index)print(picked)scatter_() 则是反方向:按照 index 把值写回目标 tensor。
out = torch.zeros(2, 3)src = torch.tensor([ [9.0], [8.0],])
out.scatter_(dim=1, index=index, src=src)print(out)如果多个位置写到同一个目标位置,普通 scatter_() 的语义不适合表达累加,应该使用
scatter_add_() 或 scatter_reduce_() 这类明确的归约写入。
8. Broadcasting
broadcasting 的规则从最后一维开始向左对齐。每一维要么相等,要么其中一个是 1,要么 其中一个 tensor 没有这一维。结果 shape 在每一维上取较大的那个。
x = torch.empty(5, 1, 4, 1)y = torch.empty(3, 1, 1)
print((x + y).shape) # torch.Size([5, 3, 4, 1])expand() 是逻辑扩展,不复制数据;repeat() 会真实复制数据。
v = torch.tensor([1, 2, 3]).view(3, 1)
e = v.expand(3, 4)r = v.repeat(1, 4)
print(e)print("expand stride:", e.stride())print("repeat stride:", r.stride())print("expand storage bytes:", e.untyped_storage().nbytes())print("repeat storage bytes:", r.untyped_storage().nbytes())expand 后被扩展的维度通常会出现 stride 为 0 的情况。逻辑上有多个元素,物理上可能
重复读同一个内存位置。对这种 view 做 in-place 写入很危险,PyTorch 会在许多场景下直接
拒绝。
in-place broadcasting 还有一个限制:左侧 tensor 的 shape 不能因为广播而改变。
x = torch.empty(1, 3, 1)y = torch.empty(3, 1, 7)
try: x.add_(y)except RuntimeError as e: print(type(e).__name__, str(e).splitlines()[0])报 shape mismatch 时,先把 shape 从右向左对齐,通常比盯着报错信息更快。
9. 逐元素、规约和矩阵运算
逐元素操作会在相同位置上计算,必要时先按 broadcasting 规则对齐 shape:
x = torch.randn(2, 3)y = torch.randn(3)
z = torch.clamp(torch.exp(x + y), max=10)print(z.shape)比较和逻辑操作返回 bool tensor:
x = torch.tensor([-1.0, 0.0, 2.0])mask = x > 0print(mask, mask.dtype)print(torch.where(mask, x, torch.zeros_like(x)))规约操作最重要的是 dim 和 keepdim:
x = torch.randn(2, 3, 4) # batch, seq, hidden
print(x.sum().shape)print(x.sum(dim=1).shape)print(x.sum(dim=1, keepdim=True).shape)
values, indices = x.max(dim=-1)print(values.shape, indices.shape)keepdim=True 会保留被规约的维度,长度变成 1。这样后续和原 tensor 做 broadcasting
会更自然:
x = torch.randn(2, 3, 4)mean = x.mean(dim=-1, keepdim=True)centered = x - meanprint(centered.shape)矩阵乘法相关 API 的 shape 约定不同:
a = torch.randn(3, 4)b = torch.randn(4, 5)print(torch.mm(a, b).shape) # 只处理二维矩阵
ba = torch.randn(2, 3, 4)bb = torch.randn(2, 4, 5)print(torch.bmm(ba, bb).shape) # 批量二维矩阵乘
w = torch.randn(4, 5)print(torch.matmul(ba, w).shape) # 支持 broadcasting 的通用 matmulprint((ba @ w).shape)einsum 适合表达更复杂的维度关系,但不应该为了炫技替代清晰的 matmul、sum 或
transpose:
x = torch.randn(2, 3, 4)w = torch.randn(4, 5)
print(torch.einsum("bsh,hk->bsk", x, w).shape)10. 拼接、拆分和重排
cat 沿已有维度拼接,stack 新增一个维度再拼接:
a = torch.ones(2, 3)b = torch.zeros(2, 3)
print(torch.cat([a, b], dim=0).shape) # torch.Size([4, 3])print(torch.cat([a, b], dim=1).shape) # torch.Size([2, 6])print(torch.stack([a, b], dim=0).shape) # torch.Size([2, 2, 3])print(torch.stack([a, b], dim=1).shape) # torch.Size([2, 2, 3])拆分操作常用于把一个大 tensor 切成若干块:
x = torch.arange(10)
print(torch.split(x, 3))print(torch.chunk(x, 3))print(torch.tensor_split(x, 3))unbind 会沿某个维度去掉这一维,并返回多个 tensor:
x = torch.arange(6).reshape(2, 3)rows = torch.unbind(x, dim=0)cols = torch.unbind(x, dim=1)
print(rows)print(cols)其他常用重排操作:
x = torch.tensor([1, 2, 3])
print(torch.repeat_interleave(x, repeats=2))print(torch.tile(x, dims=(2,)))print(torch.roll(x, shifts=1))print(torch.flip(x, dims=(0,)))这些操作看起来都在“变形”,但内存行为不同。性能敏感路径中,repeat、tile、
advanced indexing、contiguous()、某些 reshape() 都可能带来真实复制。
11. 调试检查清单
Tensor 相关 bug 大多可以先打印以下信息:
def debug_tensor(name, x): print( name, "shape=", tuple(x.shape), "stride=", x.stride(), "dtype=", x.dtype, "device=", x.device, "contiguous=", x.is_contiguous(), )常用判断:
- shape mismatch:把两个 shape 从右向左对齐,按 broadcasting 规则逐维检查。
view()报错:检查is_contiguous()和stride(),必要时用reshape()或contiguous().view()。- 结果被意外修改:检查是不是拿到了 view,或者是不是做了 in-place 操作。
- 显存或内存突然上涨:检查
repeat、advanced indexing、contiguous()、CPU/GPU 往返。 - CUDA 程序突然变慢:检查循环里是否频繁
.item()、.cpu()、print(cuda_tensor)。
一个实用习惯是:只看数值不够,shape、stride、dtype、device 四个属性要一起看。很多 PyTorch 张量操作的差别不在数学结果,而在它们是否共享 storage、是否改变 contiguous 状态、是否把数据搬到了另一个 device。