☰
同样一份权重,冷加载比热加载慢 9 倍,差在页缓存
2026/10/18 6:55:22 网站建设 项目流程

版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

同一份权重,第一次被读和第二十次被读,耗时差一个数量级。本文实测的那份 256 MiB 文件,冷读 160 毫秒,热读 17 毫秒;按这个比例外推到 7B 级别(FP16 约 14 GB)的权重,冷读 9 秒上下,热读不到 1 秒。加载代码一行没改,变的只是这份文件此前有没有被这台机器读过。

这就是「本地热加载很快,换到新机器第一次加载就超时」的物理来源。它跟容器、依赖、import 顺序都无关,只是把 14 GB 字节从盘搬到内存这一段路的物理成本,在缓存为空的时候一次性付清了。

这篇做一件事:把read和mmap两条读法,在冷、热两种缓存状态下各测一遍,再拿官方文档逐条对账,看这个差距由什么决定、在什么条件下会反过来。

先把测试条件统一:同一份文件、同一块盘、同样 256 MiB

比较三条读法之前,得先保证它们读的是同一批字节、走的是同一块盘、页缓存状态可控——否则测出来的只是缓存命中率的差别,不是读法的差别。而这台机器上,缓存状态恰恰是最难控制的那一项。

测试落在 Apple Silicon 的 Mac 上(Darwin 27.0.0),Python 3.13.12,文件建在/tmp,实际写在同一块 NVMe 盘上,大小 256 MiB,读块 4 MiB。所有代码只用标准库。

第一个坑出现在构造「冷」状态上。第一版我按最直觉的做法:正常把文件写下去,再对读句柄调fcntl(fd, fcntl.F_NOCACHE, 1),以为这样就绕过了统一缓冲。跑出来 256 MiB 只花 23 毫秒,折算 11 GB/s——这是内存带宽的数字,不是盘的速度。查了 fcntl 的手册页,F_NOCACHE的原文只有一句Turns data caching off/on,它没有承诺读路径一定把页缓存挡住。

第二版换了造法:用F_NOCACHE打开的写句柄把文件写下去并fsync,写进去的页不进统一缓冲,紧接着再用F_NOCACHE读。这一次 256 MiB 花了 174.9 毫秒,折算 1.46 GB/s,量级终于回到盘上。后文所有「冷」的数据都是这么造出来的。

四条路径定义如下,这是本文唯一的一处口径声明:

路径读法计时开始时文件在页缓存里吗它回答的问题
coldos.read,4 MiB 块,读句柄带F_NOCACHE不在(写入时就没让它进缓存)这块盘顺序读一遍要多久
warmos.read,先读一遍不计时,再读第二遍计时在同样的字节命中缓存要多久
mmap_hotmmap后逐页触碰,每页取 1 字节在映射读在命中时有多快
mmap_cold同上,但文件以F_NOCACHE写入不在缺页这条路径要付多少代价

每条路径跑 3 次取中位数,并且每条路径单独起一个子进程——ru_maxrss是进程生命期内的峰值高水位,只有让子进程从零开始,内存增量这一列才有意义。

实测:冷读 160 毫秒,热读 17 毫秒,差 9 倍以上

同一份 256 MiB 文件,冷读 160.3 毫秒,热读 16.9 毫秒,五次运行的比例落在 9.1 到 9.7 倍;mmap 热的时候 19.8 毫秒,冷的时候 356.6 毫秒。差距不在读法上,在文件有没有进页缓存。

🧪 实测环境:Python 3.13.12 / macOS(Darwin 27.0.0,Apple Silicon)/ 仅用标准库(fcntl、mmap、resource)

