3. 张量存储机制:张量在显存中的布局、连续与非连续张量、存储偏移与视图
好,咱们今天聊聊张量在显存里到底是怎么放的。这个问题,说实话,很多同学一开始都不太在意。觉得不就是个多维数组嘛,能有什么花样?
嗯,我当年也是这么想的。直到有一次,我训练一个超大模型,显存怎么优化都爆了。最后发现,问题就出在张量的存储布局上。一个简单的转置操作,让显存占用翻了一倍。从那以后,我再也不敢小看这个“存储机制”了。
3.1 张量在显存中的布局:一维线性空间
先讲个最基础的概念。你想想看,GPU的显存,本质上就是一个超大的、一维的数组。每个地址对应一个字节。
那问题来了:我们平时用的二维矩阵、三维张量,是怎么塞进这个一维空间里的?
答案是:按行优先(Row-major) 展开。这是PyTorch和NumPy的默认方式。
举个例子,一个2x3的矩阵:
[[1, 2, 3],
[4, 5, 6]]
它在显存里的实际存储顺序是:
1, 2, 3, 4, 5, 6
先存完第一行,再存第二行。就这么简单。
核心要点: 不管张量有多少维度,底层都是一维的。我们看到的“形状”只是逻辑上的视图。
我个人习惯用 tensor.storage() 来查看底层的存储。你可以试试:
import torch
t = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(t.storage()) # 输出: 1 2 3 4 5 6
看到了吧?底层就是一串连续的数字。
3.2 连续张量 vs 非连续张量
好,接下来是重点中的重点。什么叫连续?什么叫非连续?
连续张量:元素在显存中是紧密排列的,没有空隙。比如上面那个2x3的矩阵,就是连续的。
非连续张量:元素在显存中不是紧密排列的,中间有“跳步”。
为什么会这样?最常见的原因就是——转置。
你看:
t = torch.tensor([[1, 2, 3], [4, 5, 6]])
t_t = t.t() # 转置
print(t_t.is_contiguous()) # 输出: False
转置后的张量,逻辑上是2行3列变成了3行2列。但底层的存储顺序没变,还是 1, 2, 3, 4, 5, 6。这就导致读取的时候,不能按顺序读了,得跳着读。
我在项目中遇到过一个问题:一个模型推理特别慢,排查了半天,发现是某个中间特征图做了转置,变成了非连续张量。后续的卷积操作被迫做了很多“非连续内存访问”,速度直接掉了30%。
避坑指南: 我曾经因为没注意张量的连续性,在训练循环里反复做切片操作,导致显存碎片化严重。最后不得不手动调用 .contiguous() 来整理内存。
怎么判断一个张量是否连续?用 tensor.is_contiguous()。怎么让它变连续?用 tensor.contiguous()。注意,contiguous() 会返回一个新的张量,底层会重新分配内存。
3.3 存储偏移与视图:零拷贝的魔法
接下来这个知识点,我个人觉得是PyTorch最精妙的设计之一——视图(View)。
什么叫视图?就是多个张量共享同一块底层存储,但逻辑上看起来不一样。
举个例子:
base = torch.arange(12).reshape(3, 4)
print(base)
# 输出:
# tensor([[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]])
# 创建一个视图
view = base[1:, 1:]
print(view)
# 输出:
# tensor([[ 5, 6, 7],
# [ 9, 10, 11]])
这里 view 和 base 共享同一块显存。但 view 的起始位置不是0,而是偏移了5个元素(从索引5开始)。这个偏移量,就是 存储偏移(Storage Offset)。
你可以用 tensor.storage_offset() 查看:
print(view.storage_offset()) # 输出: 5
为什么说这是零拷贝的魔法?因为创建视图时,不需要复制数据。只是修改了元数据(形状、步长、偏移量)。这在处理大张量时,能省下大量的显存和时间。
小技巧: 当你需要提取张量的子区域时,尽量用切片操作(返回视图),而不是用索引操作(返回拷贝)。比如 tensor[0:10] 比 tensor[0:10].clone() 省显存。
3.4 步长(Stride):张量布局的核心
要真正理解张量的存储,必须搞懂 步长(Stride)。
步长是什么?简单说,就是在每个维度上,前进一个单位,需要在底层存储中跳过多少个元素。
还是用那个2x3的矩阵:
t = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(t.stride()) # 输出: (3, 1)
解释一下:
- 在行维度上,前进一行(从第0行到第1行),需要跳过3个元素(因为一行有3个元素)。所以步长是3。
- 在列维度上,前进一列(从第0列到第1列),需要跳过1个元素。所以步长是1。
那转置后的张量呢?
t_t = t.t()
print(t_t.stride()) # 输出: (1, 3)
看到了吗?步长反过来了。这就是为什么转置后的张量是非连续的——因为步长不再是单调递增的了。
我建议你记住这个公式:
对于连续张量,步长满足:stride[i] = stride[i+1] * shape[i+1]
如果不满足,那就是非连续张量。
3.5 知识体系图:张量存储机制全景
下面我用一张SVG图,把整个知识体系串起来。你可以把它当作一个思维导图来看。
3.6 实战:如何利用存储机制优化显存
讲完了理论,咱们来点实际的。怎么利用这些知识来优化显存?
第一,避免不必要的转置。 如果必须转置,尽量在转置后调用 .contiguous(),让底层重新排列。虽然会多一次拷贝,但后续操作会更快。
第二,善用视图。 需要提取子张量时,优先用切片。比如:
# 坏做法:拷贝一份
sub = tensor[0:100, 0:100].clone()
# 好做法:视图,不占额外显存
sub = tensor[0:100, 0:100]
第三,注意广播机制。 广播操作有时会创建非连续张量。如果你发现某个操作后显存暴涨,检查一下是不是广播导致的。
一句话总结: 张量的存储机制,说白了就是“底层一维,逻辑多维”。搞懂了步长和偏移,你就能精准控制显存的使用。
嗯,今天就先聊到这里。这些概念虽然基础,但真的很重要。我每次做显存优化,第一步就是检查张量的连续性和存储布局。很多时候,问题就出在这些细节上。
想要更多资料,联系可可