这是一篇前置讲,不对应原课的某一次课。原课的先修要求写着”会 Python 以及 C / C++ / Java 之一”,第 2 讲起就开始写 CUDA 内核——而 CUDA 是 C/C++ 的扩展。原课第 3 讲 slides 上的第一段代码是
cudaMalloc(&dA, n * sizeof(int)),第 4 讲的第一个内核里有a[row * N + k]。这篇为只会 Python 的读者,把读懂、写出这些代码所需的 C 概念从头讲清楚。 依据说明:本讲讲的是通用的 C 语言与计算机知识(类型、指针、栈与堆、行优先存储),参考 Kernighan & Ritchie《C 程序设计语言》、NVIDIA CUDA C++ Programming Guide 的内存管理接口、PMPP(Programming Massively Parallel Processors,第 4 版)第 2 章的向量加法例子、NumPy 文档里关于 strides 的说明,以及原课 HW1 / miniTorch 的张量数据结构(index_to_position、to_index、broadcast_index)。所有实验都是本讲义自己设计的。 配套代码 Code:code/prereq1/(3 个脚本 + 2 个模块 + 2 个 C 源文件;需要 numpy 和一个 C 编译器;每个脚本 1–2 秒) 预计阅读时间:3 小时(含运行代码) 建议阅读时机:第 2 讲之前(写 CUDA 内核必需)
0. 本讲导航 · How to read this lecture
0.1 为什么要懂这些
下面是一个完整的 CUDA 程序的骨架(向量加法,原课第 2–3 讲和 PMPP 第 2 章的标准例子;本讲义的环境没有 NVIDIA GPU,这段代码只用来”读”):
__global__ void vecAdd(const float* A, const float* B, float* C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x; // 这个线程负责第 i 个元素
if (i < n) C[i] = A[i] + B[i]; // 越界检查 + 计算
}
int main(void) {
int n = 1 << 20;
size_t size = n * sizeof(float);
float* A_h = (float*)malloc(size); // CPU 内存(host)
/* …… 给 A_h、B_h 填数据 …… */
float* A_d; // 将来指向 GPU 显存(device)
cudaMalloc((void**)&A_d, size); // 为什么是 &A_d?为什么要转成 void**?
cudaMemcpy(A_d, A_h, size, cudaMemcpyHostToDevice);
vecAdd<<<(n + 255) / 256, 256>>>(A_d, B_d, C_d, n);
cudaMemcpy(C_h, C_d, size, cudaMemcpyDeviceToHost);
cudaFree(A_d); free(A_h);
return 0;
}
如果你只会 Python,这里几乎每一行都有问题:float* 是什么?sizeof(float) 是多少?为什么 cudaMalloc 要传 &A_d、还要转成 (void**)?A[i] 越界了会怎样?malloc 和 free 为什么要成对出现?<<<…>>> 之外的部分其实都是普通的 C。这篇前置讲把这些概念讲清楚,§7 会回到这段代码,逐行解释。
0.2 学习目标 Learning goals
- 说清楚编译型语言和解释型语言的区别;知道一个 C 程序从源代码到可执行文件经过哪几步;能用实验解释 Python 循环为什么慢,以及”调用一次 C 函数”本身的开销。
- 知道 C 的基本类型各占几个字节,一个数在内存里是哪几个字节(字节序)。
- 理解指针:取地址
&、取值*、指针运算、数组与指针的关系、按指针传参、指向指针的指针(void**)。 - 理解栈和堆:局部变量的生命周期、
malloc/free、内存泄漏和悬空指针;知道 GPU 显存的cudaMalloc/cudaFree/cudaMemcpy是同一套思路。 - 理解多维数组的行优先存储和步长:能算出任意元素的一维位置,知道转置、切片、广播为什么是”零拷贝的视图”、什么时候必须复制;能写出原课 HW1 里的
index_to_position、to_index、broadcast_index。 - 会用
ctypes从 Python 调用 C 函数、传递 NumPy 数组和结构体,并知道为什么”一次处理一大批数据”比”每个元素调用一次”快得多。
0.3 本讲地图 Map
§1 编译型 vs 解释型:C 程序的一生;Python 为什么慢;求和实验;跨语言调用的开销 ← 代码 01
§2 类型与字节:sizeof;字节序;读懂 CUDA 所需的最少 C 语法 ← 代码 02 第 1–2 部分
§3 指针:& 与 *;指针运算;越界;按指针传参;void** ← 代码 02 第 3–5 部分
§4 内存的生命周期:栈与堆;malloc / free;泄漏;GPU 上的对应 ← 代码 02 第 6 部分
§5 多维数组:行优先、步长、转置、切片、广播;HW1 式下标函数 ← 代码 03 第 1–4 部分
§6 结构体与 Python ↔ C:Tensor2D、ctypes、共享库;为什么还是要用库 ← 代码 03 第 5–6 部分
§7 回到开头:逐行读懂一个 CUDA 程序
§8 小结 §9 常见误区 §10 练习 §11 延伸阅读 §12 下一篇预告
0.4 先修
会写 Python 函数、循环、列表;知道 NumPy 数组是什么。不需要任何 C 的经验。代码需要一个 C 编译器:macOS 上运行一次 xcode-select --install,Linux 上安装 gcc(sudo apt install build-essential)。
1. 编译型 vs 解释型 · Compiled vs interpreted
1.1 一个 C 程序的一生
直觉。 Python 程序像是请了一位同声传译:你每说一句,他当场翻译一句,下次再说同样的话,他还得再翻译一遍。C 程序像是一本事先译好的书:翻译只做一次,之后读者直接读译文。
C 的”翻译”由编译器完成,分四步。01 号脚本第 1 部分把 hello_pointers.c(一个 58 行的小程序)按步骤拆开编译,统计了每一步的产物(本机 Apple clang 21):
| 步骤 | 命令 | 产物 |
|---|---|---|
| (源代码) | hello_pointers.c | 58 行文本 |
| 1. 预处理 | cc -E | 1,791 行文本:#include <stdio.h> 把标准库的声明整个粘贴进来,#define 被展开 |
| 2. 编译 | cc -S | 206 行汇编:本机 CPU 的指令,人勉强能读 |
| 3. 汇编 | cc -c | 2,288 字节的目标文件(.o),二进制机器码 |
| 4. 链接 | cc x.o -o x | 33,488 字节的可执行文件:接上了 printf、malloc 所在的标准库 |
平时一条 cc -O2 -o hello hello_pointers.c 就把四步一起做了。-O2 是优化级别:让编译器花更多时间,生成更快的机器码。
这些工作都在运行之前一次做完。运行时,CPU 直接执行机器指令,不需要再”理解”源代码。CUDA 也一样:.cu 文件先用 NVIDIA 的编译器 nvcc 编译,CPU 部分变成普通机器码,GPU 部分(内核)变成 GPU 的机器码;运行时 CPU 把内核”发射”到 GPU 上执行。
1.2 Python 为什么慢
Python 是解释型的:源代码先被翻译成字节码(一种虚拟机指令),解释器再一条一条地执行字节码,每执行一条都要检查操作数的类型、决定调用哪段 C 代码。01 号脚本第 2 部分用 dis 模块看了一个求和循环:
def add_all(xs):
s = 0.0
for x in xs:
s += x
return s
每循环一次要执行 6 条字节码(Python 3.13):FOR_ITER → STORE_FAST → LOAD_FAST_LOAD_FAST → BINARY_OP → STORE_FAST → JUMP_BACKWARD。BINARY_OP 要先看 s 和 x 是什么类型(float?int?自定义类?),再调用对应的加法,结果还要装进一个新的 float 对象。
数据的存放方式也不一样:
| 东西 | 字节数 | 说明 |
|---|---|---|
| 一个 Python float 对象 | 24 | 类型指针 + 引用计数 + 8 字节的数值 |
| 一个 C float | 4 | 只有数值本身 |
| 100 万个数的 Python 列表(列表 + 所有对象) | 32,448,728 | 列表里存的是 100 万个指针,数值在另外 100 万个对象里 |
| 100 万个 float32 的 NumPy 数组(数据区) | 4,000,000 | 一段连续的 C 数组 |
Python 的灵活(变量可以随时换类型、整数可以任意大)是用这些开销换来的。
1.3 实验:把一百万个数加起来
01 号脚本第 3 部分用五种写法把 100 万个 [0, 1) 之间的随机 float32 加起来(⏱ 本机实测,取 5 次中最快的一次):
| 写法 | ⏱ 毫秒 | ⏱ 每个元素 | ⏱ 相对 Python | 累加器 | 结果 | 与精确值之差 |
|---|---|---|---|---|---|---|
| 纯 Python for 循环 | 7.7 | 7.7 ns | 1× | Python float(64 位) | 500377.2696 | +0.0000 |
C,-O0(不优化) | 0.64 | 0.64 ns | 12× | float(32 位) | 500373.4375 | −3.8321 |
C,-O2 | 0.54 | 0.54 ns | 14× | float(32 位) | 500373.4375 | −3.8321 |
C,-O2,double 累加 | 0.55 | 0.55 ns | 14× | double(64 位) | 500377.2696 | +0.0000 |
NumPy arr.sum() | 0.11 | 0.11 ns | 约 71× | float32,分块成对相加 | 500377.2500 | −0.0196 |
(“精确值”用 float64 求和得到:500377.2696。)
这张表里有三个值得细想的地方,其中两个和我写代码前的预期不一样:
(1) C 只比 Python 快 14 倍左右,开 -O2 也只多快 1.2 倍。 我原以为编译器优化会带来明显的加速。可是这个循环有一个依赖链:第 i 次加法必须等第 i−1 次加法的结果出来才能开始。CPU 做一次浮点加法要几个时钟周期,于是每个元素至少要等这么久。要打破依赖链,就得把数组拆成几段、各自累加、最后再合起来——但这会改变加法的顺序,而浮点加法不满足结合律(P2),结果会变。C 编译器默认不允许自己改变结果,所以不会这样做。
(2) NumPy 比我们的 C 循环还快约 5 倍。 NumPy 的求和恰好做了编译器不敢做的事:用 SIMD 指令(一条指令同时处理好几个数)维护多个部分和,最后两两相加(成对求和,pairwise summation)。这就是并行归约(parallel reduction):GPU 上的 reduce 内核(原课 HW1 的 Problem 3、第 3 讲)是同一个思路,只是并行度高得多——几千个线程各自加一段,再一层层合并。
(3) 结果不一样。 用 32 位 float 顺序累加,差了 3.83:当部分和长到几十万时,再加一个不到 1 的数,舍入误差就不可忽略了。用 double 累加没有可见误差;NumPy 的成对求和把每一步的部分和都控制在相近的大小,误差只有 0.02。换一种加法顺序,结果就在最后几位上不同——这是 GPU 程序的输出常常不能逐位复现的根源,也是 GPU 上做归约时要用高精度累加器的原因。P2 会专门讲。
1.4 跨语言调用的开销
如果 C 这么快,能不能在 Python 循环里对每个元素调用一次 C 函数?01 号脚本第 4 部分测了一下(⏱):
| 操作 | ⏱ 每次耗时 |
|---|---|
| 经 ctypes 调用一个什么都不做的 C 函数 | 约 143 ns |
| 调用一个什么都不做的 Python 函数 | 约 40 ns |
| 一次 C 调用里加完 100 万个数 | 约 535,000 ns |
跨语言调用一次约 143 纳秒,相当于 C 里做约 270 次加法。“每个元素调用一次 C 函数”会把 C 的速度全部吃掉;正确的做法是一次调用处理一大批数据。这条原则在 GPU 上更重要:CPU 发射一个 GPU 内核要几微秒,如果内核只处理很少的数据,时间几乎全花在发射上。第 10–11 讲的”算子融合”要把多个小内核合成一个,原因之一就在这里。
1.5 Python + C:今天的标准分工
PyTorch、NumPy 的核心都是 C/C++(和 CUDA)写的,Python 只是”指挥官”:它决定调用哪个算子、按什么顺序,真正的计算全在编译好的代码里。原课的 miniTorch 也是这样:张量和自动微分用 Python 写,CUDA 内核编译成共享库,再从 Python 调用(HW1)。本讲的 c_lab.py 用 ctypes 做的是同一件事的最小版本(§6.2)。
2. 类型与字节 · Types and bytes
2.1 C 的基本类型
C 的每个变量都要先声明类型,类型决定它占几个字节、怎样解释这些字节。02 号脚本第 1 部分让 C 自己报告了 sizeof(本机 64 位 macOS):
| C 类型 | sizeof | 对应的 NumPy 类型 | 常见用途 |
|---|---|---|---|
char | 1 | int8 | 字节、字符 |
short | 2 | int16 | |
int | 4 | int32 | 循环下标、线程编号 |
long | 8 | — | (64 位 Windows 上只有 4 字节!) |
long long | 8 | int64 | 大数组的下标 |
float | 4 | float32 | 深度学习的默认精度 |
double | 8 | float64 | 科学计算 |
void*(任何指针) | 8 | — | 地址 |
size_t | 8 | — | 字节数、元素个数 |
几点说明:
long的大小因平台而异。需要确定位数时用<stdint.h>里的int32_t、int64_t、uint8_t等,它们在任何平台上都是固定的位数。CUDA 和 PyTorch 的源代码里到处是这些类型。- 指针是 8 字节:在 64 位机器上,地址本身就是一个 64 位整数。
size_t是”大小”的专用类型(无符号):malloc(n * sizeof(float))、cudaMalloc(&p, size)的size都是它。- 对照 Python:
int1 占 28 字节、2**100占 40 字节、float 占 24 字节——Python 的整数可以任意大,代价是每个数都是一个对象。
2.2 一个数在内存里是哪几个字节
内存可以想象成一条很长的”字节街”,每个字节有一个门牌号(地址)。一个 4 字节的 int 占 4 个相邻的门牌。02 号脚本第 2 部分把几个数的原始字节读了出来:
| 值 | 内存里的字节(地址从低到高) | 倒过来读 |
|---|---|---|
| int 42 | 2a 00 00 00 | 00 00 00 2a |
| int 256 | 00 01 00 00 | 00 00 01 00 |
| int −1 | ff ff ff ff | ff ff ff ff |
| int 2147483647 | ff ff ff 7f | 7f ff ff ff |
| float 1.0 | 00 00 80 3f | 3f 80 00 00 |
| float 0.1 | cd cc cc 3d | 3d cc cc cd |
| double 0.1 | 9a 99 99 99 99 99 b9 3f | 3f b9 99 99 99 99 99 9a |
- 本机是小端(little-endian):低位字节放在低地址,所以 42 = 0x2a 存成
2a 00 00 00。几乎所有 CPU 和 GPU 都是小端;网络协议常用大端。读写二进制文件(例如模型权重的.bin/.safetensors)时,格式里会约定字节序。 - −1 是四个
ff:这是补码表示(P2 §1)。 - 0.1 在 float 和 double 里都不是精确的 0.1,只是最接近 0.1 的二进制小数(P2 §2)。
2.3 读懂 CUDA 内核所需的最少 C 语法
对照着 Python 看:
| 概念 | Python | C |
|---|---|---|
| 声明变量 | n = 10 | int n = 10;(必须写类型、语句以分号结尾) |
| 浮点字面量 | 0.5 | 0.5f(float)/ 0.5(double)——不加 f 的是 double |
| 整数除法 | 7 // 2 | 7 / 2(两个整数相除自动取整,结果 3) |
| for 循环 | for i in range(n): | for (int i = 0; i < n; i++) { … } |
| 条件 | if i < n: | if (i < n) { … }(只有一句时可以省略花括号) |
| 函数 | def f(a, b): return a + b | float f(float a, float b) { return a + b; }(参数和返回值都要写类型) |
| 没有返回值 | def g(): … | void g(void) { … }(CUDA 内核都是 void) |
| 注释 | # … | // … 或 /* … */ |
| 打印 | print(f"{x:.2f}") | printf("%.2f\n", x); |
| 类型转换 | float(i) | (float)i |
| 自增 | i += 1 | i++ 或 i += 1 |
最常让 Python 程序员出错的三个地方:整数除法会取整(1 / 2 是 0);0.1 默认是 double,和 float 运算时会悄悄提升精度(在 GPU 上会变慢);变量不初始化就使用,值是随机的垃圾(C 不会自动清零)。
3. 指针 · Pointers
3.1 地址与指针
直觉。 变量住在内存里,每个变量都有一个”门牌号”——地址。指针就是一个专门用来存门牌号的变量。
两个运算符:
&x:取x的地址(“x 住在哪里”)。*p:取指针p指向的那个变量(“去 p 写的那个门牌号,看看里面是什么”)。
hello_pointers.c 的开头:
int x = 42;
int* px = &x; // px 是一个"指向 int 的指针",存的是 x 的地址
*px = 7; // 通过 px 修改 x
printf("%d", x); // 打印 7
运行时它打印 &x = 0x16d8e282c 之类的地址——每次运行都不同,因为操作系统每次把程序放在随机的位置(地址空间布局随机化,ASLR,一种安全措施)。我们关心的从来不是地址的绝对值,而是地址之间的差。
int* px 的写法读作”px 的类型是 int*“,也就是”指向 int 的指针”。float* 指向 float,void* 是”不知道指向什么类型”的通用指针(只能存地址,不能直接取值)。
3.2 数组与指针运算
C 的数组就是一段连续的内存,数组名可以当作首元素的地址来用。02 号脚本第 3 部分建了一个 float a[5],从 C 里读回每个元素:
| i | &a[i] − &a[0](字节) | a[i] | *(a + i) |
|---|---|---|---|
| 0 | +0 | 10.0 | 10.0 |
| 1 | +4 | 11.0 | 11.0 |
| 2 | +8 | 12.0 | 12.0 |
| 3 | +12 | 13.0 | 13.0 |
| 4 | +16 | 14.0 | 14.0 |
两条规则:
a[i]就是*(a + i)的简写:先算地址,再取值。- 指针运算以”元素”为单位:
p + 3不是地址加 3,而是加 3 × sizeof(元素)。02 号脚本测得float*加 3 前进 12 字节,double*加 3 前进 24 字节。指针”知道”自己指向什么类型,这正是它需要类型的原因。
所以 GPU 内核里的 A[i],意思就是”从 A 指向的地址往后数 i 个 float,读出那个数”。
3.3 C 不检查越界
Python 的列表越界会抛出 IndexError;C 的 a[5]、a[-1] 都能编译,运行时会读写到别人的数据——可能读到垃圾、可能悄悄改坏别的变量、也可能让程序崩溃(“段错误” segmentation fault)。C 不检查,是为了速度:每次访问都检查边界是要花时间的。
这就是 GPU 内核开头那句 if (i < n) 的来历:线程是按块(例如每块 256 个)发射的,n 不一定是 256 的倍数,最后一块里会有一些线程的 i ≥ n,它们必须什么都不做。线程下标越界是 CUDA 程序里最常见的 bug。(02 号脚本没有真的做越界访问——那是”未定义行为”,结果不可预测,不适合当演示。)
3.4 按值传参与按指针传参
C 的函数参数总是复制一份传进去(按值传递)。02 号脚本第 4 部分:
void swap_by_value(int a, int b) { int t = a; a = b; b = t; } // 交换的是副本
void swap_by_pointer(int* a, int* b) { int t = *a; *a = *b; *b = t; } // 通过地址改原来的变量
| 调用 | 调用后 (x, y) |
|---|---|
| 初始 | (1, 2) |
swap_by_value(x, y) | (1, 2):没变 |
swap_by_pointer(&x, &y) | (2, 1):交换成功 |
指针传参还有一个作用:避免复制大数据。把一个 100 万元素的数组传给函数时,传的只是 8 字节的首地址,函数通过地址直接读写原数组。CUDA 内核没有返回值,结果全部通过”传进来的指针”写回显存,是同一个机制。
3.5 指向指针的指针:cudaMalloc(&dA, size)
现在可以回答开头的问题了。cudaMalloc 要做两件事:报告成功还是失败,以及把新分配的显存地址交给调用者。返回值被错误码占用了(cudaError_t,0 表示成功),地址只能写进调用者的变量里。要修改调用者手里的指针变量 dA,就必须拿到 dA 的地址——dA 本身是 float*,它的地址就是 float**,再统一转成通用的 void**。
02 号脚本第 5 部分用一个普通的 malloc 模仿了这个接口:
int my_malloc(void** out, size_t nbytes) {
void* p = malloc(nbytes);
if (p == NULL) return 2; // 失败:返回错误码
memset(p, 0, nbytes);
*out = p; // 把地址写回调用者的变量
return 0; // 成功
}
从 Python 调用:先建一个空指针变量 p(NULL),把它的地址传进去;调用后返回 0,p 变成了一个地址;再把这个地址当作 float* 交给 fill_squares 写入 0, 1, 4, 9, …,读回来验证;最后 my_free(p)。
和真正的 cudaMalloc 唯一的区别:cudaMalloc 得到的地址指向 GPU 显存,CPU 不能直接读写它,必须用 cudaMemcpy 在主机内存和显存之间搬运(§4.3)。
3.6 const 和 restrict
两个经常出现在内核签名里的修饰词:
const float* A:承诺”不会通过 A 去修改它指向的数据”。编译器会检查(写了就报错),也可以据此做优化。只读的输入通常都标const。float* __restrict__ C(CUDA)/restrict(C99):承诺”这个指针指向的内存不会和其他指针重叠”。没有这个承诺,编译器担心写C[i]会改到A的内容,就不敢把A的值缓存在寄存器里。性能敏感的内核里常见。
4. 内存的生命周期:栈与堆 · Stack and heap
4.1 栈:自动管理的短期内存
函数里声明的普通局部变量(int x;、float a[4];)放在栈(stack)上:进入函数时自动分配,函数返回时自动回收。栈很快,但很小(通常几 MB),而且大小在编译时就要确定。
一个经典的 bug:
float* make_array(void) {
float a[100]; // 局部数组,在栈上
return a; // 错!函数返回后 a 的内存就被回收了
}
调用者拿到的是一个悬空指针(dangling pointer):它指向的内存已经不属于你,随时会被别的函数覆盖。编译器通常会对这种写法给出警告。
4.2 堆:手动管理的长期内存
运行时才知道要多大、或者要在函数返回后继续使用的内存,从堆(heap)上用 malloc 申请,用完必须 free:
float* a = (float*)malloc(n * sizeof(float)); // 申请 n 个 float
if (a == NULL) { /* 申请失败 */ }
/* …… 使用 a …… */
free(a); // 归还
a = NULL; // 好习惯:避免之后误用
堆上的三种经典错误:忘了 free(内存泄漏)、free 之后还在用(use-after-free)、同一块 free 两次(double free)。Python 有垃圾回收,这些都替你管了;C 没有。
02 号脚本第 6 部分实测了泄漏(⏱):分配 40 次 × 8 MiB 并写满,
| 阶段 | ⏱ 峰值内存增加 |
|---|---|
| 每次用完都 free | 8 MiB(只有一块的大小:同一块内存被反复使用) |
| 都不 free | 约 313 MiB(≈ 40 × 8 MiB) |
在一个长时间运行的服务里,泄漏会一直涨,直到内存耗尽、进程被杀掉。
4.3 GPU 上的对应
GPU 有自己的内存(显存,device memory),和 CPU 的内存(host memory)是两块不同的物理内存,通过 PCIe 或 NVLink 相连。CUDA 提供了一一对应的接口:
| CPU(host) | GPU(device) | 说明 |
|---|---|---|
malloc(size) | cudaMalloc(&ptr, size) | 申请内存 |
free(ptr) | cudaFree(ptr) | 释放 |
memcpy(dst, src, size) | cudaMemcpy(dst, src, size, 方向) | 复制;方向是 cudaMemcpyHostToDevice 等 |
直接 a[i] | 只有 GPU 上的内核能直接 a[i] | CPU 不能直接解引用设备指针 |
两条要记住的规则:设备指针和主机指针都是”一个地址”,类型上看不出区别,混用(在 CPU 上解引用设备指针)会崩溃;主机和设备之间的拷贝很贵(PCIe 4.0 x16 约 32 GB/s,而 H100 的显存带宽是 3.35 TB/s,第 1 讲 §4.7),所以要尽量让数据留在 GPU 上。显存比内存小得多,“谁持有这块显存、什么时候释放”在推理服务里是核心问题(第 24 讲 vLLM 管理 KV 缓存)。
5. 多维数组:行优先与步长 · Row-major layout and strides
5.1 一维内存里的二维矩阵
内存只有一维,矩阵却有两维。最常见的排法是行优先(row-major,C、NumPy、PyTorch 的默认):第 0 行的元素依次排完,接着排第 1 行……03 号脚本第 1 部分看了一个 3×4 的 float32 矩阵:
a = 0 1 2 3 内存里的顺序: 0 1 2 3 | 4 5 6 7 | 8 9 10 11
4 5 6 7
8 9 10 11
元素 的一维位置是 。这两个系数叫步长(strides):行号加 1 要跳过 4 个元素,列号加 1 跳过 1 个。NumPy 的 a.strides 是 (16, 4)——它以字节为单位(每个 float32 4 字节),除以 4 就是元素为单位的 (4, 1)。03 号脚本用一个只拿到”首地址 + 两个步长”的 C 函数 get2d(base, i, j, 4, 1) 读出了和 NumPy 完全相同的元素。
5.2 步长的一般公式
维张量的元素 的位置:
行优先连续存放时,最后一维的步长是 1,往前每一维的步长等于后面所有维度大小的乘积。例如形状 (2, 3, 4) 的步长是 (12, 4, 1):第 0 维加 1 要跳过一整个 3×4 的”面”。tensor_layout.py 的 strides_for 就是这个计算。
5.3 转置 = 交换步长
直觉。 转置不需要搬动任何数据:只要换一种”读法”。03 号脚本第 2 部分:
| 数组 | 形状 | 步长(元素) | 行优先连续? | 数据区 |
|---|---|---|---|---|
a | (3, 4) | (4, 1) | 是 | 原始数据 |
a.T | (4, 3) | (1, 4) | 否 | 同一块数据 |
np.ascontiguousarray(a.T) | (4, 3) | (3, 1) | 是 | 新复制的数据 |
a.T 和 a 共用同一块内存,只是形状和步长交换了。C 函数用同一个指针、步长 (1, 4) 读出的 4×3 矩阵正好等于 a.T。
代价是 a.T 不再连续:按行读它时,相邻元素在内存里相隔 4 个位置。很多 C / CUDA 函数假设输入是连续的,这时要么先复制成连续的(ascontiguousarray、PyTorch 的 .contiguous()),要么把步长也传进去。
什么时候必须复制? 把 a.T 展平成 12 个元素,顺序是 0 4 8 1 5 9 2 6 10 3 7 11——没有任何一组步长能表达这个顺序,所以 a.T.reshape(12) 只能复制一份(03 号脚本验证了结果与 a 不共享内存)。PyTorch 的 view() 遇到这种情况会直接报错,reshape() 会悄悄复制——很多人第一次写 Transformer 时都在这里踩过坑(多头注意力里的 transpose 之后接 view,第 6 讲)。
5.4 切片与广播也是视图
| 视图 | 形状 | 步长(元素) | 起点偏移 | 共享原数据 |
|---|---|---|---|---|
a[:, 1:3] | (3, 2) | (4, 1) | 1 | 是 |
a[::2, ::2] | (2, 2) | (8, 2) | 0 | 是 |
np.broadcast_to(v, (3, 4)) | (3, 4) | (0, 1) | 0 | 是 |
- 切片 = 新的起点偏移 + 新的形状 + 新的步长;隔一个取一个就是步长翻倍。写切片就是写原数组(03 号脚本里改
s1[0, 0],a[0, 1]也变了)。 - 广播 = 某一维的步长为 0:沿着那一维走,地址不动,同一个元素被重复使用。偏置加法 就是这样把长度为 d 的向量 b 加到每一行上的,不需要把 b 真的复制成矩阵(广播视图只占 v 本身的 16 字节,而真正的 3×4 数组要 48 字节)。
5.5 原课 HW1 的下标函数
原课 HW1 的提示里专门有一节”数据布局与步长”:要在 CUDA 里实现 map、zip、reduce,每个线程首先要知道自己负责哪个元素、它的输入在哪里。miniTorch 用三个小函数完成这件事(下面是讲解用的参考版本,原课作业要你自己写):
to_index(ordinal, shape):按行优先顺序数的第 ordinal 个元素 → 多维下标。从最后一维开始,反复”除以这一维的大小,余数就是这一维的下标”。例:shape (2, 3, 4) 的第 17 个元素 → (1, 1, 1),因为 17 = 1×12 + 1×4 + 1。GPU 线程拿到的是一维编号i = blockIdx.x * blockDim.x + threadIdx.x,第一步就是to_index。index_to_position(index, strides):多维下标 → 存储里的位置(§5.2 的公式)。broadcast_index(big_index, big_shape, shape):广播后的大张量里的下标 → 原来的小张量里的下标:小张量和大张量的最后几维对齐,大小为 1 的维度下标取 0,多出来的前几维丢掉。例:把 (3, 1) 广播到 (2, 3, 4) 时,大下标 (1, 2, 3) 对应小下标 (2, 0)。
03 号脚本第 4 部分把它们和 NumPy 逐个对了答案:4 种形状下 to_index 再 index_to_position 回到原编号(71/71);(2, 3, 4) 的 6 种维度排列下按步长读取等于 NumPy 的 transpose(144/144);广播加法 (2, 1, 4) + (3, 1) → (2, 3, 4) 的每个输出元素,用 broadcast_index 找到两个输入后相加,等于 NumPy 的结果(24/24)。
于是一个”逐元素 zip”的 GPU 内核在逻辑上就是这样(伪代码):
线程 i:
if i >= out_size: return
out_index = to_index(i, out_shape)
a_index = broadcast_index(out_index, out_shape, a_shape)
b_index = broadcast_index(out_index, out_shape, b_shape)
out[index_to_position(out_index, out_strides)] =
f(a[index_to_position(a_index, a_strides)], b[index_to_position(b_index, b_strides)])
第 3 讲会把它真正写出来(在 SIMT 模拟器上运行)。
6. 结构体与 Python ↔ C · Structs and calling C from Python
6.1 结构体:把相关的数据打包
结构体(struct)把几个字段按顺序排在一起。描述一个二维张量视图,需要一个数据指针、形状和步长:
typedef struct {
float* data; // 指向第一个元素
long rows, cols;
long stride_row; // 行号加 1 时跳过多少个元素
long stride_col; // 列号加 1 时跳过多少个元素
} Tensor2D;
float tensor_get(const Tensor2D* t, long i, long j) {
return t->data[i * t->stride_row + j * t->stride_col]; // t->x 等价于 (*t).x
}
03 号脚本第 5 部分用同一个数据指针构造了两个 Tensor2D,交给同一个 C 函数 tensor_row_sums:
| Tensor2D | rows×cols | 步长 | 按行求和的结果 | NumPy 对照 |
|---|---|---|---|---|
a | 3×4 | (4, 1) | [6, 22, 38] | a.sum(axis=1) |
a 的转置(同一个 data 指针) | 4×3 | (1, 4) | [12, 15, 18, 21] | a.sum(axis=0) |
换一组步长,“按行求和”就变成了”按列求和”,一个字节都没有复制。miniTorch 的 TensorData(存储 + 形状 + 步长)就是这个结构体的 Python 版本;PyTorch 的张量内部也是”存储 + 尺寸 + 步长 + 偏移”。(这个结构体占 40 字节:1 个指针 + 4 个 long。)
6.2 用 ctypes 从 Python 调用 C
c_lab.py 演示了完整的流程:
- 编译成共享库:
cc -O2 -shared -fPIC -o build/p1lib.dylib csrc/p1lib.c(Linux 上是.so)。共享库是”可以被别的程序在运行时加载的函数集合”。 - 加载:
lib = ctypes.CDLL(路径)。 - 声明签名:
lib.sum_floats.argtypes = [POINTER(c_float), c_long],lib.sum_floats.restype = c_float。ctypes 默认把所有参数当 int、返回值当 int;不声明的话,传 float 或返回 double 时会得到莫名其妙的数字(甚至崩溃)。 - 传 NumPy 数组:
arr.ctypes.data_as(POINTER(c_float))取出数组数据区的地址(不复制)。前提是 dtype 对得上(float32 ↔float),并且 C 用完之前数组不能被释放。 - 传”变量的地址”:
ctypes.byref(x)相当于 C 里的&x(§3.4、§3.5)。 - 传结构体:Python 侧定义一个字段顺序和类型完全相同的
ctypes.Structure。
PyTorch 的 C++ 扩展、miniTorch 加载 CUDA 内核,用的是更高级的工具(pybind11 等),但原理一样:Python 拿着数据的地址和几个整数,交给编译好的代码去算。
6.3 为什么还是要用库
03 号脚本第 6 部分最后做了两个实验(⏱ 本机):
| 实验 | 结果 |
|---|---|
256×256 矩阵乘法:C 三重循环 matmul_naive | 约 10.5 毫秒(约 3.2 GFLOP/s) |
同样的乘法:NumPy A @ B(调用 BLAS 库) | 约 0.025 毫秒(约 1.36 TFLOP/s),快约 420 倍 |
| 遍历 4096×4096 的行优先矩阵:按行 | 约 9 毫秒 |
| 同一个矩阵:按列 | 约 53 毫秒,慢约 5.7 倍 |
- 同样是 次运算,专业的 BLAS 库比朴素三重循环快两个多数量级:它做了分块(让数据留在缓存里)、向量化,并用上了专用的矩阵运算单元。第 4 讲会在 GPU 上一步步重做这些优化,你会看到为什么实际工作中总是调用 cuBLAS。(这一组输入上两种实现的结果碰巧逐位相同;一般来说,加法顺序不同会带来最后几位的差异。)
- 按列遍历读的是同样多的数,却慢了 5.7 倍:CPU 每次从内存取来的是一整块(缓存行,本机 128 字节 = 32 个 float),按行读时整块都用上了,按列读时每次只用其中一个。内存访问的顺序决定速度——P4 会讲存储层次,第 4 讲会讲 GPU 上的”合并访存”(coalescing),本质是同一件事。
7. 回到开头:逐行读懂一个 CUDA 程序
现在可以逐行读 §0.1 的那段代码了:
| 代码 | 含义 | 本讲的哪一节 |
|---|---|---|
__global__ void vecAdd(const float* A, const float* B, float* C, int n) | 一个在 GPU 上运行、由 CPU 发射的函数(内核);没有返回值;A、B 只读,C 用来写结果;n 是元素个数 | §2.3、§3.4、§3.6 |
int i = blockIdx.x * blockDim.x + threadIdx.x; | 每个线程算出自己的全局编号:第几个块 × 每块的线程数 + 块内编号(第 2 讲) | §5.5(一维编号) |
if (i < n) C[i] = A[i] + B[i]; | 越界检查;A[i] = 从 A 往后数 i 个 float | §3.2、§3.3 |
size_t size = n * sizeof(float); | 字节数 = 元素个数 × 每个元素的字节数 | §2.1 |
float* A_h = (float*)malloc(size); | 在 CPU 内存(堆)上申请;(float*) 把 malloc 返回的 void* 转成 float* | §4.2 |
float* A_d; cudaMalloc((void**)&A_d, size); | 在显存上申请,地址写回 A_d;传 &A_d 是因为要修改 A_d 本身 | §3.5 |
cudaMemcpy(A_d, A_h, size, cudaMemcpyHostToDevice); | 把数据从主机内存复制到显存(经过 PCIe,很贵) | §4.3 |
vecAdd<<<(n + 255) / 256, 256>>>(A_d, B_d, C_d, n); | 发射内核:(n + 255) / 256 个块(整数除法向上取整的写法),每块 256 个线程(第 2 讲) | §2.3(整数除法) |
cudaMemcpy(C_h, C_d, size, cudaMemcpyDeviceToHost); | 结果拷回主机 | §4.3 |
cudaFree(A_d); free(A_h); | 显存和内存各自释放,不能混用 | §4.2、§4.3 |
唯一还没讲的是 __global__、<<<…>>>、blockIdx、threadIdx——它们是 CUDA 在 C 之上加的扩展,正是第 2 讲的内容。
8. 小结 · Summary
- 编译型 vs 解释型:C 在运行前一次翻译成机器码(预处理 → 汇编 → 目标文件 → 链接);Python 边解释边执行,每条字节码都要判断类型、每个数都是对象。实测求和时 C 比 Python 快约 14 倍,NumPy 快约 71 倍——NumPy 用多个部分和打破了依赖链,这正是并行归约的思想。换一种加法顺序,浮点结果就会不同。
- 跨语言调用有固定开销(约 140 纳秒一次):要一次处理一大批数据,GPU 内核同理。
- 类型决定字节数和解释方式:int 4 字节、float 4 字节、double 8 字节、指针 8 字节;需要确定位数时用
int32_t等。本机是小端。 - 指针存地址:
&取地址、*取值;a[i]≡*(a + i),指针运算以元素为单位;C 不检查越界,所以内核要写if (i < n);要修改调用者的变量就传它的地址;cudaMalloc(&ptr, size)用void**把地址写回调用者。 - 栈上的局部变量自动回收,堆上的内存要 malloc / free 成对;忘了 free 就泄漏(实测 40 × 8 MiB)。GPU 显存有对应的
cudaMalloc/cudaFree/cudaMemcpy,主机和设备之间的拷贝很贵。 - 张量 = 一维存储 + 形状 + 步长:位置 = Σ 下标 × 步长。转置、切片、广播(步长为 0)都是零拷贝的视图;视图不连续时,有的操作(展平、某些内核)必须复制。HW1 的
to_index、index_to_position、broadcast_index就是把线程编号翻译成内存位置的全部工具。 - 结构体把”指针 + 形状 + 步长”打包交给 C;ctypes 让 Python 直接调用 C。专业库(BLAS / cuBLAS)比朴素实现快几百倍;内存访问的顺序决定速度。
9. 常见误区 · Common pitfalls
- “C 一定比 Python 快一百倍。” 取决于 C 那一侧写得怎样。本讲的顺序求和 C 只快 14 倍,因为它被依赖链卡住了;NumPy 的成对求和又比它快 5 倍。
- “打印出来的地址每次都一样,可以用来判断两个变量是不是同一个。” 绝对地址每次运行都不同(ASLR)。判断两个数组是否共享内存,用
np.shares_memory或比较同一次运行里的地址。 - “
p + 1让地址加 1。” 加的是 1 个元素的大小:float*加 4 字节,double*加 8 字节。 - “函数里改了参数,调用者的变量就变了。” C 按值传参,改的是副本;要改调用者的变量,必须传它的地址。
- “转置会复制数据。” 转置只交换步长;但之后如果需要连续的数据(展平、调用要求连续输入的内核),才会发生复制。PyTorch 里
x.transpose(1, 2).view(...)会报错,要用.contiguous().view(...)或.reshape(...)。 - “广播会把小张量复制成大张量。” 广播是步长为 0 的视图,不占额外内存;只有在你真的把结果写出来时,输出才占满整块。
- “
1 / 2等于 0.5。” 在 C 里两个整数相除结果是 0(取整)。(n + 255) / 256正是利用这一点实现”向上取整”。 - “设备指针和主机指针可以混用。” 它们都只是地址,类型上看不出区别;在 CPU 上解引用设备指针会崩溃,必须用
cudaMemcpy搬运。 - “ctypes 能自动猜出参数类型。” 不能。不声明
argtypes/restype,float 和 double 会被当成 int,得到错误的结果。
10. 练习 · Exercises
练习 1(字节数)。 (a) 一个有 100 万个 double 的数组占多少字节?(b) 一个指向它的指针占多少字节?(c) 一个 70 亿参数的模型,权重存成 float32 要多少 GB?
参考答案:(a) 字节 = 8 MB。(b) 8 字节(64 位机器上任何指针都是 8 字节,和它指向多大的数组无关)。(c) 字节 = 28 GB(这就是为什么要用 16 位甚至更低的精度,P2、第 19 讲)。
练习 2(指针运算)。 假设 float* p 的值是 0x1000。p + 5 的值是多少?如果 p 是 double* 呢?(char*)p + 5 呢?
参考答案:
float*:0x1000 + 5×4 = 0x1014;double*:0x1000 + 5×8 = 0x1028;char*:0x1005(char 是 1 字节)。这也是 02 号脚本里ptr_diff_bytes先把指针转成char*再相减的原因。
练习 3(按值传参)。 下面的函数想把数组每个元素乘 2,并把数组长度改成 0 表示”处理完了”。哪一部分生效、哪一部分不生效?怎样修改?
void process(float* a, int n) {
for (int i = 0; i < n; i++) a[i] *= 2;
n = 0;
}
参考答案:乘 2 生效(通过指针 a 修改的是调用者的数组);
n = 0不生效(n 是副本)。要让调用者的长度变成 0,参数要改成int* n,函数里写*n = 0,调用时传&n。
练习 4(void**)。 仿照 cudaMalloc,写一个 C 函数 int alloc_floats(float** out, int n):申请 n 个 float、全部置 1、把地址写进 *out、成功返回 0。再写出调用它的代码。
参考答案:
int alloc_floats(float** out, int n) { float* p = (float*)malloc(n * sizeof(float)); if (p == NULL) return 1; for (int i = 0; i < n; i++) p[i] = 1.0f; *out = p; return 0; } /* 调用:*/ float* buf = NULL; if (alloc_floats(&buf, 100) == 0) { /* 使用 buf …… */ free(buf); }
练习 5(行优先位置)。 形状 (2, 3, 4) 的连续张量,元素 (1, 2, 3) 的一维位置是多少?反过来,位置 23 对应哪个下标?
参考答案:步长 (12, 4, 1),位置 = 1×12 + 2×4 + 3×1 = 23;
to_index(23, (2, 3, 4))= (1, 2, 3)。(23 是最后一个元素,因为总共 24 个。)
练习 6(维度排列)。 对形状 (2, 3, 4)、步长 (12, 4, 1) 的张量做 permute((2, 0, 1)),新的形状和步长是多少?它连续吗?
参考答案:新的第 k 维 = 原来的第 order[k] 维:形状 (4, 2, 3),步长 (1, 12, 4)。连续张量的步长应该是 (6, 3, 1),所以不连续。
练习 7(广播)。 (a) 形状 (4, 1) 和 (1, 5) 广播后是什么形状?(b) np.broadcast_to(v, (4, 5))(v 的形状是 (5,)、float32)的 .strides 是多少(字节)?(c) 广播到 (4, 5) 后,大下标 (3, 2) 对应 (4, 1) 张量的哪个下标、(1, 5) 张量的哪个下标?
参考答案:(a) (4, 5)。(b) (0, 4):第 0 维步长 0(每一行复用同一个 v),第 1 维 4 字节。(c) (4, 1) 张量:(3, 0);(1, 5) 张量:(0, 2)。
练习 8(找 bug)。 下面的函数有什么问题?怎样修改?
float* ones(int n) {
float a[1024];
for (int i = 0; i < n; i++) a[i] = 1.0f;
return a;
}
参考答案:两个问题。① 返回了栈上局部数组的地址,函数返回后这块内存就被回收,调用者拿到的是悬空指针。② 当 n > 1024 时会越界写。修改:用
float* a = (float*)malloc(n * sizeof(float));在堆上分配(并检查是否为 NULL),由调用者负责free。
练习 9(泄漏)。 一个推理服务每处理一个请求就 malloc 一块 2 MB 的缓冲区存中间结果,但忘了 free。每秒 100 个请求,一台 64 GB 内存的机器大约多久会耗尽内存?
参考答案:每秒泄漏 200 MB,64 GB / 0.2 GB/s = 320 秒,约 5 分钟。泄漏的可怕之处在于它在测试时(几个请求)看不出来,上线后才爆发。
练习 10(求和的精度)。 01 号脚本里,用 float 顺序累加 100 万个 [0, 1) 的随机数差了 3.83,NumPy 只差 0.02。用你自己的话解释为什么,并说出两种不换成 double 也能提高精度的办法。
参考答案:顺序累加时,部分和很快长到几十万,而 float 只有大约 7 位有效数字,几十万量级的数再加上一个零点几的数,最后几位要被舍入掉,误差一步步累积。NumPy 的成对求和让每次相加的两个数大小相近,舍入误差小得多。不换 double 的办法:① 成对 / 分块求和(树形归约,GPU 上的 reduce 就是这样);② Kahan 补偿求和(用一个额外的变量记住每一步被舍掉的部分,P2)。
练习 11(局部性)。 03 号脚本里按列求和比按行慢 5.7 倍。如果你确实需要一个行优先矩阵的每一列的和,怎样写才快?
参考答案:仍然按行遍历,但维护一个长度为 cols 的累加数组:对每一行,把这一行的元素逐个加到
col_sum[j]上。这样内存访问仍然是连续的,结果就是每一列的和。(另一种办法是先转置成连续的再按行求和,但要多一次复制。GPU 上做按列归约时也会用类似的”换一种遍历顺序”的技巧。)
练习 12(HW1 预习)。 用 §5.5 的伪代码,手算一个 zip 内核的一个线程:输出形状 (2, 3)、输入 a 的形状 (2, 1)(连续)、b 的形状 (3,)(连续),线程 i = 4。它读 a 和 b 的哪个位置、写输出的哪个位置?
参考答案:
to_index(4, (2, 3))= (1, 1)。a:broadcast_index((1, 1), (2, 3), (2, 1))= (1, 0),步长 (1, 1),位置 1;b:broadcast_index((1, 1), (2, 3), (3,))= (1,),位置 1;输出步长 (3, 1),位置 1×3 + 1 = 4。即out[4] = f(a[1], b[1])。
11. 延伸阅读 · Further reading
- C 语言:Kernighan & Ritchie,《C 程序设计语言》(第 2 版)第 5 章(指针与数组)、第 6 章(结构体);Bryant & O’Hallaron, Computer Systems: A Programmer’s Perspective(CS:APP)第 2–3 章(数据的机器级表示)。
- CUDA 的内存管理接口:NVIDIA CUDA C++ Programming Guide 的 “Programming Interface → CUDA Runtime → Device Memory” 一节;PMPP 第 2 章(向量加法的完整例子)。
- 步长:NumPy 文档 “Internal memory layout of an ndarray”;Edward Z. Yang 的博客文章 PyTorch internals(讲 PyTorch 张量的存储、尺寸、步长和视图)。
- 原课 HW1:作业说明里”Data layout and strides”一节(https://llmsystem.github.io/llmsystemhomework/assignment_1/ )。
- ctypes:Python 官方文档
ctypes — A foreign function library for Python。
12. 下一篇预告 · Next
本讲多次碰到”浮点数的舍入误差”:float 顺序累加差了 3.83、0.1 不能精确表示、换一种加法顺序结果就变。前置讲 P2(数字的表示) 会从比特讲起:补码与整数溢出,IEEE 754 浮点数的符号、指数、尾数,FP32、TF32、FP16、BF16、FP8 各自的范围与精度,浮点运算为什么不满足结合律,混合精度训练为什么需要 FP32 主权重和损失缩放,以及量化(第 19–20 讲)的基本思路。