vocab_size = 154856
parameters = 1,309,190,144
约 1309.19M
这里有一个必须写进博客的数字:
154856 × 2048 ≈ 3.17e8
词表 Embedding 约占 24% 参数
也就是说,V5 的“1B”有将近四分之一花在 Embedding 上。
这不是浪费。大词表对中文更友好,后面接 GLM 生态也更省事。但它会让你对“我这个模型到底有多少层在干活”产生错觉。后面 proxy 那节会再碰到一次。
训练默认:
seq_len = 1024
batch_size = 1
gradient_accumulation = 32
AdamW
lr = 3e-4
warmup = 2000
max_steps = 20000
这些是正式预训练的参数。今天没有跑满。
4. 第一个决定:不要再训 32k 运维 BPE
机器刚起来的时候,手里其实已经有一份 V5 的 32k tokenizer,语料是大约 89MB 技术文档。
测下来很典型:
kubectl 可能被切成 3 个 token
中文大约 1.14 字符 / token
领域 BPE 的问题我在 V2 就已经见过:它对文档站里的英文术语很亲,对真正的中文问答并不亲。
更致命的是 V1 到 V3 反复踩过的坑:
Tokenizer 一旦在训练开始后改掉,前面的权重全部作废。
所以今天不再训词表。
直接从 ZGCM-1-7B 只拿走 tokenizer,不拿 7B 权重:
tokenizer/glm51
HuggingFace AutoTokenizer
eos_token_id 需要能放进 Embedding
这里有一个容易写错的细节。
tokenizer.vocab_size 打印出来是:
154820
但 eos_token_id 也是:
154820
如果 Embedding 建成 nn.Embedding(154820, d_model),EOS 会直接越界。
V5 里实际使用:
vocab_size = max(vocab_size, len(tokenizer), eos_id + 1)
= 154856
以后如果有人复现,不要只抄 tokenizer.vocab_size。
5. 数据从哪来:不是 txt,是 parquet
V2 的数据是我自己爬的文档站和 GitHub Markdown。
V5 改用 ZGCM-1 预训练 stage1 的 scout 分片。这次实际落到磁盘上的是两个 parquet:
zgcm-1-pretrain-stage1
revision 20a6cf27f80d53a2c1fcc8f0dbb9bef7cdd144c3
part-00689.parquet ≈ 1.45GB
part-00882.parquet ≈ 1.45GB
很多人第一次看 HuggingFace Dataset,会以为里面是一堆 txt。
不是。
常见形态就是:
一个 repo / dataset
↓
若干 parquet shard
↓
每一行一个 sample
↓
其中一列叫 text
parquet 是列式存储,这次用的是 ZSTD 压缩。对训练脚本来说,它只是“能按列流式读的二进制表”,不是给人类翻的小说。
抽查过这些 scout 行之后,观感非常直接:
预训练语料很乱。大量 FineWeb 风格英文网页,中文占比不高,质量也谈不上“运维教材”。
这不是 bug。
这就是现在大规模预训练的常态。模型先在很杂的文本上学会“下一个 token 更可能是什么”,而不是先学会“Kubernetes Pending 应该怎么排障”。
6. 预训练到底在学什么
V2、V3 我已经写过很多次:val loss 下降不等于会回答问题。
今天对着 parquet 里那些乱七八糟的网页,这件事变得更具体。
预训练的目标仍然只是:
P(下一个 token | 前面所有 token)
不是:
检索文档
回答问题
执行 runbook
所以如果有人问:
这些网页这么乱,模型以后怎么当 SRE?
诚实的答案是:
这一步不负责当 SRE。
这一步只负责把语言和世界的统计结构先装进权重。
SRE 怎么说话、怎么排障,是后面 SFT 的事。
这也是我后来决定今晚不去砸满 20000 step 的原因之一。
1B 全量预训练要的是时间、电和数据覆盖。租卡倒计时里硬跑,得到的只是一个没训完的 checkpoint,以及一张更贵的账单。
7. 分词怎么切 train / val
V5 的 memmap 不再用“整条 token 河最后 1% 当 val”。
那种切法看起来方便,但文档中间切开以后,val 可能只是同一篇文章的后半段。V2 的验证集可信度问题,有一部分就来自这种泄漏。
这次按文档做 hash:
MD5(text 前 256 字符)
val_percent = 1
每个文档完整进 train 或完整进 val,然后追加 EOS,写成:
data/v5/binary/glm51/part-00689_plus_tech/train.bin
data/v5/binary/glm51/part-00689_plus_tech/val.bin
dtype 是 uint32,用 memmap 读,15GB 内存撑得住。
689 分片加上原来的 tech_docs,大约 20:42 分完:
docs_train ≈ 949420
docs_val ≈ 9586
train_tok ≈ 847,508,995
val_tok ≈ 8,808,550
也就是说,单这一个 scout 分片加技术文档,独立 token 已经比 V2 的 2300 万高一个数量级以上。
882 分片在 CPU 上继续跑。GPU 不必等它。
8. 一个很丢人的 shape 错误
Smoke 不是一上来就过的。
第一版 SwiGLU 的 down_proj 写成了:
nn.Linear(d_model, d_model)
正确的是:
nn.Linear(d_ff, d_model)
于是前向直接炸:
RuntimeError: mat1 and mat2 shapes cannot be multiplied
256x5632 vs 2048x2048
256 是当时的 seq_len,5632 是 d_ff,2048 是 d_model。
这种错误和“模型会不会回答 Pending Pod”无关。
它只说明:
1B 配置写到纸上之前,必须先用随机 token 做一次前向和反向。
9. Smoke:随机 token 也能说明很多事
修好以后,先跑 seq=256:
python scripts/smoke_v5_1b.py \
--scale 1b \
--seq-len 256 \
--batch-size 1 \
--tokenizer tokenizer/glm51
输出:
GPU: NVIDIA A100-SXM4-40GB
scale: 1b
vocab_size: 154856
parameters: 1,309,190,144 (1309.19M)
loss=12.4402
peak_allocated=11.03GB
SMOKE PASS
再跑训练长度:
python scripts/smoke_v5_1b.py \
--scale 1b \
--seq-len 1024 \
--batch-size 1 \
--tokenizer tokenizer/glm51
loss=12.3669
peak_allocated=11.25GB
SMOKE PASS
两件事值得单独说。
第一,随机初始化的 loss 应该接近:
ln(154856) ≈ 11.95
12.4 左右是对的。如果这里出现 0.00,那一定是又把 label 写坏了。
第二,256 和 1024 的峰值显存几乎一样。
这就是 activation checkpointing 在干活。没有它,1B / 1024 在 40GB 上会紧张得多。
Smoke 用的是 torch.randint,不读真实语料。
所以它只证明:
模型能放进 A100
BF16 前向反向能走通
词表尺寸没写错
checkpointing 有效
它不证明模型会运维。
10. 真正的训练环:20 步 proxy
V1 到 V3 还有一个存盘 bug,我这次专门改了:
以前 latest.pt / final.pt 里的 val_loss 会被写成 0.0
看起来像训练出了神迹
其实只是没 eval 的时候填了默认值
V5 改成记住最后一次真正的 eval。没 eval 过就是 None,不许再写 0。
数据环用已经分好的 689+tech,模型先用 proxy,不直接上 1B 全量:
python scripts/train_v5.py \
--scale proxy \
--tokens data/v5/binary/glm51/part-00689_plus_tech \
--tokenizer tokenizer/glm51 \
--seq-len 1024 \
--batch-size 4 \
--gradient-accumulation 8 \
--max-steps 20 \
--eval-every 10 \
--save-every 20 \
--log-every 1 \