🏠 总目录📚 本教程 01 · 张量到底是什么 ← →
📑 本页目录(点开跳转)

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 编出来的。

storage:底下真实存在的只有这一条 48 字节 0 1 2 3 4 5 6 7 8 9 10 11 第几个元素 说明书 A:x shape = (3, 4) stride = (4, 1) offset = 0 看到全部 12 个格子 说明书 B:x[1:, 1:3] shape = (2, 2) stride = (4, 1) offset = 5 只看到高亮的 4 个格子

六个字段各自管什么:

字段 管什么 改了它会怎样
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())

实测输出(⭐ 报错是真实原文):

关键信息

x stride (4, 1) 连续? True
t stride (1, 4) 连续? False
t.view(-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.reshape(-1) 可以: [0.0, 4.0, 8.0, 1.0, 5.0, 9.0, 2.0, 6.0, 10.0, 3.0, 7.0, 11.0]
reshape 的结果连续吗: True

⭐ .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 章说的第 ④ 个主题:报错原文带着答案,但它建议的修法不一定对。)

⭐ 多头注意力里 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]).dtype = torch.int64
torch.tensor([1., 2.]).dtype = torch.float32
torch.Tensor([1, 2, 3]).dtype = torch.float32
torch.arange(3).dtype = torch.int64
torch.zeros(3).dtype = torch.float32
整数 + requires_grad -> Only Tensors of floating point and complex dtype can require gradients

⭐ 三条要记住的:

  1. torch.tensor(...) 看你给的字面量:[1, 2, 3] 是 int64,[1., 2.] 是 float32。 ⚠️ 少写一个小数点,整个张量的类型就变了。
  2. torch.Tensor(...)(大写 T)不看内容,一律 float32。 它是老 API,⭐ 别用,永远写小写的 torch.tensor。
  3. ⭐ 整数张量不能 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)

实测输出:

关键信息

dtype 不一致 -> mat1 and mat2 must have the same dtype, but got Double and Float
交叉熵标签用了浮点 -> expected target dtype to be Long or Byte, but got Float
报错关键词 真实原因 修法
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 描述的是「接下来对它做的每一步要不要被记账」。

这就是第 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

✅ 检查点

  1. 张量的说明书上有哪六个字段?哪三个是「纯说明书」(改了不动内存),哪三个碰内存或碰语义?
  2. x = torch.arange(12.).reshape(3,4) 之后 y = x[1:, 1:3],y 的 shape、stride、offset 各是多少?为什么 stride 没变?
  3. x.t() 之后 x.t().view(-1) 报的是什么错?为什么 reshape 就可以?reshape 付出了什么代价?
  4. torch.tensor([1, 2, 3]) 和 torch.Tensor([1, 2, 3]) 的 dtype 分别是什么?为什么建议永远用小写的那个?
  5. 整数张量能不能 requires_grad=True?报错原文的关键词是什么?
  6. mat1 and mat2 must have the same dtype, but got Double and Float 通常从哪来?该改哪一边?
  7. .clone()、.detach()、.detach().clone() 三者在「新内存」和「记不记账」上各是什么组合?
  8. torch.from_numpy(arr) 和 torch.tensor(arr) 有什么区别?实测里改了 tensor 之后 numpy 数组分别变成了什么?
  9. x.expand(3, 4) 的 stride 是多少?底下真实占了多少字节?为什么说它「只读」?
👀 答案
  1. shape / stride / storage_offset / dtype / device / requires_grad。前三个是纯说明书,改了一个字节都不动;dtype 和 device 改了必然要新内存或搬数据;requires_grad 不动内存,但改变后面每一步的行为。
  2. 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。
  3. 报 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 就是证据)。
  4. torch.tensor([1,2,3]) 是 int64(跟着字面量走),torch.Tensor([1,2,3]) 是 float32(大写 T 不看内容,一律 float32,是老 API)。建议永远用小写的,因为大写那个的行为和你写的字面量无关。
  5. 不能。报错原文是 Only Tensors of floating point and complex dtype can require gradients —— 梯度是导数,整数格点上没有导数。
  6. 数据多半是从 NumPy 来的(NumPy 浮点默认 float64,torch 默认 float32)。改数据这一边,把数据 .float();不要把模型 .double(),那会让整个模型慢一大截。
  7. .clone() = 新内存 + 还记账(梯度会流回原张量);.detach() = 同一块内存 + 不记账;.detach().clone() = 新内存 + 不记账,是「既要独立数据又不要梯度」的标准写法。⚠️ .detach() 共享内存这件事是第 03、04 章几个坑的根源。
  8. torch.from_numpy(arr) 是借同一块内存,torch.tensor(arr) 是拷贝。实测:from_numpy 那条改完 tensor 后 numpy 数组变成 [99 2 3];torch.tensor 那条 numpy 数组还是 [1 2 3]。反方向的 .numpy() 也是借(实测 [42. 1. 1.])。
  9. 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。

🛑 可以停在这里

⚡ 走神救援

先记住这几件事

下一节 👉 02-autograd怎么建图.md

打卡记录保存在你的浏览器里,首页能看到总进度