KV Cache 与 PagedAttention 详解:理论推导 + RTX 3090 实测数据

一、前言

前两篇文章分别建立了 BF16 基线和 AWQ INT4 量化实验,我们发现了一个有趣的现象:AWQ 量化把模型权重从 14 GB 压缩到 4 GB,TPOT 降了 65%,但吞吐却翻了一倍多------这个倍数远超 TPOT 降幅能解释的范围。

吞吐翻倍的另一半原因藏在 KV Cache 里。

本文不做新的优化实验,而是深入理解 LLM 推理中最关键的显存消费者------KV Cache。通过理论推导和实测验证,回答以下问题:

  • KV Cache 是什么?为什么它是高并发场景下的显存瓶颈?
  • vLLM 的 PagedAttention 是怎么管理 KV Cache 的?
  • KV Cache 占用和序列长度、并发数的定量关系是什么?
  • AWQ 量化后吞吐翻倍,KV Cache 在其中扮演了什么角色?

先看结论:

实验 关键发现
单请求 KV Cache vs 序列长度 严格线性关系,实测 ~55 KB/token,与理论值 56 KB 一致
并发数 vs KV Cache 占用 线性增长,BF16 模型在 conc≈88 时 KV Cache 打满
BF16 vs AWQ 的 KV Cache 容量 AWQ 省出 10 GB 给 KV Cache,容量扩大 3 倍(5306 → 15839 blocks)

最重要的洞察:AWQ 量化的吞吐翻倍不只是因为 TPOT 降低(带宽收益),更因为模型权重压缩后省出的显存全部给了 KV Cache,系统能同时服务 3 倍数量的请求------这是吞吐翻倍的真正推手。

二、KV Cache 与 PagedAttention

2.1 LLM 推理的两个阶段

LLM 生成文本时,每次只输出一个 token,依赖前面所有 token 的信息,直到达到最大长度或生成结束符(EoS)。整个推理过程分为两个阶段:

Prefill 阶段 :把用户输入(prompt)一次性送进模型,计算出所有输入 token 对应的 Key-Value 对,并生成第一个输出 token。这个阶段是计算密集型的,GPU 算力是瓶颈。

Decode 阶段 :逐个生成后续 token,每一步都需要用到输入和之前所有已生成 token 的 Key-Value 对。这个阶段是访存密集型的,显存带宽是瓶颈。

Prefill 阶段(一次性处理所有输入),以输入"中国首都是"为例:

模型一次性计算 K 1 V 1 , K 2 V 2 , K 3 V 3 , K 4 V 4 , K 5 V 5 K_1 V_1, K_2 V_2, K_3 V_3, K_4 V_4, K_5 V_5 K1V1,K2V2,K3V3,K4V4,K5V5,并生成第一个输出 token:"北"。这一步的耗时就是 TTFT。

Decode 阶段(逐个生成),每步只计算一个新 token 的 KV,并存入 cache:

  • 生成"北"时:从 cache 读取 K 1 ... K 5 K_1 \dots K_5 K1...K5,计算新的 K 6 V 6 K_6 V_6 K6V6 存入 cache,输出"京"
  • 生成"京"时:从 cache 读取 K 1 ... K 6 K_1 \dots K_6 K1...K6,计算新的 K 7 V 7 K_7 V_7 K7V7 存入 cache,输出"。"
  • 生成"。"后触发 EoS,结束。每步耗时就是 TPOT。

2.2 KV Cache------用空间换时间

在 Decode 阶段,每生成一个新 token 都需要与前面所有 token 做 Attention 计算。Attention 的核心公式为:

