4 minute read

“The first principle is that you must not fool yourself — and you are the easiest person to fool.” — Richard Feynman,《Cargo Cult Science》,1974 年加州理工毕业演讲

我写了一份六步排查手册,然后发现最该做的是把它删掉

从「我会诊断」到「问题不发生」——Senior 与 Principal 的分界线,就在这一步。

昨晚 19:48,我盯着一行日志,脑子里已经有了答案。

我在自己的工作站上跑本地大模型,一个 24B 的模型加载完之后,ollama 只把 41 层里的 34 层放进了显卡。我翻日志,一眼看到这行:

level=INFO source=sched.go:450 msg="gpu memory" available="11.9 GiB" free="12.4 GiB"

11.9 GiB。 但我这张卡有 15.9 GiB。

答案不是很明显吗——上一个模型还没退干净,OLLAMA_KEEP_ALIVE=30m 让它霸占着显存,所以调度器是拿着一份过期的显存账本在做分层决策。清一下就好了。

我甚至已经准备好把这条写进排查手册了。

然后我做了一件差点没做的事:我去证伪它。

ollama stop huihui_ai/mistral-small:24b
sleep 8
nvidia-smi --query-gpu=memory.used --format=csv,noheader
# 3304 MiB —— 确认显存干净了
ollama run huihui_ai/mistral-small:24b --verbose "..."
load_tensors: offloaded 34/41 layers to GPU

34/41。一模一样。

我的假设死了。而且死得很干脆——如果我当时跳过这一步,直接把”清显存就好”写进手册,我会带着一个错误的因果模型继续往下排查,后面三次翻车我一次都解释不了。

读完这篇,你应该能拿走三样东西:

  • ollama ps 里那个 CPU 百分比在骗你——它看起来是斜坡,实际是阶跃
  • MoE 架构在显存不足时是所有架构里最差的选择,和网上流行的说法正好相反
  • 最有价值的排查,是让排查变得没有必要——这句听起来像鸡汤,但它可以被压缩成一行 shell 函数

下面的顺序不是我当晚的发现顺序,而是教学顺序——先给你最省时间的判断法,再给你支撑它的机制。当晚我是反过来走的,走了四遍。


一、那个骗了我的百分比

事情的起点是这样一幅画面:btop 里 24 个核全部烧红。

CPU      95%          Load avg: 26.41 22.26 12.64
C0  95%  C8  95%  C16 94%
C1  95%  C9  94%  C17 97%
C2  94%  C10 93%  C18 94%
...

同一秒,nvidia-smi

| 0%   30C   P3   36W / 360W |  14355MiB / 16303MiB |   0%   Default |

显存 14.3GB 塞满,GPU 利用率 0%,功耗 36W,温度 30 度。

这个组合第一次见会很懵:显存明明满了,卡为什么是凉的?

top 给出了第一个硬证据:

PID       USER    %CPU   RES     COMMAND
1524594   ollama  2140   15.3g   ollama

%CPU 2140——21.4 个核,全在 ollama 一个进程里。

