所有之前:这篇是关于NanoGPT项目的核心代码的总结和理解,我会持续把主流技术加入到这个项目中,并对核心代码进行解释。完整项目代码需要查询NanoGPT.

模型架构

Base

最基础的模型架构是一个Decoder-only的Transformer模型,代码实现参考CodeByMyself,在CodeByMyself的基础上,使用了更现代化的Rope位置编码和SwiGLU激活函数, 并且加入了Moe用于和FFN对比,可在配置文件中选择是否使用Moe。部分代码如下:

# swiglu
class ffn_swiglu(nn.Module):

    def __init__(self, d_model, d_hidden, dropout=0.1):
        super().__init__()
        #第一个 Linear 输出 2 * d_hidden
        self.w_gate_up = nn.Linear(d_model, 2 * d_hidden)  #同时生成 gate 和 up
        self.w_down = nn.Linear(d_hidden, d_model)
        self.dropout = nn.Dropout(dropout)


    def forward(self, x):
        # x: [B, L, d_model]
        gate_up = self.w_gate_up(x)                     # [B, L, 2 * d_hidden]
        gate, up = gate_up.chunk(2, dim=-1)             # each: [B, L, d_hidden]
        swiglu_out = F.silu(gate) * up                  # [B, L, d_hidden]
        out = self.w_down(self.dropout(swiglu_out))     # [B, L, d_model]
        return out
# moe

class moe(nn.Module):
    def __init__(self,n_expert,d_model,top_k=2,dropout=0.1):
        super().__init__()
        self.d_model = d_model
        self.n_expert = n_expert
        self.top_k = top_k
        self.dropout = dropout

        self.gate = nn.Linear(d_model,n_expert,bias=False)
        self.softmax = nn.Softmax(dim=-1)
        # self.experts = nn.ModuleList([ffn(self.d_model,self.d_model*4,dropout) for _ in range(n_expert)])  relu激活的expert
        self.experts = nn.ModuleList([ffn_swiglu(self.d_model,self.d_model*2,dropout) for _ in range(n_expert)])

    def forward(self,x):
        b,t,d = x.shape
        assert d == self.d_model,f"输入维度和moe设置维度不匹配"

        x_flat = x.view(-1,d)
        N = x_flat.shape[0]

        gate_logits = self.gate(x_flat)
        topk_weights,topk_indices = torch.topk(gate_logits,self.top_k,dim=-1)
        topk_weights= self.softmax(topk_weights)

        out = torch.zeros_like(x_flat)

        for i,expert in enumerate(self.experts):
            mask = (topk_indices == i)
            if not mask.any():
                continue
            token_indices,expert_pos = torch.where(mask)
            select_x = x_flat[token_indices]
            expert_out = expert(select_x)
            weights = topk_weights[token_indices, expert_pos]
            out.index_add_(0, token_indices, expert_out * weights.unsqueeze(1))
        return out.view(b,t,d)

def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
    """
    预计算旋转角度的复数表示(cos + i*sin)
    """
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    t = torch.arange(end, device=freqs.device)
    freqs = torch.outer(t, freqs).float()  # [end, dim//2]
    freqs_cos = freqs.cos()
    freqs_sin = freqs.sin()
    return freqs_cos, freqs_sin  # 分开存储更便于后续操作,不使用torch.complex类型,因为部分系统不支持

SwiGLU激活函数的实现是通过将线性层的输出分成两部分,一部分作为gate,另一部分作为up,然后使用SILU函数对gate进行激活,并与up相乘得到最终的输出。

Moe的实现则是通过一个线性层计算每个token对于每个专家的权重,然后选择top_k个专家进行计算,最后将专家的输出加权求和得到最终的输出。这是一种稀疏hard routing的实现方式,现在也有soft routing的实现方式,后续会加入,并且没有对专家进行负载均衡等优化。

RoPE的位置编码则是通过预计算旋转角度的复数表示来实现的,RoPE的核心思想是将位置编码表示为一个旋转操作,使得模型能够更好地捕捉序列中的相对位置信息,公式如下:

$$\begin{aligned} \text{RoPE}(x, pos) = \text{Re}(x) \cdot \cos(\theta_{pos}) + \text{Im}(x) \cdot \sin(\theta_{pos}) \end{aligned}$$

其中,$x$ 是输入的token表示,$pos$ 是位置索引,$\theta_{pos}$ 是根据位置索引计算的旋转角度。

Pre-Training

预训练阶段选了TinyStoriesV2数据集,dataset如下:

class StoryDataset(Dataset):
    def __init__(self, data_path, block_size):
        self.block_size = block_size
        self.tokenizer = tiktoken.get_encoding("cl100k_base")
        self.vocab_size = self.tokenizer.n_vocab
        self.eot_token = self.tokenizer.eot_token

        with open(data_path, 'r', encoding='utf-8') as f:
            text = f.read()

        self.stories = [s.strip() for s in text.split('<|endoftext|>') if s.strip()]

    def __len__(self):
        return len(self.stories)

    def __getitem__(self, idx):
        story_text = self.stories[idx]
        tokens = self.tokenizer.encode(story_text)
        if len(tokens) < self.block_size + 1:
            tokens += [self.eot_token] * (self.block_size + 1 - len(tokens))
        else:
            start_idx = random.randint(0, len(tokens) - (self.block_size + 1))
            tokens = tokens[start_idx:start_idx + self.block_size + 1]
            
        x = torch.tensor(tokens[:-1], dtype=torch.long)
        y = torch.tensor(tokens[1:], dtype=torch.long)
        return x, y

Tokenizer使用了tiktoken库,不同故事之间用特殊分隔符 <|endoftext|> 隔开。然后将每个故事按分隔符切分、去除首尾空白,保存到 self.stories 列表中。空故事被过滤掉。

getitem 方法中,首先获取指定索引的故事文本,然后使用 tokenizer 将文本编码成 token ID 列表。接着根据 block_size 的要求,如果 token 数量不足,则在末尾填充特殊的 eot_token 直到满足长度;如果 token 数量超过 block_size + 1,则随机选择一个起始位置,从 token 列表中截取连续的 block_size + 1 个 token。最后将输入 x 定义为前 block_size 个 token,目标 y 定义为后 block_size 个 token,返回它们作为训练样本。

这个写法还有很多优化空间:一次性加载全部文本会占用内存;填充使用eot_token可能会引入不必要的训练信号;随机截取可能导致训练不稳定(预训练阶段可以接受);重复的 tokenization增加训练时间。

训练循环很简单,不在这里写了。

SFT指令微调