返回关卡地图
预判 0 / 3 闯关题:0 / 3 XP 0 / 300

Level 27 | 零基础导学关卡

ZeRO 优化器模拟

ZeRO Optimizer Sim

先通过图解和小例子理解原理,再做预测与练习;不懂的地方可以反复尝试,最后把理解写进官方 Notebook。

本课官方 Notebook ↗
27_ZeRO_Optimizer_Sim.ipynb 并行通信ZeRO参数切分
Mission 1 概念练习

先分清对象:两张逻辑 GPU 各负责一半 Parameter

🎯 先猜一猜

SimpleModel(dim=4) 有两个无 bias 的 Linear 层。

list(model.parameters()) 中有多少个 Parameter 对象?

先猜一个答案,再对照下面的讲解。可以随时修改选择,猜错会得到解释。

先补的知识

  • model.parameters() 返回一个可迭代对象;先转成 list,后面才能按下标切片并反复访问。
  • 当前 SimpleModel 有 fc1.weight 和 fc2.weight 两个 Parameter,因为两个 Linear 都设置了 bias=False。
  • Notebook 文字用 flatten 帮助理解,但当前代码并没有把每个 Parameter 的元素拼成大向量;它实际切分的是 Parameter 对象列表。
  • 列表切片保留原 Parameter 的引用,不会复制权重。后面通过这些引用更新时,SimpleModel 里的权重也会改变。

图解原理

把优化器看成一个任务分派员。它先把模型参数按出现顺序排成列表,再从中点切成前后两组:逻辑 GPU 0 负责前半,逻辑 GPU 1 负责后半。这里没有真实 CUDA 通信,字典只是模拟每张卡的责任范围。

收集model_params -> self.params 列表 找中点half_idx = 参数对象数 // 2 切前半逻辑 GPU 0 的参数引用 切后半逻辑 GPU 1 的参数引用

当前模型到底有几个参数对象

fc1.weight一个 Parameter,测试中 shape=[4,4] fc2.weight一个 Parameter,测试中 shape=[4,4] bias不存在,因为 bias=False len(self.params)2,而不是 32 个标量

切分后的所有权

参数列表
[fc1.weight, fc2.weight]
GPU 0
[fc1.weight]
GPU 1
[fc2.weight]

测试正是按这个顺序提供梯度:键 0 对应 fc1,键 1 对应 fc2。

结构外层类型内容是否复制 Tensor
self.paramslist全部 Parameter 引用
self.gpu_partitionsdictgpu_id -> Parameter 列表
self.gpu_partitions[0]list当前测试中只有 fc1.weight
不要对每个 [4,4] 权重张量沿元素维度切半;可见测试希望每张逻辑 GPU 恰好负责一个完整 Parameter。

语法热身:用待处理任务列表练习中点切分

tasks = ['embed', 'attention', 'mlp', 'head']
middle = len(tasks) // 2
owners = {
    0: tasks[:middle],
    1: tasks[middle:],
}

assert owners[0] == ['embed', 'attention']
assert owners[1] == ['mlp', 'head']

例子中的变量 -> Notebook TODO 变量/操作

  • tasks -> self.params:Notebook 先用 list 收集 Parameter 引用。
  • middle -> half_idx:都使用列表长度的整除中点。
  • owners -> self.gpu_partitions:键是 gpu_id,值是它负责的列表切片。
  • 字符串任务没有 shape;Notebook 列表元素是 Parameter,各自保留原 shape、dtype、device。

巩固一下

为什么 gpu_partitions 中应保存原 Parameter 引用?

学完这一段,试着做

用一个小动作确认自己理解了;最后再进入官方题目。

Mission 2 概念练习

再建局部账本:每张卡只给自己的参数保存动量

🎯 先猜一猜

fc1.weight 在 CPU 上是 float32,shape=[4,4]。

torch.zeros_like(fc1.weight.data) 会得到什么?

先猜一个答案,再对照下面的讲解。可以随时修改选择,猜错会得到解释。

先补的知识

  • 优化器状态与参数不是同一个东西:Parameter 保存权重,状态 Tensor 保存历史动量。
  • 外层字典按 gpu_id 找到某张逻辑卡,内层字典按 id(p) 找到某个参数的状态。
  • id(p) 是当前 Python 进程中对象身份的整数标识;同一个 Parameter 引用在分区和更新阶段会得到同一个 id。
  • torch.zeros_like(p.data) 会复制 p 的 shape、dtype 和 device,但把数值全部初始化为 0。

图解原理

分区只是写下“谁负责谁”,局部状态才是真正省重复存储的地方。GPU 0 不为 fc2 建动量,GPU 1 也不为 fc1 建动量。每个状态必须长得和对应参数一样,后面才能与梯度逐元素相加。

分区告诉我们该为谁建状态

GPU 0
fc1.weight
一个 [4,4] 零动量
GPU 1
fc2.weight
一个 [4,4] 零动量

两层字典的读取路径

第一层optimizer_states[gpu_id] 找到本卡状态表 第二层states[id(p)] 找到参数 p 的动量 状态值与 p 同 shape/dtype/device 的 Tensor
测试对象状态条目数状态 shape初始数值
GPU 0 / fc1.weight1[4,4]全 0
GPU 1 / fc2.weight1[4,4]全 0

为什么用 zeros_like

它一次满足数值、shape、dtype、device 四项初始化契约,梯度到来时可以直接逐元素相加。

为什么不用同一个零 Tensor

