首页 / 模块 3 · Python、NumPy、PyTorch 与实验工程 / 第 3 课(共 10 课)

PyTorch 张量、设备与 dtype

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:PyTorch 张量——device/dtype/requires_grad 三属性;吃掉最高频报错

上一课你把循环里的 Python 慢操作改写成了 NumPy 的向量化与广播。现在我们把同样的"数组直觉"搬到 PyTorch 的张量(tensor)上——这是从零写训练循环之前的最后一块工程地基。好消息是:张量的索引、广播、向量化几乎就是 NumPy;坏消息是,它多了三个会让你反复栽跟头的属性——device(设备)、dtype(数据类型)、requires_grad(是否记录梯度)。本课就专门吃掉这三者引发的最高频报错,下一课再用 requires_grad 进入 autograd。

读完这一课,你将能够

  • 说清张量比 ndarray 多了哪三个属性,并在代码里读出它们。
  • 判断一个操作(切片 / from_numpy / transpose)是否共享内存,预测改一个会不会动另一个。
  • 解释为什么 transpose 后 .view 会报错,并用 .contiguous().reshape 修复。
  • .to(device) 把张量和模型搬到同一设备,避免"设备不匹配"错误。
  • 选对 dtype(float32 / bf16 / int64),并用 NumSpy 演示预判 dtype 提升 / 截断 / 精度丢失会发生什么;同时知道 PyTorch 的提升规则与 NumPy 略有不同。

张量 = ndarray + 三个属性

机制直觉。把张量想成一块连续的数字内存(buffer)加上一套"怎么读它"的元数据:形状(shape)、步长(stride)、起点。NumPy 的 ndarray 已经是这个结构了。PyTorch 在它之上挂了三个新标签:

张量 = 一块数字内存 + 三个 PyTorch 专属标签 device、dtype、requires_grad · 其余数组操作与 NumPy 一致 device 数据放在哪 cpu / cuda:0 dtype 每格几字节 float32 / bf16 / int64 数值精度 · 占用内存 requires_grad True / False 是否记录梯度(下一课展开,本课先不深入) 数组本体 shape / stride 索引 / 广播 与 NumPy ndarray 相同 0.5 -1.2 3.0 0.0 2.7 -0.4 torch.Tensor · 一块数字内存 相同的数组本体 + 三个新挂件 → 这就是张量与 ndarray 的全部差别
张量 = 一块数字内存 + 三个 PyTorch 专属标签:device、dtype、requires_grad。其余数组操作与 NumSPy 一致。

除了这三个标签,张量的"数组操作"和 NumPy 几乎一一对应。下面这格用 NumPy 复习几个会原样迁移过来的操作——把它当成"张量也是这样"的预演。

import numpy as np

x = np.arange(12).reshape(3, 4)   # 形状 (3, 4)
print("shape:", x.shape, "dtype:", x.dtype)

# 索引 / 切片 —— torch 写法完全一样
print("第0行:", x[0])
print("最后一列:", x[:, -1])

# 广播:(3,4) 与 (4,) 相加,行向量自动复制到每一行
bias = np.array([10, 20, 30, 40])
print("广播加法:\n", x + bias)

# 向量化归约
print("按列求和:", x.sum(axis=0))
print("按行均值:", x.mean(axis=1))

真实 API(指针)。把上面的 np. 换成 torch.axis= 换成 dim=,几乎就是 PyTorch。差别全在那三个新属性上:

import torch

x = torch.arange(12).reshape(3, 4)        # 默认 dtype=int64, device=cpu
print(x.shape, x.dtype, x.device, x.requires_grad)
# torch.Size([3, 4]) torch.int64 cpu False

# 创建浮点张量并要求梯度
w = torch.randn(3, 4, dtype=torch.float32, requires_grad=True)
print(w.dim(), w.sum(dim=0))               # dim 替代 numpy 的 axis

