ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Mac统一内存跑多模型:七个大坑与完整修复实战

Mac统一内存跑多模型:七个大坑与完整修复实战 事情得从我把 70B 对话大模型、BGE 向量模型、Whisper 语音转写、FLUX 出图模型和一个 7B 代码模型同时塞进同一台 128G 统一内存的 Mac 开始说起。听起来内存管够但真正跑起来才发现统一内存的“大容量”和传统显存的“私有容量”完全不是一回事。为了把这五个模型安排明白我手写了一个本地模型管理器结果前前后后踩了七个大坑每一个坑都值得单独拿出来说。这篇就当作一个实战记录你会看到我为什么写这个管理器统一内存场景下内存和带宽该怎么算账以及那七个坑的完整排查链路和最终修复方案。不管你是想在 Mac 上本地跑多模型服务还是只是被“128G 统一内存”的宣传吸引了这篇应该能帮你少走不少弯路。1. 为什么我在 M 系列 Mac 上写了一个本地模型管理器1.1 一个把五个模型同时常驻的工作场景我的日常任务基本可以拆成五块长文写作和对话要靠一个大模型RAG 检索需要 embedding 模型会议录音要转文字写代码时需要一个响应快的代码模型偶尔还要出图。于是我把它们排成了一桌模型用途运行框架稳定时物理占用70B Quant 主力模型长文生成、对话MLX38~42G7B 代码模型代码补全、工具调用llama.cpp约 5GBGE-M3RAG 向量化PyTorch MPS约 2.5GWhisper large-v3音频转写whisper.cpp约 3GFLUX.1-dev图像生成ComfyUI / PyTorch约 12G峰值更高每个模型单独跑都没问题但五份服务一起开我很快就发现三件事第一系统内存明明还剩不少却开始频繁压缩页面UI 卡顿肉眼可见第二每个服务各自为政谁也不知道别人占了多少内存更不知道什么时候能释放第三一旦某个模型的内存缓存异常膨胀我完全没法精准定位是谁干的。所以我决定写一个管理器统一管理这些模型的进程生命周期、内存占用和请求调度。这个管理器不是要替代 Ollama 这种现成工具而是要解决一个 Ollama 管不了的问题多种异构推理框架、多个不同用途的模型在同一块统一内存上共存时谁来排优先级、谁来记账、谁来控制驱逐。1.2 统一内存的“容量大”和“带宽窄”是两回事M 系列芯片上的统一内存本质是 CPU 和 GPU 共享同一个物理内存池。好处很直接没有显存和内存的物理边界模型权重想放多少放多少。但坏处很多人没意识到——所有计算单元共享同一条内存带宽。打个比方传统 CPU独显的机器像两个独立仓库各进各的货统一内存是一个超大仓库存储容量巨大但所有搬运都走同一条传送带。大模型推理恰恰是典型的“搬运密集型”任务每生成一个 token都要把模型权重从头到尾读一遍。一个 40G 的模型在 400GB/s 的理论带宽下单次读取权重需要约 0.1 秒所以单模型解码速度上限就是 10 token/s 左右。五个模型一起跑传送带是唯一的总吞吐不会变大只会互相抢。这就是我后来踩第三个坑的理论基础。1.3 管理器到底帮我管什么我最终把管理器的职责收敛成三块。内存核算搞清楚每个模型进程真正占了多少物理内存而不是看ps的 RSS 自欺欺人。这需要引入 macOS 的 footprint 指标后面专门讲。状态调度每个模型进程在“加载中、空闲、推理中、待驱逐、已退出”之间切换由管理器统一决定何时加载、何时驱逐而不是让每个服务自己乱来。请求路由所有外部调用走管理器的统一网关由网关把请求转发到正确的模型进程同时控制并发避免多个大模型同时推理时互相踩踏带宽。架构上我选了“管理器 多子进程”的方式管理器是一个 Python 3.11 写的 asyncio 服务子进程是各推理框架自己的服务进程。管理器和子进程之间通过 HTTP 通信状态记录在 SQLite 里。这个选择本身也踩了不少坑后面会细说。2. 管理器设计多进程隔离、内存盘点与租约调度2.1 为什么不用一个进程把所有模型都 load 进来第一个设计决策就是绝对不把所有模型塞进同一个 Python 进程。理由很实际。MLX、PyTorch MPS、llama.cpp、ComfyUI 的 Python 依赖完全不是一套生态。硬塞进一个进程光是 libomp、protobuf、OpenMP 线程库的符号冲突就能让你从早折腾到晚。就算装成功任何一个框架的缓存池污染都会把其他模型的显存分配拖下水。多个进程隔离后ComfyUI 崩了我的 RAG 服务还活着这个收益在长跑中非常值钱。代价也要坦白说每个推理框架各自维护一套内存缓存池统一内存里确实会多出一些不可共享的缓存冗余。但相对于管理复杂度这个代价完全可接受。2.2 内存盘点从 RSS 到 footprint 的账本传统 Linux 服务器上大家习惯了看 RSS但在 macOS 上直接看 RSS 会吃大亏。ps的 RSS 是“进程映射的所有物理页”它包含文件映射的页缓存而这些页在内存压力下是可以被系统回收的。尤其是 llama.cpp 默认用 mmap 方式加载 GGUF虚拟地址空间很大RSS 看起来虚高实际物理压力却没那么大。我最终采用的核算命令是这些# 系统级别内存压力输出 level 为 ok / warning / critical memory_pressure -Q # swap 用量 sysctl vm.swapusage # 单个进程的物理内存占用看 phys_footprint 字段 footprint -p 12345 # 传统方法只用于快速对照 ps -o pid,rss,vsz,comm -p 12345footprint输出的phys_footprint才是 macOS 用来判断进程真实物理内存占用的指标它会把页缓存、压缩内存这些因素尽量排除掉。后面踩第二个坑时我就是靠它救回来的。2.3 调度策略LRU 驱逐 租约锁定管理器最关键的部分是调度。最开始我幼稚地写了个 LRU谁最久没用就驱逐谁。这个方案在只有一个服务说话的时候没问题但多服务并发时直接爆雷——我驱逐了一个看起来“空闲”的模型结果它的推理请求还在路上进程被 kill请求直接全挂。这就是第五个坑。后来我改成“租约”模型每个请求从网关注册一个租约推理结束后释放。只有租约数为 0且空闲时间超过阈值的进程才允许被驱逐。数据库里每个模型进程的状态就长这样CREATE TABLE model_registry ( name TEXT PRIMARY KEY, proc_pid INTEGER, status TEXT, peak_footprint INTEGER, idle_since REAL, lease_count INTEGER DEFAULT 0, start_command TEXT );驱逐时先发 SIGTERM给进程 10 秒自己清理超时再 SIGKILL。这套逻辑最终扛住了多请求并发也是整个管理器从“玩具”走向“可用”的关键一步。3. 坑一MPS 缓存不释放内存明明够却被 OOM3.1 现象RAG 服务和 FLUX 出图服务跑了一阵之后PyTorch 进程直接抛 OutOfMemory。但诡异的是系统内存明明还有三四十 G 空闲vm_stat显示的内存压力也没到 warning 级别。我一度以为是模型加载太大后来发现根本不是容量不够是 PyTorch 的 MPS 后端在骗我。3.2 根因PyTorch 的 MPS 后端有一个 caching allocatorTensor 被释放后GPU 内存不会立刻还给系统池而是留在自己的缓存池里备用。问题在于某些版本下这个缓存池只扩不缩内存碎片越积越多。系统层面明明还有大把物理内存但 MPS 进程自己认为“可分配空间已经用完”于是抛 OOM。本质上就是两层账本对不上系统记账系统框架记账框架。我只看系统空闲PyTorch 只看自己的缓存池。3.3 解决修复分两步。第一步设置 MPS 后端的水位阈值让缓存池达到上限后主动收缩export PYTORCH_MPS_HIGH_WATERMARK_RATIO0.4 export PYTORCH_MPS_LOW_WATERMARK_RATIO0.2HIGH_WATERMARK_RATIO控制缓存池能涨到多高LOW_WATERMARK_RATIO控制降到多低。默认是 1.0也就是几乎不主动收缩调整后缓存会激进得多。第二步在每次推理循环后主动清理import torch torch.mps.synchronize() torch.mps.empty_cache()对于 embedding 这种高频小请求服务我后来还在管理器里加了周期性重启策略彻底把缓存池重新归零。这个坑给我的教训是想在统一内存上管多模型不仅要看系统空闲内存还要看每个框架自己的内存账本。4. 坑二llama.cpp 的 mmap 让 RSS 虚高内存统计被带偏4.1 现象管理器上线第二天调度器疯了。它看到 llama.cpp 的 7B 代码模型进程 RSS 高达 5G加上 70B 模型的 40G还有 FLUX 的 12G觉得内存要爆于是开始疯狂驱逐模型。但实际系统内存压力一直正常memory_pressure -Q输出的始终是 ok。我一度怀疑是 macOS 的统计工具坏了后来才明白是我的统计维度用错了。4.2 根因分析llama.cpp 默认通过 mmap 加载 GGUF意思是把文件直接映射进进程地址空间。这些映射页会算进 RSS但它们本质上是文件页缓存系统在内存压力下完全可以回收。更麻烦的是如果两个 llama.cpp 进程加载同一个 GGUF物理页还能共享RSS 却会各自加一遍导致总账看起来莫名其妙。如果用 RSS 总和来决定驱逐策略结果一定是“假想内存不够”在最不该驱逐的时候把模型杀掉了。4.3 修正方案我做了三件事。第一所有模型进程启动时尽量用 footprint 的 phys_footprint 作为内存核算基准不再信任 RSS 总和。第二在核算公式里增加固定缓冲模型真实占用 ≈ footprint 输出的 phys_footprint × 1.05多出来的 5% 是给框架缓存和碎片留的余量。第三针对 llama.cpp我明确区分两种情况用--no-mmap启动时模型权重会拷贝进匿名内存RSS 可信但加载慢保持 mmap 时RSS 可能虚高但系统可回收。管理器内部对每个模型记账时会记录它启动参数里的 mmap 模式再决定按 RSS 还是 footprint 核算。下面是同一个 7B 模型在两种模式下的对照当时给我留下很深印象统计方式mmap 模式 RSSno-mmap 模式 RSSfootprint phys_footprint数值5.2G5.1G4.8G系统内存压力变化无明显压力略微上升参考能否自行回收可以不可以准确反映保留量5. 坑三统一内存带宽共享五个模型并行推理互相踩踏5.1 现象管理器把所有服务都拉起来后并发控制还没写我天真地以为 128G 内存足够大五个模型同时推理毫无压力。结果实测下来主力模型单跑有 12 token/s同时让 FLUX 出一张图主力模型直接掉到 4 token/s再叠加一个 Whisper 转写主力模型掉到 2.3 token/sFLUX 出图也肉眼可见地变慢。每个模型都没“死”但综合吞吐低得离谱。5.2 根因这就是 1.2 节说的带宽问题。大模型 decode 属于带宽密集任务每个 token 都要把模型权重读一遍。五个模型并行推理相当于五路请求同时抢一条内存总线谁的权重大谁被拖得越惨。传统显存架构下GPU 有自己的私有显存带宽CPU 内存带宽再紧张也不至于影响模型推理。但统一内存完全没有这个隔离带宽就是公共水管。5.3 解决方案管理器里加了一个“推理互斥阀”在同一时刻只允许一个重量级模型权重大于 30G做推理。其他模型请求排队等待。轻量任务比如 embedding因为权重小、读取量低直接放行不影响大局。调度核心长这样import asyncio global_infer_lock asyncio.Lock() async def run_inference(model_name: str, weight_gb: float, request): if weight_gb 30: async with global_infer_lock: return await do_request(model_name, request) else: return await do_request(model_name, request)加了这个互斥阀之后主力模型重新回到了 11~12 token/sFLUX 出图排队时稍慢但整体吞吐和响应时间都可接受。这个设计的核心思想是容量可以共享带宽必须排队。6. 坑四macOS 的 App Nap 和内存压缩把模型冻住了6.1 现象管理器本身是个后台服务没有窗口。Mac 用着用着我发现某些模型首次请求巨慢原来一个 embedding 请求 300ms闲置 10 分钟后变成 8 秒Whisper 闲置后第一个转写请求甚至花了接近一分钟。CPU 在狂转GPU 却没什么动静。我当时第一反应是网络或网关出问题了但单独 curl 子进程端口响应照样慢。6.2 根因这里有两个 macOS 机制在捣乱。一是 App Nap。macOS 对后台无 UI 进程会降低定时器频率、限制 IO 和 GPU 调用频率。对于模型服务这种“平时很闲、来活必须秒回”的程序App Nap 简直是灾难。二是内存压缩。macOS 在内存压力下不会直接把非活跃页面清掉而是压缩它们。模型权重被压缩后一旦需要推理就要现场解压。70B 模型的权重解压CPU 直接被打满几分钟。这两个机制叠加就是“模型明明还活着却像被冻住了一样”。6.3 修复修复的关键是让管理器告诉 macOS我是重要的后台计算任务不要 App Nap 我。我在管理器主进程和所有子进程初始化时通过 PyObjC 绑定 NSProcessInfo 的活动 tokenfrom Foundation import NSProcessInfo info NSProcessInfo.processInfo() token info.beginActivityWithOptions( NSActivityUserInitiated | NSActivityLatencyCritical, reasonkeep model services hot ) # 进程结束前记得释放 # info.endActivity(token)同时对 70B 这种主力模型我用--mlock把权重锁定在物理内存防止它被系统换出或压缩。代价是这部分内存不能被系统回收但换来的是响应时间的稳定。这个坑还有一条血泪经验不要等活动来了才发现内存已经被压缩要主动监控memory_pressure -Q和 swap 用量一旦指标越线立刻启动驱逐流程。7. 坑五到坑七驱逐杀进程、swap 拖垮推理、冷启动风暴7.1 坑五LRU 驱逐杀掉了还在推理的进程这个坑前面提过。第一版 LRU 驱逐只看“上次请求结束时间”结果一个模型进程 idle 超过 60 秒但它某个长请求的响应还挂在网上等着返回。管理器一杀调用方直接收到连接重置。修复就是租约机制。每个活跃请求对应一个 lease请求结束才释放。驱逐前必须满足两个条件lease_count 0且idle_since超过阈值。驱逐时先 SIGTERM等 10 秒再检查进程是否退出超时了才 SIGKILL。这套机制上线后我再没遇到过“杀错进程”的事。7.2 坑六swap 之后推理速度崩了某天机器跑了很重的多任务我看了眼sysctl vm.swapusageswapused 已经 20G。那时主力模型响应从 10 token/s 直接掉到 0.5 token/s基本属于不可用状态。排查下来是系统内存不够时macOS 把不活跃模型的权重换到了磁盘。一旦推理请求过来要把几十 G 的权重从磁盘读回内存而我这台机器的内部盘虽然很快但几十 GB 的读回也要好几分钟。这段时间里模型就像卡死了一样。修复策略是内存水位管理物理内存使用率到 75% 时管理器开始驱逐“可驱逐”的模型。到 90% 时立刻驱逐所有非活跃租约模型。轮询监控vm.swapusage只要 swapused 超过 10G就强制把最小的模型驱逐。运行稳定后我把内存预算压到物理内存的 85% 以内swap 基本没再出现过。7.3 坑七冷启动加载风暴管理器重启之后我写了个“一次性拉起全部模型”的启动逻辑结果五个模型同时开始读磁盘里的权重文件。SSD 并发读取被打满系统 UI 卡死模型加载时间反而比单独加载更慢。更讽刺的是加载完第一个模型后想推理却发现后面几个模型还在排队读盘前几个模型又因为吃内存被系统压缩了。修复也简单加载队列化同一时间只允许一个模型加载按优先级排队。比如主力模型优先embedding 第二FLUX 最后。同时把每个模型的冷启动耗时记到 SQLite 里后续调度时做预热预估如果预测到马上会用到某个模型就提前开始加载而不是等到请求到了才冷启动。7.4 三个坑其实是联动的驱逐、swap、冷启动是相互影响的。加载新模型前必须等被驱逐进程真正退出、内存水位降到安全线以下再开始。不然就会一边驱逐旧模型一边加载新模型SSD 在读、内存压力不减最终又一次滑入 swap 灾难。所以我后来把所有动作串成一个状态机不允许同时出现“驱逐中 加载中”两个动作。哪怕调用方排队多等几秒也比内存状态混乱好得多。8. 最终修复对照表与一套可复制的监控脚本8.1 七个坑修复对照表每个坑的具体修复方案我总结成了这张表贴在这里给有需要的人直接抄作业坑根因修复检测命令MPS 缓存不释放框架缓存池只扩不缩设置水位环境变量 empty_cache观察 PyTorch OOM 与系统内存压力RSS 虚高mmap 页缓存计入 RSS改用 footprint 核算footprint -p PID带宽共享多模型并行抢占带宽推理互斥阀 重量级串行powermetrics gpu_powerApp Nap 冻结系统限制后台进程 IO/GPU绑定 NSProcessInfo 活动 token观察闲置后首请求延迟驱逐误杀只看了空闲时间没看活跃请求租约机制 SIGTERM 等待请求日志与进程退出日志swap 拖垮权重被换到磁盘75% 水位驱逐 mlocksysctl vm.swapusage冷启动风暴并发加载打满 SSD加载串行化 预热预估观察加载耗时与 IO 压力8.2 我日常用的监控脚本管理器跑起来后我每天会用这个脚本扫一遍内存账本#!/bin/bash while true; do echo memory pressure memory_pressure -Q echo swap usage sysctl vm.swapusage for pid in $(pgrep -f llama-server|mlx_lm|whisper|comfy|embed); do echo PID $pid: footprint -p $pid 2/dev/null | grep -E phys_footprint|compressed done sleep 5 done这个脚本的价值不在于好看而在于把每个模型进程的真实物理占用、压缩内存和系统压力拉到同一个屏幕上。出问题时第一眼就能判断到底是容量不够、带宽争抢还是框架缓存异常。8.3 请把“内存账本”当成项目的最高优先级我最大的感受是在统一内存上做多模型管理网络路由和调度策略都不是最难的最难的是账本一致。系统有一套账PyTorch 有一套账mlx 有一套账llama.cpp 因为 mmap 又有一套账。如果管理器自己的账本也是乱的后面所有调度决策都是错的。所以我的建议是动手写调度逻辑之前先把footprint和memory_pressure这几个命令的读数机制彻底搞清楚否则后面每个坑都会重复踩。8.4 还能继续扩展的方向这个管理器目前已经能稳定跑我日常的五模型组合。后续我打算把统一网关做成 OpenAI 兼容 API这样本地跑着的若干模型可以无缝给 OpenWebUI、Claude Code 这类前端调用。还可以按任务语义做路由比如代码问题自动走 7B 代码模型长文生成自动走 70B 主力模型embedding 只负责 RAG 检索。另外针对“128G 统一内存”这种环境我觉得未来还能加一个智能预热模块根据历史请求规律预测下一个要唤醒的模型提前把它从磁盘加载进内存。这样才能真正把统一内存的容量优势发挥出来而不是在冷启动上浪费时间。最后说一个我的个人判断128G 统一内存的容量确实很诱人但它不是“128G 显存”的替代品。它是一块需要精细管理的内存带宽有限、压缩机制会捣乱、框架缓存又各有脾气。如果只是插上五六个服务随便跑跑步半小时修坑两星期是常有的事。上面这七个坑是我真实踩出来的希望能帮你绕开它们。
返回列表