Shihanmax
大模型笔记
1/ NLP基础知识
文本处理技术
文本Embedding技术
参考博文: http://shihanmax.top/2022/05/embedding_of_text
核心架构
Transformer架构
参数量计算与显存分析
参考:显卡参数信息: https://moonshot.feishu.cn/sheets/OaBLsKXazhMXjLt9dw5cC4Gvnzc
参数量计算
以llama2-7b为例:
总参数量公式:$Vh + L(12.04h^2 + 12.36h)$:
- 词向量
- $Vh$($V$为词表大小,$h$为隐层维度)
- Transformer Layer × L
- Self-Attention($4h^2 + 4h$)
- $W_Q, W_K, W_V$矩阵:$h^2 \times 3 + 3h$
- $W_O$矩阵:$h^2 + h$
- MLP($8.04h^2 + 6.36h$)
- 上投影矩阵$W_1$:$h \times \text{intermediate_size} \ (2.68h) + 2.68h$
- 上投影矩阵$W_{\text{gate}}$:$h \times \text{intermediate_size} \ (2.68h) + 2.68h$
- 下投影矩阵$W_2$:$\text{intermediate_size} \ (2.68h) \times h + h$
- RMSNorm × 2
- Self-Attention层和MLP层后各一个:$h \times 2$
- Self-Attention($4h^2 + 4h$)
训练时显存分析
以llama2-7b为例(总显存占用约140GB):
- 模型状态
- fp16参数:2 bytes/参数
- fp16梯度:2 bytes/参数
- 优化器状态
- fp32参数:4 bytes/参数
- fp32梯度:4 bytes/参数
- fp32动量:4 bytes/参数
- fp32方差:4 bytes/参数
总计:$20 \times 7B = 140GB$
推理时显存分析
以llama2-7b为例(总显存占用约14GB):
- fp16参数:2 bytes/参数
总计:$2 \times 7B = 14GB$
计算量
训练时间估计
KV Cache分析
Tokenizer
在自然语言处理(NLP)任务中,分词算法扮演着至关重要的角色。它是语言模型理解文本的基础,通过对文本的切分,模型能够更有效地提取特征、捕捉语义。本文将深入探讨分词算法的不同类型、工作流程以及它们在实际应用中的表现。
分词流程
-
文本归一化 文本归一化是分词的第一步,它涉及清除文本中的噪声,如多余的换行符、空格等,并进行大小写归一化。对于特定语言,如带有音调的汉语,音调移除也是必要的步骤。归一化的目的是确保文本的一致性,为后续的分词过程打下坚实基础。
-
预切分 预切分是基于规则的初步分词过程,通常依赖于空格、标点等自然语言的分隔符。例如,BERT的分词器会基于空格和标点进行切分,而GPT-2在此基础上做了改进,将空格保留为特殊字符。预切分的效果直接影响到后续分词的准确性和效率。
-
基于分词模型的切分 这一阶段是分词过程的核心,不同的模型采用不同的算法来实现。例如,BPE、WordPiece和Unigram等方法都是基于子词(subword)的分词方法。这些方法通过合并频繁出现的字符序列来构建词表,能够有效地捕捉词汇之间的关系,并减少未知词(UNK)的出现。
-
后处理 后处理阶段通常包括添加特殊token,如开始、结束标记,以及其他辅助性token。这些特殊token对于模型理解句子结构、识别句子边界至关重要。
分词粒度的选择
分词粒度一般分为三种:基于字的(char),基于子词的(subword)、基于词的(word);基于字的分词方式虽然简单,但信息量较少,解码效率也较低。基于词的分词方式虽然词表庞大,但无法有效捕捉相近词之间的关系,并且存在UNK问题。相比之下,基于子词的方法在词表规模和捕捉同源词关系方面取得了平衡,且无UNK问题。
典型的子词(subword)分词算法
BPE(Byte-Pair Encoding)
BPE算法通过迭代合并频繁出现的字符对来扩充词表。它的核心思想是将字符序列视为可合并的单元,通过不断合并出现频率最高的单元,逐步构建出能够表示所有文本的最小词表。这种方法的优势在于它的简单性和高效性。
训练流程
- 将原始文本数据分段;
- 对每段文本,进行预切分;
- 对所有预切分得到的词,进行wordlevel切分,生成原始词表;
- 在达到退出条件前,循环执行以下步骤;
- 统计相邻子词pair的出现次数;
- 选择次数最多的一组pair,将其加入词表,同时记录一条合并规则;
- 将上述pair的合并更新到每个词的wordlevel切分结果;
推理流程
- 原始文本预切分
- 将每个词切分为最小单元
- 遍历每个merge rule,对每个词,将最小单元进行合并,得到最终结果
BPE示例代码
# basic implementation of BPE algorithm
import itertools
from collections import defaultdict
from transformers import AutoTokenizer
from utils import readlines
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")
pre_tokenize_function = gpt2_tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str
def train_bpe(corpus_path="corpus.txt", max_vocab_size=200):
"""
max_vocab_size: 预设的最大词表大小
"""
lines = readlines(corpus_path, skip_nl=True)
print("\n\n\nlines: ----------------------------------")
print(lines[:5])
# 1. pre-tokenize
pre_tokenized_result = [pre_tokenize_function(line) for line in lines]
print("\n\n\npre_tokenized_result: ----------------------------------")
print(pre_tokenized_result[:5])
# 2. 统计词频
word2count = defaultdict(int)
for line in pre_tokenized_result:
for word, pos in line:
word2count[word] += 1
print("\n\n\nword2count: ----------------------------------")
print(word2count)
vocab_set = set()
for word in word2count.keys():
vocab_set.update(list(word))
vocab = list(vocab_set)
print("\n\n\noriginal vocab: ----------------------------------")
print(len(vocab), vocab)
word2splits = {word: [c for c in word] for word in word2count.keys()}
print("\n\n\nword2splits: ----------------------------------")
print(word2splits)
def _compute_pair2score(word2splits, word2count):
"""根据预分词的词频 和所有的子词,获得子词pair的出现频率"""
pair2count = defaultdict(int)
for word, word_count in word2count.items():
splits = word2splits[word]
if len(splits) == 1:
continue # 只有一个子词,跳过不处理
for i in range(len(splits) - 1):
pair = (splits[i], splits[i + 1])
pair2count[pair] += word_count
return pair2count
pair2count = _compute_pair2score(word2splits, word2count)
print("\n\n\npair2count: ----------------------------------")
print(pair2count)
def _get_most_high_freq_pair(pair2count):
"""获得出现频次最高的pair"""
max_pair = max(pair2count.items(), key=lambda x: x[1])
return max_pair
highest_freq_pair = _get_most_high_freq_pair(pair2count)
print("\n\n\nhighest_freq_pair: ----------------------------------")
print(highest_freq_pair)
# 合并pair,记录合并规则
merge_rules = []
vocab.append("".join(highest_freq_pair[0])) # 注意,加入合并后的,不删除
merge_rules.append(highest_freq_pair[0])
print("\n\n\nh updated vocab: ----------------------------------")
print(vocab)
def _merge_pair(word2splits, subword0, subword1):
"""更新词表后,更新word2splits"""
new_word2splits = {}
for word, split in word2splits.items():
if len(split) == 1:
new_word2splits[word] = split
continue
i = 0
while i < len(split) - 1:
if split[i] == subword0 and split[i + 1] == subword1:
split = split[:i] + [subword0 + subword1] + split[i + 2:]
else:
i += 1
new_word2splits[word] = split
return new_word2splits
word2splits = _merge_pair(word2splits, highest_freq_pair[0][0], highest_freq_pair[0][1])
print("\n\n\n updated word2splits: ----------------------------------")
print(word2splits)
while len(vocab) < max_vocab_size:
print(f"vocab size: {len(vocab)}")
pair2count = _compute_pair2score(word2splits, word2count)
highest_freq_pair = _get_most_high_freq_pair(pair2count)
print("\n\n\nhighest_freq_pair: ----------------------------------")
print(highest_freq_pair)
vocab.append("".join(highest_freq_pair[0])) # 注意,加入合并后的,不删除
merge_rules.append(highest_freq_pair[0])
print("\n\n\nupdated vocab: ----------------------------------")
print(f"vocab: {vocab}\n")
print(f"merge rules: {merge_rules}\n")
word2splits = _merge_pair(word2splits, highest_freq_pair[0][0], highest_freq_pair[0][1])
return vocab, merge_rules
def inference_bpe(merge_rules, sentence):
# 1. pre-tokenize
words = [word for word, _ in pre_tokenize_function(sentence)]
print(words)
# 2. split to char level
splits = [[c for c in word] for word in words]
# 3. apply merge rules
for rule in merge_rules:
for word_idx, split in enumerate(splits):
i = 0
while i < len(split) - 1:
if split[i] == rule[0] and split[i+1] == rule[1]:
split = split[:i] + ["".join(rule)] + split[i+2:]
else:
i += 1
splits[word_idx] = split
# print(sum(splits), [])
print(splits)
tokenized_tokens = list(itertools.chain(*splits))
print(tokenized_tokens)
return tokenized_tokens
vocab, merge_rules = train_bpe(corpus_path="corpus.txt", max_vocab_size=200)
print(vocab)
print(merge_rules)
tokenized = inference_bpe(merge_rules, "Then Alice went to the rabbit hole after six hours waiting.")
BBPE(Byte-level BPE)
与BPE的区别是:切分与合并的粒度是utf-8编码级别,即需要先对原文本进行utf-8编码。
WordPiece
WordPiece算法是BPE的一个变种,区别在于子词pair的分数计算,BPE是直接取pair数量最高的;WordPiece是pair数量除以两个子词的频率之积,即$score = \frac{N_{pair}}{N_{sub_1} * N_{sub_2}}$
Unigram
Unigram算法是一种基于概率的分词方法。它通过计算每个子串的负对数概率来构建词表。Unigram的核心在于它能够为每个子串分配一个重要性分数,从而在构建词表时优先保留那些信息量大的子串。
训练过程
- 将语料按行分割,并进行预分词(对于中文,切分为字,对于使用空格来分割的语言,直接使用空格split);此时可以得到所有词(word);
- 根据现有的词,拆分出所有的子词,即针对每一个词(假设长度为n),均从中取长度[2,n-1]的子串,并统计所有子串的出现频率(注意,长度为1的子串一直保留);根据频率,计算出每个子串的负对数概率;
- 在到达退出条件(如词表长度仍大于某一个设定值)之前,重复以下:
- 计算当前语料库的分数(所有token的负对数似然,需要使用DP实现encode_word:计算某个token的负对数似然的方法,是unigram分词算法的核心,本质是基于model中所有的子词,用动态规划计算出负对数似然最小的那一组sub word;(如:hello 是该划分为 h e ll o 还是 he llo 还是其他的))
- 遍历语料库中的每一个token,计算将其删掉之后,整个语料库的分数;
- 至此得到所有token的重要程度(信息量),按从大到小,删去负对数似然较大的一批token(如10%,认为这部分token不常见);
- 更新模型;
推理过程
- 对语料进行预分词;
- 针对每一个切分后的词执行上述步骤3.1中的encode_word,得到该词的切分方式;
- 将所有词的切分结果拼接在一起,即得到最终的结果;
unigram 示例代码
# basic implementation of unigram tokenization
from collections import defaultdict
import copy
import math
corpus = [
"This is the Hugging Face Course.",
"This chapter is about tokenization.",
"This section shows several tokenizer algorithms.",
"Hopefully, you will be able to understand how they are trained and generate tokens.",
]
word_freq = defaultdict(int)
# 预切分
for sentence in corpus:
for word in sentence.split():
word = "_" + word
word_freq[word] += 1
print(f"word_freq: {word_freq}\n")
char_freq = defaultdict(int)
subword_freq = defaultdict(int)
for word, freq in word_freq.items():
for i in range(len(word)):
char_freq[word[i]] += freq
for j in range(i + 2, len(word) + 1): # i+2
subword_freq[word[i:j]] += freq # 统计所有子词的频率
print(f"char_freq: {char_freq}\n")
print(f"subword_freq: {subword_freq}\n")
# Sort subwords by frequency
sorted_subword_freq = sorted(subword_freq.items(), key=lambda x: x[1], reverse=True)
print(f"sorted_subword_freq: {sorted_subword_freq[:10]} ... \n")
token_freq = {
i[0]: i[1] for i in
list(char_freq.items()) + sorted_subword_freq[:300 - len(char_freq)] # 截断subword_freq 以保证初始状态下为300个token
}
print(f"token_freq: {token_freq} ...\n")
total_freq = 0
for k, v in token_freq.items():
total_freq += v
model = {
token: -math.log(freq / total_freq) for token, freq in token_freq.items()
}
print(model)
def encode_word(word, model):
# [[start, score]]
best_segmentations = [[0, 1]] + [[None, None] for _ in range(len(word))]
for start_idx in range(len(word)):
best_score_at_start = best_segmentations[start_idx][1]
for end_idx in range(start_idx + 1, len(word) + 1):
curr_token = word[start_idx: end_idx]
if curr_token in model and best_score_at_start is not None:
score = model[curr_token] + best_score_at_start
if best_segmentations[end_idx][1] is None or best_segmentations[end_idx][1] > score:
best_segmentations[end_idx] = [start_idx, score]
segmentation = best_segmentations[-1]
# print(segmentation)
if segmentation[1] is None:
return ["UNK"], None
score = segmentation[1]
start = segmentation[0]
end = len(word)
tokens = []
while start != 0:
tokens.append(word[start: end])
next_start = best_segmentations[start][0]
end = start
start = next_start
tokens.append(word[0: end])
tokens = tokens[::-1] # reverse it
return tokens, score
print(encode_word("hello", model))
print(encode_word("word", model))
print(encode_word("Hopefully", model))
print(encode_word("This", model))
def compute_loss(model):
loss = 0
for word, freq in model.items():
_, word_loss = encode_word(word, model)
loss += word_loss * freq
return loss
def compute_scores(model):
# 计算每一个词删去后,语料库的loss
scores = {}
model_loss = compute_loss(model)
for token, score in model.items():
if len(token) == 1:
continue
model_without_token = copy.deepcopy(model)
model_without_token.pop(token)
scores[token] = compute_loss(model_without_token) - model_loss
return scores
percent_to_remove = 0.1 # 每次删去10%的token
while len(model) > 100:
print("- - " * 12)
scores = compute_scores(model)
sorted_scores = sorted(scores.items(), key=lambda x: x[1])
for i in range(int(len(model) * percent_to_remove)):
token_freq.pop(sorted_scores[i][0])
print(f"remove token: {sorted_scores[i][0]}")
subword_freq_sum = sum([freq for token, freq in token_freq.items()])
model = {token: -math.log(freq / subword_freq_sum) for token, freq in token_freq.items()}
print(model)
def tokenize(text, model):
pre_tokenized_text = ["_" + i for i in text.split()]
encoded_words = [encode_word(word, model)[0] for word in pre_tokenized_text]
return sum(encoded_words, [])
tokenized_res = tokenize("Hi how are you today", model)
print(tokenized_res)
参考:
- 从词到数:Tokenizer与Embedding串讲
- 大模型基础组件 - Tokenizer
- [BTTB] BPE:从 Byte 到 Byte,到 No BPE
- Byte Pair Encoding and Data Structures
位置编码
Sinusoid
RoPE
公式: \(\begin{bmatrix} \cos m\theta & -\sin m\theta \\ \sin m\theta & \cos m\theta \end{bmatrix}\)
RoPE的几种内插/外推方法
- 外推目的:在短文本上训练,在更长的文本上inference,同时能够保持相同或接近的性能(本质都是缩小向量的旋转角度m \theta);
- 外推面临的问题:外推部分位置编码训练中没有见过,泛化性能差(即使无参数,也会影响上层学习),预测时序列更长,注意力更分散,熵更高
- 认知误区:
- 单单优化位置编码的设计,未必能够解决长度外推问题;
- 外推的基本前提是函数的光滑性,而RoPE、Sinusoidal等位置编码是正余弦函数的组合,在某些位置是高频振荡的;
- 而不震荡的位置编码函数,往往又缺乏足够的容量来编码位置信息;
外推方法:
- 推理时加入(以下几个方法的推导参考论文进行展开:https://arxiv.org/pdf/2309.00071)
- PI(线性插值):将RoPE的位置下标除以一个常数\lambda,使得超出训练范围内的位置下标缩放回训练区间内。
- NTK-aware:放大旋转角(10000^(-2i/d))的base(默认为10000),由于theta是base的指数,因此对于高维度部分的分量(低频部分),其旋转降幅更高一些;
- NTK-by-parts:由于低维是高频分量,认为无需进行插值,NTK-by-parts仅对高维度部分的低频分量进行插值,具体地,仅在(10000^(-2i/d))中的i大于一定值时才进行插值;
- Dynamic NTK:上述插值方法,能够实现一定程度上的长度外推,但是却改变了原有训练长度内的位置编码特性,因此Dy-NTK提出,当推理长度小于训练长度时,不进行插值操作,当推理长度大于训练长度时,则每一步都使用NTK-aware进行动态插值;
- YARN:基于NTK-by-parts方法,同时在注意力计算中引入温度t:$\mathrm{softmax} (\frac{q_m^T k_n}{t \sqrt{\mid D\mid } })$ ,温度的计算方式如下:$\sqrt{\frac{1}{t}} = 0.1 \mathrm{ln}(s) + 1$
- ReRoPE
- 训练时加入
- HWFA
Alibi
- HWFA
RoPE对q和k施加变换,在softmax计算attention score前,将相对位置信息引入;Alibi则直接对计算出的q_i K^T 矩阵施加一个固定的、无需训练的偏置;具体地,对于第i个q分量q_i,计算其对所有K的attention score时,添加一个偏置向量:$\mathrm{softmax}(q_i K^T + m \cdot [-(i - 1), …, -2, -1, 0] )$,对于不同的attention head,斜率$m$ 不同,论文推荐为$2^{\frac{-8}{n}}$ (n为头的个数);
Alibi的外推方法(参考:https://zhuanlan.zhihu.com/p/657161287)
内插:缩放长度(将相对位置缩减一个倍数),可能会导致分辨率变化,丢失信息
- 论文:Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation
- 公式:
softmax(q_i K^T + m * (-(i-1), ..., -2, -1, 0)),其中m=2^(-8/n)对应注意力头i..n - 外推方法:
- 内插
- NTK Alibi
- 参考实现:NTK Alibi
Normalization
post-norm、pre-norm
Attention
MHA & MQA & GQA
MQA(Multi-Query Attention) 通过所有Query共享同一个Key和Value集合来减少缓存需求。这种方法在减少显存的同时,可能会牺牲一些模型性能。MQA可以将KV Cache的显存需求降低$n_{head}$倍。
q = q_proj(x) # (b, L, H) \* (H, n_head, H_head) -> (b, L, n_head, H_head) -> (b, n_head, L, H_head)
k = k_proj(x) # (b, L, H) \* (H, H_head) -> (b, L, H_head)
v = v_proj(x) # (b, L, H) \* (H, H_head) -> (b, L, H_head)
attn_weights = torch.matmul(q, k.unsqueeze(1).repeat(1, n_head, 1, 1)) # (b, n_head, L, H_head) \* (b, n_head, H_head, L) -> (b, n_head, L, L)
attn_weights = attn_weights + casual_mask
attn_weights = torch.softmax(attn_weights, dim=-1)
attn_output = torch.matmul(attn_weights, v.unsqueeze(1).repeat(1, n_head, 1, 1)) # (b, n_head, L, L) \* (b, n_head, L, H_head) -> (b, n_head, L, H_head) -> (b, L, n_head, H_head) -> (b, L, H)
..layer_norm..
..mlp..
..layer_norm..
GQA(Grouped Query Attention) 是MQA的改进版,它将Query分成多个组,每组共享一组Key和Value。这种方法在减少缓存需求的同时,保留了一定的模型性能。GQA可以将KV Cache的显存需求降低$n_{head} / n_{group}$倍。
MLA
多头潜在注意力(Multi-head Latent Attention, MLA)是由 DeepSeek 团队在 DeepSeek-V2/V3 模型中提出的 Transformer 注意力架构创新,旨在显著降低推理显存占用(KV Cache)而不牺牲模型性能。
背景与动机
一、核心思想 多头潜在注意力(Multi-Head Latent Attention, MLA) 是 DeepSeek-V3 的核心创新之一,旨在解决传统 Transformer 模型在处理长序列时面临的 KV 缓存内存占用过高的问题。MLA 通过对注意力键(Key)和值(Value)进行低秩联合压缩,显著减少推理过程中的显存开销,同时保持模型表达能力。
二、技术实现机制
- 低秩联合压缩
- 压缩流程:
对于每个注意力层的输入隐藏状态 $h_t$,MLA 将其键(K)和值(V)投影到一个共享的低秩潜在空间:
\(c_t^{KV} = W^{DKV} h_t\)
其中 $W^{DKV}$ 是降维矩阵,将原始高维 KV 压缩为低秩向量 $c_t^{KV}$(维度远低于原始 KV);
- 压缩流程:
- 解压缩与重构:
需要计算注意力时,通过升维矩阵重构 KV:
\(k_t^C = W^{UK} c_t^{KV}, \quad v_t^C = W^{UV} c_t^{KV}\)
最终键 $k_t$ 由压缩部分 $k_t^C$ 和原始键的低秩保留部分 $k_t^R$ 拼接而成。
- 解耦的位置编码
- RoPE 分离处理:
位置编码(RoPE)仅作用于未压缩的原始键 $ k_t^R $,避免压缩导致的位置信息损失:
\(k_t^R = \text{RoPE}(W^{KR} h_t)\)
这种设计在减少缓存的同时保留了位置感知能力2,3。
- RoPE 分离处理:
- 矩阵乘重排序优化
- 计算顺序调整:
MLA 通过改变矩阵乘法结合律,将计算复杂度从 $O(d \cdot n_k)$ 降至 $O(d_c \cdot n_k) d_c \ll d$)。例如:
原始: $(q \cdot k) \rightarrow O(d \cdot n_k)$
优化后: $(c_t^{Q} \cdot c_t^{KV}) \rightarrow O(d_c \cdot n_k)$
显著提升解码效率(尤其当 $n_q = 1$ 时)。
- 计算顺序调整:
三、参数配置
| 组件 | 参数值 | 作用 |
|---|---|---|
| 注意力头数 | 128 | 增强多视角语义捕捉能力 |
| 每个头维度 | 128 | 平衡计算效率与表达能力 |
| KV 压缩维度 | 512 | 将原始 KV 缓存减少 4-8 倍 |
| RoPE 维度 | 保留原始 | 确保位置信息不丢失 |
四、优势与效果
-
显存优化
KV 缓存从传统 MHA 的 $ O(n \cdot d) $ 降至 $ O(n \cdot d_c) $,长文本处理能力提升(支持 10K tokens+) -
推理加速
矩阵重排序和低秩计算使解码速度 提升 40%+,尤其适合流式生成场景。 -
性能无损
在 GLUE、长文本摘要等任务中,MLA 在减少 70% 缓存的同时,准确率与原始 MHA 相当;
MOE架构
MOE混合专家模型
- 核心是将传统稠密模型中的前馈网络(FFN)层替换为多个专家(Expert) 和一个路由网络(Router),每个输入仅激活少量相关专家(通常Top-K,K=1或2);
核心优势:
- 高效计算:模型总参数量可达万亿级,但每次推理仅激活约10%-20%的参数(如DeepSeek-V3激活370亿/总参1.8万亿);
- 专业分工:专家学习不同领域知识(如数学、编程),路由网络动态分配输入到最匹配的专家;
- 扩展灵活:通过增加专家数量线性扩展模型容量,计算成本几乎不变
主要挑战:
- 负载不均衡:少数专家过度激活,其他专家欠训练:专家级/设备级负载损失、噪声注入路由、动态专家合并(FoE)
- 训练不稳定:梯度更新频率差异导致收敛困难:梯度裁剪、持续预训练(如LLaMA-MoE分步构建专家)
- 通信开销:分布式训练中专家跨设备通信成本高:专家并行(EP)融合数据/张量/流水线并行、GroupedGEMM优化
- 微调泛化差:多任务奖励加权(如数学+代码+安全性加权)、指令微调
长文本下,对Attention机制的改进
- dense attention:原始版本的稠密attention,计算复杂度$O(T^2)$,当推理长度大于预训练长度时,性能会下降;
- window attention(每个token,仅关注其前面L个token,计算复杂度O(TL),推理时效率跟高,但当开始位置的token脱离当前窗口后,性能会急剧恶化)
- sliding window attention
- attention Sink(streamingLLM)
- shift short attention (s2-attention) (longlora)
激活函数
Scaling Law
对于Decoder-only的模型
- 模型的计算量$C$ (Flops), 模型参数量$N$,数据大小$D$ (token数) 三者满足:$C \approx 6ND$
- 模型性能与$C$、$N$、$D$有关,与模型具体结构无关
- 固定$N$、$C$、$D$中任意两个变量,剩下的一个变量与模型性能呈幂律关系
- 提升模型性能时,模型参数量$N$和数据$D$需要同步放大
- Scaling Law 同时适用于语言模型、其他模态及多模态任务
Encoder-only
EncoderDecoder
Decoder-only
Q:为什么现在的大模型都是decoder-only的? A:
基础网络组件
MLP
Q:为什么大模型的知识存储在 FFN 中? A:
激活函数
1. ReLU 及其变体
ReLU (Rectified Linear Unit)
- 公式:
f(x) = max(0, x) - 特点:
- 计算高效(简单阈值操作)
- 缓解梯度消失(正区间梯度为1)
- 缺陷:
- 神经元死亡(负输入梯度归零)
- 输出非零中心化(影响收敛)
变体改进
| 变体 | 公式 | 优势 | 典型应用 |
|---|---|---|---|
| Leaky ReLU | f(x)=max(αx, x) (α≈0.01) | 缓解神经元死亡 | 图像生成模型 |
| PReLU | f(x)=max(αx, x) (α可学习) | 自适应负斜率 | ResNet 系列 |
| ELU | x>0?x : α(eˣ-1) | 输出均值接近零,加快收敛 | NLP 早期模型 |
这个表格清晰地展示了三种ReLU变体的公式、优势和典型应用场景。
2. GELU (Gaussian Error Linear Unit)
公式
GELU(x) = x * Φ(x) (Φ(x) 为标准高斯累积分布函数)
近似计算:0.5x(1 + tanh[√(2/π)(x + 0.044715x³)])
核心优势
| 特性 | 说明 | | ———— | ————————————– | | 概率自适应 | 通过权重输入的概率分布决定激活程度(比ReLU更平滑) | | 更好的梯度流 | 负值区域有非零梯度,避免神经元死亡 | | 性能优势 | 在Transformer中比ReLU提高1-2%精度(BERT, GPT等) |
经典应用:BERT、GPT-3、RoBERTa 等主流Transformer模型默认激活函数
3. SwiGLU 在大模型中的崛起
基本结构
SwiGLU(x, W, V, b, c) = Swish(xW + b) ⊗ (xV + c)
其中 Swish:x * sigmoid(βx)(β通常=1)
核心优势
- 增强表达能力 门控机制动态调制信息流(类似LSTM的门控结构)
- 效果显著提升
- LLaMA、PaLM等模型验证:相同参数量下比GELU高0.5-1.5个BLEU/准确率
- 训练收敛速度提升约15%(PaLM 报告)
- 工程优化空间大
- 可与其他层合并计算(如线性投影),减少内存访问
典型应用场景
- **LLaMA 1/2/3**:全系列采用 SwiGLU - **Gemini**:Google 多模态大模型核心激活函数 - **ChatGLM**:基于GLU架构的中文大模型
- 可与其他层合并计算(如线性投影),减少内存访问
4. 激活函数选择的考虑因素
| 因素 | 说明 | |——————–|————————————————————————-| | 计算开销 | ReLU > GELU > SwiGLU(SwiGLU需要双线性变换) | | 模型性能 | SwiGLU > GELU > ReLU(大模型场景差距显著) | | 训练稳定性 | GELU/Swish > SwiGLU > ReLU(ReLU有神经元死亡风险) | | 硬件支持 | ReLU/GELU有GPU专优指令,SwiGLU依赖高效矩阵乘优化 | | 任务适配 | 视觉任务:ReLU变体仍主流;文本/多模态:GELU/SwiGLU占优 |
✅ 大模型当前趋势:
- <10B 参数模型:GELU(平衡效率与性能)
10B 参数模型:SwiGLU(性能优先,尤其生成类任务)
边缘部署:ReLU变体(低计算开销需求)
2/ 预训练技术
数据处理
预训练流程
结果评估
3/ 后训练技术
监督微调(SFT)
PEFT
prompt-tuning、Prefix-tuning、P-Tuning、Adapter
LoRA系列
LoRA
- lora微调target选择
-
- 理论上transformer中的所有linear层均可配置为lora target
-
- 部分target的lora微调,能够和所有linear层微调效果持平:https://arxiv.org/pdf/2106.09685
-
- lora显存&参数&训练时间分析
- LoRA+
- 原理
- 参考
- https://arxiv.org/pdf/2402.12354.pdf
- https://kexue.fm/archives/10001
- DoRA
- https://arxiv.org/pdf/2402.09353.pdf
- QLoRA
- LongLoRA
- LongQLoRA
LoRA的变种对比(LoRA+/DoRA/rsLoRA/adaLoRA) - DIDO的文章 - https://zhuanlan.zhihu.com/p/706497842
人类偏好对齐(RLHF)
RLHF流程
RL 算法
Reasoning
思维链、思维树
CoT、ToT、GoT、MCTS…
RLVR
4/ 分布式
并行训练基础知识
分布式通信原语
分布式训练用到的数据交换方式的原子操作:
- Broadcast: Reduce operation: one rank receives the reduction of input values across ranks.
- Scatter: Scatter result in equal-sized blocks between ranks
- Gather: One rank receives the aggregation of data from all ranks in the order of the ranks.
- Reduce: One rank receives the reduction of input values across ranks.
- AllReduce:Each rank receives the reduction of input values across ranks.
- AllGather: Each rank receives the aggregation of data from all ranks in the order of the ranks.
- ReduceScatter:The ReduceScatter operation performs the same operation as Reduce, except that the result is scattered in equal-sized blocks between ranks, each rank getting a chunk of data based on its rank index.
- RingAllReduce == ReduceScatter + AllGather
参考资料: NCCL文档
训练流程
- 前向传播:上一层fp16的激活值和fp16的参数进行计算,得到本层fp16激活值;
- 反向传播:本层激活值和参数进行计算,得到fp16的梯度;
- 参数更新:fp16梯度、fp32参数副本、momentum、variance参与计算,得到更新后的fp32参数、momentum、variance;将fp32的参数转化为fp16进行本层参数更新;
训练时显存占用情况
在推理状态下,一个fp16的模型,大概需要占用约2倍其参数量的显存空间,而训练状态需要更多的显存,训练状态下显存占用主要分为两部分:模型状态和残余状态。
模型状态:包含模型参数、梯度和优化器状态。目前主流训练都使用fp16精度,前向和反向传播都在fp16精度下完成,而在反向传播的最后阶段,即更新参数时,混合精度的优化器则会使用到fp32的参数,以及fp32的其他优化器状态。以Adam优化器为例,假设参数量为$\Phi$ ,fp16的参数和梯度分别需要$2\Phi$ 的显存空间,而优化器中的fp32的参数、fp32的momentum和variance,分别需要$4 \Phi$ 的空间,共需要$16 \Phi$ 的显存空间,模型状态中,优化器状态就占用了75%。
残余状态:包含中间激活值、临时缓冲区和内存碎片。
-
在训练过程中,中间激活值(前向传播中计算得到,反向传播中用到)会占用大量显存,例如,拥有1.5B参数的GPT2模型,在长度1K、batch_size为32时,中间激活值需要约60GB的显存。(如果使用激活值重计算的方式,可以以增加约33%的计算量为代价,将中间激活值);
-
梯度的all_reduce操作、归一化操作等,都需要将所有的梯度保存到一个单独的缓冲区用于计算操作,当模型尺寸较大时,此缓冲区也会占用一定的缓存;
-
有时候,即便设备的空余内存多于需求量,也会有无法使用的情况,(并非所有的空余内存是contiguous的)
DP、PP、TP
数据并行(Data Parallelism)
单台设备可以容纳完整的模型,在多个设备上用多份数据并行训练,每个设备上保存一份模型副本,在一个训练step中,每个设备使用一份mini-batch的数据进行前向和反向传播,然后将各个设备上的梯度进行平均,再依次分发到每台设备上,计算出最新的参数;
主要有Ring All Reduce(无master节点)和Parameter Server(有master节点)两种实现;
RingAllReduce:比较早期;RIngAllReduce在pytorch中的实现就是DDP,用环状通信降低时延;
ParameterServer:设计了一套分布式训练框架,将训练拆分为一个master节点和多个worker节点,worker节点每轮迭代执行:从master 拉取参数->加载训练数据->计算梯度->推送梯度到master->等待master完成一次全worker同步;可能会有单点依赖的问题(master需要等待所有worker完成计算后才能进行合并)
流水线并行(Pipeline Parallelism)
当模型尺寸继续变大,单卡无法容纳整个模型时,可以将模型按层切分为多个分区,在多个分区之间进行流水线式的计算,这就是流水线并行。朴素的流水线并行,会导致显卡在多数时间上都是空闲的,因此流水线并行中需要尽可能缩短卡等待的“气泡时间”。介绍流水线并行的两种流行的方式:G-pipe和PipeDream。
G-pipe:,G-pipe不仅将模型的参数切分为多个部分,还将模型在前向计算中产生的激活值切分为多个部分,使得多个分区可以并行处理数据,为了最大化效率,G-pipe要求batch_size和流水线分区数成比例(如果批大小不足以填满所有分区,某些分区可能会空闲,导致流水线中出现气泡,效率做不到最大化),但是过大的batch_size可能会影响收敛性,且需要非常大的内存开销用于中间激活值的存储。
PipeDream:PipeDream通过保留多个过时参数的副本来隐藏流水线气泡,通过使用过时的参数,每个分区可以继续进行计算而无需等待其他分区的更新,与G-pipe不同的是,PipeDream不再需要通过扩大batch_size的方式来消除流水线气泡,但同时保存历史版本的参数也会增加内存开销。另外,使用过时版本的参数在一定程度上不完全符合目前主流深度学习模型训练的方式,可能会对模型的收敛性产生一些影响。
张量并行(Tensor Parallelism)
当模型中某些层,通过流水线并行切分后仍无法放在单卡上时,就需要将这些层继续切分,即张量并行。
训练框架概览
总览
- Tensorflow:由Google开发的开源机器学习框架,它提供了强大的分布式训练功能。TensorFlow支持数据并行、模型并行和分布式策略等多种分布式训练方法;
- PyTorch:由Facebook的AI研究团队开发的流行的开源机器学习库。它提供了分布式包(torch.distributed),支持分布式训练,并且可以通过使用torch.nn.DataParallel来实现数据并行;
- Horovod:由Uber开源的分布式训练框架,它基于MPI(Message Passing Interface)并提供了一种简单的方法来并行化TensorFlow、Keras、PyTorch和Apache MXNet等框架的训练,Horovod特别适合于大规模的深度学习模型训练;
- Ray:开源的分布式框架,用于构建和运行分布式应用程序。Ray提供了Ray Tune(用于超参数调优)和Ray Serve(用于模型服务),并且可以与TensorFlow、PyTorch和MXNet等深度学习库集成
- Accelerate:HuggingFace开发为了简化PyTorch模型的分布式训练而设计的,它提供了一个简单的API来启动分布式训练,并支持使用单个或多个GPU以及TPU;
- DeepSpeed:微软开发的一个开源库,用于加速PyTorch模型的训练。它提供了各种优化技术,如ZeRO(Zero Redundancy Optimizer)和模型并行性,以支持大规模模型的训练;
DeepSpeed
–
DeepSpeed ZERO系列
增大参数量(模型尺寸)是提升模型效果的一种有效的方式,如今,随着预训练模型的参数量越来越大,单机、单卡显然已经无法支撑起有效的训练,而传统的数据并行(DP)无法降低模型的显存占用,常规的具有32G显存的显卡只能支撑起十亿级别参数的模型;而流水线并行(PP)、模型并行(MP)、CPU-Offloading等技术也只是在内存占用/计算/通信效率和功能性、易用性之间作权衡。在上述方法中MP可以算是最有效的一种训练方式,Megatron-LM、11B的T5模型都使用了MP技术,当模型尺寸进一步增加时,MP的可用性会打折扣。MP将模型层中的张量切分并放置在多个设备上,层与层之间对通信有很高的要求,因此在单节点上运行时,MP的效果很好,但是一旦扩展到多机/多节点的情况,MP的性能会明显受到影响。
在ZERO这篇文章中,作者分析了模型训练时的内存占用情况,希望通过对内存占用的分析,找到优化大参数量模型训练的方法。作者将训练状态下的内存消耗分为两类:模型状态(model states)和残余状态(residual states)。模型状态包含模型的参数、模型的梯度、优化器状态(以Adam为例,包含一阶、二阶动量、参数、梯度);残余状态包括中间激活值、临时缓冲区和内存碎片(unusable fragmented memory)。ZERO使用ZERO-DP和ZERO-R针对优化这两种状态。
ZERO-DP(ZERO-powered data parallel):优化模型状态
数据并行(DP)的计算/通信效率很高,但是每个卡都要保存模型副本,比较浪费显存;而模型并行(MP)将模型状态进行切分,带来了外的计算/通信成本,扩展能力不够强。另外,这两种方式,会在整个训练过程中保存模型状态(尽管有一些状态在某些时段是非必需的)。针对此,ZERO-DP将模型状态进行切分,优化了计算/通信效率,同时使用动态通讯机制来保证能够保持计算粒度和通讯量。ZERO-DP定义训练期间的内存消耗对象有以下三类:OS:优化器状态、G:梯度、P:模型参数;定义N为机器数量,\Phi 为模型参数量,\theta_{os}、\theta_{g}、\theta_{p}分别代表优化器状态、梯度和模型参数,\beta为单台机器的出入带宽;ZERO-DP有三个优化阶段:
-
P_{os}(优化器状态分割):每台设备上有完整的\theta_{g}、\theta_{p},但只保留\frac{1}{N}的\theta_{os},训练时也只更新这部分状态对应的参数,每轮训练完成后,使用reduce-scatter将所有机器上的所有参数的梯度合并到负责的机器上,然后用all-gather将每台机器计算出的参数发给全局,因此参数同步的时间为\frac{2(N-1)\Phi}{B\beta},此种方法可以减少4倍内存消耗,通信量与DP相同;
-
P_{os+g}(增加梯度分割):每台设备有完整的\theta_p、保留\frac{1}{N}的\theta_{os}、\theta_{g}。反向传播时每经过一层参数就开启一轮reduce-scatter将梯度整合到一个节点,计算出下一层梯度后删除前一层梯度。参数同步时间为 2(𝑁−1)Ψ / 𝑁𝛽 ,和数据并行一致。减少8倍内存消耗,通信量与DP相同;
-
P_{os+g+p}(增加参数分割):内存消耗与$N_d$反比,通信量是DP的1.5倍左右;在P_g的基础上每一轮加上一次broadcast,把模型参数分发到各个设备上,因此时延相较前两个有显著提升,参数同步时间为因为 3(𝑁−1)Ψ / 𝑁𝛽 。
ZERO-DP可以显著降低单卡内存,最大可以将120G训练内存需求降低为1.9G。
ZERO-R(优化残余状态)
和ZERO-DP在同一篇论文中提出,ZERO-DP提升了模型状态的内存使用效率,中间激活值,临时缓冲区和内存碎片带来的内存浪费也逐渐突出:
$P_a$ (Partitioned Activation Checkpointing):中间激活值保存于前向传播阶段,用于后向传播中的梯度计算。ZERO-R在模型并行的基础上,使用激活值切分策略,识别并减少冗余的激活值存储;
$C_B$ (Constant Size Buffers):为临时缓冲区定义合适的大小,避免预留显存空间远超实际需求而导致显存浪费。
$M_D$ (Memory Defragmentation):内存碎片主要在激活值检查点和梯度计算中产生。当前向传播中使用激活值检查点时,只有一小部分激活值被保存下来用于反向传播,其余大量的激活值都被丢弃掉,并在反向传播时通过重新计算得到。在这个过程中,根据生命周期不同,产生了两类内存需求:长期内存(被保存下来的激活值)和短期内存(丢弃掉的激活值);另外,在反向传播中,参数的梯度需要长期内存,而激活值的梯度等其他用于计算梯度的部分则需要短期内存。这种长期内存和短期内存交替存储/释放的过程,会导致内存碎片的产生。当显存总量有限制时,过多的内存碎片可能会导致因无法申请到连续的大段内存而导致OOM,另外,也可能会导致内存分配的效率降低。ZERO-R的解决办法是,实时地为激活值和梯度预申请连续的内存区域,一旦激活值和梯度被计算得到,立即将其复制到预申请区域内,这样不仅可以使ZeRO可以以更大的batch_size训练模型,而且可以提高训练的效率(如内存申请)
有了以上的优化,一个自然的问题:有了ZERO,还有必要做模型并行吗?
ZERO提升了传统DP的效率,使得模型并行在训练大模型时的必要性下降。ZERO-DP可以做到与模型并行相同的内存经济性(甚至在模型切分不均衡时,ZERO-DP可以做到更优的内存占用),其扩展性也相对较好,同时,ZERO-DP易于实现,而模型并行可能还需要一定的适配工作。当然,模型并行还是有一定的应用场景的,比如:
- 对于超大模型训练,如果MP能够结合ZERO-R,可以更加节省内存;
- 对于小模型,batch_size很大时,使用DP可能会导致收敛性不够好,此时,可以将ZERO与MP结合来支持更大的batch_size
ZERO-Offload
核心思想:让CPU和内存参与到训练中,将fp32参数更新和float2half操作拆分到CPU和内存上计算,前向传播和反向传播依旧由GPU负责。文章进一步提出了One-Step Delayed,在训练后期模型收敛后,CPU的参数计算和更新会放到下一轮迭代周期中完成,这可能带来震荡,但是也会稀释CPU计算的时间,且总体不会对收敛效果有太大的影响。
ZERO-Infinity
DeepSpeed 的 ZeRO-Infinity 是微软开发的革命性分布式训练优化技术,旨在突破 GPU 显存限制,支持万亿参数级大模型训练。其核心思想是通过分层存储和智能调度,将 GPU 无法容纳的模型状态(参数、梯度、优化器状态)卸载(Offload)到 CPU 内存和 NVMe 固态硬盘,同时保持训练效率。
https://zhuanlan.zhihu.com/p/688873027 一篇大模型分布式训练的学习过程总结 https://zhuanlan.zhihu.com/p/366906920 Megatron论文和代码详细分析(1) https://zhuanlan.zhihu.com/p/663517415 ZeRO: Zero Redundancy Optimizer,一篇就够了 https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/collectives.html Collective Operations https://arxiv.org/pdf/1910.02054 ZeRO: Memory Optimizations Toward Training Trillion Parameter Models
Megatron-LM
专注于张量并行的高效实现,支持大规模模型训练。
Accelerate
HuggingFace提供的分布式训练库,简化多卡/多机训练配置。
5/ 推理优化
LLM Decoding
解码方法
- 确定性方法
- greedy:每一步选择当前概率最高的 token
- beam search (beam size k,k为1时,贪心搜索;k 为无穷时,暴力搜索 BFS):每一步保留 k 个概率最高的路径,相当于以束宽 k 对 BFS 进行剪枝;
- 随机性方法
- random sampling
- top-k 采样(每一步 保留概率最大的 k 个,归一化后采样);
- top-p(nucleus 核采样,动态选择累积概率超过阈值 p 的最小 ;token 集合,归一化后采样);
- 温度调节:通过温度参数 T 来调整概率分布的平滑度:$P_{new}(w_i)=\frac{exp(z_i / T)}{\sum_{j} exp(z_j / T)}$,T>1越高越平滑(随机),可与 top-k、top-p 结合;
框架应用
推理框架
vLLM
设计详情: 设计了一种针对KVCache的显存管理策略,通过地址不连续的分块显存来保存KVCache,减少显存碎片,提高显存的有效利用率。显存碎片产生的原因是,为了避免生成长度过长导致KVCache越界,因此会预分配最大长度的KVCache,但是实际上大部分请求的长度远小于最大长度,所以实际上很多显存被KVCache浪费了。
SGLang、TensorRT-LLM
性能优化算法
注意力优化
PagedAttention FlashAttention
显存优化:KVCache
本文针对大模型推理中的KV Cache技术进行讨论,主要内容如下:
- llm的推理流程
- 推理过程中面临的问题(重复计算)
- KV Cache的原理(缓存的哪些内容,如何工作,节省多少计算量)
- KV Cache存在的问题(如对显存空间的要求,显存碎片,频繁申请显存带来的IO瓶颈等)
- 引入KV Cache的计算(即给定模型尺寸的情况下,给定序列长度时候,KV Cache需要的显存量)
- 根据上述计算公式,讨论可以从哪些角度减小KV Cache的显存需求
- 引入MQA、GQA,二者的原理介绍,二者如何缓解KV Cache的显存压力
LLM的推理流程
大型语言模型(LLM)如GPT系列,在推理过程中采用自回归的方式生成文本。该过程从一个初始文本提示开始,模型基于此上下文预测下一个最可能的token,然后将这个token添加到上下文中,并继续预测下一个token,这个过程一直重复,直到遇到终止符或生成序列长度达到限制值。包含两个阶段:预填充阶段(prefilling)和生成阶段(generating),前者将初始文本提示一次性输入模型,得到初始文本token对应的各层计算结果,进入生成阶段,基于当前时间步以前的所有信息,逐个生成下一个token。对于一个包含若干层transformer decoder的GPT模型来说,第k-1层的输出是第k层的输入。假设当前的序列长度是L,第l-1层的输出为x,则第l层decoder的运算过程可以概述为:
q = q_proj(x) # (b, L, H) \* (H, n_head, H_head) -> (b, L, n_head, H_head) -> (b, n_head, L, H_head)
k = k_proj(x) # (b, L, H) \* (H, n_head, H_head) -> (b, L, n_head, H_head) -> (b, n_head, L, H_head)
v = v_proj(x) # (b, L, H) \* (H, n_head, H_head) -> (b, L, n_head, H_head) -> (b, n_head, L, H_head)
attn_weights = torch.matmul(q, k.transpose(2, 3)) # (b, n_head, L, H_head) \* (b, n_head, H_head, L) -> (b, n_head, L, L)
attn_weights = attn_weights + casual_mask
attn_weights = torch.softmax(attn_weights, dim=-1)
attn_output = torch.matmul(attn_weights, v) # (b, n_head, L, L) \* (b, n_head, L, H_head) -> (b, n_head, L, H_head) -> (b, L, n_head, H_head) -> (b, L, H)
..layer_norm..
..mlp..
..layer_norm..
KV Cache
在自回归推理中,模型在每个时间步都会将新的输出token添加到输入序列中,这导致在计算新的注意力权重时,需要重复使用之前步骤中已经计算过的键(k_proj(x))和值(v_proj(x))向量。这种重复计算不仅增加了计算负担,也增加了内存的使用,因为每步都需要重新计算整个序列的注意力。考虑到casual mask的存在,对于任意一层decoder,对于t时刻的token,其k、v及其他量的计算不受其后时间步生成的token的影响,因此可以将q = q_proj(x)与k = k_proj(x)这两步的结果缓存起来,用$2bn_{head}LH_{head}$的空间换取$4bLh^2$的时间复杂度。
引入KVCache后,显存需求可以通过以下公式估算:
$\text{显存需求} = 2b \times L \times l \times n_{head} \times H_{head}$
以7B的Llama模型为例,对于长度为L的序列,平均每个token需要占用的显存空间为:$2 * 32 * 32 * 128 * 2bytes \sim 521 KB$,对于一块拥有24GB显存的A10推理卡来说,加载7B模型大约需要14GB的空间,剩余的10GB空间理想情况下可以容纳至多20480个token的KV Cache。
KV Cache虽然能够通过空间换时间的方式提高推理效率,但也会带来一些问题,比如,由于KV Cache需求的显存空间正比于序列长度,因此当序列长度过长时,会导致GPU内存瓶颈,另外,频繁在DRAM上读写缓存,可能会导致GPU运算资源空置,降低GPU利用率。
如何降低KV Cache对显存的需求量呢?考虑到上述计算公式,KV Cache主要受序列长度$L$、注意力头个数$n_{head}$、注意力头隐藏层维度$H_{head}$、decoder 层数$l$、batch size $b$的影响,在上述这些影响因素中,可以通过将多头自注意力机制MHA优化为MQA和GQA,来缩小注意力头个数$n_{head}$的规模。
参考:
- LLM推理入门指南②:深入解析KV缓存
- Multi-Query Attention is All You Need
- https://blog.fireworks.ai/multi-query-attention-is-all-you-need-db072e758055
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints
- 读懂KVCache
- 为什么现在大家都在用 MQA 和 GQA?
蒸馏、量化、剪枝
量化
数据精度
浮点数表示形式
- 组成:符号位(sign)、指数位(exponent)、尾数位(fraction)
- 二进制转十进制公式:$(-1)^{sign} \times 2^{(exponent - 偏置)} \times 1.fraction_{(二进制)}$
Q: 为什么以fp16精度训练模型时,优化器状态要使用fp32的精度来存储?(一阶动量、二阶动量、参数、梯度)
混合精度训练,特别是使用fp16(16位浮点数)作为数据精度来训练大型模型时,通常会在反向传播和参数更新的阶段使用更高精度的fp32(32位浮点数)。这样做主要有以下几个原因:
- 数值稳定性:当使用fp16进行计算时,由于更小的数字范围和精度,容易发生数值上溢(overflow)和下溢(underflow)问题。在模型的权重更新过程中,这些数值问题可能导致训练不稳定或甚至完全失败。使用fp32可以提供更大的数值范围,降低这类问题的风险。
- 梯度精度:在计算梯度时,微小的参数变化可能导致损失函数的显著变动,这就需要对梯度有较高的精度。如果使用fp16存储梯度,那些小的梯度值可能会丢失,这会影响模型收敛和最终性能。因此,将梯度及优化器状态以fp32保留能够减少这种精度损失。
- 累积误差:深度学习模型通常需要大量的迭代来训练。在这些迭代过程中,使用fp16可能会导致误差不断累积,特别是在微调和小的权重更新方面。使用fp32有助于减少这种累积误差,特别是对于大型模型和复杂任务而言。
- 动量和自适应学习率算法:许多优化器(如Adam、RMSprop等)使用一阶或二阶动量来平滑梯度。这些动量的计算涉及到梯度的累加或平方累加,使用fp16可能会导致精度不足。因此,优化器的状态维护在fp32中能够确保更高精度的计算。
- 硬件支持:现代GPU对fp16和fp32的计算都有很好的支持,但是对于精度要求更高的操作,使用fp32可以充分利用硬件设计的优势,以确保计算的准确性。
综上所述,虽然使用fp16可以减少内存占用并可能提高计算速度,但在反向传播和优化器状态更新时使用fp32是为了保持训练过程的稳定性和准确性。混合精度训练正是试图在性能和精度之间寻找平衡,通过智能地结合fp16和fp32的使用,来加速训练同时保障模型的性能。
混合精度训练
混合精度训练指的是在训练神经网络时,同时使用不同的数值精度来表示和计算权重、激活值和梯度,以加速训练过程并减少显存占用。
- FP32
- 组成:1位符号位 / 8位指数位 / 23位尾数位
- FP16
- 组成:1位符号位 / 5位指数位 / 10位尾数位
- BF16
- 组成:1位符号位 / 8位指数位 / 7位尾数位
低比特量化
- INT4/INT8 weight only (W4A16 & W8A16)
- Weight + Activation同时量化
- KVCache量化
- Hopper架构下的FP8
6/ 强化学习
强化学习关键词
PPO
DPO
由ELO算法到Bradley–Terry Model,然后推导出DPO算法。
以下是基于DPO论文(《Direct Preference Optimization:Your Language Model is Secretly a Reward Model》)从Bradley-Terry(BT)模型推导DPO算法的完整过程。
1. Bradley-Terry(BT)模型基础
Bradley-Terry模型是偏好建模的核心工具,用于量化两个响应之间的偏好概率。给定输入 x 和两个响应 y_1, y_2,偏好 $y_1 \succ y_2$ 的概率为:
其中:
- $r^*$ 是隐式奖励函数(真实但未知);
- $\sigma$ 是Sigmoid函数。
意义:BT模型将人类偏好转化为奖励值的差异比较,但 $r^*$ 需通过数据拟合。
2. 传统RLHF的局限性
RLHF(Reinforcement Learning from Human Feedback)通过三阶段对齐人类偏好:
- 监督微调(SFT):训练基础模型 $\pi_{\text{SFT}}$。
- 奖励建模(RM):用BT模型拟合奖励函数 $r_\phi$,优化负对数似然损失:
\(\mathcal{L}_{\text{RM}} = -\mathbb{E}_{(x,y_w,y_l) \sim \mathcal{D}} \left[ \log \sigma\left(r_\phi(x, y_w) - r_\phi(x, y_l)\right) \right]\) \(\max_{\pi_\theta} \mathbb{E}_{x,y \sim \pi_\theta} [r_\phi(x,y)] - \beta D_{\text{KL}}(\pi_\theta(y|x) \parallel \pi_{\text{SFT}}(y|x))\)
问题:RM训练和PPO优化过程复杂且不稳定,需多次采样和策略迭代。
3. DPO的核心推导:奖励函数的重参数化
DPO的关键创新是跳过显式奖励建模,直接将策略 $\pi_\theta$ 与参考策略 $\pi_{\text{ref}}$(即 $\pi_{\text{SFT}}$)关联。推导如下:
步骤1:RLHF目标的解析解
带KL约束的奖励最大化问题存在闭式解:
\[\pi^*(y|x) = \frac{\pi_{\text{ref}}(y|x) \exp\left(\frac{1}{\beta} r^*(x,y)\right)}{Z(x)}\]| 其中 $Z(x) = \sum_y \pi_{\text{ref}}(y | x) \exp\left(\frac{1}{\beta} r^*(x,y)\right)$是配分函数。 |
推导过程:
该步骤是DPO理论的核心基础,其推导过程基于带KL散度约束的奖励最大化问题的最优化条件。以下是详细的推导步骤:
RLHF的强化学习阶段目标函数为:
\[\max_{\pi_\theta} \mathbb{E}_{x \sim \mathcal{D}, y \sim \pi_\theta(y|x)} \left[ r_\phi(x,y) \right] - \beta D_{\text{KL}} \left( \pi_\theta(y|x) \parallel \pi_{\text{ref}}(y|x) \right)\]其中:
- $r_\phi(x,y)$ 是奖励模型的输出;
- $\beta$ 是控制KL惩罚强度的超参数;
- $\pi_{\text{ref}}$ 是参考策略(通常为SFT模型)。
通过变分法求解上述目标的最优策略。首先将目标函数改写为:
\[\max_{\pi_\theta} \mathbb{E}_{x,y \sim \pi_\theta} \left[ r_\phi(x,y) - \beta \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)} \right]\]这一步利用了KL散度的定义:$D_{\text{KL}}(\pi_\theta \parallel \pi_{\text{ref}}) = \mathbb{E}y \left[ \log \frac{\pi\theta(y|x)}{\pi_{\text{ref}}(y|x)} \right]$。
| 引入概率分布的归一化约束 $\sum_y \pi_\theta(y | x) = 1$,构造拉格朗日函数: |
\(\mathcal{L} = \mathbb{E}_{x,y} \left[ r_\phi(x,y) - \beta \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)} \right] + \lambda \left(1 - \sum_y \pi_\theta(y|x)\right)\) 求导并解出最优策略:对 $\pi_\theta(y|x)$ 求偏导并令导数为零:
\[\frac{\partial \mathcal{L}}{\partial \pi_\theta} = r_\phi(x,y) - \beta \log \frac{\pi_\theta(y|x)}{\pi_{\text{ref}}(y|x)} - \beta + \lambda = 0\]整理得:
\[\pi_\theta(y|x) = \pi_{\text{ref}}(y|x) \exp \left( \frac{1}{\beta} r_\phi(x,y) + \frac{\lambda + \beta}{\beta} \right)\]其中 $\lambda$ 是拉格朗日乘子。通过归一化条件,引入配分函数 $Z(x)$:
\[\pi^*(y|x) = \frac{1}{Z(x)} \pi_{\text{ref}}(y|x) \exp \left( \frac{1}{\beta} r_\phi(x,y) \right), \quad Z(x) = \sum_y \pi_{\text{ref}}(y|x) \exp \left( \frac{1}{\beta} r_\phi(x,y) \right)\]最终得到最优策略的闭式解:
\[\boxed{\pi^*(y|x) = \frac{\pi_{\text{ref}}(y|x) \exp \left( \frac{1}{\beta} r_\phi(x,y) \right)}{Z(x)}}\]这里 $Z(x)$ 是归一化常数(配分函数),确保概率和为1。
步骤2:奖励函数的策略表示
重组上式,用 $\pi^*$ 表示奖励函数:
\[r^*(x,y) = \beta \log \frac{\pi^*(y|x)}{\pi_{\text{ref}}(y|x)} + \beta \log Z(x)\]配分函数 $Z(x)$ 在BT模型的差值比较中被消去(核心洞察)。
步骤3:代入BT模型
将 $r^*$ 的表达式代入BT模型:
\[p^*(y_1 \succ y_2 \mid x) = \sigma\left( \beta \log \frac{\pi^*(y_1|x)}{\pi_{\text{ref}}(y_1|x)} - \beta \log \frac{\pi^*(y_2|x)}{\pi_{\text{ref}}(y_2|x)} \right)\]意义:人类偏好概率直接由策略 $\pi^$ 和 $\pi_{\text{ref}}$ 决定,无需显式 $r^$。
4. DPO损失函数的构建
通过极大似然估计,直接优化策略 $\pi_\theta$ 以拟合偏好数据:
\[\mathcal{L}_{\text{DPO}}(\pi_\theta; \pi_{\text{ref}}) = -\mathbb{E}_{(x,y_w,y_l) \sim \mathcal{D}} \left[ \log \sigma\left( \beta \log \frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta \log \frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)} \right) \right]\]梯度更新分析:
-
偏好响应 $y_w$:增加其对数概率 $\log \pi_\theta(y_w x)$; -
非偏好响应 $y_l$:减少其对数概率 $\log \pi_\theta(y_l x)$; - 动态权重:权重由隐式奖励误差 $\sigma(\cdot)$ 决定,错误分类的样本梯度更大。
5. DPO vs. PPO的关键区别
| 特性 | PPO | DPO |
|---|---|---|
| 训练流程 | 需奖励模型 + 在线策略采样 | 直接优化策略,无需奖励模型或在线采样 |
| 计算效率 | 高复杂度(策略迭代+价值函数) | 低复杂度(单阶段监督式训练) |
| 稳定性 | 依赖KL裁剪,仍可能发散 | 损失函数平滑,训练更稳定 |
| 数据依赖 | 需实时生成响应 | 使用静态偏好数据集 |
| 理论本质 | 强化学习的策略优化 | 隐式奖励模型下的监督微调 |
DPO在情感控制、摘要生成等任务上匹配或超越PPO,且训练速度提升3倍以上。
6. DPO的实现要点
以下是针对DPO(Direct Preference Optimization)损失函数的数学推导形式与代码实现之间联系的详细解析,结合MLE(最大似然估计)推导的DPO损失公式和实际代码计算步骤进行说明。
MLE推导的DPO损失函数公式
DPO损失函数基于Bradley-Terry偏好模型,通过最大化人类偏好数据的似然概率推导而来。其核心公式为:
\[\mathcal{L}_{\text{DPO}} = -\mathbb{E}_{(x,y_w,y_l)\sim\mathcal{D}} \left[ \log \sigma \left( \beta \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \beta \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right]\]其中:
- $\sigma$:Sigmoid函数,用于将奖励差转换为概率。
- $\beta$:温度超参数,控制策略模型与参考模型的KL散度约束强度(一般取0.1–0.5)。
- $\pi_\theta$:待优化的策略模型(训练时更新参数)。
- $\pi_{\text{ref}}$:参考模型(通常为SFT模型,训练时冻结参数)。
- $y_w, y_l$:同一输入
x对应的偏好响应和非偏好响应。
公式的直观含义
优化目标为:增大偏好响应 $y_w$ 的相对概率,降低 $y_l$ 的相对概率。
- $\log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)}$:策略模型对 $y_w$ 的概率提升程度(相对于参考模型)。
- $\beta$ 调节提升幅度,避免策略模型偏离参考模型太远。
代码中的损失计算步骤
以下以PyTorch实现的DPO损失函数为例(参考),逐步说明其如何对应数学公式:
import torch
import torch.nn.functional as F
def dpo_loss(policy_chosen_logps, policy_rejected_logps,
ref_chosen_logps, ref_rejected_logps, beta=0.1):
# 计算隐式奖励差 (对应公式中的内部项)
log_ratio_w = policy_chosen_logps - ref_chosen_logps
log_ratio_l = policy_rejected_logps - ref_rejected_logps
reward_diff = beta * (log_ratio_w - log_ratio_l) # = β[log(πθ/πref)_w - log(πθ/πref)_l]
# 计算损失:-log σ(reward_diff)
loss = -F.logsigmoid(reward_diff).mean()
return loss
关键代码变量与公式的对应关系
| 数学公式 | 代码变量 | 说明 |
|---|---|---|
| $\log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)}$ | policy_chosen_logps - ref_chosen_logps |
策略模型与参考模型对 y_w 的对数概率差 |
| $\log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)}$ | policy_rejected_logps - ref_rejected_logps |
对 y_l 的对数概率差 |
| $\beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right)$ | beta * (log_ratio_w - log_ratio_l) |
隐式奖励差值(即Sigmoid输入) |
| $\log \sigma(\cdot)$ | F.logsigmoid(reward_diff) |
计算偏好概率的对数 |
| $-\mathbb{E}[\cdot]$ | .mean() |
批量数据的期望损失 |
数学公式与代码联系的深层解析
1. 对数概率的计算方式
代码中需预先计算策略模型和参考模型对完整响应序列的对数概率:
# 示例:计算模型输出的对数概率
logits = model(input_ids) # [batch, seq_len, vocab_size]
log_probs = torch.log_softmax(logits, dim=-1) # 对数概率矩阵
- 响应序列的对数概率:对
y_w或y_l的每个token概率求和:
# 提取响应部分的对数概率(假设响应位置索引为 [start:end])
response_logps = log_probs[:, start:end, :]
chosen_logps = response_logps.gather(2, response_token_ids).sum(dim=1) # 按token求和
2. 损失函数的梯度行为
反向传播时,梯度更新方向为:
- 偏好响应
y_w:增加 $\pi_\theta(y_w \mid x)$ 的概率(梯度符号为正)。 - 非偏好响应
y_l:降低 $\pi_\theta(y_l \mid x)$ 的概率(梯度符号为负)。
3. $\beta$ 的超参数作用
- $\beta \to 0$:策略模型可自由偏离参考模型(可能过拟合)。
- $\beta \to \infty$:策略模型强制接近参考模型(偏好学习失效)。
代码中通过调整 $beta$ 值平衡探索与约束(常用0.1–0.5)。
四、完整计算流程图
graph TD
A[输入批量数据] --> B[计算策略模型对数概率 πθ]
A --> C[计算参考模型对数概率 πref]
B --> D[提取 y_w 和 y_l 的 logπθ]
C --> E[提取 y_w 和 y_l 的 logπref]
D & E --> F[计算 log_ratio_w = logπθ_w - logπref_w]
D & E --> G[计算 log_ratio_l = logπθ_l - logπref_l]
F & G --> H[reward_diff = β * log_ratio_w - log_ratio_l]
H --> I[loss = -logσ reward_diff]
I --> J[反向传播更新 πθ]
五、核心洞察:公式与代码的统一性
DPO通过隐式奖励建模将强化学习问题转化为监督学习:
- 数学本质:用策略与参考模型的概率比替代显式奖励函数。
- 代码实现:直接计算对数概率差,通过Sigmoid函数构造损失。
- 效率优势:避免了RLHF中的奖励模型训练和PPO在线采样。
💡 实践建议:若需处理长度偏差(如偏好响应更长),可在损失中加入长度正则化项
\alpha (|y_w| - |y_l|)(参考正则化DPO)。
最终,DPO的数学形式与代码实现紧密耦合,其简洁性与高效性正是源于理论推导到工程实现的直接映射。
总结:DPO的理论贡献
- 化隐为显:将奖励函数隐式表示为策略与参考模型的比值,避免复杂RL流程。
- 效率提升:将RLHF三阶段简化为单阶段监督学习,降低计算成本。
- 稳定性保障:KL散度约束通过参考模型自然融入损失函数。
DPO的推导揭示了语言模型本身即是一个奖励模型,通过直接优化策略概率比,实现了人类偏好的高效对齐。
GRPO、ORPO、DAPO等
7/ 多模态
8/ 常见大模型架构
经典架构
创新架构
Mamba
RWKV
线性Attention
9/ 大模型应用
RAG
Agent
ReAct 范式
Reasoning & Acting,它通过将推理能力与行动能力动态结合,使Agent能够像人类一样在复杂环境中规划、调整并完成任务。其核心思想源于2023年ICLR的研究。 ReAct的创新在于扩展Agent的行动空间,引入“思维”(Thought)作为语言模型空间中的内部动作。这种思维不直接影响环境,而是通过推理分解任务、规划步骤、注入常识知识,并基于观察结果动态调整策略。
工作流程:ReAct通过思考(Thought)-行动(Action)-观察(Observation)(TAO) 的循环迭代推进任务:
- 思考(Thought)
Agent分析当前状态,推理下一步策略。例如:“用户需要巴黎行程但预算不足,需搜索转机方案”; - 行动(Action)
调用工具执行具体操作,如调用机票API查询“上海-巴黎转机航班”; - 观察(Observation)
获取工具返回结果(如“直飞1万元,转机6990元”),并评估是否满足约束条件; - 循环迭代
若结果未达标(如超预算),则重新进入思考阶段,直至生成最终答案或达到终止条件;
产品
Cursor、Cline等