# 三个属性都能在创建时指定
t = torch.zeros(2, 2, dtype=torch.float32, device="cpu")

from_numpy 的零拷贝陷阱

机制直觉。很多"为什么我没改它它却变了"的 bug,本质都是两个变量指向同一块内存。NumPy 里切片返回的是视图(view)而不是副本,这正好可以用来建立"共享内存"的直觉——torch.from_numpy 干的就是同一件事。

import numpy as np

a = np.array([1, 2, 3, 4], dtype=np.float32)
b = a[1:3]              # 切片 = view,与 a 共享同一块内存
b[0] = 99               # 改 b
print("a =", a)         # a[1] 也变成 99 !
print("b =", b)
print("共享内存?", np.shares_memory(a, b))   # True

c = a.copy()            # 显式拷贝才独立
c[0] = -1
print("copy 后 a 不变:", a)

跑一下:a 变成 [1, 99, 3, 4]。这就是"共享内存"的感觉——b 不是新数组,只是同一块内存的另一种读法。

真实 API(指针)。torch.from_numpy(a)a 共享内存,改一个动另一个;想要独立副本就显式 .clone()torch.tensor(a)(后者会拷贝)。

import numpy as np, torch

a = np.array([1.0, 2.0, 3.0], dtype=np.float32)
t = torch.from_numpy(a)     # 零拷贝:t 和 a 共享内存
t[0] = 99.0
print(a)                    # [99.  2.  3.]  —— numpy 数组也变了!

# 反方向同理
t.numpy()[1] = -1.0
print(a)                    # [99. -1.  3.]

# 想要独立副本:
t2 = torch.tensor(a)        # 拷贝一份
t3 = torch.from_numpy(a).clone()

连续性(contiguous):view 要求连续,reshape 不一定

机制直觉。张量逻辑上是多维的,物理上却是一条一维内存。默认按"行优先"(C order)摆放:先放完第 0 行,再放第 1 行……transpose / permute 这类操作只改步长(stride)、不动内存——逻辑视图转置了,物理顺序原封不动。.view 要求"内存顺序正好就是新形状想要的顺序",所以转置后的张量不连续,.view 会拒绝;.reshape 则在必要时悄悄帮你拷贝一份连续内存。

原数组 a (2x3) 0 1 2 3 4 5 物理内存(一维) 0 1 2 3 4 5 行优先 C-contiguous 逻辑顺序 = 物理顺序 转置 t = a.T (3x2) 0 3 1 4 2 5 逻辑读取顺序 0,3,1,4,2,5 0 1 2 3 4 5 物理内存仍是 0 1 2 3 4 5(没变) 读取要“跳着走” stride 改变,内存未动 → 不连续 两条出路 .view(6) 要求 逻辑顺序 == 物理顺序 .contiguous().view(6) 0 3 1 4 2 5 拷贝重排:新内存条按逻辑顺序排好 .reshape(6) 需要时自动拷贝(连续则等于 view) 结论:不确定就用 .reshape
transpose 只改逻辑视图,不动物理内存:原数组行优先存为 0..5;转置后逻辑上是 (3,2),但内存顺序没变,所以 .view 无法直接套用新形状。
import numpy as np

a = np.arange(6).reshape(2, 3)        # 行优先
print("a=\n", a)
print("物理内存顺序:", a.ravel())       # [0 1 2 3 4 5]
print("C连续?", a.flags['C_CONTIGUOUS'])   # True

t = a.T                               # transpose:逻辑变,内存没动
print("t=\n", t)                       # [[0 3] [1 4] [2 5]]
print("t C连续?", t.flags['C_CONTIGUOUS'])     # False
print("t 物理内存仍是:", t.ravel(order='K'))    # [0 1 2 3 4 5]

# reshape 在不连续时自动拷贝,给出"按逻辑顺序"的结果
print("t.reshape(6):", t.reshape(6))  # [0 3 1 4 2 5]

