跳到主要内容

一切从张量开始:一块连续内存和它的视图

这一章讲三件事: 张量到底是什么(不是玄学,是一块内存加一份说明书);为什么转置、切片这些操作可以「不花钱」;以及怎么把同一份代码搬到 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 / bfloat162省一半内存;bfloat16 是 Google 2019 年提的变体,牺牲精度细节换更大的表示范围,训练大模型常用
int648索引(按下标取数)专用——拿一个张量去索引另一个张量时,PyTorch 要求索引是 64 位整数
bool1points > 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 上的张量则会先复制回 CPU16torch.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. 可带走的

  1. 张量 = 多维数组,0 维标量、1 维向量、2 维矩阵都是它;「第几维」就是「第几个下标」。
  2. 张量 = storage(连续一维内存)+ size/offset/stride(读法说明书);offset + stride[0]×i + stride[1]×j 是定位公式。
  3. 切片、转置、换布局都是改说明书,不复制数据;改视图会改原张量,要副本用 clone()
  4. 尾巴带下划线的方法是原地改(zero_);view 只服务连续张量,contiguous() 会真的重排内存。
  5. 广播:形状从右往左对,相等或为 1;为 1 的被虚拟复制。w * t_u + b 能对整个数据集一次算,靠的是它。
  6. dtype 决定字节数和范围:默认 float32,索引用 int64,比较产 bool,混用自动转宽。
  7. .to(device) 是搬家的全部写法;模型和数据必须同设备;CPU/GPU 同一份 API。
  8. .numpy() 在 CPU 上零拷贝共享内存——方便,也意味着改 NumPy 数组会改张量。

9. 原文地图

主题原书章原文位置
张量定义、ndarray 别名ch3text/11-ch03-3-it-starts-with-a-tensor.txt:78(搜「generalization of vectors and matrices」) · :101(搜「lingua franca」)
列表装箱 vs 连续内存ch3text/11-ch03-3-it-starts-with-a-tensor.txt:195(搜「allocated in memory」) · :200(搜「4 million contiguous bytes」)
points 三坐标例子ch3text/11-ch03-3-it-starts-with-a-tensor.txt:250(搜「4.0, 1.0」)
storage 一维、视图机制ch3text/11-ch03-3-it-starts-with-a-tensor.txt:704(搜「contiguous chunks of memory」) · :772(搜「always one-dimensional」)
子张量共享内存、clonech3text/11-ch03-3-it-starts-with-a-tensor.txt:896(搜「side effect on the original tensor」)
转置只换 stridech3text/11-ch03-3-it-starts-with-a-tensor.txt:947(搜「share the same storage」) · :976(搜「the very definition of transposing」)
连续与 contiguous()ch3text/11-ch03-3-it-starts-with-a-tensor.txt:1024(搜「defined as contiguous」) · :1031(搜「only work on contiguous tensors」)
下划线原地操作ch3text/11-ch03-3-it-starts-with-a-tensor.txt:790(搜「trailing underscore」)
广播规则ch3text/11-ch03-3-it-starts-with-a-tensor.txt:348(搜「from right to left」)
dtype 全家桶与默认ch3text/11-ch03-3-it-starts-with-a-tensor.txt:569(搜「12 different dtype」) · :586(搜「default data type」) · :575(搜「bfloat16」)
GPU、device、多后端ch3text/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 注意ch3text/11-ch03-3-it-starts-with-a-tensor.txt:1219(搜「zero-copy」) · :1250(搜「64-bit」)
dispatcher 与广义张量ch3text/11-ch03-3-it-starts-with-a-tensor.txt:1258(搜「separate from the tensor API」)
named tensor 弃用ch3text/11-ch03-3-it-starts-with-a-tensor.txt:532(搜「experimental nature」)