importfcntl,mmap,os,resource,statistics,subprocess,sys,time P="/tmp/csdn_exp1018_payload.bin"SIZE,CHUNK,PAGE,REPS=256<<20,4<<20,4096,3defrss_mib():# macOS 的 ru_maxrss 单位是字节returnresource.getrusage(resource.RUSAGE_SELF).ru_maxrss/2**20defprep(cold):fd=os.open(P,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o644)ifcold:# 写进去的页不进统一缓冲,随后读到的是盘上的字节fcntl.fcntl(fd,fcntl.F_NOCACHE,1)buf=os.urandom(CHUNK)for_inrange(SIZE//CHUNK):os.write(fd,buf)os.fsync(fd)os.close(fd)defone(mode):cold=modein("cold","mmap_cold")prep(cold)fd=os.open(P,os.O_RDONLY)ifcold:fcntl.fcntl(fd,fcntl.F_NOCACHE,1)r0=rss_mib()ifmode.startswith("mmap"):# 逐页触碰,每页取 1 字节求和mm=mmap.mmap(fd,0,access=mmap.ACCESS_READ)t0=time.perf_counter()s=0foroffinrange(0,SIZE,PAGE):s+=mm[off]t1=time.perf_counter()mm.close()else:ifmode=="warm":# 先读一遍喂热页缓存,只计第二遍whileos.read(fd,CHUNK):passos.lseek(fd,0,os.SEEK_SET)t0=time.perf_counter()whileos.read(fd,CHUNK):passt1=time.perf_counter()os.close(fd)print(f"{mode},{round((t1-t0)*1000,1)},{round(SIZE/2**20/(t1-t0),1)},{round(rss_mib()-r0,1)}")iflen(sys.argv)>1:# 子进程只跑一条路径,保证 RSS 高水位从零开始one(sys.argv[1])sys.exit()print("路径 中位耗时(ms) 折算带宽(MB/s) 峰值RSS增量(MiB) 三次耗时(ms)")med={}formodein("cold","warm","mmap_hot","mmap_cold"):rows=[]for_inrange(REPS):out=subprocess.run([sys.executable,__file__,mode],capture_output=True,text=True,check=True)rows.append([float(x)forxinout.stdout.strip().split(",")[1:]])med[mode]=statistics.median(r[0]forrinrows)print(f"{mode:10s}{med[mode]:9.1f}{statistics.median(r[1]forrinrows):13.1f}"f"{max(r[2]forrinrows):15.1f}{[r[0]forrinrows]}")print(f"\n冷读/热读 ={med['cold']/med['warm']:.2f}x 冷mmap/热mmap ={med['mmap_cold']/med['mmap_hot']:.2f}x")os.remove(P)print(f"临时文件已删除,exists={os.path.exists(P)}")

下面是其中一次的原始读数。这类耗时读数每次运行都会变,别把某一行当成精确值:

路径 中位耗时(ms) 折算带宽(MB/s) 峰值RSS增量(MiB) 三次耗时(ms) cold 160.3 1596.9 0.0 [174.2, 154.0, 160.3] warm 16.9 15160.7 0.0 [16.9, 16.3, 17.2] mmap_hot 19.8 12905.0 256.0 [19.8, 19.7, 20.5] mmap_cold 356.6 718.0 256.0 [367.2, 356.6, 354.9] 冷读/热读 = 9.49x 冷mmap/热mmap = 18.01x 临时文件已删除,exists=False

同样的脚本连跑五次,各行的中位数落在这些区间里,倍数没有跨过 10:

路径中位耗时(毫秒)折算带宽(MB/s)峰值 RSS 增量(MiB)五次运行的耗时区间(毫秒)
cold160.315970.0157.4 – 163.1
warm16.9151610.016.6 – 17.3
mmap_hot19.812905256.018.9 – 19.9
mmap_cold356.6718256.0353.1 – 366.6

表中「中位耗时」一列取的是第一跑的数,区间一列覆盖五次运行的每一次中位数。这两个脚本在正文写完之后又各复跑过一次,退出码 0,输出结构完全一致,只有毫秒数不同——计时类读数没有可复现的精确值,只有可复现的量级。

两件事要解释清楚,否则这张表会被误读。