(顺带一个容易读错的点:btop 里同一个进程显示的是 82.6%,因为它按核数归一化;top 用的是单核累加制。两个工具口径不同,别拿它们互相验证。

再看 ollama 自己怎么说的:

NAME                      SIZE     PROCESSOR          CONTEXT
dolphin-mixtral:latest    27 GB    58%/42% CPU/GPU    4096

58%/42% CPU/GPU。一个 26GB 的模型,塞进一张 16GB 的卡——装不下的部分被丢回了 CPU。日志里写得明明白白:

load_tensors: offloaded 13/33 layers to GPU
NumThreads:24

33 层里只有 13 层在 GPU 上,剩下 20 层由 24 个 CPU 线程去硬算。btop 那 24 根红柱子,出处就在这里。

记住这个指纹

现象组合 含义 下一步
显存满 + GPU 0% + CPU 满 混合推理,模型选大了 换小模型
显存满 + GPU 70%+ 正常,它就该长这样 无需处理
显存满 + CPU 满 压根没用上 GPU 查驱动 / CUDA / 容器透传

在这三个指标里,power.drawutilization.gpu 更诚实。 利用率是瞬时采样,两次采样之间的峰值它看不见;功耗有热惯性,骗不了人。 正常推理时我这张卡是 253W,offload 时是 36W——TDP 的 10%,这个信号没有歧义。


二、我推翻了自己的假设

回到开头那个实验。

假设死掉之后,真正的原因反而简单得多:这个模型在这张卡上本来就装不下,跟调度时序、跟 KEEP_ALIVE、跟任何配置都没关系。

14.3GB 的权重,15.9GB 的显存,扣掉 KV cache、CUDA context 和显存碎片——它差的不是一点点运气,是结构性的。

这一段我想多说两句,因为它和技术细节无关,和做事方法有关:

  看起来在排查 真的在排查
看到可疑现象 直接当成原因 提出假设
下一步 去修 设计一个能证伪它的实验
实验失败时 找理由解释过去 承认假设死了,重新提
产出 一条”玄学修复” 一条能复现的因果链

左边那一列,三个月后会变成代码考古学里那种”这行不能删,删了就出事,但没人记得为什么”的注释。

一个没被证伪过的假设,不配叫结论。

11.9 GiB 那行日志到今天依然是真的——它确实是个 bug(调度器读了脏账本)。但它不是这次故障的原因。真实系统里同时存在好几个异常是常态,排查的难点从来不是找到异常,是证明哪个异常是因


三、阶跃,不是斜坡

现在给数字。以下全部来自我自己的机器,同一晚,同一张 RTX 5080 16GB。

我用同一个问题测了四个模型:

模型 架构 权重 CPU 占比 prompt eval eval rate
qwen2.5-14b Dense 14B 9.0 GB 0% 1705.85 tok/s 86.97 tok/s
mistral-small Dense 24B 14.3 GB 18% 487.34 tok/s 2.35 tok/s
qwen3:30b MoE 30.5B 19 GB 35% 7.40 tok/s 1.38 tok/s
dolphin-mixtral MoE 46.7B 26 GB 58% 个位数

盯着第二行看三秒:

只有 18% 的层在 CPU 上,吞吐损失了 97%。

普通人的看法 vs 资深工程师的洞察

普通人的看法18%/82% CPU/GPU——那大概就是慢个两成吧,凑合能用。

资深工程师的洞察:Transformer 的层是严格串行的。第 1 层的输出是第 2 层的输入,中间没有任何并行空间。当层被拆成 CPU/GPU 两半:

token N:  GPU 算 34 层(快) ──→ 等 ──→ CPU 算 7 层(慢) ──→ 输出
                                      ↑
                              整条链被这 7 层锁死

混合推理不做加权平均,它做的是取最小值。 这是 Amdahl 定律在推理栈上的现身——串行部分决定上限,跟它占比多小没关系。

GPU 利用率 28%、功耗 47W:它有 70% 的时间在发呆等 CPU。

更该盯的是 prompt eval

大家都看生成速度,但真正的杀手在另一列:

  prompt eval 退化
全量 GPU 1705.85 tok/s 基准
35% offload 7.40 tok/s ↓ 230 倍

预填充退化得比生成还狠。

这很反直觉——prefill 是批量并行的矩阵运算,本该是 GPU 最擅长的环节,它崩得却最厉害。说明 CPU 那几层把批处理的并行优势整个吃掉了

对实际业务的翻译:

2000 token 的 RAG 上下文 ÷ 7.40 tok/s = 270 秒

第一个字都还没吐出来,你已经等了四分半。 如果你打算把本地模型接进 RAG 或长文档流水线,prompt eval rateeval rate 重要得多。

混合推理没有中间地带。ollama ps 里只要出现 CPU 百分比——任何数值,哪怕 1%——这个配置就是废的。

这条给团队讲的时候必须用二元判断。一旦留了”看情况”的口子,下属就会为了跑更大的模型去容忍 10%、20%,然后回来抱怨机器慢。


四、MoE:最不该被 offload 的架构

这是当晚最反直觉的一个发现,因为它和网上大部分说法正好相反

qwen3:30b 这个名字里没有 “moe”,也没有 “mixtral”。我是 ollama show 才看出来的:

architecture        qwen3moe        ← 在这
parameters          30.5B
quantization        Q4_K_M

Qwen3-30B-A3B:30.5B 总参数,每 token 只激活约 3B。

按流行说法,”MoE 激活参数少 = 对低配硬件友好”,它该比 dense 24B 快才对。

实测:1.38 tok/s,比 dense 24B 的 2.35 还慢。

为什么?因为 CPU 推理的瓶颈从来不是算力

  Dense 层在 CPU 上 MoE 层在 CPU 上
每 token 读哪些权重 同一块,固定 128 个专家里随机 8 个
内存访问模式 顺序、连续 随机、跳跃
CPU 预取器 完美命中 基本失效
L3 缓存复用 极低
有效带宽 接近 DDR5 峰值 远低于峰值

MoE 的路由是逐 token、逐层动态决定的。上一个 token 刚把 8 号专家读进缓存,下一个 token 路由说:去拿 73 号。缓存全废,预取器全废——每个 token 都是一次冷启动。

GPU 不怕这个(GDDR7 随机访问延迟低、带宽冗余大)。CPU 怕得要命。

MoE 省的是算力,CPU 缺的是带宽。 它省在了你不缺的地方,代价压在了你最缺的地方。

还有一层更早的坑:MoE 的显存按总参数算,算力按激活参数算。 你付 46.7B 的显存代价,只买到 12.9B 的智力——因为路由是逐 token 决定的,8 个专家必须全部随时待命,一个都不能卸。

修正后的选型顺序:

能全进显存的 MoE  >  能全进显存的 dense  >>  溢出的 dense  >>  溢出的 MoE

“MoE 对低配硬件友好”这个说法,只在能全量进显存时成立。 一旦需要 offload,它是最差的那个。


五、会浮动的预算:看不见的 Windows 显存税

当晚还有一个只有在 WSL2 上才会踩到的坑。

把所有模型卸载干净之后:

nvidia-smi --query-gpu=memory.used --format=csv,noheader
3304 MiB          ← 一个模型都没加载,却占着 3.3GB

追查是谁:

nvidia-smi --query-compute-apps=pid,used_memory,name --format=csv
pid, used_gpu_memory [MiB], process_name
                              ← 空的

WSL2 通过 GPU-PV(半虚拟化)访问显卡,nvidia-smi 在 WSL 里看得见显存总量,却枚举不出 Windows 侧的进程。 那 3.3GB 是宿主机上的东西——浏览器硬件加速、编辑器、桌面合成器。

于是预算公式被改写了:

  标称 实际
显存总量 16.3 GB 16.3 GB
Windows 常驻 −3.3 GB
CUDA context + 碎片 −0.5 GB
ollama 实际可用 ~15.9 GB ≈ 12.5 GB

但故事还没完。 两小时后我再测同一条命令:

0 MiB, 16303 MiB

那 3.3GB 消失了——我关掉了浏览器。

所以正确的说法不是”预算是 12.5GB”,而是:预算在 12.5 ~ 15.3GB 之间浮动,取决于 Windows 侧当时在干什么。

这才是真正危险的地方

假如你按 15.3GB 去选一个 14GB 的模型:

  • 今晚:100% GPU,跑得飞快 ✅
  • 明早开了浏览器和会议软件:Windows 拿走 3.3GB → 下次加载悄悄退化成 CPU offload → 速度掉 60 倍 ❌

而你完全不知道发生了什么,只会觉得”今天机器怎么这么卡”。

这种间歇性的、依赖外部状态的故障,是所有故障类型里最难排查的。 因为它不可复现——你去查的时候浏览器已经关了,一切正常。

容量规划要按最坏情况算。 宁可选 9GB 的模型留足余量,也不要选 14GB 去赌 Windows 今天心情好。

在共享 GPU 的机器上,“余量”本身就是一项性能指标。一个平时快 5%、但会间歇性掉速 60 倍的配置,工程上是负分。


六、让排查消失

现在回到这篇文章真正想说的事。

当晚我把那套排查法跑了三遍

# 模型 排查结论
1 dolphin-mixtral 26GB 超预算 → offload
2 mistral-small 14.3GB 超预算 → offload
3 qwen3:30b 19GB 超预算 → offload

三次故障,同一个根因

跑到第三次的时候我意识到一件事:我在用一套很漂亮的方法论,反复解决一个本来不该发生的问题。

  初级 SME 高级 SME
触发点 故障发生后 决策发生前
动作 跑六步排查 → 找到根因 一行算术 → 拒绝这个选项
耗时 每次 20 分钟 每次 5 秒
产出 一次正确的诊断 一个不会再出现的故障类别

这三次故障,全部可以被 ollama pull 之前的一次体积检查消灭掉

把部落知识变成工程产物

“16GB 的卡别跑超过 12.5GB 的模型”——这句话如果只活在我脑子里,它就是部落知识:靠口口相传,靠我在场,靠别人记得问我。

Principal 和 Senior 的分界线,就是把它变成一个工程产物

# 放进 shell 配置 —— pull 之前先问它
ollama-fit() {
  local budget=12.5   # 你的真实预算:标称 − Windows税 − CUDA context
  python3 -c "
import urllib.request, json, sys
m = sys.argv[1]
ns, tag = m.rsplit(':', 1) if ':' in m else (m, 'latest')
p = ns if '/' in ns else 'library/' + ns
d = json.loads(urllib.request.urlopen(
    f'https://registry.ollama.ai/v2/{p}/manifests/{tag}', timeout=20).read())
s = sum(l['size'] for l in d['layers'] if 'model' in l['mediaType']) / 1e9
print(f'{m}: {s:.1f} GB  ->', 'PULL OK' if s < $budget else f'REJECT (budget ${budget}GB)')
" "$1"
}
$ ollama-fit qwen3:30b
qwen3:30b: 19.0 GB  -> REJECT (budget 12.5GB)

$ ollama-fit gemma4:12b
gemma4:12b: 7.4 GB  -> PULL OK

5 秒钟,不用下载 19GB,不用烧 21 个核,不用写事后总结。

再配一条专治 MoE 的规矩:

# pull 之前看架构,别被名字里的数字骗了
ollama show <model> | grep -E 'architecture|parameters'
# 看到 moe(qwen3moe / mixtral / deepseek2…)→ 按【总参数】算显存

扁鹊的那个回答

魏文王问扁鹊:你们兄弟三人,谁的医术最好?

扁鹊说:长兄最好,中兄次之,我最差。

“长兄于病视神,未有形而除之,故名不出于家; 中兄治病,其在毫毛,故名不出于闾; 若扁鹊者,鑱血脉,投毒药,副肌肤,故名闻于诸侯。” ——《鹖冠子·世贤》

名气最大的那个,是因为他处理的都是已经爆发的重症。而真正最高明的那位,因为病在没有成形时就被除掉了,连”他治好过什么”都没人说得出来

一个从不出事故的系统,和一个事故处理得很漂亮的系统,在旁观者眼里长得不一样;但在工程上,前者是更高的成就。

这也是为什么我说那份排查手册应该被删掉——不是因为它错,而是因为它的成功标志,是再也用不上它


三张表,一件事

层次 问题 答案
现象 CPU 为什么 95%? 模型溢出,20 层丢回 CPU 硬算
机制 为什么 18% offload 损失 97%? Transformer 层严格串行,取最小值不做平均
决策 怎么不再发生? pull 之前一行算术:体积 < 预算

从上往下,是排查;从下往上,是根本不需要排查。


立刻可以做的事

  1. 测出你机器的真实 VRAM 预算,而且要在最忙的时候测。
    # 浏览器、IDE、会议软件全开着跑,测出来的是下限
    nvidia-smi --query-gpu=memory.total,memory.used --format=csv,noheader
    # 真实预算 ≈ (total − used) − 500MB
    
  2. ollama-fit 抄进你的 shell 配置,把 budget 改成你自己测出来的那个数。以后 pull 之前先跑它。

  3. 检查你现有的模型库,把所有超预算的删掉——它们不是”慢一点”,是完全不可用:
    ollama list                               # 对照你的 budget 逐行过
    ollama show <每一个> | grep architecture   # 看到 moe 要额外警惕
    
  4. 修掉一个我踩过的坑ollama stop --all 这个 flag 不存在(我在 v0.13.2 上验证过),而且它报错时 exit code 还是 0,连 set -e 都拦不住。正确写法:
    ollama ps | awk 'NR>1 && NF {print $1}' | xargs -r -n1 ollama stop
    
  5. 把第 2 条发到你团队的频道里。 它在你脑子里是部落知识,在配置文件里才是工程产物;而发出去之后,它才开始替你工作。

最好的排查手册,是那份因为再也没人需要翻开、而在抽屉里落灰的手册。

Updated: