(一)张量:形状、dtype 与设备¶
张量就是一块连续的数字,外加一组描述"怎么读它"的元数据。这一章把最先会咬人的三件事讲清楚:形状、dtype 和设备。重点不是 API 列表,而是几条会反复出现的规则——整数相除会变成浮点、和 NumPy 互转是共享内存的、跨设备的运算一定报错。
自测:能答上来就可以跳过本章
torch.tensor([1, 2, 3])和torch.tensor([1.0, 2.0])的 dtype 分别是什么?torch.tensor([7, 2]) / 2的结果是整数还是浮点?t.numpy()之后改t,numpy 数组会变吗?x.to("cpu"),当x本来就在 CPU 上时,会复制一份吗?torch.empty(3)和torch.zeros(3)有什么区别?
自测参考答案(先自己答,再展开对照)
int64和float32。Python 的整数推成 64 位整数,浮点推成 32 位(不是 float64,这一点和 NumPy 不同)。- 浮点。
/永远是真除法,要整除用//。 - 会。
numpy()和from_numpy()都共享同一块内存,要断开就.clone()。 - 不会。
.to()在设备和 dtype 都没变时直接返回原对象,连新 Python 对象都不建。 empty只分配内存、不初始化,里面是上一次留下的值;zeros会写一遍 0。想要未初始化的缓冲区才用empty。
三件套:形状、dtype、设备¶
"""张量的三件套:形状、dtype、设备。顺便看看哪些操作是共享内存的"""
import numpy as np
import torch
g = torch.Generator().manual_seed(0)
x = torch.randn(2, 3, 4, generator=g)
print(f"shape={tuple(x.shape)} dtype={x.dtype} device={x.device} "
f"numel={x.numel()} 每个元素 {x.element_size()} 字节 共 {x.numel() * x.element_size()} 字节")
print(f"stride={x.stride()} 连续={x.is_contiguous()}")
print("\n—— 几种构造方式 ——")
print("torch.tensor([1, 2, 3]) ", torch.tensor([1, 2, 3]).dtype, " 从 Python 整数推出来的是 int64")
print("torch.tensor([1.0, 2.0]) ", torch.tensor([1.0, 2.0]).dtype, " 浮点默认 float32,不是 float64")
print("torch.zeros(2, 3).dtype ", torch.zeros(2, 3).dtype)
print("torch.arange(5) ", torch.arange(5).tolist(), torch.arange(5).dtype)
print("torch.full((2,), 3.0).dtype ", torch.full((2,), 3.0).dtype)
print("torch.empty(3) 只分配不初始化,里面是上一次留下的垃圾值,别当零用")
print("\n—— dtype 的坑 ——")
a = torch.tensor([7, 2])
print("整数相除得到的是 float:", (a / 2).dtype, (a / 2).tolist())
print("要整除用 //(floor_divide):", (a // 2).tolist())
print("int 和 float 相加,按类型提升规则走:", (a + 0.5).dtype)
half = x.to(torch.float16)
print(f"float32 -> float16 后每个元素 {half.element_size()} 字节,最大误差 {(half.float() - x).abs().max():.3e}")
print("\n—— 和 NumPy 互转:共享同一块内存 ——")
t = torch.ones(3)
n = t.numpy()
t[0] = 99
print("改 tensor,numpy 跟着变:", n)
n2 = np.arange(3, dtype=np.float32)
t2 = torch.from_numpy(n2)
n2[0] = -1
print("改 numpy,tensor 也跟着变:", t2.tolist())
print("要断开就复制一份:", torch.from_numpy(n2).clone().tolist())
print("\n—— 设备 ——")
print("有没有 CUDA:", torch.cuda.is_available(), " 当前默认设备:", torch.empty(0).device)
dev = "cuda" if torch.cuda.is_available() else "cpu"
y = x.to(dev) # .to 在同设备同 dtype 时直接返回自己,不复制
print(f"x.to({dev!r}) 之后 device={y.device},是不是同一个对象:{y is x}")
print("跨设备的运算会直接报错,不会偷偷帮你搬——这是故意的,搬运很贵")
shape=(2, 3, 4) dtype=torch.float32 device=cpu numel=24 每个元素 4 字节 共 96 字节
stride=(12, 4, 1) 连续=True
—— 几种构造方式 ——
torch.tensor([1, 2, 3]) torch.int64 从 Python 整数推出来的是 int64
torch.tensor([1.0, 2.0]) torch.float32 浮点默认 float32,不是 float64
torch.zeros(2, 3).dtype torch.float32
torch.arange(5) [0, 1, 2, 3, 4] torch.int64
torch.full((2,), 3.0).dtype torch.float32
torch.empty(3) 只分配不初始化,里面是上一次留下的垃圾值,别当零用
—— dtype 的坑 ——
整数相除得到的是 float: torch.float32 [3.5, 1.0]
要整除用 //(floor_divide): [3, 1]
int 和 float 相加,按类型提升规则走: torch.float32
float32 -> float16 后每个元素 2 字节,最大误差 8.788e-04
—— 和 NumPy 互转:共享同一块内存 ——
改 tensor,numpy 跟着变: [99. 1. 1.]
改 numpy,tensor 也跟着变: [-1.0, 1.0, 2.0]
要断开就复制一份: [-1.0, 1.0, 2.0]
—— 设备 ——
有没有 CUDA: False 当前默认设备: cpu
x.to('cpu') 之后 device=cpu,是不是同一个对象:True
跨设备的运算会直接报错,不会偷偷帮你搬——这是故意的,搬运很贵
逐条看几个容易踩的地方:
- stride 是"沿着这一维走一步要跳几个元素":
(2,3,4)的张量 stride 是(12,4,1),所以最后一维是连着的。下一章的permute会把它打乱,很多"为什么不能 view"的问题都在这里; - 浮点默认 float32:写惯 NumPy 的人常以为是 float64。模型权重、激活几乎都用 float32 或更低,这个默认值是对的;
- 整数除法:
/得到浮点,//才是整除。算n_heads这类下标时用错会得到1.0这样的浮点数,后面索引直接报错; - 和 NumPy 共享内存:
t.numpy()不复制。这是好事(省一次拷贝),但改一个另一个会跟着变,调试时足以让人怀疑人生。要独立的一份就.clone(); .to()不保证复制:同设备同 dtype 时返回的就是原对象。想要一份独立的拷贝,用.clone(),别指望.to()。
只有 CPU 也不影响学
这本书的代码全部在 CPU 上跑。GPU 相关的只有一条规则要记住:张量在哪个设备上,运算就必须在哪个设备上,PyTorch 不会偷偷帮你搬——搬运一次要走 PCIe,比大多数算子本身还贵,所以它宁可报错也不替你决定。
怎么讲清楚
讲"张量和 NumPy 数组有什么区别":张量多了三样东西——设备(能放在 GPU 上)、autograd(能记录计算图求梯度)、dtype 更克制(默认 float32,因为深度学习不需要 float64)。再补一句互转是共享内存的,所以零拷贝但要小心别名;以及 .to() 在无需转换时不复制,真要副本得 .clone()。
练习¶
1. 猜 dtype。 不运行,先说出 torch.tensor([1, 2]) + torch.tensor([1.5])、torch.ones(2, dtype=torch.int32) * 2.0、torch.arange(3) / 1 各是什么 dtype,再跑一遍验证。
参考答案
依次是 float32、float32、float32。PyTorch 的类型提升规则是:整数遇到浮点升到浮点;不同宽度的同类相遇升到宽的那个;Python 标量不参与"升级",只按张量的类别走。/ 的结果永远是浮点,即使两边都是整数。
2. 别名。 写一个函数,接收一个张量并把它所有负数置零。分别用 x[x < 0] = 0 和 x = torch.clamp(x, min=0) 实现,调用之后检查调用方的张量有没有被改掉。
参考思路
第一种是原地修改,调用方的张量被改了;第二种返回新张量,调用方不受影响。库函数应该明确自己属于哪一种:PyTorch 的约定是带下划线的才原地(clamp_),不带的一律返回新张量。
3. 省内存。 一个 (1024, 1024) 的 float32 张量占多少 MiB?换成 bfloat16 呢?用 element_size() 和 numel() 验证一遍。
参考答案
4 MiB 和 2 MiB。numel() * element_size() 就是字节数。推理时把权重换成 bf16/fp16 能省一半显存,也能快——但要注意梯度和优化器状态通常仍然留在 fp32。
小结¶
- 张量 = 一块数据 + 形状、stride、dtype、设备这组元数据;
numel() * element_size()就是它占的字节数。 - Python 整数推成
int64,浮点推成float32;/永远是真除法。 - 和 NumPy 互转共享内存,
.to()在不需要转换时不复制——要独立的副本就.clone()。 - 张量在哪个设备上,运算就得在哪个设备上;PyTorch 不会偷偷帮你搬。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题