排查的终点,是让排查消失:一夜四次 GPU 翻车实录
“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.draw比utilization.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 rate 比 eval 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 之前一行算术:体积 < 预算 |
从上往下,是排查;从下往上,是根本不需要排查。
立刻可以做的事
- 测出你机器的真实 VRAM 预算,而且要在最忙的时候测。
# 浏览器、IDE、会议软件全开着跑,测出来的是下限 nvidia-smi --query-gpu=memory.total,memory.used --format=csv,noheader # 真实预算 ≈ (total − used) − 500MB -
把
ollama-fit抄进你的 shell 配置,把budget改成你自己测出来的那个数。以后 pull 之前先跑它。 - 检查你现有的模型库,把所有超预算的删掉——它们不是”慢一点”,是完全不可用:
ollama list # 对照你的 budget 逐行过 ollama show <每一个> | grep architecture # 看到 moe 要额外警惕 - 修掉一个我踩过的坑:
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 - 把第 2 条发到你团队的频道里。 它在你脑子里是部落知识,在配置文件里才是工程产物;而发出去之后,它才开始替你工作。
最好的排查手册,是那份因为再也没人需要翻开、而在抽屉里落灰的手册。