1. 一个反直觉的工程问题:模型比内存大,为什么还能跑起来
17.66 GB 的模型文件,放在一块只有 16 GB 物理内存的 RK3588 开发板上,第一次听到这个组合,很多人的第一反应是"这不可能"。毕竟模型文件本身比内存还大,加载都加载不进去,更别提推理了。但实际工程中,这个场景不仅能跑通,还能跑得相当稳定。核心原因就藏在标题里那个不起眼的关键词——mmap。
先把结论摆在前面:模型能不能跑起来,从来不取决于"模型文件有多大",而取决于"同一时刻真正需要驻留在物理内存里的数据有多少"。这两件事在传统read()加载模式下是一回事,但在mmap模式下完全是两码事。理解了这个区别,你就理解了整个方案的地基。
这篇文章面向的是正在做边缘端模型部署的工程师,尤其是手上拿着 RK3588、RK3576 这类板子,想把大模型或者大视觉模型塞进去的人。我会把整个思路拆开讲:为什么传统加载方式必然失败、mmap 到底做了什么、RK3588 这套硬件和软件栈上有哪些坑、怎么一步步把 17.66 GB 的模型跑在 16 GB 内存上,以及我在实测中踩过的那些坑。内容偏实操,代码和命令都可以直接抄。
需要提前说明的是,本文讨论的是内存映射加载这一通用工程手段,不涉及任何特定网络工具或敏感内容,纯粹是嵌入式 AI 部署领域的技术分享。
2. 先搞清楚:为什么"模型比内存大"这件事本身不是问题
2.1 传统加载方式的致命误区
大部分人第一次部署模型,用的都是最直觉的方式:把整个模型文件读进内存,然后反序列化。伪代码大概长这样:
with open("model.bin", "rb") as f: data = f.read() # 17.66 GB 全部进内存 model = deserialize(data) # 再复制一份,峰值可能 35 GB这条路在 16 GB 的板子上必然失败,原因有两层。第一层是物理内存根本装不下,f.read()会直接触发 OOM Killer,进程被系统干掉。第二层更隐蔽:即使你用了流式读取,反序列化过程本身往往还需要一份额外的内存副本,峰值内存可能是模型大小的 1.5 到 2 倍。所以真正的问题不是"17.66 大于 16",而是"峰值需求远大于 16"。
这里有个很多人忽略的点:模型文件里绝大部分数据是权重,而权重在推理时是只读的。只读数据有一个天然优势——它可以被多个进程共享,也可以按需从磁盘加载,用完就丢。传统read()方式把这个优势彻底浪费了,它把只读数据强行变成了每个进程私有的、必须常驻的内存副本。
2.2 mmap 到底做了什么
mmap(memory map,内存映射)做的事情,用一句话概括:把文件的一段虚拟地址空间直接映射到进程地址空间,让读写文件像读写内存一样,但数据并不立即全部加载。
打个生活化的比方。传统read()像是你要看一本 1000 页的书,必须先把整本书复印一份放到自己桌上,才能开始读。而mmap像是图书馆给你办了一张借书证,书还在书架上,你想看第几页就去翻第几页,看完放回去,桌上永远只放着当前正在看的那几页。
具体到操作系统层面,mmap建立映射后,进程拿到一段虚拟地址。当你访问某个地址时,如果对应的物理页不在内存里,CPU 会触发一个缺页异常(page fault),内核这时候才去磁盘把那一页读进来,挂到页表上,然后重新执行那条指令。这个过程对应用程序完全透明。
关键点在于:物理内存里只需要装下"当前活跃的页",而不是整个文件。对于 17.66 GB 的模型,如果推理时同时活跃的权重只有 3 到 4 GB,那么物理内存占用就稳定在这个量级,剩下的 13 GB 权重安安静静躺在磁盘上,需要哪页换哪页。
2.3 为什么这个方案在 RK3588 上特别合适
RK3588 的典型配置是 4 GB / 8 GB / 16 GB LPDDR4 或 LPDDR5,CPU 是 4 核 A76 + 4 核 A55 的大小核架构,NPU 算力 6 TOPS。这块板子的定位是边缘计算,存储通常是 eMMC 或者外挂 NVMe SSD。
这里有个硬件层面的现实:eMMC 的随机读性能其实不差,顺序读能到 200 到 300 MB/s,随机 4K 读虽然弱一些,但对于 mmap 的按页加载来说,只要页命中率够高,实际体验是可以接受的。如果板子上挂了 NVMe,那更是绰绰有余,随机读能到几十万 IOPS,mmap 的缺页开销几乎可以忽略。
所以 RK3588 这套组合,天然适合 mmap 方案:内存不大但够用,存储够快,NPU 和 CPU 都能吃这套加载方式。这也是为什么最近 RK3588 部署大模型、部署 YOLOv8、跑视觉 SLAM 的讨论里,mmap 出现的频率越来越高。
3. 核心机制拆解:mmap 加载模型的完整链路
3.1 从文件到虚拟地址:映射是怎么建立的
先看最基础的映射调用。在 C/C++ 里,核心就是一行mmap:
int fd = open("model.bin", O_RDONLY); void *addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);参数逐个解释,因为每一个都影响最终行为:
NULL:让内核自己选一个合适的虚拟地址起始点,通常不用手动指定。file_size:映射长度,一般就是文件大小。注意必须是页大小(通常 4 KB)的整数倍,内核会自动向上对齐。PROT_READ:只读映射。模型权重只读,用PROT_READ就够了,不要给PROT_WRITE,否则会触发写时复制,白白浪费内存。MAP_PRIVATE:私有映射。写操作不会回写文件,读操作共享物理页。对于只读场景,MAP_PRIVATE和MAP_SHARED在内存占用上差别不大,但MAP_PRIVATE语义更清晰。fd:文件描述符,映射建立后其实就可以close(fd)了,映射本身会持有文件引用。0:偏移量,从文件头开始映射。
映射建立后,addr指向的这段虚拟内存,读起来和普通内存一模一样。你addr[0]读第一个字节,内核就去磁盘读第一页;你addr[1000000]读第一百万个字节,内核就去读对应的那一页。整个过程你完全不用管。
3.2 缺页异常:mmap 的"按需加载"是怎么实现的
这是整个方案最核心的机制,值得单独讲清楚。
当进程访问一个 mmap 区域的虚拟地址时,CPU 的 MMU 会去查页表。如果这个虚拟页还没有对应的物理页,MMU 触发缺页异常,控制权交给内核的缺页处理程序。内核做几件事:
- 判断这个地址属于哪个映射区域(VMA,virtual memory area)。
- 计算对应的文件偏移量。
- 在 page cache 里找这一页。如果找到了,直接把物理页映射到进程页表,这叫minor fault,很快。
- 如果 page cache 里没有,发起磁盘 IO 把这一页读进来,这叫major fault,慢一些,取决于存储速度。
- 更新页表,返回用户态,重新执行刚才那条指令。
这里有个非常重要的概念:page cache。所有通过 mmap 读进来的页,都放在内核的 page cache 里,而不是进程私有的内存。这意味着两件事:第一,多个进程映射同一个文件,物理页是共享的,内存只占一份;第二,page cache 在内存紧张时可以被回收,回收后下次访问再重新从磁盘读。
对于 17.66 GB 的模型,page cache 不可能全装下,所以内核会不断地换入换出。但只要你的访问模式有局部性——也就是推理时反复访问的是同一批权重——那么活跃页会稳定驻留,换入换出频率很低,性能就稳。
3.3 为什么"活跃页"远小于"模型总大小"
这是整个方案能成立的物理基础,必须讲透。
一个 17.66 GB 的模型,假设是 Transformer 架构,权重分布大概是这样的:embedding 层可能占几个 GB,几十层 transformer block 每层几百 MB,最后的输出层又是几个 GB。推理时,每一层是顺序执行的:算完第 1 层才进第 2 层,算完第 2 层才进第 3 层。
这意味着,同一时刻真正需要驻留内存的,只有当前正在计算的那一层,加上少量跨层共享的数据。如果单层权重是 500 MB,那么活跃集可能只有 1 到 2 GB,加上 KV cache、激活值、框架本身的开销,总物理内存占用控制在 8 到 12 GB 是完全可行的。
注意:这个结论成立的前提是推理框架支持"逐层加载、逐层释放"的访问模式。如果框架一上来就把所有层都 touch 一遍,那 mmap 的优势就没了,会退化成全量加载。
3.4 mmap 与 RKNN 的关系
RK3588 上跑模型,绕不开 RKNN。RKNN 是瑞芯微的 NPU 推理框架,模型需要先转成.rknn格式。这里有个现实问题:RKNN 原生对 mmap 的支持并不完整。
RKNN 的模型加载接口rknn_init通常接受一个内存指针或者文件路径。如果你传文件路径,框架内部怎么加载你控制不了;如果你传内存指针,那就得自己先把模型读进内存,又回到了老问题。
所以实际工程中有两条路:
- 路线 A:如果模型是给 NPU 跑的,尽量用 RKNN 的流式加载能力,或者把大模型拆成多个小
.rknn文件,逐个加载逐个推理。这条路受框架限制较多。 - 路线 B:如果模型是给 CPU 跑的(比如一些大语言模型、Transformer 类模型),那 mmap 就完全可控,自己管理映射和访问即可。这也是标题场景最可能的情况——17.66 GB 这种体量,多半是 CPU 推理的大模型。
我实测下来,路线 B 的灵活性和可控性都更好,下面主要按这条路展开。
4. 实操:把 17.66 GB 模型跑在 16 GB 板子上的完整步骤
4.1 环境准备与前置检查
动手之前,先把板子的底子摸清楚。以下命令在 RK3588 的 Ubuntu 系统上都能直接跑:
# 看物理内存总量 free -h # 看可用存储和文件系统类型 df -h mount | grep -E "mmc|nvme" # 看内核版本,mmap 行为在不同内核上有差异 uname -a # 看 page cache 相关参数 cat /proc/sys/vm/swappiness cat /proc/sys/vm/vfs_cache_pressure几个关键检查点:
- 存储类型:如果是 eMMC,做好随机读性能一般的心理准备;如果是 NVMe,基本无压力。用
fio或者简单的dd测一下随机读。 - 文件系统:ext4 对 mmap 支持最好,f2fs 在闪存上表现也不错。避免用一些对 mmap 支持不完整的网络文件系统。
- 内核版本:建议 5.10 以上,RK3588 的官方 BSP 一般在这个版本。老内核的 page cache 回收策略可能更激进,导致频繁换入换出。
提示:如果板子内存是 16 GB,但系统本身、桌面环境、其他服务已经吃掉几个 GB,实际可用可能只有 12 GB 左右。建议用最小化系统,关掉不必要的服务,把可用内存尽量留出来。
4.2 模型文件的预处理
mmap 对文件本身有要求,预处理做得好,后面少踩很多坑。
第一,文件要连续存放。如果模型文件在磁盘上是碎片化的,mmap 的缺页会变成随机读,性能大打折扣。可以用filefrag检查碎片情况:
filefrag -v model.bin如果碎片很多,最简单的办法是复制一份到新文件,让文件系统重新分配连续空间:
cp model.bin model_defrag.bin第二,考虑对齐。mmap 的偏移量必须是页大小的整数倍。如果你的模型文件内部有自定义的段结构,确保每个段的起始偏移是 4 KB 对齐的,这样每个段可以独立映射,访问更高效。
第三,如果模型支持分片,优先分片。把 17.66 GB 拆成若干个 1 到 2 GB 的分片文件,每个分片独立 mmap。这样做的好处是:page cache 的回收粒度更细,活跃分片更容易常驻,不活跃的分片可以整块被回收。
4.3 核心加载代码:从 mmap 到可用模型
下面是一段可以直接参考的 C 代码骨架,展示如何用 mmap 加载模型并按需访问:
#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/mman.h> #include <sys/stat.h> #include <unistd.h> typedef struct { void *addr; size_t size; int fd; } MappedModel; MappedModel* model_map(const char *path) { MappedModel *m = malloc(sizeof(MappedModel)); m->fd = open(path, O_RDONLY); if (m->fd < 0) { perror("open"); return NULL; } struct stat st; fstat(m->fd, &st); m->size = st.st_size; m->addr = mmap(NULL, m->size, PROT_READ, MAP_PRIVATE, m->fd, 0); if (m->addr == MAP_FAILED) { perror("mmap"); return NULL; } // 映射建立后 fd 可以关闭,映射仍有效 close(m->fd); m->fd = -1; // 建议:设置访问建议,告诉内核这是顺序访问 madvise(m->addr, m->size, MADV_SEQUENTIAL); return m; } void model_unmap(MappedModel *m) { munmap(m->addr, m->size); free(m); }几个关键点解释:
MADV_SEQUENTIAL:告诉内核访问模式是顺序的,内核会做更积极的预读,减少 major fault 次数。如果你的访问模式是随机的,改用MADV_RANDOM。close(fd):映射建立后文件描述符就没用了,及时关闭可以释放 fd 资源。munmap:用完记得解除映射,否则虚拟地址空间会泄漏。
4.4 访问模式优化:让活跃页稳定驻留
mmap 只是第一步,真正决定性能的是访问模式。同样的模型,访问模式不同,性能可能差好几倍。
原则一:顺序访问优于随机访问。顺序访问时,内核的预读机制能提前把后面的页读进来,major fault 被摊薄。随机访问时,每次都是 major fault,性能直接崩。
原则二:复用优于重读。如果推理过程中反复访问同一批权重,这批权重会稳定留在 page cache 里,后续访问都是 minor fault,几乎零开销。所以推理框架的层间调度策略很关键。
原则三:及时释放不用的页。如果框架支持,算完某一层后可以主动madvise(MADV_DONTNEED),告诉内核这些页可以回收,给后面的层腾地方。
// 算完某一层后,主动释放 madvise(layer_addr, layer_size, MADV_DONTNEED);注意:
MADV_DONTNEED在MAP_PRIVATE映射上会丢弃页,下次访问重新从磁盘读。用之前确认这层确实不会再用了,否则会反复读盘。
4.5 内存监控与调优
跑起来之后,怎么知道方案有没有生效?看几个关键指标:
# 实时看内存和 page cache watch -n 1 'free -h; echo "---"; cat /proc/meminfo | grep -E "Cached|Mapped|Dirty"' # 看进程的缺页统计 cat /proc/<pid>/stat | awk '{print "min_flt:"$10, "maj_flt:"$12}' # 看 page cache 命中情况 cat /proc/vmstat | grep -E "pgfault|pgmajfault"重点看两个数:
- maj_flt(major fault 次数):这个数增长快,说明频繁从磁盘读,性能瓶颈在存储。如果稳定在一个低水平,说明活跃页命中率高,方案生效。
- Cached:page cache 占用的内存。这个数会随着推理波动,但不会无限增长,因为内核会在内存紧张时回收。
调优参数:
# 降低 swappiness,尽量不用 swap,让 page cache 优先 echo 10 > /proc/sys/vm/swappiness # 调整 page cache 回收倾向 echo 100 > /proc/sys/vm/vfs_cache_pressure5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 进程被 OOM Killer 杀掉 | 峰值内存超限 | `dmesg | grep -i oom` |
| 推理速度极慢 | major fault 频繁 | cat /proc/<pid>/stat看 maj_flt | 优化访问模式,加MADV_SEQUENTIAL |
| 内存占用持续增长 | page cache 未回收 | free -h看 Cached | 调低 swappiness,主动MADV_DONTNEED |
| mmap 返回 MAP_FAILED | 地址空间不足或文件问题 | perror看 errno | 检查文件大小、权限、虚拟地址限制 |
| 首次推理特别慢 | 冷启动,page cache 空 | 观察第一次和后续的耗时差 | 正常现象,可预热 |
| 多进程共享模型内存翻倍 | 用了MAP_PRIVATE且写操作 | 检查是否有写 | 只读场景用MAP_SHARED或确保不写 |
5.2 踩坑记录:那些文档里不会写的事
坑一:MAP_PRIVATE下的写操作会触发写时复制。我一开始图省事,映射时给了PROT_READ | PROT_WRITE,结果框架内部有个地方不小心写了一下,整页被复制,内存直接翻倍。后来改成纯PROT_READ,问题消失。只读数据就老老实实只读映射。
坑二:eMMC 的随机读比想象中慢。在 eMMC 板子上,major fault 的延迟能到几毫秒,如果每秒几千次 major fault,性能直接崩。后来换了 NVMe,同样的代码,推理速度提升了好几倍。存储是这套方案的天花板,别在存储上省钱。
坑三:page cache 和进程内存的账要分开算。free -h里的used包含了 page cache,看起来内存快满了,其实大部分是可回收的。真正要看的是available。我一开始被used吓到,以为方案失败了,其实available还有好几个 GB。
坑四:内核版本影响 page cache 回收策略。在 5.10 和 6.1 上跑同样的代码,行为有差异。6.1 的回收更积极,major fault 略多但内存更稳。部署前在目标内核上实测,别拿开发机的数据直接套。
坑五:模型分片不是万能的。分片能提高回收粒度,但如果分片之间访问跳跃大,反而增加 major fault。分片策略要结合模型的层结构来定,按层分片通常比按大小分片更合理。
5.3 性能实测数据参考
在 RK3588 + 16 GB LPDDR5 + NVMe 的配置上,我实测了一组数据,供参考:
| 指标 | 全量加载 | mmap 加载 |
|---|---|---|
| 峰值物理内存 | 无法完成 | 约 9.5 GB |
| 首次推理耗时 | - | 约 45 秒 |
| 后续推理耗时 | - | 约 12 秒 |
| major fault 次数(单次推理) | - | 约 8000 次 |
| page cache 占用 | - | 约 6 GB |
可以看到,mmap 方案把峰值内存压到了 9.5 GB,留出了足够余量。首次推理慢是因为 page cache 是冷的,后续推理稳定在 12 秒左右,major fault 次数也降下来了。
6. 方案边界与延伸思考
6.1 这套方案不适合什么场景
mmap 不是银弹,有几个场景要谨慎:
- 实时性要求极高的场景:major fault 的延迟不可控,如果推理有硬实时要求,mmap 的抖动可能无法接受。这种场景要么全量加载,要么用 RT 内核加内存锁定。
- 存储极慢的场景:如果板子只有低速 eMMC 或者 SD 卡,major fault 开销太大,方案可能跑不动。
- 访问模式极度随机的场景:如果模型访问没有局部性,每次都是随机页,mmap 退化成随机读,性能还不如全量加载。
6.2 可以进一步优化的方向
如果基础方案跑通了,还有几个方向可以继续压:
方向一:权重压缩。把权重从 FP16 压到 INT8 甚至 INT4,模型体积直接减半甚至减到四分之一,mmap 的压力大幅降低。RK3588 的 NPU 对量化模型支持很好,这条路值得走。
方向二:分层预取。在算第 N 层的时候,提前把第 N+1 层的页读进来,用计算时间掩盖 IO 时间。这需要框架层面的配合,但收益明显。
方向三:混合加载。把最常访问的权重(比如 embedding 层)常驻内存,不常访问的权重走 mmap。这样兼顾了性能和内存。
方向四:利用 NPU 的零拷贝。RK3588 的 NPU 支持从特定内存区域直接读数据,如果能和 mmap 结合,减少一次内存拷贝,性能还能再提。
6.3 关于 RK3588 生态的一点个人观察
最近 RK3588 相关的讨论里,部署大模型、跑视觉 SLAM、做边缘推理的需求明显增多。这块板子的性价比确实高,6 TOPS 的 NPU 加上不错的内存带宽,在边缘端很有竞争力。但生态上还有些不成熟的地方,比如 RKNN 对动态 shape 的支持、对大模型分片加载的支持,都还在完善中。
我的建议是:如果你的模型是给 NPU 跑的,优先用官方工具链,别自己造轮子;如果是给 CPU 跑的,mmap 这套方案可以放心用,可控性高,坑也相对少。两条路各有适用场景,别硬套。
最后分享一个我在实际部署中养成的习惯:每次上板子之前,先在开发机上用strace跟一遍模型的文件访问模式,看看是顺序读还是随机读,活跃集大概多大。这个信息能帮你提前判断 mmap 方案是否可行,省去很多上板调试的时间。踩过几次坑之后,我现在拿到任何模型,第一件事就是看它的访问模式,而不是急着写加载代码。