第一,热读那 15 GB/s 不是盘的速度,是本机内存带宽的量级。页缓存命中时数据根本没有下盘,所以这一列衡量的是「内存到用户态缓冲区」的拷贝成本,跟盘无关;只有 cold 那一行的 1.6 GB/s 才代表这块盘给单线程顺序读的实际带宽。

第二,读路径的 RSS 增量报 0.0 是测量口径的假象。ru_maxrss记的是高水位,而prep()在写文件时已经分配过一块 4 MiB 随机缓冲,进程峰值提前被顶上去,后面读 256 MiB 是反复申请释放 4 MiB 缓冲,峰值一直没超过写文件时的那一下,自然没有创出新高。相反,mmap 那两行的 256.0 MiB 是真实的:逐页触碰之后,整个文件的页都驻留了。

反直觉的那一条:mmap 在文件冷的时候比 read 慢一倍多

mmap 不是「更快的读」,它只是把「一次性读完」拆成「每页一次缺页」;缺页本身有固定成本,文件冷的时候这笔成本要付 65536 次,所以它反而更慢——356.6 毫秒对 160.3 毫秒,慢了 2.2 倍。

这个数可以直接算:256 MiB ÷ 4 KiB = 65536 页,mmap_cold 比 cold 多花了约 196 毫秒,摊到每页上约 3 微秒。这是粗算,只用来说明量级——其中既有缺页陷入内核的固定开销,也有「一次只读 4 KiB」把内核预读窗口废掉的损失;read那 64 次 4 MiB 调用是允许内核做顺序预读的。

命中状态下 mmap 也不占便宜:19.8 毫秒对 16.9 毫秒,慢 17%。页在缓存里,缺页依然要走一遍内核,只是不用等盘。

那 mmap 到底买到了什么?两样东西,都不是「更快」:

  1. 首字节延迟。read至少要凑满一个 4 MiB 块才返回,按 1.6 GB/s 算约 2.6 毫秒才有第一批字节可用;mmap触到第一页就返回。这是换算,不是测量,但方向没问题。
  2. 有效读取量可以小于文件大小。如果你只用到 14 GB 里的 3 GB 张量,mmap 按需触碰只把 3 GB 拉进来;read全量读一遍就是 14 GB 一个字节不少。safetensors 那套「只读需要的张量」靠的就是这一点。

代价同样明确:「mmap 省内存」只在你不全量触碰时成立。表里那 256.0 MiB 的 RSS 增量就是证据——逐页走完一遍,文件页全部驻留,一 MiB 也没省下。

还有一个只有上线时才会碰上的坑:映射的是文件本身,如果发布过程中同名文件被替换或截断,触碰超出文件末尾的那一页会收到 SIGBUS——那是信号不是异常,进程直接挂掉,日志里连栈都没有。排查时可以用mincore查一下映射里哪些页当前驻留在页缓存。

找独立信源对一遍:官方文档也没说 mmap 更快

四个一手来源都支持「惰性加载」和「省峰值内存」,但没有一个说 mmap 能省磁盘带宽;safetensors 的 README 甚至自己给 zero-copy 加了「文件已经在缓存里」这个前提。

来源一:safetensors 仓库 README(Hugging Face),Notes 一节。原文:

Zero-copy: No format is really zero-copy in ML, it needs to go from disk to RAM/GPU RAM (that takes time). On CPU, if the file is already in cache, then it can truly be zero-copy, whereas on GPU there is not such disk cache, so a copy is always required but you can bypass allocating all the tensors on CPU at any given point.

这句话和我的实测对得上,而且是逐字对上:它说的if the file is already in cache,正是我表里 19.8 毫秒和 356.6 毫秒那条分界线;它说的it needs to go from disk to RAM ... (that takes time),就是 cold 那 160 毫秒。同一份 README 里给 zero-copy 下定义的那一行更直白——Zero-copy: Does reading the file require more memory than the original file ?——它衡量的是内存,不是时间。把 zero-copy 当成「加载快」,多半是从这里误读出来的。

