CODING WITH AI · LECTURE 06

返回课堂 · 讲义

已保存可复算的 CPU 教学输出。ICL fixture 为合成评分测试,所有 Lab 均未调用真实模型。

LAB 01 · 训练预算、适配与分块归一化

这一本沿同一个问题逐步推进:想把模型训练或适配到可用,资源到底花在哪里?先算训练预算,再看持久显存,最后把低秩更新、低位存储与注意力分块分清。

证据身份:所有输出均为确定性 CPU 教学算例;没有训练 8B 模型,没有运行 GPU kernel,也没有测量任何加速比。 每节均可改一个参数,先写预测再运行。全本只使用 Python 标准库,按顺序运行即可,不依赖其他 Notebook 的变量。

对应内容:规模扩展 → 全参数状态 → LoRA/QLoRA → FlashAttention。读完后应能回答:参数少 256 倍为什么不意味着速度快 256 倍?分块之后为什么不能平均各块的结果?

1. 固定预算,参数与数据一起选择

N 是参数个数,D 是消费的训练 token 数,C≈6ND 是常见稠密 Transformer 训练近似。它忽略额外注意力成本、具体架构和实现开销,不是 GPU 实测 FLOPs。

基线令 N=8B、D=160B。只把 N 翻倍时,固定 C 允许的 D 会减半。这张表不能告诉你哪个配置的 loss 最低;那需要按同一训练协议测量。Chinchilla 的论文证据来自多组训练与拟合,不是本表凭空生成的曲线。

查看与修改计算代码
import math, json
N = 8_000_000_000
D = 160_000_000_000
C = 6*N*D
print(f'基线 C≈{C:.3e} FLOPs(名义近似)')
for n in (4e9, 8e9, 16e9, 32e9):
    print(f'N={n/1e9:4.0f}B, 固定预算 D={C/(6*n)/1e9:6.1f}B tokens')
assert C == 7.68e21

保存的本地 CPU 输出 · budget-adaptation-02

基线 C≈7.680e+21 FLOPs(名义近似)
N=   4B, 固定预算 D= 320.0B tokens
N=   8B, 固定预算 D= 160.0B tokens
N=  16B, 固定预算 D=  80.0B tokens
N=  32B, 固定预算 D=  40.0B tokens

修改一项:只把 for n 候选列表中的 16e9 改为 12e9,保持上面定义基线 N、D 与 C 的三行不动,再观察固定预算下允许的 D。不要同时修改预算和数据后仍称为单变量比较。收益下降的条件是数据不足、架构或训练效率变化;本计算仅给预算约束,不能替代能力评价。

2. 8B 全参数 Adam:四卡复制与分片

这里计算的是全参数训练的持久状态,后面再比较冻结底座的 LoRA 适配。每参数:BF16 权重 2 bytes、BF16 梯度 2、FP32 主权重 4、两个 FP32 动量合计 8。十进制 GB=10⁹ bytes。

DDP 复制全部状态;ZeRO-1 分片主权重与动量;ZeRO-2 再分片梯度;ZeRO-3 再分片低精度权重。临时 all-gather、激活、缓冲区没有计入,因此表中数字不是总峰值。

查看与修改计算代码
P=8_000_000_000
cards=4
parts={'weight':P*2/1e9,'gradient':P*2/1e9,'master':P*4/1e9,'moments':P*8/1e9}
def state_ledger(k):
    w,g,o=parts['weight'],parts['gradient'],parts['master']+parts['moments']
    return {'DDP':w+g+o,'ZeRO-1':w+g+o/k,'ZeRO-2':w+(g+o)/k,'ZeRO-3':(w+g+o)/k}
print('持久状态 GB:',parts)
print('四卡,每卡持久状态 GB:',state_ledger(cards))
print('单卡时所有分片方案回到同一账本:',state_ledger(1))
assert list(state_ledger(4).values()) == [128,56,44,32]
assert len(set(state_ledger(1).values())) == 1

