3154 字
16 分钟

PyTorch张量操作

2026-07-09

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,也不是每一行各自分配一块内存。更接近 实际情况的模型是三层:

  1. Python 侧的 torch.Tensor 对象,是用户代码拿到的句柄。
  2. 底层 C++ 的 TensorImpl 记录这个 tensor 的元信息,例如 size、stride、 storage offset、dtype、device、layout,以及 autograd 和版本计数相关状态。
  3. StorageImpl / DataPtr 指向实际数据缓冲区,并记录分配器、字节数和释放方式。

Tensor 内部结构

对普通 torch.strided dense tensor 来说,数值数据通常是一段扁平连续的 byte buffer。 dtype 决定每个元素占多少字节,shapestride 决定多维下标如何映射到这段 buffer。这里有一个很重要的区分:storage 通常是一段连续分配的底层缓冲区,但 tensor 这个逻辑视图不一定是 contiguous 的。

GPU tensor 也遵循类似的对象模型,只是实际数值 buffer 位于设备内存中。Python 对象、 TensorImpl、shape、stride、dtype、device、layout、引用计数和大部分调度元信息仍由 宿主进程里的 PyTorch 运行时管理。读取 x.shapex.dtypex.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)

yz 都没有重新分配一份完整数据。它们复用 x 的 storage,只是记录了不同的 shape、stride 和 storage_offsetdata_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 5

x 的逻辑形状是 (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 号元素。

Storage 与 stride

转置不会移动数据。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] = 100
print(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 都有 dtypedevicelayout。最常见的 layout 是 torch.strideddtype 决定元素解释方式和单个元素大小,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()) # 读元信息,不需要复制整块数据回 CPU

CPU 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 不会。

View 与 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)

常见操作可以粗略分成三类:

类型常见操作判断方式
通常返回 viewbasic slicing、viewtransposepermutesqueezeunsqueezeexpand不搬运数据,改元信息
通常返回 copyclone、advanced indexing、repeat新 storage
视情况而定reshapeflattencontiguous能复用就复用,必要时复制

带下划线的方法通常表示 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 历史,但通常仍共享 storage
b = 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 == 0
x[mask] = 0
print(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])

Broadcasting 对齐规则

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 > 0
print(mask, mask.dtype)
print(torch.where(mask, x, torch.zeros_like(x)))

规约操作最重要的是 dimkeepdim

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 - mean
print(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 的通用 matmul
print((ba @ w).shape)

einsum 适合表达更复杂的维度关系,但不应该为了炫技替代清晰的 matmulsumtranspose

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,)))

这些操作看起来都在“变形”,但内存行为不同。性能敏感路径中,repeattile、 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。

参考资料#

PyTorch张量操作
https://blog.gzher.com/posts/pytorch-tensor-operations/
作者
中会 / ChatGPT 5.5
发布于
2026-07-09
许可协议
CC BY-NC-SA 4.0