本地部署 Qwen3,80 线程反而慢 2.66 倍
结论先行:这类负载压根不需要那么多核
这句判断来自一次实测量出来的数:1→8 提速 3.42 倍、8→20 掉 24%、80 线程退回单线程水平、跨 NUMA 慢 19%。
环境、命令、量出来的数都在下面,可以直接照着重跑。
我手上有一台 80 逻辑核的服务器,跑一个 6 亿参数的小模型。
它跑不过 8 个线程。
不是差一点,是慢 2.66 倍。而且我一开始不知道,第一件事就是把线程数开到了最大——所有人第一次跑本地模型都会这么干。
这篇文章说的是为什么,以及你该怎么配。
① 先说清楚我拿什么在测
这台机器不是我的台式机,是集群里的一台节点,我管它叫 fn02。
我先把身份读出来,不抄台账:
$ lscpu | grep -E "Model name|Socket|Core|Thread"
Model name: Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz
Socket(s): 2
Core(s) per socket: 20
Thread(s) per core: 2
2 路 × 20 核 × 2 超线程 = 80 个逻辑核。
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0-19 40-59
node 1 cpus: 20-39 60-79
node 0 size: 64135 MB
node 1 size: 64134 MB
两个 NUMA 节点,每个节点一半的核。这一点很关键,后面会说它怎么单独干掉 19% 的性能。
L3 缓存 100 MiB(两个实例),内存 125 GiB,测的时候可用 108 GiB。CPU 调度器是 schedutil,空闲 1200 MHz,上限 3600 MHz。
模型选得很小:Qwen3-0.6B-Q8_0.gguf,unsloth 发布的量化版。
$ stat -c %s Qwen3-0.6B-Q8_0.gguf
639447744
639,447,744 字节,也就是 609.83 MiB。下载花了 38 秒,折算下来约 16.1 MB/s。
推理运行时是 llama-cpp-python。装完 du 量了一下,160 MB。
这个数字我第一次看到的时候愣了一下。我们平时讨论本地部署,讨论的都是显存、是硬盘、是几十 GB 的权重,但装一个推理运行时只要 160 MB,比一个手机 App 大不了多少。
所以「本地部署装不起来」这件事,门槛从来不在软件。
模型加载三次,warm 状态:0.537 / 0.543 / 0.556 秒。常驻内存 RSS 751.9 MB——610 MB 是权重,剩下约 140 MB 是 Python 运行时。
一个小模型,一台大机器。听起来应该是随便跑。
② 我第一件事就是把线程开满
llama-cpp-python 有个 n_threads 参数。我很自然地写了 80——毕竟机器有 80 个逻辑核,不用白不用。
跑出来的结果是 11.04 tok/s。
我不知道这个数是快还是慢,因为它没有参照。于是我做了件更笨也更靠谱的事:把线程数从 1 一路扫到 80,每个配置跑 5 次取中位。
先在同一颗 CPU 的单个 NUMA 节点内测(taskset -c 0-19,只准用 node0 的核):
| threads | decode tok/s(5 次中位) | 5 次采样 | 相对 1 线程 |
|---|---|---|---|
| 1 | 10.61 | 10.46 / 10.60 / 10.61 / 10.67 / 10.69 | 1.00× |
| 2 | 19.30 | 18.42 / 19.30 / 19.30 / 19.33 / 19.47 | 1.82× |
| 4 | 31.67 | 29.06 / 31.66 / 31.67 / 31.75 / 31.77 | 2.99× |
| 8 | 36.27 | 32.70 / 36.04 / 36.27 / 36.32 / 36.39 | 3.42×(峰值) |
| 16 | 35.59 | 32.25 / 35.49 / 35.59 / 35.65 / 35.67 | 3.35×(饱和) |
| 20 | 27.57 | 25.52 / 27.52 / 27.57 / 27.60 / 27.70 | 2.60×(回落) |
这个表里有两件事让我停下来看了很久。
第一件:峰值在 8 线程。 80 个逻辑核,峰值出现在 8 个,占 10%。从 1 线程到 8 线程提速 3.42 倍,这部分符合直觉。
第二件:过了 8 就往下掉。 16 线程 35.59,比 8 线程低 1.9%——基本算平了。但 20 线程掉到 27.57,比 8 线程整整低了 24%。这里没有任何报错,没有任何警告,程序跑得好好的,只是慢了。
一个参数从 8 改成 20,性能掉四分之一。你如果不去扫这个参数,永远不会知道。---
③ 更离谱的在跨节点
我上面那组数据是绑在单个 NUMA 节点内跑的。也就是说,我限制了它只能用 node0 的 20 个核。
那如果放开,让 80 个核全用呢?
| threads | decode tok/s(5 次中位) | 5 次采样 |
|---|---|---|
| 8 | 29.35 | 26.64 / 29.25 / 29.35 / 29.41 / 29.47 |
| 16 | 24.54 | 21.30 / 24.19 / 24.54 / 24.73 / 24.76 |
| 40 | 21.81 | 17.77 / 21.55 / 21.81 / 21.96 / 22.05 |
| 80 | 11.04 | 9.86 / 9.99 / 11.04 / 11.19 / 11.40 |
看第一行和第四行。
同样 8 个线程,单节点内是 36.27,跨节点是 29.35。代码没改,模型没改,线程数没改,只改了 taskset 的绑核范围——慢了 19%。
这就是 NUMA 的代价。线程如果散落在两个节点上,模型的权重页会被反复跨节点拉取。610 MB 的权重本身不大,但每生成一个 token 就要完整读它一遍,跨节点的每次拉取都要走 QPI 链路,成本叠起来就是这 19%。
然后看 80 线程那一行:11.04 tok/s。
它跟 1 线程的 10.61 几乎一模一样。
一台 80 逻辑核、125 GiB 内存、双路 Xeon 的服务器,把线程开满之后,吞吐量退回到了单线程水平。你付了 80 个核的电费,拿到了 1 个核的产出。
④ 为什么:瓶颈从来不是核
这件事的机制其实很干净,我推给你看。
0.6B 模型量化到 Q8_0 之后是 610 MB。大模型生成是自回归的——生成第 N 个 token 时,要把整个模型的权重从头读一遍。
一个 token = 读一次 610 MB。
8 线程下 36.27 tok/s,乘一下:
610 MB × 36.27 /s ≈ 22.1 GB/s
也就是说,跑起来的时候,机器每秒要把权重从内存搬到 CPU 约 22 GB/s。
这颗双路 E5-2673 v4 的理论内存带宽是 2 × 68 GB/s ≈ 136 GB/s。22 只占了 16%。
看起来明明还有大把余量,为什么加核不涨反降?
因为单条生成流是串行依赖的。生成第 N+1 个 token 必须先拿到第 N 个 token 的结果,这个链条没法并行。你能并行的只有「把这 610 MB 搬过来」这一段的指令级并行——8 个线程差不多刚好把内存控制器的队列喂满。
再往上加线程,搬的还是那同一份 610 MB。多出来的线程不干活,它们在抢同一份内存带宽,同时带来调度开销和缓存污染。L3 只有 100 MiB,本来就装不下 610 MB,线程一多,各自的工作集互相挤,命中率进一步下降。
所以这不是「核没用」,是这类负载压根不需要那么多核。你缺的不是算力,是带宽,而带宽在单条流里是喂不满的。
🔴 口径说明:上面那个 22.1 GB/s 是我用「610 MB × 36.27 tok/s」推出来的下限估算,没有扣除 L3 命中(100 MiB 理论上能缓存约 16% 的权重)。它是用来解释趋势的,不是实测的内存带宽读数。
⑤ 读长文和写长文,该配的线程数不一样
上面全是「生成」(decode)的数字。但实际用起来还有一半时间是「读提示词」(prefill)。
这两个阶段的行为是相反的,我单独测了一组:241 token 的提示词 + 生成 64 token。
| threads | decode tok/s | 端到端 tok/s |
|---|---|---|
| 4 | 16.86 | 63.5 |
| 8 | 18.69 | 70.4 |
| 20 | 14.48 | 54.5 |
| 40 | 12.32 | 46.4 |
| 80 | 10.71 | 40.3 |
端到端吞吐(含 prefill)整体高于纯 decode,这符合预期——prefill 阶段是真正的矩阵乘,能吃多核,这一段加核是有用的。
但即使在这个更有利于多核的场景下,80 线程的端到端还是 40.3,低于 8 线程的 70.4。
结论没变。
这组数的实际用处是:如果你主要拿它读长文档、做摘要、做 RAG 的召回重排,线程数可以往上放一点;如果你是让它写长文、连续对话,8 线程左右就该停手。同一个程序,两种用法,两个不同的合适值。
🔴 口径说明:llama-cpp-python 的 usage 只给总时长,prefill 和 decode 是混在一起的,所以「端到端 tok/s」是混合值,不能当纯 decode 数字引用。
⑥ 有件事必须说清楚:这组数不是这颗 CPU 的峰值
fn02 上常驻着一台 libvirt 虚拟机,qemu-system-x86_64 -smp 24 -m 40960,24 核 40 GB,已经跑了 6 天。
我测的时候,load average 在 68 到 287 之间波动。
这是别人的业务,我不能为了测一个数把它停掉。所以:
上表所有绝对 tok/s 都是「共享负载下的下限」,不是这颗 CPU 跑 Qwen3-0.6B 的真实峰值。
我这篇文章能站得住的,是相对关系:1→8 提速 3.42 倍、8→20 掉 24%、80 线程退回单线程水平、跨 NUMA 慢 19%。这些结论是在同一台机器、同一时段、同一份负载下测出来的,横向可比。
不能站得住的是「这颗 CPU 峰值是 36.27 tok/s」——那是错的,真峰值会比这高。
我把这句写在正文里,是因为我在别的地方见过太多「XX 处理器跑出 XX tok/s」的绝对数,没有人交代测试环境的负载。环境一换,数字就没意义了。
⑦ 所以你现在该做什么
如果你手上是台多核服务器或者工作站,正在跑本地模型,做这三件事:
第一,别用默认的 n_threads。 很多封装库默认取 cpu_count(),在你的机器上就是满核。先把它改成 8 试一次,再往上加,加一次测一次。
第二,把线程绑在单个 NUMA 节点内。 我这次实测跨节点白丢 19%,不需要任何成本就能拿回来:
# 先看拓扑
numactl --hardware
# 只准用 node0 的核
taskset -c 0-19 python your_script.py
# 或者用 numactl 直接绑节点
numactl --cpunodebind=0 --membind=0 python your_script.py
--membind=0 这个参数别省。只绑核不绑内存,权重还是可能被分配到远端节点上,省不掉那次跨节点拉取。
第三,判断「能不能跑」时,算带宽不算核数。 拿模型的权重大小除以你这台机器的内存带宽,得到一个理论下限,再打个对折。核数只影响你能不能把提示词快速读完,不影响它生成得多快。
⑧ 最后一句
我知道今天有个 35B 的扩散语言模型在传,Clover-35B-A3B-Preview,Apache-2.0,BF16 权重按官方模型卡是 69 GB,131K 上下文。
很多人看到「激活只有 3B」就以为能塞进 16G 卡里。不能。MoE 省的是计算量,不是显存——69 GB 的权重一个字节都不会少,你要跑它,先准备 69 GB 的地方放它。
我没跑成它。今天时间盒给到 00:50,69 GB 从公网拉按我这里的 16 MB/s 要 72 分钟,还没算 SGLang 部署和验证。所以我只测了 Qwen3-0.6B,Clover 的配置数字全部来自官方模型卡,不是我实测的。
这条界线我划在这:跑过的写跑过,没跑的说没跑。
顺便说一句,69 GB 这个东西如果你从公网拉要 72 分钟,但从本地 NVMe 读只要 30 秒——这个差距我在下一篇里算了账。
参考来源
- Qwen3-0.6B-Q8_0.gguf:Hugging Face
unsloth/Qwen3-0.6B-GGUF(Apache-2.0) - llama-cpp-python:GitHub
abetlen/llama-cpp-python - Clover-35B-A3B-Preview 参数与 69 GB BF16 权重:Hugging Face
Diffuspace/Clover-35B-A3B-Preview官方模型卡(Apache-2.0,2026-10-28 发布) - 机器规格与全部 tok/s:本机实测,2026-10-30 00:08–00:30
本文所有数字均为本机实测,非引用。环境与脚本版本见文末参考来源。 作者:丘山录 · 记录本地部署与算力实测