保存的本地 CPU 输出 · budget-adaptation-04

持久状态 GB: {'weight': 16.0, 'gradient': 16.0, 'master': 32.0, 'moments': 64.0}
四卡,每卡持久状态 GB: {'DDP': 128.0, 'ZeRO-1': 56.0, 'ZeRO-2': 44.0, 'ZeRO-3': 32.0}
单卡时所有分片方案回到同一账本: {'DDP': 128.0, 'ZeRO-1': 128.0, 'ZeRO-2': 128.0, 'ZeRO-3': 128.0}

观察:四卡 ZeRO-3 每卡 32 GB,四卡合计仍是 128 GB 的持久状态。它没有让模型本身消失。卡数变成 1 时所有分片收益消失,是一个可解释的边界。真正运行时,每个数据 rank 仍让自己的样本经过全部层,需要收集当前层参数,再把梯度归约到负责的分片。

3. LoRA 的两条路:冻结 W,更新 A/B

令列向量 x 进入 Wx + sBAx。课堂形状是 W:4096×4096,A:8×4096,B:4096×8,固定 s。下面先算参数量,再用 2×2 小矩阵直接看一次更新后的输出。这里显式只改 B;A/B 都属于可训练增量,W 保持原值。

查看与修改计算代码
width=4096
rank=8
full=width*width
trainable=2*width*rank
print('完整 W 参数:',full,'LoRA 参数:',trainable,'比例:',trainable/full)
assert full==16777216 and trainable==65536
W=[[1.0,2.0],[0.0,1.0]]
A=[[1.0,-1.0]]
B=[[0.5],[1.0]]
x=[2.0,1.0]
s=1.0
W_before=json.dumps(W)
def mv(M,x): return [sum(a*b for a,b in zip(row,x)) for row in M]
def lora_output(W,A,B,x,s=1.0):
    base=mv(W,x); delta=mv(B,mv(A,x))
    return {'Wx':base,'sBAx':[s*v for v in delta],'sum':[u+s*v for u,v in zip(base,delta)]}
print('更新前:',lora_output(W,A,B,x,s))
B[0][0]+=0.1
print('只更新 B 后:',lora_output(W,A,B,x,s))
assert json.dumps(W)==W_before
for r in (8,64,2048,4096):
    print('rank',r,'适配参数/原矩阵参数=',2*r/width)

保存的本地 CPU 输出 · budget-adaptation-06

完整 W 参数: 16777216 LoRA 参数: 65536 比例: 0.00390625
更新前: {'Wx': [4.0, 1.0], 'sBAx': [0.5, 1.0], 'sum': [4.5, 2.0]}
只更新 B 后: {'Wx': [4.0, 1.0], 'sBAx': [0.6, 1.0], 'sum': [4.6, 2.0]}
rank 8 适配参数/原矩阵参数= 0.00390625
rank 64 适配参数/原矩阵参数= 0.03125
rank 2048 适配参数/原矩阵参数= 1.0
rank 4096 适配参数/原矩阵参数= 2.0

低秩约束的是增量 BA,不是 W 本身。rank=2048 时两小矩阵合计参数已经与原矩阵一样多;rank=4096 时反而更多。即使 rank 很小,仍须计算冻结主路、保存必要激活并让梯度通过底座传播。因此 1/256 是这一矩阵的可训练参数比例,不是整体训练速度。

4. 低位保存不等于低位完成全部计算

下面是对称均匀量化教学函数,并非 NF4,也不是完整 QLoRA。整数编码按组最大绝对值定标,解码后用 Python 浮点做乘法。只增加一个离群值,就能看见原来较小的权重被压到 0。

查看与修改计算代码
def quantize(values,bits=4):
    qmax=2**(bits-1)-1
    scale=max(abs(x) for x in values)/qmax if any(values) else 1.0
    ints=[max(-qmax,min(qmax,round(x/scale))) for x in values]
    restored=[q*scale for q in ints]
    err=sum((a-b)**2 for a,b in zip(values,restored))/len(values)
    return {'scale':scale,'integer':ints,'dequantized':restored,'MSE':err}