来源二:PyTorch 文档torch.load的mmap参数。原文:

When themmapflag is set, instead of copying the tensor storages from disk to CPU memory in the first step,fis mapped, which means tensor storages will be lazily loaded when their data is accessed.

lazily loaded when their data is accessed——这就是我测出来的缺页路径。文档写的是「什么时候加载」,不是「加载得多快」,两个问题不能混。

来源三:Hugging Face transformers 的 big model 加载文档。讲分片检查点的那句原文:

The main advantage of sharded checkpoints for big models is that each shard is loaded after the previous one, which caps the memory usage to only the model size and the largest shard size.

注意caps the memory usage这四个词:官方给分片加载记的功劳是峰值内存,不是磁盘耗时。同一页里low_cpu_mem_usage=True的说明也一样,讲的全是「不要在内存里同时存两份模型」。

来源四(这一条对不上,需要解释):safetensors README 里的 BLOOM 数字。原文写using this format enabled to load the model on 8 GPUs from 10mn with regular PyTorch weights down to 45s,约 13 倍。这个倍数比我测出来的 mmap 冷路径好太多——我这边 mmap 冷加载比read还慢。

差异来自三处:其一,那 10 分钟里混着 pickle 的反序列化和 CPU 上的额外拷贝,同一份 README 在 Faster load 一节自己写了it does seem to have an extra copy on CPU, which we can bypass in this lib,所以 13 倍里有一部分不是磁盘的功劳;其二,原文没写盘型、没写缓存状态,8 卡机器上权重大概率已经在页缓存或本地盘阵列里;其三,它的口径是「端到端加载完成」,我的口径是「字节从盘到用户态」。

所以那个 13 倍不能往自己的场景上套。能套的只有本文这组同机同盘、缓存状态可控的读数。

差异来自哪里:命中率、页粒度、并发

三条路的差距不是算法快慢,而是三个因子相乘:页缓存命中率、每次 I/O 的有效字节、以及此刻有几个进程在抢同一块盘。

命中率决定你落在 1.6 GB/s 还是 15 GB/s 之间。这是一条插值线,不是二选一:一份权重如果 60% 的页已在缓存里,加载时间大致是 0.6 × 热 + 0.4 × 冷。这解释了最典型的线上现象——同一个镜像,第一个副本启动 9 秒,第二、第三个副本启动不到 1 秒,因为第一个副本已经把页缓存喂热了;也解释了为什么「本地很快」:你的开发机上这份文件早被读过几十遍,命中率接近百分之百。

页粒度决定有效读取量。页是 4 KiB,你哪怕只取每页的第一个浮点数,物理上也要读满 4 KiB,读放大 4096 倍。safetensors 的 README 专门讲过这件事的后果:张量在文件里排布得散,就算支持惰性加载,也可能为了取一个张量扫掉半个文件,incurring many DISK -> RAM copies。判断方法很简单:看你的加载路径是不是只碰了需要的那几个张量,还是get_tensor把整份都解出来。

「耗时 ≈ 大小 ÷ 带宽」这条估算式,得先在自己盘上验一次。我拿 64 / 128 / 256 MiB 三档文件各冷读 3 次,看耗时是不是跟着大小等比走:

🧪 实测环境:Python 3.13.12 / macOS(Darwin 27.0.0,Apple Silicon)/ 仅用标准库(fcntl)

