对应实践:
course/practice/labs/lab01-step0/
主要修改文件:course/practice/labs/lab01-step0/framework/student.c
验证命令:make clean && make test
到这一章,课程才算真正进入代码。
在 Chapter 0 里,你已经完成了一件非常重要但又容易被忽略的事:确认自己接下来真正要改的是 lab 里的实验代码,而不是项目里已经写好的那套实现。你已经看到过 Lab01 的初始输出是“3 个 FAIL + 1 个 PASS”。这意味着练习边界已经准备好了,接下来就该真正填第一批代码。
本章之所以从张量开始,不是因为张量“理论上最基础”,而是因为 miniLLM 的后面所有模块,本质上都在反复做同一件事:对一块连续的浮点内存,按照某个 shape 和 stride 规则去读、写、重排、相乘、归一化。只要你对这一层没有建立直觉,后面的 embedding、attention、FFN、甚至训练和缓存,看起来都会像不同风格的魔法;但一旦这一层清楚了,后面的复杂模块就会开始显得“不过是更大一点的张量操作组合”。
所以这章真正要建立的,不只是三个函数会不会写,而是一种观察方式:以后你每看到一个模块,都先问自己三件事。
- 输入和输出分别是什么 shape?
- 这些 shape 在内存里怎样排布?
- 这一段代码到底是在“按什么规则读数据、算数据、再写回数据”?
1.1 本章你要完成什么
这章结束后,你应当能做到下面几件事:
- 用自己的话解释
Tensor结构体里data、shape、strides、ndim、size各自表示什么。 - 看着一个二维张量,手算出任意元素的一维偏移。
- 明白为什么
student_tensor_get和student_tensor_set是第一批最值得亲手写的函数。 - 明白 softmax 为什么不能直接对原始输入做
expf,以及“先减最大值”到底解决了什么问题。 - 在
Lab01的实践框架中,把这三个函数补完,并看到全部测试转成[PASS]。
这五点里,前四点是理解,第五点是实践。缺一不可。只读懂不写,你的理解会很虚;只写出来不懂,你后面一到 attention 就会失速。
1.2 先看 practice target:你这章到底改哪里
正式进入概念之前,先把实践边界说清楚。
这一章不要去改 step0/src/tensor.c,也不要去改根目录 step0/src/math_ops.c。那些文件是项目里现成的实现,不是你本章的主工作区。
你这章真正应该进入的是:
course/practice/labs/lab01-step0/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前 lab 的设计非常克制。student.c 里只留了三个待完成函数:
student_tensor_getstudent_tensor_setstudent_softmax_stable
这三个函数之所以被选出来,不是偶然。它们刚好覆盖了 miniLLM 后面几乎所有运算的三个基本动作:
- 按索引规则读数据;
- 按索引规则写数据;
- 对一段数做数值稳定的归一化。
当你把这三个动作理解透,后面很多“高级模块”的底层直觉其实已经提前出现了。
1.3 为什么张量在工程里不是抽象名词,而是内存规则
很多初学者第一次听到“张量”,会下意识把它理解成一种很高级的数学对象。这个理解当然没错,但如果你现在停在这里,它对写 C 代码几乎没有帮助。
在工程里,尤其在像 miniLLM 这样纯 C 的项目里,张量首先不是一个抽象名词,而是一个非常朴素的问题:
我有一块连续的
float内存。我想把它看成 1 维、2 维、3 维甚至更多维的数据。那我该如何记录“第几个元素属于哪一行哪一列”,以及“给我一个多维坐标,我怎么快速找到它在这块线性内存里的位置”?
这就是 Tensor 结构体存在的理由。
看一下参考接口 step0/src/tensor.h,你会发现它的核心字段并不神秘:
typedef struct {
float* data;
int* shape;
int* strides;
int ndim;
int size;
} Tensor;
把这几个字段翻译成人话,大致是:
data:真正存数值的连续内存;shape:每一维有多长;strides:沿某一维走一步,在线性内存里要跳过多少个元素;ndim:总共有几维;size:所有元素加起来一共多少个。
这套设计非常重要,因为它把“多维世界”压扁成了“一个数组 + 一套索引规则”。而神经网络代码里几乎所有计算,本质上都建立在这套压扁规则之上。
1.4 从二维矩阵开始理解 stride
现在不要急着考虑高维。先把二维情况吃透。
假设你有一个 shape 为 [2, 3] 的张量,也就是两行三列。我们把它写成矩阵,看起来像这样:
[[a, b, c],
[d, e, f]]
但在内存里,它不是按“行和列”分开存的,而是按一条线排开:
[a, b, c, d, e, f]
这就叫 row-major,也就是行优先存储。对 C 语言背景的同学来说,这其实并不陌生,因为普通二维数组在底层也是类似思路。
问题来了:如果我现在问你要元素 [1, 2],也就是第二行第三列的那个值,你怎样从线性内存里定位它?
这时 stride 就登场了。
对 [2, 3] 这样的二维张量,stride 是 [3, 1]。它表示:
- 沿第 0 维,也就是“换一行”,你要在
data里跳过 3 个元素; - 沿第 1 维,也就是“换一列”,你只要往后走 1 个元素。
于是坐标 [i, j] 对应的一维偏移就是:
offset = i * strides[0] + j * strides[1]
对 [1, 2] 来说,就是:
offset = 1 * 3 + 2 * 1 = 5
而 data[5] 正好就是 f。
这一步必须理解得非常扎实。因为本章的前两个待实现函数,本质上都是同一件事:
get:算出 offset,再去读data[offset]set:算出 offset,再去写data[offset] = value
1.5 为什么这两个函数值得你亲手写一遍
你可能会想:这种函数看起来很简单,为什么不直接给学员现成答案?
原因恰恰在于它简单,但又“极具代表性”。
对于项目型课程来说,第一章最好不要让学员同时承受三种负担:
- 新概念太多;
- 代码范围太大;
- 验证链太长。
student_tensor_get 和 student_tensor_set 的好处是,它们在概念上足够小,在代码上足够短,在验证上又非常直接。你能非常快地看到:自己是不是算对了 offset,是不是理解了 row-major,是不是知道读和写其实共享同一套索引规则。
这会给后续章节一个很好的心理起点:原来所谓“写神经网络底层代码”,第一步也不过是把一个结构体和索引公式想清楚。
1.6 softmax 为什么会出现在第一章
看到 student_softmax_stable 时,很多人会有点意外:前两个函数都在讲张量索引,为什么第三个突然变成了 softmax?
这不是跳跃,而是刻意安排。
因为 stride 公式解决的是“如何访问数据”,但 miniLLM 后面还有另一个会反复出现的问题:如何在浮点数世界里稳定地计算概率分布。softmax 正是这个问题最经典、也最早出现的例子。
如果你以后实现 attention,会不断遇到“先算一堆分数,再把它们变成和为 1 的权重”。那套变换的核心就是 softmax。因此,把它提早放在第一章,有两个好处:
- 让你尽早建立“数值稳定性不是优化项,而是正确性的一部分”的意识;
- 让你第一次在一个很小的练习里,接触后面真正模型计算里会反复出现的模式。
1.7 softmax 的公式没有问题,问题出在数值
softmax 的数学定义很短:
softmax(x_i) = exp(x_i) / sum_j exp(x_j)
如果你只看纸面公式,会觉得这再自然不过。先把每个数做指数,再归一化成概率分布。
但工程实现的问题从来不在“公式看起来对不对”,而在“它在真实机器上的浮点表示里会不会炸”。
例如,假设输入是:
[1000, 1001, 1002]
从数学上看这完全合法,甚至很普通:第三个数最大,所以它的概率应该最大。
但从计算机执行 expf(1000) 的角度看,这就危险了。因为指数函数增长太快,直接 expf 很容易溢出,产生 inf,后续再做除法,就会把整个结果污染成 NaN 或其它无意义数值。
这就是为什么稳定实现要先减最大值。
把上面的输入变成:
[1000 - 1002, 1001 - 1002, 1002 - 1002]
= [-2, -1, 0]
再去做 expf,数值就安全得多了。而且 softmax 的最终结果不会变,因为分子分母同时乘上了同一个常数因子。
你可以把它理解成:我们没有改变这组三个数“谁比谁更大”的相对关系,只是把它们整体平移到了一个更适合浮点运算的坐标系里。
这就是 student_softmax_stable 真正要你学的东西。不是会背一个技巧,而是知道:
数学上等价,不代表数值上同样安全。工程实现必须同时尊重公式和机器。
1.8 先看一眼验证器,它到底在检查什么
在动手之前,建议你读一遍 course/practice/labs/lab01-step0/framework/verify.c。不需要逐行背,但要知道它在验证哪些事实。
当前 Lab01 的四个测试大致在检查:
- 你能不能从二维张量里按坐标正确读到值;
- 你能不能按坐标正确把值写回去;
- 你写的 softmax 输出是不是能归一化成和为 1 的分布;
- 你的 softmax 在大数输入下会不会溢出。
你会注意到,这些检查都非常“局部”,但非常有针对性。它们不是在问“你会不会写完整神经网络”,而是在问“本章最核心的三个动作,你到底会不会”。
这就是这种 lab 结构的价值:它让验证结果直接对准本章教学目标。
1.9 本章实践步骤
现在开始进入真正的操作。建议你边看这一节,边在终端里跟着做。
task 1.1:重新确认 baseline
进入实践目录:
cd course/practice/labs/lab01-step0
make clean && make test
如果你前面已经做过一次,这里看到的现象应当还是一样:
TEST 1FAILTEST 2FAILTEST 3FAILTEST 4PASS
这一步的作用不是浪费时间,而是确认你接下来每一次变化,都能和这次 baseline 对比。
task 1.2:实现 student_tensor_get
打开:
course/practice/labs/lab01-step0/framework/student.c
找到:
float student_tensor_get(Tensor* t, int* indices)
这里不要急着写很多保护逻辑。先把主干想清楚:
t和indices至少不能是空;- 对当前 lab,可以按二维张量来处理;
- 用
indices[0] * t->strides[0] + indices[1] * t->strides[1]算 offset; - 返回
t->data[offset]。
如果你写完以后 TEST 1 还没过,先别怀疑 softmax,也别怀疑 Makefile。先回到纸上,再手算一遍 [1, 2] 对应的 offset。
task 1.3:实现 student_tensor_set
第二个函数其实是第一个函数的镜像版本。
同样先算 offset,然后执行写入:
t->data[offset] = value;
这里最容易犯的错,不是公式写错,而是思维没有对齐:你在读数据时承认 stride 规则,在写数据时又试图用别的索引方式。这个 lab 正是在训练你形成一个一致的意识:
读和写不是两套规则,它们共享同一套内存定位逻辑。
task 1.4:实现 student_softmax_stable
最后一个函数建议分三段写,而不是一口气压成一团:
- 先扫描输入,找最大值;
- 再计算
expf(in[i] - max)并累加总和; - 最后做一次归一化,把每个
out[i]除以总和。
这类函数非常适合写得稍微“啰嗦一点”,因为可读性本身就是稳定性的一部分。你以后会越来越发现:在数值代码里,清晰的分步实现,往往比花哨的压缩写法更可靠。
task 1.5:重新验证
完成后重新执行:
make clean && make test
理想结果是:
[TEST 1] ... [PASS]
[TEST 2] ... [PASS]
[TEST 3] ... [PASS]
[TEST 4] ... [PASS]
All tests passed!
当你第一次看到这里全部转成 PASS,虽然代码量很小,但它的意义很大。因为这说明你已经完成了这门课的第一块真正可验证的工程积木。
1.10 常见错误与排查顺序
如果结果不对,按下面顺序排:
| 现象 | 更可能的问题 | 优先检查 |
|---|---|---|
TEST 1 失败 |
offset 公式错了,或没处理输入 | student_tensor_get |
TEST 2 失败 |
写回逻辑没用同一套 offset | student_tensor_set |
TEST 3 失败 |
没归一化,或输出顺序不对 | student_softmax_stable |
TEST 4 失败 |
没有先减最大值 | student_softmax_stable 第一步 |
这里最重要的排错原则是:不要同时改三处再赌一次结果。
一旦验证器已经把失败点切得这么细,你就应该一处一处地定位。学会尊重这种细粒度反馈,是后面做更复杂章节时很关键的工程习惯。
1.11 思考题
- 如果把
[2, 3]张量的 stride 错写成[2, 1],哪些坐标会最先读错?为什么? student_tensor_get和student_tensor_set现在只按二维张量写。如果以后要支持任意维度,你觉得最自然的推广方式是什么?- softmax 先减最大值后,为什么最终概率分布不变?这里真正保持不变的量是什么?
TEST 4只证明“不会溢出”,它有没有证明“概率一定正确”?如果没有,它没有覆盖哪一类错误?
这些问题不是装饰。它们会决定你后面看到 attention、layernorm、cross entropy 时,是把它们当作新名词,还是把它们看成“旧模式的新组合”。
1.12 本章小结
这一章你做的事情看起来不大,但它在课程结构里非常关键。
你第一次真正进入了本章实验代码,第一次修改了需要自己完成的文件,第一次通过自动验证器获得了高信噪比反馈。更重要的是,你开始建立了这门课后面最重要的三个底层直觉:
- 多维结构最终要落成线性内存;
- 正确的索引规则比表面 API 更重要;
- 数值计算要同时尊重数学等价和浮点稳定性。
这三点以后会反复出现。只是下一次,它们会披上更复杂的外衣。
1.13 通往下一章
下一章我们会从“如何在内存里放一个矩阵”走到“如何把文本放进模型”。也就是从张量基础走向 tokenizer。
在概念上,这是第一次把“人类能读的字符串”翻译成“模型能处理的离散 ID”;在实践上,这意味着你会第一次接触词表、特殊 token、编码和解码的一致性问题。