ik_llama.cpp 多 GPU 混合卸载调优实战:-ot 半层拆分下 TG 性能骤降的根因分析与 -fmoe 的正确用法
【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp
本指南以 ik_llama.cpp 仓库 Issue #521 的完整排查过程为骨架,深入剖析在 CUDA + CPU 混合环境下使用-ot(--override-tensor)按张量粒度做层内拆分时,为什么文本生成(TG)吞吐会从正常水平骤降到 1.5 t/s 左右,而提示处理(PP)却表现正常;并给出经实测验证的修复方案(关闭-fmoe或避免跨设备拆分 fused 张量对)以及-fmoe的正确使用建议。读完本文,你将掌握 MoE 大模型(以 DeepSeek 系列为例)在多卡环境下做细粒度张量卸载时的性能诊断方法,以及 fused MoE 优化的适用边界与注意事项。
一、问题背景:一场 7 卡混跑的 TG 性能灾难
1.1 复现环境
问题由用户Panchovix在一套异构 GPU 集群上复现,硬件环境如下:
- CPU:Ryzen 7 7800X3D
- 内存:192GB
- 操作系统:Fedora 41
- GPU(共 7 张,通过
./llama-server --list-devices列出):
ggml_cuda_init: found 7 CUDA devices: Device 0: NVIDIA GeForce RTX 5090, compute capability 12.0, VMM: yes Device 1: NVIDIA GeForce RTX 4090, compute capability 8.9, VMM: yes Device 2: NVIDIA GeForce RTX 4090, compute capability 8.9, VMM: yes Device 3: NVIDIA GeForce RTX 5090, compute capability 12.0, VMM: yes Device 4: NVIDIA GeForce RTX 3090, compute capability 8.6, VMM: yes Device 5: NVIDIA GeForce RTX 3090, compute capability 8.6, VMM: yes Device 6: NVIDIA RTX A6000, compute capability 8.6, VMM: yes测试模型为 DeepSeek-R1-0528 的 UD 量化版本(DeepSeek-R1-0528-UD-Q3_K_XL,分片 GGUF,共 7 个分片),运行于 ik_llama.cpp。
1.2 症状描述
使用llama-sweep-bench做基准测试时,配置了非常复杂的逐层逐张量卸载规则:前 35 个 decoder 层的 FFN 权重被拆到 7 张不同的 GPU 上,第 35、36 层则被拆成"半层"——即ffn_norm、ffn_gate_inp、ffn_gate_shexp、ffn_down_shexp、ffn_up_shexp以及ffn_gate_exps等张量被分散到 CUDA4 / CUDA5 两张卡上,剩余 FFN 权重留在 CPU。
结果非常反直觉:PP(提示处理)速度正常(约 150 t/s),但 TG(文本生成)速度暴跌到约 1.5 t/s——对于一个 671B 参数的 MoE 模型来说,这个数字连"能用"都算不上。而在同样的命令下,主线的 llama.cpp(llama-server)TG 却能保持 7 t/s 以上(尽管用户表示主线无法完整复现完全相同的 sweep-bench 设置,只能加载 server 独立测试)。
1.3 第一组对照实验
用户分别在 ik_llama.cpp 与主线 llama.cpp 上跑了几乎相同的命令,差异仅在于 ik 侧额外带了-mla 1与-fmoe:
ik_llama.cpp(llama-sweep-bench)实测结果:
main: n_kv_max = 16384, n_batch = 2048, n_ubatch = 2048, flash_attn = 1, n_gpu_layers = 999, n_threads = 8, n_threads_batch = 8 | PP | TG | N_KV | T_PP s | S_PP t/s | T_TG s | S_TG t/s | |-------|--------|--------|----------|----------|----------|----------| | 2048 | 512 | 0 | 13.489 | 151.83 | 326.413 | 1.57 | | 2048 | 512 | 2048 | 12.965 | 157.96 | 326.891 | 1.57 | | 2048 | 512 | 4096 | 13.751 | 148.93 | 327.513 | 1.56 | | 2048 | 512 | 6144 | 14.467 | 141.56 | 328.236 | 1.56 | | 2048 | 512 | 8192 | 15.263 | 134.18 | 329.009 | 1.56 |TG 稳定在 1.56~1.57 t/s,且几乎不随 KV 缓存增长而变化——这强烈暗示瓶颈不在 KV 缓存访问,而是每一步解码都卡在某个固定的串行环节上。
主线 llama.cpp(llama-server)实测结果(抽取关键时序):
prompt eval time = 9258.01 ms / 740 tokens ( 12.51 ms per token, 79.93 tokens per second) eval time = 155399.04 ms / 1138 tokens ( 136.55 ms per token, 7.32 tokens per second) prompt eval time = 12816.60 ms / 2036 tokens ( 6.29 ms per token, 158.86 tokens per second) eval time = 179147.95 ms / 1299 tokens ( 137.91 ms per token, 7.25 tokens per second) prompt eval time = 21481.40 ms / 2169 tokens ( 9.90 ms per token, 100.97 tokens per second) eval time = 266511.08 ms / 1903 tokens ( 140.05 ms per token, 7.14 tokens per second) prompt eval time = 24427.19 ms / 2751 tokens ( 8.88 ms per token, 112.62 tokens per second) eval time = 281851.24 ms / 1996 tokens ( 141.21 ms per token, 7.08 tokens per second)主线在完全相同(不含-fmoe/-mla)的卸载方案下,PP 约 80~160 t/s,TG 约 7 t/s。也就是说:同样的"半层拆分"在主线不致命,但在开启-fmoe的 ik_llama.cpp 上 TG 性能直接雪崩。
1.4 修复对照:整层卸载一切正常
用户随后改用"整层"卸载(把第 20~23 层的 FFN 整块放到 CUDA4,第 24~26 层整块放到 CUDA5,不再做层内半层拆分):
./llama-server -m '/models_llm/DeepSeek-R1-0528-UD-Q3_K_XL-00001-of-00007.gguf' -c 32768 --no-mmap -ngl 999 \ -ot "blk.(0|1|2|3|4|5|6|7).ffn.=CUDA0" -ot "blk.(8|9|10|11).ffn.=CUDA1" \ -ot "blk.(12|13|14|15).ffn.=CUDA2" -ot "blk.(16|17|18|19|20).ffn.=CUDA3" \ -ot "blk.(20|21|22|23).ffn.=CUDA4" -ot "blk.(24|25|26).ffn.=CUDA5" \ -ot "blk.(27|28|29|30|31|32|33|34).ffn.=CUDA6" -ot "ffn.*=CPU" -fa -mg 0 -ub 2048 -mla 1 -fmoe结果 TG 恢复"预期速度"。由此可以确认:问题出在第 35/36 层的半层(层内)张量拆分与-fmoe的交互上,而不是整体的多卡拓扑。
二、破案关键:-fmoe 与张量跨设备拆分的冲突
2.1 社区线索:up/gate 必须同卡,down 可以另放
社区成员Ph0rk0z第一时间给出了方向性判断:如果启用了-fmoe,部分层的 up/gate 权重会被融合(fused)成一个算子;在张量级卸载场景下,up/gate 必须放在同一张卡上,down 可以单独放另一张卡。否则推理图会在多张卡之间反复"弹跳",形成严重的跨设备同步瓶颈——这也解释了用户提到的"PCIe 只有 X4,跨卡带宽极差"的加剧效应。
Ph0rk0z还提到两个可观察的判据:
- 当 TG 卡死时,往往只有 2 张 GPU 长期保持 >50% 占用率,其余 GPU 基本闲置,整个生成期间持续如此;
- GPU 利用率飙升的本质是"在两卡之间来回弹跳(bounce between 2 GPUs)造成瓶颈"。
2.2 实测验证:关闭 -fmoe 立即恢复
用户按此建议,在保持原半层拆分不变的前提下仅去掉-fmoe,TG 性能立即恢复:
| PP | TG | N_KV | T_PP s | S_PP t/s | T_TG s | S_TG t/s | |-------|--------|--------|----------|----------|----------|----------| | 2048 | 512 | 0 | 10.990 | 186.35 | 58.726 | 8.72 | | 2048 | 512 | 2048 | 10.805 | 189.53 | 59.120 | 8.66 | | 2048 | 512 | 4096 | 11.567 | 177.05 | 59.698 | 8.58 | | 2048 | 512 | 6144 | 12.275 | 166.84 | 60.586 | 8.45 |对比表:
| 配置 | S_PP t/s(N_KV=0) | S_TG t/s(N_KV=0) |
|---|---|---|
半层拆分 +-fmoe(ik) | 151.83 | 1.57 |
半层拆分(主线,无-fmoe) | ~80–160 | ~7.1–7.3 |
半层拆分 + 关闭-fmoe(ik) | 186.35 | 8.72 |
关闭-fmoe后,不仅 TG 从 1.57 t/s 恢复到 8.72 t/s,PP 也进一步从 151.83 提升到 186.35 t/s。这说明:当张量被跨设备拆分时,fused MoE 非但不是加速,反而成了性能杀手。
三、源码级原理解析:-ot 与 -fmoe 到底做了什么
3.1 -ot / --override-tensor:张量级缓冲类型覆盖
-ot是 ik_llama.cpp 提供的按张量名覆盖缓冲(buffer)类型的参数,允许把任意张量显式放置到指定后端设备(如CUDA0…CUDA7、CPU)。其解析逻辑位于 common/common.cpp:
if (arg == "-ot" || arg == "--override-tensor") { CHECK_ARG if (!parse_buft_overrides(std::string{ argv[i] }, params.tensor_buft_overrides)) { fprintf(stderr, "error: Invalid tensor buffer type override: %s\n", argv[i]); invalid_param = true; } return true; }核心函数parse_buft_overrides(common/common.cpp)会先枚举当前注册的所有后端 buffer 类型(ggml_backend_reg_get_default_buffer_type),构造一个名字 -> buft的映射表;随后按逗号拆分tensor_name=buft键值对,逐一校验=两侧的合法性,最终生成llama_model_tensor_buft_override列表。如果=右侧的名字不在映射表中,会打印出所有可用 buffer 类型并返回错误。
关键语法点:
- 支持正则表达式:张量名部分可以是正则,如
blk.(0|1|2|...).ffn.*,因此-ot "blk.35.ffn_gate_exps.weight=CUDA4"可以精确命中某一层的某个具体张量; - 可多次指定:每条
-ot只声明一条规则,多条规则按命令行顺序叠加,从而构造出上文中那种逐层逐张量的复杂拓扑; - 覆盖粒度是张量:这是与
-ngl(按层数卸载)最本质的区别——-ot可以在同一层内把不同张量分配到不同设备(即"半层/层内拆分")。
3.2 -fmoe / -no-fmoe:fused MoE 优化开关
-fmoe(--fused-moe)默认开启,对应参数params.fused_moe_up_gate;-no-fmoe(--no-fused-moe)用于显式关闭(common/common.cpp):
if (arg == "-no-fmoe" || arg == "--no-fused-moe") { params.fused_moe_up_gate = false; return true; }该参数最终传导到上下文参数cparams.fused_moe_up_gate(src/llama-cparams.h),并在加载时打印状态:
LLAMA_LOG_INFO("%s: fused_moe = %d\n", __func__, cparams.fused_moe_up_gate); LLAMA_LOG_INFO("%s: fused_up_gate = %d\n", __func__, cparams.fused_up_gate);(见 src/llama.cpp,默认值均为true,见 src/llama.cpp。)
其底层行为可从 src/llama-build-context.cpp 的图构建逻辑看出端倪:当can_use_fmoe && cparams.fused_moe_up_gate && up_exps->type == gate_exps->type成立时,构建器会调用ggml_moe_up_gate/ggml_moe_up_gate_ext,将up 专家矩阵与 gate 专家矩阵的mul_mat_id合并成单个算子("fused up+gate");否则退化为两次独立的ffn_moe_up与ffn_moe_gate计算,再用ggml_fused_mul_unary做 SiLU/GELU 门控融合(src/llama-build-context.cpp)。
注意:即使关闭
-fmoe,fused_up_gate(up/gate 激活门控融合)仍可能生效;-no-fug(--no-fused-up-gate)可再单独关闭它。两者是不同层级的优化:-fmoe融合的是跨专家(expert)的 up/gate 矩阵乘,-fug融合的是单个 token 上的 up/gate 激活运算。
由此可以推断性能雪崩的直接机理:
-fmoe把ffn_up_exps与ffn_gate_exps视为必须在同一设备上成对参与同一个融合算子的输入;- 而
-ot的半层拆分把它们分别放到了 CUDA4 / CUDA5 两张不同的卡(甚至与ffn.*=CPU规则叠加后部分落到 CPU); - 每次解码步骤中,融合算子需要跨设备搬运/同步
up_exps与gate_exps的数据,而用户机器上 GPU 间仅 PCIe x4 连接; - 结果每一步 token 生成都被串行地阻塞在跨卡数据传输上,TG 从 ~8 t/s 雪崩到 ~1.5 t/s,而 PP 阶段由于 batch 大、可并行掩盖延迟,看起来仍"正常"。
这正与 issue 中"PP 正常、TG 崩盘"的现象吻合,也与Ph0rk0z观察到的"TG 期间少数 GPU 长期高占用、其余闲置"的 GPU 利用率特征一致。
3.3 张量命名约定:DeepSeek 的 ffn 权重族
为了写出正确的-ot规则,需要知道 DeepSeek 系列 MoE 层的 FFN 张量命名。从 src/graphs/build_deepseek2.cpp 和 src/graphs/build_deepseek4.cpp 可以看到构建时使用的一族张量:
ffn_norm:FFN 输入归一化权重ffn_gate_inp:门控(router)输入权重ffn_gate_exps:门控专家权重(与 up 融合的候选者)ffn_up_exps:up 专家权重ffn_down_exps:down 专家权重ffn_gate_shexp/ffn_up_shexp/ffn_down_shexp:共享专家(shared expert)的门控 / up / down 权重
issue 中-ot "blk.35.ffn_(norm|gate_inp|gate_shexp|down_shexp|up_shexp).weight=CUDA4"一类规则正是用正则一次性命中某层的这组"小张量",再单独用-ot "blk.35.ffn_gate_exps.weight=CUDA4"处理专家矩阵。正是这种把ffn_gate_exps与(潜在的融合对象)分开对待的写法,与-fmoe的融合逻辑产生了冲突。
四、实战排查方法论:如何定位多卡 TG 瓶颈
4.1 用 llama-sweep-bench 复现并量化问题
ik_llama.cpp 自带的 examples/sweep-bench(源码位于 examples/sweep-bench/sweep-bench.cpp,构建目标见 examples/sweep-bench/CMakeLists.txt)专为"观察性能随上下文长度变化"而设计:它在每个 ubatch 窗口内依次执行"生成 ubatch/4 个 token → 测生成速度 → 清 KV → 处理一批随机 token → 测提示处理速度",从而给出 PP/TG 随N_KV递增的逐窗口指标,而不是把整个上下文平均成一个数。
sweep-bench 输出列的含义(摘自其 README):
| 列 | 含义 |
|---|---|
PP | 每个 ubatch 处理的提示 token 数 |
TG | 每个 ubatch 生成的 token 数 |
N_KV | 当前 KV 缓存大小 |
T_PP | 提示处理耗时(即 TTFT 相关) |
S_PP | 提示处理速度((B*PP)/T_PP或PP/T_PP) |
T_TG | 生成全部 batch 的总耗时 |
S_TG | 文本生成速度((B*TG)/T_TG) |
同时支持--output-format jsonl输出机器可读的 JSONL(每个窗口一条记录,字段与上表一一对应),便于脚本化对比不同配置。
最小复现命令(来自 README 示例):
./llama-sweep-bench -c 8704 -ub 512 -m models/Meta-Llama-3.2-3B-Instruct-Q8_0.ggufissue 中实际使用的完整命令还叠加了多卡卸载、-fa(flash attention)、-mg 0(主 GPU 为 0 号)、-ub 2048(微批 2048)、-mla 1(MLA 路径)与-fmoe。
4.2 二分法对比实验设计
issue 的排查过程本身就是一套可复用的"控制变量"方法:
- 基线整层卸载:
-ot只按层拆分(blk.N.ffn.=CUDAx),确认"无半层拆分"时 TG 正常; - 加入半层拆分:把个别层的
ffn_gate_exps与其余 FFN 张量分开(blk.35/36分别到 CUDA4/CUDA5),复现 TG 雪崩; - 同一拓扑、不同实现:主线 llama.cpp 无
-fmoe时 TG 约 7 t/s,证明拓扑本身不是致命问题; - 同一实现、切换开关:ik 侧关闭
-fmoe后 TG 恢复到 8.72 t/s,锁定根因是 fused MoE 与跨设备拆分冲突。
这套方法的核心启示:当 TG 性能异常而 PP 正常时,优先怀疑"解码路径上新增的跨设备同步点";对 MoE 模型,先检查-fmoe融合的张量对是否被-ot拆到了不同设备。
4.3 辅助判据:GPU 利用率
用户补充的观测进一步佐证了瓶颈位置:
- PP 阶段主 GPU(
-mg 0)接近 100% 占用,正常; - TG 阶段开始时若干 GPU 各约 90%,随后跌到 10~30% 并持续——说明大部分时间 GPU 在等待跨卡/跨设备数据,而不是在计算;
Ph0rk0z描述的"仅 2 张 GPU >50% 占用持续整个生成过程"是另一类典型症状:融合算子被迫在两卡之间反复弹跳。
注意:用户指出其 PCIe 为 x4,跨 GPU 带宽很差,这放大了跨卡搬运的代价;若 PCIe 带宽充足(如 x16 或 NVLink),症状可能相对缓和,但 fused 张量对跨设备的逻辑冲突依然存在。
五、结论与最佳实践
5.1 明确结论
在 ik_llama.cpp 中,-ot提供的张量级卸载与默认开启的-fmoe(fused MoE)存在已知的冲突场景:当 up/gate 专家权重被-ot分散到不同 GPU(或 GPU 与 CPU 之间)时,-fmoe反而会让 TG 性能雪崩(本案例从 ~8.7 t/s 跌到 ~1.5 t/s),而 PP 基本不受影响。
5.2 操作建议
MoE 模型强烈建议保留
-fmoe(社区成员ubergarm在 DeepSeek-TNG-R1T2-Chimera-GGUF 讨论中的总结):-fmoe对任何 MoE(包括 DeepSeek 系列)都是重要的计算优化;- 不要用
-ot把ffn_gate_exps(gate/up 专家权重)拆分到不同 GPU/CPU——up/gate 必须同卡,才能让 fused 算子完整生效; - down 专家(
ffn_down_exps)与 up/gate 不参与同一融合算子,可以视容量放到另一张卡。
若确实需要层内半层拆分(例如显存不足以整层落卡),则必须显式关闭 fused MoE:
./llama-server -m model.gguf -ngl 999 -fmoe 0 -ot ...(半层拆分规则)或使用等价的-no-fmoe。代价是失去 fused MoE 的算子级优化,但可以换取跨设备拓扑的正确性。
验证手段:修改拓扑或开关后,用
llama-sweep-bench(或--output-format jsonl)在相同-c/-ub下对比S_TG随N_KV的变化曲线;同时用nvidia-smi观察 TG 阶段的 GPU 利用率分布,二者结合可快速判断是否又出现跨卡同步瓶颈。关于主线对比的说明:issue 中主线 llama.cpp 未启用
-fmoe/-mla,且用户无法以完全相同的 sweep-bench 参数加载,只能以 server 独立测试近似对比,因此主线 7 t/s 与 ik 侧数值并非严格同口径,但"半层拆分在 ik +-fmoe下 TG 雪崩"的结论由 ik 侧自身的开关对照实验(-fmoevs-no-fmoe)即可独立验证。其它相关开关:
-no-fug(--no-fused-up-gate)可单独关闭 up/gate 激活融合,-no-mmad(--no-fused-mul-multiadd)可关闭 mul-multiadd 融合(common/common.cpp)。在多卡/异构拓扑下做张量级卸载时,如仍遇异常,可依次关闭这些融合选项做二分定位。
5.3 一句话总结
-ot让你能把模型拆到任意设备,-fmoe则假设 up/gate 专家矩阵始终"成对出现、同地计算"——两者叠加时必须保证融合张量对不跨设备,否则 TG 性能会以数量级的形式偿还这笔"拆分之债"。
【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考