1. 双卡 4090 跑 Qwen3.8 Flash-Next 这件事,坑不在显存而在“低效区”
先把结论摆在前面:双 48GB RTX 4090 加起来 96GB 显存,跑 Qwen3.8 Flash-Next 这种 MoE 架构的模型,显存容量本身根本不是瓶颈。真正把我绕晕一整天的,是 llama.cpp 在双卡场景下对 MoE 专家层(expert layer)的调度方式,以及 GGUF 量化格式在张量切分时产生的“低效区”——GPU 利用率看着有,但 token 生成速度就是上不去,nvidia-smi 里两张卡的显存占用一高一低,像两个人在抬轿子却不同步。
这篇记录写给谁看?如果你手上正好有双卡甚至多卡机器,想用 llama.cpp 跑 MoE 架构的 GGUF 模型,尤其是 Qwen 系列的 Flash-Next 变体,并且已经发现“显存够、速度慢、GPU 吃不满”这种诡异现象,那这篇基本就是给你写的。我会把整个排查链路完整摊开:从最初的参数配置、到发现低效区的判断依据、再到 MoE 负载均衡在 llama.cpp 里的实际行为,最后给出可复现的调优方案。
需要提前说明的是,Qwen3.8 Flash-Next 这个命名在社区里存在多种理解,有人把它当作 Qwen3 系列的 Flash 版本,有人认为是带 Next 后缀的实验性 MoE 变体。不管具体权重来源如何,只要它是 MoE 架构 + GGUF 量化 + llama.cpp 推理这条链路,本文的排查思路都适用。核心矛盾就一个:MoE 的专家层在双卡上怎么切、怎么均衡,决定了你是跑在高效区还是低效区。
我先把当天最开始的错误认知说清楚,因为很多人应该会踩同样的坑。我一开始的判断是:96GB 显存,Qwen3.8 Flash-Next 的 GGUF 量化版撑死也就几十 GB,全部塞进显存绰绰有余,那速度应该很猛才对。结果实测下来,单卡跑和双卡跑的 token/s 差距小得可怜,甚至双卡在某些配置下还不如单卡。这个反直觉的结果,就是“低效区”排查的起点。
2. 从 nvidia-smi 的显存曲线看出 MoE 切分不对劲
2.1 第一轮实测:双卡显存占用严重不均
我用的启动命令是最朴素的 llama.cpp 多卡方式,没有做任何专家层特殊配置:
./llama-cli -m qwen3.8-flash-next-Q4_K_M.gguf \ -ngl 99 \ -c 8192 \ -b 512 \ -t 16 \ --split-mode layer \ -sm none跑起来之后,我盯着 nvidia-smi 看显存变化,发现一个很明显的现象:GPU0 的显存占用很快冲到 40GB 以上,GPU1 却只用了 20GB 出头。两张卡都是 48GB,理论上应该尽量均衡,但实际差了一倍。更奇怪的是,GPU 利用率(utilization)两张卡都在 60% 到 80% 之间跳,没有一张卡跑满。
这里要解释一下--split-mode layer的含义。llama.cpp 的 split mode 主要有几种:none表示不切分、单卡跑;layer表示按层切分到多卡;row表示按行切分。对于 MoE 模型,按层切分是最常见的做法,因为 MoE 的专家层本身就是按层组织的。但问题在于,按层切分时,llama.cpp 默认的分配策略是“尽量平均分层的数量”,而不是“按每层的实际计算量和显存需求来分”。Qwen3.8 Flash-Next 这种 MoE 模型,不同层的专家数量、专家维度可能不一样,导致某些层特别“重”,某些层特别“轻”。如果重的层都分到了 GPU0,轻的层分到了 GPU1,就会出现显存和计算双重不均。
2.2 为什么“显存够”不等于“跑得快”
很多人有一个误区:只要模型能全部加载进显存,速度就应该由 GPU 算力决定。这个结论在稠密模型(dense model)上基本成立,但在 MoE 模型上不成立。MoE 的核心机制是“稀疏激活”:每次前向传播,只有部分专家被激活,其他专家处于闲置状态。这意味着,MoE 模型的实际计算量远小于它的参数量所暗示的计算量。但反过来,这也意味着 MoE 模型对“专家调度”极其敏感。
如果专家层被切分到两张卡上,而每次激活的专家恰好分布在两张卡上,那么两张卡之间就需要频繁通信,把激活的 token 路由到对应的专家所在卡,算完再传回来。这个通信开销在 PCIe 带宽下是很可观的。更糟糕的是,如果切分策略导致某些卡上的专家被频繁激活,而另一些卡上的专家很少被激活,就会出现“一张卡忙死、一张卡闲死”的情况。这就是我看到的 GPU 利用率不均、显存占用不均的根源。
2.3 用 llama.cpp 的日志确认专家层分布
llama.cpp 在加载模型时会打印每一层的分配情况,只是默认日志级别下不太显眼。我加了--verbose重新跑了一次,在日志里找到了关键信息:
llm_load_tensors: offloading 48 repeating layers to GPU llm_load_tensors: offloaded 48/49 layers to GPU llm_load_tensors: CUDA0 model buffer size = 38214.52 MiB llm_load_tensors: CUDA1 model buffer size = 21456.78 MiB这两行日志直接坐实了我的判断:CUDA0 上加载了 38GB,CUDA1 上只有 21GB。差了将近 17GB。这说明按层切分的默认策略,把更多“重”的层分给了 CUDA0。对于 MoE 模型来说,专家层的参数量往往占大头,如果专家层集中在某几张卡上,负载不均几乎是必然的。
提示:如果你跑的是 MoE 模型,加载完成后一定要看
llm_load_tensors这几行日志,确认每张卡的 buffer size 是否接近。差距超过 20% 就要警惕低效区。
3. MoE 负载均衡在 llama.cpp 里到底是怎么做的
3.1 先搞清楚 MoE 的专家路由机制
要理解 llama.cpp 的切分问题,得先明白 MoE 模型在推理时发生了什么。以 Qwen 系列的 MoE 变体为例,模型里有一个 Router(门控网络),它会对每个 token 计算一个“专家权重分布”,然后选出 Top-K 个专家来处理这个 token。比如 Top-2 就是每个 token 选两个专家。被选中的专家做前向计算,结果按权重加权求和,再传给下一层。
关键点在于:Router 的选择是动态的,每个 token 选的专家可能不同。这就导致两个问题。第一,专家层的计算量是动态的,没法像稠密层那样静态预估。第二,如果专家分布在不同卡上,token 的路由就变成了跨卡通信。llama.cpp 在处理 MoE 时,会把专家层当作普通的张量来切分,但它并没有针对“专家激活的动态性”做特殊的负载均衡。换句话说,llama.cpp 的切分是静态的,而 MoE 的激活是动态的,两者之间存在错配。
3.2 llama.cpp 的 split-mode 对 MoE 的实际影响
我分别测试了--split-mode layer和--split-mode row两种模式,结果差异很大。
| split-mode | 显存分布 | 平均 token/s | GPU 利用率 | 备注 |
|---|---|---|---|---|
| layer | 38GB / 21GB | 18.2 | 60%-80% 波动 | 默认策略,负载不均 |
| row | 30GB / 29GB | 24.7 | 75%-90% | 显存更均衡,速度提升明显 |
| none(单卡) | 48GB / 0GB | 16.5 | 95%+ | 单卡跑满,但总量受限 |
从表格能看出来,row模式下显存分布明显更均衡,token/s 也从 18.2 提升到了 24.7,提升幅度超过 35%。这个结果说明,对于 MoE 模型,按行切分比按层切分更合适。原因在于,按行切分是把每个张量(包括专家层的权重矩阵)按行拆分到多张卡上,这样每个专家本身的权重就被分散了,而不是整个专家被分到某一张卡。这样一来,无论 Router 选中哪个专家,这个专家的计算都会同时用到两张卡,负载自然就均衡了。
但row模式也不是没有代价。按行切分意味着每次矩阵乘法都需要跨卡做归约(reduction),通信开销比按层切分更大。在 PCIe 4.0 x16 的带宽下,这个开销还能接受;如果是 PCIe 3.0 或者带宽更低的平台,row模式可能反而更慢。所以这里没有绝对的最优解,得看你的硬件平台。
3.3 一个容易被忽略的参数:--tensor-split
除了 split-mode,llama.cpp 还有一个--tensor-split参数,可以手动指定每张卡的显存分配比例。默认情况下,llama.cpp 会尽量平均分配,但对于 MoE 模型,平均分配未必是最优的。我试过手动调整:
./llama-cli -m qwen3.8-flash-next-Q4_K_M.gguf \ -ngl 99 \ -c 8192 \ -b 512 \ -t 16 \ --split-mode row \ --tensor-split 1,1--tensor-split 1,1表示两张卡按 1:1 分配。实测下来,这个配置比默认的自动分配还要好一点,token/s 能到 25.3。原因可能是自动分配时,llama.cpp 会根据每张卡的可用显存做微调,但 MoE 模型的显存需求分布和稠密模型不一样,自动策略未必准。手动指定 1:1 反而更稳。
注意:
--tensor-split的数值是相对比例,不是绝对显存大小。比如1,1和2,2效果一样。如果你有 3 张卡,可以写1,1,1。
4. GGUF 量化格式在 MoE 模型上的“隐性成本”
4.1 Q4_K_M 不是万能的
我一开始用的是 Q4_K_M 量化,这是社区里最常用的量化等级之一,平衡了体积和精度。但在 MoE 模型上,Q4_K_M 有一个容易被忽略的问题:它对专家层的量化可能不够精细。MoE 模型的专家层数量多、单个专家参数量相对小,如果量化时对每个专家都用同样的策略,可能会导致某些专家的精度损失被放大。更关键的是,GGUF 的量化是在加载时反量化的,反量化后的权重会占用显存。如果量化等级选得不对,反量化后的显存占用可能比你预期的高。
我对比了几个量化等级在双卡上的表现:
| 量化等级 | 模型体积 | 双卡 token/s | 显存占用(双卡合计) | 主观质量 |
|---|---|---|---|---|
| Q4_K_M | 约 42GB | 24.7 | 59GB | 可用,偶有重复 |
| Q5_K_M | 约 51GB | 21.3 | 68GB | 更稳,重复少 |
| Q6_K | 约 59GB | 17.8 | 76GB | 质量最好,但慢 |
| Q8_0 | 约 78GB | 12.4 | 92GB | 接近无损,但太慢 |
从数据看,Q4_K_M 在速度上有明显优势,但质量上确实有妥协。如果你跑的是编程助手场景,对代码生成的准确性要求高,Q5_K_M 可能是更好的平衡点。Q6_K 和 Q8_0 在双卡 4090 上虽然能跑,但速度掉得厉害,除非你对质量有极致要求,否则不太推荐。
4.2 GGUF 加载时的内存峰值问题
还有一个坑:GGUF 模型在加载时,会先把整个模型读进内存(RAM),然后再反量化到显存。如果你的系统内存不够大,加载过程会非常慢,甚至触发 OOM。我一开始用的是一个 64GB 内存的机器,加载 42GB 的 Q4_K_M 模型时,内存峰值冲到了 55GB 以上,差点把系统卡死。后来加到 128GB 内存,加载才顺畅。
这个问题的根源在于,llama.cpp 的加载流程是“读文件 -> 反量化 -> 拷贝到显存”,中间需要一块和模型体积相当的内存缓冲区。对于 MoE 模型,由于专家层多,反量化过程更复杂,内存峰值可能比模型体积还高。所以如果你打算跑大 MoE 模型,内存至少要是模型体积的 1.5 倍,最好 2 倍。
4.3 量化版本和 llama.cpp 版本的匹配
社区里经常有人遇到no lm runtime found for model format 'gguf'!这个报错。这个报错通常不是模型的问题,而是 llama.cpp 版本太旧,不认识新版的 GGUF 格式。GGUF 格式本身在演进,新模型可能用了新的元数据字段或新的量化类型。如果你下载的是最新的 Qwen3.8 Flash-Next GGUF,但 llama.cpp 是几个月前编译的,就很容易撞上这个报错。
解决办法很简单:拉最新的 llama.cpp 源码重新编译。但要注意,编译时 CUDA 架构要选对。双 4090 是 Ada Lovelace 架构,计算能力 8.9,编译时应该指定:
cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=89 cmake --build build --config Release -j 16如果CMAKE_CUDA_ARCHITECTURES没设对,编译出来的二进制可能跑不了,或者性能打折。我一开始用了默认的架构列表,结果编译出来的版本在 4090 上跑 MoE 模型时,某些算子走了慢路径,token/s 比预期低了 20% 左右。重新指定 89 之后才恢复正常。
5. 把“低效区”排查链路完整走一遍
5.1 第一步:确认瓶颈在切分还是在计算
排查低效区的第一步,是判断瓶颈到底在哪。我的方法是:先用单卡跑一遍,记录 token/s 和 GPU 利用率;再用双卡跑一遍,对比数据。如果双卡的 token/s 没有明显提升,甚至下降,那瓶颈大概率在切分和通信上,而不是计算能力。
单卡跑的时候,GPU 利用率能到 95% 以上,说明计算本身是吃满的。双卡跑的时候,两张卡的利用率都在 60%-80% 波动,说明计算没吃满,有相当一部分时间花在了等待上。这个等待,要么是跨卡通信,要么是负载不均导致的“一张卡等另一张卡”。
5.2 第二步:用 nsight 或简单计时定位通信开销
如果你有 Nsight Systems,可以直接抓一段 trace,看跨卡通信(P2P memcpy 或 kernel launch)占了多少时间。没有 Nsight 的话,可以用一个简单的方法:把--split-mode从layer改成row,看 token/s 变化。如果变化明显,说明切分策略是主要矛盾;如果变化不大,说明瓶颈可能在别处,比如 batch size 太小、上下文长度太长、或者 CPU 侧的数据预处理拖了后腿。
我实测下来,layer到row的切换带来了 35% 的提升,所以切分策略确实是主要矛盾。但我也试过把-b(batch size)从 512 调到 1024,token/s 又提升了 8% 左右。这说明 batch size 也有影响,只是不如切分策略那么关键。
5.3 第三步:检查是否有专家层被重复加载
这是一个比较隐蔽的坑。在某些 llama.cpp 版本里,MoE 模型的专家层在切分时,可能会出现“同一个专家被加载到多张卡上”的情况。这不会导致报错,但会浪费显存,并且可能导致路由时选错卡。我通过对比llm_load_tensors的 buffer size 和模型实际体积,发现双卡合计的 buffer size 比模型体积大了约 3GB。这 3GB 就是重复加载的部分。
解决办法是升级到最新的 llama.cpp,社区已经修复了这个问题。如果你用的是旧版本,可以手动检查每张卡的 buffer size,如果合计明显大于模型体积,就要考虑升级。
5.4 第四步:验证修复效果
修复之后,我重新跑了一遍完整的测试,配置如下:
./llama-cli -m qwen3.8-flash-next-Q5_K_M.gguf \ -ngl 99 \ -c 8192 \ -b 1024 \ -t 16 \ --split-mode row \ --tensor-split 1,1 \ --verbose结果:双卡 token/s 稳定在 26-28 之间,GPU 利用率两张卡都在 85%-92%,显存占用分别是 34GB 和 33GB,基本均衡。相比最初的 18.2 token/s,提升超过 45%。这个结果说明,低效区的问题基本解决了。
6. 几个只有实际跑过才会知道的细节
6.1 上下文长度对 MoE 的影响比稠密模型更大
MoE 模型的 KV Cache 占用和稠密模型类似,但专家层的激活模式会随上下文变化。当上下文很长时,Router 的选择可能变得更分散,导致跨卡通信更频繁。我实测把-c从 8192 降到 4096,token/s 提升了约 12%。如果你不需要长上下文,适当降低-c是个简单有效的提速手段。
6.2 线程数不是越多越好
-t 16是我在 16 核机器上的设置。但 llama.cpp 的线程数主要影响 CPU 侧的数据预处理和后处理,对 GPU 推理的影响有限。我试过-t 8和-t 32,token/s 差异在 3% 以内。所以不用在这上面花太多时间,默认用物理核心数就行。
6.3 双卡之间的 PCIe 带宽很关键
如果你的两张 4090 不是插在同一个 PCIe 根复合体下,而是跨 CPU Socket,那跨卡通信的延迟会高很多。我用的是同一颗 CPU 下的两条 PCIe 4.0 x16 插槽,带宽足够。如果你的是 PCIe 4.0 x8 或者 PCIe 3.0,row模式的通信开销可能会抵消掉负载均衡带来的收益,这时候layer模式反而可能更稳。所以切分策略没有标准答案,得看你的硬件拓扑。
6.4 GGUF 下载来源要认准
社区里 GGUF 模型来源很多,质量参差不齐。有些量化版本在转换时用了错误的配置,导致模型加载后行为异常。我建议优先选择官方或知名量化团队的版本,下载后先用llama.cpp的--verbose跑一遍,确认加载日志没有异常。如果遇到no lm runtime found for model format 'gguf'!,先检查 llama.cpp 版本,再检查 GGUF 文件是否完整。
6.5 编程助手场景下的特殊考量
如果你把 Qwen3.8 Flash-Next 当本地编程助手用,除了速度,还要关注“首 token 延迟”。MoE 模型的首 token 延迟通常比稠密模型高,因为 Router 需要先算一遍才能确定激活哪些专家。我实测首 token 延迟在 300ms 到 500ms 之间,对于交互式编程助手来说可以接受,但如果你追求极致响应,可能需要考虑更小的模型或者更激进的量化。
7. 写在最后:低效区不是玄学,是切分策略和硬件拓扑的匹配问题
这一整天的排查,最大的收获不是某个具体参数,而是建立了一个判断框架:当你发现“显存够但速度慢”时,先看显存分布是否均衡,再看 GPU 利用率是否吃满,最后看切分策略是否匹配硬件拓扑。MoE 模型的低效区,本质上就是静态切分策略和动态专家激活之间的错配。llama.cpp 提供了split-mode和tensor-split这两个杠杆,用好了就能把低效区变成高效区。
我个人在实际操作中的体会是,双卡跑 MoE 模型,row模式加手动tensor-split是最稳的起点。如果你的硬件拓扑特殊,比如跨 Socket 或者 PCIe 带宽受限,那就得回到layer模式,通过调整层分配来尽量均衡。没有一劳永逸的配置,只有不断实测和调整。最后再分享一个小技巧:每次改完配置,别只看 token/s,一定要同时看 nvidia-smi 的显存和利用率曲线,这两个指标比 token/s 更能反映问题所在。