📘 第13章 分布式计算
📄 一句话概括

同一份代码在两个人机器上跑出两种结果,九成是依赖的版本不一样。 这一节把「装环境」拆成三道关:解依赖、锁版本、分层传。 每一道都有自己的数,不是玄学。

📝 本节和前面几节的关系

13.2 讲硬件的马力,这一节讲硬件上面那一层。机器配好只是开始,装不对照样跑不起来。 13.4 的平台会把这一节的结论直接用上:每个作业都要带上自己的镜像。

一、依赖是一张网

每个包都有好几个版本,包与包之间还有「我要至少某个版本」的要求:

包 可用版本 它要求
框架 1.0、2.0、2.3、3.0 数值库≥2.0、工具库≥20.0
图像库 8.0、9.0、10.0 数值库≥1.24
绘图库 3.5、3.7、3.8 数值库≥2.0、图像库≥9.0
工具库 20.0、21.0、22.0 日志库≥1.0
日志库 0.9、1.0、2.0 没有要求
六行单独看都很讲道理。可它们叠在一起,就成了一张必须同时满足的网。
Demo 用最原始的回溯:从框架开始往下选,撞到不满足就退回上一层换版本。
一共只试了 7 次,就找到一组能同时满足的版本。
注意它挑的是「能满足的最新的」,不是最旧的。
能装、同时又不太老,这两件事它一步都做了。

二、972 种组合,只有 144 种能用

把六个包的所有版本列一遍:972 种组合。 满足全部约束的只有 144 种,占 14.8%——随便挑一组,八成半装不上。

谁的约束 要求 被这一条否掉的组合数
框架 数值库≥2.0 648
绘图库 图像库≥9.0 108
工具库 日志库≥1.0 72
光「框架要数值库≥2.0」这一条,就否掉了 648 种。
它一条不是错,三条叠起来就把选择砍掉了六分之五。
回溯试了 7 次,枚举是 972 种——差 139 倍。
这就是为什么依赖解析不能靠手挑:人算不过来,得让程序退回。

三、不锁版本,半年后装出另一套

假设每个包每月发一版,而且都保证「小版本不破坏兼容」。 同一份「框架≥2.0」的需求,六月装和十二月装:

包 六月装到 十二月装到 变化
框架 2.3 3.0 大版本跨了
数值库 1.24 2.0 大版本跨了
图像库 9.0 10.0 大版本跨了
绘图库 3.7 3.8 小版本
工具库 21.0 22.0 大版本跨了
日志库 1.0 2.0 大版本跨了
六个包里有 5 个跨了大版本。
「小版本不破坏兼容」这句话,在大版本面前不成立。
同一份需求文件、同一台机器,隔半年装出来是两套东西。
「在我机器上能跑」这句话,一半是这么来的。
治法只有一个:把解出来的那组版本连小版本号一起写死,存进文件。
这不是洁癖,是让别人能复现你的结果的最低要求。

四、镜像分层与版本链

环境不光要装对,还要能整包搬走。搬的办法是打包成镜像,分层存。

层 大小 多久变一次
基础系统 900 MB 几乎不变
系统依赖库 420 MB 很少变
Python 依赖 610 MB 偶尔变
项目代码 5 MB 天天变
配置文件 1 KB 天天变
整个镜像 1935 MB。改一行代码,要重新传多少?
做法 每次要传 一天改二十次
分层(按变化频率排) 5.001 MB 100 MB
全塞一层 1935 MB 38700 MB
差 387 倍。分层的规矩只有一条:变得慢的放前面,变得快的放后面。
写反了——先把代码拷进去再装依赖——改一行代码,装依赖那一层也得重来。
这是最常见的镜像写坏法。
最后是版本链。深度学习环境最容易出问题的,是驱动、运行时、框架三者错位:
组合 驱动要的最低版本 结果
470 驱动 + 11.8 运行时 520 驱动太旧,起不来
520 驱动 + 11.8 运行时 520 能用
535 驱动 + 11.8 运行时 520 能用,向下兼容
520 驱动 + 12.2 运行时 525 运行时太新,起不来
规律是:驱动版本必须大于等于运行时要求的最低版本,高可以,低不行。
而框架的编译版本又要求运行时匹配——所以三个箭头是单向的:
驱动 ≥ 运行时 ≥ 框架编译版本。

