【LLM訓練系列】NanoGPT原始碼詳解和中文GPT訓練實踐

LeonYi發表於2024-08-25

本文是【訓練LLM系列】的第一篇,主要重點介紹NanoGPT程式碼以及中文、英文預訓練實踐。最新版參見我的知乎:https://zhuanlan.zhihu.com/p/716442447

除跑通原始NanoGPT程式碼之外,分別使用了《紅樓夢》、四大名著和幾十本熱門網路小說,進行了字元級、自行訓練tokenizer以及使用Qwen2的Tokenizer的中文GPT訓練嘗試,並展示了續寫的效果。

可供快速預訓練中文GPT使用。

第二篇會透過debug分析的方式來學習NanoGPT。後續還會介紹一些開源小LLM訓練專案,也是實踐+程式碼分析的思路。
NanoGPT專案

碼字不易,轉載請註明出處:LeonYi

0、前言

當下開源效果每過一段時間就越來越好,大模型已被頭部大廠壟斷,絕大多數人都不會有訓練LLM的需要

大部分場景是偏應用工作中因向業務效果看齊,更多時間會花在提示詞最佳化、資料+各種任務LoRA微調,連領域微調或全參微調的時間都越來越少或沒有。

學習小LLM訓練的目的,是為了掌握原理,也可以為訓練自己的LLM提供指導經驗:

  • 專案積累的是實戰落地經驗,但深入LLM訓練原理也很重要,這可以為設計更可行和靠譜的方案提供支撐。實戰和原理互為補充。

  • 就算跑過小模型預訓練和增量預訓練開源LLM,還是差點什麼。原因在於沒有深入到具體的訓練過程、吃透原理。

    預訓練過程屬於整資料和啟動指令碼,調參空間實在有限。只能算跑通

  • 目前進展日新月異,ToDoList越來越多,與其更多的時間精力跟蹤前沿,不如花時間吃透基礎。模型演算法千變萬化但不離其宗,掌握基礎可以不變應萬變。

    穩定且能夠遷移的一些基本原理: 基礎模型演算法、分詞器、最佳化器、Pytorch底層原理、演算法涉及的統計、矩陣、微積分等數學、高效能運算和計算機系統原理。

使用開源的LLM有時像黑盒,但自己掌握原理實踐訓練出小的LLM,大模型的黑盒就被解開了。

尤其是,準備資料SFT自己訓練好的Base模型,在回答問題時,是一種非常特殊的感覺。

總之,最近對小規模LLM訓練實踐了一番。學習的思路: 先跑專案,再學習程式碼,然後改動實踐。

1、NanoGPT介紹

NanoGPT是karpathy在23年開源的復現GPT2規模LLM的專案:https://github.com/karpathy/nanoGPT。

專案無特別依賴,給定語料本地筆記本即可快速訓練自己的小規模的因果語言模型。
專案requiremenets

1.1 專案解析

專案頁面

專案主要程式碼

  • data

    • 存放原始資料,以及預處理Tokenize的資料
    • 預處理Tokenize程式碼(支援非常簡單的字元級分詞,和tiktoken的GPT2分詞)。
    • 預處理程式碼的邏輯:劃分訓練和驗證資料,然後分詞後,儲存為numpy的int格式,持久化為bin檔案,用於在訓練時基於numpy的memmap分batch讀取硬碟上的大的tokenized檔案,供mini-batch的訓練。

      這裡非常容易就可以用Transformers的tokenizers訓練一個自己的分詞器或直接用Qwen2等現有tokenizer

  • config

    • 存放訓練和微調gpt2,以及評估open gpt2的程式碼

      這裡可以自定義模型配置,以及訓練的超引數

  • model.py

    • 實現了GPT
  • train.py

    • GPT訓練程式碼。支援PyTorch Distributed Data Parallel (DDP)的進行單機多卡和多機多卡分散式訓練。
  • sample.py

    • GPT推理

特性:

  • 特別適合在Pycharm上debug每一步訓練過程,深入理解TransformerDecoder的訓練步驟。
  • 適合對程式碼進行魔改(直接把model.py換成modeling_qwen.py,或一步步修改model.py的GPT模型結構)

接下來,對程式碼做個簡要介紹。

實際上GPT模型結構的程式碼,和之前我的一篇numpy實現GPT文章:https://zhuanlan.zhihu.com/p/679330102 非常類似,只不過從numpy遷移到torch.

1.2 NanoGPT的模型程式碼實現

LLM-visualization: https://bbycroft.net/llm
LLM-visualization專案: https://bbycroft.net/llm

可同時結合LLM-visualization和Pycharm Debug NanoGPT的程式碼,效果最佳。

GPT核心是CausalSelfAttention+LayerNorm+MLP構成的TransformerDecoder, 即下面程式碼中的Block。

import math
import inspect
from dataclasses import dataclass
import torch
import torch.nn as nn
from torch.nn import functional as F

class LayerNorm(nn.Module):
    """ LayerNorm but with an optional bias. PyTorch doesn't support simply bias=False """
    def __init__(self, ndim, bias):
        super().__init__()
        self.weight = nn.Parameter(torch.ones(ndim))
        self.bias = nn.Parameter(torch.zeros(ndim)) if bias else None

    def forward(self, input):
        return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)

class MLP(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.c_fc    = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
        self.gelu    = nn.GELU()
        self.c_proj  = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x):
        x = self.c_fc(x)  # 升維變化, (B, T, C) -> (B, T, 4*C)
        x = self.gelu(x)
        x = self.c_proj(x)
        x = self.dropout(x)
        return x

TransformerDecoder結構

