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图,把整个知识体系串起来。你可以把它当作一个思维导图来看。

张量存储机制 存储布局 • 一维线性空间 • 按行优先(Row-major) • storage() 查看底层 连续 vs 非连续 • is_contiguous() 判断 • contiguous() 整理内存 • 转置导致非连续 存储偏移与视图 • storage_offset() • 零拷贝共享内存 • 切片 vs 索引 步长(Stride) • stride() 查看步长 • 连续张量步长公式 • 非连续张量步长特征 实际影响 • 显存占用翻倍 • 推理速度下降30% • 显存碎片化 核心:理解底层存储,优化显存与速度

3.6 实战:如何利用存储机制优化显存

讲完了理论,咱们来点实际的。怎么利用这些知识来优化显存?

第一,避免不必要的转置。 如果必须转置,尽量在转置后调用 .contiguous(),让底层重新排列。虽然会多一次拷贝,但后续操作会更快。

第二,善用视图。 需要提取子张量时,优先用切片。比如:

# 坏做法:拷贝一份
sub = tensor[0:100, 0:100].clone()

# 好做法:视图,不占额外显存
sub = tensor[0:100, 0:100]

第三,注意广播机制。 广播操作有时会创建非连续张量。如果你发现某个操作后显存暴涨,检查一下是不是广播导致的。

一句话总结: 张量的存储机制,说白了就是“底层一维,逻辑多维”。搞懂了步长和偏移,你就能精准控制显存的使用。

嗯,今天就先聊到这里。这些概念虽然基础,但真的很重要。我每次做显存优化,第一步就是检查张量的连续性和存储布局。很多时候,问题就出在这些细节上。

想要更多资料,联系可可