打开任何一个深度学习框架的代码,你几乎立刻会碰到张量这个词。PyTorch文档的第一行示例代码是创建张量,TensorFlow的名字本身就在暗示张量的核心地位。但当你试图理解"张量究竟是什么"时,可能会发现答案飘忽不定——有人说它是矩阵的推广,有人提到坐标变换下的不变性,还有人干脆说"就是多维数组"。
这些回答都对,但都不完整。张量的概念跨越数学、物理和计算机科学三个领域,每个领域强调的侧面不同。对深度学习实践者而言,最重要的不是物理学家关注的坐标不变性,也不是数学家关心的多重线性映射定义,而是一个更朴素的问题:当我在代码里写下torch.tensor([1,2,3])时,内存里到底发生了什么,为什么这个数据结构能支撑起ChatGPT这样的巨型神经网络?
从单个数字到张量:维度的递进
理解张量最直观的方式是从低维到高维逐层构建。一个单独的数字,比如温度值23.5,在深度学习术语中叫做标量(scalar),也叫零维张量。零维的含义是:如果你想引用这个数字,不需要任何索引——它就是它自己。
import torch
scalar = torch.tensor(3.14)
print(scalar.dim()) # 输出: 0
print(scalar.shape) # 输出: torch.Size([])
当你把一组标量排成一行,就得到了向量(vector),即一维张量。比如 [1, 2, 3, 4] 是一个长度为4的向量。为什么叫"一维"?因为引用其中任何一个元素,只需要一个索引:v[0]、v[1]、v[2]、v[3]。
vector = torch.tensor([1, 2, 3, 4])
print(vector.dim()) # 输出: 1
print(vector.shape) # 输出: torch.Size([4])
把多个向量排在一起,形成行列结构,就得到矩阵(matrix),即二维张量。灰度图像就是典型的矩阵——每个像素点是一个标量(亮度值),整张图是一个矩阵。引用矩阵中的元素需要两个索引:行号和列号。
matrix = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(matrix.dim()) # 输出: 2
print(matrix.shape) # 输出: torch.Size([2, 3])
继续这个逻辑,三维张量可以想象成矩阵的堆叠。彩色RGB图像就是三维张量:宽度×高度×3个颜色通道。引用一个像素的红色分量,需要三个索引:image[width_index][height_index][0]。
graph TD
A["标量 Rank 0<br/>单个数字<br/>shape: []"] --> B["向量 Rank 1<br/>一维数组<br/>shape: n"]
B --> C["矩阵 Rank 2<br/>二维表格<br/>shape: m, n"]
C --> D["三维张量 Rank 3<br/>矩阵堆叠<br/>shape: a, b, c"]
D --> E["高维张量 Rank N<br/>任意维度<br/>shape: d1, d2, ..., dn"]
这个递进关系揭示了一个关键点:张量的秩(Rank)就是维度数,或者说轴数。一个秩为 $r$ 的张量需要 $r$ 个索引才能引用到具体的数据元素。
三大核心属性:秩、轴、形状
深度学习框架中,每个张量都有三个紧密关联的属性:秩(Rank)、轴(Axis)和形状(Shape)。它们不是独立的三个东西,而是同一枚硬币的三面。
graph LR
subgraph 张量属性
R["秩 Rank<br/>维度数量"]
A["轴 Axis<br/>每个维度"]
S["形状 Shape<br/>各维度大小"]
end
R -->|"告诉有多个"| A
A -->|"告诉每个有多长"| S
S -->|"编码完整信息"| R
秩回答"这个张量有几个维度"。秩为2的张量是矩阵,秩为3的张量可以表示一批图像。在代码中,秩通过.dim()或.ndim获取。
轴是维度的另一种说法。一个秩为3的张量有三个轴,习惯上可以命名为:第0轴、第1轴、第2轴。每个轴有自己的长度,表示沿该轴有多少个位置可以索引。
形状是一个元组,记录每个轴的长度。形状 (2, 3, 4) 表示第0轴长度为2,第1轴长度为3,第2轴长度为4。形状的乘积 2 × 3 × 4 = 24 就是张量中元素的总数。
tensor_3d = torch.randn(2, 3, 4)
print(f"秩: {tensor_3d.dim()}") # 秩: 3
print(f"形状: {tensor_3d.shape}") # 形状: torch.Size([2, 3, 4])
print(f"元素总数: {tensor_3d.numel()}") # 元素总数: 24
这三者的关系可以总结为:秩告诉你有多少个轴,形状告诉你每个轴有多长。深度学习编程中,形状是最常打交道的信息——调试时第一件事往往是打印张量形状。
内存中的张量:一维数组的伪装
这是理解张量的关键一步:无论张量的形状多么复杂,在物理内存中,它都是一维数组。
假设有一个形状为 (2, 3) 的矩阵:
[[1, 2, 3],
[4, 5, 6]]
在内存中,这六个数字按照行优先顺序存储为:[1, 2, 3, 4, 5, 6]。PyTorch和NumPy都默认使用行优先存储——先存完第一行的所有元素,再存第二行,以此类推。
graph LR
subgraph 逻辑视图
M["矩阵 2x3<br/>1,2,3<br/>4,5,6"]
end
subgraph 内存存储
R["一维数组<br/>[1,2,3,4,5,6]"]
end
M -->|"行优先展平"| R
如果张量是 (2, 3, 4) 的三维结构,存储顺序是:先存 [0,0,0] 到 [0,0,3](第一个"矩阵"的第一行),然后 [0,1,0] 到 [0,1,3](第一矩阵的第二行),存完第一个矩阵后,再存第二个矩阵。
这种存储方式带来了一个概念:步长(Stride)。步长告诉我们在一维内存中,沿某个轴移动一个位置需要跳过多少个元素。对于形状 (2, 3, 4) 的张量,步长是 (12, 4, 1):
graph TD
subgraph 形状 2,3,4
A["第0轴: 长度2<br/>步长12"]
B["第1轴: 长度3<br/>步长4"]
C["第2轴: 长度4<br/>步长1"]
end
A -->|"移动一步跳过"| D["完整3x4矩阵<br/>12个元素"]
B -->|"移动一步跳过"| E["一行4个元素"]
C -->|"移动一步跳过"| F["相邻元素"]
- 沿第0轴移动一步,跳过12个元素(一个完整的 3×4 矩阵)
- 沿第1轴移动一步,跳过4个元素(一行有4个元素)
- 沿第2轴移动一步,跳过1个元素(相邻元素)
t = torch.randn(2, 3, 4)
print(f"形状: {t.shape}") # 形状: torch.Size([2, 3, 4])
print(f"步长: {t.stride()}") # 步长: (12, 4, 1)
步长的威力在于:很多张量操作不需要复制数据,只需要修改步长。转置操作 transpose() 就是典型例子——它不移动实际数据,只是交换步长顺序,创建一个"视图"而非副本。这就是为什么转置操作是 $O(1)$ 时间复杂度。
连续与非连续张量
当一个张量的步长与行优先存储的预期一致时,称为连续张量(contiguous tensor)。转置、切片等操作可能产生非连续张量——物理存储顺序与逻辑形状不匹配。
t = torch.randn(3, 4)
print(f"转置前步长: {t.stride()}") # (4, 1) - 连续
print(f"转置前是否连续: {t.is_contiguous()}") # True
t_t = t.transpose(0, 1)
print(f"转置后步长: {t_t.stride()}") # (1, 4) - 非连续
print(f"转置后是否连续: {t_t.is_contiguous()}") # False
非连续张量在某些操作上会报错。.view() 方法要求张量连续,而 .reshape() 可以处理非连续张量(必要时会复制数据)。理解这个区别可以避免很多莫名其妙的错误。
张量运算:数据流动的管道
深度学习本质上是张量的运算流水线。虽然运算种类繁多,但可以归纳为几类基础操作。
mindmap
root((张量运算))
逐元素运算
加法减法
乘法除法
指数对数
线性代数运算
矩阵乘法
转置
求逆
形状变换
reshape
transpose
squeeze/unsqueeze
归约运算
sum
mean
max/min
逐元素运算
逐元素运算(element-wise operation)是最简单的一类。加法、乘法、指数、对数等操作独立作用于每个元素,输入输出的形状完全相同。[1, 2, 3] + [4, 5, 6] = [5, 7, 9]。这类运算是可并行化的——GPU可以在成千上万个核心上同时计算不同元素。
矩阵乘法
矩阵乘法是深度学习的核心运算。对于形状 (m, k) 和 (k, n) 的两个矩阵,乘积形状为 (m, n)。公式是:
矩阵乘法之所以重要,是因为它可以批量执行线性变换。神经网络的一层本质上是 $y = Wx + b$,其中 $W$ 是权重矩阵。当批量处理 $B$ 个样本时,输入变成形状 (B, input_dim) 的矩阵,一次矩阵乘法就完成了 $B$ 次线性变换。
graph LR
subgraph 矩阵乘法并行化
A["矩阵A m×k"] --> C["结果C m×n"]
B["矩阵B k×n"] --> C
end
subgraph GPU并行
G1["核心1: C11"]
G2["核心2: C12"]
G3["核心3: C21"]
G4["核心N: Cmn"]
end
C -.->|"每个元素独立"| G1
C -.-> G2
C -.-> G3
C -.-> G4
广播机制
广播机制(broadcasting)处理形状不完全匹配的运算。当两个张量形状不同时,框架会自动"扩展"较小的张量以匹配较大的。规则是:从右向左对齐形状,如果某个维度是1,就复制扩展到目标大小。
graph TD
subgraph 广播示例
A["A: 3,1<br/>[[1],[1],[1]]"]
B["B: 1,4<br/>[[1,1,1,1]]"]
C["结果: 3,4<br/>[[2,2,2,2],<br/>[2,2,2,2],<br/>[2,2,2,2]]"]
end
A -->|"复制列"| C
B -->|"复制行"| C
# 形状 (3, 1) + 形状 (1, 4) -> 形状 (3, 4)
a = torch.ones(3, 1) # [[1], [1], [1]]
b = torch.ones(1, 4) # [[1, 1, 1, 1]]
c = a + b # 形状 (3, 4),全是2
广播让代码更简洁,但也是形状错误的常见来源。当形状不符合广播规则时,会抛出难以理解的错误。
爱因斯坦求和
爱因斯坦求和(Einstein Summation,简称einsum)是一种优雅的张量运算表示法。传统的矩阵乘法 torch.matmul(A, B) 可以写成 torch.einsum('ik,kj->ij', A, B)。字符串 'ik,kj->ij' 精确描述了索引关系:$A$ 的列索引 $k$ 与 $B$ 的行索引 $k$ 求和消去,留下 $i$ 和 $j$。
einsum 的优势在于表达复杂操作。注意力机制的公式 $\text{Attention}(Q, K, V) = \text{softmax}(QK^T / \sqrt{d_k})V$ 用 einsum 写成:
# Q: [batch, heads, seq_len, head_dim]
# K, V: 同上
attn_weights = torch.softmax(
torch.einsum('bhqd,bhkd->bhqk', Q, K) / sqrt(d_k), dim=-1
)
output = torch.einsum('bhqk,bhkd->bhqd', attn_weights, V)
一个函数替代了转置、矩阵乘法、缩放等一系列操作,代码更清晰,也减少中间张量的创建。
Transformer中的张量流动
理解张量形状变化的最佳案例是Transformer。让我们追踪一个批次的文本如何流过解码器层。
输入文本经过分词后变成token ID序列,假设批次大小为1,序列长度为4:形状 [1, 4]。
经过Embedding层,每个token ID映射为一个向量(假设维度768):形状变成 [1, 4, 768]。
位置编码添加位置信息,形状不变:[1, 4, 768]。
进入多头注意力层。首先通过三个线性层生成Query、Key、Value。每个线性层保持形状:Q、K、V都是 [1, 4, 768]。
然后"切头":将768维分成8个头,每头96维。形状变成 [1, 4, 8, 96],再转置为 [1, 8, 4, 96](把头维度放到前面便于并行计算)。
计算注意力权重:$QK^T$。Key转置后形状 [1, 8, 96, 4],乘积形状 [1, 8, 4, 4]——每个头有一个 4×4 的注意力矩阵。
注意力权重乘以Value:[1, 8, 4, 4] × [1, 8, 4, 96] = [1, 8, 4, 96]。
“合头”:转置回来,拼接8个头,形状恢复为 [1, 4, 768]。
经过前馈网络:先扩展到 768 × 3 = 2304 维,再压缩回768维。形状变化:[1, 4, 768] → [1, 4, 2304] → [1, 4, 768]。
最后一层线性变换将768维映射到词表大小(假设9735):形状变成 [1, 4, 9735]。对最后一个维度做softmax,得到每个位置预测下一个token的概率分布。
flowchart LR
A["Input: [1, 4]"] --> B["Embedding: [1, 4, 768]"]
B --> C["Q, K, V: [1, 4, 768]"]
C --> D["Split Heads: [1, 8, 4, 96]"]
D --> E["Attention: [1, 8, 4, 4]"]
E --> F["Attn × V: [1, 8, 4, 96]"]
F --> G["Concat: [1, 4, 768]"]
G --> H["FFN: [1, 4, 768]"]
H --> I["Output: [1, 4, 9735]"]
整个过程中,形状的变化不是随意的。每一次reshape、transpose、split都有明确的计算目的。调试神经网络时,最有效的策略就是画出每一步的形状——形状对了,数值错误往往就容易定位。
GPU为何擅长张量计算
CPU设计目标是快速处理复杂逻辑,少量核心,强大的分支预测和缓存。GPU设计目标完全不同:大规模并行计算,数千个简单核心,每个核心执行相同指令但处理不同数据。
这正是张量运算的特性。矩阵乘法 $C = AB$ 中,$C$ 的每个元素 $C_{ij}$ 的计算是独立的——它们都读取相同的 $A$ 和 $B$,但计算不同的输出位置。这意味着可以让1000个GPU核心同时计算1000个不同的 $C_{ij}$。
graph TD
subgraph CPU架构
C1["核心1"]
C2["核心2"]
C3["核心3"]
C4["核心4"]
C5["..."]
C8["核心8"]
end
subgraph GPU架构
G1["核心1"]
G2["核心2"]
G3["..."]
G4["核心1000+"]
end
CPU架构 -->|"复杂逻辑<br/>少量核心"| L1["低并行度"]
GPU架构 -->|"简单计算<br/>海量核心"| L2["高并行度"]
这种执行模式叫做SIMD(Single Instruction, Multiple Data)——单指令多数据。GPU的架构变体叫SIMT(Single Instruction, Multiple Threads),更灵活地处理线程级并行。
现代GPU还引入了Tensor Core,专门加速矩阵乘法。一个Tensor Core可以在一个时钟周期内完成一个 4×4 矩阵乘加运算。以A100为例,它有432个Tensor Core,理论峰值算力达到312 TFLOPS(FP16)。
要让Tensor Core发挥作用,张量的形状和内存布局很重要。NVIDIA文档指出,Tensor Core在NHWC(批次-高度-宽度-通道)内存布局下效率最高,而PyTorch默认使用NCHW。这也是为什么推荐在推理前将张量转换为channels-last格式:
# 转换为channels-last格式
x = x.to(memory_format=torch.channels_last)
output = model(x)
PyTorch官方博客的实验显示,行优先和列优先访问的性能差距可达15倍。这不是微优化——内存访问模式直接影响L1缓存命中率,而缓存命中与未命中的延迟差距在100倍量级。
常见错误与调试策略
张量编程中最常见的错误类型有三:形状不匹配、设备不一致、梯度断裂。
graph TD
subgraph 三大错误类型
S["形状错误<br/>shape mismatch"]
D["设备错误<br/>device mismatch"]
G["梯度断裂<br/>graph detached"]
end
S --> S1["分段打印形状<br/>添加断言检查"]
D --> D1["统一.to(device)<br/>检查tensor.device"]
G --> G1["避免.item/.numpy<br/>检查requires_grad"]
形状错误
形状错误通常表现为"mat1 and mat2 shapes cannot be multiplied"或"index out of range"。调试方法是分段打印形状,定位形状变化与预期不符的位置。一个有用技巧是在代码中插入断言:
assert x.shape == (batch_size, seq_len, hidden_dim), f"Unexpected shape: {x.shape}"
设备错误
设备错误发生在CPU张量和GPU张量混合运算时。错误信息通常很明确:“Expected all tensors to be on the same device”。解决方法是确保同一运算中的所有张量在同一设备:
x = x.to(device) # 统一移到GPU
梯度断裂
梯度断裂更隐蔽。某些操作(如.item()、.numpy()、inplace修改)会断开计算图,导致梯度无法回传。症状是loss不下降或报错"leaf variable has been moved into the graph interior"。
一个常被忽视的陷阱是批量维度。Transformer输入需要形状 [batch, seq_len],但如果忘记unsqueeze,单个样本会被误认为一维张量:
# 错误:单样本形状是 [seq_len],没有batch维度
input_ids = tokenizer.encode("Hello world")
# 正确:添加batch维度
input_ids = tokenizer.encode("Hello world", return_tensors="pt")
张量与矩阵的本质区别
回到最初的问题:张量和矩阵有什么区别?在深度学习语境下,答案是"维度数量的差异"。矩阵是二维张量,向量是一维张量,标量是零维张量。
但这个回答忽略了张量在数学上的严格定义。在微分几何和张量分析中,张量的本质是"多重线性映射",关键性质是在坐标变换下保持不变。一个物理量是否是张量,取决于它在坐标系旋转、拉伸等变换下的行为。
对深度学习而言,这个"坐标不变性"没有直接意义——神经网络不需要区分张量和"伪张量"。但在某些场景下,数学定义确实有启发。比如,为什么梯度是张量?因为它需要满足链式法则,在变量变换下保持一致性。
实践中,把张量理解为"带有形状信息的多维数组"已经足够。但记住它背后有严谨的数学基础,会让你在面对更高级的话题(如张量分解、张量网络)时不那么陌生。
总结
张量是深度学习的基石数据结构。它的核心思想是:用形状信息组织数据,让运算可以批量、并行执行。秩告诉维度数量,形状告诉每个维度的大小,步长告诉内存如何访问。理解这三者的关系,就掌握了张量编程的核心。
GPU的并行架构与张量运算天然契合。SIMD执行模式让数千核心同时工作,Tensor Core专门加速矩阵乘法。内存布局(行优先、channels-first、channels-last)直接影响缓存效率和最终性能。
调试张量代码的核心技能是形状追踪。画出每一步的形状变化,大多数错误就能迅速定位。常见的坑包括批量维度缺失、设备不一致、广播陷阱。养成打印形状和添加断言的习惯,可以预防大量问题。
从PyTorch的torch.tensor()到GPT-4的万亿参数,中间隔着的不是什么神秘魔法,而是无数张量的流动与变换。理解张量,就是理解深度学习这座大厦的砖石。
参考资料
- PyTorch官方文档 - Tensor Operations: https://pytorch.org/docs/stable/tensors.html
- HuggingFace Blog - Mastering Tensor Dimensions in Transformers: https://huggingface.co/blog/not-lain/tensor-dims
- deeplizard - Rank, Axes and Shape Explained: https://deeplizard.com/learn/video/AiyK0idr4uM
- PyTorch Blog - Efficient PyTorch: Tensor Memory Format Matters: https://pytorch.org/blog/tensor-memory-format-matters/
- Tim Rocktäschel - Einsum is All you Need: https://rockt.ai/2018/04/30/einsum
- NVIDIA A100 Tensor Core GPU Specifications: https://www.nvidia.com/en-us/data-center/a100/
- Stanford CS231n - Python Numpy Tutorial: http://cs231n.github.io/python-numpy-tutorial/
- Deep Learning Book - Linear Algebra: https://www.deeplearningbook.org/contents/linear_algebra.html