\[ \mathbf{H} = \mathrm{softmax}\left(\frac{\mathbf Q \mathbf K^\top }{\sqrt{d}}\right) \mathbf V \in \mathbb{R}^{T\times d} \]

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        assert config.n_embd % config.n_head == 0
        # key, query, value projections for all heads, but in a batch
        self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
        # output projection
        self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
        # regularization
        self.attn_dropout = nn.Dropout(config.dropout)
        self.resid_dropout = nn.Dropout(config.dropout)
        self.n_head = config.n_head
        self.n_embd = config.n_embd
        self.dropout = config.dropout
        # flash attention make GPU go brrrrr but support is only in PyTorch >= 2.0
        self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
        if not self.flash:
            print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
            # causal mask to ensure that attention is only applied to the left in the input sequence
            self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
                                        .view(1, 1, config.block_size, config.block_size))

    def forward(self, x):
        B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)

        # calculate query, key, values for all heads in batch and move head forward to be the batch dim
        q, k, v  = self.c_attn(x).split(self.n_embd, dim=2)
        k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
        q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
        v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)

        # causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
        if self.flash:
            # efficient attention using Flash Attention CUDA kernels
            y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True)
        else:
            # manual implementation of attention
            ## 1、計算注意力得分
            att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))  # (B, nh, T, hs) x (B, nh, hs, T)
            ## 2、注意力得分加入因果掩碼上三角矩陣
            att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))  # (B, nh, T, T)
            ## 3、Softmax計算注意力係數
            att = F.softmax(att, dim=-1) # (B, nh, T, T)
            ## 4、Attention Dropout
            att = self.attn_dropout(att)
            ## 5、計算注意力結果(基於因果注意力係數進行加權求和,每個位置對當前位置及其之前的值向量加權求和)
            y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)

        # re-assemble all head outputs side by side, nh個頭的結果合到一起,然後變換一下
        y = y.transpose(1, 2).contiguous().view(B, T, C) # (B, T, nh, hs) -> (B, T, C), view重組了tensor的shape

        # output projection
        y = self.resid_dropout(self.c_proj(y)) # 輸出頭變換, transformer的程式碼早在20年就調包了,但是用錯了地方,GAT/序列
        return y

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
        self.attn = CausalSelfAttention(config)
        self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
        self.mlp = MLP(config)

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlp(self.ln_2(x))
        return x

GPT網路結構配置和GPT實現
GPT網路結構

@dataclass
class GPTConfig:
    block_size: int = 1024
    vocab_size: int = 50304 # GPT2的vocab_size為50257, 將vocab_size填充為64的倍數以提升訓練效率(據說可以提升+30%)
    n_layer: int = 12
    n_head: int = 12
    n_embd: int = 768
    dropout: float = 0.0    # 預訓練dropout為0,微調時可以設定小的dropout值
    bias: bool = True       # True: bias in Linears and LayerNorms, like GPT-2. False: a bit better and faster

class GPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        assert config.vocab_size is not None
        assert config.block_size is not None
        self.config = config

        self.transformer = nn.ModuleDict(dict(
            wte = nn.Embedding(config.vocab_size, config.n_embd),
            wpe = nn.Embedding(config.block_size, config.n_embd),
            drop = nn.Dropout(config.dropout),
            h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
            ln_f = LayerNorm(config.n_embd, bias=config.bias),
        ))
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
        self.transformer.wte.weight = self.lm_head.weight # https://paperswithcode.com/method/weight-tying

        # init all weights,對nn.module的map方法
        self.apply(self._init_weights)
        # apply special scaled init to the residual projections, per GPT-2 paper
        for pn, p in self.named_parameters():
            if pn.endswith('c_proj.weight'):
                torch.nn.init.normal_(p, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer))

        # report number of parameters
        print("number of parameters: %.2fM" % (self.get_num_params()/1e6,))

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, idx, targets=None):
        device = idx.device
        b, t = idx.size() # shape (b, t)
        assert t <= self.config.block_size, f"Cannot forward sequence of length {t}, block size is only {self.config.block_size}"
        pos = torch.arange(0, t, dtype=torch.long, device=device) # shape (t)

        # forward the GPT model itself
        tok_emb = self.transformer.wte(idx) # token embeddings of shape (b, t, n_embd)
        pos_emb = self.transformer.wpe(pos) # position embeddings of shape (t, n_embd)
        x = self.transformer.drop(tok_emb + pos_emb)
        for block in self.transformer.h:
            x = block(x)
        x = self.transformer.ln_f(x)

        if targets is not None:
            # 如果有targets標籤資訊
            # x, shape(b, t, d), 和lm_head, shape(d, vocab_size)做矩陣乘法(內積打分)
            logits = self.lm_head(x)
            # target為輸入token的詞表id向右偏移一個位置,即下一個token預測(還可以偏移多個呢,multi-token預測,第2個token預測🤣)
            # 訓練的時候,輸出輸入序列每一個位置的預測logit得分(極端多分類), torch計算交叉熵使用unnormalized logits(內部會歸一化)
            # 將每一個label對應的這個和傳統的序列預測就沒啥區別了。
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
        else:
            # 推理模式,當然只需要計算最後一個position的logits,即預測下一個token的機率
            logits = self.lm_head(x[:, [-1], :]) # note: using list [-1] to preserve the time dim
            loss = None

        return logits, loss

GPT預測程式碼

image

