本地部署 Qwen3,80 线程反而慢 2.66 倍

0 阅读10分钟

本地部署 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 的核):

threadsdecode tok/s(5 次中位)5 次采样相对 1 线程
110.6110.46 / 10.60 / 10.61 / 10.67 / 10.691.00×
219.3018.42 / 19.30 / 19.30 / 19.33 / 19.471.82×
431.6729.06 / 31.66 / 31.67 / 31.75 / 31.772.99×
836.2732.70 / 36.04 / 36.27 / 36.32 / 36.393.42×(峰值)
1635.5932.25 / 35.49 / 35.59 / 35.65 / 35.673.35×(饱和)
2027.5725.52 / 27.52 / 27.57 / 27.60 / 27.702.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 个核全用呢?

threadsdecode tok/s(5 次中位)5 次采样
829.3526.64 / 29.25 / 29.35 / 29.41 / 29.47
1624.5421.30 / 24.19 / 24.54 / 24.73 / 24.76
4021.8117.77 / 21.55 / 21.81 / 21.96 / 22.05
8011.049.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。

threadsdecode tok/s端到端 tok/s
416.8663.5
818.6970.4
2014.4854.5
4012.3246.4
8010.7140.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

本文所有数字均为本机实测,非引用。环境与脚本版本见文末参考来源。 作者:丘山录 · 记录本地部署与算力实测