被"张量(Tensor)"吓退?用 C 语言的指针和内存,一眼看穿深度学习的底牌
1. 开场暴击 别被"张量"这个词唬住了
PyTorch 教程翻到第一页,torch.tensor(...)。往下两行,标量、向量、矩阵、张量,四个词排着队走过来。再点开一个视频,弹幕开始聊物理学里的应力张量。合上电脑,你只记得一件事,这玩意儿好像很高深。
刚学完 C 的同学卡在这一步的特别多。可他们早就把最难的那部分学完了,只是没人告诉他们这两件事是连着的。
在写 C 的人眼里,Tensor 就是一段连续的内存,外加一张写着"这块内存怎么切"的纸条。 纸条上记着形状(shape)和步长(stride)。内存负责存数,纸条负责告诉你第 1 行第 2 列在哪。
你写过的 int arr[2][3],其实已经是一个二维 Tensor 了。差别只是 PyTorch 给它套了一层自动求导的外壳。
先别急着翻公式。我们把这段内存的地址打印出来,看它到底长什么样。
2. C 语言视角 二维数组在内存里长什么样
#include <stdio.h>
int main(void)
{
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
for (int i = 0; i < 2; i++)
for (int j = 0; j < 3; j++)
printf("arr[%d][%d] = %d addr = %p\n",
i, j, arr[i][j], (void *)&arr[i][j]);
printf("sizeof(arr) = %zu sizeof(arr[0]) = %zu\n",
sizeof(arr), sizeof(arr[0]));
printf("arr + 1 - arr = %td bytes\n",
(char *)(arr + 1) - (char *)arr);
printf("*(*(arr + 1) + 2) = %d\n", *(*(arr + 1) + 2));
return 0;
}
我在本机用 MSVC 14.51 编译跑了一遍。地址每次运行会变,差值不变。
arr[0][0] = 1 addr = 0000009D598FF9A0
arr[0][1] = 2 addr = 0000009D598FF9A4
arr[0][2] = 3 addr = 0000009D598FF9A8
arr[1][0] = 4 addr = 0000009D598FF9AC
arr[1][1] = 5 addr = 0000009D598FF9B0
arr[1][2] = 6 addr = 0000009D598FF9B4
sizeof(arr) = 24 sizeof(arr[0]) = 12
arr + 1 - arr = 12 bytes
*(*(arr + 1) + 2) = 6
六个元素,地址从 ...9A0 一路排到 ...9B4,每两个挨着的差 4 字节,正好一个 int。
把这段地址画成一条线,二维的"行"就消失了。
地址 ...9A0 ...9A4 ...9A8 ...9AC ...9B0 ...9B4
数据 1 2 3 4 5 6
下标 [0][0] [0][1] [0][2] [1][0] [1][1] [1][2]
CPU 拿到的就是这么一条直线。它不认识第几行第几列,编译器的方括号语法帮我们把二维下标换算成了偏移量。
换算规则只有一条,编译器每次遇到方括号下标,做的都是下面这个加法。
&arr[i][j] = 起始地址 + (i * 3 + j) * sizeof(int)
arr[1][2] 对应 起始 + (1*3+2)*4 = 起始 + 20,也就是 ...9B4,里面躺着 6。
再看那行让新手头大的指针写法。它和上面的公式是同一件事,只是把加法交给了指针运算。
*(*(arr + 1) + 2)
把它拆开,一共三步,每一步都在做地址加法。
arr退化为指向一整行(3 个int)的指针,arr + 1跳 12 字节,落到第二行开头。*(arr + 1)拿到第二行这个数组,再退化成int*,+ 2跳 8 字节。- 最外层
*取值,拿到 6。
所谓"第 1 行第 2 列",全部工作就是算一次偏移量,然后读那个地址。 这也是 sizeof(arr[0]) 等于 12 的原因,一行 3 个 int,按行跨一步就是 12 字节。
顺带把那个让很多人挂科的坑填掉。arr + 1 跳 12 字节,arr[0] + 1 只跳 4 字节,两个名字看着像,类型完全不同。我在同一个程序里打印过这两个差值。指针加 1 跳多远,由它指向的类型决定,跟数组名字叫什么没关系。 arr 指向一整行,arr[0] 指向一个 int,步长自然差三倍。这一点后面看 stride 时会原封不动地回来。
拿刻度尺打个比方。内存像一把只刻了毫米的尺子,上面没有"行"这个单位。你说第 1 行第 2 列,等于说从零刻度往前数 5 格。
3. 深度学习的底牌 Tensor 只是换了皮的 C 数组
镜头切回 Python。
import torch
t = torch.tensor([[1, 2], [3, 4]])
print(t.shape) # torch.Size([2, 2])
print(t.stride()) # (2, 1)
print(t.is_contiguous()) # True
print(t.flatten()) # tensor([1, 2, 3, 4])
torch.tensor 在 Python 层看着像变魔术。往下走,PyTorch 的 C++ 引擎申请了一块连续内存,把 1、2、3、4 依次放进去。做这件事的代码,和你写的 int arr[2][3] 在内存布局上没有区别。
它多带了两样东西,shape 和 stride。两边的术语可以一一对上。
| C 语言里的说法 | PyTorch 里的说法 |
|---|---|
int arr[2][3] | torch.zeros(2, 3, dtype=torch.int32) |
一行 3 个 int | shape 里的 3 |
| 跨一行要跳 3 个元素 | stride()[0] 等于 3 |
&arr[i][j] 的偏移算法 | i * stride[0] + j * stride[1] |
| 数组首地址 | data_ptr() |
拿快递柜打个比方。一排柜子从 0 号编到 5 号,物理上就是一条直线。快递员在纸上写"每行 3 个格子",于是 0、1、2 号叫第一行,3、4、5 号叫第二行。柜子一下都没挪,换的只是那张纸。
纸上那个数字就是步长。二维时,元素 (i, j) 的位置按这个式子算。
offset = i * stride[0] + j * stride[1] 单位是元素个数
stride[0] = 2 表示往下一行要跨过 2 个元素,stride[1] = 1 表示往右一列跨 1 个元素。t[1][1] 就是 1*2 + 1*1 = 3,读到第 4 个数,值是 4。
这个式子和上一节的 i * 3 + j 是同一条公式,只是把 3 和 1 换成了变量。Tensor 的"维"靠算出来,内存里并没有存"行"这个东西。
推广到三维也没有新东西。一批图片常写成 (B, H, W),步长相应是 (H*W, W, 1)。想取第 b 张图第 h 行第 w 列的像素,偏移就是 b*H*W + h*W + w*1。四维五维都只是在这个式子里多加几项。内存始终是一条直线,从来没长成立方体。
3.1 用 C 把 stride 亲手跑一遍
纯 C 就能验证,连框架都不用装。
#include <stdio.h>
int main(void)
{
int data[6] = {1, 2, 3, 4, 5, 6}; // 底层只有这一条直线
// 看法 A 2 行 3 列,步长 (3, 1)
for (int i = 0; i < 2; i++)
{
for (int j = 0; j < 3; j++)
printf("%d ", data[i * 3 + j * 1]);
printf("\n");
}
// 看法 B 转置,步长 (1, 3),data 一个字节都没动
printf("\n");
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 2; j++)
printf("%d ", data[i * 1 + j * 3]);
printf("\n");
}
return 0;
}
本机跑出来的结果。
1 2 3
4 5 6
1 4
2 5
3 6
同一块 data,换一组 stride,一次读出原矩阵,一次读出转置矩阵。转置没有搬动任何数据,只换了数格子的规则。 view()、reshape()、permute() 在内存层面干的也是这件事。
3.2 PyTorch 里看 stride
u = t.t() # 转置
print(u.stride()) # (1, 2)
print(u.is_contiguous()) # False
print(u) # tensor([[1, 3], [2, 4]])
u 打印出来行列换了,底层内存还是 1、2、3、4。步长从 (2, 1) 变成 (1, 2),读的时候按新规则跳着取。
这里立着新手最常撞的一堵墙。
u.view(-1) # 报错,view 要求底层连续
u.reshape(-1) # 能跑,内部发现不连续,先复制一份再拉平
view 只肯改纸条,不肯动柜子。步长已经跳着走了,硬按一维顺序读会读出乱序数据,它直接拒绝。reshape 会重新申请一块连续内存,把数按新顺序搬进去,再给结果。一个不复制,一个可能悄悄复制。两行代码只差几个字母,性能和内存占用能差出一截。
还有个单位上的坑。NumPy 的 .strides 给字节数,PyTorch 的 .stride() 给元素个数。我在本机打印了一个 int32 的 2×3 数组。
a.shape (2, 3)
a.strides (12, 4) 字节,12 = 3 * 4
a.T.strides (4, 12) 转置后不再连续
c.strides (8, 4) ascontiguousarray 复制后重新变连续
把 12 除以 4,就得到 PyTorch 会告诉你的 3。单位不同,规则相同。
切蛋糕的说法放在这里正合适。蛋糕还是那块蛋糕,view 换的是刀法,contiguous() 是重新烤一块再切。以后看到 transpose() 后面紧跟一句 contiguous(),你就知道那里花了一次拷贝的钱。
4. 灵魂拷问 为什么矩阵乘法能加速
神经网络里绝大部分算力花在矩阵乘法上。先看最原始的做法,三层 for。
#define N 3
void matmul(int a[N][N], int b[N][N], int c[N][N])
{
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
{
int sum = 0;
for (int k = 0; k < N; k++)
sum += a[i][k] * b[k][j];
c[i][j] = sum;
}
}
拿 {{1,2,3},{4,5,6},{7,8,9}} 乘 {{9,8,7},{6,5,4},{3,2,1}},本机跑出的结果。
30 24 18
84 69 54
138 114 90
算法一行公式都不用,就是固定次数的一乘一加。可这三层循环里,内存访问的脾气完全不一样。
a[i][k]沿着行走,地址一次加 4 字节。b[k][j]沿着列走,k每加 1 要跨过一整行。c[i][j]按行写,同样连续。
CPU 读内存不会一个字节一个字节地拿。它一次搬一整条缓存行,常见是 64 字节,够装 16 个 int。你顺着读,一次搬进来的后面 15 个数马上要用。你跳着读,搬进来的大半被丢掉,下一次又得重新搬。
我在本机做了一组对照。3000×3000 的 int 数组,重复遍历 10 遍求和,编译器关掉优化(MSVC 14.51,/Od)。
// 顺着行读
for (int i = 0; i < M; i++)
for (int j = 0; j < M; j++)
s1 += big[i][j];
// 跳着列读
for (int j = 0; j < M; j++)
for (int i = 0; i < M; i++)
s2 += big[i][j];
row-major 0.162 s col-major 0.316 s sums = 90000000 / 90000000
两次求的是同一批数,结果也一样,时间差了将近一倍。差的这一倍,全花在等内存上。 换成更大的矩阵、更慢的显存访问,这个差距还会放大。
GPU 把这件事推到极致。CUDA 里一批线程同时开工,相邻线程访问相邻地址时,硬件能把几十次零散请求合并成一次宽的显存事务,这叫合并访存。数据要是散在各处,同一批线程就得排好几次队,几千个核心一起干等。显卡算力再强,也得先让数据排好队。
AI 框架底层那堆 BLAS、cuBLAS、oneDNN,把分块、循环展开、向量化指令和寄存器复用都做进了 C 与 Fortran 代码里,目标只有一个,让连续内存被读得又快又满。你在 Python 里写一行 torch.matmul(a, b),底下干活的是它们。
有人会问,既然三层循环里 b[k][j] 跳着读会慢,底层库干吗不先把 b 转置一份?它们确实会做类似的事,只是手法更细。把大矩阵切成能塞进高速缓存的小块,让每个小块内部尽量连续,再配合向量化指令一次算多个数。分块切多大,是这些库几十年攒下来的手艺。
回头看第 3 节那个 contiguous(),动机就清楚了。框架宁愿多花一次拷贝,也要把数据重新摆成连续的一条线,好让后面的矩阵乘法吃到顺序读取的红利。
5. 总结与避坑
学 AI 要补数学,这点不用怀疑。可模型能不能跑起来、跑多快、显存会不会爆,答案经常藏在你大一就学过的内存里。
shape 说明怎么切,stride 说明怎么走,那块连续内存才是数据本身。
把下面几条记进笔记,能省下不少对着维度报错发呆的晚上。
- shape 变了,数据未必搬了。
transpose、permute和多数切片只改 stride 与起始偏移。 view要求连续,reshape可能复制。 想确认有没有多花一次内存,看is_contiguous()。- dtype 决定一步跨多少字节。
float32一个元素 4 字节,float64是 8 字节。NumPy 的 strides 直接给字节,PyTorch 给元素个数,别把两个数混着算。 - CPU 上的 Tensor 送到 GPU,搬的是整块连续内存。
t.to("cuda")这一步的耗时和显存占用,都按这块内存的大小算。 - 别用 Python 循环手写矩阵乘法。 交给
torch.matmul,让底层库去管缓存和并行。 - 遇到维度变换,先问两个问题。 步长变成多少了,有没有发生复制。这两件事想明白,
view、reshape、flatten、permute就只是一组换规则的写法。
回到开头那句话。你学过 &arr[i][j],学过指针偏移,学过数组在内存里连成一条线。这些知识没有因为换成 Python 就作废,它们只是被 PyTorch 藏进了一层封装里。
下次看见 x = x.view(B, -1, H, W),别急着翻公式。在心里画一条连续的格子,想想这把刀往哪儿切。
Tensor 没打算吓退谁。它一直是你认识的那块内存,换了个名字而已。

136



被折叠的 条评论
为什么被折叠?



