Chapter 1 — step0 张量与数学运算

对应实践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、甚至训练和缓存,看起来都会像不同风格的魔法;但一旦这一层清楚了,后面的复杂模块就会开始显得“不过是更大一点的张量操作组合”。

所以这章真正要建立的,不只是三个函数会不会写,而是一种观察方式:以后你每看到一个模块,都先问自己三件事。

  1. 输入和输出分别是什么 shape?
  2. 这些 shape 在内存里怎样排布?
  3. 这一段代码到底是在“按什么规则读数据、算数据、再写回数据”?

1.1 本章你要完成什么

这章结束后,你应当能做到下面几件事:

  1. 用自己的话解释 Tensor 结构体里 datashapestridesndimsize 各自表示什么。
  2. 看着一个二维张量,手算出任意元素的一维偏移。
  3. 明白为什么 student_tensor_getstudent_tensor_set 是第一批最值得亲手写的函数。
  4. 明白 softmax 为什么不能直接对原始输入做 expf,以及“先减最大值”到底解决了什么问题。
  5. 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_get
  • student_tensor_set
  • student_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_getstudent_tensor_set 的好处是,它们在概念上足够小,在代码上足够短,在验证上又非常直接。你能非常快地看到:自己是不是算对了 offset,是不是理解了 row-major,是不是知道读和写其实共享同一套索引规则。

这会给后续章节一个很好的心理起点:原来所谓“写神经网络底层代码”,第一步也不过是把一个结构体和索引公式想清楚。

1.6 softmax 为什么会出现在第一章

看到 student_softmax_stable 时,很多人会有点意外:前两个函数都在讲张量索引,为什么第三个突然变成了 softmax?

这不是跳跃,而是刻意安排。

因为 stride 公式解决的是“如何访问数据”,但 miniLLM 后面还有另一个会反复出现的问题:如何在浮点数世界里稳定地计算概率分布。softmax 正是这个问题最经典、也最早出现的例子。

如果你以后实现 attention,会不断遇到“先算一堆分数,再把它们变成和为 1 的权重”。那套变换的核心就是 softmax。因此,把它提早放在第一章,有两个好处:

  1. 让你尽早建立“数值稳定性不是优化项,而是正确性的一部分”的意识;
  2. 让你第一次在一个很小的练习里,接触后面真正模型计算里会反复出现的模式。

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 的四个测试大致在检查:

  1. 你能不能从二维张量里按坐标正确读到值;
  2. 你能不能按坐标正确把值写回去;
  3. 你写的 softmax 输出是不是能归一化成和为 1 的分布;
  4. 你的 softmax 在大数输入下会不会溢出。

你会注意到,这些检查都非常“局部”,但非常有针对性。它们不是在问“你会不会写完整神经网络”,而是在问“本章最核心的三个动作,你到底会不会”。

这就是这种 lab 结构的价值:它让验证结果直接对准本章教学目标。

1.9 本章实践步骤

现在开始进入真正的操作。建议你边看这一节,边在终端里跟着做。

task 1.1:重新确认 baseline

进入实践目录:

cd course/practice/labs/lab01-step0
make clean && make test

如果你前面已经做过一次,这里看到的现象应当还是一样:

  • TEST 1 FAIL
  • TEST 2 FAIL
  • TEST 3 FAIL
  • TEST 4 PASS

这一步的作用不是浪费时间,而是确认你接下来每一次变化,都能和这次 baseline 对比。

task 1.2:实现 student_tensor_get

打开:

course/practice/labs/lab01-step0/framework/student.c

找到:

float student_tensor_get(Tensor* t, int* indices)

这里不要急着写很多保护逻辑。先把主干想清楚:

  1. tindices 至少不能是空;
  2. 对当前 lab,可以按二维张量来处理;
  3. indices[0] * t->strides[0] + indices[1] * t->strides[1] 算 offset;
  4. 返回 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

最后一个函数建议分三段写,而不是一口气压成一团:

  1. 先扫描输入,找最大值;
  2. 再计算 expf(in[i] - max) 并累加总和;
  3. 最后做一次归一化,把每个 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 思考题

  1. 如果把 [2, 3] 张量的 stride 错写成 [2, 1],哪些坐标会最先读错?为什么?
  2. student_tensor_getstudent_tensor_set 现在只按二维张量写。如果以后要支持任意维度,你觉得最自然的推广方式是什么?
  3. softmax 先减最大值后,为什么最终概率分布不变?这里真正保持不变的量是什么?
  4. TEST 4 只证明“不会溢出”,它有没有证明“概率一定正确”?如果没有,它没有覆盖哪一类错误?

这些问题不是装饰。它们会决定你后面看到 attention、layernorm、cross entropy 时,是把它们当作新名词,还是把它们看成“旧模式的新组合”。

1.12 本章小结

这一章你做的事情看起来不大,但它在课程结构里非常关键。

你第一次真正进入了本章实验代码,第一次修改了需要自己完成的文件,第一次通过自动验证器获得了高信噪比反馈。更重要的是,你开始建立了这门课后面最重要的三个底层直觉:

  • 多维结构最终要落成线性内存;
  • 正确的索引规则比表面 API 更重要;
  • 数值计算要同时尊重数学等价和浮点稳定性。

这三点以后会反复出现。只是下一次,它们会披上更复杂的外衣。

1.13 通往下一章

下一章我们会从“如何在内存里放一个矩阵”走到“如何把文本放进模型”。也就是从张量基础走向 tokenizer。

在概念上,这是第一次把“人类能读的字符串”翻译成“模型能处理的离散 ID”;在实践上,这意味着你会第一次接触词表、特殊 token、编码和解码的一致性问题。

继续阅读:Chapter 2
对应实践:Lab02