本地跑大模型多快才算正常?一个除法算出 token/s 天花板(附速查表)
📌 本文部分内容由 AI 辅助整理,已经人工核对。文中所有 token/s 都是按公式推出来的理论上限,不是实测——我手上没有文中提到的这些硬件,一个实测数字都不会替你编。脚本是我自己写的,贴的输出是真跑出来的。硬件带宽的算法在第三节,你可以自己核。
有个问题我见过很多次:
模型跑起来了,输出也是对的,就是慢。于是换更强的卡、加更多核、调各种参数,token/s 却几乎没动。
这篇想说的是:单请求解码这件事,卡住你的通常不是算力,是内存带宽。而它的上限,一个除法就能算出来——算完你就知道,手上这套配置到底还有没有救。
一、先给结论:一个除法
理论上限 token/s = 内存带宽(GB/s) ÷ 模型权重大小(GB)
就这么简单。
70B 的模型量化到 Q4_K_M 大约 40.6 GB,放在一张 1008 GB/s 带宽的卡上:
1008 ÷ 40.6 = 24.8 token/s
实测只会比这个低,一般能到六到八成。也就是说这套配置的现实区间大概是 15~20 token/s。
如果你实测出来是 18,那恭喜——你已经把这套硬件榨得差不多了,再怎么调参也就这样。想更快只有两条路:把分子做大(换带宽更高的硬件),或者把分母做小(换更激进的量化)。
如果你实测只有 5,那说明有别的问题,值得去查。
这个除法真正的用处,是让你知道该不该继续折腾。
二、为什么是带宽,不是算力
这一步想明白了,上面那个式子就是自然而然的。
自回归解码是一个 token 一个 token 往外吐的。每吐一个 token,都要把整个模型的权重完整地过一遍。
不是过一部分——是全部。因为每一层都要参与这次计算。
所以生成 100 个 token,权重就要被完整读 100 遍。
那么每个 token 至少要花的时间就是:
把全部权重从显存搬到计算单元所需的时间 = 权重大小 ÷ 内存带宽
取倒数,就是每秒最多能吐多少个 token。
这里的关键是:这段时间里,计算单元大部分时候在等数据。 矩阵乘法本身很快,慢的是把几十 GB 的权重搬过来。所以你堆更多算力,只是让它等得更久而已。
这就解释了几件很常见的事:
为什么换了更强的卡,token/s 没怎么涨。 你要看的是那张卡的显存带宽涨了多少,不是看算力标称值。这两个数字的增长幅度经常对不上。
为什么量化能明显提速。 它不是让计算变快了,是直接把分母变小了。权重从 140 GB 压到 40 GB,要搬的数据少了三倍多,速度自然上去。
为什么纯 CPU 跑大模型慢到没法用。 双通道 DDR5 的带宽大概 90 GB/s 量级,而显卡是一千往上——差一个数量级,速度就差一个数量级。这跟 CPU 有多少核没关系。
三、带宽这个数从哪来
不用背表,自己就能算:
带宽(GB/s) = 位宽(bit) × 等效频率(Gbps) ÷ 8
除以 8 是因为要从 bit 换成 byte。
拿一张常见的卡验算:某张 24GB 的消费级旗舰卡,规格页上写着 384-bit 位宽、GDDR6X、21 Gbps 等效频率。
384 × 21 ÷ 8 = 1008 GB/s
和厂商标称的带宽一致。 位宽和频率都印在规格页上,任何一张卡你都可以自己推。
内存那边同理,双通道 DDR5-5600 大约是 89.6 GB/s(5600 × 2 通道 × 8 字节)。
我刻意不列具体型号的对照表。型号太多、规格年年变,列了很快就过期;而位宽和频率印在每张卡的规格页上,你自己算一遍比抄我的表可靠。方法比数字耐用。
四、速查表:带宽档 × 模型规模
把上面那个除法铺开,就是一张表。纵轴是带宽档位,不是具体型号——你按第三节算出自己那套配置的带宽,对号入座就行。这张表是第五节脚本 --table 直接跑出来的,不是我手打的:
理论解码上限速查表(Q4_K_M,单位 token/s)
纵轴是内存带宽,横轴是参数量。实测按 60~80% 折。
7B(4G) 14B(8G) 32B(19G) 70B(41G) 120B(70G)
---------------------------------------------------------------------------------------
双通道 DDR5-5600(纯 CPU) 90 22.1 11.0 4.8 2.2* 1.3*
八通道 DDR5(服务器 CPU) 205 50.4 25.2 11.0 5.0 2.9*
中端独显 / 部分统一内存 448 110.3 55.2 24.1 11.0 6.4
消费级旗舰独显 1008 248.3 124.1 54.3 24.8 14.5
HBM 加速卡 2000 492.6 246.3 107.8 49.3 28.7
HBM3 加速卡 3350 825.1 412.6 180.5 82.5 48.1
* = 低于 3 token/s,等一句话要几十秒,基本没法交互使用。
横着看:同一套硬件,模型每大一档,速度就等比例掉一档。
竖着看:同一个模型,带宽翻倍速度就翻倍——这是买硬件时该看的那个数。
几个值得单独拎出来的格子:
纯 CPU 那一行,到 70B 就只剩 2.2 了。 这是什么概念——生成一段两百字的回答要等一分半。不是"慢一点",是没法交互。这跟你的 CPU 有多少核没关系,是带宽的物理限制。
7B 在旗舰卡上是 248。 远远超过人的阅读速度,这就是为什么小模型在本地体感那么好。
换量化档,整张表会平移。 表里默认 Q4_K_M,加 --quant Q8_0 重印一次,所有数字大约折半——因为分母翻倍了。同一张卡跑 70B,FP16 只有 7.2,Q4_K_M 是 24.8,差 3.45 倍。(FP16 那档还有个前提:140 GB 单卡根本放不下,得多卡拆,那又是另一笔账。)
五、脚本
零依赖,只用标准库。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
decode_speed.py —— 本地推理的理论解码上限,以及你离它有多远。
自回归解码每吐一个 token,都要把**整个模型的权重**从显存/内存里过一遍。
所以单请求的解码速度不是被算力卡住的,是被**内存带宽**卡住的:
理论上限 token/s = 内存带宽(GB/s) ÷ 模型权重大小(GB)
这个式子解释了很多"为什么":
· 为什么换更强的卡但 token/s 没怎么涨 —— 你换的是算力,不是带宽
· 为什么纯 CPU 跑 70B 慢到没法用 —— 双通道 DDR5 的带宽比显存低一个数量级
· 为什么量化能提速 —— 它直接把式子的分母变小了
⚠️ 边界(重要):
· 算的是**理论上限**,实测只会更低,通常能到 60~80% 就不错了
· 只对**单请求解码**成立。批量并发是另一回事——那时候权重读一次可以
喂多个请求,瓶颈会从带宽转向算力
· 不含 prefill(处理你输入的那段)。长 prompt 的首 token 延迟是算力问题
· KV cache 也要占带宽,上下文越长实际越慢,这里没算进去
只用标准库。
python3 decode_speed.py --size 40 --bw 1008
python3 decode_speed.py --params 70 --quant Q4_K_M --bus 384 --gbps 21
python3 decode_speed.py --demo
python3 decode_speed.py --table
"""
import argparse
import sys
import unicodedata
def wpad(s, width):
"""
按**显示宽度**左对齐补空格。
不能直接用 str.ljust —— 它数的是字符个数,而中文、日文、全角括号
在等宽终端里一个字占两格。混排时用 ljust 排出来的表,带中文的行会
整体右移,列就散了。
"""
w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
return s + " " * max(0, width - w)
# 每参数占多少字节。GGUF 的 K-quant 实际位宽含缩放系数开销,
# 这里用社区通行的经验值,不是官方定义——真实文件大小以 du -sh 为准。
BPW = {
"FP16": 2.0, "BF16": 2.0, "FP8": 1.0, "INT8": 1.0,
"Q8_0": 1.06, "Q6_K": 0.82, "Q5_K_M": 0.71, "Q4_K_M": 0.58,
"Q4_0": 0.56, "Q3_K_M": 0.45, "Q2_K": 0.33,
}
def bandwidth_from_bus(bus_bits, gbps):
"""
从位宽和等效频率算显存带宽。
带宽(GB/s) = 位宽(bit) × 等效频率(Gbps) ÷ 8
例:RTX 4090 是 384-bit GDDR6X @ 21 Gbps
384 × 21 / 8 = 1008 GB/s ← 与厂商标称一致
自己查卡的规格页就能算,不用背表。
"""
return bus_bits * gbps / 8
def weight_size_gb(params_b, quant):
"""参数量(B) × 每参数字节数 = 权重大小(GB)。"""
if quant not in BPW:
raise SystemExit(f"未知量化档 {quant},可选:{', '.join(BPW)}")
return params_b * BPW[quant]
def report(size_gb, bw_gbs, label=""):
if bw_gbs <= 0 or size_gb <= 0:
raise SystemExit("带宽和模型大小都得是正数")
ceil = bw_gbs / size_gb
if label:
print(f"\n {label}")
print(f" 模型权重 {size_gb:>8.1f} GB")
print(f" 内存带宽 {bw_gbs:>8.1f} GB/s")
print(f" ── 理论上限 {ceil:>7.1f} token/s")
print(f" 现实区间 {ceil * 0.6:>7.1f} ~ {ceil * 0.8:.1f} token/s(按 60~80% 折)")
if ceil < 3:
print(" ⚠️ 这个组合基本没法交互使用,等一句话要几十秒")
elif ceil < 10:
print(" ⚠️ 勉强能用,但明显慢于人的阅读速度")
return ceil
DEMO = [
# (说明, 权重GB, 带宽GB/s)
("70B Q4_K_M 放在 RTX 4090 上(384bit×21Gbps)", 70 * BPW["Q4_K_M"], bandwidth_from_bus(384, 21)),
("同一个模型,改用纯 CPU + 双通道 DDR5-5600", 70 * BPW["Q4_K_M"], 89.6),
("7B Q4_K_M 放在 RTX 4090 上", 7 * BPW["Q4_K_M"], bandwidth_from_bus(384, 21)),
("7B Q4_K_M 用纯 CPU + 双通道 DDR5-5600", 7 * BPW["Q4_K_M"], 89.6),
("70B 不量化直接上 FP16", 70 * BPW["FP16"], bandwidth_from_bus(384, 21)),
]
# 速查表用的两个轴。带宽只写档位不写型号——型号年年换,档位不会,
# 你按第三节的式子把自己那张卡/那套内存算出来,对号入座就行。
BW_TIERS = [
(89.6, "双通道 DDR5-5600(纯 CPU)"),
(204.8, "八通道 DDR5(服务器 CPU)"),
(448.0, "中端独显 / 部分统一内存"),
(1008.0, "消费级旗舰独显"),
(2000.0, "HBM 加速卡"),
(3350.0, "HBM3 加速卡"),
]
PARAM_TIERS = [7, 14, 32, 70, 120]
def table(quant="Q4_K_M"):
"""带宽档 × 模型规模的理论上限速查表。数字全是那个除法现算的。"""
sizes = [(n, weight_size_gb(n, quant)) for n in PARAM_TIERS]
print(f"\n 理论解码上限速查表({quant},单位 token/s)")
print(f" 纵轴是内存带宽,横轴是参数量。实测按 60~80% 折。\n")
head = " " + wpad("", 32) + "".join(f"{n}B({s:.0f}G)".rjust(11) for n, s in sizes)
print(head)
print(" " + "-" * (len(head) - 2))
for bw, name in BW_TIERS:
row = " " + wpad(name, 26) + f"{bw:>6.0f}"
for _, size in sizes:
v = bw / size
# 标记只用 ASCII。⚠ 这类符号是 Ambiguous 宽度,同一份输出在不同
# 终端里占 1 格或 2 格都有可能,拿它做表内标记,列迟早会串。
row += (f"{v:>10.1f}*" if v < 3 else f"{v:>11.1f}")
print(row)
print("\n * = 低于 3 token/s,等一句话要几十秒,基本没法交互使用。")
print(" 横着看:同一套硬件,模型每大一档,速度就等比例掉一档。")
print(" 竖着看:同一个模型,带宽翻倍速度就翻倍——这是买硬件时该看的那个数。")
def main():
p = argparse.ArgumentParser(description="算本地推理的理论解码上限")
p.add_argument("--size", type=float, help="模型权重大小,GB(知道文件多大就直接填这个)")
p.add_argument("--params", type=float, help="参数量,单位 B(如 70 表示 70B)")
p.add_argument("--quant", default="Q4_K_M", help=f"量化档,默认 Q4_K_M。可选:{', '.join(BPW)}")
p.add_argument("--bw", type=float, help="内存带宽,GB/s")
p.add_argument("--bus", type=float, help="显存位宽,bit(配合 --gbps)")
p.add_argument("--gbps", type=float, help="显存等效频率,Gbps(配合 --bus)")
p.add_argument("--demo", action="store_true", help="跑几个对照组")
p.add_argument("--table", action="store_true", help="打印带宽档 × 模型规模速查表")
a = p.parse_args()
if a.table:
table(a.quant)
return 0
if a.demo:
print("=" * 62)
print(" 同一件事的五种配法,看带宽怎么决定速度")
print("=" * 62)
for label, size, bw in DEMO:
report(size, bw, label)
print("\n 最后一组和第一组的差别只有量化档:分母翻了 3.4 倍,速度就掉这么多。")
print(" 第一组和第二组的差别只有硬件:带宽差 11 倍,速度就差 11 倍。")
return 0
if a.size:
size = a.size
elif a.params:
size = weight_size_gb(a.params, a.quant)
print(f" {a.params}B × {BPW[a.quant]} 字节/参数({a.quant})= {size:.1f} GB")
else:
p.print_help()
return 1
if a.bw:
bw = a.bw
elif a.bus and a.gbps:
bw = bandwidth_from_bus(a.bus, a.gbps)
print(f" {a.bus:.0f}bit × {a.gbps}Gbps / 8 = {bw:.1f} GB/s")
else:
print(" 需要 --bw,或者 --bus 加 --gbps", file=sys.stderr)
return 1
report(size, bw)
return 0
if __name__ == "__main__":
sys.exit(main())
六、跑给你看
python3 decode_speed.py --demo
==============================================================
同一件事的五种配法,看带宽怎么决定速度
==============================================================
70B Q4_K_M 放在 RTX 4090 上(384bit×21Gbps)
模型权重 40.6 GB
内存带宽 1008.0 GB/s
── 理论上限 24.8 token/s
现实区间 14.9 ~ 19.9 token/s(按 60~80% 折)
同一个模型,改用纯 CPU + 双通道 DDR5-5600
模型权重 40.6 GB
内存带宽 89.6 GB/s
── 理论上限 2.2 token/s
现实区间 1.3 ~ 1.8 token/s(按 60~80% 折)
⚠️ 这个组合基本没法交互使用,等一句话要几十秒
7B Q4_K_M 放在 RTX 4090 上
模型权重 4.1 GB
内存带宽 1008.0 GB/s
── 理论上限 248.3 token/s
现实区间 149.0 ~ 198.6 token/s(按 60~80% 折)
7B Q4_K_M 用纯 CPU + 双通道 DDR5-5600
模型权重 4.1 GB
内存带宽 89.6 GB/s
── 理论上限 22.1 token/s
现实区间 13.2 ~ 17.7 token/s(按 60~80% 折)
70B 不量化直接上 FP16
模型权重 140.0 GB
内存带宽 1008.0 GB/s
── 理论上限 7.2 token/s
现实区间 4.3 ~ 5.8 token/s(按 60~80% 折)
⚠️ 勉强能用,但明显慢于人的阅读速度
最后一组和第一组的差别只有量化档:分母翻了 3.4 倍,速度就掉这么多。
第一组和第二组的差别只有硬件:带宽差 11 倍,速度就差 11 倍。
第四节那张速查表,是这条命令印的:
python3 decode_speed.py --table
python3 decode_speed.py --table --quant Q8_0 # 换个量化档重印
算你自己的配置,两种填法。知道模型文件多大就直接填:
python3 decode_speed.py --size 40 --bw 1008
只知道参数量和卡的规格,让它替你换算:
python3 decode_speed.py --params 70 --quant Q4_K_M --bus 384 --gbps 21
70.0B × 0.58 字节/参数(Q4_K_M)= 40.6 GB
384bit × 21.0Gbps / 8 = 1008.0 GB/s
模型权重 40.6 GB
内存带宽 1008.0 GB/s
── 理论上限 24.8 token/s
现实区间 14.9 ~ 19.9 token/s(按 60~80% 折)
那张每参数字节数的表是社区通行的经验值,不是官方定义——K-quant 的实际位宽含缩放系数开销,各家统计略有出入。知道文件实际多大,就用 --size 直接填,比估算准。
七、这个式子不管什么
边界不说清楚,工具就会被用错地方。
① 它算的是理论上限,不是你能拿到的数。 实测一定更低。中间还有 kernel 效率、显存访问模式、框架开销、采样耗时。六到八成是个粗略的经验区间,不是保证。
② 它只对单请求解码成立。 批量并发完全是另一回事——那时候权重读一遍可以同时喂多个请求,瓶颈会从带宽转向算力。所以线上服务的吞吐不能拿这个式子算,它算的是"你一个人用的时候有多快"。
③ 它不含 prefill。 处理你输入的那一大段 prompt 是另一个阶段,那个阶段是算力受限的。长 prompt 首 token 要等很久,跟这个式子无关。
④ 它没算 KV cache。 上下文越长,KV cache 越大,也要占带宽,实际会比算出来的更慢。
⑤ 多卡的带宽不能简单相加。 张量并行的时候卡间通信可能成为新瓶颈,这取决于你的互联方式。式子里那个分子写成"总带宽"是乐观估计。
第五条是我最没底的一条。单卡的账很清楚,多卡拆开之后分子到底该按什么算,取决于切分策略和互联带宽,我没有多卡环境实测过,不敢给系数。如果你手上有多卡实测,实测除以"总带宽算出来的理论值"大概是多少——这个数我很想知道。
⑥ MoE 的分母不是总参数量。 这一条容易踩。
MoE 每一步只把 token 路由给一小部分专家,没被选中的专家这一步根本不参与计算,也就不需要读进来。所以带宽这边,分母更接近激活参数对应的权重大小,而不是总参数。一个总参很大、激活很小的 MoE,解码速度可以比同样总参的稠密模型快得多——这正是 MoE 这个结构想要的效果。
但显存那边是另一回事:所有专家都得装下。 因为下一个 token 可能路由到任何一个专家,你没法只留一部分在显存里。
所以同一个 MoE 模型,两笔账的分母是不一样的:
- 显存够不够 → 按总参数算,全都得装下
- 跑多快 → 按激活参数算,每步只读被选中的那部分
这两个数经常差一个数量级,混在一起算就会得出很离谱的结论——要么以为自己跑得动其实装不下,要么以为会慢得没法用其实挺快。用脚本算 MoE 的速度上限时,--size 请填激活部分的大小,不是整个文件的大小。
还有个附带的坑:批量并发的时候这条又不成立了。不同请求会路由到不同专家,攒在一起几乎所有专家都得读一遍,分母又退回接近总参数。MoE 单请求快、并发时优势缩水,就是这个原因。
八、怎么在你自己机器上验一遍
我没有这些硬件,但验证方法是通用的,你可以拿自己的机器走一遍。
第一步,量一个干净的数。 用一个短 prompt(避免 prefill 干扰),生成长度设大一点(比如 256),单请求跑,别开并发。llama.cpp 跑完会直接打印 eval time 和 tokens per second,vLLM 和 Ollama 也都有对应的统计。多跑三次取中位数,第一次通常偏慢。
第二步,算理论值。 模型文件实际多大就填多大(du -sh 看一眼),带宽按第三节自己算。
第三步,看比值。
| 实测 ÷ 理论 | 说明 |
|---|---|
| 60% ~ 80% | 正常,这套配置基本榨干了 |
| 80% 以上 | 很好,或者你的带宽数填大了 |
| 40% ~ 60% | 偏低但不离谱,可能是框架开销或采样配置 |
| 低于 40% | 值得查,往下看 |
比值特别低的时候,先查这几件事:
- 权重是不是全在显存里。 只要有一部分溢到内存,那部分就得按内存带宽算,整体会被拖垮。llama.cpp 看
-ngl是不是把所有层都送上去了,日志里会写实际 offload 了多少层 - 上下文是不是拖太长。 KV cache 也吃带宽,长上下文会明显拉低实际速度
- 是不是多卡拆分了。 卡间通信可能成为新瓶颈,这时候分子不能按总带宽算
- 采样参数。 某些复杂的采样策略在每步都有额外开销,虽然通常占比不大
如果比值很正常,那结论就很明确了: 这套硬件加这个量化档,天花板就在这儿。继续调参数是没有意义的,该动的是分子或分母。
照着走一遍。 假设你手上是一张 24 GB 的旗舰卡,跑 32B 的 Q4_K_M,实测 30 token/s——这个数正常吗?
查表:32B Q4_K_M 约 18.6 GB,带宽 1008,理论上限 54.3。比值 30 ÷ 54.3 = 55%,落在"偏低但不离谱"那档。结论是不算异常,但还有空间。这时候最值得先试的是把上下文长度降下来重测一次——如果比值明显回升,就确认是 KV cache 在吃带宽。
换个数字:同样这套配置,实测只有 12 token/s,比值 22%。这就明显不对了。第一件该查的是权重是不是真的全在显存里——18.6 GB 的模型放进 24 GB 的卡本来绰绰有余,但上下文一开大,KV cache 会把显存挤掉一块,框架可能自动把一部分层挪回内存,那部分就得按 90 GB/s 那一档算了。日志里的 offload 层数会露馅。
这两个例子的差别值得注意:55% 和 22% 指向的排查顺序是不一样的。 比值告诉你的不只是"快不快",还有"该从哪儿查起"——这才是把它算出来的意义。
九、所以想快一点该怎么办
顺着这个式子看,可选项其实很清楚——要么动分子,要么动分母。
动分母(模型侧),成本最低:
- 换更激进的量化档。从 FP16 到 Q4,分母小三倍多
- 换更小的模型。7B 和 70B 差十倍,很多任务上小模型够用
- 别用"能跑就行"的思路选档位——先算一下这个档位的速度上限你能不能接受
动分子(硬件侧),看清楚再花钱:
- 买卡之前先看规格页上的显存带宽,而不是只看算力和显存容量
- 显存容量决定"能不能跑",显存带宽决定"跑多快",这是两件事
- 纯 CPU 方案在小模型上可用,上了几十 B 就基本没戏——这是带宽的物理限制,加核数解决不了
还有一类不在这个式子里的办法: 投机解码、批处理、缓存复用,它们绕开了"每个 token 都要过一遍全部权重"这个前提。那是另一个话题了。
小结
- 单请求解码是内存带宽受限的:每吐一个 token 都要把全部权重过一遍
- 理论上限 = 带宽 ÷ 权重大小,实测能到六到八成
- 带宽自己就能算:位宽 × 等效频率 ÷ 8,不用背表,规格页上都有
- 同一个 70B,独显和纯 CPU 差 11 倍;同一张卡,FP16 和 Q4 差 3.45 倍
- 显存容量决定能不能跑,显存带宽决定跑多快——买之前分清楚
- 这个式子不管并发、不管 prefill、不管 KV cache,别拿它算线上服务吞吐
文中所有 token/s 都是公式推出来的理论值。 你自己的机器跑出来是多少,以你的为准——如果实测接近理论值的六到八成,说明这套配置已经榨得差不多了;如果差得远,那说明有别的问题值得查。
更多推荐


所有评论(0)