☰
mmap直挂权重文件:推理服务加载从3秒优化到1.2秒
2026/10/8 12:07:53 网站建设 项目流程

做推理服务部署的,大概都经历过这种尴尬:模型文件明明就放在SSD上,代码里一个torch.load()执行下去,CPU先飙高,内存尖峰翻倍,用户那边的首包延迟已经悄悄溜走好几秒。我上个月手头那个项目,模型权重接近1.3GB,每次重启服务都要忍受这个加载过程,于是换了思路,改用mmap直挂权重。结果非常直接:整体加载从3秒多降到了1.2秒,内存峰值也肉眼可见地降了一截。

这个数字刚出来的时候我自己也愣了一下,后来把mmap的原理和整个加载链路捋了一遍,才发现1.2秒并不是运气好,而是把大量本可以省掉的拷贝和构造开销剔除了。这篇文章就把这个“1.2秒”掰开揉碎:mmap在权重加载这件事上到底做了什么、权重文件为什么能直挂、实际工程里怎么落地、以及哪些坑我是一步步踩出来的。如果你也在做推理服务、模型部署或者大模型相关的服务优化,这篇应该能给你一个直接可用的方案。

1. 先搞懂mmap到底做了什么

1.1 普通读取要倒腾两次,mmap只倒腾一次

常规方式读一个权重文件,调用的是read()或fread()。这条路径大致是:应用发起read系统调用,陷入内核,内核在页缓存里找文件数据。如果页缓存没有,就从磁盘读进页缓存,然后再把页缓存里的数据拷贝到用户态缓冲区,最后返回给应用。

这里的关键在于:文件的数据从内核态到用户态,必须经历一次真正的内存拷贝。文件越大,这次拷贝的开销越线性增长。而且这还只是“读字节”的开销,权重文件读进来之后还要做反序列化、构造Python对象、分配Tensor存储,这些都是额外的活。

mmap的方式完全不同。mmap()系统调用本身不读任何文件数据,它只是把文件的某一段区域映射到进程的虚拟地址空间,返回一个指针。之后你访问这个地址时,内核通过缺页中断(page fault)把对应的文件页调入内存,并且直接把这一页映射到你的进程地址空间。也就是说,应用读到的数据就是页缓存里的那一份,中间没有再拷贝一次。

用快递打个比方:read是快递员把包裹送到你家门口,你再搬进屋里;mmap是快递员直接把整个快递柜装进了你家客厅,你想用哪个箱子就打开哪个箱子,不用搬第二次。

1.2 mmap的真正优势是“按需加载”

很多人以为mmap快是因为“零拷贝”,这话对了一半。更本质的优势在于:建立映射的时候几乎没有物理内存开销,真正加载数据的动作被推迟到了第一次访问的时候。

进程调用mmap()之后,内核只是把虚拟内存区域和文件inode关联起来,页表项先留空。你访问被映射的地址时,如果对应页还没在页缓存里,内核才真正发起磁盘IO,把数据读进页缓存,再建立页表映射。这个机制叫做按需分页(demand paging)。

这个特性对加载大权重文件特别有价值。因为很多场景下,你并不需要立刻访问所有权重,比如服务启动时先加载模型结构,真正触发权重读取的往往是第一个推理请求。mmap直挂让“加载权重”这个动作从一次性的大开销,变成了按访问节奏分摊的、可控的开销。

另外还有一个容易被忽略的点:当多个进程映射同一个文件时,它们共享同一份物理页缓存,不会因为fork了多个worker就让内存翻倍。这一点在多进程推理服务里非常关键,后面实操部分我会专门讲。

1.3 对权重这种结构化大文件,意义不止在拷贝

权重文件跟普通文本文件、图片文件有一点很大的不同:它不是一堆裸字节直接给程序用,而是要经过一层“结构化”的处理。比如PyTorch的.pt/.pth文件,本质是pickle序列化格式,里面存的是完整的Python对象图。加载时要走 pickle 解析、创建字典、还原Tensor、分配内存这一大套流程。

这一层反序列化开销,有时候比磁盘IO还要高。你想想,一个1GB的pickle文件,光解出来就涉及到成千上万个Tensor对象的构造,每个对象都要分配内存、绑定数据,这个过程在Python层面非常费时。

