1. 项目概述:一个“麻雀虽小,五脏俱全”的现代语言模型实现
最近在GitHub上看到一个挺有意思的项目,叫skyzh/tiny-llm。光看名字就能猜个大概,这是一个“微型”的语言模型实现。在动辄数百亿参数、需要几十张A100才能跑起来的LLM时代,这种“小”项目反而有种返璞归真的吸引力。它不是一个拿来即用的产品级模型,而更像一个教学标本,或者一个供开发者深入理解Transformer架构和现代LLM训练流程的“解剖图”。
这个项目的核心价值在于“透明”和“可操作性”。它用相对精简的代码(项目自称约1000行),清晰地展示了从数据预处理、模型架构定义、训练循环到推理生成的完整链路。对于想踏入大模型领域,但又对PyTorch里那些动辄数万行的代码库望而生畏的开发者、学生,或者单纯对LLM内部工作原理感到好奇的技术爱好者来说,tiny-llm提供了一个绝佳的切入点。你不用在复杂的分布式训练、混合精度优化、各种工程化trick的迷宫里打转,而是能直接看到最核心的数学运算和逻辑是如何一步步组织起来的。
我自己也花时间把代码拉下来跑了一遍,感觉就像在拼一个精致的高达模型,每个零件(模块)都清晰可见,组装过程(训练流程)一目了然。接下来,我就结合代码和实操,把这个项目的里里外外拆解一遍,聊聊它的设计思路、实现细节,以及你在复现或学习时可能会遇到的“坑”和可以获得的启发。
2. 核心架构与设计哲学解析
2.1 为什么是“Tiny”?定位与取舍
tiny-llm的“小”是刻意为之的设计选择。这种“小”体现在多个维度:
- 模型规模小:它实现的通常是一个参数在千万级别(如10M-100M)的微型Transformer。这个规模远小于GPT-3(175B)甚至LLaMA-7B,但足以验证架构的正确性,并在小数据集(如莎士比亚作品、维基百科片段)上学习到有意义的语言模式。
- 代码库精简:项目追求极简的实现,只保留最核心的组件。这意味着它可能没有集成
deepspeed、flash-attention(最新版可能支持)、复杂的日志系统或监控看板。它的训练循环可能就是最朴素的PyTorchtrain()和eval()模式切换。 - 功能聚焦:它专注于“语言模型”这一核心任务,即根据上文预测下一个词(token)。你可能不会在这里找到复杂的指令微调(Instruction Tuning)、RLHF(人类反馈强化学习)或者检索增强生成(RAG)的代码。它是一个纯净的自回归语言模型实现。
这种设计的优势非常明显:极低的学习和实验门槛。你可以在自己的笔记本电脑(有GPU更好)上,在几分钟内完成代码的阅读、理解,并启动训练。任何错误或异常都很容易追溯到具体的几行代码,而不是在庞大的代码库中迷失。它的目标是成为一个“教学工具”和“研究沙盒”,让使用者能快速验证想法,比如修改注意力机制、尝试新的归一化层,或者改变位置编码方式。
2.2 核心组件拆解:现代Transformer的骨架
尽管“小”,但tiny-llm必须包含现代LLM的核心骨架。根据我对类似项目和当前主流架构的观察,它通常会包含以下关键模块:
2.2.1 分词器(Tokenizer)虽然简单,但不可或缺。它可能直接使用tiktoken(OpenAI的BPE分词器)或者Hugging Face tokenizers库来加载一个现成的词表(如GPT-2的)。在“tiny”的语境下,它甚至可能实现一个极其简单的基于字符或空格的词表。分词器负责将文本字符串转化为模型能理解的数字ID序列(token ids),以及反向的解码。
2.2.2 嵌入层(Embedding)包括词嵌入(Token Embedding)和位置嵌入(Positional Embedding)。词嵌入是一个查找表,将每个token ID映射为一个高维向量。位置嵌入则用来给模型注入序列中token的顺序信息。这里可能会实现几种主流方案:
- 绝对位置编码(如正弦余弦公式):原始Transformer论文的方法,但目前在大型模型中较少使用。
- 旋转位置编码(RoPE):LLaMA、GPT-NeoX等模型采用的主流方法,通过旋转矩阵将位置信息注入到注意力计算中,能更好地处理长序列。
- ALiBi(Attention with Linear Biases):另一种流行的方案,通过在注意力分数上添加一个与距离成比例的偏置来实现,完全无需可学习的嵌入参数。
tiny-llm很可能会选择实现RoPE或ALiBi,因为它们是当前的高效标配。
2.2.3 Transformer块(Transformer Block)这是模型的核心。每个块通常包含:
- 注意力层(Attention):实现多头自注意力机制。关键步骤包括计算Q(查询)、K(键)、V(值)矩阵,进行缩放点积注意力计算。这里会涉及
causal mask(因果掩码)的实现,以确保在生成时,每个位置只能看到它之前的token,这是语言模型自回归特性的基础。 - 前馈网络(Feed-Forward Network, FFN):通常是一个两层MLP,中间有一个非线性激活函数(如GELU、SwiGLU)。在小型模型中,可能使用标准的GELU。
- 归一化层(Normalization):在注意力层和前馈层前后应用层归一化(LayerNorm)。现代架构如LLaMA使用
RMSNorm(均方根归一化)代替LayerNorm,因为计算更简单且效果相当。 - 残差连接(Residual Connection):每个子层(注意力、FFN)周围都有残差连接,这是训练深层网络的关键。
2.2.4 输出层(Output Layer)最后一个Transformer块的输出经过最终的层归一化后,会通过一个线性层(通常与词嵌入层共享权重,以节省参数并可能提升效果)映射回词表大小的向量。这个向量经过Softmax后,就得到了下一个token的概率分布。
2.3 训练流程设计:从数据到损失
一个完整的训练流程包括以下几个环环相扣的部分:
- 数据加载与批处理:从文本文件(如
.txt)中读取数据,使用分词器进行编码,然后切割成固定长度(如512)的序列。批处理(Batching)时,需要小心处理填充(Padding)和注意力掩码(Attention Mask),确保模型不会从填充位置学习到无意义的信息。 - 前向传播(Forward):将token ID批次送入模型,依次经过嵌入层、多个Transformer块、输出层,最终得到对数概率(logits)。
- 损失计算:语言模型的标准损失是交叉熵损失(Cross-Entropy Loss)。具体来说,我们将模型对每个位置预测的下一个token的概率分布,与真实的下一个token的标签(one-hot形式)进行比较。通常我们会忽略对填充token的损失计算。
- 反向传播与优化:计算损失相对于模型所有参数的梯度,然后使用优化器(如AdamW)更新参数。这里会涉及学习率调度(如余弦退火)、梯度裁剪(防止梯度爆炸)等训练稳定化技术。
- 评估与生成:在验证集上计算困惑度(Perplexity, PPL)来评估模型性能。同时,会实现一个简单的生成函数(如贪婪解码或Top-p采样),用于在训练中途或之后观察模型生成的文本质量,这是最直观的评估方式。
tiny-llm的价值就在于,它用最直白的代码,把上述这个复杂的流程清晰地串联了起来,让你能一眼看穿大模型训练的“黑盒”。
3. 关键实现细节与源码级解读
让我们深入到代码层面,看看一些关键部分是如何实现的,并讨论其中的设计抉择和注意事项。
3.1 旋转位置编码(RoPE)的实现
RoPE是当前许多LLM的标配。它的核心思想不是将位置信息作为静态向量加到词向量上,而是通过旋转矩阵来变换查询(Q)和键(K)向量,使内积计算自然地携带相对位置信息。
在tiny-llm的注意力层中,你可能会看到类似下面的伪代码逻辑:
def apply_rotary_pos_emb(q, k, freqs): """ q, k: [batch_size, num_heads, seq_len, head_dim] freqs: 预先计算好的旋转频率,形状可能为 [seq_len, head_dim//2] """ # 将q和k的最后一维(head_dim)视为复数对 (x1, x2, x3, x4,...) -> (x1+ix2, x3+ix4,...) q_complex = torch.view_as_complex(q.float().reshape(*q.shape[:-1], -1, 2)) k_complex = torch.view_as_complex(k.float().reshape(*k.shape[:-1], -1, 2)) # 应用旋转:复数乘法 e^{i * m * theta} = cos(m*theta) + i*sin(m*theta) # freqs_cis 是预先计算好的 cos(m*theta) 和 sin(m*theta) q_rotated = q_complex * freqs_cis k_rotated = k_complex * freqs_cis # 转换回实数表示 q_out = torch.view_as_real(q_rotated).flatten(-2) k_out = torch.view_as_real(k_rotated).flatten(-2) return q_out.type_as(q), k_out.type_as(k)注意事项:
- 精度问题:RoPE计算涉及三角函数,对数值精度敏感。在混合精度训练(如FP16)时,需要确保
freqs_cis的计算和复数乘法在足够的精度(如FP32)下进行,否则可能导致训练不稳定或效果下降。通常的实践是在模型初始化时用FP32计算好freqs_cis并缓存,在前向传播时将其转换为与Q/K相同的精度。 - 维度匹配:
head_dim(每个注意力头的维度)必须能被2整除,因为我们将向量视为复数对。常见的设置如head_dim=64或128都满足。 - 外推性:RoPE的一个优点是具有良好的长度外推性。即,如果训练时最大序列长度为2048,在推理时处理稍长(如2300)的序列,模型可能仍能工作,但性能会逐渐下降。更先进的外推方法(如NTK-aware scaling、YaRN)是当前的研究热点,但在基础版
tiny-llm中可能不会涉及。
3.2 注意力机制中的因果掩码(Causal Mask)
这是确保语言模型“只能看过去,不能看未来”的关键。在计算注意力分数矩阵(attn = Q @ K.transpose(-2, -1))之后,我们需要将一个下三角矩阵(主对角线及以下为0,以上为负无穷)加到attn上。
def causal_mask(size): """生成一个下三角矩阵,对角线及以下为0,以上为负无穷(-inf)""" mask = torch.triu(torch.ones(size, size), diagonal=1).bool() return mask # 形状 [size, size] # 在注意力计算中应用 attn_scores = Q @ K.transpose(-2, -1) / math.sqrt(d_head) # [batch, num_heads, seq_len, seq_len] attn_scores = attn_scores.masked_fill(causal_mask(seq_len), float('-inf')) attn_weights = F.softmax(attn_scores, dim=-1)实操心得:
- 效率:对于很长的序列,这个
[seq_len, seq_len]的掩码矩阵在内存上可能成为瓶颈。在实际的大型训练中,通常会使用更高效的“滑动窗口注意力”或“块状因果掩码”。但在tiny-llm的学习场景下,全掩码是最清晰易懂的实现。 - 广播:注意掩码的形状需要能与
attn_scores进行广播。通常我们的掩码是[1, 1, seq_len, seq_len],这样能自动广播到所有的批次(batch)和注意力头(head)上。
3.3 前馈网络(FFN)与激活函数
标准的FFN是FFN(x) = W2 * GELU(W1 * x + b1) + b2。在LLaMA等模型中,使用了SwiGLU变体:FFN(x) = (Swish(xW1) * (xW2)) W3,其中Swish(x) = x * sigmoid(x)。SwiGLU被证明比标准GELU更高效。
在tiny-llm中,为了极简,可能使用GELU。但了解SwiGLU的实现也很有益:
class SwiGLU(nn.Module): def __init__(self, dim, hidden_dim): super().__init__() self.w1 = nn.Linear(dim, hidden_dim, bias=False) self.w2 = nn.Linear(dim, hidden_dim, bias=False) self.w3 = nn.Linear(hidden_dim, dim, bias=False) def forward(self, x): return self.w3(F.silu(self.w1(x)) * self.w2(x)) # F.silu 就是 Swish 激活函数为什么这么设计?SwiGLU相当于有两个并行的门控线性层(w1和w2),其中一个经过Swish激活后作为“门”来控制另一个的信息流。这种门控机制能让模型更灵活地选择通过哪些信息,在实践中往往能带来更好的性能,尽管参数略有增加。
3.4 权重初始化与稳定性
Transformer模型对初始化非常敏感。糟糕的初始化可能导致训练初期梯度爆炸或消失。常见的初始化策略包括:
- 线性层/嵌入层:通常使用正态分布初始化,如
N(0, 0.02)或更小的标准差。nn.Linear默认的初始化(Kaiming Uniform)对于Transformer可能不是最优。 - 注意力层的QKV投影:有些实现会对Q和K的权重用更小的标准差初始化(如0.01),而对V用标准初始化,这有助于稳定训练初期的注意力分布。
- 输出层:最后一个线性层(将隐藏状态映射到词表)的权重,如果与词嵌入层共享,则初始化已经完成。如果不共享,也需要小心初始化,避免初始logits过大导致Softmax溢出或梯度问题。
在tiny-llm中,你可能会看到一个集中的init_weights函数,应用这些启发式规则。这是保证模型能从零开始成功训练的第一步。
4. 从零开始训练一个微型语言模型
假设我们现在要利用tiny-llm的代码框架,在某个小数据集上从头训练一个模型。以下是详细的步骤和核心环节。
4.1 环境准备与数据预处理
环境:Python 3.8+, PyTorch 1.12+(建议2.0+以获得更好的性能和特性), 以及tiktoken或transformers库用于分词。
数据:我们选择“莎士比亚全集”作为示例数据集。它是一个中等规模、风格独特的英文文本,非常适合小模型学习。
- 下载数据:可以从古登堡计划等网站获取纯文本文件。
- 清洗与分割:简单的清洗,如去除多余的空格、换行符。然后将整个文本分割成训练集和验证集(如90%/10%)。
- 构建分词器:为了简单,我们使用
tiktoken加载GPT-2的cl100k_base词表。这个分词器能处理大多数英文文本。import tiktoken enc = tiktoken.get_encoding("cl100k_base") train_tokens = enc.encode(train_text) # 得到一个巨大的整数列表 val_tokens = enc.encode(val_text) - 创建数据集:我们需要将长长的token序列切割成固定长度的片段(如
block_size=256),并制作成输入-标签对。标签就是输入向右偏移一位。def create_dataset(tokens, block_size): # tokens: 一维的token列表 dataset = [] for i in range(0, len(tokens) - block_size, block_size): # 可以跳跃,也可以滑动 chunk = tokens[i:i+block_size+1] # 多取一个作为标签 input_ids = torch.tensor(chunk[:-1], dtype=torch.long) target_ids = torch.tensor(chunk[1:], dtype=torch.long) dataset.append((input_ids, target_ids)) return dataset注意:这里我们采用了非重叠的块切割。也可以使用滑动窗口(步长为1)来生成更多样本,但样本间相关性极高。对于小数据集,非重叠切割通常足够。
4.2 模型配置与初始化
根据tiny-llm的代码结构,我们需要定义一个配置类或字典来设置模型超参数。一个典型的微型配置可能如下:
model_config = { 'vocab_size': enc.n_vocab, # 分词器词表大小,cl100k_base是100256左右 'dim': 512, # 模型隐藏层维度 'n_layers': 6, # Transformer层数 'n_heads': 8, # 注意力头数 'head_dim': 64, # 每个头的维度,需满足 n_heads * head_dim = dim 'ffn_dim_multiplier': 4, # FFN隐藏层维度是dim的多少倍,通常为4 'norm_eps': 1e-5, # 归一化层的epsilon 'max_seq_len': 256, # 最大序列长度,需与数据块大小匹配 'rope_theta': 10000.0, # RoPE的base频率 'dropout': 0.1, # 用于正则化的dropout率 }然后,根据这个配置初始化模型。确保调用我们之前讨论的init_weights函数进行正确的参数初始化。
4.3 训练循环的构建
训练循环是机器学习工程的“心脏”。一个健壮的基础训练循环包括:
model = TinyLLM(model_config) model.to(device) # 'cuda' 或 'cpu' optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs) for epoch in range(num_epochs): model.train() train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) for batch_inputs, batch_targets in train_loader: batch_inputs, batch_targets = batch_inputs.to(device), batch_targets.to(device) optimizer.zero_grad() # 前向传播 logits = model(batch_inputs) # [batch, seq_len, vocab_size] # 计算损失。需要将logits和targets reshape成二维和二维 loss = F.cross_entropy(logits.view(-1, logits.size(-1)), batch_targets.view(-1)) # 反向传播 loss.backward() # 梯度裁剪,防止爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() # ... 记录loss ... scheduler.step() # 验证阶段 model.eval() with torch.no_grad(): val_loss = 0 for val_inputs, val_targets in val_loader: # ... 计算验证集loss ... val_ppl = torch.exp(val_loss) # 困惑度 ≈ exp(loss) print(f"Epoch {epoch}, Val PPL: {val_ppl:.2f}") # 可选:保存检查点,或采样生成一些文本看看效果 if epoch % 5 == 0: generate_sample_text(model, enc, device, start_prompt="To be or not to be")关键参数解析:
- 学习率(lr):
1e-3是AdamW一个比较常用的起点。对于更小的模型或数据集,可能需要调低(如5e-4)。 - 权重衰减(weight_decay):
0.01或0.1是常见的值,用于防止过拟合。 - 梯度裁剪(clip_grad_norm_):
max_norm=1.0是一个保守且常用的值。它能有效防止训练因梯度爆炸而崩溃。 - 批大小(batch_size):在GPU内存允许的情况下,尽可能调大。更大的batch size通常能使梯度估计更稳定,但可能会影响泛化能力。对于小模型和数据集,
32或64是不错的选择。
4.4 文本生成(推理)的实现
训练是为了生成。实现一个简单的贪婪解码或采样函数至关重要,它能直观地反映模型的学习成果。
def generate(model, tokenizer, prompt, max_new_tokens=50, temperature=1.0, top_p=0.9): """ 使用模型生成文本。 prompt: 起始字符串 temperature: 温度,>1更随机,<1更确定 top_p: 核采样参数,保留累积概率超过p的最小词集 """ model.eval() tokens = tokenizer.encode(prompt) input_ids = torch.tensor(tokens, dtype=torch.long).unsqueeze(0).to(device) for _ in range(max_new_tokens): # 注意力:推理时需要传入之前所有生成的token,并应用因果掩码 # 模型内部会缓存之前的K,V以加速(如果实现了的话),这里为简单起见,每次都重新计算 with torch.no_grad(): logits = model(input_ids) # [1, seq_len, vocab_size] # 取最后一个位置的logits next_token_logits = logits[0, -1, :] / temperature # Top-p (nucleus) 采样 sorted_logits, sorted_indices = torch.sort(next_token_logits, descending=True) cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1) # 移除累积概率超过top_p的token sorted_indices_to_remove = cumulative_probs > top_p # 但至少保留一个token sorted_indices_to_remove[1:] = sorted_indices_to_remove[:-1].clone() sorted_indices_to_remove[0] = False indices_to_remove = sorted_indices[sorted_indices_to_remove] next_token_logits[indices_to_remove] = -float('Inf') # 从剩余分布中采样 probs = F.softmax(next_token_logits, dim=-1) next_token_id = torch.multinomial(probs, num_samples=1) # 将新token附加到序列中 input_ids = torch.cat([input_ids, next_token_id.unsqueeze(0)], dim=1) # 如果序列过长,可以只保留最后max_seq_len个token(滑动窗口),但这里简单处理 generated_tokens = input_ids[0].tolist() return tokenizer.decode(generated_tokens)这个生成函数包含了温度调节和Top-p采样,是当前获得高质量、多样性文本的常用技术组合。贪婪解码(temperature=0)只是取概率最大的token,生成结果通常很确定但可能枯燥。
5. 常见问题、调试技巧与性能优化
即使有了清晰的代码,在训练自己的tiny-llm时,你依然会遇到各种问题。下面是一些常见坑点和解决思路。
5.1 训练不收敛或Loss为NaN
这是最令人头疼的问题。可以按以下步骤排查:
- 检查数据:确保数据加载和分词正确。打印几个样本的
input_ids和target_ids,看看是否对齐。标签是否真的是输入右移一位? - 检查损失计算:确认交叉熵损失函数的输入(logits)和目标的形状是否正确。确保没有在填充token(如果有的话)上计算损失。
- 梯度爆炸:这是导致NaN的常见原因。
- 梯度裁剪:确保你已经实施了梯度裁剪(
clip_grad_norm_)。可以尝试更小的裁剪阈值,如0.5。 - 学习率过高:尝试将学习率降低一个数量级,例如从
1e-3降到1e-4。 - 初始化问题:回顾模型的权重初始化代码。尝试使用更小的初始化标准差。
- 梯度裁剪:确保你已经实施了梯度裁剪(
- 激活函数/数值稳定性:检查是否有地方出现了极大的中间值。可以在前向传播中添加一些断言或打印语句,监控张量的范围(如
torch.isnan(x).any())。对于Softmax,确保输入logits不会过大,否则可能导致溢出。PyTorch的F.cross_entropy内部已经做了数值稳定处理,通常没问题。 - 混合精度训练:如果你使用了
torch.cuda.amp进行自动混合精度训练,在初期调试时建议先关闭,因为梯度缩放(Grad Scaling)有时会掩盖问题。等FP32训练稳定后再开启AMP。
5.2 模型过拟合
在小数据集上训练“大”模型很容易过拟合,表现为训练损失持续下降,但验证损失很早就开始上升。
- 增加正则化:
- Dropout:在Transformer的注意力层和前馈层后添加Dropout。
tiny-llm的配置中可能已经有dropout=0.1,可以尝试增加到0.2。 - 权重衰减:增加
AdamW优化器中的weight_decay参数,如从0.01调到0.1。
- Dropout:在Transformer的注意力层和前馈层后添加Dropout。
- 数据增强:对于文本,简单的方法包括随机打乱句子顺序(在文档级别)、随机删除或替换少量token(类似BERT的MLM,但需小心,因为语言模型是自回归的)。
- 早停(Early Stopping):持续监控验证集困惑度(PPL),当其在连续多个epoch(如5-10个)不再下降时,停止训练。
- 减小模型容量:如果过拟合严重,最直接的方法是减少模型层数(
n_layers)或隐藏维度(dim)。
5.3 生成文本质量差或无意义
模型训练完了,但生成的文本像是乱码或不断重复。
- 检查生成逻辑:确保生成函数中的采样逻辑(温度、Top-p)正确。过高的温度(如>1.5)会导致输出完全随机;温度=0(贪婪解码)可能导致重复循环。
top_p值过低(如<0.5)会限制候选词集,可能生成不通顺的文本。建议从temperature=0.8, top_p=0.9开始尝试。 - 模型未充分训练:困惑度(PPL)是重要指标。在莎士比亚数据集上,一个训练良好的小模型PPL应该能降到10以下。如果PPL还在几十甚至上百,说明模型还没学会语言的基本规律,需要更多训练轮次或检查训练过程。
- 训练数据噪声:如果数据清洗不干净,包含大量无关字符、代码或格式标记,模型会学到这些噪声。
- 推理/训练不一致:确保模型在推理时(
model.eval())和训练时(model.train())的行为一致。Dropout和BatchNorm(如果有)在eval模式下会被关闭。
5.4 训练速度慢
在个人电脑上训练,速度是主要瓶颈。
- 使用GPU:这是最大的加速因素。确保PyTorch安装了CUDA版本。
- 增大批大小:在GPU内存允许的范围内,尽可能增大
batch_size。更大的批次能更充分地利用GPU并行计算能力,并减少参数更新的次数。 - 梯度累积:如果GPU内存不足以容纳大的批大小,可以使用梯度累积。例如,设置
batch_size=8,但每4步才更新一次权重(累积步数=4),这等效于batch_size=32的效果,但峰值内存占用只有batch_size=8。accumulation_steps = 4 optimizer.zero_grad() for i, (inputs, targets) in enumerate(train_loader): loss = compute_loss(model, inputs, targets) loss = loss / accumulation_steps # 损失按累积步数缩放 loss.backward() if (i+1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad() - 混合精度训练:使用
torch.cuda.amp可以显著减少GPU内存占用并加快计算速度。但需注意上文提到的稳定性问题。 - 简化模型:如果只是学习,可以进一步减小
dim、n_layers和seq_len。一个4层、256维的模型也能学到不少东西,且训练飞快。
5.5 内存不足(OOM)
尤其是在尝试增大batch_size或seq_len时遇到。
- 减小批大小或序列长度:这是最直接的方法。
- 梯度检查点(Gradient Checkpointing):这是一种用计算时间换内存的技术。它在前向传播时不保存所有中间激活,而是在反向传播时重新计算一部分。PyTorch中可以通过
torch.utils.checkpoint.checkpoint实现。对于Transformer层,你可以对每个Transformer块应用检查点。# 在定义forward时 def forward(self, x): for layer in self.layers: x = torch.utils.checkpoint.checkpoint(layer, x) # 这会节省内存但更慢 return x - 使用更高效的内存格式:确保你的数据加载器使用
pin_memory=True(当使用GPU时),并使用num_workers > 0来并行加载数据,避免数据加载成为瓶颈导致GPU空闲。
通过tiny-llm这个项目,你获得的不只是一段能运行的代码,而是一张通往大模型核心地带的详细地图。它强迫你去理解每一个张量变换,思考每一个超参数的意义,亲手解决训练中冒出的每一个问题。这个过程带来的深度理解,是单纯调用高级API无法比拟的。当你成功让这个微型模型吐出第一句看似通顺的莎士比亚风格句子时,那种成就感,就是学习技术最大的乐趣所在。