Apple Silicon 内存优化指南
在 512GB 统一内存上跑 456B 参数模型的内存管理策略
统一内存架构
Apple Silicon 的核心优势 —— CPU 和 GPU 共享同一块物理内存,零拷贝。但这也意味着:
┌────────────────── 512GB 统一内存 ──────────────────┐
│ │
│ CPU 使用 GPU 使用 共享区域 │
│ ├─ macOS ├─ Metal ├─ 模型权重 (mmap) │
│ ├─ Apps ├─ Buffers ├─ KV Cache │
│ └─ ... └─ ... └─ ... │
│ │
│ ⚠️ 一方吃太多 → 全部遭殃 │
└─────────────────────────────────────────────────────┘
GPU 可用上限:约为总内存的 75%,即 ~384GB。超过这个值,系统会回退到 CPU + SSD swap,性能骤降 200 倍。
vm_stat 解读
macOS 的内存分为四种状态:
vm_stat
# Pages free: xxx
# Pages active: xxx
# Pages inactive: xxx
# Pages wired down: xxx| 状态 | 含义 | 大模型场景 |
|---|---|---|
| Wired | 内核 + Metal GPU buffer,不可换出 | 模型权重的 GPU 副本 (~234GB for 8-bit) |
| Active | 正在使用的内存页 | 应用程序 + 推理计算 |
| Inactive | 最近未使用的 file-backed 缓存 | mmap 映射的模型文件 |
| Free | 完全空闲 | 可用于新分配 |
关键公式
Available = Free + Inactive
Inactive 内存可以被立即回收,所以 Available 才是真正可用的内存。不要只看 Free。
M2.5 8-bit 稳态参考
| 状态 | 数值 |
|---|---|
| Wired | ~234 GB(模型 GPU buffer) |
| Active | ~20 GB(系统 + 应用) |
| Inactive | 变化(mmap 缓存) |
| Free | ~240 GB |
Wired vs Active vs Inactive
Wired 内存 —— 最重要的指标
Wired 内存 = 内核 + Metal GPU buffer。加载大模型后,Wired 会暴涨:
模型磁盘大小: 237 GB (safetensors 文件)
↓
mx.load() mmap: ~237 GB → Inactive(虚拟映射,按需加载)
↓
Metal GPU buffer: ~234 GB → Wired(不可换出!)
为什么磁盘 237GB 的模型占 234GB Wired?
因为 Metal 需要将权重拷贝到 GPU buffer 用于计算。这部分内存被锁定(wired),macOS 无法将其换出到 swap。
Inactive 不是浪费
mx.load() 使用 mmap 映射模型文件。这些页面在 Inactive 状态,但不是浪费 —— 它们是文件缓存,可以被立即回收给其他用途。
监控命令
# 快速查看
memory_pressure
# 详细状态
vm_stat | head -10
# 实时监控(NeoWatch)
# 访问 http://localhost:3939Metal GPU Buffer
为什么大模型占这么多 GPU 内存?
MLX 将模型权重加载到 Metal GPU buffer 进行推理:
safetensors 文件 → mmap 映射 → Metal buffer (GPU)
↓
推理计算在这里进行
(矩阵乘法、attention)
Metal buffer 特点:
- 分配在统一内存中,但被标记为 GPU 独占
- 属于 Wired 内存,不可被 swap 或压缩
- 只有进程退出后才会释放
释放时序
这是导致 OOM Panic 的根本原因:
pkill mlx_lm.server
↓ 进程收到信号
↓ Python 开始清理
↓ Metal buffer 逐步释放 (10-30 秒)
↓ mmap 页面回收 (几秒)
↓ 内存完全释放
关键:pkill 之后必须等待进程完全退出(轮询 pgrep),不能只 sleep 2。
OOM 预防策略
1. 不要同时运行两个大模型
M2.5 8-bit: 270 GB 运行时
M2.5 4-bit: 150 GB 运行时
两者同时: 420 GB → 超出 512GB → Panic
使用 ai-switch 切换模型,它会:
launchctl unload停止旧服务- 轮询等待进程完全退出
- 检查内存空闲百分比
- 更新 plist 模型路径
launchctl load启动新服务
2. 设置 KV Cache 上限
长对话的 KV cache 会持续膨胀。oMLX 的 SSD Paged Cache 解决了这个问题:
- KV cache 自动持久化到 SSD
- prefix 复用减少重复计算
- 内存不够时自动驱逐
3. LaunchAgent ThrottleInterval
<key>ThrottleInterval</key>
<integer>60</integer>崩溃后至少等 60 秒再重启。防止崩溃循环把内存压爆。
4. 内存压力监控
# 检查内存压力
memory_pressure
# 结果解读
# "The system has X free percentage"
# < 30% → 危险,不要启动新服务
# 30-50% → 正常
# > 50% → 充足量化策略选择
| 量化 | 磁盘 | 运行时内存 | 速度 | 质量损失 | 推荐 |
|---|---|---|---|---|---|
| 8-bit | 237 GB | ~270 GB | ~25 tok/s | 基线 | 日常使用 |
| 4-bit | 120 GB | ~150 GB | ~51 tok/s | <1% | 多任务 / 快速迭代 |
| 3-bit | ~85 GB | ~110 GB | ~51 tok/s | 3-5% | 不推荐 |
M2.5 是 MoE 架构(456B 总参数,45.9B 激活)。MoE 对量化的敏感度比 dense 模型低,因为大部分 expert 在每次推理中不被激活。4-bit 是最佳平衡点。
进阶:Mixed Precision
对不同层使用不同量化精度:
# 概念示例
def mixed_quant(name, module):
if "embedding" in name:
return {"q_bits": 8} # 高精度
elif "experts" in name:
return {"q_bits": 3} # 激进量化
else:
return {"q_bits": 4} # 默认效果:从 230GB 进一步降到 180-200GB。但这是实验性的,日常不需要。
NeoWatch 内存监控
NeoWatch 是 AIOS 的监控仪表盘,实时追踪内存状态:
访问: http://localhost:3939
监控项:
- 统一内存使用(Wired / Active / Inactive / Free)
- GPU Metal buffer 大小
- swap 使用量(应该始终为 0)
- oMLX 推理速度(tok/s)
- 内存压力阈值告警(通过 Telegram 推送)
告警阈值
| 指标 | 警告 | 危险 |
|---|---|---|
| Free 内存 | < 50 GB | < 20 GB |
| Wired 内存 | > 300 GB | > 400 GB |
| Swap 使用 | > 0 | > 1 GB |
常见问题
Q: 为什么 Activity Monitor 显示的内存和 vm_stat 不一样?
Activity Monitor 的 "Memory Used" 包含了 Inactive,实际可用内存 = Free + Inactive。用 memory_pressure 看更准。
Q: 8-bit 和 4-bit 之间切换安全吗?
安全,但必须用 ai-switch。它会确保旧进程完全退出、内存完全释放后才启动新模型。
Q: 如果看到 swap 使用量增加怎么办?
说明内存不够了。检查是否有其他大型进程在运行。如果是模型本身导致的,考虑切换到 4-bit。
Q: Metal GPU Hang 怎么办?
GPU Hang 是 Metal 驱动层面的问题,和内存优化无关。唯一解决方案是重启机器。表现为 tok/s 骤降或推理卡死。
统一内存是双刃剑 —— 省去了 CPU↔GPU 数据传输,但也意味着一方出问题全部遭殃。监控和预防比事后修复更重要。