端侧 MoE 推理这两年从“能不能跑”逐渐卷到了“怎么跑好”,但我接触的多数团队在动手部署时,撞上的第一堵墙并不是算力,而是存储墙。一个 20B 总量、带 16 个专家的 MoE 模型,光权重在 FP16 下就要 40 多 GB,可端侧设备给到推理进程的显存往往只有 16GB 甚至 8GB,于是如何把模型塞进去、把每一字节安排明白,成了所有后续优化绕不开的前置条件。
这篇文章是模拟项目 X 的端到端落地记录,围绕显存账本精算、预取 I/O 重叠、量化补偿、异构流水线四条主线展开。我会把每个环节的计算口径、实现思路、踩过的坑全部摊开,适合正在做端侧 LLM 推理优化、准备把 MoE 模型压进终端设备的部署工程师,也适合想系统理解存储墙问题成因的同学。内容偏工程实践,尽量做到拿过来就能对着自己的项目改。
1. 显存账本精算:先把每一字节的去向数清楚
1.1 四大内存开销逐项拆解
MoE 和 dense 模型在显存构成上有本质差异。一个 dense 模型的内存大头就是权重、KV cache、激活,而 MoE 多了一个让所有人头疼的项:专家权重全集。
具体到端侧推理,显存消耗可以拆成四笔账:
- 共享层权重:包括 embedding、注意力、router、输出层。这部分每个 token 都会用到,必须常驻。
- 专家权重全集:虽然推理时每个 token 只激活 top-k 个专家(通常 2 到 4 个),但模型文件里所有专家的权重都存在。端侧显存有限时,选择只有两个:全部常驻,或者把一部分专家放到外部存储按需换入。
- KV cache:随序列长度线性增长,长上下文和批处理场景下增长非常快。
- 激活值与临时 workspace:prefill 阶段尤其明显,batch 越大越占。
先给公式。权重最好算:
W_bytes = (shared_params + expert_params × num_experts) × bits_per_param / 8
KV cache 的精确口径是:
KV_bytes = 2 × num_layers × num_kv_heads × head_dim × seq_len × kv_bytes_per_param
激活部分没有统一公式,可以按 batch_size × seq_len × hidden_dim × 2 bytes × layer 数做粗估,但要额外留 20% 左右的余量给框架内部缓冲。
以我这次的目标模型为例:一个共享层 4B、16 个专家每个 1.2B 的 MoE,总参数量 23.2B。FP16 全精度就是 46.4GB,直接不用想。做 INT4 weight-only 量化后,专家部分降到 9.6GB,共享层用 INT8 是 4GB,加起来 13.6GB。注意,这个数字还没算 KV cache 和激活,所以 16GB 设备已经非常极限,精确管理预算是后面一切动作的地基。
下面这段脚本我会在每个模型版本迭代时跑一遍,把所有候选精度配置的账都算好再拍板:
def moe_vram_budget(shared_b=4.0, expert_b=1.2, n_experts=16, resident_experts=8, seq_len=8192, s_bits=8, e_bits=4, kv_bits=16): # 权重部分:共享层 + 常驻专家 shared_gb = shared_b * s_bits / 8 expert_gb = expert_b * resident_experts * e_bits / 8 # KV cache 粗算:L=32 层、n_kv=8、head_dim=128 kv_gb = 2 * 32 * 8 * 128 * seq_len * kv_bits / 8 / 1e9 # 激活 workspace 预留 2GB,框架运行时留 0.5GB total = shared_gb + expert_gb + kv_gb + 2.0 + 0.5 return dict(shared=shared_gb, expert=expert_gb, kv=kv_gb, total=total)1.2 精度配置方案与 16GB 设备的预算对照
账算完了,还要看不同精度方案的组合是否真的放得下。我整理了三个候选方案做对照:
| 方案 | 共享层 | 专家 | 权重合计 | KV cache(8k) | 激活+运行时 | 总量 | 16GB 余量 |
|---|---|---|---|---|---|---|---|
| A | FP16 | INT4 | 17.6GB | 2.0GB | 2.5GB | 22.1GB | 严重超限 |
| B | INT8 | INT4 | 13.6GB | 2.0GB | 2.5GB | 18.1GB | 超限 2.1GB |
| C | INT8 | INT4,常驻 8/16 | 8.8GB | 2.0GB | 2.5GB | 13.3GB | 约 2.7GB |
方案 C 是最终采用的配置:共享层全部常驻,16 个专家只把路由频率最高的 8 个常驻,剩下 8 个放到 SSD 上按需换入。KV cache 单路 8k 上下文按 2GB 预估,激活 workspace 统一预留 2GB,再算上框架自身的运行时开销,16GB 设备能留下约 2.7GB 安全区。
这里有一个差点让我翻车的细节:预算表一定要在动代码之前做,并且余量按“最坏情况”预留。我第一次上线时信心满满,结果长上下文一跑,KV cache 把最后那点余量吃穿,直接 OOM。后面加了动态 KV cache 预算,按实时序列长度回收,才把稳定性救回来。这类预算工具最好直接接入 CI,每次模型结构变更都自动重新算一遍,别靠人工记忆。
1.3 记完账之后的三层存储体系
预算算完,接下来是存储体系的分层设计。我按访问频率把权重分成了三层:
- T0 常驻显存:共享层 + 最热的 8 个专家 + KV cache,这是推理主路径,任何一次 miss 都是延迟灾难。
- T1 快速换入:剩余 8 个专家放在高速 SSD,根据路由频率动态换入换出,配合后面要讲的预取机制。
- T2 兜底:全量权重文件只存在于存储介质上,启动时只加载 T0 部分,绝不整模型加载。
这个分层的核心依据是:MoE 专家虽然多,但真实数据下的路由分布通常有很强的偏斜——可能有 80% 的 token 只落在一半专家上。“热专家常驻、冷专家走存储”本质上是拿统计规律换显存空间。
2. 预取 I/O 重叠:让存储延迟从加法变取最大值
2.1 I/O 为什么会成为硬瓶颈
当专家权重被放到 SSD 之后,第一个问题立刻冒出来:每层推理都要去读专家权重,decode 阶段一个 token 要串行过几十层,如果每层都先等 I/O 再计算,性能会直接崩掉。
举一个具体数字。在批处理 decode 场景下,一层里的多个 token 经常会路由到 8 个不同的专家,所以一次可能需要换入 8 个专家。每个 1.2B 的专家在 INT4 下约 600MB,8 个就是 4.8GB。按 2GB/s 的实际顺序读速度计算,一次完整换入耗时约 2.4 秒。而 decode 一层的计算通常只有几十毫秒。这个量级差距意味着:如果不做重叠,每层都要干等几十倍的 I/O 时间,整体完全不可用。
所以预取 I/O 重叠在这条技术路线上是刚需,不是可选项。核心思路一句话:计算第 N 层时,提前把第 N+1 层需要的专家从 SSD 读入缓冲;计算第 N+1 层时,再读第 N+2 层。I/O 和计算并行执行,端到端时间从“两者相加”变成“取两者最大值”。
2.2 双缓冲与独立 I/O 线程的落地实现
实现上我推荐双缓冲加独立 I/O 线程。双缓冲保证计算线程永远有一份“下一层已经就绪”的专家权重,不会出现计算完成了、数据还没到位的空窗。
骨架如下,这是我在模拟项目 X 里实际跑通的 C++ 伪代码结构:
// I/O 线程 + 计算线程,通过两个缓冲区交替工作 std::array<ExpertBuffer*, 2> bufs; int cur = 0; // I/O 线程:提前读入未来层的专家权重 void io_loop() { for (int layer = 0; layer < num_layers; ++layer) { auto next = bufs[1 - cur]; load_from_ssd(layer + prefetch_distance, next); // 提前读 notify_io_ready(layer + prefetch_distance); // 通知计算线程 } } // 计算线程:等 I/O 就绪后使用缓冲计算 void compute_loop() { for (int layer = 0; layer < num_layers; ++layer) { wait_io_ready(layer); compute_layer(bufs[cur], layer); // 使用当前缓冲计算 cur = 1 - cur; // 交换缓冲角色 } }落地时有三个关键点,都是从实际调试里总结出来的:
- prefetch_distance 建议从 2 开始调。距离太近覆盖不了 I/O 延迟,太远则多个层的专家同时驻留,内存占用会快速膨胀。我最后停在 2 这个值,3 时显存压力变大,收益反而下降。
- 缓冲必须放在计算单元能直接访问的内存区域(统一内存或 pinned 缓冲),把“SSD 到内存”和“内存到计算单元”两段拷贝合并,避免二次拷贝的额外开销。
- I/O 线程和计算线程之间用条件变量通信,别用轮询 sleep。轮询要么导致响应慢,要么白白烧电,在端侧设备上这两样都不可接受。
2.3 预取距离、带宽与隐藏上限的调优
预取不是万能的。我实测下来,重叠收益的上限取决于计算时间和 I/O 时间的关系:
- 计算时间大于 I/O 时间时,I/O 完全被隐藏,效果接近“零 I/O 开销”;
- I/O 时间远大于计算时间时(冷专家比例高、带宽不足),重叠顶多把延迟从 T_compute + T_io 降到 T_io,还是被存储带宽卡死。
所以要进一步做“专家地址映射表”:统计每个专家最近被路由的时间,把高频专家固定在 SSD 的高带宽顺序读区域,冷门专家放慢速区。这个映射定期更新一次,能明显减少随机读对顺序读带宽的拖累。在真实设备上,这个优化把平均每层 I/O 等待从 25ms 压到了 7ms 左右,重叠覆盖率从五成拉到八成以上。
3. 量化补偿:精度损失从哪里来,就回哪里去
3.1 精度损失的主要来源
压显存最直接的手段是量化。INT8 的共享层加 INT4 的专家权重是我们最终配置,但量化不是白吃的午餐。实际调下来,精度损失主要来自三个来源:
- 权重离群点:大模型权重里总有几个特别大的值,INT4 的表示范围有限,一截断就带崩一整组的量化尺度。
- 激活分布动态范围大:MoE 里不同专家对激活的敏感度不同,统一量化策略会让敏感专家的误差被放大。
- router 精度下降:router 输出来自共享层,如果共享层量化过狠,专家选择会出错,后续误差一路累积,越往后越难看。
这也是为什么“无脑全量 INT4”在 MoE 上通常不好使。mixed precision 不是可选项,是起步配置。
3.2 三套补偿手段的组合用法
补偿手段我按投入成本从轻到重排了三层:
第一层是混合精度,也是最基础的一层。共享层保持 INT8 或 FP16,专家才用 INT4。注意力和 router 对精度最敏感,这块不要贪。我们的最终配置是共享层 INT8、专家 INT4,如果评测发现 attention 层输出漂移明显,就单独把注意力层拉回 FP16,通常只多占 1GB 以内。
第二层是 scale 与 clip 的精细调校。对每个专家、每个权重 group 单独统计绝对值最大值并做平滑裁剪。这里推荐 activation-aware 的思路:用一组校准数据跑一遍推理,观察每个通道的激活幅度,优先保护激活幅度大的通道——把它们分到更高精度的 group,或者乘一个放大系数再量化。这个思路工程上类似 AWQ 的做法,但可以完全离线完成,不依赖训练框架。
第三层是量化感知微调,也是最后手段。离线补偿后评测分仍不达标时,才需要做低秩微调(LoRA 风格)。只加低秩适配器,不改变量化权重。MoE 的专家数量多,微调时不用全调,只调评测样本中高频路由到的那些专家,省下大量时间和算力。
3.3 量化评测的正确姿势
不要只看总体的 loss 或困惑度,那个数字太粗。我会拆成三份报告:
- 通用知识评测集(覆盖常识、推理、代码的题目),看平均分变化是否可接受。
- 长文本任务评测,重点盯长序列下掉点是否被放大。
- 单层敏感性分析:逐层把量化权重临时换回高精度,观察哪一层对精度影响最大。这个分析结果直接指导混合精度配置。
从实践数据来看,INT8+INT4 混合配置在我们目标模型上能做到平均掉点 1 到 2 个点以内,而纯 INT4 全量量化会掉 4 个点以上,差距非常明显。单层敏感性分析显示,敏感层集中在前几层和最后几层,这几层保持 INT8 就能补回一半以上的精度损失。
4. 异构流水线落地:CPU、GPU、NPU 各管一段
4.1 为什么是异构,而不是纯 GPU
端侧设备往往不只有 GPU。有些平台 GPU 算力强但显存有限,NPU 算力一般但能访问更宽的内存带宽,CPU 慢但什么都能兜底。异构流水线的价值在于把每段计算放到最划算的单元上:注意力这种每个 token 都要跑、并行度高的算子留给 GPU;专家 FFN 这种“批量密集但单个算子重”的活,放到带宽友好、能容忍异步执行的单元上。
我们这条流水线实际分三段:
- GPU:注意力、router、sampling,以及最后的 logits 输出。这是延迟关键路径,需要最强算力。
- NPU/CPU:专家 FFN 的批量执行,接收 GPU 送来的 token 向量和专家路由结果。它的延迟相对宽松,适合异步排队。
- 存储层:SSD 加双缓冲,为 NPU/CPU 侧的专家计算供数,就是第 2 章讲的那套机制。
4.2 生产者-消费者握手与共享内存设计
异构最怕的不是慢,是空等。三个单元之间必须有明确的生产者-消费者关系,我把它实现成两个队列和一个共享内存区:
- GPU 每层做完注意力,把(hidden_states, expert_ids, 层号)打包成一个任务塞进任务队列;
- NPU/CPU 从队列取任务,加载专家权重,算完 FFN 结果放进结果队列;
- GPU 等待结果队列非空后取回,继续下一层计算。
这个设计里有一个非常容易踩的坑:任务打包时的拷贝成本。hidden_states 如果每次从 GPU 显存拷到 CPU 内存,再拷回来,延迟直接翻倍。正确做法是申请一块两侧都能访问的共享内存(统一内存或 pinned 缓冲),GPU 写完、NPU 直接读,不做物理拷贝。这个优化实测省下的时间占到总时延的 10% 左右,属于性价比极高的一项改动。
struct ExpertTask { int layer; std::vector<uint16_t> expert_ids; // top-k 路由结果 float* hidden_states; // 指向共享内存 float* output_states; // 指向共享内存 }; // 双条件变量生产者-消费者队列 std::mutex mtx; std::condition_variable cv_has_task, cv_has_result; std::queue<ExpertTask> task_queue, result_queue;队列深度在实际运行中要设置上限并带背压逻辑。否则 GPU 生产任务的速度一旦超过 NPU 的消费速度,内存里的任务堆积就会把显存预算再次击穿——显存账本辛辛苦苦省下来的空间,会被队列缓冲悄悄吃掉。
4.3 实测耗时分布与瓶颈定位
拿最终跑通的配置说:16GB 统一内存设备,T0 常驻 8 个专家,T1 走 SSD 预取,共享层 INT8、专家 INT4,16 个专家选 2 个激活。连续输出 512 token 的测试里,端到端速度大约在 4 到 6 token/s。关键路径耗时按每 token 拆解如下:
- 注意力(GPU):约 8ms
- 专家计算(NPU/CPU):约 35ms
- 专家权重加载(SSD,平均):约 25ms,已和计算重叠
- 同步/打包开销:约 5ms
这份数据告诉我们:当前瓶颈已经转移到专家计算本身,SSD 加载的延迟被预取机制隐藏掉了七成以上。后续优化方向应该转向增加常驻专家数量,或者从算子层面压缩专家 FFN 的计算时间,而不是继续堆 I/O 相关的优化。这个判断非常重要,因为团队很容易在已经接近极限的 I/O 优化上继续投入,但实际收益早就递减了。
5. 上线路上的坑:排查清单与实测心得
5.1 高频问题速查表
把这次项目里遇到的高频问题整理成一张速查表,按“现象 - 排查方向 - 解决手段”的路径列出来:
| 现象 | 排查方向 | 解决手段 |
|---|---|---|
| 长上下文跑到一半 OOM | KV cache 未设动态上限 | 按实时 seq_len 动态回收 KV cache,预留 10% 安全区 |
| 预取后 I/O 仍卡顿 | prefetch_distance 太小 / 缓冲未 pinned | 调大距离到 2~3,改用 pinned 缓冲 |
| 量化后某个下游任务崩盘 | 该任务风格在校准集中占比低 | 扩充校准集,专项任务单独做一组校准 |
| 某个专家计算时间异常高 | 该专家权重落在慢速存储区 | 更新热专家映射,把高频专家搬到高带宽区 |
| 异构流水线 GPU 空转 | 队列消费端处理太慢 | 增大队列上限,给 NPU 侧加批以摊薄开销 |
| 显存估算正确但真机仍 OOM | 框架运行时显存池申请过大 | 显式配置 arena 大小,KV cache 单独划池子 |
两个最常被低估的问题单独展开说。第一个是“显存明明算好了,跑起来还是 OOM”——绝大多数原因是框架运行时内部的显存池没有按预估初始化,而是按“最大可能”申请。解决办法是显式配置 arena 大小和分配比例,给 KV cache 单独划一块池子,不要和权重抢同一个分配器。第二个是“预取逻辑正确但重叠率不到 50%”——排查下来是 SSD 读取没有按 block 对齐,小文件随机读把顺序读的优势毁了。把专家权重合并成一个大文件、按固定偏移读取,重叠率立刻拉到 80% 以上。
5.2 用时间换来的三条经验
显存账本这个表,每个模型版本更新都要重算一遍。我们吃过一次亏:模型从 16 专家涨到 20 专家,预算表没同步更新,某次评测直接跑挂。现在预算表挂在 CI 里,结构参数一变就自动重算并检查。
量化补偿里“先离线分析敏感层、再逐层调精度”的顺序一定不能反。先无脑全量 INT4 再靠后处理拉精度,收益远不如先把敏感层识别出来单独保护。前者是修补,后者是对症下药,成本差一个量级。
所有 I/O 相关参数都要用真实设备实测校准。模拟环境里 SSD 带宽很理想,真机上一堆后台进程抢 I/O,实测数值直接腰斩。我把所有预算公式里的带宽参数统一打六折再排期,之后再没出现过上线即超时的尴尬。
最后再分享一个小技巧:把预取、量化、异构流水线放进同一个“配置开关”体系。部署现场的设备差异太大,不可能一条配置走天下。把每层专家放显存还是 SSD、共享层用 INT8 还是 FP16、预取距离多大,全部做成启动参数,然后写一个自动扫参脚本对着评测集把所有组合跑一遍,最终选定“评测分达到底线、速度最快”的那组配置上线。这套流程比手工盲调靠谱得多,后续加新设备型号,跑一遍脚本就能出结论。