importfcntl,os,statistics,time MIB,CHUNK=1<<20,4<<20defcold_read(path,size):fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o644)fcntl.fcntl(fd,fcntl.F_NOCACHE,1)# 写盘不进统一缓冲,读时才真要读盘buf=os.urandom(CHUNK)for_inrange(size//CHUNK):os.write(fd,buf)os.fsync(fd)os.close(fd)fd=os.open(path,os.O_RDONLY)fcntl.fcntl(fd,fcntl.F_NOCACHE,1)t0=time.perf_counter()n=0whileTrue:b=os.read(fd,CHUNK)ifnotb:breakn+=len(b)t1=time.perf_counter()os.close(fd)return(t1-t0)*1000,nprint("大小(MiB) 中位耗时(ms) 折算带宽(MB/s) 三次耗时(ms)")bw={}formibin(64,128,256):path=f"/tmp/csdn_exp1018_{mib}.bin"samples=[]for_inrange(3):ms,n=cold_read(path,mib*MIB)assertn==mib*MIB samples.append(round(ms,1))med=statistics.median(samples)bw[mib]=mib/(med/1000)print(f"{mib:8d}{med:11.1f}{bw[mib]:13.1f}{samples}")os.remove(path)print(f"\n按 256 MiB 档的{bw[256]:.0f}MB/s 外推 7B 级 FP16 权重(14 GB):")print(f" 14000 MB /{bw[256]:.0f}MB/s ={14000/bw[256]:.1f}秒")print(f" 按 64 MiB 档的{bw[64]:.0f}MB/s ={14000/bw[64]:.1f}秒")print("残留临时文件:",[fforfinos.listdir("/tmp")iff.startswith("csdn_exp1018_")])

同样地,下面是其中一次的原始读数,每次运行的毫秒数都会有出入:

大小(MiB) 中位耗时(ms) 折算带宽(MB/s) 三次耗时(ms) 64 40.7 1572.5 [40.7, 40.6, 42.6] 128 81.7 1566.7 [80.0, 81.7, 85.8] 256 162.1 1579.3 [160.9, 162.1, 175.3] 按 256 MiB 档的 1579 MB/s 外推 7B 级 FP16 权重(14 GB): 14000 MB / 1579 MB/s = 8.9 秒 按 64 MiB 档的 1572 MB/s = 8.9 秒 残留临时文件: []

三档的带宽是 1572 / 1567 / 1579 MB/s,几乎一条直线,说明在这块盘上、这个文件尺寸区间里,加载时间就是字节数除以有效带宽,没有别的项。同一个脚本跑了四遍,64 MiB 的中位数落在 40.7 到 41.6 毫秒,256 MiB 落在 158.7 到 162.3 毫秒,抖动在 3% 以内。

按 1579 MB/s 外推,7B 级 FP16 权重(140 亿字节)冷读约8.9 秒。这个数是按本机实测带宽外推的估算,不是厂商数据,也不是任何框架的 benchmark。它唯一的用处是给你一个量级:如果探针超时是 1 秒、权重是 14 GB,无论换 read 还是 mmap 都装不下——差别只在于等 9 秒还是等 20 秒(按 mmap 冷路径的 718 MB/s 折算约 19.5 秒)。

并发决定单进程拿到的带宽。一块盘的顺序读带宽是共享的,N 个进程同时冷加载,单进程大致只有 1.5/N GB/s。这一条我没有在本机做并发实测,是按带宽守恒做的估算,但它对应的现象很常见:滚动发布时几个副本一起冷启动,一起把探针顶超时,然后一起被重启——重启之后缓存已经热了,于是「重试就好了」,问题被误判成偶发。

还有一层容易被忽略:写入侧的缓存也会骗人。文件刚写下去就立刻读,读到的可能是还没回写的脏页,计时会显著偏低。脚本里那句os.fsync(fd)不是装饰,少了它,冷路径的读数会掉到内存带宽那一档。

这份资料的用法:本文两个实测脚本、多次运行的原始读数,以及一份按「权重 GB ÷ 实测带宽」算预留秒数的表格,都放在资料包里,扫码即可获取:

上线清单:什么时候该预热,什么时候预热反而更慢

预热只对「冷盘 + 大文件 + 服务常驻」这一格有收益;对按需读部分张量的场景,预热是把用不到的字节也搬进内存,纯亏,还会把 RSS 顶上去。

场景判断依据动作预留时间
权重 ≥ 2 GB,每次发布都是新容器命中率为 0,冷读走满盘带宽启动时顺序read一遍,或对文件调POSIX_FADV_WILLNEED秒数 = 权重 GB ÷ 1.5;14 GB 约 9 秒
权重 < 512 MiB,进程常驻冷读 0.3 秒级(256 MiB 实测 0.16 秒)不预热,直接读0.5 秒以内
只用到一部分张量(多模型共用底座、分布式分片)有效读取量远小于文件大小mmap按需触碰,别全量预热按实际触碰比例折算
就绪探针超时 1 秒,而加载要 9 秒探针会把正常启动判成失败拆开存活检查与就绪检查,就绪项等预热完成探针超时 ≥ 加载时间 × 2
同一块盘上起 N 个 worker带宽被 N 除串行预热,或把权重分散到不同盘单进程秒数 × N
想用 mmap 逐页读来「预热」冷 mmap 356.6 毫秒,比read的 160.3 毫秒慢 2.2 倍预热就用顺序read,别用缺页——

落到可执行的动作只有三步。先用dd这类工具或本文第一个脚本,对自己的盘量一次顺序读带宽,得到一个 GB/s 的数(不要抄我这台的 1.6)。再用「权重 GB ÷ 实测带宽 × 1.5」算出预留秒数,乘 1.5 是给并发和抖动留的余量。最后拿这个秒数去对探针超时:装不下就改探针或在启动脚本里预热,装得下就什么都别做。

什么时候该停手:当你发现自己在调mmap的各种参数想「加快加载」时,回头看一眼前面的表——冷文件下 mmap 比read慢 2.2 倍。这条路能解决内存峰值和首字节延迟,解决不了磁盘带宽。

这份资料的用法:从量带宽、算预留秒数,到写进启动脚本和探针的完整示例,都收在资料包里,扫码即可获取:

附表 A:本文引用事实与出处对照表

事实出处(文档名 + 发布方 + 链接)本文位置
zero-copy 在 ML 里并不真正存在,磁盘到内存的搬运要花时间;只有在文件已进缓存时 CPU 上才算零拷贝safetensors README「Notes」,Hugging Face,https://github.com/huggingface/safetensors/blob/main/README.md第 4 章来源一
safetensors 对 zero-copy 的定义是「读这个文件是否比原文件占用更多内存」,衡量内存而非时间同上,README「Yet another format ?」表格下的图例第 4 章来源一
BLOOM 用该格式在 8 张卡上从 10 分钟降到 45 秒;README 自述「CPU 上多一次拷贝,本库可用 from_file 绕过」同上,README「Benefits」一节的 Lazy loading 与 Faster load第 4 章来源四
mmap=True时不做磁盘到 CPU 的存储拷贝,而是映射文件,张量在被访问时才惰性加载torch.load 文档,PyTorch,https://pytorch.org/docs/stable/generated/torch.load.html第 3 章、第 4 章来源二
分片检查点的主要优势是逐片加载,把内存占用压到「模型大小 + 最大分片」Big model loading 文档「Sharded checkpoints」,Hugging Face,https://huggingface.co/docs/transformers/main/en/big_models第 4 章来源三
POSIX_FADV_WILLNEED会发起一次非阻塞读,把指定区域读进页缓存;POSIX_FADV_DONTNEED尝试释放该区域的缓存页posix_fadvise(2) 手册页,Linux man-pages 项目,https://man7.org/linux/man-pages/man2/posix_fadvise.2.html第 6 章清单第 1 行
F_NOCACHE的作用只有「关闭/开启数据缓存」,原文未限定读写路径fcntl(2) 手册页(macOS),manpagez 镜像,https://www.manpagez.com/man/2/fcntl/第 1 章
访问超出映射文件末尾的页会收到 SIGBUS;一个映射里哪些页当前驻留页缓存,可用mincore查mmap(2) 手册页,Linux man-pages 项目,https://man7.org/linux/man-pages/man2/mmap.2.html第 3 章末

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:






添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

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

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

立即咨询