📑 本页目录(点开跳转)
01 · 张量:一块内存 + 一份说明书
⏱ 74 分钟 | ⭐ 实测:x[1:, 1:3] 和 x 是同一块 storage,切片改的只是说明书上的三个数
🎯 一句话
一个张量 = 一块连续的字节(storage)+ 一份说明书(shape / stride / storage_offset / dtype / device / requires_grad)。
切片、转置、view、expand、detach 全都只改说明书,一个字节都不搬。
本章后面的所有「怪事」——包括为什么改了一个张量另一个跟着变、为什么 view 会报错、为什么 expand 出来的 (3,4) 底下只有 3 个数 —— 都是这一句的直接后果。
🧩 一、说明书上有六个字段
先把它们一次打出来看看:
import torch
x = torch.arange(12.0).reshape(3, 4)
print("shape ", tuple(x.shape)) # 每一维多长
print("stride ", x.stride()) # ⭐ 走一步跳几个元素
print("storage_offset", x.storage_offset()) # 从内存的第几个元素开始
print("dtype ", x.dtype) # 每个元素占几字节、怎么解释
print("device ", x.device) # 这块内存在谁那儿
print("requires_grad ", x.requires_grad) # 要不要被 autograd 记账
print("底下的字节数 ", len(x.untyped_storage()))
实测输出:
对照
shape (3, 4)
stride (4, 1)
storage_offset 0
dtype torch.float32
device cpu
requires_grad False
底下的字节数 48
⭐ 注意最后一行:12 个 float32,每个 4 字节 → 48 字节。
底下就是一条 48 字节的直线,没有「行」和「列」这回事 —— 行列是说明书里的 shape 和 stride 编出来的。
六个字段各自管什么:
| 字段 | 管什么 | 改了它会怎样 |
|---|---|---|
shape |
逻辑上是几维、每维多长 | 只是「怎么数」,不动内存 |
stride |
⭐ 沿这一维走一步,在 storage 里跳几个元素 | 只是「怎么走」,不动内存 |
storage_offset |
从 storage 的第几个元素开始看 | 只是「从哪起」,不动内存 |
dtype |
每个元素几字节、按整数还是浮点解释 | ⚠️ 改它必然要新内存(字节含义变了) |
device |
这块内存在 CPU 还是某张卡上 | ⚠️ 改它必然要搬数据 |
requires_grad |
要不要被 autograd 记账 | 不动内存,但改变后面每一步的行为(第 02、04 章) |
⭐ 前三个是纯说明书,后三个碰内存或碰语义。 这条分界线记住,本章后面全在用它。
⭐
stride的机制不在这一章讲。 「为什么stride是这几个数、C 序和 F 序怎么走、按行求和为什么反而慢」属于缓冲区那一层, 站内归 NumPy 与向量化思维 05。 这一章只用到一句话:stride是说明书上的一个数组,改它不搬内存。
🧩 二、切片只改说明书上的三个数
import torch
x = torch.arange(12.0).reshape(3, 4)
y = x[1:, 1:3] # 切片:只改说明书,不搬数据
print("x shape", tuple(x.shape), "stride", x.stride(), "offset", x.storage_offset())
print("y shape", tuple(y.shape), "stride", y.stride(), "offset", y.storage_offset())
print("同一块 storage 吗:",
x.untyped_storage().data_ptr() == y.untyped_storage().data_ptr()) # ⭐
y[0, 0] = -1 # 改 y
print("x 变了吗:\n", x) # ⚠️ x[1,1] 也变成 -1
实测输出:
对照
x shape (3, 4) stride (4, 1) offset 0
y shape (2, 2) stride (4, 1) offset 5
同一块 storage 吗: True
x 变了吗:
tensor([[ 0., 1., 2., 3.],
[ 4., -1., 6., 7.],
[ 8., 9., 10., 11.]])
⭐ 逐个字段看差在哪:
x |
y = x[1:, 1:3] |
变化 | |
|---|---|---|---|
shape |
(3, 4) |
(2, 2) |
少看了一行两列 |
stride |
(4, 1) |
(4, 1) |
⭐ 完全没变 —— 行还是隔 4 个元素 |
offset |
0 |
5 |
⭐ 起点右移 5 个元素,正好是 x[1,1] 的位置(1×4 + 1 = 5) |
一个字节都没搬。 所以 y[0,0] = -1 直接就改到了 x[1,1]。
💀 这是新手最常掉进去的一个坑,因为它不报错:
import torch
X = torch.ones(200, 3)
val_x = X[:100] # 想「取出验证集」
val_x[:] = 0 # 想「把这份拷贝清零」
print("X 的前 100 行被清零了吗:", bool((X[:100] == 0).all())) # ⚠️ True
print("X 剩下的行:", X[100].tolist())
实测输出:
要点
X 的前 100 行被清零了吗: True
X 剩下的行: [1.0, 1.0, 1.0]
⭐ 想要真拷贝就写 .clone()。 判断「是不是同一块内存」的最直接方法是比 data_ptr()
(第七节还会用到它)。
⚠️ 不是所有索引都是视图。「哪些索引给视图、哪些给拷贝」这条分界线属于缓冲区层, 在 NumPy 与向量化思维 04 有完整的判据表,torch 的规则和那边一致。
🧩 三、view 改不出来的时候会报错
view 的定义就是「只准改说明书」。改不出来它就直说:
import torch
x = torch.arange(12.0).reshape(3, 4)
t = x.t() # 转置:只把 stride 反过来
print("x stride", x.stride(), "连续?", x.is_contiguous())
print("t stride", t.stride(), "连续?", t.is_contiguous()) # ⭐ (1, 4)
try:
t.view(-1) # ⚠️ view 只改说明书,改不出来就报错
except RuntimeError as e:
print("t.view(-1) ->", e)
print("t.reshape(-1) 可以:", t.reshape(-1).tolist())
print("reshape 的结果连续吗:", t.reshape(-1).is_contiguous())
实测输出(⭐ 报错是真实原文):
关键信息
⭐ .t() 只把 stride 从 (4, 1) 换成 (1, 4),一个字节没搬。于是要把它「拉平成一条」时,
按新说明书走出来的顺序(0, 4, 8, 1, 5, 9, …)在 storage 里是跳着的,
一份说明书表达不出来 —— 这就是那句 spans across two contiguous subspaces 的意思。
| 你写的 | 它保证什么 | 改不出来时 |
|---|---|---|
view(...) |
⭐ 一定不拷贝(要么是视图,要么报错) | 报错 |
reshape(...) |
尽量给视图,给不了就悄悄拷一份 | 静默拷贝(is_contiguous() 变 True 就是证据) |
.contiguous() |
显式重排一次内存 | —— |
⚠️ 报错原文让你 Use .reshape(...) instead,但那不一定是你该做的。
(这就是 00 章说的第 ④ 个主题:报错原文带着答案,但它建议的修法不一定对。)
- 你只是想换个形状、不在乎多一次拷贝 → 用
reshape,没问题 - 你在乎这一步不能拷贝(比如就地写回、或者在意显存) → 应该往回看为什么它不连续,
而不是用
reshape把问题盖住
⭐ 多头注意力里 transpose(1,2) 之后拼回去那一步的标准写法(.contiguous().view(...))
在 ML 基础附录 C 里已经有了,这里不重写。
🧩 四、dtype:默认值本身就是坑
dtype 决定「一个元素占几个字节、按什么解释」。⚠️ 改 dtype 一定会新开内存,因为字节的含义变了。
真正咬人的是默认值不统一:
import torch
print("torch.tensor([1, 2, 3]).dtype =", torch.tensor([1, 2, 3]).dtype) # ⚠️ int64
print("torch.tensor([1., 2.]).dtype =", torch.tensor([1., 2.]).dtype) # float32
print("torch.Tensor([1, 2, 3]).dtype =", torch.Tensor([1, 2, 3]).dtype) # ⚠️ 大写 T 一律 float32
print("torch.arange(3).dtype =", torch.arange(3).dtype) # ⚠️ int64
print("torch.zeros(3).dtype =", torch.zeros(3).dtype) # float32
try:
torch.tensor([1, 2, 3], requires_grad=True) # ⭐ 整数不能要梯度
except RuntimeError as e:
print("整数 + requires_grad ->", e)
实测输出:
关键信息
⭐ 三条要记住的:
torch.tensor(...)看你给的字面量:[1, 2, 3]是int64,[1., 2.]是float32。 ⚠️ 少写一个小数点,整个张量的类型就变了。torch.Tensor(...)(大写 T)不看内容,一律float32。 它是老 API,⭐ 别用,永远写小写的torch.tensor。- ⭐ 整数张量不能
requires_grad=True—— 报错原文Only Tensors of floating point and complex dtype can require gradients。 梯度是导数,整数格点上没有导数可言。
两个最高频的 dtype 报错,抄的是真实原文:
import torch
import torch.nn as nn
m = nn.Linear(3, 1)
try:
m(torch.randn(2, 3).double()) # ⚠️ 输入 float64,权重 float32
except RuntimeError as e:
print("dtype 不一致 ->", e)
logits = torch.randn(2, 3)
try:
nn.CrossEntropyLoss()(logits, torch.tensor([0., 1.])) # ⚠️ 标签是浮点
except RuntimeError as e:
print("交叉熵标签用了浮点 ->", e)
实测输出:
关键信息
| 报错关键词 | 真实原因 | 修法 |
|---|---|---|
mat1 and mat2 must have the same dtype |
数据是 float64(多半来自 NumPy 的默认 float64),模型权重是 float32 |
⭐ 把数据 .float(),别把模型 .double() —— 后者会让整个模型慢一大截 |
expected target dtype to be Long or Byte |
标签建成了浮点 | labels.long()。⚠️ 顺便确认标签是整数类别索引而不是 one-hot(ML 基础 15 的六个 Bug 里有这条) |
⚠️ float64 从哪来的:从 NumPy。NumPy 的浮点默认是 float64,torch 的默认是 float32,
所以「从 NumPy 转过来的数据喂进模型就报 Double and Float」是必然会撞一次的。
🛑 读到这里可以停 —— 前半章讲完了(约 32 分钟)。 后半章还有:
device:唯一必须搬数据的字段 ·requires_grad:说明书上唯一改变「未来」的字段 · 下划线结尾 = 就地改 · 和 NumPy 之间:一个借内存,一个拷贝 ·expand:stride可以是 0 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、device:唯一必须搬数据的字段
device 说的是「这块 storage 在谁的内存里」。⚠️ CPU 上的张量和显卡上的张量不能直接一起算,
因为它们在物理上根本不在一个地方。
import torch
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
print("这台机器上 device =", device)
model = nn.Linear(3, 1)
x = torch.randn(2, 3)
x.to(device) # ⚠️ 白写一行:返回了一个新张量,没人接
x = x.to(device) # ⭐ 正确
model.to(device) # ⭐ Module 是就地改,这样写是有效的
print("x 在", x.device, " 权重在", model.weight.device)
实测输出(⚠️ 本机没有 GPU,所以 device 是 cpu):
要点
这台机器上 device = cpu
x 在 cpu 权重在 cpu
⭐ 两个必须分清的行为差异(这一条是 05 章的伏笔):
| 对象 | .to(device) 的行为 |
|---|---|
| 张量 | ⚠️ 返回一个新张量,原来的不变。x.to("cuda") 单写一行等于白写,必须 x = x.to(...) |
nn.Module |
⭐ 就地改(并且返回自己)。model.to(device) 单写一行是有效的 |
🗓️ 未实跑 —— 需要 GPU:本套的验证环境
torch.cuda.is_available()是False, 所以「设备不匹配」那条报错(Expected all tensors to be on the same device)没有实跑原文, 本章不引用它的具体措辞。上面那两条.to()的行为差异在 CPU 上同样成立,是可验证的。
🧩 六、requires_grad:说明书上唯一改变「未来」的字段
前五个字段描述的是「这块数据现在是什么样」,requires_grad 描述的是「接下来对它做的每一步要不要被记账」。
- 它是
False:算就算了,算完什么都不留 - 它是
True:⭐ 每一步运算都会在结果上挂一个「怎么反着算回来」的节点
这就是第 02 章的全部内容。这里只要记住一件事:
⭐
requires_grad只能设在「叶子」上。 你自己造出来的张量(模型参数、你手动requires_grad=True的那个)是叶子; 由运算产生的中间结果不是叶子,它的requires_grad是算出来的,不是你设的。
🧩 七、下划线结尾 = 就地改
PyTorch 有一条贯穿全库的命名约定:
⭐ 方法名以下划线结尾(
add_、mul_、clamp_、zero_、normal_)= 就地修改调用者本身,并返回它自己。 不带下划线的同名方法 = 新开一块内存放结果,原张量不动。
import torch
x = torch.ones(3)
y = x.add(1) # 不带下划线:新开一块内存
print("x =", x.tolist(), " y =", y.tolist(), " 同一块?", x.data_ptr() == y.data_ptr())
z = x.add_(1) # ⭐ 带下划线:就地改 x,并把 x 自己返回
print("x =", x.tolist(), " z =", z.tolist(), " 同一块?", x.data_ptr() == z.data_ptr())
c = x.clone() # 拷一份新内存,梯度关系保留
d = x.detach() # ⭐ 同一块内存,只是不再记账(第 4 章细讲)
print("clone 同一块?", x.data_ptr() == c.data_ptr(),
" detach 同一块?", x.data_ptr() == d.data_ptr())
实测输出:
对照
x = [1.0, 1.0, 1.0] y = [2.0, 2.0, 2.0] 同一块? False
x = [2.0, 2.0, 2.0] z = [2.0, 2.0, 2.0] 同一块? True
clone 同一块? False detach 同一块? True
⭐ 两行最容易混的:
| 写法 | 新内存? | 还记账吗? | 什么时候用 |
|---|---|---|---|
x.clone() |
⭐ 是 | 是(梯度会流回 x) |
想要一份真正独立的数据 |
x.detach() |
否(同一块) | ⭐ 否 | 只想断梯度(第 04 章) |
x.detach().clone() |
是 | 否 | ⭐ 「既要独立数据、又不要梯度」的标准写法 |
⚠️ x.detach() 共享内存这件事是第 03、04 章几个坑的根源:你以为拿到了一份「安全的副本」,
其实改它就是在改原张量。
⚠️ 就地操作会和 autograd 打架,三种打法三种报错,那是第 03 章一整章的内容。这里只先立住命名约定。
🧩 八、和 NumPy 之间:一个借内存,一个拷贝
import numpy as np
import torch
arr = np.array([1, 2, 3])
a = torch.from_numpy(arr) # ⭐ 借用同一块内存
a[0] = 99
print("from_numpy 之后改 tensor,numpy 数组 =", arr)
arr2 = np.array([1, 2, 3])
b = torch.tensor(arr2) # ⚠️ 这个是拷贝
b[0] = 99
print("torch.tensor 之后改 tensor,numpy 数组 =", arr2)
c = torch.ones(3)
d = c.numpy() # ⭐ 反方向同样是借用
c[0] = 42
print("改 tensor 之后 .numpy() 拿到的数组 =", d)
实测输出:
对照
from_numpy 之后改 tensor,numpy 数组 = [99 2 3]
torch.tensor 之后改 tensor,numpy 数组 = [1 2 3]
改 tensor 之后 .numpy() 拿到的数组 = [42. 1. 1.]
⭐ 一句话:torch.from_numpy() 和 .numpy() 是借,torch.tensor() 是拷。
名字长得很像,行为完全相反。
| 写法 | 内存 | 什么时候用 |
|---|---|---|
torch.from_numpy(arr) |
⭐ 共享 | 数据很大、不想多占一份;⚠️ 代价是之后改哪边都会互相影响 |
torch.tensor(arr) |
拷贝 | ⭐ 默认就用这个,行为最不容易出意外 |
t.numpy() |
⭐ 共享 | 只是拿去画图 / 算指标时用;⚠️ 别在这个数组上就地改 |
⚠️ 两个附加条件:.numpy() 要求张量在 CPU 上、且 requires_grad 是 False
(要梯度的得先 .detach().cpu().numpy())。
⭐ 完整的 NumPy ↔ torch 术语对照表(
dim和axis、广播规则的差异、随机数种子怎么各管各的) 不在这一章。 站内归 NumPy 与向量化思维 11。 这里只留和「内存说明书」直接相关的这三行。
🧩 九、expand:stride 可以是 0
最后一个例子,把「说明书能编到什么程度」推到极限:
import torch
x = torch.arange(3.0).reshape(3, 1)
e = x.expand(3, 4) # ⭐ 广播成 (3,4),但没有多占内存
print("e shape", tuple(e.shape), "stride", e.stride()) # ⚠️ 第二维步长是 0
print("底下还是", len(e.untyped_storage()), "字节(3 个 float32)")
e[0, 0] = -1 # ⚠️ 不报错
print("只改了 e[0,0],整行都变了:", e[0].tolist())
print("连来源 x 也变了:", x.flatten().tolist())
实测输出:
要点
e shape (3, 4) stride (1, 0)
底下还是 12 字节(3 个 float32)
只改了 e[0,0],整行都变了: [-1.0, -1.0, -1.0, -1.0]
连来源 x 也变了: [-1.0, 1.0, 2.0]
⭐ stride 的第二维是 0 —— 意思是「沿这一维走一步,在内存里原地不动」。
于是逻辑上的 12 个格子,物理上只有 3 个 float32(12 字节)。广播就是这么实现的,
它不复制数据,只是把某一维的步长设成 0。
💀 代价:往 stride=0 的维度写,一个下标能改到一整片,而且它不报错。
上面 e[0, 0] = -1 一句话改掉了整行 4 个格子,还顺手改了来源 x。
⭐ 规矩:expand 出来的张量只读。要写就先 .clone()(或者用 repeat,那个是真复制)。
| 复制数据? | 能安全写入? | |
|---|---|---|
expand |
⭐ 不复制(stride=0) |
⚠️ 不能 |
repeat |
复制 | 能 |
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 02 · autograd 怎么建图 | 说明书上的第六个字段 requires_grad 展开就是下一章 —— 它一开,每一步运算就开始留节点 |
| 03 · 三种就地操作报错 | 本章第七节的「下划线 = 就地」和第二节的「共享内存」撞在一起,就是那一章的三种报错 |
| NumPy 与向量化思维 05 · 内存布局与 stride | ⭐ stride 到底怎么算出来的、C 序 F 序怎么走 —— 本章只用了「它是说明书上的一个数组」这一句 |
| NumPy 与向量化思维 04 · 视图还是拷贝 | 「哪些索引给视图、哪些给拷贝」的完整判据表,torch 的规则和那边一致 |
| NumPy 与向量化思维 11 · 从 NumPy 到 PyTorch | dim 和 axis、广播差异、种子各管各的 —— 完整对照表在那边 |
| ML 基础附录 C · 手撕代码速查 | 多头注意力拼回去那一步 .contiguous().view(...) 的标准写法,本章第三节引用的就是它 |
| AI 基础设施 02 · GPU 到底是什么 | ⭐ 「不连续为什么慢」的硬件层答案:跳跃访问无法合并成一次内存事务,有效带宽可能掉到 1/32 |
✅ 检查点
- 张量的说明书上有哪六个字段?哪三个是「纯说明书」(改了不动内存),哪三个碰内存或碰语义?
x = torch.arange(12.).reshape(3,4)之后y = x[1:, 1:3],y的shape、stride、offset各是多少?为什么stride没变?x.t()之后x.t().view(-1)报的是什么错?为什么reshape就可以?reshape付出了什么代价?torch.tensor([1, 2, 3])和torch.Tensor([1, 2, 3])的dtype分别是什么?为什么建议永远用小写的那个?- 整数张量能不能
requires_grad=True?报错原文的关键词是什么? mat1 and mat2 must have the same dtype, but got Double and Float通常从哪来?该改哪一边?.clone()、.detach()、.detach().clone()三者在「新内存」和「记不记账」上各是什么组合?torch.from_numpy(arr)和torch.tensor(arr)有什么区别?实测里改了 tensor 之后 numpy 数组分别变成了什么?x.expand(3, 4)的stride是多少?底下真实占了多少字节?为什么说它「只读」?
👀 答案
shape/stride/storage_offset/dtype/device/requires_grad。前三个是纯说明书,改了一个字节都不动;dtype和device改了必然要新内存或搬数据;requires_grad不动内存,但改变后面每一步的行为。shape=(2, 2)、stride=(4, 1)、offset=5。stride完全没变,因为「隔一行」在 storage 里仍然是跳 4 个元素这件事没变;变的只是从哪起(offset从 0 到 5,正好是x[1,1]的位置 1×4+1=5)和看几个(shape)。同一块 storage,所以y[0,0] = -1会把x[1,1]也改成 -1。- 报
view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.。因为.t()只把stride从(4,1)换成(1,4),拉平后的顺序(0,4,8,1,5,9,…)在内存里是跳着的,一份说明书表达不出来。reshape可以,代价是它悄悄拷了一份(结果的is_contiguous()变成True就是证据)。 torch.tensor([1,2,3])是int64(跟着字面量走),torch.Tensor([1,2,3])是float32(大写 T 不看内容,一律 float32,是老 API)。建议永远用小写的,因为大写那个的行为和你写的字面量无关。- 不能。报错原文是
Only Tensors of floating point and complex dtype can require gradients—— 梯度是导数,整数格点上没有导数。 - 数据多半是从 NumPy 来的(NumPy 浮点默认
float64,torch 默认float32)。改数据这一边,把数据.float();不要把模型.double(),那会让整个模型慢一大截。 .clone()= 新内存 + 还记账(梯度会流回原张量);.detach()= 同一块内存 + 不记账;.detach().clone()= 新内存 + 不记账,是「既要独立数据又不要梯度」的标准写法。⚠️.detach()共享内存这件事是第 03、04 章几个坑的根源。torch.from_numpy(arr)是借同一块内存,torch.tensor(arr)是拷贝。实测:from_numpy那条改完 tensor 后 numpy 数组变成[99 2 3];torch.tensor那条 numpy 数组还是[1 2 3]。反方向的.numpy()也是借(实测[42. 1. 1.])。stride是(1, 0)—— 第二维步长为 0,走一步原地不动。底下真实只有 12 字节(3 个 float32),逻辑上却是 12 个格子。说它只读是因为往stride=0的维度写会一个下标改到一整片而且不报错:实测e[0,0] = -1一句改掉了整行[-1.0, -1.0, -1.0, -1.0],还顺手把来源x改成了[-1.0, 1.0, 2.0]。要写就先.clone(),或者用真复制的repeat。
🛑 可以停在这里
⚡ 走神救援
先记住这几件事
- 把张量分成底层存储与描述它的元数据来理解。
- shape、stride 和 storage_offset 一起决定元素怎样映射到存储。
- 改变设备、数据类型或求导设置的代价不同;判断是否共享内存要实际检查。
下一节 👉 02-autograd怎么建图.md