mmap 内存映射:让 17.66GB 模型在 16GB RK3588 上稳定推理

发布时间:2026/9/19 12:26:26
mmap 内存映射:让 17.66GB 模型在 16GB RK3588 上稳定推理 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 到底做了什么mmapmemory 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 或 LPDDR5CPU 是 4 核 A76 4 核 A55 的大小核架构NPU 算力 6 TOPS。这块板子的定位是边缘计算存储通常是 eMMC 或者外挂 NVMe SSD。这里有个硬件层面的现实eMMC 的随机读性能其实不差顺序读能到 200 到 300 MB/s随机 4K 读虽然弱一些但对于 mmap 的按页加载来说只要页命中率够高实际体验是可以接受的。如果板子上挂了 NVMe那更是绰绰有余随机读能到几十万 IOPSmmap 的缺页开销几乎可以忽略。所以 RK3588 这套组合天然适合 mmap 方案内存不大但够用存储够快NPU 和 CPU 都能吃这套加载方式。这也是为什么最近 RK3588 部署大模型、部署 YOLOv8、跑视觉 SLAM 的讨论里mmap 出现的频率越来越高。3. 核心机制拆解mmap 加载模型的完整链路3.1 从文件到虚拟地址映射是怎么建立的先看最基础的映射调用。在 C/C 里核心就是一行mmapint 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 触发缺页异常控制权交给内核的缺页处理程序。内核做几件事判断这个地址属于哪个映射区域VMAvirtual 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_fltmajor fault 次数这个数增长快说明频繁从磁盘读性能瓶颈在存储。如果稳定在一个低水平说明活跃页命中率高方案生效。Cachedpage 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 杀掉峰值内存超限dmesggrep -i oom推理速度极慢major fault 频繁cat /proc/pid/stat看 maj_flt优化访问模式加MADV_SEQUENTIAL内存占用持续增长page cache 未回收free -h看 Cached调低 swappiness主动MADV_DONTNEEDmmap 返回 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 层的时候提前把第 N1 层的页读进来用计算时间掩盖 IO 时间。这需要框架层面的配合但收益明显。方向三混合加载。把最常访问的权重比如 embedding 层常驻内存不常访问的权重走 mmap。这样兼顾了性能和内存。方向四利用 NPU 的零拷贝。RK3588 的 NPU 支持从特定内存区域直接读数据如果能和 mmap 结合减少一次内存拷贝性能还能再提。6.3 关于 RK3588 生态的一点个人观察最近 RK3588 相关的讨论里部署大模型、跑视觉 SLAM、做边缘推理的需求明显增多。这块板子的性价比确实高6 TOPS 的 NPU 加上不错的内存带宽在边缘端很有竞争力。但生态上还有些不成熟的地方比如 RKNN 对动态 shape 的支持、对大模型分片加载的支持都还在完善中。我的建议是如果你的模型是给 NPU 跑的优先用官方工具链别自己造轮子如果是给 CPU 跑的mmap 这套方案可以放心用可控性高坑也相对少。两条路各有适用场景别硬套。最后分享一个我在实际部署中养成的习惯每次上板子之前先在开发机上用strace跟一遍模型的文件访问模式看看是顺序读还是随机读活跃集大概多大。这个信息能帮你提前判断 mmap 方案是否可行省去很多上板调试的时间。踩过几次坑之后我现在拿到任何模型第一件事就是看它的访问模式而不是急着写加载代码。