mmap直挂之所以能省掉这一大块,是因为它让Tensor的存储直接指向文件映射出来的内存区域。不需要“读文件到内存再反序列化成Tensor”,而是“文件映射的内存本身就是Tensor的存储”。这就是“直挂”二字的含义。

2. 权重文件为什么能“直挂”

2.1 不是所有权重格式都适合mmap

这是很多人踩的第一个坑:拿了个老式的.pt或者.pth文件,直接mmap()上去,然后发现完全没法用。原因很简单——文件内部的布局是pickle格式,不是“Tensor字节按偏移排列”的结构。

pickle格式里,数据前面有大量的协议头、类型标记、对象引用关系。就算是新版PyTorch默认的zip序列化格式,也只是把Tensor数据压缩存储在一个zip容器里,mmap之后仍然要解析zip目录、解压、再组装成Tensor。整个过程做不到“零拷贝直挂”。

那什么格式能做到?就是safetensors。这个格式从设计之初就是为了安全、快速、可mmap而生的。HuggingFace生态里的模型权重基本都转成这种格式了。社区里你下载的DINOv3权重、YOLOv11权重文件,如果是safetensors版本,基本都可以直接mmap;如果还是官方.pt、.pth,则需要先转换一次。

顺带提一句,新版PyTorch的torch.load(..., mmap=True)虽然也用到了mmap,但它内部仍然会把权重拷贝到普通内存中,因为返回的Tensor不能跟文件存储直接绑定生命周期。所以它省掉的是部分读取开销,不是真正意义的零拷贝直挂。

2.2 safetensors的头和偏移表是怎么设计的

safetensors的文件结构非常简单,这也是它能直挂的基础。文件开头是8字节的小端无符号整数,表示后面JSON头部的字节长度。紧接着就是一段JSON文本,里面记录了这个权重文件里所有张量的元信息。再往后,就是纯粹的二进制张量数据区。

JSON里的每一项大概长这样:

{ "model.layers.0.weight": { "dtype": "BF16", "shape": [4096, 4096], "data_offsets": [0, 33554432] } }

这里的data_offsets给出的是这个张量在数据区内的起始偏移和结束偏移,注意是相对数据区起始位置的,不是相对文件开头的。数据区的起始位置是8 + header_len。所以一个张量在文件里的绝对偏移是8 + header_len + data_offsets[0]。

基于这个结构,mmap之后只需要做两件事:第一,读取头部8字节得到header长度,解析JSON得到偏移表;第二,需要某个张量时,根据偏移表直接定位到内存映射中的对应位置。整个过程干净利落,没有任何多余的数据搬移。

2.3 用几十行代码实现零拷贝张量

理解了格式,自己写一个最简实现并不难。下面这个WeightHandle类可以帮你把safetensors里的任意张量映射成PyTorch张量,注意它只覆盖了float32、float16这类numpy能直接表示的dtype,BF16建议直接用safetensors官方库(后面会说)。

import json import mmap import numpy as np import torch _DTYPE_MAP = { "F32": (np.float32, torch.float32), "F16": (np.float16, torch.float16), "I64": (np.int64, torch.int64), "I32": (np.int32, torch.int32), } class WeightHandle: def __init__(self, path: str): self._path = path self._file = open(path, "rb") self._mmap = mmap.mmap(self._file.fileno(), 0, access=mmap.ACCESS_READ) # 读取头部8字节,得到JSON长度 header_len = int(np.frombuffer( self._mmap, dtype=np.uint64, count=1 )[0]) header = json.loads( self._mmap[8: 8 + header_len].decode("utf-8") ) self._data_start = 8 + header_len self._metas = header def get(self, name: str, device: str = "cpu"): meta = self._metas[name] dtype, torch_dtype = _DTYPE_MAP[meta["dtype"]] offset = self._data_start + meta["data_offsets"][0] count = int(np.prod(meta["shape"])) arr = np.frombuffer( self._mmap, dtype=dtype, count=count, offset=offset ) tensor = torch.from_numpy(arr).view(meta["shape"]) if device != "cpu": tensor = tensor.to(device) return tensor

