📌 本文部分内容由 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 都是公式推出来的理论值。 你自己的机器跑出来是多少,以你的为准——如果实测接近理论值的六到八成,说明这套配置已经榨得差不多了;如果差得远,那说明有别的问题值得查。

更多推荐