前阵子把一个 70B 级别的模型部署到两机十六卡的时候,第一次等权重加载等得我怀疑人生。vLLM 里其实有个一直被我忽略的功能:Save Sharded State(分片状态保存),它能把分布式推理的启动时间从十几分钟压到一分钟上下。这篇就把这个东西从原理到实操讲清楚,包括它在源码层面到底保存了什么、怎么用、以及我踩过的那些文件权限和拓扑匹配的坑。
1. 先弄清楚:分布式推理加载权重,慢在哪里
很多人第一次接触分布式推理时,都会有一个疑问:模型不就 100 多 GB 嘛,从本地 NVMe 读出来几十秒,为什么实际启动要十几分钟?这个问题的答案,不在磁盘读取,而在“分发”环节。
1.1 常规加载流程的几个耗时环节
我以一次常见的多卡部署为例拆解一下。假设模型权重以通用格式存成若干个大文件,推理框架启动时,每个进程大致要做这几件事:
- 扫描权重目录,读取索引文件,建立“张量名 -> 文件 -> 字节偏移”的映射关系。这一步看起来轻,但文件数量一多、目录在共享存储上时,元数据操作的耗时会被放大。
- 读权重数据。在多卡环境下,某些张量需要被切分,切分前主进程往往要把完整的权重张量读进内存。
- 按并行策略做映射。比如张量并行(TP)下,一个 QKV 权重可能要按输出维度切成若干份,决定每一份归哪张卡。
- 跨卡分发和广播。主卡或某个源进程把数据准备好后,要通过集合通信把权重送到其他 rank 上。卡数越多,通信链越复杂,耗时越明显。
这里最关键的是第 4 步。很多部署环境里,磁盘读取只要两三分钟,但 8 卡甚至 16 卡之间的广播通信,能把总耗时拉到十分钟以上。如果还叠加了流水线并行(PP),rank 之间的依赖关系会让加载过程更难并行化。
1.2 分片状态保存想改掉的到底是什么
Save Sharded State 的核心思路很简单:不要在每次启动时重新读完整权重、再做一次全量分发,而是把“已经按当前并行拓扑切好的权重”直接落盘。每个 rank 只保存它自己实际负责的那一份分片文件。下次启动时,每个 rank 直接读自己那份,跳过跨卡广播环节。
对应到生活场景,就像全馆图书每次都要给每个阅览室复印一整份,后来改成每个阅览室只搬自己书架上的那部分书。省掉的不只是复制的时间,还有协调和等待的时间。
这也是“Sharded State”这个名字的来源:它不是一个完整的模型快照,而是按分布式执行拓扑拆开的状态集合。理解这一点很重要,因为它决定了这个功能的使用边界。
1.3 哪些场景最需要它
不是所有部署都需要分片状态保存,但有四类场景非常受益:
- 在线服务滚动升级。新版本进程启动时直接加载分片文件,可以先在后台就绪,再切流量。
- 动态扩容。推理集群要临时加卡时,新节点如果能直接指向分片目录,就能快速加入。
- 容灾恢复。某个进程被系统杀掉后,新容器需要尽快接管,分片状态保存能显著缩短空窗期。
- 多环境复用。在共享存储上保存一份分片目录,多个推理服务实例可以共用,避免每个实例各自读原始权重并按相同拓扑重复切一遍。
2. 分片状态保存的底层设计:文件、张量和并行拓扑
用 API 很容易,但如果不理解底层的文件组织方式,遇到问题时会很被动。我建议在动手前先搞清楚三件事:分片是怎么切出来的、文件里装了什么、它和原始权重目录到底有什么不同。
2.1 权重分片:不是简单切文件,而是按张量边界切
模型权重不是一块连续的大二进制块,而是由大量命名张量组成的结构化集合。所以分片保存不能像文件切割工具那样按字节均分,必须按张量边界来切。
有些张量可以被切分。比如注意力相关权重在张量并行下,通常按输出特征维度切成多份,每张卡拿一份;MLP 中间层权重也可能按列切。而像 LayerNorm 的权重和偏置这类张量,通常不被切分,每个 rank 都需要一个完整副本。
vLLM 的分片保存逻辑会结合当前引擎初始化时的并行配置,逐个张量判断“这个张量该归哪个 rank、这个 rank 拿哪一段”。然后把属于同一个 rank 的权重聚到一组文件里。整个过程不是盲目扫描文件,而是带着并行策略做的“目标性切割”。
2.2 文件里到底放了什么:张量名、偏移与索引
每个分片文件本质上还是一个张量容器,但它的内部结构需要注意:文件头部记录了里面每个张量的名字和字节偏移,主体部分是原始权重字节。文件旁边还会有一个全局索引,记录所有分片文件与张量名的映射。
这样设计有两个直接好处。
第一,加载时不需要把所有文件都读进内存。GPU 可以通过按需读取的方式,只取当前 rank 需要的那些张量,适合超大模型场景。
第二,索引文件让加载器可以快速跳过不属于自己的文件。启动时先读索引,解析出本 rank 对应的文件列表,然后按列表顺序读取,而不是每个文件都打开扫描一遍。
很多人在保存后直接看目录,发现文件数量很多、命名规则也比较机械,就以为它是简单按权重大小切的。其实每个文件名和目录结构都隐含了 rank 和分片序号信息,这个信息在恢复时会用到。
2.3 与原始权重目录的几个关键区别
我用一张表说明分片目录和原始权重目录的差异。
| 对比维度 | 原始权重目录 | 分片状态目录 |
|---|---|---|
| 组织维度 | 按参数字典全局组织 | 按 GPU rank 和并行拓扑组织 |
| 是否包含切分信息 | 不含,加载时才临时切 | 已按拓扑切好并落盘 |
| 加载时是否依赖跨卡广播 | 需要 | 不需要,每个 rank 直读本地分片 |
| 可移植性 | 任意拓扑下都可加载 | 原则上绑定保存时的拓扑 |
| 文件数量 | 通常固定、可控 | 由 size_per_file 参数决定,可能很多 |
原始权重目录本身是“通用格式”,它适合模型导出、微调、转换等场景。分片状态目录则更像是“面向某个具体推理拓扑的缓存”,所以它更快,但也更“挑环境”。
3. 实操:保存与恢复一个分片状态的关键步骤
搞懂了原理之后,实际操作其实非常简洁。vLLM 对外暴露的 API 不算多,核心就是保存和恢复两个动作,外加一个“把分片目录当普通模型目录”的加载路径。
3.1 保存分片状态时用到的参数与注意事项
先看一段最直接的保存示例:
from vllm import LLM llm = LLM( model="/data/models/example-70b", tensor_parallel_size=8, ) llm.save_sharded_state( "/data/models/example-70b-sharded", format="safe", size_per_file=4096, )这段代码做的事情是:先把一个 70B 模型按 8 卡张量并行加载起来,然后把已经切好的权重分片保存到指定目录。
几个参数需要解释一下:
format:分片文件的容器格式,一般用"safe",对应的就是带安全校验、头部分离的张量文件格式。size_per_file:期望的单文件大小阈值,单位是 MB。这个参数直接决定文件切分的粒度。- 保存目录如果已存在,框架的常规做法会校验或要求为空目录,避免新旧文件混在一起导致索引错乱。
保存时有一个隐性成本:模型必须先完整加载到显存或内存里。如果你想省显存,可以不开额外并行配置直接保存,但这样生成的分片目录可能没有按目标拓扑切好,后续换拓扑加载时依然要做映射,等于白保存。
3.2 加载分片状态:直接把它当作模型目录
保存完成后的分片目录,已经具备“可加载模型目录”的能力。冷启动场景下,你不需要调用任何特殊恢复接口,直接把模型路径指过去就行:
llm = LLM( model="/data/models/example-70b-sharded", tensor_parallel_size=8, )框架启动时会识别目录中的分片索引,并自动走快速加载路径。每个 rank 只读属于自己那部分文件,省略了完整读取和跨卡广播。
还有一个适合运行时热恢复的场景。假如你已经创建好了一个 LLM 实例,希望在不重建引擎的情况下恢复到某个已保存的分片状态,可以这样操作:
llm.load_sharded_state("/data/models/example-70b-sharded")这个接口适合做状态切换或回滚。不过在我实际使用中,冷启动直接指定分片目录是更常见的用法,因为在线服务通常就是用新进程替换旧进程,而不是在同一个进程里反复切状态。
3.3 滚动升级的标准动作:保存一次,加载N次
我把分片状态保存融入滚动升级的标准流程后,整体动作变成四步:
- 新版本发布前,用当前稳定权重做一次分片保存。
- 新版本进程启动时,把模型路径指向分片目录。
- 新进程内部完成权重加载和 KV Cache 预分配,通过健康检查后开始接收流量。
- 确认新进程运行稳定后,销毁旧实例。
这样做的好处是,保存分片的成本只在第一步发生一次,后续每次发布、扩容、重启都能摊薄这次成本。而且因为加载路径从“读全部文件再分发”变成了“每个 rank 读自己那份”,发布频率越高,收益越明显。
4. 常见坑:保存路径、文件大小与拓扑一致性
分片状态保存用起来简单,但生产环境和本地 Demo 不一样,坑基本都藏在细节里。我把自己踩过的和帮别人排查过的几个典型问题列出来。
4.1 大文件与太多小文件都不可取
size_per_file这个参数很容易被忽略,但它对加载性能影响很大。
设得太小,文件数量会非常夸张。一个 140GB 的权重,如果 size_per_file 设成 64MB,会产生两千多个文件。文件系统 inode 压力、目录扫描时间、共享存储的元数据操作都会变成新瓶颈。
设得太大,单个分片文件体积大,加载时按需读取的效率会下降,而且如果加载进程会把整个文件映射进内存,会平白多占用很多内存。
我实际测试下来,大部分场景取 2048 或 4096(MB)比较稳。建议在自己的硬件上做一组对比:分别用 1024、2048、4096、8192 保存同一个模型,再测加载时间,选拐点位置的值。不要直接抄网上配置,不同并行度下最优值会漂。
4.2 保存目录不能直接放在低性能共享存储上
这是我最开始犯的错。图省事直接把分片目录写到了网络共享存储上,结果保存过程慢到离谱。原因不算复杂:大量小文件并发写入时,共享存储服务端的元数据锁竞争非常严重,每个文件创建、目录项更新都要抢锁。
建议的做法是:
- 在本地 NVMe 上完成分片保存,耗时通常能控制在几分钟内。
- 保存完成后,再把整个目录同步到共享存储,作为持久化副本。
- 新实例启动时,优先从共享存储拉取目录到本地盘,再从本地加载。
如果你的共享存储是高性能并行文件系统,可以省略本地中转,但一定要先做并发读写压测,确认承受得了多节点同时读写的场景。
4.3 拓扑一定要和保存时一致
这是分片状态保存最容易踩的兼容性坑。保存时如果是tensor_parallel_size=8,加载时用了tensor_parallel_size=4,大概率会报张量缺失、形状不匹配之类的错误。
原因在于分片文件里的张量切片是根据保存时的并行边界切出来的,换拓扑后,每个 rank 期望的张量形状都不一样,原来的切片自然无法复用。
解决路径有三个:
- 保持相同拓扑加载,这是最省事的选择。
- 用原始权重目录重新做一次分片保存,生成新拓扑下的分片目录。
- 直接加载原始权重目录,让框架在启动时按新拓扑现场切分。
所以分片状态不是一个“通用模型格式”,更像一个“定点缓存”。在运维上一定要把拓扑信息写进目录名,比如model-70b-tp8,避免后续误用。
4.4 文件权限与进程属主不一致
多人协作或多容器共享存储时,这个问题出现频率很高。保存分片的进程属主是 A 账号,加载实例进程的属主是 B 账号,结果读取文件时直接 Permission Denied。
我现在的规范是:固定服务账号运行所有推理实例,统一设置分片目录的属主和组权限;目录上不用777这种宽松权限,容易在安全审计时被点名。目录命名里带上版本号,也能避免新老文件权限配置不一致导致的隐蔽问题。
4.5 格式版本变化带来的兼容性
框架版本升级后,分片文件的头部结构、索引格式都可能调整。我有一次升级后直接加载旧分片目录,报了版本匹配失败。不是说旧文件读不出来,而是新版本加载器对元数据结构要求变了。
所以在生产环境里,我建议把“分片目录 + 框架版本号”绑定记录,升级后主动重新保存一次分片,不要赌它一定向后兼容。
5. 用一份压测数据说话:到底省了多少时间
说了这么多,不如一组数据直观。以下是我在内部压测环境里记录的加载耗时,硬件基于普通 x86 服务器加本地 NVMe,模型为内部模拟项目中的大模型权重,数据仅供参考,不代表任何公开基准。
5.1 一组内部压测数据
| 模型规模 | 并行配置 | 原始权重加载耗时 | 分片状态加载耗时 | 分片保存一次耗时 |
|---|---|---|---|---|
| 13B | TP=1 | 1分08秒 | 42秒 | 1分20秒 |
| 70B | TP=8 | 8分30秒 | 55秒 | 2分40秒 |
| 70B | TP=16 | 15分12秒 | 1分18秒 | 4分05秒 |
| 130B | TP=16 | 21分40秒 | 1分52秒 | 6分10秒 |
数据有几个特点值得注意。
第一,单卡小模型提升并不夸张,13B 级别原始加载本身只要一分钟,分片后也就省几十秒,意义不大。
第二,卡数越多,分片收益越明显。70B 在 TP=16 下从十五分钟压到一分多钟,这对在线服务来说是本质区别。
第三,保存本身也有成本,70B 在 TP=16 下要四分钟左右,但这是一次性投入,加载收益会在多次发布中不断叠加。
5.2 成本账:不是所有时候都划算
分片状态保存并不是银弹。我用一张表说明什么场景值得做、什么场景不必折腾:
| 场景 | 推荐程度 | 理由 |
|---|---|---|
| 多卡在线推理服务 | 非常推荐 | 启动时间直接关系到发布频率和可用性 |
| 单卡小模型 | 不推荐 | 原始加载已经够快,保存反而多一次操作 |
| 频繁更新权重的实验阶段 | 不推荐 | 权重一变,分片缓存就失效,重复保存成本高 |
| 模型权重基本稳定、反复部署 | 非常推荐 | 一次保存,多次受益 |
| 临时调试、只跑一次推理 | 不推荐 | 直接用普通权重目录最省事 |
做不做分片保存,核心就看一句话:权重变不变、加载频不频繁。权重稳定且加载频繁,闭眼做;权重天天变或者只加载一次,别折腾。
5.3 我现在的使用习惯
踩过几次坑之后,我现在的工作流固定成了这样:模型权重交付到推理集群时,先按目标拓扑做一次分片保存,目录名写成“模型名 + 并行配置 + 版本号”,同时把对应的框架版本号记录在一个小文件里。日常发布、扩容、回滚都直接指向分片目录。
这样做的直接收益是,发布流程从“等权重加载完再切流量”变成了“新实例就绪秒级切换”,发布频率也敢往上提了。如果微调后权重有变化,第一时间重新保存分片,避免索引与权重不一致的诡异问题。说到底,Save Sharded State 并不是一个复杂的黑盒功能,理解了它的原理和边界之后,它就是分布式推理里一个非常趁手的运维工具。