每个 Parameter 必须拥有独立历史。共享同一个状态会让一个参数的梯度污染另一个参数。

语法热身:为两个独立计数器建立按对象身份索引的状态

counter_a = torch.randn(2, 3)
counter_b = torch.randn(4)
assigned = {0: [counter_a], 1: [counter_b]}

history = {}
for worker_id, tensors in assigned.items():
    history[worker_id] = {
        id(t): torch.zeros_like(t) for t in tensors
    }

assert history[0][id(counter_a)].shape == counter_a.shape

例子中的变量 -> Notebook TODO 变量/操作

  • assigned -> self.gpu_partitions:外层键表示负责者。
  • history -> self.optimizer_states:每个负责者拥有自己的内层状态表。
  • id(t) -> id(p):用原对象身份取回对应状态。
  • zeros_like(t) -> zeros_like(p.data):状态继承参数的 shape、dtype、device。

巩固一下

当前测试为什么要求 len(optimizer.optimizer_states[0]) == 1?

学完这一段,试着做

用一个小动作确认自己理解了;最后再进入官方题目。

Mission 3 概念练习

最后更新原权重:对齐 params、grads、states 三条列表

🎯 先猜一猜

初始 momentum 为 0,梯度每个元素都是 2.0,学习率是 0.1。

第一次 step 后,旧权重减新权重的每个元素是多少?

先猜一个答案,再对照下面的讲解。可以随时修改选择,猜错会得到解释。

先补的知识

  • gradients_from_all_gpus 已经模拟 Reduce-Scatter 的结果:每个 gpu_id 只拿到自己负责参数的平均梯度列表。
  • 同一分区中 params 与 grads 的顺序必须一一对应;zip(params, grads) 正好按位置配对。
  • 本页的 momentum 是教学简化版,只做 old_momentum + gradient,没有 beta 系数,也没有 Adam 的二阶状态。
  • 更新公式是新权重 = 旧权重 - lr * momentum;减号表示沿降低损失的方向移动。

图解原理

每张逻辑卡打开自己的三样东西:负责的参数、收到的梯度、保存的动量。先把新梯度记进动量,再用学习率缩小这一步,最后从原权重减掉。因为 params 保存的是原模型引用,改完后测试直接读取 model.fc1.weight 就能看到变化。

按 gpu_id取本卡 params 同一个键取本卡 grads 与 states 逐对 zip参数 p 对齐梯度 g 先状态后参数momentum += g,p -= lr × momentum

GPU 0 的第一步

1momentum: 0 + 1 = 1梯度是全 1 Tensor
2update: 0.1 × 1 = 0.1每个权重元素都减 0.1
3initial_w1 - new_w1 = 0.1测试比较的正是这个差值

GPU 1 的第一步

1momentum: 0 + 2 = 2梯度是全 2 Tensor
2update: 0.1 × 2 = 0.2每个权重元素都减 0.2
3initial_w2 - new_w2 = 0.2与第二条 allclose 断言对齐
配对项当前测试 shapedtype/device作用
p[4,4]模型参数原属性被更新的原权重
g[4,4]由 ones_like/full_like 对齐Reduce-Scatter 模拟梯度
momentum[4,4]由 zeros_like 对齐累积历史并参与更新
states[id(p)] 必须写回新的 momentum。只更新局部变量而不写回字典,会让第二次 step 忘记历史,破坏“状态”的含义。

语法热身:用两个货架的库存与累计修正量练局部更新

shelves = {0: [torch.tensor([5.0, 7.0])], 1: [torch.tensor([9.0])]}
corrections = {0: [torch.tensor([1.0, 2.0])], 1: [torch.tensor([3.0])]}
memory = {worker: {id(item): torch.zeros_like(item) for item in items}
          for worker, items in shelves.items()}
rate = 0.25

for worker in (0, 1):
    local_memory = memory[worker]
    for item, change in zip(shelves[worker], corrections[worker]):
        accumulated = local_memory[id(item)] + change
        local_memory[id(item)] = accumulated
        item.sub_(rate * accumulated)

例子中的变量 -> Notebook TODO 变量/操作

  • shelves[worker] -> paramscorrections[worker] -> grads
  • local_memory -> states,通过 id(p) 读取并写回 momentum。
  • zip(item, change) -> zip(params, grads):顺序必须一致,shape 才逐项匹配。
  • rate -> self.lr;Notebook 使用 Parameter 的 data 更新原模型引用。

巩固一下

为什么 TODO 3 要同时执行 states[id(p)] = momentum?

学完这一段,试着做

用一个小动作确认自己理解了;最后再进入官方题目。

把理解变成自己的代码

准备好,去官方题目试一试

在本页用小例子建立直觉,再去官方 Notebook 完成实现。先运行你自己的测试,遇到困难时再查看官方提示与参考答案。

前往本课官方 Notebook ↗

这节课会遇到的代码对象

SimpleModel · forward · ZeRO1_Optimizer_Sim · step · zero_grad

先找题目里的输入、输出与 TODO,再把本课的手算过程对应进去;以官方题目中的函数说明和测试为准。

练习来源:Datawhale 官方仓库 · 518cc45。这里的入口直接打开官方版本,不读取或分享你的本地 Notebook。

完成 3 道闯关题后,本关即算完成;作业 checklist 用来辅助你回 notebook 练习。
进度只保存在当前浏览器 localStorage,分享 HTML 不会带走你的记录。
上一关:L26 下一关:L28