一切从张量开始:一块连续内存和它的视图
这一章讲三件事: 张量到底是什么(不是玄学,是一块内存加一份说明书);为什么转置、切片这些操作可以「不花钱」;以及怎么把同一份代码搬到 GPU 上跑。 它在全书的位置:第 01 章说了「模型是一堆参数」,这一章回答「那些数和我们的数据在机器里长什么样」。后面每一章都在这一章的地基上盖楼。
1. 先解决一个误会:张量不是数学里的张量
如果你学过物理或数学,「张量」在那里有严格的定义。深度学习把它借走了,意思变了1:
在深度学习里,张量就是「向量、矩阵向任意维数的推广」——一个多维数组。
一个数叫标量(0 维),一排数叫向量(1 维,比如 [4, 1, 5]),一张数表叫矩阵(2 维,3 行 2 列),把几张矩阵码成一摞就是 3 维张量,以此类推2。一个张量是几维,就看你用几个下标才能指到其中一个数。NumPy(Python 里最流行的多维数组库,地位高到书里叫它「数据科学的通用语」)里同一个东西叫 ndarray——张量、多维数组、ndarray,三个名字一张脸3。
神经网络的输入、输出、参数、中间结果,全是张量。所以这一章不是「学一个数据结构」,是学这门手艺的原材料。
2. 张量的本质:连续内存 + 一份读法说明
Python 列表为什么不顶用
先看反例。Python 列表 [1.0, 2.0, 1.0] 里的每个数,都是一个独立的 Python 对象,各自占一块内存,带着引用计数等一整套包袱(这叫「装箱」,boxed)。存一百万个数,就是一百万个对象散落在内存里,做乘加要一个个解包4。
张量的做法完全不同:所有数挤在一块连续内存里,是同一种裸的 C 语言数值类型,没有任何对 象包装。 一百万个 float32(32 位浮点数)就是整整齐齐的 4MB,外加一点点元数据(数据之外的说明信息,比如形状和类型)4。这就是张量快的原因,也是它「同质」(所有元素同类型)的原因——连续内存里没法混装。
一块内存,多种读法
这是本章最要紧的一节。PyTorch 把「数据在哪」和「怎么读它」拆成了两层5:
- storage(存储):一块一维的连续内存,数实实在在躺在这里。永远一维。
- tensor(张量):一份「读法说明书」,写在同一块 storage 上。说明书只有三项:size(每维多长)、offset(从 storage 的第几格开始读)、stride(沿每维往前走一格,在 storage 里要跳过几个数)。
主走查从这里开始,跟踪三个二维点 (4,1)、(5,3)、(2,1):
torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
storage(真实内存,一维): [4, 1, 5, 3, 2, 1]
说明书: size = (3, 2) offset = 0 stride = (2, 1)
读 points[1, 0] (第 2 个点、第 1 个坐标):
位置 = offset + stride[0]×1 + stride[1]×0
= 0 + 2×1 + 1×0 = 2 → storage[2] = 5 ✓
图说:二维的「表」是说明书算出来的;内存里从来只有一排数。
stride = (2, 1) 读作:换行要在内存里跳 2 格,换列只跳 1 格——所以这行数据在内存里是一行一行挨着排的6。查表公式 offset + stride[0]×i + stride[1]×j 是整个 PyTorch 的心脏,后面所有「不花钱」的操作都是改说明书,不动内存。
切片是视图,不是副本
points[1](取出第二个点)返回什么?不是复制出来的新数组,而是同一块 storage 上的另一份说明书:size=(2,),offset=2,stride=(1,)7。
这有一个必须当场记住的后果:改视图,原张量跟着变。 书里演示:second_point = points[1]; second_point[0] = 10.0,然后 points 变成了 [[4,1],[10,3],[2,1]]8。要真正的独立副本,用 .clone()。这个坑真实到每个 PyTorch 用户都踩过;而到了第 03 章,同一条性质会翻过来变成好事——把一张图片的通道维挪到最前面,只改说明书、不复制那几百万个数。
转置:只换说明书
points.t()(行列互换)的结果:同一块 storage(书里用 id() 验了,同一块),stride 从 (2,1) 换成 (1,2)——完成。一个数都没搬9。
points size (3,2) stride (2,1) → 按行读
points.t() size (2,3) stride (1,2) → 按列读
内存始终是 [4, 1, 5, 3, 2, 1]
图说:转置前,「沿行走」要跳 2 格;转置后,「沿行走」只跳 1 格。
同一块内存,两种走法,就是两个张量。
由此自然引出两个配套概念:
- contiguous(连续):如果说明书的读法恰好是「从最右维开始一格不跳地读完整块内存」,这个张量就是连续的。
points连续,points.t()不连续。 - 有些操作(比如第 03 章会用到的
view)只在连续张量上工作,因为它们的实现依赖「挨排读」。这时调.contiguous()会得到一个新 storage:把数按新读法真正重排(重新排布顺序)一遍——这一步要复制数据,所以它不再是免费的了10。
另外记住一个命名约定:方法名尾巴带下划线的是原地改(zero_()、fill_()),不带下划线的返回新张量11。
3. 广播:形状不同的张量怎么一起算
张量之间的加减乘除默认是「对应位置相算」,那形状不一样怎么办?PyTorch 沿用 NumPy 的广播(broadcasting)规则:把两个形状从右往左逐维对齐(一维对一维地比),每一维要么相等、要么其中一边是 1;为 1 的那一边被「虚拟复制」到另一边那么大——不真的复制,只是读的时候反复读同一格12。
(3,1) (1,3) 对齐后都读成 (3,3)
[[10], × [[1,2,3]] = [[10, 20, 30],
[20], [20, 40, 60],
[30]] [30, 60, 90]]
图说:左边那列数,每一行都被「复 制」了三份去读;其实内存没动。
广播以后到处都用:第 04 章里 w * t_u + b 之所以能对一万个样本同时算,靠的就是标量 w、b 被广播到整个输入向量上。先在这里混个脸熟。
4. dtype:数字的粒度
「数」在张量里不是抽象的数,是占固定字节(8 个二进制位;存一个英文字母正好一个字节)的编码。dtype 就是「这个张量里每个数用哪种编码、占几个字节」。常用的几个13:
| dtype | 字节 | 干什么用 |
|---|---|---|
float32(默认) | 4 | 神经网络的默认工作精度(一个数能记到多细);更准的 float64 在这个领域买不到精度,只买到内存和时间 |
float16 / bfloat16 | 2 | 省一半内存;bfloat16 是 Google 2019 年提的变体,牺牲精度细节换更大的表示范围,训练大模型常用 |
int64 | 8 | 索引(按下标取数)专用——拿一个张量去索引另一个张量时,PyTorch 要求索引是 64 位整数 |
bool | 1 | points > 1.0 这类比较的结果,逐元素 True/False |
两条实战规矩:混着用会自动转成更宽的类型(float32×int16 算出 float32);NumPy 的默认是 float64,PyTorch 是 float32,从 NumPy 转过来时记得 .float()14。
5. GPU:换块地方住,代码一个字不改
第 01 章说过 GPU 是这门手艺的硬件前提。到张量这一层,它的全部使用方式就是:每个张量有个 device 属性,注明它住在哪(CPU 内存还是某块 GPU 的显存);.to(device='cuda') 把它复制过去,之后的运算就全在那块 GPU 上发生15。
points_gpu = points.to(device='cuda') # 复制到 GPU
points_gpu = 2 * points_gpu # 这步在 GPU 上算,结果留在 GPU
points_cpu = points_gpu.to('cpu') # 要拿回 CPU 才复制回来
图说:数据一旦过去,一连串运算都留在那边;只有你要看(打印/存盘)时才回来。
三个细节:苹果芯片用 device='mps'、AMD 用 ROCm、TPU 走另外的包——2025 年之后 PyTorch 支持的硬件后端已经是一排15;同一代码不用改,因为 API(Application Programming Interface 的缩写,就是库暴露给你调用的那组函数)对 CPU/GPU 张量完全一致;模型和数据必须在同一块设备上,一边在 CPU 一边在 GPU 会直接报错——第 08 章的训练循环里会看到这两行搬家代码长什么样。
顺带把「和外界交换」说完:.numpy() 把张量转成 NumPy 数组,CPU 上零拷贝——两边共享同一块内存,改一边另一边也变;GPU 上的张量则会先复制回 CPU16。torch.save/torch.load 用 Python 的 pickle 把对象落成字节流,这一步叫序列化(把内存对象写成可以存盘的字节流);要跨软件交换就用 HDF5 这类通用格式,它还能让你不加载整个文件、只读其中几行17。
6. 作者的判断与证据
| 说法 | 性质 |
|---|---|
| 「1M 个 float32 恰好 4MB 连续内存」 | 可验算,32 位 × 10⁶ = 4×10⁶ 字节4 |
| 「转置/切片是零拷贝」 | 有据,书里用 id(storage) 相同当场验证9 |
| 「float64 不提升模型精度」 | 经验共识,书里没给实验;作为「业界通行做法」接受13 |
| 「named tensor(给每个维起名字)能消掉一类对齐错误」 | 作者们认可方向但本书弃用:该特性多年仍是实验状态18。书里引了那篇有名的《Tensor Considered Harmful》,知道有这个提案即可 |
7. 边界与局限
- 本章讲的都是稠密(每个格子都真存一个数)、规则的张量。稀疏(大部分格子是零,只存非零的)张量、各种硬件专用张量存在,但走另一条路:PyTorch 内部有个 dispatcher(调度器), 把同一份 API 分发到不同后端——知道「API 和存储是分离的」这一点就够了19。
- storage/stride 这套视图机制在官方路线图上有过「未来版本可能不直接暴露」的注记5;心智模型不会过时,具体属性名(如
.storage())可能变。 - 视图共享内存是双刃剑:它省内存,也是「改了视图忘了是视图」这类 bug 的温床。
8. 可带走的
- 张量 = 多维数组,0 维标量、1 维向量、2 维矩阵都是它;「第几维」就是「第几个下标」。
- 张量 = storage(连续一维内存)+ size/offset/stride(读法说明书);
offset + stride[0]×i + stride[1]×j是定位公式。 - 切片、转置、换布局都是改说明书,不复制数据;改视图会改原张量,要副本用
clone()。 - 尾巴带下划线的方法是原地改(
zero_);view只服务连续张量,contiguous()会真的重排内存。 - 广播:形状从右往左对,相等或为 1;为 1 的被虚拟复制。
w * t_u + b能对整个数据集一次算,靠的是它。 - dtype 决定字节数和范围:默认 float32,索引用 int64,比较产 bool,混用自动转宽。
.to(device)是搬家的全部写法;模型和数据必须同设备;CPU/GPU 同一份 API。.numpy()在 CPU 上零拷贝共享内存——方便,也意味着改 NumPy 数组会改张量。
9. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 张量定义、ndarray 别名 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:78(搜「generalization of vectors and matrices」) · :101(搜「lingua franca」) |
| 列表装箱 vs 连续内存 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:195(搜「allocated in memory」) · :200(搜「4 million contiguous bytes」) |
| points 三坐标例子 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:250(搜「4.0, 1.0」) |
| storage 一维、视图机制 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:704(搜「contiguous chunks of memory」) · :772(搜「always one-dimensional」) |
| 子张量共享内存、clone | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:896(搜「side effect on the original tensor」) |
| 转置只换 stride | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:947(搜「share the same storage」) · :976(搜「the very definition of transposing」) |
| 连续与 contiguous() | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:1024(搜「defined as contiguous」) · :1031(搜「only work on contiguous tensors」) |
| 下划线原地操作 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:790(搜「trailing underscore」) |
| 广播规则 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:348(搜「from right to left」) |
| dtype 全家桶与默认 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:569(搜「12 different dtype」) · :586(搜「default data type」) · :575(搜「bfloat16」) |
| GPU、device、多后端 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:1125(搜「Moving tensors to the GPU」) · :1134(搜「ROCm」) · :1194(搜「no information flows to the CPU」) |
| NumPy 零拷贝与 float64 注意 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:1219(搜「zero-copy」) · :1250(搜「64-bit」) |
| dispatcher 与广义张量 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:1258(搜「separate from the tensor API」) |
| named tensor 弃用 | ch3 | text/11-ch03-3-it-starts-with-a-tensor.txt:532(搜「experimental nature」) |