这一步看着只是「下载加加载」,实际要做四件事:拿代码、拿权重、校验、映射进内存。
四件事里最容易出事的是第三件——下了一半的权重,加载时报的错跟尺寸不匹配一模一样。
这一节把映射与分片真量一遍,再说清 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 秒实验
- 把文件从 64 MiB 改成 256 MiB,再量一次全读与映射。 看两边的耗时是不是按比例长——如果映射那一列基本不变,你就理解了「打开」与「读」是分开的。
- 在
peek()里把m[:4096]改成m[:],再量一次。 这时候它会退化成全读,耗时应该接近全读那一行。 - 把八片改成十六片,看打开耗时涨到多少。想一想:分片数有没有上限? (提示:每个分片都要占一个文件句柄)
常见误区
- 用浏览器下载权重。 它不支持断点续传,也不会校验,下到一半断了你会得到一个大半是垃圾的文件, 而加载时报的错会指向尺寸不对。
- 以为分片多会拖慢加载。实测八个分片多花不到一毫秒。 分片反而是好事——能并行下、坏了只重下一个。
- 把权重放在用户主目录。HF 的缓存默认就在那儿,很多人 C 盘满了才发现。 要用环境变量把缓存目录指到别的盘。
自测三问(点开对答案)
1. 映射加载之后,物理内存到底占了没有? —— 没有。映射只划了地址范围,物理内存是空的,碰到哪一页才读哪一页。 所以「打开」很快,但第一次前向传播会慢——那时候才真读盘。 2. 为什么说「权重没下完就加载」是最坏的情况? —— 因为映射会成功,加载会走到一半,然后在访问被截断的那一页时触发总线错误,Python 层捕获不到, 进程直接退出,连堆栈都没有。看起来像随机的崩溃。 3. `device_map='auto'` 为什么会让模型「能跑但很慢」? —— 它把放不进显存的层留在内存里,用到时再搬过去。 搬一次就是一次 PCIe 传输,带宽比显存慢一个数量级。 所以能跑,但每生成一个 token 都在搬,速度掉得很厉害。东西搬进来了,下一节给它开个门——让别的程序能通过接口来用它。
- ☐ (本篇完,下一篇 2.3 模型部署)
相关笔记
- 24 课 计算机是怎样跑起来的:程序从磁盘到内存
- 29 课 Linux 是怎样工作的:页缓存与缺页中断
- 🦙 Llama 大模型实践 · 索引