regular=[0.1,-0.2,0.5,1.0]
outlier=regular+[12.0]
for label,values in [('基线',regular),('只增加离群权重',outlier)]:
    print(label,quantize(values,4))
print('同一离群数组改为8bit:',quantize(outlier,8))
print('全零边界:',quantize([0,0,0]))
assert quantize(outlier,4)['MSE'] > quantize(regular,4)['MSE']

保存的本地 CPU 输出 · budget-adaptation-08

基线 {'scale': 0.14285714285714285, 'integer': [1, -1, 4, 7], 'dequantized': [0.14285714285714285, -0.14285714285714285, 0.5714285714285714, 1.0], 'MSE': 0.0025510204081632642}
只增加离群权重 {'scale': 1.7142857142857142, 'integer': [0, 0, 0, 1, 7], 'dequantized': [0.0, 0.0, 0.0, 1.7142857142857142, 12.0], 'MSE': 0.1620408163265306}
同一离群数组改为8bit: {'scale': 0.09448818897637795, 'integer': [1, -2, 5, 11, 127], 'dequantized': [0.09448818897637795, -0.1889763779527559, 0.47244094488188976, 1.0393700787401574, 12.0], 'MSE': 0.0004922809845619681}
全零边界: {'scale': 1.0, 'integer': [0, 0, 0], 'dequantized': [0.0, 0.0, 0.0], 'MSE': 0.0}

这说明量化误差与数值分布、分组及位宽共同相关。QLoRA 中冻结底座通常以低位保存,计算时反量化到适合计算的精度,LoRA 参数及梯度有自己的精度;不能把上面这个均匀量化函数改个名字便说复现了 QLoRA。NF4 针对正态分布形状设计码本;双重量化再压缩量化常数;paged optimizer 管理优化器内存峰值,三者作用不同。

5. 分块 softmax:为何保存分子、分母与最大值

课堂先给正权重 [2,1,3] 和值 [10,20,30],两块分别是前两个和最后一个。它们分母恰好同为 3,简单平均会偶然答对。只把第三权重 3 改为 4,偶然性就消失。

查看与修改计算代码
values=[10.,20.,30.]
def block_example(weights):
    block1=sum(a*b for a,b in zip(weights[:2],values[:2]))/sum(weights[:2])
    block2=values[2]
    exact=sum(a*b for a,b in zip(weights,values))/sum(weights)
    return {'exact':exact,'wrong_sum':block1+block2,'unweighted_mean':(block1+block2)/2,'denominators':[sum(weights[:2]),weights[2]]}
print('[2,1,3]:',block_example([2,1,3]))
print('[2,1,4]:',block_example([2,1,4]))
assert math.isclose(block_example([2,1,3])['exact'],130/6)
assert not math.isclose(block_example([2,1,4])['exact'],block_example([2,1,4])['unweighted_mean'])

保存的本地 CPU 输出 · budget-adaptation-10

[2,1,3]: {'exact': 21.666666666666668, 'wrong_sum': 43.333333333333336, 'unweighted_mean': 21.666666666666668, 'denominators': [3, 3]}
[2,1,4]: {'exact': 22.857142857142858, 'wrong_sum': 43.333333333333336, 'unweighted_mean': 21.666666666666668, 'denominators': [3, 4]}

现在加入数值稳定性。每块 logit 先减 running max。新块最大值变大时,旧分子和旧分母都乘 exp(old_max-new_max);两者在同一尺度上才可相加。下面支持 mask 后的空块;全部被 mask 时返回 None,不把 0/0 伪装成有效注意力。