@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
    """
    Take a conditioning sequence of indices idx (LongTensor of shape (b,t)) and complete
    the sequence max_new_tokens times, feeding the predictions back into the model each time.
    Most likely you'll want to make sure to be in model.eval() mode of operation for this.
    """
    for _ in range(max_new_tokens):
        # if the sequence context is growing too long we must crop it at block_size
        idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:]
        # 呼叫模型的forward預測輸入序列對應位置的分類logits
        logits, _ = self(idx_cond)
        # Softmax前的logits之temperature機率分佈縮放
        logits = logits[:, -1, :] / temperature
        # top-k截斷
        if top_k is not None:
            v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
            logits[logits < v[:, [-1]]] = -float('Inf')
        # apply softmax to convert logits to (normalized) probabilities
        probs = F.softmax(logits, dim=-1)
        # 類別分佈取樣(多項式分佈,次數為1時)
        # 原理即先根據隨機種子用偽隨機演算法生成近似均勻分佈的數,然後看數落在按機率劃分的連續1維空間的區域,來判斷類別
        idx_next = torch.multinomial(probs, num_samples=1)
        # append sampled index to the running sequence and continue
        idx = torch.cat((idx, idx_next), dim=1)

    return idx

主要難理解一點的就是,具體的torch的3維或4維tensor的拆分和合並操作,實際GPT實現緊湊不超過150行。

這個GPT模型結構的程式碼,和之前我的一篇講解picoGPT的numpy實現文章基本沒啥區別,就是換成了torch程式碼可以自動微分計算。
https://zhuanlan.zhihu.com/p/679330102

1.3 NanoGPT訓練程式碼

image

三種模型初始化方式

  • 1、scratch從頭開始時訓練
  • 2、載入之前訓練的checkpoint繼續訓練(即用在微調上)
  • 3、基於OpenAI GPT-2的預訓練權重繼續訓
model_args = dict(n_layer=n_layer, n_head=n_head, n_embd=n_embd, block_size=block_size,
                  bias=bias, vocab_size=None, dropout=dropout) # start with model_args from command line
if init_from == 'scratch':
    # init a new model from scratch
    print("Initializing a new model from scratch")
    # determine the vocab size we'll use for from-scratch training
    if meta_vocab_size is None:
        print("defaulting to vocab_size of GPT-2 to 50304 (50257 rounded up for efficiency)")
    model_args['vocab_size'] = meta_vocab_size if meta_vocab_size is not None else 50304
    gptconf = GPTConfig(**model_args)
    model = GPT(gptconf)
elif init_from == 'resume':
    print(f"Resuming training from {out_dir}")
    # resume training from a checkpoint.
    ckpt_path = os.path.join(out_dir, 'ckpt.pt')
    checkpoint = torch.load(ckpt_path, map_location=device)
    checkpoint_model_args = checkpoint['model_args']
    # force these config attributes to be equal otherwise we can't even resume training
    # the rest of the attributes (e.g. dropout) can stay as desired from command line
    for k in ['n_layer', 'n_head', 'n_embd', 'block_size', 'bias', 'vocab_size']:
        model_args[k] = checkpoint_model_args[k]
    # create the model
    gptconf = GPTConfig(**model_args)
    model = GPT(gptconf)
    state_dict = checkpoint['model']
    # fix the keys of the state dictionary :(
    # honestly no idea how checkpoints sometimes get this prefix, have to debug more
    unwanted_prefix = '_orig_mod.'
    for k,v in list(state_dict.items()):
        if k.startswith(unwanted_prefix):
            state_dict[k[len(unwanted_prefix):]] = state_dict.pop(k)
    model.load_state_dict(state_dict)
    iter_num = checkpoint['iter_num']
    best_val_loss = checkpoint['best_val_loss']
elif init_from.startswith('gpt2'):
    print(f"Initializing from OpenAI GPT-2 weights: {init_from}")
    # initialize from OpenAI GPT-2 weights
    override_args = dict(dropout=dropout)
    model = GPT.from_pretrained(init_from, override_args)
    # read off the created config params, so we can store them into checkpoint correctly
    for k in ['n_layer', 'n_head', 'n_embd', 'block_size', 'bias', 'vocab_size']:
        model_args[k] = getattr(model.config, k)

訓練程式碼
訓練標籤構造

optimizer = model.configure_optimizers(weight_decay, learning_rate, (beta1, beta2), device_type)
X, Y = get_batch('train') # fetch the very first batch
t0 = time.time()
local_iter_num = 0 # number of iterations in the lifetime of this process
raw_model = model.module if ddp else model # unwrap DDP container if needed

while True:
    # determine and set the learning rate for this iteration
    lr = get_lr(iter_num) if decay_lr else learning_rate
    for param_group in optimizer.param_groups:
        param_group['lr'] = lr

    # evaluate the loss on train/val sets and write checkpoints
    if iter_num % eval_interval == 0 and master_process:
        losses = estimate_loss()
        print(f"step {iter_num}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
        if losses['val'] < best_val_loss or always_save_checkpoint:
            best_val_loss = losses['val']
            if iter_num > 0:
                checkpoint = {
                    'model': raw_model.state_dict(),
                    'optimizer': optimizer.state_dict(),
                    'model_args': model_args,
                    'iter_num': iter_num,
                    'best_val_loss': best_val_loss,
                    'config': config,
                }
                print(f"saving checkpoint to {out_dir}")
                torch.save(checkpoint, os.path.join(out_dir, 'ckpt.pt'))
    if iter_num == 0 and eval_only:
        break

    # forward backward update, with optional gradient accumulation to simulate larger batch size
    # and using the GradScaler if data type is float16
    for micro_step in range(gradient_accumulation_steps):
        if ddp:
            model.require_backward_grad_sync = (micro_step == gradient_accumulation_steps - 1)
        with ctx:
            logits, loss = model(X, Y)  # 前向傳播
            loss = loss / gradient_accumulation_steps # scale the loss to account for gradient accumulation
        # immediately async prefetch next batch while model is doing the forward pass on the GPU
        X, Y = get_batch('train')
        # backward pass, with gradient scaling if training in fp16
        scaler.scale(loss).backward()  # 反向傳播

    # 裁減gradient
    if grad_clip != 0.0:
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
    # 更新optimizer和scaler
    scaler.step(optimizer)
    scaler.update()
    # 模型gradients清零並解除memory佔用
    optimizer.zero_grad(set_to_none=True)

    # 最大訓練step次數,結束訓練
    if iter_num > max_iters:
        break

2、專案實戰

image

NanoGPT的程式碼(推廣到任何GPT torch程式碼)本身對於用什麼分詞器是無感知的,畢竟只要求輸入是int型別的token id序列。

無論輸入是什麼模態資料(時序、音訊、圖片、影片、使用者點選行為序列等),只要tokenize後就能訓練。

所以,無論是這套程式碼,還是其他的專案,本質上的模型以外的改動就是修改分詞器作用於預處理encode和推理預測時decode的過程。

2.1 莎士比亞字元級shakespeare_char

這邊先把tinyshakespeare的文字下下來,按下面3步走即可復現

python data/shakespeare_char/prepare.py
python train.py config/train_shakespeare_char.py
python sample.py --out_dir=out-shakespeare-char

配置展示

root@eais-bjyo5z6grbpbqo36a86q-85b696d67f-zhx46:/mnt/workspace/nanoGPT-master# python train.py config/train_shakespeare_char.py
Overriding config with config/train_shakespeare_char.py:
# train a miniature character-level shakespeare model
# good for debugging and playing on macbooks and such

out_dir = 'out-shakespeare-char'
eval_interval = 250 # keep frequent because we'll overfit
eval_iters = 200
log_interval = 10 # don't print too too often

# we expect to overfit on this small dataset, so only save when val improves
always_save_checkpoint = False

wandb_log = False # override via command line if you like
wandb_project = 'shakespeare-char'
wandb_run_name = 'mini-gpt'

dataset = 'shakespeare_char'
gradient_accumulation_steps = 1
batch_size = 64
block_size = 256 # context of up to 256 previous characters

# baby GPT model :)
# n_layer = 6
# n_head = 6
# n_embd = 384

##### debug  ####
n_layer = 2
n_head = 4
n_embd = 128
###############

dropout = 0.2

learning_rate = 1e-3 # with baby networks can afford to go a bit higher
max_iters = 5000
lr_decay_iters = 5000 # make equal to max_iters usually
min_lr = 1e-4 # learning_rate / 10 usually
beta2 = 0.99 # make a bit bigger because number of tokens per iter is small

warmup_iters = 100 # not super necessary potentially

# on macbook also add
device = 'cpu'  # run on cpu only
compile = False # do not torch compile the model

tokens per iteration will be: 16,384
found vocab_size = 65 (inside data/shakespeare_char/meta.pkl)
Initializing a new model from scratch
number of parameters: 0.40M
/usr/local/lib/python3.10/site-packages/torch/amp/grad_scaler.py:131: UserWarning: torch.cuda.amp.GradScaler is enabled, but CUDA is not available.  Disabling.
  warnings.warn(
num decayed parameter tensors: 10, with 434,304 parameters
num non-decayed parameter tensors: 5, with 640 parameters
using fused AdamW: False
step 0: train loss 4.1860, val loss 4.1832
iter 0: loss 4.1887, time 38322.89ms, mfu -100.00%
iter 10: loss 3.9036, time 417.94ms, mfu 0.04%
iter 20: loss 3.6104, time 400.67ms, mfu 0.04%
iter 30: loss 3.4437, time 452.36ms, mfu 0.04%
iter 40: loss 3.2072, time 445.09ms, mfu 0.04%
iter 50: loss 2.9770, time 397.14ms, mfu 0.04%
iter 60: loss 2.8188, time 482.85ms, mfu 0.04%
iter 70: loss 2.7120, time 426.90ms, mfu 0.04%
iter 80: loss 2.6770, time 434.85ms, mfu 0.04%
iter 90: loss 2.6223, time 410.70ms, mfu 0.04%
iter 100: loss 2.5966, time 444.17ms, mfu 0.04%
iter 110: loss 2.5599, time 378.64ms, mfu 0.04%
iter 120: loss 2.5563, time 459.60ms, mfu 0.04%
iter 130: loss 2.5470, time 482.69ms, mfu 0.04%

訓練了200step的預測結果,英文字元不太連續:

root@eais-bjyo5z6grbpbqo36a86q-85b696d67f-zhx46:/mnt/workspace/nanoGPT-master# python sample.py --out_dir=out-shakespeare-char --device=cpu
Overriding: out_dir = out-shakespeare-char
Overriding: device = cpu
number of parameters: 0.40M
Loading meta from data/shakespeare_char/meta.pkl...

HWAF heve wabeacar
F s sele in
WAnd hur stout arined ato ce h, tofow, couriourtheald ath as and mathise m tetil f chom, s ke ar tanetr ieaifo he s mat y dsakthe go irsten ar:
Anoupat he, pit thinougaris inge veeathed 'oilithangis, wey, ored sh toe t,
Thende me m pon.

I me beng wed timy youlre tshofundt,

And t corst thand?

MENSTUDUCWIO:
Pane mashele he y hayouthe.
Y I w INIxpare fetodis be mimicos w INILARY we atidses fand s h andathe tofery dad ge wn withisthatod br fr anfanor hith, he shat t
---------------

BEENoo arot, toth ly whond wistore thou hed we che tif d win chathere he clomapal ond br t inde heeronghen'theathowng ourat ads the maimes one shiten he ove whese thitha mbath fr ir thanore acheit t hes d inoce t whinkert sackitrmome t ond hend ind,
And n t s areroras y eethit fass ad foueld se nout as ce oore aldeath nchitherds w om y owin d hasing spllore
We thoururake,
An be mame tipy ine rar, m.
I burisorthe ng ce
MENEROLENCEN:

Achollamucais y the co as sererrury by s yodounor theer itorean

2.2《紅樓夢》字元級GPT

很簡單,直接下載《紅樓夢》,將檔案重新命名為input.txt下再按shakespeare_char的3步即可訓練。

因為,shakespeare_char/prepare.py是按字元級別分詞,這裡即按中文字粒度分詞,分詞後詞表大致4千多。

展示部分字元級別token

length of dataset in characters: 875,372
all the unique characters: 
!"()-.:<>?—―‘’“”…─ 、。《》〔〕ㄚㄠ一丁七萬丈三上下不與丐醜專且世丘丙業叢東絲丞丟兩嚴喪個丫中豐串臨丸丹為主麗舉乃久麼義之烏乍乎乏樂喬乖乘乙乜九乞也習鄉書乩買亂乳乾了予爭事二於虧雲互五井亙些亡亢交亥亦產畝享京亭亮親褻嚲人億什仁仃仄僅僕仇今介仍從倉仔仕他仗付仙代令以儀們仰仲仵件價任份仿伊伍伏伐休眾優夥會傘偉傳傷倫佇伯估伴伶伸伺似伽但位低住佐佑體何餘佚佛作佞你傭佩佯佳佻使侃侄侈例侍侑供依俠僥側儕儂侮侯侵便促俄俊俏俐俑俗俘俚保俞俟信儔儼倆儉修俯俱俵俸騄馬馭馱馴馳驅駁驢駙駒駐駝駕驛罵驕駱駭驪驗騎騅騙騭騷騖騾驄驟驥骨骯骰骷骸髏髓高髟髯髻鬅鬒鬢鬟鬼魁魂魄魆魘魏魔鮹魚魯鮑鮪鮫鮮鯗鱘鯽鯨鰍鰉鰲鰥鱉鱗鳷鶒鸂鳥鳩雞鳴鷗鴉鴆鴣鴨鴞鴦鴒鴛鷙鴰鴿鸞鴻鵑鵠鵝鵡鵲鵪鶉鷫鶻鷂鶺鶴鷖鸚鷓鷺鷹鹺鹿麀麈麋麒麝麟麥麻麾黃黌黍黎黏黑默黛黜黥黧黹黻黼黽黿鼎鼐鼒鼓鼠鼻鼾齁齊齏齒齡龍龕龜︰﹐﹒﹔﹕﹗!(),.:;?¥
vocab size: 4,432
train has 787,834 tokens
val has 87,538 tokens

訓練配置

tokens per iteration will be: 16,384
found vocab_size = 4432 (inside data/shakespeare_char/meta.pkl)
Initializing a new model from scratch
number of parameters: 0.96M
num decayed parameter tensors: 10, with 993,280 parameters
num non-decayed parameter tensors: 5, with 640 parameters
using fused AdamW: False

訓練了250step的續寫預測結果,不太連續且疊詞會重複多次預測:

python sample.py --out_dir=out-shakespeare-char --device=cpu --start=黛玉見了寶玉便說
Overriding: out_dir = out-shakespeare-char
Overriding: device = cpu
Overriding: start = 黛玉見了寶玉便說
number of parameters: 0.96M
Loading meta from data/shakespeare_char/meta.pkl...
黛玉見了寶玉便說:“你為這的!”寶玉聽了衣服,便不著襲人忙道:“你有意思定是他也不成?”黛玉笑道:
一口,我一回來了她了一回來,在園裡笑道:“你們不是我說:“我等她的?今日,是,把你們的奶奶奶奶奶,我要請你一個金桂又要罵寶玉悄悄悄道:“你不好話。”寶玉忙在還沒趣,不用些‘這麼倒好,她,就難道:“你們還得!”
香菱道:“我原來的,再是我們吃了。如今年實喝著,不得了。”
說:“我自己笑道‘什麼?”寶玉。”寶蟾笑笑道:“奶奶家來。”寶玉看了這原來。
寶玉聽了。
其裡,又說著,便說著你哥哥哥哥哥哥哥哥哥哥妹妹妹妹妹妹妹姊妹妹回身忙笑道:“這裡。”鳳姐道:“我們。”說道:“他只見那裡頭的事,果然若有些東西。”鳳姐道:“林姑娘回來看著黛釵道:“寶玉道:“好了。你在家裡頭無法兒笑道:“這裡有聲,便問道:“我們不好巧你疼你有一般,還好姐兒,也不知道,只得你去了他的,只怕得?哪裡們就叫你還不是這些歹,你說是:“拿來,急了,不得,你竟是什麼樣,叫我沒沒不說?”晴雯道:“你們那裡罷了。”茗煙下呢?”寶玉、寶釵聽著,便問什麼意思。”寶玉說:“還沒作什麼樣兒,就是這個人笑道:“我不知,道:“你們一陣,便笑來。”湘雲往這一個也不
---------------
黛玉見了寶玉便說個小廝呢,便向寶玉笑道:“他會子,我可強說著咱們們是我很是你叫我也沒是東西,你得好的的,我在我。當出來好歹氣好說得知道,你們什麼你我見人聽了。”薛姨媽笑道:“沒有什麼好說話,叫噯喲,還不許你們著我也沒去快上去了來,我等。”此說得你拿我這裡打笑道:“這,快倒是奴裡倒是我是這個人也好笑道:“你們來,可是有。”寶玉道:“噯?你這麼樣意就算你太太太太太太太太太跟了,也說見你們又拿個我這會子把我又是沒罷。”司棋才答應,你們和我又不敢叫我們知說,又不知道:“你這麼!你也不能得說,有不得吃,便道我說。”湘雲:“你老爺太的話,又拿了一把這是什麼?我去。”又不是隻是怎麼!”薛姨媽道:“竟沒奶奶奶奶奶一邊身子一把什麼不得了,你還不是他坐話,這麼去罷了一不如今不得不著,無人道:‘況且也不知道:“你,好事倒是我到過去!”說,也沒人都打聽見奶叫你們我當時,只是了。”賈母道:“你們婆子,也不過來,只是我只揀,寶釵等你也再求了這裡的的我們的做老太太都想起身上不好,看了。”鳳姐二爺說個姑娘的,只要你們也才在心裡,自己向道:“你來了來回去。”賈璉便從我只管哥哥哥哥,沒有‘不過說:“那裡來的方娘和我們有好些玩,叫人忙道:

更改模型配置,多訓練了一會兒。結果看起來通順多了

batch_size = 32
block_size = 256 # context of up to 256 previous characters
n_layer = 24
n_head = 8
n_embd = 64 * n_head

step 0: train loss 8.4835, val loss 8.4883
...
step 2250: train loss 1.2916, val loss 4.3947
iter 2470: loss 1.4080, time 446.68ms, mfu 1.51%
iter 2480: loss 1.3687, time 447.08ms, mfu 1.51%
iter 2490: loss 1.4792, time 447.05ms, mfu 1.51%
root@eais-bjsrvif50zbuisrd5dfe-64847b6ff7-496mt:/mnt/workspace/buildLLM/nan
oGPT-master# python sample.py --out_dir=out-tonestory-char
Overriding: out_dir = out-tonestory-char
number of parameters: 40.03M
Loading meta from data/shakespeare_char/meta.pkl...

這尤氏執意不想尤氏過來叫:“噯喲,這可倒是你們的命兒?”尤氏笑道:“我不信兒,是寶丫頭是寶兄弟。”尤氏笑道:“可不是,你們只管說話了。是小子,可不是不是?”尤氏笑道:“如今因小兒,你說不是‘金玉’‘金’‘金’‘了’,‘金’蕙香”,鳳姐兒笑問道:“誰?不要與你也是見過,告訴你老太太。如今除了你家的,是你老人家,到了這裡,沒的東西。”尤氏笑道:“什麼東西?”尤氏笑道:“我不和你。你又不起,並不是這樣,只管說準了。你們商議論到了房裡,倒有人。”鳳姐兒道:“老太太說了,我常說了,不過是要的。”
因說道:“這個女孩子,主子們問她是什麼福氣。我聽見說你們說,我們實疑惑的。”一壁走,一面坐著,一面又打發一個丫頭走來,卻是一個婆子們走。尤氏便說:“嫂子,你別抱怨。”王夫人聽了,心中又是喜,因問:“老太太不知道。”王夫人笑道:“我們的話倒不怕。你們這小,那裡知道。”
正說話之間,只見王夫人的兩個就往鳳姐兒房裡來。鳳姐兒便打發彩霞和一個婆子來回報說:“這裡一個丫頭們,都得了。”周瑞家的道:“你們的規矩,我們只當著眾人也不是,別和平兒說話,沒法兒的。”周瑞家的因問:“這會子哪裡請大老爺?”周瑞家的道:“你們
---------------

這日正是,只聽一陣雨村笑。進來,忽見寶玉進來,便提起黛玉,笑道:“你好好個鬼,我往哪裡去?”寶玉聽說,便叫:“林姐姐姐說的這話,你怎麼不煩惱了?”寶玉聽了,方知是何意思,心下疑惑。
忽聽院門上人說:“林妹妹哪裡去了?”寶玉聽了,心下實猶豫。急得裡想著,原來是:“林妹妹妹忽然有人來是那幾個丫頭。回來我病了,不敢睡,睡了一夜,懶待接,大家散了。”便出去。要知端的,且聽下回分解。
第十一百八回 痴痴女子杜撰芙蓉癖 痴公子悲深 痴痴情
話說寶玉心  瀟湘雲偶感重心
話說寶釵出嫁惜春薨逝  瀟瀟湘雲病
話說寶玉想來,見寶玉回來,方出來,便作辭,不是沒話。寶玉此時心中疑惑,和他說話也不敢答,便出門走到王夫人房中,趕過來,叫人問:“寶兄弟叫我去。”寶玉聽了,又命人來:“太太請出來。”回頭一面去後,一面來回了賈母。賈母又見了賈母,問她哥的玉好,然後過入園落在園中,不在話下。
正是回去,只見周瑞走進來,一面走來問:“家的嫂子,她哪裡想起我來的?”周瑞、周瑞家的道:“你們和小孩兒怎麼說得周瑞的?”周瑞家的冷笑道:“今兒家大走了,你們看見了這銀子,她們就認得!”周瑞家的到周瑞家的家的丫頭處來,見她娘回至房中
---------------

有一個小丫頭捧盒子,笑道:“這個是老婆,我不知哪裡的,拿著這個錢快拿了來!”小丫頭不理,只得拿了錢來,遞與他小丫頭,說:“我就不得!完了,快請你們去罷。老太太也不知道。”
賈母因問,那巧姐兒不好意思,只得跟著,忽見一個婆子出來回說:“奶奶不大了。”賈母聽了便下拔下一個小丫頭來,一邊說:“太太可打發人送了人來。”又說:“什麼東西?我們兩家姑娘們又比不同,還得空兒。”回頭命人取了出去,說著一個婆子來,進來,辭。
這裡鳳姐方才要進屋裡頭,見了進來,見鳳姐兒正疑惑不甚差,便說道:“我的話不稱,即刻叫我聽見。”鳳姐兒聽了不自禁,說:“你不用忙,就是這樣的,我就管不得很。”平兒道:“不好看,我想著了,已經去了,我才看你了。”平兒道:“你來了,我已經過去了,我也要請你回老太安。”鳳姐聽了,即起身讓,又說:“你只別來了。”平兒一兒便命:“去告訴二奶奶,別略站一歇就進去。”平兒答應著出去了,剛要走來,只見丫頭走來,說:“大奶奶的屋裡這裡用,我們問問明白。”賈母因問:“那丫頭家的人不好,只管說有些什麼事。”平兒聽了笑道:“你不信,告訴你老太太。”平兒聽了,又說道:“這是鳳丫頭那裡的人,還有什麼事呢。”平兒
---------------

吃得,寶玉因問道:“不得了,襲人姐姐的手,那裡的胭筍香甜,只管沏些,又炸了。”寶玉笑道:“不好,什麼吃,只管醒得去了。”襲人道:“這些東西,我們叫她好送去罷。”襲人笑道:“我們給她這個。”襲人道:“這裡不是什麼。”寶玉道:“你不用提起來,只是跟著,我就不要了。”襲人笑道:“你去自然也罷。”襲人道:“你不去,我不信。他也不去,不必煩他來了。”襲人道:“你別生氣,我也得一點兒。”襲人道:“好姐姐,我才睡下了,睡了倒了。”寶玉道:“你怎麼樣?”襲人道:“在屋裡等著呢。”襲人道:“明兒再來,有話說是老太太賞上去。”襲人道:“襲人姐也不必說那一個。”寶玉道:“昨兒在這裡,夜已四更,到老太太安歇晌,老太太安歇歇去,不在這裡,還得咱們了。”襲人道:“你只管放心,你睡罷。”襲人道:“這會子睡了,仔細說了。”襲人道:“我不要出去回去,大家來逛逛逛逛去。”寶玉道:“明兒再睡罷。”襲人道:“你不必去。”襲人道:“既是懶的,只是你也該放心了。”襲人道:“我不困,你就該睡了。”襲人道:“我也睡下了,咱們再睡下了。”襲人道:“睡不用睡,又遲一歇。”襲人道:“已是了,又好。”寶玉道:“你也睡下了,天又睡得了。”襲人
---------------

一日,只見王夫人的言語著淚痕,把東西都忘了。眾人聽了,都唬了,於是忙忙問:“你這話怎麼樣?”王夫人道:“你知道外頭女兒也不是那一個說話兒?”賈母道:“我昨兒又聽見有個女兒在那裡。”黛玉道:“叫做什麼?”寶玉道:“我吆喝酒,你們到南里來。”又伸手搬住。賈母又道:“不用,你們在外頭混說了。你們這裡又不好氣,不用說話兒,早晚上回來了。”史湘雲道:“不是。”寶玉笑道:“不是,還有什麼話,咱們昨兒一班大擺酒,都擺喝酒。”賈母因問:“這個話?”賈母道:“你們家多坐,你們還大吃飯。”鴛鴦道:“那裡頭屋子也不吃飯,我們的飯都擺酒。”鴛鴦笑道:“不必。昨兒說了,我說該賞螃蟹去。”鴛鴦答應了。鴛鴦又道:“今兒一來的,你們說話,各人吃了,不吃飯,咱們的喝一杯。”鴛鴦笑道:“那裡是前兒在園子裡,都唱了。”鴛鴦早飛洗了手,依言賈母道:“你們只管請,我們就來了。”賈母道:“你們這裡也不用過去逛,不然;我們又不大笑。”鴛鴦道:“是。”鴛鴦道:“賞了,賞你們吃這些。”鴛鴦道:“上一個螃蟹,我們吃了兩。”鴛鴦答應著,帶著便走出來。鴛鴦因問道:“太太問不敢吃,我們說,只管帶回去。”鴛鴦道:“今兒才回來,說那邊沒吃飯,不必
---------------

這時,只見一個人往日中一日,都像從哪裡去了。寶玉笑道:“我聽見說太太的話,還是呢?”襲人忙道:“你聽見林姑娘,這樣黑心細,我為什麼病,我就過去了。”寶玉道:“林姑娘昨兒才老爺叫雪雁告訴雪雁,才剛告訴雪雁呢。”探春道:“你知道你二哥和平兒說,告訴我,我病著了。”探春道:“不許和你老人告訴二奶說,只怕我不敢說。”說著便命人到探春房中來。平兒見她這般,心中想不到自己心,便死了,自己便說道:“幸而善了,人家也不知,過了作訊息。”平兒慌了,便回頭向平兒道:“姐姐姐這麼著,大奶奶再出去請安,又打發人來瞧去。”平兒含淚道:“她當著不是個要事,她也是小事。哪裡是她?既這麼說,你們又不要她去,不許累她去了,先告訴人還。”平兒聽了,詫異道:“自然,你倒這麼個胡塗,也就過去了。”探春道:“你說,我們自然也不快,不知是她這裡的,不如何肯和她說了?”平兒道:“不是,也是她們找不著。”探春道:“人家學裡話,太太說的是太屋裡住著呢。”平兒忙答應了,果然一言解。探春只得又去了,要同著平兒至裡間,探春又來陪笑。平兒笑道:“你吃了飯,只管告訴你。”平兒道:“平丫頭雖在老太太那裡,還不知是什麼原故?”平兒道:“怎麼不回家去

2.3 基於BBPE tokenizer的中文GPT

這個操作也簡單,自己寫一個prepare.py,設定詞表的大小,先基於tokenizers的byte-level byte pair encoding(BBPE)直接訓練分詞器後,儲存下詞表和配置,在sample推理時再使用。

然後,在基於這個訓練好的tokenizer對資料分詞。

這邊分別嘗試了基於《紅樓夢》、四大名著以及幾十本熱門網路小說,並在不同的模型配置上(參照最新的MobileLLM的深而窄模型架構設定)進行了單卡或多卡訓練

使用torchrun跑pytorch的自帶的單機多卡DDP訓練,小模型上和Deepspeed沒啥區別。

torchrun --standalone --nproc_per_node=8 train.py config/train_gpt2.py

訓練經驗:

  • 這邊訓練時主要追求過擬合,驗證集的損失參考意義不大(懶得劃分資料集了)
  • 使用Adam最佳化器,其在check-point中佔了2倍模型的空間(一階和二階的梯度資訊)
  • 在相對大語料自己訓練的分詞器生成的結果會比字元級分詞流暢一些,估計時詞表比字符集普遍大,壓縮率更大。不過沒有詳細對比
  • 續寫能力:
    • 提及單個小說的人物能夠擬合續寫。
    • 在只用四大名著訓練時,勉強可以把不同人物混在一起續寫,出現林沖和黛玉
    • 在網路小說語料上,強行將不同小說的人物混在一起,續寫時模型基本理解不了😅

把各種語料

2.4 基於Qwen2 tokenizer的GPT

參照第3個自己訓練的BBPE tokenizer,這邊直接使用Qwen2 tokenize。

1、訓練結果基本輸出是亂碼的💔

無論是在《紅樓夢》還是在接近一億字的網路小說語料上都是這種結果。估計語料太少了,而Qwen2的詞表15萬左右,太少訓練語料、且語料僅涵蓋單一型別,大部分token的embedding沒有訓練到,導致模型train不好。

2.5 基於openwebtext dataset復現GPT2

這邊就是直接參考原專案操作。難點是資料怎麼搞。因為openwebtext和tiktoken的gpt2牆內下不了。

這邊選擇在kaggle上找了個NanoGPT的專案,把它處理好的train.bin(6.76GB)和test.bin下來,直接訓練。

openwebtext資料

Kaggle專案連結:https://www.kaggle.com/code/carrot1500/nanogpt-trained-on-openwebtext

python3 train.py config/train_gpt2.py --wandb_log=False \
  --max_iters=1000 \
  --log_interval=1 \
  --eval_interval=200 \
  --eval_iters=20 \
  --learning_rate="0.0001" \
  --gradient_accumulation_steps=4 \
  --batch_size=12 \
  --n_layer=8 \
  --n_head=8 \
  --n_embd=512 \
  --compile=False \
  --out_dir=out
Overriding config with config/train_gpt2.py:
# config for training GPT-2 (124M) down to very nice loss of ~2.85 on 1 node of 8X A100 40GB
# launch as the following (e.g. in a screen session) and wait ~5 days:
# $ torchrun --standalone --nproc_per_node=8 train.py config/train_gpt2.py

wandb_log = True
wandb_project = 'owt'
wandb_run_name='gpt2-124M'

# these make the total batch size be ~0.5M
# 12 batch size * 1024 block size * 5 gradaccum * 8 GPUs = 491,520
batch_size = 12
block_size = 1024
gradient_accumulation_steps = 5 * 8

# this makes total number of tokens be 300B
max_iters = 600000
lr_decay_iters = 600000

# eval stuff
eval_interval = 1000
eval_iters = 200
log_interval = 10

# weight decay
weight_decay = 1e-1

Overriding: wandb_log = False
Overriding: max_iters = 1000
Overriding: log_interval = 1
Overriding: eval_interval = 200
Overriding: eval_iters = 20
Overriding: learning_rate = 0.0001
Overriding: gradient_accumulation_steps = 4
Overriding: batch_size = 12
Overriding: n_layer = 8
Overriding: n_head = 8
Overriding: n_embd = 512
Overriding: compile = False
Overriding: out_dir = out
tokens per iteration will be: 49,152
Initializing a new model from scratch
defaulting to vocab_size of GPT-2 to 50304 (50257 rounded up for efficiency)
number of parameters: 50.93M
num decayed parameter tensors: 34, with 51,445,760 parameters
num non-decayed parameter tensors: 17, with 8,704 parameters
using fused AdamW: True
step 0: train loss 10.8790, val loss 10.8800
iter 0: loss 10.8837, time 12969.53ms, mfu -100.00%
iter 1: loss 10.8779, time 2646.03ms, mfu -100.00%
iter 2: loss 10.8718, time 2645.71ms, mfu -100.00%
iter 3: loss 10.8618, time 2647.04ms, mfu -100.00%
iter 4: loss 10.8776, time 2643.33ms, mfu -100.00%
iter 5: loss 10.8743, time 2644.41ms, mfu 2.12%
...
iter 996: loss 6.2718, time 2657.69ms, mfu 2.11%
iter 997: loss 6.2626, time 2654.19ms, mfu 2.11%
iter 998: loss 6.3724, time 2657.96ms, mfu 2.11%
iter 999: loss 6.1987, time 2657.67ms, mfu 2.11%
step 1000: train loss 6.3108, val loss 6.2706
saving checkpoint to out
iter 1000: loss 6.3944, time 13053.12ms, mfu 1.94%

3、總結

程式碼基本是現成,但訓練好一個小規模實際可用的LLM並不容易。

調研決定模型架構,需要多少資料,再進行資料準備、資料配比、資料去重篩選清洗、資料增強,收集、清洗、標註和生成SFT資料,以及對齊。

ToDo

  • 後續,會把修改的程式碼放到git上。
  • NanoGPT只是經典GPT-2的實現,其結構和現在的LLM還是有差異,如;
    • 旋轉位置編碼ROPE,長上下文建模的關鍵
    • GQA分組注意力
    • RMSprop,只有中心化,而非標準化的Layernorm操作
    • SwiGLU

如果您需要引用本文,請參考:

LeonYi. (Aug. 25, 2024). 《【LLM訓練系列】NanoGPT原始碼詳解和中文GPT訓練實踐》.

@online{title={【LLM訓練系列】NanoGPT原始碼詳解和中文GPT訓練實踐},
author={LeonYi},
year={2024},
month={Aug},
url={https://www.cnblogs.com/justLittleStar/p/18379771},
}

參考資料
【1】配圖大部分來自,https://jalammar.github.io/illu

相關文章