五、动手 Demo:装得上不等于装得对

五段:依赖求解、枚举全部组合、不锁版本的后果、镜像分层、工具链版本链。 没有一个包是装上去的,全部是自己写的版本表。 这样「为什么退回」这件事能被看清,而不是被包管理器一句报错带走。

PKGS = {u"框架": [u"1.0", u"2.0", u"2.3", u"3.0"],
        u"图像库": [u"8.0", u"9.0", u"10.0"],
        u"数值库": [u"1.24", u"2.0"]}
NEED = {(u"框架", u"数值库"): u"2.0"}   # 包之间「我要至少某个版本」


def solve(i, pick):               # 回溯:逐层往下选,撞墙就退回
    if i == len(ORDER):
        return dict(pick)
    for v in PKGS[ORDER[i]]:
        pick[ORDER[i]] = v
        if not violates(pick):
            r = solve(i + 1, pick)
            if r:
                return r
    pick.pop(ORDER[i], None)
    return None

实跑输出:

--------------------------------------------------------------------
装得上不等于装得对
--------------------------------------------------------------------
==============================================================

[1] 依赖求解:六个包,各自要求不同的版本

  每个包有几个版本,包之间有「我要至少某个版本」的要求:

    包        可用版本                它要求
    框架      1.0、2.0、2.3、3.0      数值库≥2.0、工具库≥20.0
    图像库    8.0、9.0、10.0          数值库≥1.24
    绘图库    3.5、3.7、3.8           数值库≥2.0、图像库≥9.0
    工具库    20.0、21.0、22.0        日志库≥1.0
    日志库    0.9、1.0、2.0           —

  下面用最原始的回溯:从「框架」开始,逐层往下选,撞到约束不满足就退回换个版本。

    包        选中的版本
    框架      3.0
    图像库    10.0
    绘图库    3.8
    数值库    2.0
    工具库    22.0
    日志库    2.0

  一共试了 7 次,找到一组能同时满足的版本。

  **它挑的是「能满足的最新的」版本,试错次数远少于组合总数。**
  **因为回溯会整段退回:某一层没得选,就把上一层换个版本重来。**
  **这就比「把所有组合都列一遍」快得多——第 2 段会量这个差别。**

[2] 把所有组合枚举一遍,看有多少真的能用

  六个包的所有版本组合,一共 972 种。
  其中满足全部约束的只有 144 种,占 14.8%。
  **也就是说,随便挑一组,972 次里有 828 次装不上。**

  最常见的几种「卡在哪」:

    谁的约束        要求            被这一条否掉的组合数
    框架            数值库≥2.0      648
    绘图库          图像库≥9.0      108
    工具库          日志库≥1.0      72

  **回溯那次只试了 7 次,这里是 972 种组合——差 139 倍。**
  **每一条约束单独看都很讲道理,叠在一起就排除了八成半。**
  **这就是为什么依赖解析不能靠手挑——人算不过来,得让程序回溯。**

[3] 不锁版本会怎样:六个月后装出另一套

  假设每个包每月发一版,而且都保证「小版本不破坏兼容」。
  同一份「框架≥2.0」的需求,六月装和十二月装:

    包        六月装到的    十二月装到的    变化
    框架      2.3           3.0             大版本跨了
    数值库    1.24          2.0             大版本跨了
    图像库    9.0           10.0            大版本跨了
    绘图库    3.7           3.8             小版本
    工具库    21.0          22.0            大版本跨了
    日志库    1.0           2.0             大版本跨了

  **六个包里有 5 个跨了大版本。**
  **「小版本不破坏兼容」这句话,在大版本面前不成立。**
  **所以同一份需求文件、同一台机器、隔半年装出来的是两套东西。**
  **「在我机器上能跑」这句话,一半是这么来的。**

[4] 容器镜像:为什么要分层

    层            大小        多久变一次
    基础系统      900 MB      几乎不变
    系统依赖库    420 MB      很少变
    Python 依赖   610 MB      偶尔变
    项目代码      5 MB        天天变
    配置文件      1 KB        天天变

  整个镜像 1935 MB。改一次代码要重新传多少?

    做法                  每次要传      一天改二十次
    分层(按变化频率排)  5.001 MB      100 MB
    全塞一层              1935 MB       38700 MB

  **差 387 倍。**
  **分层的规矩只有一条:变得慢的放前面,变得快的放后面。**
  **写反了——比如先把代码拷进去再装依赖——**
  **改一行代码,装依赖那一层也得重来。这是最常见的镜像写坏法。**