这里有个非常关键的细节:np.frombuffer()返回的数组是只读的,torch.from_numpy()拿到之后也会成为一个不可写的Tensor。这其实是安全特性,因为直接写这个Tensor等于直接改文件。在推理场景下没问题,但如果你要微调,必须先clone()出来。

如果你的项目里用到了BF16权重,我强烈建议直接用safetensors官方库,它内部处理了BF16的映射逻辑,比自己折腾numpy靠谱得多:

from safetensors import safe_open f = safe_open("model.safetensors", framework="pt", device="cpu") tensor = f.get_tensor("model.layers.0.weight") # 这个Tensor就是mmap直挂的视图

这样拿到的Tensor同样是零拷贝直挂,而且safetensors库会在后台帮你保持mmap对象的生命周期,不用自己操心。

3. 1.2秒到底花在哪

3.1 一次冷启动加载的四段旅程

既然标题问的是“为什么只要1.2秒”,那咱们就把这1.2秒拆开看看。一次冷启动的mmap直挂加载,大致可以分成四段:

第一段是mmap()系统调用本身。这一步只是建立虚拟内存映射,不触发磁盘IO,耗时通常在几毫秒以内,可以忽略不计。

第二段是解析safetensors头部。读取8字节长度、加载JSON、构建偏移表。对于几百MB到几个GB的权重文件,这段耗时一般在10~50毫秒左右。JSON再大也就几MB,解析很快。

第三段是首次前向时触发缺页中断,把文件数据真正读入内存。这是整个1.2秒里的大头。以1.3GB的文件为例,机械性地顺序读一遍需要的时间,在普通SATA SSD上差不多就是1秒上下。内核的readahead会做预读,把后续可能访问的页提前拉进来,所以顺序访问大文件时mmap的效率可以逼近甚至超过read。

第四段是CUDA上下文初始化、创建推理引擎之类的杂项开销,一般也得花几十到一百毫秒。

所以1.2秒的构成大概是:几毫秒的mmap + 几十毫秒的头部解析 + 1秒左右的缺页读盘 + 几十毫秒的杂项。真正不可避免的只有第三段的物理IO时间,其他部分都是靠直挂省出来的。

3.2 本地实测:普通加载和mmap直挂的差距

我在本地的SATA SSD上做了一个简单对比,环境是PyTorch 2.4、模型文件约1.3GB的BF16权重。数字只能做趋势参考,不同机器肯定有差异,但差距的方向和幅度是稳定的:

加载方式冷启动耗时进程内存峰值是否零拷贝
torch.load()老式pth大约3.4秒大约2.8GB否,pickle反序列化+多份临时拷贝
torch.load(..., mmap=True)大约1.9秒大约1.9GB否,读取优化了但Tensor仍是普通内存
safetensors safe_open()大约1.2秒大约1.3GB是,Tensor直接指向文件映射

注意一下内存峰值那列。普通torch.load()之所以内存飙到2.8GB,是因为pickle加载过程中既要保留原始字节流,又要构造Tensor对象,还有一堆临时字典和中间对象。而mmap直挂几乎不产生额外临时内存,权重占多少,物理内存就基本用多少。

3.3 热启动几乎瞬间,但别把功劳全记给mmap

还有一件事值得说清楚:同一份权重,如果mmap已经建立过一次,而且文件页还在页缓存里,第二次加载耗时可以压到几十毫秒以内。原因是缺页中断直接命中页缓存,不需要再读磁盘。这个特性对常驻服务特别友好,比如你在服务里做了模型热更新,切换权重文件时能明显感觉到秒开。

但反过来,也不要以为mmap是玄学加速。它并没有减少磁盘要读的字节数,也没有消除首次访问时的物理IO延迟。如果磁盘很慢,1.2秒照样会变成4秒、5秒。mmap真正砍掉的是read路径上的用户态内核态拷贝,以及pickle反序列化那一整套对象构造开销。所以准确地说,1.2秒不是“快”,而是“不浪费”。

4. 实操落地:从权重转换到推理服务

4.1 第一步:把权重统一导出为safetensors

