# llm.c 筆記 (0) 開發方式
TLDR. 先用 pytorch 簡單做出該層,並產生測資 ln.bin
再跑C實作,C實作讀取 ln.bin後計算並驗證,再輸出驗證結果。
```
import torch
eps = 1e-5
class LayerNorm:
@staticmethod
def forward(x, w, b):
B, T, C = x.size()
mean = x.sum(-1, keepdim=True) / C # B,T,1
xshift = x - mean # B,T,C
var = (xshift**2).sum(-1, keepdim=True) / C # B,T,1
rstd = (var + eps) ** -0.5 # B,T,1
norm = xshift * rstd # B,T,C
out = norm * w + b # B,T,C
cache = (x, w, mean, rstd)
return out, cache
@staticmethod
def backward(dout, cache):
x, w, mean, rstd = cache
# recompute the norm (save memory at the cost of compute)
norm = (x - mean) * rstd
# gradients for weights, bias
db = dout.sum((0, 1))
dw = (dout * norm).sum((0, 1))
# gradients for input
dnorm = dout * w
dx = dnorm - dnorm.mean(-1, keepdim=True) - norm * (dnorm * norm).mean(-1, keepdim=True)
dx *= rstd
return dx, dw, db
# create a small dummy example and check w.r.t PyTorch backward
B = 2
T = 3
C = 4
x = torch.randn(B, T, C, requires_grad=True)
w = torch.randn(C, requires_grad=True)
b = torch.randn(C, requires_grad=True)
out, cache = LayerNorm.forward(x, w, b)
dout = torch.randn(B, T, C)
dx, dw, db = LayerNorm.backward(dout, cache)
# compare to PyTorch autograd
fakeloss = (out * dout).sum()
fakeloss.backward()
print("dx error:", (x.grad - dx).abs().max().item())
print("dw error:", (w.grad - dw).abs().max().item())
print("db error:", (b.grad - db).abs().max().item())
# for reference checking in C also
x, w, mean, rstd = cache
def write(tensor, handle):
handle.write(tensor.detach().numpy().astype("float32").tobytes())
# Write to file
with open('ln.bin', 'wb') as file:
write(x, file) # (B, T, C)
write(w, file) # (C, )
write(b, file) # (C, )
write(out, file) # (B, T, C)
write(mean, file) # (B, T)
write(rstd, file) # (B, T)
write(dout, file) # (B, T, C)
write(dx, file) # (B, T, C)
write(dw, file) # (C, )
write(db, file) # (C, )
```
```
// must run `python layernorm.py` first to generate the reference data
// then compile for example as `gcc layernorm.c -o layernorm -lm`
// and then run as `./layernorm` to see the output
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
void layernorm_forward(float* out, float* mean, float* rstd,
float* inp, float* weight, float* bias,
int B, int T, int C) {
float eps = 1e-5f;
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
// seek to the input position inp[b,t,:]
float* x = inp + b * T * C + t * C;
// calculate the mean
float m = 0.0f;
for (int i = 0; i < C; i++) {
m += x[i];
}
m = m/C;
// calculate the variance (without any bias correction)
float v = 0.0f;
for (int i = 0; i < C; i++) {
float xshift = x[i] - m;
v += xshift * xshift;
}
v = v/C;
// calculate the rstd
float s = 1.0f / sqrtf(v + eps);
// seek to the output position in out[b,t,:]
float* out_bt = out + b * T * C + t * C;
for (int i = 0; i < C; i++) {
float n = (s * (x[i] - m)); // normalized output
float o = n * weight[i] + bias[i]; // scale and shift it
out_bt[i] = o; // write
}
// cache the mean and rstd for the backward pass later
mean[b * T + t] = m;
rstd[b * T + t] = s;
}
}
}
void layernorm_backward(float* dinp, float* dweight, float* dbias,
float* dout, float* inp, float* weight, float* mean, float* rstd,
int B, int T, int C) {
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
float* dout_bt = dout + b * T * C + t * C;
float* inp_bt = inp + b * T * C + t * C;
float* dinp_bt = dinp + b * T * C + t * C;
float mean_bt = mean[b * T + t];
float rstd_bt = rstd[b * T + t];
// first: two reduce operations
float dnorm_mean = 0.0f;
float dnorm_norm_mean = 0.0f;
for (int i = 0; i < C; i++) {
float norm_bti = (inp_bt[i] - mean_bt) * rstd_bt;
float dnorm_i = weight[i] * dout_bt[i];
dnorm_mean += dnorm_i;
dnorm_norm_mean += dnorm_i * norm_bti;
}
dnorm_mean = dnorm_mean / C;
dnorm_norm_mean = dnorm_norm_mean / C;
// now iterate again and accumulate all the gradients
for (int i = 0; i < C; i++) {
float norm_bti = (inp_bt[i] - mean_bt) * rstd_bt;
float dnorm_i = weight[i] * dout_bt[i];
// gradient contribution to bias
dbias[i] += dout_bt[i];
// gradient contribution to weight
dweight[i] += norm_bti * dout_bt[i];
// gradient contribution to input
float dval = 0.0f;
dval += dnorm_i; // term 1
dval -= dnorm_mean; // term 2
dval -= norm_bti * dnorm_norm_mean; // term 3
dval *= rstd_bt; // final scale
dinp_bt[i] += dval;
}
}
}
}
// poor man's tensor checker
int check_tensor(float *a, float *b, int n, char* label) {
int ok = 1;
printf("%s\n", label);
for (int i = 0; i < n; i++) {
if (fabs(a[i] - b[i]) <= 1e-5) {
printf("OK ");
} else {
printf("NOT OK ");
ok = 0;
}
printf("%f %f\n", a[i], b[i]);
}
return ok;
}
int main() {
int B = 2; // batch
int T = 3; // time / sequence length
int C = 4; // number of channels
float* x = (float*) malloc(B * T * C * sizeof(float));
float* w = (float*) malloc(C * sizeof(float));
float* b = (float*) malloc(C * sizeof(float));
float* out = (float*) malloc(B * T * C * sizeof(float));
float* mean = (float*) malloc(B * T * sizeof(float));
float* rstd = (float*) malloc(B * T * sizeof(float));
float* dout = (float*) malloc(B * T * C * sizeof(float));
float* dx = (float*) malloc(B * T * C * sizeof(float));
float* dw = (float*) malloc(C * sizeof(float));
float* db = (float*) malloc(C * sizeof(float));
// read reference information from Python
FILE *file = fopen("ln.bin", "rb");
if (file == NULL) {
printf("Error opening file\n");
return 1;
}
fread(x, sizeof(float), B * T * C, file);
fread(w, sizeof(float), C, file);
fread(b, sizeof(float), C, file);
fread(out, sizeof(float), B * T * C, file);
fread(mean, sizeof(float), B * T, file);
fread(rstd, sizeof(float), B * T, file);
fread(dout, sizeof(float), B * T * C, file);
fread(dx, sizeof(float), B * T * C, file);
fread(dw, sizeof(float), C, file);
fread(db, sizeof(float), C, file);
fclose(file);
// now let's calculate everything ourselves
// forward pass
float* c_out = (float*) malloc(B * T * C * sizeof(float));
float* c_mean = (float*) malloc(B * T * sizeof(float));
float* c_rstd = (float*) malloc(B * T * sizeof(float));
layernorm_forward(c_out, c_mean, c_rstd, x, w, b, B, T, C);
// check correctness of forward pass
check_tensor(out, c_out, B*T*C, "out");
check_tensor(mean, c_mean, B*T, "mean");
check_tensor(rstd, c_rstd, B*T, "rstd");
// backward pass (note calloc inits grads to zero)
float* c_dx = (float*) calloc(B * T * C, sizeof(float));
float* c_dw = (float*) calloc(B * T, sizeof(float));
float* c_db = (float*) calloc(B * T, sizeof(float));
layernorm_backward(c_dx, c_dw, c_db, dout, x, w, c_mean, c_rstd, B, T, C);
// check correctness of backward pass
check_tensor(c_dx, dx, B*T*C, "dx");
check_tensor(c_dw, dw, C, "dw");
check_tensor(c_db, db, C, "db");
free(x);
free(w);
free(b);
free(out);
free(mean);
free(rstd);
free(dout);
free(dx);
free(dw);
free(db);
return 0;
}
```
```
LayerNorm 快速教程。
讓我們來看看如何處理 LayerNorm,作為模型中的一個例子層。
我們從 PyTorch 的 LayerNorm 文檔開始。
LayerNorm 當然是源自 Ba 等人在 2016 年的原始論文,
並被納入 Vaswani 等人的著名論文《Attention is All You Need》中的 Transformer。
GPT-2 採用了與 Transformer 相同的架構,
但 LayerNorm 的位置被著名地移至現在稱為預標準化版本的位置。
也就是說,Transformer 的殘差路徑保持清潔,LayerNorms 現在是 Transformer
每個塊的第一層。這顯著改善了訓練穩定性。
當查看 PyTorch 的 LayerNorm 時,
首先要注意的是,你很可能找不到方程式的實際實現。
這是因為它深藏在代碼的第 30 層,背後是一個難以理解的動態分配器,
在一些可能自動生成的 CUDA 代碼中
(有興趣的可以查看 layer_norm.cpp 和 layer_norm_kernel.cu)。PyTorch 真的非常關心效率,
這是可以理解的。
但對於我們的目的來說,
我們首先要使用較簡單的 PyTorch 操作手動實現 LayerNorm。
這會比直接使用 LayerNorm 模塊的效率低得多,
但在演算法上具有教學意義。這是使用較簡單的 PyTorch 操作直接實現 LayerNorm 數學公式的方法:
import torch
eps = 1e-5
class LayerNorm:
@staticmethod
def forward(x, w, b):
# x 是輸入激活值,形狀為 B,T,C
# w 是權重,形狀為 C
# b 是偏差,形狀為 C
B, T, C = x.size()
# 計算平均值
mean = x.sum(-1, keepdim=True) / C # B,T,1
# 計算方差
xshift = x - mean # B,T,C
var = (xshift**2).sum(-1, keepdim=True) / C # B,T,1
# 計算逆標準差:**0.5 是平方根, **-0.5 是 1/平方根
rstd = (var + eps) ** -0.5 # B,T,1
# 正常化輸入激活值
norm = xshift * rstd # B,T,C
# 在最後對正常化的激活值進行縮放和移位
out = norm * w + b # B,T,C
# 返回輸出和 cache,cache 中包含之後反向傳播過程中需要的變數
cache = (x, w, mean, rstd)
return out, cache
這裡我們用一些隨機數來進行這一層的前向傳播:
B = 2 # 這裡有一些玩具數字
T = 3
C = 4
x = torch.randn(B, T, C, requires_grad=True)
w = torch.randn(C, requires_grad=True)
b = torch.randn(C, requires_grad=True)
out, cache = LayerNorm.forward(x, w, b)
我們得到的輸出是形狀為 B,T,C 的張量 out,其中每個 C 維的“纖維”(我們稱之為激活)都被正常化,然後縮放,最後還被這一層的權重和偏差移位。重要的是,我們還返回了一個變數 cache,它是一個包含輸入激活值 x、權重 w、平均值 mean 和倒數標準差 rstd 的元組。這些都是我們在反向傳播過程中需要的變數。
PyTorch 當然可以為我們進行這一層的反向傳播:
dout = torch.randn(B, T, C)
fakeloss = (out * dout).sum()
fakeloss.backward()
你可以看到,我們創建了一個 fakeloss,
它簡單地將所有 layernorm 的輸出的(隨機)加權組合起來。
這就是將所有 B,T,C 數字投影到一個單一的標量值(損失)上,
這樣我們就有了我們“計算圖”的一個單一輸出。
通常這將是模型的損失,但在這裡我們只是進行一個假的損失。
然後我們在這個標量上調用 backward(),PyTorch 將為我們計算所有輸入到這個圖的梯度——即輸入激活值 x、權重 w 和偏差 b。
如果你對 autograd 不太熟悉,我建議你觀看我的 micrograd 視頻,
在那裡我們構建了一個微小的 autograd 引擎。
所以 PyTorch autograd 的神奇之處在於,
在我們調用 .backward 之後,它將用損失相對於該張量的梯度填充所有具有 requires_grad=True 的張量的 .grad 屬性。
這些梯度告訴我們 x、w、b 中所有輸入數字的損失斜率。因此,x.grad、w.grad 和 b.grad 的形狀與 x、w 和 b 的形狀完全相同。
但我們不想使用 PyTorch Autograd。
我們希望手動進行反向傳播。因此,我們拿出筆和紙,
寫出 LayerNorm 的表達式。前向傳播有以下數學形式:
其中:
是逐元素乘法,
是平均值,
是方差,以及
是一個小常數,以避免除以零。根據微積分的微分規則,
我們現在想要導出梯度。對於這部分,我的視頻《成為反向傳播忍者》
可能非常有幫助,因為我詳細地處理了一個類似的層——批量歸一化層。
當你處理微分時,你會注意到表達式在分析上簡化了,
你可以移動條款並簡化表達式。所以你不必手動反向每一行在前向傳播中。
特別是,我們得到:
@staticmethod
def backward(dout, cache):
x, w, mean, rstd = cache
# 重新計算 norm(以計算代價節省記憶體)
norm = (x - mean) * rstd
# 權重、偏差的梯度
db = dout.sum((0, 1))
dw = (dout * norm).sum((0, 1))
# 輸入的梯度
dnorm = dout * w
dx = dnorm - dnorm.mean(-1, keepdim=True) - norm * (dnorm * norm).mean(-1, keepdim=True)
dx *= rstd
return dx, dw, db
因此,根據儲存在 dout 中的每個個別輸出數字的梯度和前向傳播的緩存,
我們現在可以通過這一層反向傳播到輸入中,
以繼續反向傳播的鏈式規則。
所以現在我們可以進行我們自己的反向傳播並看到它們匹配(誤差很小):
dx, dw, db = LayerNorm.backward(dout, cache)
print("dx error:", (x.grad - dx).abs().max().item())
print("dw error:", (w.grad - dw).abs().max().item())
print("db error:", (b.grad - db).abs().max().item())
注意一件事。在反向傳播中,我们重新计算了变量 norm。
我们已经在前向传播中计算过这个变量,但之后我们把它丢掉了!
我们其实可以把这也作为缓存的一部分,以节省这次重新计算?
实际上我们完全可以这样做,你当然会得到完全相同的结果。
我们保存到我们缓存中的东西完全取决于我们。
我们甚至不必保存平均值和 rstd,我们可以在反向传播中重新计算它们。
区别在于平均值和 rstd 都非常小,只有 B,T 的形状,
而 norm 的形状是 B,T,C。所以这只是内存和计算之间的一种权衡。
通过不保持 norm 在缓存中,我们节省了内存,
但我们在反向传播中用了一点计算来换取。这在所有层中都很常见,
你会看到深度学习框架中各种层的不同实现可能都有不同的“检查点设置”。
是的,令人困惑的是,这被称为检查点,并且与将模型权重保存到磁盘无关。
这是关于在前向传播中保存中间变量以节省在反向传播中的计算。
好了,这就是使用 PyTorch 张量的版本。
现在我们必须将这转移到 C 语言并去掉 Tensor 抽象。
在给你完整的前向传播实现之前,简单谈谈 Tensors。
什么是 Tensors?它们是 1) 一个称为 Storage 的 1D 内存块,
用来存储原始数据,以及 2)
一个在该存储上保持其形状的 View。
PyTorch 内部机制在这里可能会有帮助。例如,如果我们有 3D 张量:
torch.manual_seed(42)
B, T, C = 2, 3, 4
a = torch.randn(B, T, C)
print(a)
tensor([[[ 1.9269, 1.4873, 0.9007, -2.1055],
[ 0.6784, -1.2345, -0.0431, -1.6047],
[ 0.3559, -0.6866, -0.4934, 0.2415]],
[[-1.1109, 0.0915, -2.3169, -0.2168],
[-0.3097, -0.3957, 0.8034, -0.6216],
[-0.5920, -0.0631, -0.8286, 0.3309]]])
这是一个 2x3x4 的张量,
但它的底层内存只是一个大小为 234=24 的单一 1D 数组。View 只是这个 1D 数组上的一个形状。
所以现在当我们索引这个 PyTorch 张量,
例如 a[1,2,3],PyTorch
计算到 1D 数组的偏移量为 134 + 24 + 3 = 23,
并返回该偏移量的值。一般的公式是,
如果你想检索任何元素 b,t,c,
你计算到 Storage 的偏移量为 bTC + tC + c。例如:
b,t,c = 1,2,3
print(a[b,t,c])
print(a.view(-1)[b*T*C + t*C + c])
这两个都打印 0.3309。所以这样,我们就知道如何访问所有个别元素,
并如何偏移所有指针。特别注意的是,通道维度是最内层维度。
所以当我们增加偏移量时,我们正在遍历通道维度。
这对我们 C 实现的内存布局很重要。相当于在 C 中的前向传播变成:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
void layernorm_forward(float* out, float* mean, float* rstd,
float* inp, float* weight, float* bias,
int B, int T, int C) {
float eps = 1e-5f;
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
// 定位到输入位置 inp[b,t,:]
float* x = inp + b * T * C + t * C;
// 计算平均值
float m = 0.0f;
for (int i = 0; i < C; i++) {
m += x[i];
}
m = m/C;
// 计算方差(不进行任何偏差修正)
float v = 0.0f;
for (int i = 0; i < C; i++) {
float xshift = x[i] - m;
v += xshift * xshift;
}
v = v/C;
// 计算 rstd
float s = 1.0f / sqrtf(v + eps);
// 定位到输出位置 out[b,t,:]
float* out_bt = out + b * T * C + t * C;
for (int i = 0; i < C; i++) {
float n = (s * (x[i] - m)); // 正规化输出
float o = n * weight[i] + bias[i]; // 缩放并移位
out_bt[i] = o; // 写入
}
// 缓存平均值和 rstd 以备后续反向传播使用
mean[b * T + t] = m;
rstd[b * T + t] = s;
}
}
}
你会看到我如何偏移指针到 inp[b,t],然后你知道接下来的 C 元素是该位置(批次,时间)的通道。反向传播:
void layernorm_backward(float* dinp, float* dweight, float* dbias,
float* dout, float* inp, float* weight, float* mean, float* rstd,
int B, int T, int C) {
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
float* dout_bt = dout + b * T * C + t * C;
float* inp_bt = inp + b * T * C + t * C;
float* dinp_bt = dinp + b * T * C + t * C;
float mean_bt = mean[b * T + t];
float rstd_bt = rstd[b * T + t];
// 首先:两个 reduce 操作
float dnorm_mean = 0.0f;
float dnorm_norm_mean = 0.0f;
for (int i = 0; i < C; i++) {
float norm_bti = (inp_bt[i] - mean_bt) * rstd_bt;
float dnorm_i = weight[i] * dout_bt[i];
dnorm_mean += dnorm_i;
dnorm_norm_mean += dnorm_i * norm_bti;
}
dnorm_mean = dnorm_mean / C;
dnorm_norm_mean = dnorm_norm_mean / C;
// 现在再次迭代并累积所有梯度
for (int i = 0; i < C; i++) {
float norm_bti = (inp_bt[i] - mean_bt) * rstd_bt;
float dnorm_i = weight[i] * dout_bt[i];
// 对偏差的梯度贡献
dbias[i] += dout_bt[i];
// 对权重的梯度贡献
dweight[i] += norm_bti * dout_bt[i];
// 对输入的梯度贡献
float dval = 0.0f;
dval += dnorm_i; // 第 1 项
dval -= dnorm_mean; // 第 2 项
dval -= norm_bti * dnorm_norm_mean; // 第 3 项
dval *= rstd_bt; // 最后的缩放
dinp_bt[i] += dval;
}
}
}
}
还有一个细节需要注意,我们总是 += 进入梯度。我们从不使用 =,
也不使用 *=。这在风格上很重要,
因为如果你有一个变量在图中多次使用,反向传播的梯度总是累加。
在这个仓库中这不重要,因为我们没有复杂的分支,但这是正确的。
所以在训练中我们总是先做 zero_grad 把所有梯度设置为零,
然后在反向传播中累积它们。
关于训练和推理之间的差异的另一点说明。
你们中的一些人可能已经看过我之前的项目 llama2.c,
它在纯 C 中推理 Llama 2 架构。
与 GPT-2 不同,Llama 2 用更简单的 RMSNorm 替换了 LayerNorm。
你可以在 llama2.c 中看到 RMSNorm 的实现,这里复制粘贴:
void rmsnorm(float* o, float* x, float* weight, int size) {
// 计算平方和
float ss = 0.0f;
for (int j = 0; j < size; j++) {
ss += x[j] * x[j];
}
ss /= size;
ss += 1e-5f;
ss = 1.0f / sqrtf(ss);
// 正规化并缩放
for (int j = 0; j < size; j++) {
o[j] = weight[j] * (ss * x[j]);
}
}
这与我们上面的 LayerNorm 有何不同?
首先,从算法上看,你会注意到 RMSNorm 不追踪或减去平均值,
它只通过 norm 正规化。注意:norm,不是标准差,
因为我们没有减去平均值。这是对层的一种简化,现在已经变得非常流行,
因为它的效果一样好,如果不是更好的话。
此外,RMSNorm 没有偏差,它只有一个权重用于正规化后的缩放。
一般来说,GPT-2 在各处使用了太多的偏差,
事实证明你可以去掉这些——从所有线性层和 LayerNorms 中去掉。
网络可以“模拟”偏差,如果需要的话,
例如通过分配一个通道维度为常数(数据无关),
然后任何乘
以这个常数维度的权重实际上就像偏差一样工作。这大大简化了很多代码。
第二,推理代码没有批次维度 B,即假设批次大小为 1。
原则上,你也可以进行批量推理,
特别是如果你希望托管一个你期望许多同时查询的 LLM。
但如果你只是在本地运行一个 LLM,你可能只想有一个“生成流”,所以没有并行支持多个流的批次大小。为了保持简单,llama2.c 没有批次化,
因此你不会看到像 for (int b = 0; b < B; b++) 这样的循环。
第三,这个推理代码在这个单独的层内没有时间维度 T。在训练期间,
我们可以在每个层内循环时间并计算所有时间步的 layernorm。但在推理时,我们必须一次生成一个令牌,将在时间 t 预测的令牌输入到下一个时间步骤 t+1 的 Transformer 前向传播中。所以这就是为什么你不会看到像 for (int t = 0; t < T; t++) 这样的循环在单独层内部。这种时间循环确实存在,但它在 Transformer 前向传播的外部。
你会看到我们没有跟踪任何中间计算、内存或缓存。这是因为在推理中,之后不会有 .backward 传递。我们只需要计算输出,不需要保留任何中间变量。因此,推理的内存消耗显著低于训练。我们可以负担得起只丢弃激活,并只保留“激活前沿”的内存。同样,没有必要为这个 RMSNorm 实现反向功能,因为没有反向传递。
由于所有这些差异,训练在算法和计算上都显著更复杂和复杂,这也是我为什么先写推理(llama2.c)再实现训练(llm.c,这里)的部分原因。最后,我在同一目录下附上两个辅助文件,包含完整的代码。首先:
python layernorm.py
用来写出来自 PyTorch 的参考数据。然后编译并运行 C 版本:
gcc layernorm.c -o layernorm -lm
./layernorm
你会看到一切都匹配得很好。
这只是 LayerNorm 的教程。
我们对所有其他层也进行了完全相同的过程。
大多数其他层实际上比 LayerNorm 更容易。希望这有帮助!
```