极简Transformer实现(基于llama2.c commit-b3c4b6c 24'2/13)
与早期版本(详情可以参考这里:4e23ad8 23'7/27)不同,目前的版本有两种模式,一是generate, 根据给定的prompt生成一个简短的英文小故事。二是chat, 需要输入system prompt和问题,生成一个回答并退出,没有多轮对话,并且回答与问题没有太大关联性而且有不少问题(尤其是15M模型),所以该模式还不完善,并且有一个 feature/chat 分支处于开发中有兴趣可以关注该分支。我们主要关注代码更新的部分。
执行流程
该版本代码进行了重构,sampler封装到结构体里,其次把分阶段的执行流程各自封装到函数里面,调用逻辑会更清晰一些。同样的,main
主要处理命令行参数,然后是模型推理流程。
main
执行流程
- 解析命令行参数,包括 checkpoint 文件路径, temperature, steps等。
- 调用
build_transformer
,会调用read_checkpoint
加载checkpoint 文件内容,包括模型配置和权重。其次调用malloc_run_state
初始化运行时变量,包括kv cache等。注意,这里没有再对s->k
,s->v
分配内存,直接指向kv cache相应的位置,省掉一次内存拷贝操作。 - 调用
build_tokenizer
,加载tokenizer.bin
。 - 调用
build_sampler
,初始化采样对象。 - 接下来就是根据给定运行模式,调用
generate
或者chat
执行生成或问答流程。 - 内存清理并退出
generate
计算过程:
- 首先调用
encode
,把用户输入的prompt编码为token,存储到prompt_tokens
,维度为(strlen(prompt)+3,)
的int数组。 - 然后从
prompt_tokens
的第一个token开始(通常是BOS)执行主循环生成文本,即根据给定的**seq_len(steps)**执行循环[0, steps]
:- 调用
forward
得到当前位置pos
的logits
- 如果当前词元还是prompt,则从prompt里面取下一个token
- 否则,根据logits调用
sample
采样得到下一个token
- 如果下一个token是BOS,则终止循环。
- 调用
decode
从tokenizer里面解码得到token对应的单词,并打印。 - 继续下一个循环
- 调用
- 生成完毕,释放
prompt_tokens
临时堆内存
chat
Todo:
forward
模型推理的计算都封装到该函数里了,具体计算过程与4e23ad8 23'7/27里面的transformer
基本类似,差异是增加了对**Multi-Query Attention(MQA)和Grouped-Query Attention(GQA)**的支持。
先看下MHA
、MQA
及GQA
之间的区别,

在该代码实现里,
- 如果
n_kv_heads==n_heads
则为MHA,每个query都对应一组独立的key和value。 - 如果
n_kv_heads==1
则为MQA,所有query对应一组key和value。 - 否则
1<n_kv_heads<n_heads
则为GQA,多个query对应一组key和value,并且不止一组。
具体的代码实现流程如下:
- 初始化局部变量,新增
kv_dim = dim/n_heads * n_kv_heads = head_dim * n_kv_heads
,因为要跟query保持一致,所以kv head的head_dim
也是head_dim = dim/n_heads
固定的与n_kv_heads
无关。补充一下,这里kv_dim
代表每个token对应在kv cache里面的偏移大小或者说是每个token的dim维度,具体可以参考后面kv_cache内存结构。接下来进入正题,从token_embedding_table
中取出当前token的embedding(dim, )
放入s->x
中作为模型输入。 - 循环遍历每一层
for l in 0..n_layers
:- 对
s->x
计算rmsnorm
并存入s->xb
;计算该层kv cache偏移,loff=l*seq_len*kv_dim
,如上分析,kv_dim就是每个token的维度。 - 计算 Q、K、V,结果直接存入
s->q
,s->k
,s->v
,而后两者是指向kv cache当前位置的,所以省掉一次内存拷贝。 - 计算
RoPE
编码的Q、K,结果存入s->q
,s->k
。注意这里的改进,多头合在一起一并计算,效率更高。 - 计算多头注意力,循环遍历每个头
for h in 0..n_heads
:- Q起始位置,Q原始维度
(dim, )
拆分多头后为(n_heads, head_dim)
,所以第h
头的起始位置为**float* q = s->q + h * head_size
- atten score
(n_heads, seq_len)
,因为是按头存放的,所以在第h
头的起始位置就是s->att + h * p->seq_len
- kv cache在
dim
维度拆分多头,并考虑MQA/GQA
后维度为(layer, seq_len, n_kv_heads, head_dim)
或者简化为(layer, seq_len, kv_dim)
,因为n_kv_heads <= n_heads
要均分共享,这里的关键是需要计算query的第h个head对应的kv cache偏移在哪里。代码实现里面有个小技巧,我们先来看sequence维度,t
位置的偏移为t * kv_dim
,这个好理解,前面已经讲过,然后再计算kv_dim
里面的偏移是多少,这里会用到临时变量kv_mul = n_kv_heads / n_heads
, 则query第h
头在第t
个token维度内也就是kv_dim
内,偏移为(h/kv_mul) * head_size
。 - 计算该词元对它之前所有词元的注意力分数(包含自己),其实就是masked attention,
for t in 0..pos+1
:- 第
t
个词元对应在第h
头内的偏移为t * kv_dim
,这个好理解,前面已经讲过。综上,t
位置的cache为s->key_cache + loff + (h/kv_mul) * head_size + t * kv_dim
。 - 计算 $att(t)=\sum_{i=0}^{head\_dim}Q_i*K_i/\sqrt{head\_dim}$ ,其维度为
(seq_len, )
,但因为仅仅[0, pos]
的值为有效的,也即每个已生成的词元都有一个分数。(注意,att数组内容在计算下一个token时被重新填充)
- 第
- att经过softmax转换为注意力权重 $att=softmax(att)$
- 接下来一步,是把每个词元的
V
向量与其对应的att
值相乘,然后加总形成一个新的V
向量,存储到s->xb
中,计算公式为 $\hat{V}=\sum_{t=0}^{pos}att(t)*V(t)$,其维度为(head_dim,)
与V
相同,所以head计算完成之后,维度为(dim,)
至此,多头循环结束。
- Q起始位置,Q原始维度
- 注意力最后一步,与 $W^O$ 矩阵相乘,得到该层最终的注意力输出向量 (dim, ),存储到
s->xb2
- 接下来是 residual 的
Add&Norm
,elemwise add 和 rmsnorm,结果存入s->xb
- 接下来是FFN网络计算:
self.w2(F.silu(self.w1(x)) * self.w3(x))
s->hb=matmul(w->w1, s->xb)
s->hb2=matmul(w->w3, s->xb)
silu(x)=x*σ(x)
fors->hb
- elemwise mul
s->hb * s->hb2
- matmul,得到FFN输出,存储在
s->xb
- layer计算最后一步,residual 的
Add&Norm
,注意该实现中rmsnorm放到了layer循环的最开始,这样除最后一层外都会residual add之后执行rmsnorm,而最后一层则放在classifier 的rmsnorm。至此,layer循环结束
- 对
- Classifier 前面的 final rmsnorm, 就是针对最后一层residual的结果做rmsnorm,存入
s->x
b 中 - classifier into logits ,是个Linear层,从(dim,) → (vocab_size,) 的转换, 就是一个matmul: (vacob_size, dim) * (dim,1) → (vocab_size, 1) 结果就是logits,对应字典里每个token的分数。
其它函数
build_transformer
做了两件事情:
- 读取
checkpoint
文件并解析模型配置参数和权重参数,存入结构体变量t
里。checkpoint
文件结构详见上一篇文章。 - 初始化运行时的
RunState
结构体,分配内存空间,包括模型输入输出,attention分数,kv cache等。注:
t->s->k
和t->s->v
不分配内存空间,计算是直接读取kv cache对应的内存区域。这个跟之前的版本有区别。
164 | void build_transformer(Transformer *t, char* checkpoint_path) { |
build_tokenizer
tokenizer文件结构详见后面章节,注意跟之前的版本有差别且不兼容。此外新增了
vocab_scores
维度(vocab_size,)
byte_pieces
维度(512,)
,初始化为 0~255 字符每个都带’\0’结尾隔开,总共512个字符。
该函数代码基本上就是文件和内存操作,初始化Tokenizer
结构体,具体来说就是把tokenizer.bin
文件读取到内存中,总共32k
个词元字符串,每个词元开辟一块字符串数组内存区域单独存储,所有字符串指针放到一个数组里,可以按顺序遍历,也就是给定token,作为index即可取出对应词元的字符串。
文件结构详细信息可参考上一篇文章。
364 | // ---------------------------------------------------------------------------- |
385 | void build_tokenizer(Tokenizer*t, char* tokenizer_path, int vocab_size) { |
build_sampler
该版本将logits
到文本字符串的采样过程重新做了封装,相关参数放到了Sampler
结构体里。其次新增了ProbIndex
以支持top-p采样方式,总共支持三种采样方式:
- 贪心算法,argmax取最大值
- 随机采样
- topp采样
573 | // ---------------------------------------------------------------------------- |
该函数初始化结构体及分配内存。probindex
是维度为(vocab_size,)
的ProbIndex
结构体数组。
667 | void build_sampler(Sampler*sampler, int vocab_size, float temperature, float topp, unsigned long long rng_seed) { |
encode
Todo:
decode
解码过程就是token:int -> vocab:str
的转换,此处处理了空格及一些特殊符号。
418 | char* decode(Tokenizer* t, int prev_token, int token) { |
sample
根据temperature、topp等超参数,将推理输出结果logits
采样得到token id。根据超参数值的不同,支持三种采样方式:
temperature == 0.0f
,则采用贪心算法,取argmax最大值,返回sample_argmax
结果topp<=0 || topp>=1
,随机采样,返回sample_mult
结果- 否则 top-p 采样,返回
sample_topp
结果
691 | int sample(Sampler*sampler, float* logits) { |
sample_argmax
循环遍历所有元素,返回最大值的索引。
590 | int sample_argmax(float* probabilities, int n) { |
sample_mult
累加结果超过给定随机值,就返回当前的索引。
603 | int sample_mult(float* probabilities, int n, float coin) { |
sample_topp
$$
topp(p) = \max{k : \sum_{i=1}^{k} P(w_i) \leq p}
$$
计算过程:
- 首先设定阈值为
float cutoff = (1.0f - topp) / (n-1)
- 初筛,挑选出概率大于阈值的所有token,其它的舍弃,结果存入
probindex
数组中。 - 再按照概率值降序排列
- 细筛,从前往后累加,得到不大于
topp
的和cumulative_prob
及对应的所以last_idx
,然后截取last_idx
之前的部分作为候选列表。 - 采样,设定阈值
float r = coin * cumulative_prob
,从候选列表里找累加概率值不大于阈值r
的token返回
关键数据结构
kv_cache
内存结构
首先kv cache每个head的维度必须要与query保持一致才能做点积所以都是:head_dim=dim/n_heads
,其次是kv各自有n_kv_heads
个head,所以很容易得出kv cache的维度为(n_layers, seq_len, n_kv_heads, head_dim)
。那么从layer层面看(尚未区分多头)的话,每个token对应kv的维度就是kv_dim = n_kv_heads * head_dim
,其内存结构示意图如下。这跟query(dim
)是不一样的,切记切记。相应的,$W_k$, $W_v$权重矩阵维度也变成了(layer, dim, kv_dim)
跟Query不一样。切记切记。所以我们可以看到,MQA/GQA减少了kv head,实际上是缩小了两个权重矩阵的大小,同时有另外一层隐藏的含义,就是每个token对应的kv dim维度也变小了,也就是信息容量更少了,性能肯定受影响。
注:也许好奇,GQA为何kv总的维度不合q保持一致都是
dim
,因为head_dim
和dim
只能有一个相同,否则head数量就得一致,也就是MHA了。
接下来,因为n_kv_heads<=n_heads
所以必然有kv_dim
是要被多个相邻的query head共享的,举个例子,假如n_heads=6,n_kv_heads=3
,则kv_dim = 3 * head_dim
, 那么每个kv head会被2个query head共享。所以6个query head对应的kv head索引分别是:(0, 0, 1, 1, 2, 2),每个长度是head_dim
所以加起来正好是kv_dim
,相邻两个query对应的kv head是同一个。
layer-0 | layer-1 | … | layer-n | |||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
token-0 | token-1 | … | token-k | token-0 | token-1 | … | token-k | … | token-0 | token-1 | … | token-k |
(kv_dim,) |
(kv_dim,) |
… | (kv_dim,) |
(kv_dim,) |
(kv_dim,) |
… | (kv_dim,) |
… | (kv_dim,) |
(kv_dim,) |
… | (kv_dim,) |
tokenizer
文件
该文件结构与之前版本有差异,文件开头增加了一个4字节的int
值max_token_length
,统计了所有token最大的长度。其次是遍历每个token的内容,包括新增的一个float
的vocab_scores
,然后是4个字节int
型的token长度,紧接着是token内容(不包括结束符的字符串),所有token一个挨一个存储。内存结构示意图如下。
vocab_size
存储在checkpoint
文件里。
文件头 | token-0 | token-1 | … | token-n | ||||||
---|---|---|---|---|---|---|---|---|---|---|
max_token_length | vocab_scores | token len | token content | vocab_scores | token len | token content | … | vocab_scores | token len | token content |
27 | 0.0 | 1 | l | 0.0 | 4 | like | … | 0.0 | 11 | suggestions |
总结
MQA
、GQA
是最近出现的技术,原本的注意力机制是Multi-Head Attention(MHA)
,每个query head对应一个key、value的head,最简化的情况是MQA
,所有query head共享一组key、value head,$W_k$和$W_v$矩阵维度缩小为原来的1/n_heads
,内存占用和推理速度提升非常明显,但相应的性能下降了(每个token对应的维度也变为原来的1/n_heads
,信息被严重压缩)。折中的方案就是GQA
,处于两者中间,且可以配置n_kv_heads
大小,自由权衡取舍。
GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints