2500万参数字节级 Transformer:CPU 训练的结构与评估

18 阅读12分钟

2500万参数字节级 Transformer:CPU 训练的结构与评估

用约 2500 万参数的教学模型,可以把编码、因果注意力、损失、反向传播和评估连成一条可检查的链路。本文从一组明确的配置出发,讨论参数放在哪里、字节输入增加什么代价,以及什么时候值得加入注意力变体、MoE 或强化学习。先建立小规模基线,再比较每次结构变化。

先定义实验,不用模型名称替代配置

这里的 25M 是本文设计的教学预算。结构采用 Llama 风格的因果语言模型,词表、层数、宽度与权重共享都显式给出。模型从随机参数开始,而不是从一个名称相似的预训练目录加载权重。

一组可以逐项计数的配置

输入词表为 256,隐藏维度为 512,7 层,8 个注意力头,FFN 中间维度为 1632。使用普通多头注意力、无偏置投影与 SwiGLU,输入嵌入和输出投影共享权重。主要矩阵与归一化参数合计约为 25,026,048 个,构建后再用实际参数计数核对。

from transformers import LlamaConfig, LlamaForCausalLM

config = LlamaConfig(
    vocab_size=256, hidden_size=512, intermediate_size=1632,
    num_hidden_layers=7, num_attention_heads=8, num_key_value_heads=8,
    max_position_embeddings=256, tie_word_embeddings=True,
    bos_token_id=None, eos_token_id=None, pad_token_id=None,
    attention_bias=False, mlp_bias=False,
)
model = LlamaForCausalLM(config).to("cpu")
print(sum(p.numel() for p in model.parameters()))

配置字段与构建方式可对照 Transformers 的 Llama 文档。CPU 单步耗时还取决于线程数、序列长度、batch 和所用算子;先测一次前向与反向,再安排训练预算,不预设“几分钟训练完成”。

把一次实验写成一张记录

项目要保存的内容用途
数据来源、许可证、清洗规则与划分避免训练评估泄漏
配置层数、宽度、头数、词表与共享开关解释参数量变化
运行CPU、线程、batch、序列长度与种子比较单步成本
指标训练损失、验证损失、目标任务结果判断是否有收益

小模型可以降低一次结构检查的资源需求,但不会消除实验设计问题。某种配方在此处有效,放大到另一种模型和数据上仍要重新验证。

字节输入:256 个基本 ID 的收益与代价

把文本编码为 UTF-8,再直接使用每个字节的数值作为 token ID,就得到一个无需学习合并规则的输入方法。字节值为 0 到 255;添加结束、填充或其他控制符号时,应另分配 ID 并扩大词表。

text = "你好,模型"
ids = list(text.encode("utf-8"))
assert all(0 <= value < 256 for value in ids)
assert bytes(ids).decode("utf-8") == text
print(len(text), len(ids))

这个往返适用于可编码为 UTF-8 的文本。任意二进制序列未必能解码为有效文本,生成阶段需要约定错误处理方式。常见汉字通常占三个 UTF-8 字节,其他字符可能不同;不要把“字符数”和“模型序列长度”混用。

子词分词并不必然存在未知词

BPE、WordPiece 与直接字节编码解决问题的方式不同。具体 tokenizer 是否使用 unknown、字节回退或字节级基础符号,应查看其实现。Byte-level BPE 同样可以保留字节覆盖,不能把所有 BPE 都概括为“遇到新词只能输出 unknown”。

方案基本规则要比较的代价
子词分词常见片段可以合并词表规模、序列长度与回退机制
直接字节 ID每个 UTF-8 字节直接成为 ID小词表与更长文本序列
字节加控制符号字节外另加特殊 ID数据边界与生成终止规则

同一份数据应实际编码后再比较。不能先假定一句中文在某个 BPE 词表里一定只有多少 token,再据此计算固定倍数的收益。

Embedding 只做查表,后面仍有输出计算

import torch

ids = list("你好,模型".encode("utf-8"))
input_ids = torch.tensor([ids], dtype=torch.long)
embedding = torch.nn.Embedding(256, 512)
vectors = embedding(input_ids)
assert vectors.shape == (1, len(ids), 512)

嵌入矩阵的参数量是 V×d。V 为词表大小,d 为隐藏维度;独立输出投影通常也需要同量级权重。共享这两份权重减少独立参数,同时约束输入和输出表示,不能说它完全不影响模型约束。

字节输入的序列可能更长。密集注意力的交互成本与长度平方相关,KV cache 则还取决于层数、KV 头数、head_dim 和存储精度。省下的词表参数与增加的序列成本,要放进同一份预算里。

按矩阵形状拆开 25M 参数

固定隐藏维度 d,普通多头注意力的 Q、K、V、O 无偏置投影约为 4d²;SwiGLU 的三个主要矩阵约为 3d×d_ff。GQA 等结构会改变 K/V 的尺寸,不能直接沿用这个公式。

本文配置的主要参数可以用普通算术核对:

vocab, hidden, intermediate, layers = 256, 512, 1632, 7
embedding = vocab * hidden
per_layer = 4 * hidden**2 + 3 * hidden * intermediate + 2 * hidden
parameters = embedding + layers * per_layer + hidden
assert parameters == 25_026_048
print(parameters)

其中每层两个 RMSNorm 各含 d 个缩放参数,最终归一化再加 d;输出复用嵌入,因此没有另算一份输出矩阵。这个算式对应前面的具体选择,不是所有“25M 模型”的统一规格。

前向通路怎样检查

输入形状为 [B,T],查表后为 [B,T,d],经过因果注意力和 FFN,再归一化并投影到 [B,T,V]。pre-norm 路线把归一化放在子层输入端,残差保留原状态;这是本文选择的结构。

验证顺序是先检查形状,再检查未来信息是否被因果掩码挡住,随后检查 loss、梯度和保存加载。最后一个位置不能通过标签错位读取自己要预测的答案。使用因果语言模型接口时,应明确库内部如何移动 labels,避免在外部又移动一次。

权重绑定只省独立参数,不省去输出矩阵乘。输入查表取少数行,输出仍要对词表计算分数;输入和输出两侧的梯度还会汇入同一参数。

注意力比较:位置、连接与状态是三件事

RoPE 给 Query 与 Key 施加随位置变化的旋转,使相对位置信息进入内积。RoFormer 论文解释了这一构造。相对位移形式不保证任意长上下文上的质量,扩展长度仍要核对缩放方法和实际评测。

一个明确形状的旋转示例

import torch

def apply_rope(x, positions, base=10000.0):
    # x:[B,H,T,D],positions:[B,T],采用前后半维配对。
    dimension = x.shape[-1]
    if dimension % 2:
        raise ValueError("最后一维须为偶数")
    half = dimension // 2
    exponent = torch.arange(half, device=x.device).float() / half
    angle = positions.to(x.device).float()[:, None, :, None] * base**(-exponent)
    cosine, sine = angle.cos().to(x.dtype), angle.sin().to(x.dtype)
    first, second = x[..., :half], x[..., half:]
    return torch.cat((first*cosine-second*sine, second*cosine+first*sine), -1)

旋转配对和维度排列必须与模型实现一致。换一种排列后,不能仅因输出形状相同就认为权重兼容。NoPE 省去显式位置编码,但因果掩码等结构仍能影响位置信息。

稀疏索引与压缩 KV 不同

稀疏注意力选择部分位置参与交互;索引、排序和稀疏算子都有成本。MLA 压缩 KV 表示,也不等于已经加入稀疏位置选择。DeepSeek-V3.2-Exp明确介绍了 DeepSeek Sparse Attention;不能把 V2/V3 的 MLA 统称为同一种稀疏索引方案。

线性注意力可以通过特征映射与累积状态重新组织计算,避免构造完整的 N×N 交互矩阵。固定状态维度时,可以按序列步数递推;状态空间模型是另一类结构,不能与所有线性注意力实现画等号。

路线主要代价评估重点
密集注意力序列两两交互、KV 存储因果正确性与长序列成本
稀疏注意力候选选择和稀疏算子保留连接是否覆盖任务
递推状态状态更新与数值稳定长序列记忆与检索质量

比较时固定数据、参数预算与序列长度;同时记录实际速度和质量,不用复杂度符号直接宣布某一种方案更好。

MoE 与视觉扩展:增加结构就增加检查项

MoE 用路由器选择部分专家处理一个 token。总参数量与激活参数量不同:即使只调用部分专家,训练或推理的驻留权重仍可能包含全部专家,梯度、优化器状态和通信也占资源。

下面只展示一个 token 的 top-k 路由接口,router 与每个专家都由调用方传入:

import torch

def route_one_token(x, router, experts, k=2):
    if not 1 <= k <= len(experts):
        raise ValueError("k超出专家数量")
    scores, indices = torch.topk(router(x), k)
    weights = torch.softmax(scores, dim=-1)
    outputs = torch.stack([experts[int(i)](x) for i in indices])
    return (weights[:, None] * outputs).sum(dim=0)

实际训练还要处理批量路由、负载分布、容量和并行通信。DeepSeek-V2 论文讨论了共享与路由专家;共享专家始终参与是一种结构安排,不能直接推断任意小模型都会更容易收敛。保留同预算 dense 基线,再看收益与额外成本。

视觉输入从 patch 数开始

把图像切成 patch 后,每个 patch 投影到模型维度。对可整除的 H×W 图像,16×16 patch 的基本数量为 (H/16)×(W/16);额外 token 和预处理方式由具体实现决定。

二维位置编码表示横纵坐标。采用二维 RoPE 时,需要约定旋转维度怎样分给不同轴,并非所有实现都采用“偶数维横向、奇数维纵向”。改变分辨率会改变长度和数据分布,应重新验证投影、索引与目标任务。

从数据到一次训练更新

先划分训练集和验证集,再清洗、编码、打包和抽样。按文档或任务分组划分,可以减少相同内容的近重复片段落入两边。拼接样本时,应明确跨文档边界是否允许注意力连接。

优化器的名字不能替代实验

AdamW 把权重衰减与自适应梯度更新解耦,具体系数和参数分组仍须选择。PyTorch AdamW 文档列出了算法与配置。下例给出单步接口,调用方提供已经构建的模型、整数 ID 和优化器:

def train_step(model, input_ids, optimizer):
    model.train()
    optimizer.zero_grad(set_to_none=True)
    loss = model(input_ids=input_ids, labels=input_ids).loss
    loss.backward()
    optimizer.step()
    return float(loss.detach())

这里使用等长、未填充的文本块。增加 padding 后,还要设置 attention mask 并屏蔽填充标签。保存模型配置、数据版本、有效 token 数、训练步数和耗时,才能解释验证损失的变化。

数据顺序是变量,不是固定配方

交错采样按比例混合来源,课程学习随阶段改变来源或难度,两者都应在同一预算下比较。课程学习可能产生顺序效应,混合比例也可能偏离目标任务;没有一个表格能预先保证哪条路线 loss 最低。

对不同来源分别建立验证集。总体 loss 下降时,某一类任务仍可能退化;只看一个平均值会掩盖这个变化。放大实验前,先确认数据边界、采样权重和对照设置都可以复现。

后训练:先定义奖励与评估预算

监督微调学习示范序列;采用可验证奖励的强化学习则根据任务结果优化策略。奖励可以来自测试、规则或评审模型,但每种验证器都有成本和覆盖边界。代码执行尤其需要隔离环境与资源限制。

GRPO 的组内相对信号

DeepSeekMath 论文提出 GRPO,用同一提示下的一组候选奖励构造相对信号,减少对独立价值模型的依赖。具体实现的标准化、KL 项与采样设置应查看训练器配置;下面仅演示一种组内标准化:

def group_advantages(rewards, group_size, eps=1e-4):
    if group_size < 2 or rewards.ndim != 1 or rewards.numel() % group_size:
        raise ValueError("奖励数量须能分成至少两项的小组")
    grouped = rewards.view(-1, group_size)
    mean = grouped.mean(dim=-1, keepdim=True)
    std = grouped.std(dim=-1, keepdim=True, correction=0)
    return ((grouped-mean)/(std+eps)).flatten()

一组奖励完全相同时,相对信号可能为零;奖励解析错误、测试缺口或超时处理不当,也会扭曲训练方向。先人工检查若干真实样本,再启动批量训练。参数高效更新只改变可训练参数的范围,不免除奖励、采样和执行成本。

Pass@k 与验证损失一起看

Pass@k 描述多次生成中至少一个候选通过验证的情况;需要同时报告 k、采样方式与验证规则。验证损失衡量保留文本分布上的预测表现,与特定任务通过率不是同一指标。

任务通过率提高时,还要检查通用任务、异常输出和验证器漏洞。训练奖励升高,可能来自完成任务,也可能来自利用规则缺口;应保留未公开的评估任务,并抽查实际行为。

from statistics import mean, variance

def summarize(runs):
    if len(runs) < 2:
        raise ValueError("至少两次运行才能计算样本方差")
    return {name: {"mean": mean(r[name] for r in runs),
                   "variance": variance(r[name] for r in runs)}
            for name in ("pass_at_k", "heldout_loss")}

runs 应来自真实重复实验,每次只改变约定的种子或变量,保留配置与预算。先把编码往返、参数计数、因果性、单步梯度与保存加载做实,再加入复杂注意力、专家路由和后训练。每增加一种结构,就用同预算基线检查它是否解决了原问题。

参考来源