🦙 第2章 部署 Llama 2 大模型
📄 一句话概括

这一步看着只是「下载加加载」,实际要做四件事:拿代码、拿权重、校验、映射进内存。 四件事里最容易出事的是第三件——下了一半的权重,加载时报的错跟尺寸不匹配一模一样。 这一节把映射与分片真量一遍,再说清 from_pretrained 那一行背后藏了多少事。

📝 本章和前面几章的关系

上一节算完了显存,这一节把东西搬进来。 程序怎么从磁盘进内存, 24 课 计算机是怎样跑起来的 里讲过。 这一节讲的是一个十几 GB 的文件怎么进内存——同一个事,规模差三个数量级。

一、拿代码

两条路。 一条是直接用小仓库:Meta 官方的 llama 仓库是几份 Python 文件加一个推理脚本,依赖少, 看得清每一步在干什么,适合学。 另一条是用 transformers:一行 from_pretrained 全包了,适合用。 它的代价是你不知道中间发生了什么——出了问题只能猜。 还有一种是用 llama.cpp 这类纯 C++ 实现,不依赖 PyTorch,能在 CPU 上跑量化模型。 显存不够但内存够的机器,这条路常常是唯一能走通的。

选哪条,看你要干什么。要理解原理,先用小仓库跑通一遍;要接业务,直接上 transformers 或者推理框架。

二、拿权重:许可、分片、校验

Llama 2 的权重要先在 Meta 那边申请,填表、同意许可,通过之后才会给你下载链接。 这一步不是形式——它的许可允许商用,但月活超过七亿要另外谈。 下载下来的不是一个文件,是一堆分片。7B 是七个左右,每个几 GB。 分片的好处是能并行下、坏了只重下一个,坏处是少一个都拼不起来。 下完必须校验。官方会给每个文件的哈希,或者给一个总的校验清单。 跳过这一步,后面加载报的错会指向「尺寸不对」,而真正的原因是文件截断了。

还有个坑:下到一半断了,工具生成了半个文件,下次它会以为下完了。 所以要用支持断点续传并会校验的工具,别用手工另存。

三、加载:为什么用映射而不是全读

加载器几乎都用内存映射,不是把文件全读进内存。这不是为了快,是为了不占。 映射之后,地址空间里有了这十几 GB,但物理内存还是空的。碰到哪一页,才把那一页读进来。 这一下的差别能真量出来:64 MiB 的文件,全读 28.0 毫秒,映射打开 0.1 毫秒,差两百多倍。 映射还有两个好处:多个进程可以共用同一份页缓存(起两个服务不用两份内存); 内存不够时系统能把没碰的部分扔出去,下次再读。

它有一个真坑: 文件被截短之后,访问越界的那一页会触发总线错误,Python 层捕获不到,进程直接没了。 所以下载与加载必须错开。 分片全部打开只多花不到一毫秒,所以「分片拖慢加载」这个担心可以放下。 真正要小心的是拼接顺序——按文件名里的序号拼,别靠文件系统的返回顺序, 那个顺序在两个操作系统上不一样。

四、真机上的那一行代码

from_pretrained 这一行背后做的是:查本地缓存目录、比对清单、缺哪个下哪个、校验哈希、映射文件、 按 device_map 把层分到不同的卡上、最后反量化成运行精度。 device_map='auto' 尤其值得说。 它会把模型按层切开,能放显存的放显存,放不下的留在内存里,用到再搬。 这解释了为什么有些配置「能跑但很慢」——它在不停地搬。 还有一个参数是 load_in_4bit,配合 bitsandbytes 把权重反量化到 4 位。 它能让 7B 挤进 6 GB 显存,代价是推理变慢、精度掉一点。

五、动手 Demo:造一个文件,全读与映射各量一次

真权重下不动,就造一个 64 MiB 的 float32 文件,把它当权重。 比较三种读法:全读进内存、整体映射、映射后只碰开头 16 KiB。 再把文件切成八片,量一下分片打开的代价。

n = 64 * 1024 * 1024 // 4              # float32 的个数
arr = np.arange(n, dtype=np.float32)
arr.tofile(path)                       # 造一个 64 MiB 的‘权重’

# 读法一:全读进内存
t_full = clock(lambda: np.fromfile(path, dtype=np.float32))

# 读法二:整体映射,打开就走
t_map = clock(lambda: np.memmap(path, dtype=np.float32, mode=u'r'))

# 读法三:映射之后只碰开头 16 KiB
def peek():
    m = np.memmap(path, dtype=np.float32, mode=u'r')
    return m[:4096].sum()

# 切成八片再打开,看代价
ms = [np.memmap(x, dtype=np.float32, mode=u'r') for x in shards]

实跑输出:

--------------------------------------------------------------------
全读进内存还是按需映射:真造一个文件量一遍
--------------------------------------------------------------------


  ① 造一个 64 MiB 的权重文件
  --------------------------------------------------------------------
  文件 model.bin,16777216 个 float32,64 MiB。
  ! 真模型的权重是若干个这样的大文件,加起来十几 GB。这里缩小一千倍,好在一秒内跑完。

  ② 两种读法,各量一次
  --------------------------------------------------------------------