A t t e n t i o n ( Q , K , V ) = s o f t m a x ( Q K T d k ) V \mathrm{Attention}(Q, K, V) = \mathrm{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V Attention(Q,K,V)=softmax(dk QKT)V

其中 Q Q Q 是当前 token 的 Query 向量, K K K 和 V V V 是所有历史 token 的 Key 和 Value 向量。

如果每步都重新计算所有 K K K、 V V V,计算量是 O ( N 2 ) O(N^2) O(N2)。KV Cache 的本质是用显存空间换计算时间 ------把已经计算过的 K K K、 V V V 缓存在显存里,后续直接读取。

没有 KV Cache(每步重算) :生成第 N N N 个 token 时,需要重新计算 K 1 V 1 ... K N − 1 V N − 1 K_1 V_1 \dots K_{N-1} V_{N-1} K1V1...KN−1VN−1,总计算量 O ( N 2 ) O(N^2) O(N2)。

有 KV Cache(缓存复用)

  • 生成第 1 个 token:计算 K 1 V 1 K_1 V_1 K1V1,存入 cache
  • 生成第 2 个 token:从 cache 读 K 1 V 1 K_1 V_1 K1V1,只算 K 2 V 2 K_2 V_2 K2V2,存入 cache
  • 生成第 3 个 token:从 cache 读 K 1 V 1 , K 2 V 2 K_1 V_1, K_2 V_2 K1V1,K2V2,只算 K 3 V 3 K_3 V_3 K3V3,存入 cache
  • ......
  • 生成第 N N N 个 token:从 cache 读 K 1 ... K N − 1 K_1 \dots K_{N-1} K1...KN−1,只算 K N V N K_N V_N KNVN,总计算量 O ( N ) O(N) O(N)

每步只需计算一个新 token 的 KV 并存入 cache,其余全部从 cache 读取。代价是 KV Cache 会持续占用显存,并且随着序列长度和并发数线性增长

2.3 KV Cache 大小与序列长度的关系

KV Cache 的大小和输入长度 + 输出长度成正比:

KV Cache = n l a y e r s × 2 × n k v h e a d s × d h e a d × b d t y p e × L \text{KV Cache} = n_{layers} \times 2 \times n_{kvheads} \times d_{head} \times b_{dtype} \times L KV Cache=nlayers×2×nkvheads×dhead×bdtype×L

其中 L L L 为序列总长度(输入 + 已生成 token 数), b d t y p e b_{dtype} bdtype 为数据类型的字节数。以 Qwen2.5-7B 为例:

单 token KV = 28 × 2 × 4 × 128 × 2 = 57344 bytes ≈ 56 KB \text{单 token KV} = 28 \times 2 \times 4 \times 128 \times 2 = 57344 \text{ bytes} \approx 56 \text{ KB} 单 token KV=28×2×4×128×2=57344 bytes≈56 KB

随着序列长度和并发数增长,KV Cache 线性增长:

序列长度 单请求 KV Cache 32 并发总占用
512 token 28 MB 896 MB
2048 token 112 MB 3.6 GB
4096 token 224 MB 7.2 GB
8192 token 448 MB 14.3 GB

对比 AWQ INT4 模型权重(约 4 GB),KV Cache 在高并发 + 长序列场景下占用的显存远超模型本身。这就是上一篇实验里 conc=128 时吞吐开始饱和的根本原因。

2.4 传统 KV Cache 管理的问题

在 vLLM 之前,KV Cache 的分配方式是按最大序列长度预分配连续内存。比如请求 A 实际只用了 200 个 token,但系统会按 max_seq_len=4096 预分配一整块连续显存,剩余 3896 个 token 的空间全部浪费。

这种方式存在三个严重问题:

内部碎片(Internal Fragmentation) :预分配按最大长度,但实际生成长度无法预知,大量预留空间被浪费。论文统计实际浪费比例高达 60~80%

外部碎片(External Fragmentation):要求连续内存块,即使剩余显存足够,也可能因为碎片化而无法分配给新请求。

无法共享:不同请求即使有相同的 system prompt(prefill 部分 KV 完全一样),也要各自存一份,造成冗余。

2.5 PagedAttention------像操作系统管理内存一样管理 KV Cache

vLLM 的核心创新是 PagedAttention,灵感来自操作系统的虚拟内存和分页机制------vLLM 的 "v" 就是 virtual 的意思。

核心思路:把 KV Cache 拆成固定大小的 Block(页),不要求物理连续,按需动态分配。

以序列 "I love cats so much . Do you love me who" 为例,block_size=4:

这些 Block 在物理显存(Physical Page)上不需要连续存放 ,通过 KV Cache Manager 中的 Block Table(类似 OS 的页表)做映射:

复制代码
Block Table(逻辑 → 物理映射):
  Block 1 → 物理页 3
  Block 3 → 物理页 1
  Block 5 → 物理页 4

Attention 计算时,Query vector "who" 按照 Block Table 的映射,依次从物理上不连续的 Block 中读取 K、V,分块计算 Attention 分数,最后汇总得到最终输出。因为每次只有一小部分 KV Cache 需要加载到 GPU 片上缓存,访存局部性反而更好。

2.6 PagedAttention 的三大收益

收益 1:几乎零浪费

不再按最大长度预分配,而是生成多少 token 就分配多少 Block,序列结束立即回收:

复制代码
传统:请求用 200 token,预分配 4096 token → 浪费 95%
vLLM:请求用 200 token,分配 13 个 Block(13 × 16 = 208)→ 浪费 4%

收益 2:消除外部碎片

Block 不需要连续,任何空闲 Block 都能被任何请求使用,就像 OS 的分页消除了外部碎片一样。

收益 3:跨请求共享

多个请求如果有相同的 prefix(比如同样的 system prompt),可以共享同一组 Block,通过引用计数管理,只在写入时复制(Copy-on-Write):

复制代码
请求 A:system prompt [Block 0][Block 1] → 用户问题 [Block 5][Block 6]
                         ↑         ↑
请求 B:system prompt [Block 0][Block 1] → 用户问题 [Block 8][Block 9]
                   共享同一份!不重复存储

这就是第一篇基线文章里 vLLM metrics 中 enable_prefix_caching=True 的基础。

三、KV Cache 占用实测

3.1 实验设计

前面从理论上推导了单个 token 的 KV Cache 大小为 56 KB(BF16),现在通过实验来验证。

实验思路:固定输入内容,改变 max_tokens 控制输出长度,通过 vLLM 的 /metrics 接口实时采集 KV Cache 峰值占用,与理论值对比。

vLLM 启动配置(BF16 基线模型,无量化):

bash 复制代码
python -m vllm.entrypoints.openai.api_server \
    --model /root/autodl-tmp/models/qwen2.5-7b-instruct \
    --served-model-name qwen2.5-7b \
    --host 0.0.0.0 \
    --port 8000 \
    --gpu-memory-utilization 0.85 \
    --max-model-len 4096

测试脚本的核心逻辑是:在后台线程发送请求,主线程每 50ms 采样一次 kv_cache_usage_perc,记录整个生成过程中的峰值使用率,再乘以 KV Cache 总容量换算成 MB:

python 复制代码
# kv_cache_experiment.py
import requests
import time
import re
import threading

def get_kv_cache_usage():
    r = requests.get("http://localhost:8000/metrics")
    for line in r.text.split("\n"):
        if "kv_cache_usage_perc" in line and not line.startswith("#"):
            return float(line.split()[-1])
    return None

def get_total_kv_cache_info():
    r = requests.get("http://localhost:8000/metrics")
    for line in r.text.split("\n"):
        if "cache_config_info" in line and not line.startswith("#"):
            m1 = re.search(r'num_gpu_blocks="(\d+)"', line)
            m2 = re.search(r'block_size="(\d+)"', line)
            if m1 and m2:
                return int(m1.group(1)), int(m2.group(1))
    return None, None

result = {}

def send_request_async(max_tokens):
    r = requests.post(
        "http://localhost:8000/v1/chat/completions",
        json={
            "model": "qwen2.5-7b",
            "messages": [{"role": "user", "content": "请详细介绍一下中国的历史,从远古时代开始讲起,讲1万字"}],
            "max_tokens": max_tokens,
            "temperature": 0.7
        },
        stream=False
    )
    data = r.json()
    usage = data["usage"]
    result["prompt"] = usage["prompt_tokens"]
    result["completion"] = usage["completion_tokens"]

# 获取 KV Cache 配置
token_kv_bytes = 28 * 2 * 4 * 128 * 2  # Qwen2.5-7B BF16
num_blocks, block_size = get_total_kv_cache_info()
total_tokens_capacity = num_blocks * block_size
total_kv_mb = total_tokens_capacity * token_kv_bytes / 1024 / 1024

print(f"KV Cache 配置: {num_blocks} blocks × {block_size} tokens/block")
print(f"KV Cache 总容量: {total_kv_mb:.1f} MB")
print()

for max_tokens in [32, 64, 128, 256, 512, 1024, 2048]:
    result.clear()
    t = threading.Thread(target=send_request_async, args=(max_tokens,))
    t.start()

    peak_kv = 0.0
    while t.is_alive():
        kv = get_kv_cache_usage()
        if kv is not None and kv > peak_kv:
            peak_kv = kv
        time.sleep(0.05)
    t.join()

    prompt_tokens = result.get("prompt", 0)
    completion_tokens = result.get("completion", 0)
    total_tokens = prompt_tokens + completion_tokens
    theory_mb = total_tokens * token_kv_bytes / 1024 / 1024
    actual_mb = peak_kv * total_kv_mb

    print(f"max_tokens={max_tokens:>5}  输入={prompt_tokens}  输出={completion_tokens}  "
          f"总tokens={total_tokens}  理论={theory_mb:.2f}MB  实测={actual_mb:.2f}MB")
    time.sleep(3)

3.2 实验结果

复制代码
KV Cache 配置: 5306 blocks × 16 tokens/block
KV Cache 总容量: 4641.3 MB
max_tokens 输入 输出 总 tokens 理论 KV (MB) 实测 KV (MB) 峰值占用率
32 47 32 79 4.32 4.38 0.09%
64 47 64 111 6.07 6.13 0.13%
128 47 128 175 9.57 9.63 0.21%
256 47 256 303 16.57 16.63 0.36%
512 47 512 559 30.57 30.63 0.66%
1024 47 1024 1071 58.57 58.64 1.26%
2048 47 1857 1904 104.12 104.14 2.24%

3.3 结果分析

发现 1:理论公式与实测高度吻合

实测值比理论值一致地多出约 0.06~0.28 MB,原因是 PagedAttention 按 block_size=16 分配,实际分配的 token 数会向上取整到 16 的倍数:

实际分配 blocks = ⌈ 总 tokens block size ⌉ \text{实际分配 blocks} = \left\lceil \frac{\text{总 tokens}}{\text{block size}} \right\rceil 实际分配 blocks=⌈block size总 tokens⌉

以 79 tokens 为例:

⌈ 79 / 16 ⌉ = 5 blocks = 80 tokens × 56 KB = 4.38 MB ✓ \lceil 79 / 16 \rceil = 5 \text{ blocks} = 80 \text{ tokens} \times 56 \text{ KB} = 4.38 \text{ MB} \quad \checkmark ⌈79/16⌉=5 blocks=80 tokens×56 KB=4.38 MB✓

每一行都能精确对上,2.3 节推导的理论公式完全正确

发现 2:KV Cache 与序列长度严格线性

从实测数据中反推每 token 实际占用:

总 tokens 实测 KV (MB) 每 token 占用
79 4.38 55.4 KB
303 16.63 54.9 KB
1071 58.64 54.8 KB
1904 104.14 54.7 KB

每 token 实际占用稳定在约 55 KB,与理论值 56 KB 几乎一致(微小差异来自 Block 对齐的分母效应)。KV Cache 占用与序列长度呈严格线性关系,没有任何隐藏的额外开销。

发现 3:单请求占用很小,并发才是瓶颈

单个 2048 token 的请求只占 KV Cache 总容量的 2.24%,看起来微不足道。但如果 32 个这样的请求同时在飞:

104.14 MB × 32 = 3332 MB ≈ 3.3 GB 104.14 \text{ MB} \times 32 = 3332 \text{ MB} \approx 3.3 \text{ GB} 104.14 MB×32=3332 MB≈3.3 GB

占 KV Cache 总容量(4641 MB)的 72%。这也解释了上一篇实验中的两个现象:

第一,BF16 基线在 conc=128 时吞吐开始饱和,因为 104 MB × 128 = 13.3 GB 104 \text{ MB} \times 128 = 13.3 \text{ GB} 104 MB×128=13.3 GB 已经远超 KV Cache 总容量,大量请求被排队等待。

第二,AWQ INT4 量化后吞吐翻倍,不只是因为 TPOT 降低,更因为模型权重从 14 GB 压缩到 4 GB,省出的 ~10 GB 显存全部给了 KV Cache 池,总容量从 4641 MB 扩大到约 13000 MB,能同时服务的请求数大幅增加。

💡 启示

KV Cache 的占用取决于实际生成长度,不是设置的 max_tokens------这正是 PagedAttention 按需分配的优势。max_tokens=2048 时模型只生成了 1857 个 token(提前遇到 EoS 结束),KV Cache 只分配了 1904 token 的空间,而不是 2048,浪费极小。

3.4 并发数 vs KV Cache 占用实测

上一节验证了单请求的 KV Cache 与序列长度成线性关系,本节进一步测试:多个请求同时在飞时,KV Cache 占用如何变化,什么时候会被打满?

实验设计:使用 threading.Barrier 让 N 个请求同时发出,在生成过程中每 100ms 采样一次 KV Cache 使用率,记录整个过程中的峰值

python 复制代码
# kv_cache_concurrency.py
import requests
import time
import re
import threading

def get_kv_cache_usage():
    r = requests.get("http://localhost:8000/metrics")
    for line in r.text.split("\n"):
        if "kv_cache_usage_perc" in line and not line.startswith("#"):
            return float(line.split()[-1])
    return None

def get_total_kv_cache_info():
    r = requests.get("http://localhost:8000/metrics")
    for line in r.text.split("\n"):
        if "cache_config_info" in line and not line.startswith("#"):
            m1 = re.search(r'num_gpu_blocks="(\d+)"', line)
            m2 = re.search(r'block_size="(\d+)"', line)
            if m1 and m2:
                return int(m1.group(1)), int(m2.group(1))
    return None, None

def get_running():
    r = requests.get("http://localhost:8000/metrics")
    for line in r.text.split("\n"):
        if "num_requests_running" in line and not line.startswith("#"):
            return int(float(line.split()[-1]))
    return 0

def send_request(barrier):
    barrier.wait()
    try:
        requests.post(
            "http://localhost:8000/v1/chat/completions",
            json={
                "model": "qwen2.5-7b",
                "messages": [{"role": "user", "content": "请详细介绍一下中国的历史,从远古时代开始讲起,讲1万字"}],
                "max_tokens": 2048,
                "temperature": 0.7
            },
            timeout=300
        )
    except:
        pass

token_kv_bytes = 28 * 2 * 4 * 128 * 2
num_blocks, block_size = get_total_kv_cache_info()
total_kv_mb = num_blocks * block_size * token_kv_bytes / 1024 / 1024

for concurrency in [1, 4, 8, 16, 32, 48, 64, 80, 96, 128]:
    barrier = threading.Barrier(concurrency + 1)
    threads = [threading.Thread(target=send_request, args=(barrier,)) for _ in range(concurrency)]
    for t in threads:
        t.start()
    barrier.wait()

    peak_kv, peak_running = 0.0, 0
    while any(t.is_alive() for t in threads):
        kv = get_kv_cache_usage()
        running = get_running()
        if kv is not None and kv > peak_kv:
            peak_kv = kv
            peak_running = running
        time.sleep(0.1)

    for t in threads:
        t.join()

    actual_mb = peak_kv * total_kv_mb
    print(f"conc={concurrency:>3}  KV={peak_kv*100:.2f}%  {actual_mb:.1f}MB  running={peak_running}")
    time.sleep(5)

指标含义说明:

  • 峰值 KV%:整个实验过程中 KV Cache 使用率的最高值。KV Cache 总量在 vLLM 启动时预分配固定,这个百分比表示"已用 / 总量"的峰值。100% 表示 KV Cache 被完全占满,新请求无法分配到 KV Cache 空间。
  • 峰值 KV (MB):峰值百分比 × KV Cache 总容量(4642.8 MB),换算成绝对值。
  • 实际运行中 :峰值采样时刻 GPU 上正在同时生成 token 的请求数。当 KV Cache 打满时,这个值会小于发送的并发数------差额就是因为 KV Cache 空间不足而无法同时运行的请求数,这些请求需要等前面的请求完成释放 KV Cache 后才能开始。

实验结果:

并发数 峰值 KV% 峰值 KV (MB) 实际运行中
1 1.26% 58.6 1
4 4.26% 197.8 4
8 9.84% 456.8 8
16 19.64% 911.9 16
32 38.02% 1765.2 31
48 48.73% 2262.3 41
64 72.61% 3371.1 60
80 88.65% 4115.9 74
96 99.96% 4641.0 82
128 100.00% 4642.8 104

3.5 并发实验分析

发现 1:KV Cache 占用与并发数线性增长

从 conc=1 到 conc=64,KV Cache 占用率几乎完美线性增长,每增加一个并发约占用 1.13% 的 KV Cache(约 52 MB),与单请求实测值(~58 MB/请求)基本吻合。

发现 2:三个阶段清晰可见

复制代码
阶段一(conc=1~64):线性增长区
  KV Cache 有余量,所有请求都能同时运行
  实际运行中 ≈ 发送并发数

阶段二(conc=80~96):接近饱和区
  KV Cache 即将打满,开始出现"实际运行 < 发送并发"
  conc=96 时 KV Cache 已用 99.96%,但只有 82 个请求同时运行

阶段三(conc=128):完全饱和区
  KV Cache 100% 打满
  128 个请求只有 104 个同时运行,24 个请求需要等待

发现 3:KV Cache 打满的临界并发数

临界并发 = KV Cache 总容量 单请求峰值 KV = 4642.8 MB 58.6 MB ≈ 79 \text{临界并发} = \frac{\text{KV Cache 总容量}}{\text{单请求峰值 KV}} = \frac{4642.8 \text{ MB}}{58.6 \text{ MB}} \approx 79 临界并发=单请求峰值 KVKV Cache 总容量=58.6 MB4642.8 MB≈79

实测 conc=80 时 KV Cache 使用率 88.65%,conc=96 时 99.96%,与理论推算的临界点 ~79 基本吻合。超过这个点,新请求必须等待前面的请求释放 KV Cache。

💡 为什么 conc=128 时运行中是 104 而不是 79?

因为 128 个请求不是同时处于"最长"状态。有的请求已经快生成完了(KV Cache 即将释放),有的刚开始(KV Cache 还很小),vLLM 的 continuous batching 机制会"边完成边调度"------一个请求结束释放 KV Cache 后,排队的请求立即填入。所以任意时刻实际运行中的请求数可以超过理论临界值。

发现 4:回看前两篇实验的吞吐瓶颈

这个数据直接解释了前两篇文章的实验现象:

BF16 基线(KV Cache 总容量 4.6 GB):conc=32 时 KV Cache 用了 38%,还有余量;conc=64 时用了 73%,开始吃紧;conc=128 时完全打满。这正是基线实验中 conc=64→128 时 TPOT P99 从 165ms 暴涨到 387ms 的根本原因------不是 GPU 算力不够,而是 KV Cache 塞满了,请求被迫排队。

AWQ INT4(KV Cache 总容量 13.9 GB,是 BF16 的 3 倍):同样 conc=128 时 KV Cache 才用到约 33%,远未饱和。这就是 AWQ 吞吐翻倍的第二重原因------除了 TPOT 降低(带宽收益),KV Cache 扩容让系统能同时服务 3 倍的请求

四、总结

4.1 本文完成的工作

本文从原理到实测,完整解析了 KV Cache 在 LLM 推理中的角色:

内容 结果
KV Cache 原理与公式推导 ✅ 完成
PagedAttention 机制解析 ✅ 完成
单请求 KV Cache vs 序列长度实测 ✅ 理论与实测高度吻合
并发数 vs KV Cache 占用实测 ✅ 找到 KV Cache 打满临界点
BF16 vs AWQ 的 KV Cache 容量对比 ✅ 量化了 AWQ 吞吐翻倍的 KV Cache 因素

4.2 关键结论

结论 1:KV Cache 占用与序列长度和并发数严格线性

总 KV Cache = N r e q u e s t s × L t o k e n s × 56 KB/token \text{总 KV Cache} = N_{requests} \times L_{tokens} \times 56 \text{ KB/token} 总 KV Cache=Nrequests×Ltokens×56 KB/token

实测每 token 占用稳定在 ~55 KB,与理论值 56 KB 几乎完全一致,偏差仅来自 PagedAttention 的 Block 对齐(block_size=16)。

结论 2:KV Cache 是高并发场景下的真正瓶颈

BF16 模型下 KV Cache 总容量为 4.6 GB,临界并发约 88 个请求。超过后 KV Cache 打满,新请求被迫等待:

复制代码
conc=64:  KV Cache 72.6%,所有请求正常运行
conc=96:  KV Cache 99.96%,只有 82 个请求能同时运行
conc=128: KV Cache 100%,只有 104 个请求能同时运行

结论 3:AWQ 吞吐翻倍 = TPOT 收益 + KV Cache 扩容收益

复制代码
BF16:权重 14 GB,KV Cache 容量 5306 blocks(4.6 GB)
AWQ: 权重 4 GB, KV Cache 容量 15839 blocks(13.9 GB)
                                    ↑ 3 倍!

AWQ 把权重从 14 GB 压到 4 GB,省出的 ~10 GB 全部给了 KV Cache,容量扩大 3 倍。这意味着同样的显存条件下能同时服务 3 倍的请求,这是 AWQ 吞吐翻倍中被忽视但更重要的一半原因

4.3 未完成的实验

本文原计划测试 FP8/INT8 KV Cache 量化以进一步压缩 KV Cache,但 RTX 3090(Ampere 架构,SM 8.6)不支持原生 FP8 计算,INT8 KV Cache 的精度损失也超出可接受范围(HellaSwag 从 80.5% 降至 65.5%)。KV Cache 量化需要 Ada Lovelace(RTX 4090)或 Hopper(H100)架构的硬件支持,在 RTX 3090 上不可行。

4.4 三篇文章的完整对照表

复制代码
=== 完整实验记录 ===

                        基线 v0 (BF16)      实验 1 (AWQ INT4)
硬件:                   RTX 3090 24GB       RTX 3090 24GB
模型权重:               ~14 GB              ~4 GB
KV Cache 容量:          5306 blocks (4.6GB) 15839 blocks (13.9GB)
KV Cache 临界并发:      ~88                 ~265(推算)

精度:
  HellaSwag:            80.5%               79.6%  (-0.9%)
  ARC-Easy:             81.0%               78.8%  (-2.2%)
  ARC-Challenge:        55.5%               54.5%  (-1.0%)
  GSM8K:                71.3%               69.2%  (-2.1%)

性能:
  TPOT P50 (conc=1):    19.7 ms             6.84 ms    (-65%)
  Out_TPS (conc=32):    978 tok/s           2126 tok/s (+117%)
  严格 SLO Goodput:     826 tok/s           4547 tok/s (+450%)
  生产甜点区:            conc=8~16           conc=16~32

4.5 下一步计划

实验 方法 预期收益
Prefix Caching 效果验证 测试相同 system prompt 下的 TTFT 变化和 KV Cache 复用率 TTFT 降低、KV Cache 节省
投机采样(Speculative Decoding) 用小模型草稿 + 大模型验证,加速 decode 低并发 TPOT 降 30~50%,精度零损失

本系列下一篇:投机采样实验:精度零损失的加速方案(待更新)


参考资料:

相关推荐
Esaka_Forever1 小时前
Devin AI和Lovable区别
人工智能
happyprince1 小时前
02_verl-代码目录结构详解
人工智能·架构·强化学习
码农小白AI2 小时前
AI报告审核通审Agent版+IACheck:地方标准DB团体标准T企业标准Q智能查新
人工智能
小小龙学IT2 小时前
Composio:开源AI智能体工具集成平台深度解析
人工智能·开源
happyprince2 小时前
10_verl-Rollout模块详解
人工智能·架构·强化学习
某昆real2 小时前
从零构建轻量级推理引擎 OInfer(四):卷积算子的 OpenCL 实现
人工智能
某昆real2 小时前
从零构建轻量级推理引擎 OInfer(二):ONNX 模型解析与计算图构建
人工智能
分布式存储与RustFS2 小时前
对标MinIO!RustFS新一代AI分布式对象存储开源能力前瞻
人工智能·分布式·开源·分布式对象存储·rustfs·minio平替·s3 table