实操先从权重格式的统一开始。你从社区下载的DINOv3权重、YOLOv11权重文件,很多仍然是官方.pt或.pth格式。老式pickle版本甚至可能有反序列化安全风险,建议第一步就做转换。

转换脚本很简单,核心就是过滤出state_dict里的Tensor字段,然后交给safetensors的save_file():

from safetensors.torch import save_file import torch ckpt = torch.load("yolov11n.pt", map_location="cpu", weights_only=True) if hasattr(ckpt, "state_dict"): state_dict = ckpt.state_dict() elif isinstance(ckpt, dict): state_dict = ckpt else: raise TypeError("unsupported checkpoint format") tensors = {k: v.contiguous() for k, v in state_dict.items() if isinstance(v, torch.Tensor)} save_file(tensors, "yolov11n.safetensors")

有两个小细节要提醒一下。第一,weights_only=True是为了安全,老文件如果用了复杂自定义类可能会报错,那就要先确认来源可信再处理。第二,有些模型文件保存的是整个模型对象,而不是纯state_dict,所以上面代码里做了hasattr(state_dict)的兼容处理。转换完之后,建议顺手assert一下文件大小和原文件接近,避免漏掉字段。

4.2 第二步:封装一个带生命周期的WeightHandle

如果直接用safetensors的safe_open(),其实已经拿到直挂能力了。但在工程集成里,我建议还是再包一层,方便统一管理模型文件的打开、映射、释放和日志。

我实际用的封装大概长这样:

from safetensors import safe_open import torch class WeightStore: def __init__(self, path: str): self.path = path self._f = safe_open(path, framework="pt", device="cpu") self._names = list(self._f.keys()) def get(self, name: str, device: str = "cpu"): tensor = self._f.get_tensor(name) if device != "cpu": tensor = tensor.to(device) return tensor def weight_names(self): return self._names def close(self): # safetensors的safe_open会自己管理mmap生命周期, # 这里只需要做业务层的清理标记。 self._f = None

封装之后,模型加载逻辑就变成了:先创建一个WeightStore,然后在构建模型时,按名字取出对应张量赋值给模型参数。

这里有一个非常重要的生命周期问题,手写mmap的版本里尤其明显:如果mmap对象被垃圾回收了,底层文件映射就会关闭,之后你再访问之前拿到的Tensor,轻则报错,重则进程崩溃。safetensors官方库内部做了引用处理,但自己封装的时候一定要保证WeightStore或者WeightHandle对象存活在整个模型使用期间。我的做法是把它挂在服务实例上,而不是作为一个局部变量用完就丢。

4.3 第三步:多进程推理里的共享与预热

如果你的推理服务用到了多进程(比如gunicorn的worker、PyTorch的DataLoader多进程、或者自建的fork模型),mmap直挂的优势会在内存占用上体现得特别明显。

进程fork之后,子进程会继承父进程的文件映射。由于这些物理页是文件页缓存,多个进程访问的是同一个物理页面,不会因为fork而复制多份。也就是说,你用1.3GB权重起8个worker,物理内存并不会变成1.3GB乘以8,而是所有worker共享同一份页缓存里的1.3GB。这一点在模型服务里省内存的效果极其显著。

启动阶段的预热也值得做。冷启动时第一次前向必然触发缺页中断,如果这个动作刚好发生在第一个线上请求上,就把磁盘IO延迟暴露给了用户。更好的做法是服务启动后主动把权重文件“读一遍”,让页缓存提前填充好。

最简单的预热方式就是直接把文件读进页缓存:

cat model.safetensors > /dev/null

或者用vmtouch:

vmtouch -t model.safetensors

这两条命令都会让内核把文件读入页缓存,之后你再去mmap,缺页中断命中缓存,首个请求的延迟就稳定了。我自己实测下来,预热之后第一个请求的加载耗时从1.2秒降到了几百毫秒以内,抖动明显改善。

5. 常见坑与排查实录

5.1 映射出来的张量只读,别当普通Tensor改

这是新手最容易踩的坑。mmap直挂出来的Tensor,底层是文件映射内存,属于只读视图。如果你试图在推理脚本里原地更新权重,比如param.data.add_(0.1),大概率会得到一个“storage is read-only”或者类似报错。