| 读法 | 耗时 ms | 立刻占内存 MiB | 什么时候真正读盘 |
|---|---:|---:|---|
| 全读进内存 | 30.0 | 64 | 要用才能开始 |
| 整体映射 | 0.1 | 几乎为 0 | 打开就能用 |
| 映射后只碰头 16 KiB | 0.1 | 几乎为 0 | 碰到哪读到哪 |

  全读 64 MiB 要 30.0 毫秒,映射打开只要 0.1 毫秒——差 272 倍。
  ! 这就是「能打开」与「能工作」的区别:映射之后地址空间里有了,但物理内存还是空的,
  碰到哪一页才读哪一页。

  ③ 为什么加载器都用映射
  --------------------------------------------------------------------
| 好处 | 具体 |
|---|---|
| 启动快 | 不用等十几 GB 读完,毫秒级打开 |
| 省内存 | 多个进程可以共用同一份页缓存 |
| 可换出 | 内存不够时,系统能把没碰的部分扔出去 |
| 怕截断 | 文件被截短,碰到那一页会直接崩 |

  最后一条是真的坑:映射之后文件长度变了,访问越界的那一页会触发总线错误,Python 层捕获不到,
  进程直接没了。
  ! 所以下载和加载必须错开:权重没下完就加载,等于给这个坑递刀。

  ④ 分片文件怎么加载
  --------------------------------------------------------------------
| 怎么切 | 文件数 | 打开耗时 ms |
|---|---|---:|
| 一个 64 MiB 文件 | 1 | 0.1 |
| 八个 8 MiB 分片 | 8 | 1.0 |

  分片打开多花不到一毫秒,跟全读那 28 毫秒比可以忽略。
  但分片有个真好处:下载可以并行,一个分片坏了只重下那一个。所以官方发布的权重几乎都是分片的。
  ! 加载顺序有讲究:按名字里的序号拼回去,
  不能靠文件系统的返回顺序——那个顺序在 Windows 和 Linux 上不一样。

  ⑤ 真机上长什么样
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | from transformers import AutoModelForCausalLM
  | 
  | model = AutoModelForCausalLM.from_pretrained(
  |     'meta-llama/Llama-2-7b-chat-hf',
  |     torch_dtype='float16',
  |     device_map='auto',
  | )
  这句在真机上下载并加载七个分片;本沙箱没有 torch,也没有权重,跑不了。

  device_map='auto' 就是把不同层放到不同卡上,它内部做的正是映射与搬运。
  ! 上面那段代码读起来像一行,实际发生的事是:查缓存目录、缺哪个下哪个、校验、映射、按层搬到显存。
  所有麻烦都被这一行藏起来了。

先看第二张表:全读 28.0 毫秒,映射 0.1 毫秒,差两百多倍。 但这个倍数不是重点,重点在第三列——全读立刻占 64 MiB,映射立刻占几乎为零。 换成十几 GB 的权重,这个差别就从「快慢」变成「能不能」。 「什么时候真正读盘」这一列是理解映射的关键:打开的时候只是把地址范围划好了,碰到哪一页才读哪一页。 所以映射之后立刻「能打开」,但要等第一次前向传播才真的读。 这就回到了那句老话:「能打开」不等于「能工作」。权重没下完就加载,映射这一步会成功, 跑到后面某一页才崩,而那个崩溃 Python 抓不到。 第三张表列了映射的四个好处,最后一条是坏处:文件被截短,碰到那一页直接崩。 所以校验不是可选项——先校验,再映射。 分片那张表:一个文件打开 0.1 毫秒,八个分片 1.0 毫秒,多花不到一毫秒。 跟全读那 28 毫秒比可以忽略,所以别担心分片拖慢加载。 最后那段 from_pretrained 是本沙箱跑不了的——没有 torch 也没有权重。 但它做的事就上面列的七步,每一步都能单独验:缓存目录在不在、清单对不对、哈希过没过、映射成没成。 出问题时按这七步逐段排查,比看报错快。

30 秒实验

  1. 把文件从 64 MiB 改成 256 MiB,再量一次全读与映射。 看两边的耗时是不是按比例长——如果映射那一列基本不变,你就理解了「打开」与「读」是分开的。
  2. 在 peek() 里把 m[:4096] 改成 m[:],再量一次。 这时候它会退化成全读,耗时应该接近全读那一行。
  3. 把八片改成十六片,看打开耗时涨到多少。想一想:分片数有没有上限? (提示:每个分片都要占一个文件句柄)

常见误区

  1. 用浏览器下载权重。 它不支持断点续传,也不会校验,下到一半断了你会得到一个大半是垃圾的文件, 而加载时报的错会指向尺寸不对。
  2. 以为分片多会拖慢加载。实测八个分片多花不到一毫秒。 分片反而是好事——能并行下、坏了只重下一个。
  3. 把权重放在用户主目录。HF 的缓存默认就在那儿,很多人 C 盘满了才发现。 要用环境变量把缓存目录指到别的盘。
自测三问(点开对答案) 1. 映射加载之后,物理内存到底占了没有? —— 没有。映射只划了地址范围,物理内存是空的,碰到哪一页才读哪一页。 所以「打开」很快,但第一次前向传播会慢——那时候才真读盘。 2. 为什么说「权重没下完就加载」是最坏的情况? —— 因为映射会成功,加载会走到一半,然后在访问被截断的那一页时触发总线错误,Python 层捕获不到, 进程直接退出,连堆栈都没有。看起来像随机的崩溃。 3. `device_map='auto'` 为什么会让模型「能跑但很慢」? —— 它把放不进显存的层留在内存里,用到时再搬过去。 搬一次就是一次 PCIe 传输,带宽比显存慢一个数量级。 所以能跑,但每生成一个 token 都在搬,速度掉得很厉害。

东西搬进来了,下一节给它开个门——让别的程序能通过接口来用它。

相关笔记