Footnotes

  1. 出处:「3 It starts with a tensor」第 78 段(text/11-ch03-3-it-starts-with-a-tensor.txt:78,搜「generalization of vectors and matrices」)。原文特意提醒:数学或物理背景读者知道的「张量」和这里的不一样,深度学习里它就是向量、矩阵向任意维数的推广。

  2. 出处:「3 It starts with a tensor」第 81 段(text/11-ch03-3-it-starts-with-a-tensor.txt:81,搜「number of indices」)。维数等于指到一个标量所需的下标个数;下标从 0 开始、从左往右数。

  3. 出处:「3 It starts with a tensor」第 101 段(text/11-ch03-3-it-starts-with-a-tensor.txt:101,搜「lingua franca」)。原文:NumPy「可以说已经成了数据科学的通用语」。

  4. 出处:「3 It starts with a tensor」第 195 段(text/11-ch03-3-it-starts-with-a-tensor.txt:195,搜「allocated in memory」)与第 200 段(:200,搜「4 million contiguous bytes」)。原文对照:Python 列表是逐个分配的装箱对象;张量是连续内存上的未装箱 C 数值,一百万个 float32 恰好 4MB 加少量元数据。 2 3

  5. 出处:「3 It starts with a tensor」第 704 段(text/11-ch03-3-it-starts-with-a-tensor.txt:704,搜「contiguous chunks of memory」)。原文:值存在由 torch.Storage 管理的连续内存里,张量是「带着 offset 和各维 stride 的、指向这份 storage 的视图」;同段的注记提醒 Storage 属性未来版本可能不直接暴露。 2

  6. 出处:「3 It starts with a tensor」第 809 段(text/11-ch03-3-it-starts-with-a-tensor.txt:809,搜「size, offset, and stride」)与第 864 段(:864,搜「storage_offset +」)。定位公式原文就有:storage_offset + stride[0]*i + stride[1]*j。

  7. 出处:「3 It starts with a tensor」第 834 段(text/11-ch03-3-it-starts-with-a-tensor.txt:834,搜「second_point = points[1]」)。points[1] 的 size=(2)、offset=2、stride=(1,),与 points 同一块 storage。

  8. 出处:「3 It starts with a tensor」第 896 段(text/11-ch03-3-it-starts-with-a-tensor.txt:896,搜「side effect on the original tensor」)。原文:「这也意味着,改子张量会对原张量产生副作用」。

  9. 出处:「3 It starts with a tensor」第 947 段(text/11-ch03-3-it-starts-with-a-tensor.txt:947,搜「share the same storage」)与第 976 段(:976,搜「the very definition of transposing」)。id(points.storage()) == id(points_t.storage()) 返回 True;原文:「没有分配新内存,转置只是建了个 stride 顺序不同的新 Tensor 实例」。 2

  10. 出处:「3 It starts with a tensor」第 1024 段(text/11-ch03-3-it-starts-with-a-tensor.txt:1024,搜「defined as contiguous」)与第 1031 段(:1031,搜「only work on contiguous tensors」)。view 这类操作只认连续张量;contiguous() 会把内容按新布局复制到新 storage。

  11. 出处:「3 It starts with a tensor」第 790 段(text/11-ch03-3-it-starts-with-a-tensor.txt:790,搜「trailing underscore」)。名字带尾巴下划线的方法(如 zero_)原地修改,不带的返回新张量。

  12. 出处:「3 It starts with a tensor」第 348 段(text/11-ch03-3-it-starts-with-a-tensor.txt:348,搜「from right to left」)。广播规则:从右往左逐维比较,相等或其中一边为 1 即兼容;为 1 的维被「虚拟复制」。

  13. 出处:「3 It starts with a tensor」第 569 段(text/11-ch03-3-it-starts-with-a-tensor.txt:569,搜「12 different dtype」)、第 575 段(:575,搜「bfloat16」)与第 586 段(:586,搜「default data type」)。bfloat16 是 Google 2019 年引入:范围更大、精度更粗;默认 dtype 是 32 位浮点。 2

  14. 出处:「3 It starts with a tensor」第 632 段(text/11-ch03-3-it-starts-with-a-tensor.txt:632,搜「converted to the larger type」)与第 1250 段(:1250,搜「64-bit」)。混型运算自动升到更宽的类型;NumPy 默认 64 位浮点,转过来要留意。

  15. 出处:「3 It starts with a tensor」第 1125 段(text/11-ch03-3-it-starts-with-a-tensor.txt:1125,搜「Moving tensors to the GPU」)与第 1134 段(:1134,搜「ROCm」)。2025 年起官方支持 CUDA、ROCm、Apple MPS;TPU 与 Intel XPU 走独立扩展包。 2

  16. 出处:「3 It starts with a tensor」第 1219 段(text/11-ch03-3-it-starts-with-a-tensor.txt:1219,搜「zero-copy」)。CPU 上 .numpy() 共享同一块缓冲区,改 NumPy 数组会改张量;GPU 张量会先复制回 CPU。

  17. 出处:「3 It starts with a tensor」第 1285 段(text/11-ch03-3-it-starts-with-a-tensor.txt:1285,搜「pickle」)与第 1334 段(:1334,搜「index the dataset while on disk」)。HDF5 可以只读盘上数据的局部,不必整体加载。

  18. 出处:「3 It starts with a tensor」第 532 段(text/11-ch03-3-it-starts-with-a-tensor.txt:532,搜「experimental nature」)。named tensor 能给维起名字、让对齐错误提前暴露,但作者明说「本书余下部分仍用未命名张量」;提案出处是 Sasha Rush 的《Tensor Considered Harmful》。

  19. 出处:「3 It starts with a tensor」第 1258 段(text/11-ch03-3-it-starts-with-a-tensor.txt:1258,搜「separate from the tensor API」)。原文:数据怎么存和 Tensor API 是分开的,任何满足 API 契约的实现都算张量;dispatcher 负责把调用分发给正确的后端。