这个设计是有道理的——如果允许写,你就是在直接修改磁盘上的模型文件,而且多个进程如果共享映射,还会互相污染。

所以记住一条经验:推理场景放心用直挂;训练、微调、在线学习这些需要原地更新权重的场景,先clone()到普通内存再说。clone之后就彻底脱离文件映射,变成独立内存,想怎么改都行。

5.2 文件被替换或截断,SIGBUS可能直接崩服务

mmap还有一个隐蔽但严重的坑:映射建立之后,如果底层文件被截断或者被覆盖到映射区域之外,进程再访问已映射的内存区域时,会触发SIGBUS信号。这个信号默认行为是直接终止进程,没有任何Python异常可以捕获。

我在项目早期就吃过这个亏:服务在跑,运维同事为了方便直接覆盖了权重文件,下一秒服务就挂了,排查日志发现进程是被SIGBUS杀掉的。

现在的规避做法很明确:权重文件加载进mmap之后,不要在运行中原地重写。更新模型时,先写入一个临时文件,再通过rename()原子替换。因为mmap持有的是旧文件的inode,rename之后旧映射仍然指向原来的文件数据,新请求过来时重新打开文件即可,服务不会崩。

5.3 磁盘抖动导致的延迟尖刺,怎么压下去

mmap直挂是零拷贝,但底层IO还是躲不过去的。如果跑在磁盘IO繁忙的机器上,比如同一块盘上同时有日志写入、训练任务在跑、别人在拷贝大文件,那么缺页中断触发时,页缓存miss的延迟会很不稳定。表现就是平时1.2秒,高峰期突然变成3秒甚至更久。

我这里有两个解决办法。第一个是前面说的预热,把冷启动的缺页提前到空闲时间,高峰期就不太会出现突发读盘。第二个是把权重文件放到独立的SSD分区或者专门的缓存盘上,避免跟日志、临时文件抢IO。对于追求极致稳定的线上服务,这两条建议都值得做。

另外,内核的readahead对mmap顺序读大文件有优化,默认就开。如果你发现自己的大文件读取没有享受到预读的加速,可以查一下挂载参数和/sys/block/<disk>/queue/read_ahead_kb的配置,调大一些往往有惊喜。

5.4 什么时候别用mmap直挂

聊了这么多优势,也该说说什么场景不适合。以下四种情况我建议别折腾:

场景建议原因
几十KB的小权重,比如滤波核、单算子权重直接读文件太小,mmap的映射开销占比高,收益可以忽略
一次性训练脚本加载权重直接load进程很快结束,页缓存复用价值低,省不了多少
微调、需要原地更新权重clone后再用只读映射无法满足update需求
随机访问大量零散小张量谨慎mmap对顺序大读最友好,随机小读会频繁缺页,效率反而不如read

这里特别说一下“随机访问零散小张量”这个问题。mmap按页加载,如果你在一个大文件里到处读不同位置的小片段,每个位置都要触发缺页,每次至少读4KB甚至更多,但实际用的可能就几百字节。这种场景下mmap的效率反而不如一次性read到内存再随机访问。

我自己的判断标准很简单:只要权重文件大于100MB,且加载后主要做顺序推理,就值得用mmap直挂;否则就老老实实load。目前手头几个视觉模型,包括DINOv3、YOLOv11系列,都是这种模式,跑得很稳。

整个项目做下来,我最深的一个体会是:模型部署里的很多“慢”,并不是慢在磁盘本身,而是慢在训练框架留下的那些通用加载路径上。torch.load()是给训练、调试用的,它保证的是普适性和兼容性,不是加载速度。而mmap直挂本质上就是绕开通用路径,针对“只读大权重、常驻服务、多进程共享”这个特定场景做极致优化。成本很低,收益却很直观:几秒变一秒。

最后再分享一个小技巧。我现在的服务启动脚本里固定有这么三步:先把模型转成safetensors,再启动服务前用cat model.safetensors > /dev/null预热页缓存,最后才拉起推理进程。这几步加起来耗时不到两秒,但换来的却是线上环境稳定、可预期的首包延迟。如果你也正被权重加载折磨,建议明天就把手头这个模型转成safetensors,试着直挂一次,你会回来感谢自己的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询