查看与修改计算代码
def online_softmax_value(blocks):
    m=-math.inf; denom=0.; numer=0.; trace=[]
    for block in blocks:
        valid=[(z,v) for z,v in block if z != -math.inf]
        if not valid:
            trace.append({'empty':True,'m':m,'denom':denom,'numer':numer}); continue
        new_m=max(m,max(z for z,v in valid))
        scale=0. if m == -math.inf else math.exp(m-new_m)
        denom=denom*scale+sum(math.exp(z-new_m) for z,v in valid)
        numer=numer*scale+sum(math.exp(z-new_m)*v for z,v in valid)
        m=new_m
        trace.append({'m':m,'denom':denom,'numer':numer,'old_scale':scale})
    return (numer/denom if denom else None),trace
for shift in (0.,1000.):
    out,trace=online_softmax_value([[(math.log(2)+shift,10),(shift,20)],[],[(math.log(4)+shift,30)]])
    print('logit整体平移',shift,'输出',out,'trace',trace)
    assert math.isclose(out,160/7,rel_tol=1e-12)
assert online_softmax_value([[],[(-math.inf,10)]])[0] is None
print('全 mask 行:无有效结果(None),由调用方明确处理。')

保存的本地 CPU 输出 · budget-adaptation-12

logit整体平移 0.0 输出 22.857142857142858 trace [{'m': 0.6931471805599453, 'denom': 1.5, 'numer': 20.0, 'old_scale': 0.0}, {'empty': True, 'm': 0.6931471805599453, 'denom': 1.5, 'numer': 20.0}, {'m': 1.3862943611198906, 'denom': 1.75, 'numer': 40.0, 'old_scale': 0.5}]
logit整体平移 1000.0 输出 22.857142857143074 trace [{'m': 1000.6931471805599, 'denom': 1.5000000000000275, 'numer': 20.00000000000055, 'old_scale': 0.0}, {'empty': True, 'm': 1000.6931471805599, 'denom': 1.5000000000000275, 'numer': 20.00000000000055}, {'m': 1001.3862943611199, 'denom': 1.7499999999999698, 'numer': 39.99999999999969, 'old_scale': 0.49999999999997063}]
全 mask 行:无有效结果(None),由调用方明确处理。

这是标量输出的数学复核,不是 FlashAttention GPU kernel 复现。FlashAttention 还要处理 tile、HBM/片上存储调度、向量输出与反向重算。稠密注意力的算术规模并未因此从平方变成线性。浮点计算顺序变化也不保证逐 bit 一致。

6. 评价也会改变你看到的现象

严格格式任务需要 exact match;字符编辑距离可以描述错在哪里,但不能事后替换主要指标。下面只变一个输出字符,exact match 会跳变,距离仍能保留误差大小。输入是人工构造,不是模型输出。

查看与修改计算代码
def edit_distance(a,b):
    row=list(range(len(b)+1))
    for i,ca in enumerate(a,1):
        new=[i]
        for j,cb in enumerate(b,1): new.append(min(new[-1]+1,row[j]+1,row[j-1]+(ca!=cb)))
        row=new
    return row[-1]
for output in ('ABCDEF','ABCDEG','XXXXXX'):
    print({'人工输出':output,'目标':'ABCDEF','exact_match':output=='ABCDEF','edit_distance':edit_distance(output,'ABCDEF')})

保存的本地 CPU 输出 · budget-adaptation-14

{'人工输出': 'ABCDEF', '目标': 'ABCDEF', 'exact_match': True, 'edit_distance': 0}
{'人工输出': 'ABCDEG', '目标': 'ABCDEF', 'exact_match': False, 'edit_distance': 1}
{'人工输出': 'XXXXXX', '目标': 'ABCDEF', 'exact_match': False, 'edit_distance': 6}

离开本 Lab 前

试着用自己的话解释:哪张表只约束资源,哪段代码检查冻结对象,哪个反例说明算法的必要条件?若想说一个方案“更好”,你还缺哪些真实测量?

来源与边界:本课教学算例;Chinchilla、ZeRO、LoRA、QLoRA、FlashAttention。数值均由本本计算,未把论文性能转写成本次实测。