关键观察:t 逻辑上转置成 [[0 3] [1 4] [2 5]],但底层内存还是 0 1 2 3 4 5ravel(order='K') 按物理布局读,所以仍是原序)。NumPy 的 reshape 发现这块内存不连续,自动拷贝出按逻辑顺序排列的 [0 3 1 4 2 5]PyTorch 的 .view 不会替你拷贝——它直接抛 RuntimeError,逼你显式选择 .contiguous().reshape

真实 API(指针)。这是最常见的"view 报错"现场和两种修复:

import torch

a = torch.arange(6).reshape(2, 3)
t = a.transpose(0, 1)          # 形状 (3, 2),不连续
print(t.is_contiguous())       # False

# t.view(6)
# RuntimeError: view size is not compatible with input tensor's size and
# stride ... use .reshape(...) instead.

# 修复一:先连续化,再 view
print(t.contiguous().view(6))  # tensor([0, 3, 1, 4, 2, 5])

# 修复二:直接用 reshape(需要时自动拷贝,否则等价 view)
print(t.reshape(6))            # tensor([0, 3, 1, 4, 2, 5])

设备搬运:张量和模型必须同处一地

机制直觉。CPU 内存和 GPU 显存是两块物理上分开的内存,CPU 上的数字和 GPU 上的数字没法直接相加。深度学习里最高频的运行时错误之一就是:模型参数在 GPU、输入数据还在 CPU,两者一相乘就报 device mismatch。规矩很简单——参与同一次运算的张量必须在同一个 device 上

参与同一次运算的张量必须在同一设备 左:数据在 CPU、模型在 GPU 直接相乘报错;右:x.to(device) 对齐后正常 ✗ 设备不匹配 CPU x cpu GPU W cuda:0 RuntimeError: tensors on cuda:0 and cpu ✓ 搬到同一设备 CPU x cpu .to(device) GPU x cuda:0 W cuda:0 y cuda:0 数据每个 batch 搬一次;模型训练开始时搬一次(model.to(device))
参与同一次运算的张量必须在同一设备。左:数据在 CPU、模型在 GPU 直接相乘报错;右:x.to(device) 对齐后正常。

真实 API(指针)。.to(device) 搬运,并且约定一个 device 变量贯穿全程:

import torch

device = "cuda" if torch.cuda.is_available() else "cpu"

x = torch.randn(8, 16)              # 默认在 cpu
model_w = torch.randn(16, 4, device=device)

# x @ model_w   # 若 device=cuda 会报:
# RuntimeError: Expected all tensors to be on the same device,
# but found at least two devices, cuda:0 and cpu!

x = x.to(device)                    # 把数据搬到同一设备
y = x @ model_w                     # 现在 OK
print(y.device)

# 模型也一样要搬(下一课的 nn.Module):model = model.to(device)
# .cuda() 是 .to("cuda") 的简写;.cpu() 同理

dtype:选对精度,预判不匹配

机制直觉。dtype 决定每个数字占几个字节、能表示多大范围、多少精度。常见三类:

dtype字节用途
float324默认浮点。训练/推理的通用选择,精度足够。
bfloat16 (bf16)2混合精度训练。指数位与 float32 相同,所以可表示的数量级范围几乎一样,但尾数大幅缩短、精度低;省一半显存、算得快。
int64 (long)8整数。索引、类别标签(如交叉熵的 target)默认就是它。

用 NumPy 把"dtype 不匹配会发生什么"这件事看清楚。注意:截断 / 溢出回绕的规则 NumPy 和 PyTorch 一致,但混合 int 与 float 的具体提升目标两者不同——下面先看 NumPy 的行为,PyTorch 的差异在参考块里说明。

import numpy as np

# 1) 混合 dtype 运算:NumPy 自动提升到能容纳两者的类型
xi = np.array([1, 2, 3], dtype=np.int64)
yf = np.array([0.5, 0.5, 0.5], dtype=np.float32)
z = xi + yf
print("提升结果 dtype:", z.dtype, z)      # float64(不是 float32!)
# 为什么是 float64 而不是 float32?因为 int64 的精度需求超过 float32 能安全容纳的范围,
# NumPy 于是提升到更宽的 float64。这正是"混合 int 与低位浮点要小心"的典型坑。

# 2) 向下转换会截断/溢出回绕
big = np.array([100, 200, 300], dtype=np.int64).astype(np.int8)
print("int64 -> int8:", big)             # [100 -56  44]  超出 [-128,127] 回绕
# 提示:新版 numpy 对这种溢出可能给出 DeprecationWarning/RuntimeWarning,
# 看到黄色/红色警告不代表程序出错,只是提醒你正在丢信息。

# 3) float32 精度丢失
f64 = np.array([0.1], dtype=np.float64)
print("float64:", float(f64[0]))
print("float32:", float(f64.astype(np.float32)[0]))  # 0.10000000149...

真实 API(指针)。PyTorch 的 dtype 规则和 NumPy 有两处差别要记住:① 混合 int/float 时 PyTorch 倾向保留 float32,不会像 NumPy 那样升到 float64;② 很多操作根本不会自动提升,而是直接拒绝、抛 RuntimeError。

import torch

a = torch.tensor([1, 2, 3])              # int64
b = torch.tensor([1.0, 2.0, 3.0])        # float32

# 差别①:混合 int64 + float32,PyTorch 结果是 float32(不是 NumPy 的 float64)
print((a + b).dtype)                      # torch.float32

# 差别②:很多 op 不自动提升,dtype 不匹配直接报错。例如交叉熵
# (F = torch.nn.functional;交叉熵细节第6课讲,这里只看一点:标签 dtype 必须是 int64/long):
# import torch.nn.functional as F
# loss = F.cross_entropy(logits, labels.float())  # 报错:labels 必须是 long,不能是 float

x = torch.randn(4).to(torch.bfloat16)    # 转 bf16
print(x.dtype)                           # torch.bfloat16

y = a.float()                            # int64 -> float32 的简写
z = a.to(torch.float32)                  # 等价

调一调,观察现象

微任务 1:把切片换成 copy,观察共享是否断开。

import numpy as np
a = np.array([1, 2, 3, 4], dtype=np.float32)
b = a[1:3].copy()        # 改这里:去掉 .copy() 再跑一次对比
b[0] = 99
print("a =", a)
print("共享内存?", np.shares_memory(a, b))

微任务 2:reshape 一个连续 vs 不连续的数组,看结果顺序。

import numpy as np
a = np.arange(6).reshape(2, 3)
m = a.T                  # 改这里:换成 m = a 再跑一次
print("连续?", m.flags['C_CONTIGUOUS'])
print("reshape(6):", m.reshape(6))

微任务 3:向下转 dtype,触发溢出回绕。

import numpy as np
v = np.array([100, 200, 300], dtype=np.int64)
print(v.astype(np.int8))    # 改这里:换成 np.int16

动手练习

  1. 共享内存侦探。写一个 NumPy 例子:构造数组 a,做三种操作——切片、reshape(连续时)、.T——分别用 np.shares_memory(a, ...) 判断它们是否与 a 共享内存。预测后再跑。然后用一句话写出对应的 torch 结论:from_numpy 共享、clone 不共享。
  2. 修复 view 报错。给定一段(写在纸上或注释里的)torch 伪代码:x = torch.arange(24).reshape(2, 3, 4); y = x.permute(1, 0, 2); z = y.view(2, 12)。指出哪一行会报错、为什么,并写出两种修复(用 .contiguous() 和用 .reshape),说明它们在内存上的区别。
  3. 设备/精度清单。为一个"把 CPU 上的 float64 numpy 数组喂给 GPU 上 float32 模型"的场景,按顺序写出需要做的转换调用(device 与 dtype 各一次),并解释如果漏掉任一步会触发哪类报错。

掌握自检