[5] 工具链:三个版本号必须能对上

  深度学习环境最容易出问题的地方,是驱动、运行时、框架三者版本错位。

    组合                      驱动要的最低版本  结果
    470 驱动 + 11.8 运行时    520               驱动太旧,起不来(驱动 470)
    520 驱动 + 11.8 运行时    520               能用(驱动 520)
    535 驱动 + 11.8 运行时    520               能用(向下兼容)(驱动 535)
    520 驱动 + 12.2 运行时    525               运行时太新,起不来(驱动 520)

  **规律是:驱动版本必须大于等于运行时要求的最低版本,高可以,低不行。**
  **而框架的编译版本又要求运行时匹配——所以三者的箭头是单向的:**
  **驱动 ≥ 运行时 ≥ 框架编译版本。**

  五段连起来:**依赖之间是「互相要求版本」的网,一处不合整条退回**;
  **多一个要求就可能无解,所以要把解出来的一组版本锁死**;
  **不锁,半年后装出来的就是另一套**;
  **镜像分层按「变化快慢」排序,能少传几百倍**;
  **而驱动、运行时、框架这三层版本,只能从下往上兼容。**

第 1 段那个「7 次」值得盯住。回溯不是挨个试,它一整段地退回——某一层没得选, 就把上一层换个版本重来。 第 2 段把 972 和 7 摆在一起,是这一节最有说服力的地方。 人手工挑版本,挑的是那 14.8% 里的一个;程序挑的是「第一个能过的」。 第 3 段那张表里唯一没跨大版本的是绘图库。 这不是巧合——包越活跃,大版本蹦得越快,跨版本的概率越高。 第 4 段 387 倍这个数,是「按层存」和「一坨存」的差别。 注意最后一层只有 1 KB:配置改得最勤,却几乎不占空间,所以它放最后。

30 秒实验

  1. 把第 1 段的「工具库≥20.0」这条要求删掉,重跑。 能用的组合会明显变多——约束少一条,可用空间就大一圈,这也解释了为什么老项目反而好装。
  2. 把第 3 段的「六个月」改成「二十四个月」,重跑。 所有包都会跨大版本——时间越长,「同一份需求」这个词越没有意义。
  3. 把第 4 段的分层顺序倒过来(代码先放、依赖后装),重跑。 每次要传的量会跳到接近整个镜像——缓存失效的代价,就是每次从零开始。

常见误区

  1. 以为「装不上是网络问题」。八成是版本冲突。 把报错里的「requires」逐条念一遍,冲突点自己会浮出来。
  2. 以为「锁版本会导致装不到新东西」。锁的是「这一套能跑的版本」, 升级是另一件事:升完跑测试,过了再锁新的一组。
  3. 以为「容器解决了环境问题」。容器只保证「原样搬过去」, 它不保证「装对了」。第一层写错,整个镜像一起错。
  4. 以为「驱动越新越好」。能用就行。 新驱动配旧运行时是安全的,旧驱动配新运行时直接起不来——箭头只有一个方向。
自测三问(点开对答案) 1. 为什么依赖求解要用回溯,不能一次挑定? —— 因为**每一条约束都可能否决前面的选择**。 **Demo 里 972 种组合只有 144 种能用,一次挑定撞墙的概率是八成半。** **回溯的价值在于「整段退回」:某一层没得选,就换上一层。** 2. 不锁版本会怎样? —— **同一份需求文件,隔半年装出来是另一套。** **Demo 第 3 段:六个包里有 5 个跨了大版本。** **结果就是「在我机器上能跑」——这句话说的是环境,不是代码。** 3. 镜像分层为什么能省几百倍? —— 因为**只重传变的那几层**。**Demo 第 4 段:分层每次传 5.001 MB,全塞一层要传 1935 MB。** **前提是顺序对:变得慢的放前面,变得快的放后面。**

算力、硬件、软件三样都齐了,最后一节把它们放进同一个池子里分。 四十个作业抢八张卡——这一节讲排队,也讲谁会饿死。

相关笔记