系列 《PyTorch 深度实践:从 Tensor 到深度学习运行时》 第 2 / 11 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 3 / 14 个系列
上一篇:PyTorch 整体介绍下一篇:自动求导与动态计算图
上一篇从整体上介绍了 PyTorch:它不是只有 Python API 的库,而是连接模型代码、Tensor 编程模型、算子运行时、设备后端、Kernel 和硬件的一套计算平台。
这一次进入这张地图的核心数据抽象:
Tensor 到底是什么?1
初学 PyTorch 时,Tensor 很容易被理解成“支持 GPU 的 NumPy 数组”。这个理解可以帮助开始使用,但不足以解释真实工程中的许多现象:
- 为什么
transpose()通常不会复制数据?2 - 为什么
view()有时成功,有时会报错?3 - 为什么
reshape()有时是零拷贝,有时会产生一份新数据?4 - 为什么一个 Tensor 的
shape相同,性能却可能完全不同?5 - 为什么把模型移动到 GPU 后,输入数据还需要单独移动?6
- 为什么
float16不只是把每个数字占用的字节数减半?7 - 为什么一个看似普通的 in-place 操作会和 Autograd 冲突?8
- 为什么数据已经“释放”了,GPU 显存仍然显示被占用?9
这些问题背后都指向同一个事实:
Tensor 不只是数据本身,而是数据、形状、布局、类型、设备和生命周期的组合。
一、总览:一个 Tensor 由哪些部分组成
1. 本文的主线
本文会从一个简化的 Tensor 模型开始,逐步解释 Storage、Shape、Stride、Storage Offset、dtype、device、view、拷贝和 contiguous:先建立“逻辑视图 + 物理存储”的整体模型,再逐个展开描述 Tensor 的各个字段,然后讨论 view / clone / detach / in-place 与广播这几组容易混淆的操作,最后从 Tensor 视角解释显存问题,并动手实现一个简化版 Tensor 来验证这套模型。后面 Autograd、Dispatcher、Kernel 和性能分析文章,都会以本文建立的 Tensor 心智模型为基础。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | Tensor 的整体模型 | 逻辑视图与物理存储、核心字段、元素数量与占用空间 |
| 三 | Shape:Tensor 的逻辑形状 | view / reshape / flatten、增删维度 |
| 四 | Stride:逻辑索引如何映射到内存 | stride 的定义、二维与三维例子、view 为何可能 |
| 五 | Transpose、Permute 与 View | 只改 metadata 的变换、转置后 view 为何失败 |
| 六 | Contiguous:连续布局与数据拷贝 | contiguous 的定义、Kernel 为何关心它、其他 layout |
| 七 | Storage、Storage Offset 与共享内存 | Storage、offset、切片 view、view 的生命周期影响 |
| 八 | dtype:如何解释每个元素 | FP16 与 BF16、dtype promotion、转换的拷贝成本 |
| 九 | Device:数据到底在哪里执行 | .to() 的两个维度、H2D 搬运、device mismatch、Meta device |
| 十 | View、Clone、Detach 与 In-place | 四组操作的语义边界及其与 Autograd 的关系 |
| 十一 | Broadcasting | expand 与 repeat、广播不等于复制、排查方式 |
| 十二 | 从 Tensor 视角理解内存问题 | 数据 / 缓存 / 计算图内存、生命周期、成本模型 |
| 十三 | 实现一个简化版 Tensor | 用 Python 复现 stride、transpose 与 contiguous copy |
| 十四 | Java 工程师应该如何理解 Tensor | Tensor 与 Java 数组的关键差异 |
| 十五 | 本文小结 | |
| 十六 | 自测 | 5 道题 |
二、Tensor 的整体模型
1. Tensor 不只是一个多维数组
假设有一个二维 Tensor:
import torch
x = torch.tensor([
[1, 2, 3],
[4, 5, 6],
])
从数学上看,它是一个 2 × 3 的矩阵:
[[1, 2, 3],
[4, 5, 6]]
但 PyTorch 还需要知道:
- 数据存储在哪里?
- 每个元素是什么类型?
- 逻辑上有几行几列?
- 从一个元素移动到下一个元素,需要跨过多少个存储位置?
- 这个 Tensor 是否与另一个 Tensor 共享底层数据?
- 数据位于 CPU 还是 GPU?
- 是否需要参与 Autograd?
因此可以把一个 Tensor 粗略表示为:
- Tensor
- Storage:底层数据存储
- sizes:每个维度的长度
- strides:每个维度的步长
- storage_offset:相对于 Storage 的起始偏移
- dtype:元素类型
- device:数据所在设备
- layout:布局信息
这组信息共同决定了“如何解释一段内存”。
2. Tensor 的逻辑视图与物理存储
Tensor 有两个需要分开的层次:
- 逻辑层
- shape / sizes
- 例如:2 行 3 列
- 物理层
- storage / offset / stride
- 例如:数据如何放在一段连续内存中
逻辑形状相同的两个 Tensor,物理布局可以不同:
x = torch.tensor([[1, 2, 3], [4, 5, 6]])
y = torch.tensor([[1, 4], [2, 5], [3, 6]]).t() # 先建一个 3×2,再转置
print(x.shape, y.shape) # torch.Size([2, 3]) torch.Size([2, 3])
print(torch.equal(x, y)) # True:逐元素完全相同
print(x.stride(), y.stride()) # (3, 1) (1, 2)
print(x.is_contiguous(), y.is_contiguous()) # True False
两者的形状、打印出来的数字一模一样,但底层的六个格子排得不一样——把逻辑视图和底层存储并排画出来:
x 的存储顺序就是行优先的 1 2 3 4 5 6,读 x[i, j] 去第 i*3 + j 格;y 的存储顺序是 1 4 2 5 3 6(它是那个 3×2 矩阵按行存的),读 y[i, j] 去第 i*1 + j*2 格。同一个逻辑下标,两个 Tensor 走到的存储位置不同,靠的就是各自的 stride。 转置 .t() 没有搬动任何数据,只是把 stride 从 (2, 1) 换成了 (1, 2)——这就是第四、五章要展开的机制。
反过来的情况也成立:x.t() 形状变成 3×2、却和 x 用同一份存储。两件事合起来才是 Tensor 与普通二维数组的差异:
Tensor 的逻辑维度和底层内存布局是两层独立的信息:形状相同不代表存储相同,形状不同也不代表存储不同。连接它们的是 stride。
3. Tensor 的核心字段
可以用一张图表示 Tensor 如何解释 Storage:
%% 图:Tensor 如何解释 Storage:storage_offset、sizes、strides、dtype / device 共同决定一个 view
flowchart LR
S[Storage<br/>一段底层数据]
O[storage_offset<br/>起始位置]
Z[sizes / shape<br/>逻辑形状]
T[strides<br/>维度步长]
D[dtype / device<br/>类型与设备]
V[Tensor View<br/>逻辑访问方式]
S --> V
O --> V
Z --> V
T --> V
D --> V
其中:
Storage提供真实的数据存储;storage_offset决定 Tensor 从 Storage 的哪个位置开始;sizes决定逻辑上有多少个元素;strides决定多维索引如何映射到 Storage;dtype决定如何解释每个元素的二进制内容;device决定 Storage 位于 CPU、CUDA 还是其他设备。
这些字段在源码里对应两个 C++ 对象,都位于第一篇代码地图最底层的 c10/core/:Python 的 torch.Tensor 持有一个 c10::TensorImpl,sizes、strides、storage_offset、dtype、device 以及 Autograd 元数据都是它的成员;TensorImpl 再持有一个 c10::StorageImpl,里面只有数据指针、字节数和分配器。多个 TensorImpl 可以指向同一个 StorageImpl——这就是后文 view 的实现基础。本篇讨论的是源码上最底层的抽象,之所以放在系列最前面,是因为 Autograd、算子分发、Kernel 和显存分析都建立在这几个字段之上。
4. Tensor 的元素数量与占用空间
逻辑元素数量可以通过 numel() 获得:
x = torch.empty(2, 3, 4)
print(x.shape) # torch.Size([2, 3, 4])
print(x.numel()) # 24
如果忽略对齐、Allocator 和额外元数据,数据区大小可以粗略估算为 numel × dtype.itemsize:一百万个元素在 float32 下约 4 MB,在 float16 或 bfloat16 下约 2 MB。
但真实显存占用还可能包括:
- Tensor 对象本身;
- Storage 和分配器元数据;
- Autograd 保存的中间结果;
- 梯度;
- Optimizer state;
- CUDA caching allocator 保留的内存;
- 临时 workspace。
因此,numel × itemsize 只能估算数据本体,不能直接等同于进程的完整内存占用。
三、Shape:Tensor 的逻辑形状
1. Shape、维度和元素数量
x = torch.empty(2, 3, 4)
print(x.shape) # torch.Size([2, 3, 4])
print(x.dim()) # 3
print(x.numel()) # 24
这里 dim() 是维度数 3,shape 是每一维的长度 (2, 3, 4),numel 是三者的乘积 2 × 3 × 4 = 24。shape 描述的是逻辑结构,不直接说明数据在内存中如何排列。
2. Shape 变换不一定复制数据
下面的操作通常只需要修改 Tensor 的元数据:
x = torch.arange(6)
y = x.reshape(2, 3)
如果底层布局满足条件,y 可以和 x 共享 Storage:
print(x.untyped_storage().data_ptr() == y.untyped_storage().data_ptr()) # 比较 Storage,而不是 data_ptr()
(x.data_ptr() == y.data_ptr() 只在两者 storage_offset 相同时才等价——x[1:] 与 x 共享 Storage,但 data_ptr() 差一个元素。)但这不是 reshape() 的永久保证。它会尝试返回 view;如果现有布局不允许,就可能创建拷贝。
3. view()、reshape() 与 flatten()
view()
view() 要求现有 stride 能够支持目标形状:
x = torch.arange(6)
y = x.view(2, 3)
如果布局不满足要求,view() 通常会直接报错,而不是自动复制。这里先给一个后面反复用到的词一个最短的定义:contiguous(连续)指 Tensor 的元素在底层存储里的顺序,和按行优先一个个数过去的逻辑顺序完全一致——上一章 x 是,y 不是。view() 能不能成功,本质上是在问”目标形状能不能用一组 stride 描述当前存储”;对 contiguous 的 Tensor 永远可以。第六章专门讲它。
reshape()
reshape() 更宽松:
y = x.reshape(2, 3)
它会在可能时返回 view,在不可能时创建拷贝。因此不能仅凭 reshape() 这个名字判断是否发生了内存复制。
flatten()
x = torch.randn(2, 3, 4)
y = x.flatten(1)
print(y.shape) # torch.Size([2, 12])
这里flatten(start_dim=1) 的作用是:保持 start_dim 之前的维度不变,将从 start_dim 开始往后的所有维度“拉平”成一个维度。在这里,第 0 维(大小为 2)被保留。第 1 维和第 2 维(大小分别为 3 和 4)被合并。合并后的新维度大小为它们乘积:\(3 \times 4 = 12\)。
这种 flatten(1) 的操作在深度学习中非常常见,通常用于卷积层(Convolutional Layer)到全连接层(Linear Layer)的过渡,目的是保留样本数量(Batch Size,即第 0 维),同时将特征图的所有像素和通道展平为一维特征向量。
flatten() 也可能返回 view,也可能创建新的 Tensor,取决于原始布局。
4. 增加和删除维度
x = torch.randn(3, 4)
x1 = x.unsqueeze(0)
print(x1.shape) # torch.Size([1, 3, 4])
x2 = x1.squeeze(0)
print(x2.shape) # torch.Size([3, 4])
unsqueeze() 和 squeeze() 通常是 metadata 操作,不需要复制数据。
需要注意:squeeze() 删除的是长度为 1 的维度。如果不指定维度,输入 shape 改变后,可能删除比预期更多的维度。
5. reshape 不改变元素语义
x = torch.arange(6)
y = x.reshape(2, 3)
reshape() 改变的是如何组织元素,而不是元素顺序本身:
x: [0, 1, 2, 3, 4, 5]
y: [[0, 1, 2],
[3, 4, 5]]
如果业务语义要求交换维度,应该使用 transpose() 或 permute(),而不是把 reshape() 当成转置。
四、Stride:逻辑索引如何映射到内存
1. 什么是 stride?
对于一个 Tensor,stride 表示:
沿某个维度增加 1 时,底层存储位置需要前进多少个元素。
x = torch.arange(6).reshape(2, 3)
print(x)
# tensor([[0, 1, 2],
# [3, 4, 5]])
print(x.shape) # torch.Size([2, 3])
print(x.stride()) # (3, 1)
对于 x[i, j],底层位置可以粗略计算为 offset(i, j) = storage_offset + i × stride[0] + j × stride[1]。这个例子里 storage_offset = 0、stride = (3, 1),所以:
x[0, 0] → 0
x[0, 1] → 1
x[0, 2] → 2
x[1, 0] → 3
x[1, 1] → 4
x[1, 2] → 5
2. 二维连续 Tensor 的 stride
一个 2 × 3 的行优先连续 Tensor:
[[a, b, c],
[d, e, f]]
底层存储是 [a, b, c, d, e, f],对应 shape = (2, 3)、stride = (3, 1)。含义是:
- 行索引增加 1,需要跨过 3 个元素;
- 列索引增加 1,只需要跨过 1 个元素。
把 storage 的线性下标和逻辑格子放在一起看,每个格子里写的就是它落在 storage 的哪个位置:
3. 三维 Tensor 的 stride
x = torch.empty(2, 3, 4)
print(x.stride())
典型结果是 (12, 4, 1):从最后一维往前累乘,stride[2] = 1,stride[1] = 4,stride[0] = 3 × 4 = 12。对于索引 x[i, j, k]:
offset(i, j, k)
= storage_offset
+ i × 12
+ j × 4
+ k × 1
把 24 个 storage 下标按 i 切成两个 3 × 4 平面,就能直观看到三个 stride 各自“跨过”多少元素:
4. stride 让 view 成为可能
一个 view 不需要复制数据的关键,是新的 Tensor 能否通过新的 sizes 和 strides 正确解释原来的 Storage。
- 同一份 Storage
- Tensor A:sizes=(2,3), strides=(3,1)
- Tensor B:sizes=(3,2), strides=(1,3)
A 和 B 可以具有不同的逻辑形状,但共享同一份底层数据。
这就是为什么一个 Tensor 的 shape 不能单独说明它的性能和存储行为,必须同时观察:
print(x.shape)
print(x.stride())
print(x.is_contiguous())
五、Transpose、Permute 与 View
1. transpose() 通常只改变 metadata
x = torch.arange(6).reshape(2, 3)
y = x.transpose(0, 1)
print(x)
# tensor([[0, 1, 2],
# [3, 4, 5]])
print(y)
# tensor([[0, 3],
# [1, 4],
# [2, 5]])
print(x.shape) # torch.Size([2, 3])
print(y.shape) # torch.Size([3, 2])
print(x.stride()) # (3, 1)
print(y.stride()) # (1, 3)
y 通过新的 shape 和 stride 解释同一份数据:x[i, j] 落在 i × 3 + j × 1,y[i, j] 落在 i × 1 + j × 3。用同一份 storage 把两个视图画出来,可以看到 y 只是把 stride 的两个分量交换了,逐行读 y 时在 storage 里是跳着走的:
这是一种典型的 zero-copy view。
2. permute() 可以重新排列多个维度
x = torch.empty(2, 3, 4)
y = x.permute(2, 0, 1)
print(x.shape) # (2, 3, 4)
print(y.shape) # (4, 2, 3)
permute() 通常也只是重新排列 sizes 和 strides:
- 原始维度顺序:D0, D1, D2
- 新顺序:D2, D0, D1
这对图像、序列和批处理数据非常常见。例如不同模型可能采用:
- NCHW:batch, channel, height, width
- NHWC:batch, height, width, channel
改变布局的逻辑解释不等于立刻复制数据,但后续算子可能更偏好某种物理布局。
3. 为什么转置后 view() 可能失败?
x = torch.arange(6).reshape(2, 3)
y = x.transpose(0, 1)
z = y.view(6)
这段代码可能失败,因为 y 的 stride 已经不符合把它直接解释成一个连续的一维序列的条件。
view(6) 只允许改 metadata,因此它需要一个单一的 stride 就能从 z[k] 走到 z[k+1]。把 y 的逻辑顺序和实际 storage 下标对齐写出来,就能看到这个条件为什么不满足:
view(6) 要求:逻辑上相邻的元素,在 storage 中以同一个 stride 相邻。
| z 的下标 k | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 对应 y 元素 | y[0,0] | y[0,1] | y[1,0] | y[1,1] | y[2,0] | y[2,1] |
| storage 下标 | 0 | 3 | 1 | 4 | 2 | 5 |
| 与前一个的差 | — | +3 | −2 | +3 | −2 | +3 |
相邻差值 +3、−2 交替,不是常数,无法用一个 (stride,) 表达,所以 view 报错。换句话说,y 需要的”连续顺序”是 storage 依次为 0 3 1 4 2 5,而 storage 的实际顺序是 0 1 2 3 4 5——两者不一致。
通常可以这样处理:
z = y.contiguous().view(6)
这里的过程是:
- y:非连续 view
- contiguous():创建连续副本
- view(6):在新布局上创建一维 view
如果不希望显式拆开,也可以使用:
z = y.reshape(6)
但 reshape() 是否复制,应根据实际布局判断,而不是假设。
4. View 和原始 Tensor 共享数据
x = torch.arange(6)
y = x.view(2, 3)
y[0, 0] = 100
print(x)
# tensor([100, 1, 2, 3, 4, 5])
这说明 y 和 x 共享底层数据。
如果需要完全独立的数据副本,应明确使用:
z = x.clone()
z[0] = 200
print(x[0]) # 100
print(z[0]) # 200
六、Contiguous:连续布局与数据拷贝
1. 什么是 contiguous?
对于默认的行优先布局,Tensor 的逻辑索引顺序与底层存储顺序一致时,可以称为 contiguous:
x = torch.arange(6).reshape(2, 3)
print(x.is_contiguous()) # True
转置通常会产生 non-contiguous view:
y = x.transpose(0, 1)
print(y.is_contiguous()) # False
2. contiguous() 做了什么?
z = y.contiguous()
print(z.is_contiguous()) # True
print(z.shape) # torch.Size([3, 2])
如果原 Tensor 已经连续,contiguous() 通常可以直接返回自身或等价的引用;如果不连续,它会分配新的 Storage,并按照逻辑顺序复制数据。
以上一章的转置视图 y 为例,contiguous() 前后的两条 storage 如下:
因此:
- contiguous()
- 不是简单的“设置一个标志”
- 而可能是真实的数据拷贝
3. 为什么 Kernel 关心 contiguous?
一个 Kernel 可以处理任意 stride,但通用 stride 访问通常更复杂:
- 逻辑索引
- 根据 stride 计算地址
- 访问非连续内存
连续布局往往更有利于:
- 顺序访存;
- Cache 命中;
- GPU 合并访存;
- 向量化加载;
- 简化 Kernel 实现。
但把所有 Tensor 都提前 contiguous() 也不一定正确,因为这会增加:
- 内存分配;
- 数据拷贝;
- GPU 带宽消耗;
- 临时显存峰值。
正确的原则是:
不要为了“看起来整齐”无条件调用
contiguous(),要根据后续算子是否需要以及拷贝成本决定。
4. “layout” 到底指什么
前文几处出现的”布局 / layout”其实是三个不同层次的词,读源码和报错信息时要分清:
| 层次 | 在 PyTorch 里的名字 | 取值 | 回答什么问题 |
|---|---|---|---|
| 口语里的布局 | 本文说的”物理布局” | 由 stride + storage_offset 决定 |
逻辑下标怎么映到存储位置;is_contiguous() 只回答”是不是行优先连续”这一个具体问题 |
| memory format | torch.contiguous_format / torch.channels_last / torch.preserve_format |
x.to(memory_format=torch.channels_last) |
对同样是 strided 的 Tensor,四维图像 [N, C, H, W] 在内存里按 NCHW 还是 NHWC 排——卷积 Kernel 对后者更快;它也是一种 stride 模式,is_contiguous(memory_format=...) 可以按它检查 |
torch.layout |
x.layout 属性 |
torch.strided(默认)、torch.sparse_coo、torch.sparse_csr、torch.jagged(nested)、torch._mkldnn |
数据根本不按 stride 组织的情形:稀疏矩阵只存非零元素的坐标和值,nested 存变长序列——这些 Tensor 没有 stride() 可言 |
本文讨论的一切(stride、view、contiguous)都发生在第一、二层,也就是 layout == torch.strided 的前提下。所以工程中不应把 layout 简化成一个布尔值:is_contiguous() 只是在默认 strided 布局、默认 memory format 下回答一个具体问题,不代表 Tensor 的所有存储属性。
七、Storage、Storage Offset 与共享内存
1. Storage 是什么?
从概念上说,Storage 是一段承载实际元素数据的底层存储,而 Tensor 是对这段存储的一个带元数据解释。
多个 Tensor 可以共享同一个 Storage:
- Storage:
[0, 1, 2, 3, 4, 5] - Tensor A:
sizes=(2,3), strides=(3,1), offset=0 - Tensor B:
sizes=(3,2), strides=(1,3), offset=0
现代 PyTorch 的具体 Storage API 和底层实现会随版本变化。本文使用 Storage 这个概念,是为了说明“数据本体”和“Tensor 视图”之间的关系,不建议把某个内部类的当前细节当成稳定公共 API。
2. storage_offset()
一个 view 不一定从 Storage 的第 0 个元素开始:
x = torch.arange(10)
y = x[2:8]
print(y.storage_offset())
# 可能为 2
y 逻辑上有六个元素,但它从原始 Storage 的位置 2 开始解释:
对于一维 Tensor,可以粗略写成 y[i] = Storage[storage_offset + i × stride],这里就是 Storage[2 + i]。
3. 切片也可能只是 view
x = torch.arange(10)
y = x[2:8:2]
print(y) # tensor([2, 4, 6])
print(y.stride())
print(y.storage_offset())
y 不一定拥有独立数据,而可能通过 offset 和 stride 指向原始 Storage。
因此,长期保存一个小切片,有时可能让一整块大 Storage 继续存活。这是理解内存生命周期时需要注意的边界。
4. View 的生命周期影响
large = torch.empty(1024, 1024, 1024)
small = large[0, 0, :10]
即使 small 只包含 10 个元素,它可能仍然持有对 large Storage 的引用。如果 small 的生命周期很长,底层大块存储也可能无法释放。
如果确实需要让小结果独立,可以显式复制:
small = large[0, 0, :10].clone()
这不是说所有切片都应该 clone,而是要根据对象生命周期和内存成本做决定。
八、dtype:如何解释每个元素
1. dtype 不只是精度选项
dtype 决定了底层数据如何被解释,也直接影响:
- 单个元素占用的字节数;
- 可表示的数值范围;
- 有效精度;
- 算子支持情况;
- 内存带宽需求;
- 计算吞吐;
- 梯度和数值稳定性。
常见 dtype 包括:
- float32
- float16
- bfloat16
- float64
- int32
- int64
- bool
2. FP16 与 BF16
FP16 和 BF16 都通常占用 16 bit,但位分配不同:
- FP16:更多位用于尾数,指数范围较小
- BF16:指数范围接近 FP32,尾数精度较低
把三种格式的位域画在一起(每个字符代表 1 bit):
位域重合不等于”转换就是砍掉低 16 位”:x.to(torch.bfloat16) 按最近偶数舍入,低 16 位大于一半时会向上进位。例:1.005 的 FP32 尾数低 16 位超过一半,转 BF16 得 1.0078125(1 + 2^-7),而直接截断会得到 1.0;两者都离 1.005 一个 ulp 以内,但方向不同,累加起来偏差不一样。
因此二者的工程特性不同:
- FP16 可能更容易出现溢出或下溢;
- BF16 数值范围更接近 FP32,训练中通常更稳;
- 不同 GPU 对 FP16、BF16 的吞吐支持不同;
- 某些算子可能自动使用更高精度的累加。
不能只因为二者都是 16 bit,就认为它们可以无条件互换。
3. dtype promotion
当不同 dtype 的 Tensor 参与计算时,PyTorch 需要决定结果类型:
x = torch.ones(3, dtype=torch.float32)
y = torch.ones(3, dtype=torch.float64)
z = x + y
print(z.dtype)
类型提升规则会受到:
- 输入 dtype;
- scalar 类型;
- 算子定义;
- device;
- 当前计算上下文;
等因素影响。
工程中不要只凭直觉猜测结果 dtype,尤其是在混合精度、索引和整数计算中,应通过显式检查和测试确认。
4. dtype 转换可能产生真实拷贝
x = torch.randn(1024, 1024, device="cuda", dtype=torch.float32)
y = x.to(torch.float16)
y 通常需要一份新的数据,因为每个元素的二进制表示发生了变化。这与 view() 只改变 metadata 的情况不同。
可以用下面的方式检查:
print(x.dtype)
print(y.dtype)
print(x.data_ptr() == y.data_ptr())
5. 计算 dtype 与存储 dtype
某些硬件和算子会使用低精度存储,但采用更高精度累加。例如矩阵乘法可能:
- 输入:FP16 / BF16
- 累加:FP32 或硬件支持的内部精度
- 输出:FP16 / BF16
具体行为取决于算子、硬件和配置。分析数值问题时,要区分:
- Tensor 的存储 dtype
- Kernel 的计算 dtype
- 累加使用的内部精度
- 输出 Tensor 的 dtype
九、Device:数据到底在哪里执行
1. CPU Tensor 与 CUDA Tensor
cpu_x = torch.randn(2, 3)
gpu_x = cpu_x.to("cuda")
print(cpu_x.device) # cpu
print(gpu_x.device) # cuda:0
模型和输入必须位于兼容的设备上:
model = model.to("cuda")
inputs = inputs.to("cuda")
outputs = model(inputs)
“模型在 GPU 上”不会自动把之后传入的所有输入都移动到 GPU;“输入在 GPU 上”也不会自动移动模型参数。
2. .to() 的两个维度
.to() 既可以改变 device,也可以改变 dtype:
x = x.to(device="cuda", dtype=torch.float16)
这两个变化都可能需要新的数据存储:
- CPU → CUDA:通常发生设备间拷贝
- float32 → float16 通常发生 dtype 转换和拷贝
如果目标 device 和 dtype 与当前一致,PyTorch 通常可以避免不必要的复制,但工程代码仍应以语义和实际测试为准。
3. CPU 到 GPU 的数据搬运
训练中的典型路径是:
- 磁盘
- CPU 内存
- Pinned CPU Memory
- GPU Memory
- Kernel 执行
如果数据搬运跟不上 GPU 计算,GPU 就会等待输入。
把这条路径按 pin_memory 和 non_blocking 两个开关展开,可以看清哪一步是真正的同步点、哪一步可以和计算重叠:
%% 图:CPU 到 GPU 的数据搬运:pin_memory 与 non_blocking 决定 H2D 拷贝是同步点还是异步 DMA
flowchart TB
DISK["磁盘 / 数据集文件"]
PAGE["CPU pageable 内存<br/>DataLoader worker 进程读取、预处理"]
PIN["CPU pinned 内存(页锁定,不会被换出)<br/>pin_memory=True 时由 DataLoader 主进程拷入"]
H2D_SYNC["H2D 拷贝:(近乎)同步<br/>来源是 pageable 内存,或 non_blocking=False<br/>pageable:先搬进驱动的 pinned 中转区,CPU 等这一步;<br/>non_blocking=False:CPU 等整个拷贝 —— 同步点"]
H2D_ASYNC["H2D 拷贝:异步 DMA<br/>pinned 来源 + non_blocking=True<br/>CPU 立即返回,拷贝排入当前 CUDA stream<br/>(要与计算重叠还需另一条 stream,第八篇)"]
GPU["GPU 显存中的 batch Tensor"]
KERNEL["Kernel 执行<br/>同一 stream 上按序排队,自动等拷贝完成<br/>CPU 不需要等待"]
IMPLICIT["隐式同步点:.item()、print(tensor)、.cpu()<br/>会让 CPU 等待 stream 上全部工作完成,<br/>把异步拷贝的收益吃掉"]
DISK --> PAGE
PAGE -->|"pin_memory=True<br/>(主机内再拷一次)"| PIN
PAGE -->|"pin_memory=False"| H2D_SYNC
PIN -->|"non_blocking=False"| H2D_SYNC
PIN -->|"non_blocking=True"| H2D_ASYNC
H2D_SYNC --> GPU
H2D_ASYNC --> GPU
GPU --> KERNEL
KERNEL -.-> IMPLICIT
classDef sync fill:#fde2e2,stroke:#c0392b;
classDef async fill:#e3f4e1,stroke:#2e7d32;
classDef mem fill:#eef3fb,stroke:#3b6ea5;
class H2D_SYNC,IMPLICIT sync;
class H2D_ASYNC async;
class PAGE,PIN,GPU mem;
DataLoader 常见配置包括:
loader = DataLoader(
dataset,
batch_size=64,
num_workers=4,
pin_memory=True,
)
配合:
batch = batch.to("cuda", non_blocking=True)
可以在满足条件时改善 CPU-GPU 数据传输的重叠,但并不是打开两个参数就必然加速。实际收益取决于:
- CPU 内存是否 pinned;
- 数据预处理是否成为瓶颈;
- GPU 计算时间是否足够长;
- 是否存在隐式同步;
- 主机内存和 PCIe/NVLink 带宽。
4. Device mismatch
常见错误是:
Expected all tensors to be on the same device
排查时同时打印:
print(next(model.parameters()).device)
print(inputs.device)
print(targets.device)
还要检查:
- 模型内部动态创建的 Tensor;
register_buffer()注册的状态;- loss 函数内部的权重;
- hidden state 和 mask;
- checkpoint 加载后的设备位置。
5. Meta Device 不是普通计算设备
Meta Tensor 只有逻辑视图、没有物理存储:它携带本文讨论过的全部元数据——shape、stride、storage_offset、dtype、device、layout、requires_grad——唯独不分配 Storage 里的数据:
with torch.device("meta"):
x = torch.empty(2, 3)
print(x.shape, x.stride(), x.dtype, x.device) # torch.Size([2, 3]) (3, 1) torch.float32 meta
print(x.data_ptr()) # 0:Storage 只记了"该有 24 字节",没有真的分配
x + 1 # 可以:shape / dtype 照常推断,结果仍是 meta
x.sum().item() # 报错:没有数据可读
| 普通 Tensor(CPU / CUDA) | Meta Tensor | |
|---|---|---|
| shape · stride · offset · dtype · layout · requires_grad | 有 | 有,一样可读、一样参与形状推断 |
| device | cpu / cuda:0 |
meta |
| Storage 里的数据 | 有,占 numel × itemsize 字节 |
没有:Storage 只记录字节数,data_ptr() 为 0,不占内存 |
| 能做什么 | 一切运算 | 只能做”算形状”的事:x @ w 得到正确 shape 的 meta 结果 |
| 不能做什么 | — | 读数值:.item()、print(x) 的数值部分、.tolist() |
它适合:
- 大模型结构分析;
- 参数量估算;
- shape 推断;
- 初始化前的图变换;
- 编译和测试中的抽象执行。
Meta Tensor 不能像普通 CPU/CUDA Tensor 一样直接读取数值。它说明了“Tensor 的数据”和“Tensor 的元数据”可以在一定程度上分离。
十、View、Clone、Detach 与 In-place
1. View 与 Clone
x = torch.arange(6)
view = x.view(2, 3)
copy = x.clone()
二者的语义不同:
| 操作 | 是否共享底层数据 | 主要用途 |
|---|---|---|
view() |
通常共享 | 改变解释方式 |
reshape() |
可能共享,也可能复制 | 更宽松地改变形状 |
transpose() |
通常共享 | 交换两个维度 |
permute() |
通常共享 | 重排多个维度 |
clone() |
不共享 | 创建独立副本 |
contiguous() |
不连续时复制 | 获得连续布局 |
2. View 与 Detach 是两个维度的问题
view() 解决的是存储解释方式:
- 是否共享 Storage?
- shape 和 stride 如何变化?
detach() 解决的是 Autograd 关系:
- 是否继续连接当前计算图?
x = torch.randn(3, requires_grad=True)
y = x * 2
z = y.detach()
z 可能与 y 共享数据,但不再沿原来的 Autograd 关系传播梯度。
因此不能把“view = 不需要梯度”或“detach = 创建数据副本”作为一般规律。两者处理的是不同层次。
3. In-place 操作
带下划线的方法通常表示 in-place 操作:
x.add_(1)
x.zero_()
x.copy_(other)
它们会直接修改已有 Storage,而不是返回一份新的结果数据。
优点可能是:
- 减少内存分配;
- 减少临时 Tensor;
- 降低峰值内存。
代价可能是:
- 破坏其他 view 看到的数据;
- 让代码的数据流不明显;
- 与 Autograd 保存的中间值冲突;
- 限制编译器优化;
- 让调试和并发访问更复杂。
4. In-place 与 Autograd
x = torch.randn(3, requires_grad=True)
y = x * x
# x.add_(1) 可能触发 Autograd 相关错误
Autograd 可能需要保存某些 Tensor 的旧值。如果这个 Tensor 在 backward 之前被原地修改,保存的值就不再可靠。
并不是所有 in-place 操作都会报错,也不是所有场景都禁止 in-place。正确的原则是:
在需要梯度的计算中,只有明确理解数据依赖和 Autograd 保存关系后,才使用 in-place 优化。
十一、Broadcasting:不复制数据的逻辑扩展
1. 广播解决什么问题?
x = torch.ones(2, 3)
y = torch.ones(3)
z = x + y
y 的逻辑形状可以扩展为 (2, 3),从而与 x 逐元素相加。
广播通常遵循从最后一个维度开始对齐的规则:
(2, 3)
( 3)
------
(2, 3)
两个维度兼容的条件通常是:
- 两者相等;
- 其中一个为 1;
- 某个维度不存在。
先说为什么要有这一步:逐元素相加要求两边每个位置一一对应,(2, 3) 有 6 个位置、(3,) 只有 3 个,不能直接对。最直白的办法是把 y 抄两份拼成 (2, 3) 再加——多占一份内存、多一次拷贝。expand 做的是同一件事的零成本版本:不抄数据,只造一个看起来是 (2, 3) 的视图,让第 0 行和第 1 行都指向 y 那 3 个格子。对 (2, 3) + (3,) 这个例子,PyTorch 实际做的是先把 y 右对齐补成 (1, 3)(顺带说明写法:(3,) 里的逗号表示”一元组”,它是一维的、缺的是前面的维度,所以补成一行三列而不是三行一列),再用 expand 得到一个 shape=(2,3)、stride=(0,1) 的视图,物理上不多占一个字节:
2. expand() 与 repeat()
x = torch.tensor([[1], [2]])
a = x.expand(2, 3)
b = x.repeat(1, 3)
二者表面结果相似,但存储语义不同:
| 操作 | 是否通常复制数据 | 语义 |
|---|---|---|
expand() |
否 | 通过 stride 为 0 的 view 表示重复访问 |
repeat() |
是 | 创建实际重复的数据 |
expand() 可以节省内存,但它产生的 view 不能简单当作普通连续 Tensor;某些 in-place 操作也会受到限制,因为多个逻辑位置可能对应同一个物理位置。
3. 广播不等于真实复制
- 逻辑上:
y看起来扩展成了更大的形状,运算时按(2, 3)的每个位置取值; - 物理上:底层数据仍然只有一份,第 0 行和第 1 行读的是同一段内存。
所以”扩展”改的是怎么读(shape 和 stride),不是存了什么——目的只有一个:让两个形状不同的操作数能逐元素对应起来,而不为此复制数据。
这也是 stride 重要的原因:一个维度的 stride 为 0 时,索引增加并不一定导致物理地址增加。
4. 广播错误的排查方式
遇到:
The size of tensor a must match the size of tensor b
不要只看 Tensor 的元素数量,要打印完整信息:
for name, value in {
"x": x,
"y": y,
}.items():
print(name, value.shape, value.stride(), value.dtype, value.device)
shape 相乘相等,不代表两个 Tensor 可以逐元素广播。
十二、从 Tensor 视角理解内存问题
1. 数据内存、缓存内存和计算图内存
一个训练进程中的内存,至少可以分为:
- Tensor 数据
- 梯度
- Optimizer State
- Autograd 保存的中间结果
- Kernel 临时 workspace
- Allocator 缓存
把这几类放进同一块 GPU 显存里看,并标出 memory_allocated() 与 memory_reserved() 各自覆盖的范围:
%% 图:GPU 显存的分层:memory_allocated() 是活跃 Tensor 占用的部分,memory_reserved() 还包括分配器缓存的块
flowchart TB
subgraph RESERVED["memory_reserved():Caching Allocator 向 CUDA 申请并持有的显存"]
direction TB
subgraph ALLOCATED["memory_allocated():当前被活跃 Tensor 占用的部分"]
direction TB
PARAM["模型参数<br/>numel × itemsize,训练全程存活"]
GRAD["梯度 .grad<br/>与参数同形状,backward 后出现"]
OPT["Optimizer state<br/>Adam:每个参数额外 2 份 FP32 状态"]
ACT["前向保存的激活<br/>Autograd 为 backward 保留,随 batch / 序列长度增长"]
WS["Kernel 临时 workspace<br/>cuBLAS / cuDNN 的中间缓冲,用完即还给分配器"]
end
CACHE["已释放、仍被缓存的块(reserved 但未 allocated)<br/>del / 出作用域后 allocated 下降,reserved 不降<br/>供后续分配直接复用,empty_cache() 才归还 CUDA"]
end
OUTSIDE["不在 reserved 之内:CUDA context、cuBLAS handle、NCCL 缓冲<br/>nvidia-smi 能看到,torch.cuda.memory_* 看不到"]
PARAM ~~~ GRAD ~~~ OPT ~~~ ACT ~~~ WS
ALLOCATED ~~~ CACHE
RESERVED ~~~ OUTSIDE
classDef live fill:#e3f4e1,stroke:#2e7d32;
classDef cache fill:#fff3cd,stroke:#b8860b;
classDef ext fill:#eeeeee,stroke:#777777;
class PARAM,GRAD,OPT,ACT,WS live;
class CACHE cache;
class OUTSIDE ext;
因此,下面两个数字不是同一个概念:
torch.cuda.memory_allocated()
torch.cuda.memory_reserved()
allocated:当前 Tensor 等对象实际使用的显存;reserved:PyTorch 分配器向 CUDA 申请并保留的显存。
释放一个 Tensor 后,allocated 可能下降,但 reserved 不一定立即下降,因为缓存分配器可能保留内存供后续复用。
2. 为什么删除 Tensor 后显存仍然存在?
可能原因包括:
- 还有其他 Python 引用;
- 某个 view 仍然持有底层 Storage;
- Autograd 图仍然被保存;
- CUDA caching allocator 仍然保留内存;
- Kernel 尚未完成,存在异步执行;
- 其他 Tensor、梯度或 Optimizer state 仍在使用。
因此,del x 只删除一个 Python 名称,不代表底层数据一定立即归还操作系统或 GPU 驱动。
3. Tensor 生命周期比变量名更重要
outputs.append(model(batch))
如果 model(batch) 参与 Autograd,这个列表可能不只是保存输出值,还会间接保留计算图和中间 Tensor。
如果只需要记录数值,可以考虑:
outputs.append(model(batch).detach().cpu())
如果只需要日志指标:
loss_value = loss.detach().item()
具体做法要根据是否需要梯度和后续设备访问来决定。
4. 复制、迁移和视图的成本模型
分析一个 Tensor 操作时,可以先问四个问题:
- 是否创建新的 Storage?
- 是否复制了数据?
- 是否发生了设备迁移?
- 是否延长了原始 Storage 的生命周期?
例如:
| 操作 | 新 Storage | 数据复制 | 可能影响生命周期 |
|---|---|---|---|
view() |
通常否 | 否 | 是,共享原 Storage |
transpose() |
通常否 | 否 | 是,共享原 Storage |
clone() |
是 | 是 | 否,数据独立 |
contiguous() |
视布局而定 | 视布局而定 | 可能 |
.to("cuda") |
通常是 | 是 | 否,设备不同 |
detach() |
通常否 | 否 | 共享关系仍需注意 |
这张表是分析思路,不是对所有特殊后端和布局的绝对保证。
十三、实现一个简化版 Tensor
1. 实践目标
为了把前面的概念串起来,可以使用 Python 和 NumPy 实现一个只支持 CPU 的简化版 Tensor。
它不需要实现完整的数学运算,只需要支持:
- 一维 Storage;
- sizes;
- strides;
- storage offset;
view();transpose();- 索引访问;
- contiguous 检查。
目标数据结构:
class MiniTensor:
def __init__(
self,
storage,
sizes,
strides,
storage_offset=0,
):
self.storage = storage
self.sizes = tuple(sizes)
self.strides = tuple(strides)
self.storage_offset = storage_offset
2. 从多维索引计算物理位置
def storage_index(self, index):
if len(index) != len(self.sizes):
raise IndexError("dimension mismatch")
offset = self.storage_offset
for i, size, stride in zip(index, self.sizes, self.strides):
if not 0 <= i < size:
raise IndexError("index out of range")
offset += i * stride
return offset
对于 sizes = (2, 3)、strides = (3, 1)、offset = 0,访问 [1, 2] 时 offset = 0 + 1 × 3 + 2 × 1 = 5。
3. 实现 transpose
def transpose(self, dim0, dim1):
sizes = list(self.sizes)
strides = list(self.strides)
sizes[dim0], sizes[dim1] = sizes[dim1], sizes[dim0]
strides[dim0], strides[dim1] = strides[dim1], strides[dim0]
return MiniTensor(
self.storage,
sizes,
strides,
self.storage_offset,
)
这个实现没有复制 Storage,只是交换了 sizes 和 strides。这正是 PyTorch view 操作的核心思想之一。
4. 实现 contiguous copy
def contiguous(self):
if self.is_contiguous():
return self
values = [self[index] for index in self.iter_indices()]
return MiniTensor.from_flat(values, self.sizes)
这个实现刻意把非连续 Tensor 按逻辑顺序重新写入一段新的连续 Storage,从而体现:
- non-contiguous view
- 按逻辑顺序读取
- 创建新的连续 Storage
- 返回 contiguous Tensor
5. 这个项目不实现什么?
为了控制范围,MiniTensor 不实现:
- CUDA;
- Autograd;
- dtype promotion;
- broadcasting 的全部规则;
- sparse layout;
- 内存分配器;
- 并行 Kernel。
它的意义不是替代 PyTorch,而是把一个真实框架中的关键数据结构缩小到可以观察的范围。
十四、Java 工程师应该如何理解 Tensor
1. Tensor 不是 List<List<Float>>
List<List<Float>> 主要描述对象之间的嵌套关系,而 Tensor 还描述:
- 连续的底层存储;
- 元素类型;
- 逻辑 shape;
- stride;
- offset;
- CPU/GPU 设备;
- 梯度关系;
- 共享 Storage 和生命周期。
把 Tensor 理解成嵌套集合,会漏掉 PyTorch 最重要的性能和内存语义。
2. Tensor 更接近“带布局的设备内存视图”
对于 AI-Infra 工程师,一个更有用的近似是:
Tensor
= 一段设备内存
+ 对这段内存的形状解释
+ 对地址计算的 stride 规则
+ dtype 和 device 元数据
+ 可选的 Autograd 关系
这不是 Tensor 的完整定义,但足以帮助分析:
- 一个操作是否复制数据;
- 为什么转置可以很便宜;
- 为什么某些 Kernel 需要 contiguous;
- 为什么设备迁移成本很高;
- 为什么保存一个小 view 可能持有大块内存。
3. 与 Java 数组的关键差异
| 维度 | Java 数组 | PyTorch Tensor |
|---|---|---|
| 数据布局 | 数组对象和元素布局由 JVM 管理 | Storage、shape、stride 可以分离 |
| 多维结构 | T[][] 常是数组的数组 |
通常是一段 Storage 加 metadata |
| 视图 | 需要显式抽象 | view 可以共享底层 Storage |
| 设备 | 通常在主机内存 | CPU、CUDA 和其他设备均可 |
| 类型 | 编译期类型系统为主 | dtype 参与运行时计算语义 |
| 数值计算 | 通常由循环和库完成 | 交给算子、Kernel 和硬件后端 |
| 内存释放 | GC 管理对象可达性 | Python 引用、Storage、Autograd、Allocator 共同影响 |
类比的价值在于搭桥,但不能让 Java 的数组和对象模型覆盖 Tensor 的真实语义。
十五、本文小结
Tensor 是 PyTorch 编程模型的核心数据抽象。理解它,不能只停留在:
x.shape
x.dtype
x.device
还要同时关注:
- Storage
- sizes
- strides
- storage_offset
- dtype
- device
- layout
1. Tensor 的基本模型
Tensor
= Storage
+ Shape
+ Stride
+ Storage Offset
+ Dtype
+ Device
2. View 与拷贝
- view / transpose / permute:通常只改变 metadata
- clone / dtype conversion / device transfer:通常需要新的数据存储
- reshape / contiguous:是否复制取决于当前布局和目标要求
3. 性能分析的第一组问题
遇到一个 Tensor 操作时,先问:
- 是否创建了新的 Storage?
- 是否复制了数据?
- 是否改变了 device 或 dtype?
- 是否改变了 stride 和 contiguous 状态?
- 是否与其他 Tensor 共享底层数据?
- 是否延长了某块内存的生命周期?
- 是否影响 Autograd 或后续 Kernel?
4. 一张 Tensor 地图
把全文的字段放回一张图:上面是读者看到的逻辑层,下面是内存里的物理层,两层由 stride / offset 连接,算子执行时要同时看两层。
%% 图:Tensor 地图:逻辑层(shape)与物理层(Storage + strides / offset)由 stride 连接,dtype / device 决定怎么解释与在哪执行,三者一起进入算子
flowchart TB
subgraph LOGIC["逻辑层:用户看到的"]
direction LR
B["shape / sizes<br/>几行几列"]
E["dtype / device<br/>每个数怎么解释、在哪"]
end
subgraph PHYS["物理层:内存里的"]
direction LR
C["strides / storage_offset<br/>下标 → 存储位置"]
D["Storage<br/>一段底层数据"]
C --> D
end
B -. "同一 shape 可对应不同 stride" .-> C
LOGIC --> F["算子执行<br/>按 shape 遍历,按 stride 取数,按 dtype 算,在 device 上跑"]
PHYS --> F
5. 本篇涉及的源码位置
本篇讨论的机制在源码中的位置(对应第一篇第七章的代码地图):
| 路径 | 内容 |
|---|---|
c10/core/TensorImpl.h |
Tensor 元数据:sizes_and_strides_、storage_offset_、data_type_、device_opt_、key_set_ |
c10/core/StorageImpl.h |
底层存储:数据指针、字节数、所属 Allocator |
aten/src/ATen/native/TensorShape.cpp |
view、reshape、transpose、permute、squeeze 等只改 metadata 的操作 |
aten/src/ATen/native/TensorProperties.cpp、TensorConversions.cpp |
contiguous()、.to():何时拷贝、何时只改 metadata |
aten/src/ATen/ExpandUtils.h |
广播规则 infer_size 与 expand |
c10/core/ScalarType.h、aten/src/ATen/native/TypeProperties.cpp |
dtype 定义与 result_type(dtype promotion) |
c10/cuda/CUDACachingAllocator.cpp |
缓存分配器:为什么 del 之后显存仍被占用 |
torch/_tensor.py、torch/csrc/autograd/python_variable.cpp |
Python torch.Tensor 对象与它包装的 C++ Tensor |
下一篇将进入 Tensor 之上的梯度系统:
Autograd 如何把一次次 Tensor 运算连接成动态计算图,并在 backward 阶段沿图传播梯度?
十六、自测
-
形状
[2, 3, 4]的连续 Tensor,strides 是什么?x.transpose(0, 2)之后 shape 与 strides 是什么、连续吗?答案
strides
(12, 4, 1)(行主序,元素单位);转置后 shape[4, 3, 2]、strides(1, 4, 12),不连续——没复制数据,只交换了元数据。 -
x.view(-1)、x.reshape(-1)、x.flatten()在x不连续时各做什么?答案
view在新形状无法用一组 stride 在现有内存排列上解释时报错——不连续不等于一定报错:torch.arange(12).view(2, 6)[:, ::2](strides(6, 2))不连续,但view(-1)可以得到 stride(2,)的一维视图;transpose之后那种维度顺序被打乱的布局才做不到。reshape在能 view 时 view、否则复制一份连续的再 view;flatten等同reshape。所以reshape的返回值是否共享内存不确定,写代码时要意识到。 -
x[0](第一行)与x[:, 0](第一列)各创建了什么?两者哪个连续?答案
都是 view:新
TensorImpl、共享Storage;x[0]是storage_offset = 0、strides 去掉第一维,连续;x[:, 0]的 stride 是原第一维的 stride,不连续。两者都让整个x的 Storage 活着。 -
x.to("cuda")、x.to(torch.float16)、x.to(x.device)各复制吗?返回的是新 Tensor 还是自己?答案
跨设备与改 dtype 都要复制(新 Storage);
to到相同 device 与 dtype 时直接返回self(同一个对象),除非传copy=True。所以.to()的“是否新对象”依赖运行时条件。 -
一个 10 GB 的 Tensor 取了
y = x[0, 0](单元素 view)后del x,显存释放了吗?怎么真正释放?答案
没有——
y的TensorImpl持有同一个StorageImpl,10 GB 活着;y = x[0, 0].clone()(或.item()取标量)再del x才释放。这是“是否延长某块内存的生命周期”那一问。
-
Tensor 是六样东西的组合:数据(
StorageImpl里的字节缓冲区,可被多个 Tensor 共享)、形状(sizes)、布局(strides+storage_offset)、类型(dtype)、设备(device)与生命周期(TensorImpl/StorageImpl的引用计数)。Python 的torch.Tensor是句柄,指向 C++ 的TensorImpl,后者持有Storage。详见第二章。 ↩ -
transpose()只交换两维的size与stride,产生一个新的TensorImpl,与原 Tensor 共享同一个Storage;数据一个字节都没动,代价是结果不再连续。详见第五章。 ↩ -
view()要求新形状能用一组 stride 在现有内存排列上直接解释出来(不复制);对连续 Tensor 总能做到,对transpose之后这类维度顺序被打乱的不连续 Tensor 往往做不到,于是报错(但不连续不等于必报错:等步长的切片x[:, ::2]就能view(-1))。详见第五章。 ↩ -
reshape()= “能view就view,不能就先contiguous()复制一份再view“,所以它的返回值是否与原 Tensor 共享内存取决于输入是否连续。详见第五章与第六章。 ↩ -
shape只是逻辑形状,性能由 stride(是否连续、内存访问模式)、dtype(字节数与能否走 Tensor Core)和device决定;同一 shape 的连续与非连续 Tensor 会走不同的 kernel 路径。详见第四章与第六章。 ↩ -
device是每个 Tensor 自己的属性,model.to("cuda")只搬模块注册的参数与 buffer;输入是另一个 Tensor,没人替它搬,算子要求所有输入在同一设备上。详见第九章。 ↩ -
dtype决定每个元素的解释方式:位宽只是其一,还有能表示的范围与精度(fp16 最大 65504、bf16 尾数只有 7 位,FP32→BF16 按最近舍入而非截断)、混合运算时的类型提升规则,以及能选到哪些 kernel。详见第八章。 ↩ -
Autograd 在前向时会保存某些中间 Tensor 供反向使用,in-place 操作改写了这些被保存的数据,PyTorch 用版本计数器检测到后报错。原则:只在明确数据依赖时才用 in-place。详见第十章。 ↩
-
两个原因:某个小 view 仍持有同一个
Storage,整块数据就活着;del之后释放的块也只是回到 CUDA caching allocator 的缓存(memory_reserved不降),empty_cache()才归还驱动。详见第十二章。 ↩
系列 《PyTorch 深度实践:从 Tensor 到深度学习运行时》 第 2 / 11 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 3 / 14 个系列
上一篇:PyTorch 整体介绍下一篇:自动求导与动态计算图
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/pytorch-tensor-and-memory-layout.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。