想象你搬家。训练好的模型就像一个塞满整个房间的家当:几十万个参数张量、优化器的状态、训练轮数,全堆在一起。要把它们搬到新家,市面上有三种打包方案。第一种是神秘纸箱,随手把所有东西塞进去——这就是 torch.save,什么都能装,但开箱要依靠一个叫 pickle 的“自动拆箱器”,它会把箱子里每一件东西原样激活,包括可能藏在里面的整蛊蛇;到了新家,还必须请同一个搬家师傅(相同的 Python 环境)才能打开。第二种是透明收纳箱,箱盖上贴一张清单,写清楚每件物品的尺寸和位置——这就是 SafeTensors,想看哪件物品直接伸手拿,不用把全屋东西倒出来找。第三种是分房间分拣发货——Megatron 把权重按 GPU 切成好几箱,几辆小车同时搬,到了新家再照图纸拼装。选择哪种箱子、怎么把旧箱子换成新箱子,就是本文要讲清的 Checkpoint 格式与兼容性问题。
打开、转换、合并各种 checkpoint,是 AI 工程师的日常。搞懂三种格式的来龙去脉,你既能为团队避开从天而降的安全事故,也能在跨框架迁移时少加三天班。
可视化演示
展示连续信号的采样过程及重建效果,通过步进控制观察不同采样率对信号恢复的影响,演示奈奎斯特定理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
原始连续信号 蓝色曲线为原始模拟信号,由多个频率分量叠加而成 f(t) = sin(2πft) 点击下一步开始采样过程
torch.save 机制
搬家那天,打包师傅老王号称“什么都能装”:随手一个纸箱、一卷胶带,把衣服、碗筷、路由器一股脑塞进去,箱子上一个字都不写。这就是 torch.save 的个性——它把整个 Python 世界都当作可打包对象,不仅是模型的张量,连训练轮数、学习率,甚至你自己定义的小工具类,统统塞进同一个箱子。打包时确实爽,开箱时就有点慌。
技术上说,torch.save 的底层是 Python 的 pickle 序列化协议。pickle 把内存里的 Python 对象图(对象之间如何互相引用)翻译成一串字节,torch.load 再把字节还原成对象。听起来完美,但有一个致命点:pickle 在“还原”时会执行字节流里记录的任意函数调用。这就像那只纸箱自带一个“自动拆箱器”,它会激活箱子里的每一项内容,包括藏着的整蛊蛇。
正因如此,加载不可信的 checkpoint 等于运行别人的程序。PyTorch 2.0 起提供 torch.load(path, weights_only=True),只允许还原张量、数字、字符串这类“标准零件”,拒绝还原自定义类,把风险挡在门外。另一个隐患是环境依赖:pickle 记录的是“类所在的路径”,比如 my_module.MyModel。你换台机器、改了类名、升级大版本,开箱时就报错——因为只有旧家的搬家师傅才认识这只箱子。
所以 torch.save 是“纸箱协议”:方便、万能,但黑箱、挑师傅。知道自己打包了什么、交给谁拆,是使用它的底线。
- 累加求和把模型中所有张量逐一相加,得到整个 checkpoint 的总字节数。
- 参数个数$p_i$ 表示第 $i$ 个张量的元素个数,比如 4096×4096 的矩阵,$p=16{,}777{,}216$。
- 单元素字节$s(t_i)$ 由 dtype 决定,fp32 占 4 字节、fp16 和 bf16 占 2 字节、int8 占 1 字节。
SafeTensors 特性
隔壁搬家公司用的是透明收纳箱,每个箱子盖上贴一张清单:第一格是“fc1.weight“,第二格是”fc1.bias”,表格里写明每件物品的尺寸和位置。这就是 SafeTensors 的发明哲学——把元数据与数据分开,先让所有人看清箱子里有什么,再决定要不要取。
SafeTensors 用一段 JSON 做清单头部,正文是排列整齐的权重原始字节,没有任何 Python 对象引用。读取时,加载器借助操作系统“内存映射”机制把文件映射进虚拟内存,再根据清单里的偏移量直接算出目标张量的地址。你只取需要的权重,系统按页懒加载,不需要把整个文件解出来——这就是“零拷贝”。冷启动一个大模型,从“把几百 GB 全部反序列化”变成“按需读几页”,加载耗时可以相差数倍到十几倍。
安全性上,因为文件里没有函数调用记录,解析器只会读取字节、构造张量视图,恶意代码没有执行入口。它只支持张量这类标准零件,装不了任意 Python 对象——这既是限制,也是安全感来源。
透明箱的代价是只能装“标准零件”,任意 Python 自定义对象需要先转成张量才能存储。但换来的是透明、安全和快,这也是它成为 Hugging Face 社区默认格式的原因。
- 映射基址$\mathrm{base}$ 是文件被内存映射后的起始地址,加载时操作系统按页懒加载、不真读盘。
- 清单长度$\mathrm{hdr\_len}$ 由文件前 8 个字节记录,告诉读取者 JSON 说明文档有多长。
- 张量偏移$o_k$ 是在 JSON 清单里查到的第 $k$ 个张量的数据偏移量,由它直接定位数据。
- 零拷贝取数拿到地址后直接构造张量视图,全程不复制数据、不执行代码。
Megatron 切分
如果家里有 8 个房间要同时搬,全塞进一个大纸箱显然很笨。Megatron 的做法是“分房间分拣”:按 GPU 编号,每个房间(rank)一箱,箱子外面写明“本箱装的是哪个矩阵的哪一段”。搬家公司派 8 辆小车同时拉,效率翻倍,但到了新家必须照图纸把各部分拼回去。
张量并行把一个大矩阵切成 $TP$ 片分给 $TP$ 张卡:列并行按列方向切,行并行按行方向切,每张卡只保存它负责的切片,训练时只做自己那部分矩阵乘法。流水线并行则按层切分——就像 GPT 的前几层住“主卧”,中间层住“次卧”。两种切分叠加后,模型不是一整块,而是分布在多卡上的拼图。
于是 Megatron 的 checkpoint 不是一个文件,而是一组 rank-*.pt 分片,每个分片只含该 rank 的那段权重。要恢复完整模型,必须先把分片按切分方向拼接回去,还要处理融合布局问题,例如多头注意力里 Q、K、V 三个矩阵被拼成一个整体保存。这就是为什么 Megatron 的产物不能拿到单卡直接 torch.load。
分箱搬运的好处是并行和低单机内存,代价是“图纸”复杂:谁切的、怎么切的、箱子顺序如何,都必须靠专门工具还原。想拿回家直接用?不拼图可不行。
- 全局权重$W_{\mathrm{global}}$ 是原始大矩阵,形状为 $N \times D$,$D$ 是输出维度。
- 并行划分$TP$ 是张量并行数,输出维度 $D$ 被等分成 $TP$ 段,每段 $D/TP$ 列。
- 设备序号$r$ 是当前 GPU 的编号,从 0 到 $TP-1$,决定它拿第几段。
- 切片取列列区间 $[rD/TP, (r+1)D/TP]$ 就是第 $r$ 张卡负责的权重块。
兼容问题剖析
搬家最头疼的不是箱子沉,而是新家不认旧标签。你在纸箱上写“老床单”,新家管家只认“卧室-床品-编号001”。torch.save 里记录的类路径、state_dict 的 key 命名,换一个框架就是另一套标签体系,这就是兼容性问题的根源。
兼容问题主要有三类。第一类是跨版本:PyTorch 升级后某些序列化结构变化、类路径移动,旧权重加载失败,就像旧家钥匙插不进新锁。第二类是跨框架命名:同一个模型在不同框架里的 key 完全不同,比如 transformers 里一个注意力层叫 encoder.layer.0.attention.query.weight,而 Megatron 侧可能是一个融合好的 query_key_value.weight。第三类是结构布局差异:同样是 QKV,一个框架拆成三个矩阵,另一个框架合并成一个矩阵,形状对不上,直接加载必然报错。
因此跨框架加载前,先做 key 映射:写一个 $f(\mathrm{old})=\mathrm{new}$ 的翻译函数逐键改名,再做形状和 dtype 校验。映射就是“新旧标签对照表”,是迁移时唯一可靠的翻译官。
- 映射键集$\hat K = \{f(k) \mid k \in K\}$,把旧 key 逐一带入翻译函数得到新 key 集合。
- 键完全对齐映射后的集合必须与新框架要求的键集合 $K_t$ 一模一样,多一个少一个都不行。
- 逐键校验对每个键,检查新旧权重的形状是否一致,任一不一致整个加载失败。
- 逻辑与所有条件同时成立才算成功,这就是加载器底层校验的逻辑。
格式转换工程
既然三种箱子各有脾气,搬家公司提供“重新装箱”服务:把旧纸箱里的东西全部取出、清点、贴上透明标签、再装进新箱子。模型世界里,这叫格式转换管线——把 torch.save 的 pickle 流转换成 SafeTensors,或者把 Megatron 分片归并成完整权重。
转换管线本质是三步:load(读旧格式)、mapping(重命名 key)、save(写新格式)。难点在内存:朴素做法是先把整个模型从旧文件读进内存,全部改名后再写成新文件,内存里同时存在旧、新两份数据,峰值内存约等于两倍模型大小。对大模型来说,几百 GB 的显存/内存根本扛不住。
优化做法是逐张量流式处理:读一个张量、改写一个张量、写一个张量,峰值内存降到单个最大张量的大小。Hugging Face 的转换脚本、transformers-cli 等工具正是这么设计的。自己写转换脚本时,还要留意 dtype 与 8 字节对齐问题。重新装箱最忌贴错标签——转换后必须做全套校验:key 集合相等、所有 shape 相等、权重数值抽样一致,才算真正完工。
- 朴素方案先全部读入再全部写出,新旧两份同时在内存,所以乘 2。
- 流式方案一次只处理一个张量,峰值内存退化为最大的单个张量。
- 字节因子$d$ 是每个元素占的字节数,由 dtype 决定,转 fp16 能直接减半。
- 权衡取舍流式牺牲一点 IO 随机性,换来内存可承受,大模型转换的必要条件。
选型决策策略
搬家前,正常人会算笔账:请几个工人、买多少箱子、哪些东西怕摔。Checkpoint 选型也是同样的道理——没有最好的箱子,只有最适合这趟搬家的箱子。想清楚三件事:安全底线、加载速度、生态兼容。
决策可以用一个加权打分来量化:给安全、速度、兼容性各打一个 0 到 1 的分,再按场景配权重。加载第三方模型时,安全权重拉到最高,选 SafeTensors;自有训练链路全面依赖 Megatron 分布式训练,兼容性权重最高,继续用 Megatron 分片;做推理服务冷启动,速度优先,safetensors 的 mmap 优势明显。公式是简单的加权和,但权重系数来自真实业务场景。
比个人偏好更重要的是团队规范:把“对外发布一律存 safetensors、训练产物转出前先归并校验、转换脚本进 CI”写进工程约定,能省掉无数个“为什么加载失败”的深夜排查。选型不是一次性的,而是持续沉淀的团队经验。
回到搬家比喻:纸箱、透明箱、分拣箱各有主场。把安全、速度、兼容三件事想清楚,你就不再是被格式坑的新手,而是指挥搬家全局的老司机。
- 三档打分$\mathrm{Safety}$、$\mathrm{Speed}$、$\mathrm{Compat}$ 分别为安全、速度、兼容性打分,取值 0 到 1。
- 场景权重$w_1, w_2, w_3$ 由团队和场景决定,例如医疗合规场景 $w_1$ 极大。
- 加权求和总分越高越适合当前场景,选型就变成了可解释的计算。
AI 应用场景
开源模型的安全分发
阶段:模型发布与存储
解决的问题:传统方式下,Hugging Face 上的 checkpoint 多用 torch.save 保存为 .bin,用户下载后 torch.load 存在执行恶意代码的风险——曾出现过下载量极高的“投毒”模型事件。改用 SafeTensors 后,加载器只读 JSON 清单和原始字节,没有代码执行入口,从根上化解投毒风险。
具体案例:某开源大模型发布时同时提供 pytorch_model.bin 和 model.safetensors,后者成为官方推荐下载项;平台后台用转换管线把训练产物统一重装成透明箱,并校验 key 集合与形状后上线。
Megatron 训练到推理服务的归并
阶段:模型训练与部署衔接
解决的问题:8 卡张量并行训练完,产出 8 个 rank-*.pt 分片,推理框架需要完整权重和统一的 transformers 命名。人工拼接既慢又易错,且分片里还藏着 QKV 融合布局差异。用转换脚本按切分方向归并、映射 key、落盘 safetensors,再抽样校验数值,就能自动化衔接训练与推理。
具体案例:某团队训练 70B 模型后,通过“读取全部 rank 分片 → 按维度拼接 → 映射成 HF 命名 → 校验 shape → 落 safetensors”的管线,四小时内完成从 Megatron 产物到 vLLM 可直接加载权重的转换,避免了两天的返工。
跨框架迁移的标签翻译
阶段:数据准备与模型迁移
解决的问题:从 PyTorch 迁移到国产推理框架时,权重 key 命名、dtype、布局都不同,逐层手改极易出错。借助 key 映射表与格式转换工具,先把旧权重统一转成 safetensors 中间格式,再利用 mmap 边读边映射,实现低成本迁移。
具体案例:某算法团队将内部微调好的 BERT 类 checkpoint 迁移到自研推理引擎,映射了 199 个 key,统一转为 fp16 的 safetensors,迁移后首轮前向结果与原框架完全一致。
Python 演示
# 演示三种 Checkpoint 格式与转换流程# 运行前: pip install torch numpyimport jsonimport structfrom collections import OrderedDict import torchimport torch.nn as nn class TinyModel(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(4, 8) self.fc2 = nn.Linear(8, 2) def forward(self, x): return self.fc2(torch.relu(self.fc1(x))) def save_as_safetensors(state_dict, path): # 手写最小版 SafeTensors: JSON 清单 + 纯字节数据区 entries = {} offset = 0 for name, tensor in state_dict.items(): t = tensor.contiguous() nbytes = t.numel() * t.element_size() entries[name] = { "dtype": str(t.dtype).split(".")[-1], "shape": list(t.shape), "data_offsets": [offset, offset + nbytes], } offset += nbytes meta = json.dumps(entries).encode("utf-8") meta += b" " * ((8 - len(meta) % 8) % 8) with open(path, "wb") as f: f.write(struct.pack("<Q", len(meta))) f.write(meta) for name, tensor in state_dict.items(): f.write(tensor.contiguous().numpy().tobytes()) def main(): model = TinyModel() # 1) torch.save: 神秘纸箱, 一切靠 pickle torch.save({"model": model.state_dict(), "acc": 0.91}, "demo.pt") loaded = torch.load("demo.pt", weights_only=True) print("torch.load 恢复 acc:", loaded["acc"]) # 2) 跨框架 key 映射: 旧家钥匙换新家锁 raw = model.state_dict() renames = { "fc1.weight": "encoder.layer.0.attention.query.weight", "fc1.bias": "encoder.layer.0.attention.query.bias", "fc2.weight": "classifier.weight", "fc2.bias": "classifier.bias", } renamed = OrderedDict((renames[k], raw[k].clone()) for k in raw) # 3) 模拟 Megatron 列并行: TP=2 切分再归并 W = model.fc2.weight.data shard0, shard1 = W[:, :4], W[:, 4:] merged = torch.cat([shard0, shard1], dim=1) assert torch.equal(merged, W) print("TP 分片归并校验通过:", list(merged.shape)) # 4) 存成 SafeTensors: 透明箱 + 清单 save_as_safetensors(renamed, "demo.safetensors") with open("demo.safetensors", "rb") as f: hlen = struct.unpack("<Q", f.read(8))[0] header = json.loads(f.read(hlen)) print("safetensors keys:", list(header.keys())) # 5) 转写校验: 清单与源完全一致 assert set(header) == set(renamed) print("格式转换与校验通过") if __name__ == "__main__": main()
torch.save({"model": model.state_dict(), "acc": 0.91}, "demo.pt")把模型权重和指标随手塞进纸箱,底层是 pickle。loaded = torch.load("demo.pt", weights_only=True)只拆“标准零件”,拒绝自定义类,挡住整蛊蛇。renamed = OrderedDict((renames[k], raw[k].clone()) for k in raw)逐 key 更换新家标签,保留克隆避免引用旧张量。shard0, shard1 = W[:, :4], W[:, 4:]模拟 TP=2 的列并行切分,每个分片只有一半列。merged = torch.cat([shard0, shard1], dim=1)沿列方向把分片拼回完整矩阵,Megatron 归并的核心。meta = json.dumps(entries).encode("utf-8")箱盖上的物品清单,记录每个张量的 dtype、shape 与偏移。f.write(struct.pack("<Q", len(meta)))文件头用 8 字节记录清单长度,读取方先读它再跳转到清单。for name, tensor in state_dict.items():逐张量写入纯字节,不做任何压缩,透明且零冗余。
常见误区
误区一直用 torch.save 存 checkpoint,自己存自己取,觉得 pickle 风险与自己无关;从网上下载的 .pt 文件也敢直接 torch.load。
正解pickle 的反序列化等于让字节流里的代码在你机器上执行。自己保存的文件也可能被替换、被污染;下载的第三方模型更可能是精心仿冒的整蛊箱。对策有三:只信来源、加载时用 weights_only=True、对外分发优先转 safetensors。
为何常见:初学者大多只在自己电脑上存取,没有把“别人的文件”当成“可执行程序”来警惕。
误区.safetensors 秒开,于是认为它压缩了权重,文件更小、更省磁盘。
正解两种格式存的都是原始字节,都不压缩;safetensors 只多一个 JSON 清单头,体积几乎一样。快是因为 mmap 零拷贝按需读取,不是文件变小。torch.save 的 pickle 流反而包含大量 Python 对象描述开销,文件常常更臃肿。
为何常见:人们把“加载快”和“体积小”直觉捆绑,被加载速度误导。
误区把 demo.pt 直接改名为 demo.safetensors,觉得扩展名对了就能被新工具加载。
正解后缀只是名字,内部字节结构完全不同。safetensors 解析器读 JSON 清单、按偏移取数据;pickle 流是 Python 对象图。改名不改字节,解析必然失败。真正的转换必须走“读出旧格式 → 重写新格式”的流程。
为何常见:日常改后缀(.txt 改成 .md)的经验给人惯性,误以为格式由扩展名决定。
误区训练完把 rank-*.pt 文件拷回本地,torch.load 一把梭,认为权重本来就是完整的一块。
正解Megatron 每个 rank 只保存自己负责的切片:列并行按列切、行并行按行切、流水线按层切。不按图纸归并,拿不到完整 state_dict;同时还要处理 QKV 融合布局。必须用官方或社区转换脚本先合并,再转为目标框架格式。
为何常见:分布式训练对多数人是个黑盒,以为模型始终是一整块,看不见背后的分拣过程。
面试题
Q1. torch.save 默认序列化底层依赖哪种协议?
- A.JSON 文本序列化
- B.pickle 序列化协议← 正确答案
- C.YAML 配置序列化
- D.SQL 数据库存储
答案:B。torch.save 底层是 Python 的 pickle 协议,它把对象图翻译成字节流。就像搬家纸箱能塞进任何东西,pickle 也能打包任意 Python 对象,因此它才存在执行代码的安全隐患。
Q2. 从网上下载的 .pt 文件直接 torch.load,最大的风险是什么?
- A.磁盘空间耗尽
- B.执行任意代码← 正确答案
- C.模型精度下降
- D.显卡温度升高
答案:B。pickle 反序列化时会执行字节流里的函数调用,相当于运行对方的程序。这就像打开陌生人寄来的纸箱,里面可能藏着整蛊蛇。磁盘、精度、温度都是次要问题,代码执行才是致命风险。
Q3. 把 demo.pt 直接改名为 demo.safetensors,会发生什么?
- A.加载速度变快
- B.文件内容仍旧← 正确答案
- C.自动完成压缩
- D.数据丢失一半
答案:B。修改后缀只改了名字,内部字节还是 pickle 流。safetensors 解析器按 JSON 清单取数据,对 pickle 流完全无效。格式是字节结构,不是扩展名。转换必须走“读出 → 重写”的流程。
Q4. SafeTensors 实现零拷贝加载主要依赖哪种系统机制?
- A.虚拟内存映射← 正确答案
- B.磁盘压缩缓存
- C.网络协议加速
- D.显存自动回收
答案:A。safetensors 借助操作系统 mmap 内存映射,把文件映射进虚拟内存,再按偏移直接构造张量视图,不复制整个文件。这就像透明箱看清单伸手取物,不用把全屋东西倒出来。压缩、网络、显存回收都跟零拷贝无关。
Q5. 权重 key 映射主要解决跨框架迁移时的什么问题?
- A.参数命名差异← 正确答案
- B.算法结构缺失
- C.显卡算力不足
- D.数据标注混乱
答案:A。不同框架对同一个注意力层命名不同,一个叫 fc1.weight,另一个叫 attention.query.weight。key 映射就是新旧标签对照表,把旧家钥匙翻译成新家锁型号,解决的是命名差异问题。
Q6. 哪些场景推荐优先使用 SafeTensors 格式?
- A.加载陌生作者模型← 正确答案
- B.频繁冷启动加载← 正确答案
- C.保存任意对象
- D.分布式并发读取← 正确答案
答案:ABD。陌生模型用 safetensors 可避免 pickle 投毒;频繁冷启动受益于 mmap 快速加载;分布式并发读多个分片也方便。选 C 是错的——safetensors 只序列化张量,装不了任意 Python 对象,那是 pickle 的专属能力。
Q7. torch.load(……, weights_only=True) 无法恢复下列哪种对象?
- A.普通浮点数字
- B.张量对象副本
- C.自定义模型类← 正确答案
- D.嵌套字典结构
答案:C。weights_only=True 只允许还原张量、数字、字符串、字典等“标准零件”,拒绝实例化自定义类,从而阻断恶意代码。自定义模型类是旧家师傅才认识的对象,安全模式下开不了箱,也正是这个限制保护了你。
Q8. 将 Megatron 分片 checkpoint 归并为完整权重,需要哪些操作?
- A.按切分方向拼接← 正确答案
- B.汇总各分片权重← 正确答案
- C.修改文件拓展名
- D.校验合并后形状← 正确答案
答案:ABD。列并行沿列拼、行并行沿行拼、流水线按层顺序连接,这就是“按图纸拼装”;随后要校验合并后的形状与期望一致。只改后缀不改变字节内容,分片依然是分片,所以 C 错误。
Q9. 关于 torch.save 与 safetensors 的区别,下列说法正确的是?
- A.pickle 会执行任意代码← 正确答案
- B.safetensors 可内存映射← 正确答案
- C.safetensors 会压缩权重
- D.torch.save 依赖 Python 环境← 正确答案
答案:ABD。pickle 反序列化执行任意代码,safetensors 用 mmap 零拷贝读取,torch.save 需要相同类路径和版本才能解开。C 错误——safetensors 不做压缩,存的也是原始字节,快是来自零拷贝而非压缩。
Q10. 跨框架加载 checkpoint 时,即使 key 完全匹配,仍需检查什么?
- A.张量形状分布← 正确答案
- B.数据类型一致← 正确答案
- C.QKV 融合布局← 正确答案
- D.训练轮次数值
答案:ABC。key 对齐只是第一步,形状必须逐一对上,dtype 不一致要转精度,融合布局(QKV 合成一个矩阵还是拆成三个)也要还原。训练轮次只是日志信息,不影响加载正确性,所以 D 不选。