QuanZhou's Wiki
更新于

LLM 推理机制实验:KV Cache、预分配与 Batch Size

~/ AI Infra#LLM#Attention#KV Cache#Batch#Benchmark

1. 从“应该更快”到可验证问题

两个看似显然的优化

KV Cache

  • 历史 token 的 K/V 已经确定,不必在每轮 Decode 中重新生成;
  • 只计算新位置的 Q/K/V,再让新 Query 读取历史 K/V;
  • 因而 Cache 路径应该比完整前缀重算更快。

Batch

  • 两个 Sequence 不再分别调用同一套 Attention;
  • 每个 Decode Step 用一次 NumPy 调用同时推进两个位置;
  • 因而 Batched 执行应该比顺序执行两次更快。

但“更快”还不是解释

优化究竟消除了哪一部分工作?为什么收益会随 Sequence Length 改变?它能否迁移到真实 LLM Serving?

先说明实验边界

这不是 vLLM、GPU 或在线 Serving Benchmark,而是进入真实系统实验前的机制验证。M0/M1 的性能扫描仍只覆盖 CPU、NumPy、单 Head、等长 Sequence 和固定 Batch;随后完成的 M2-B 只新增无 Cache Multi-Head Causal Attention 的正确性 Oracle,不新增性能结论。它们能证明最小实现中的数值不变量与耗时趋势,不能直接给出 vLLM 的 TTFT、TPOT、吞吐或 P99。

机制背景见《LLM 推理优化的系统视角:从 KV Cache 到调度》。

p2-inference-systems 公开材料目录提供当前 Starter 及其运行文件、当前与历史任务书、空白工作表和当前评分器。个人实现、已填写工作表,以及仍保留的原始样本、汇总和图表由私人作答仓库管理,不随网站发布。本文公开实验结果与解释,当前下载包不包含完整的 M0/M1 复现材料。

本文不是当前任务手册

本文按“问题 → 预测 → 实现 → 测量 → 结论边界”总结已经发生的 M0/M1 实验,不跟踪今天做到哪一步。当前 Gate、任务书、工作表和总计划从阶段 2 共学页统一进入,避免把项目进度混进实验结论。

2. 实验问题与事前预测

2.1 五个问题

  • 增量 KV Cache 与每步重算完整前缀是否逐位置数值等价?
  • 动态扩展 Cache 时,反复执行 np.concatenate 带来了多少额外成本?
  • 把两个独立 Sequence 放入 B=2 的执行路径后,总耗时是否低于顺序执行两次?
  • Sequence 变长时,Batch 的相对收益为什么可能收窄?
  • 固定 T=128 后,Batch Size 从 1 增加到 8 时,吞吐收益何时开始收窄?

2.2 实验前冻结的预测

H1 / 语义不变: Dynamic Cache、Preallocated Cache 与 No Cache 的逐位置输出应在浮点容差内一致。

H2 / 分配成本: 预分配 K/V Buffer 应比逐步拼接更快,并且长度越大,累计分配与复制成本越容易显现。

H3 / Batched 执行: B=2 应减少 Python 循环、NumPy 调用和数组分配次数,所以总耗时应低于顺序执行两个 Sequence。

H4 / 收益边界: Batch 没有消除两个 Sequence 的 Attention 工作,因此加速不会稳定达到 2x;随着 TT 增长,真正的矩阵计算与 Cache 复制占比上升,相对收益可能下降。

H5 / Batch Size 拐点: Batch Wall Time 会随 B 增大,总 Positions/s 会先提高后进入平台;若相邻一级 B 的吞吐增幅低于 10%,就把该 B 记作当前工作负载下的 Knee。

结果只使用 Supported、Refuted 或 Inconclusive。这里的 Supported 只表示当前环境和扫描范围支持预测,不表示它已经成为跨 Runtime、硬件和模型的普遍结论。

3. 实现:从单 Sequence Cache 到两个 Sequence

3.1 三条 Cache 路径

同一语义,三种执行方式

No Cache

  • 第 tt 步取出 x[:t+1];
  • 对完整前缀重新计算 Q/K/V 与 Causal Attention;
  • 只保留最后一个位置的输出。

Concatenate Cache

  • 每一步只计算新 token 的 Q/K/V;
  • 使用 np.concatenate 把新 K/V 追加到历史;
  • 新 Query 仍然读取 1…t1\ldots t 的全部 K/V。

Preallocated Cache

  • 预先分配 [T, D_k] 与 [T, D_v] Buffer;
  • 每步原地写入位置 tt;
  • Attention 只读取当前有效前缀。

No Cache 的外层 Decode 循环不断调用完整 Causal Attention。单次前缀 Attention 的 Score Matrix 随 t2t^2 增长,把所有 Decode Step 累加后,核心工作接近 O(T3)O(T^3)。

两条 Cache 路径每步只有一个新 Query 与 tt 个历史 Key 做 Attention,累计工作接近 O(T2)O(T^2)。Dynamic Cache 还要反复复制逐渐增长的 K/V,Preallocated Cache 则用一次容量分配换掉这部分动态扩容。

3.2 两个 Sequence 怎样组成 Batch?

顺序路径在 Python 中分别执行两个 Sequence;Batched 路径把输入从 [T, D_model] 变成 [B, T, D_model],然后在每个 Decode Step 同时取出 BB 个位置:

x_t = x[:, t : t + 1, :]

当 B=2 时,核心 Shape 为:

TensorShape含义
x_t[2, 1, D_model]两个 Sequence 的当前位置
q_t[2, 1, D_k]两个互相独立的 Query
k_cache[2, t+1, D_k]每个 Sequence 自己的 Key 历史
scores[2, 1, t+1]Batch 内分别计算的 Attention Score
output_t[2, 1, D_v]两个 Sequence 的当前位置输出

Batch 维只表示共享一次算子调用,不能参与 Sequence 内部的 Attention。实现通过

q_t @ np.swapaxes(k_cache, -1, -2)

让每个 q_t[b] 只访问同一个 bb 下的 K/V;测试再把 Batched 输出与两个独立 No-Cache Oracle 对照,防止状态跨 Sequence 泄漏。

3.3 这个 Batch 还不是什么?

当前 B=2 与真实 Serving 的差别

当前实验

  • 两个请求同时开始;
  • 两个 Sequence 等长;
  • 每轮都共同推进一个位置;
  • 没有中途加入、完成、取消或抢占。

真实 Serving

  • Client Concurrency 只表示尚未完成的请求数;
  • Scheduler 每轮重新选择实际运行的 Sequence;
  • Prefill 可能一次占用多个 Token Budget;
  • 长度、KV 容量和调度策略共同决定实际 Batch。

因此,本实验测到的是固定形状的 Batched Execution,不是 Continuous Batching。

4. 环境、控制变量与正确性门禁

4.1 环境快照

项目当前设置
CPUAMD Ryzen 7 9700X,8 Core / 16 Thread
PythonCPython 3.14.7
NumPy2.5.2
BLASOpenBLAS 0.3.34
ThreadOPENBLAS_NUM_THREADS=1、OMP_NUM_THREADS=1
Tensorfloat64,Single Head
Dimensiond_model = d_k = d_v = 64
Sequence LengthLength Scan 为 16/32/64/128/256;Batch Size Scan 固定为 128
BatchCache 为 1;Length Scan 为 2;Size Scan 为 1/2/4/8
Random Seed42

模型权重、维度、dtype、线程数和随机种子保持不变。M0 的两组实验扫描 Sequence Length;M1 固定 T=128,只扫描 B∈{1,2,4,8}。

4.2 测量协议与当前证据

所有计时都使用 perf_counter_ns(),正确性 Oracle 不进入计时区间,每个分组保留 30 条正式样本。当前 Harness 默认每条 Path 先 Warm-up 10 次;M0 的早期汇总曾使用 3 次 Warm-up,因此历史表格与当前代码复跑不应被视为完全相同的协议。

下表沿用作者实验记录中的证据状态,不是公开下载包的文件清单。Batch Length 与 Batch Size 的原始 CSV 和重建材料由私人作答仓库管理,读者无法仅凭当前公开包独立重算结果。

实验记录的原始样本记录中的产物证据边界
Cache Strategy0仅保留正文中的历史中位数汇总历史结论保留;当前复现性为 Inconclusive
Batch Length,B=23005 个 T × 2 条路径 × 30 次重算依赖私人仓库的 raw.csv;未公开
Batch Size,T=128240汇总 CSV、P50/P95 图、吞吐图、Knee重建依赖私人仓库的代码与数据;未公开

当前 Batch Length Harness 仍按固定路径顺序执行,温度、频率或长期漂移可能形成顺序效应,因此结论关注跨长度趋势,不把某个亚毫秒差异解释成稳定的系统常数。

M1 的 Batch Size Scan 随机化 B 的执行顺序,并将每条 Path 的 Warm-up 提高到 10 次。使用 3 次 Warm-up 的首轮数据曾出现约 28.2% 的前后漂移;重新运行后,8 个分组的最大前后漂移降至 1.29%。

4.3 正确性先于计时

性能结果的进入条件

单元测试刻意使用 d_k=3、d_v=5,避免 K/V 维度相同时掩盖 Shape 错误。Benchmark 在每个长度计时前,还会用完整前缀重算生成 Oracle,并对 Dynamic、Preallocated 和 Batched 输出执行 assert_allclose(atol=1e-8)。只有正确性检查通过后才记录性能。

5. 实验 A:KV Cache 与分配策略

这一组结果的证据状态

下面保留的是 M0 当时记录的 30 次中位数和机制解释。本文记录的 450 条 Cache Strategy 原始样本缺口仍未补齐,无法从现有汇总重算这些中位数。这项证据缺失与其他实验的数据仅未公开不同,不能因为材料转由私人仓库管理就视为已经修复。

判断维度状态依据
历史结论Supported当时记录的五个长度中,Preallocated 中位数都低于 Concatenate
当前可复现性Inconclusive缺少逐样本 CSV,无法重新聚合、检查分布或核对原测量协议

因此,下面的表格是历史结果记录,不是逐样本证据完整的一组可复核测量。以后重跑产生的数据必须作为新实验保存,不能冒充原始样本。

5.1 结果

TTNo Cache / msConcatenate / msPreallocated / msNo Cache / Preallocated
160.2710.1940.1801.51x
320.5670.3070.2822.01x
641.7400.6480.5773.02x
1287.7651.3661.1746.61x
25640.5923.0832.50916.18x

表中每个值是当时 30 次正式运行的中位数。三条路径在所有长度上都通过逐位置数值等价检查;当前单元测试仍分别覆盖 Dynamic Cache 和 Preallocated Cache 与 No-Cache Oracle 的数值等价性。

5.2 结果怎样解释?

H1 得到支持。 Cache 改变的是历史状态的复用方式,没有改变当前 Query 对有效历史 K/V 的 Attention 语义。

H2 的历史结论:Supported。 五个长度上,Preallocated 的中位数都低于 Concatenate。到 T=256 时,耗时从 3.083 ms 降到 2.509 ms,减少约 18.6%。

H2 的当前可复现性:Inconclusive。 在重新生成独立的 results/cache-strategy/raw.csv 之前,这个趋势不能从本文保留的汇总记录重新聚合、检查分布或核对测量协议。新的复跑只能形成新的证据版本,不能补写成当时的原始数据。

No Cache 与 Preallocated 的差距从 1.51x 扩大到 16.18x。这里不是 Cache 让 Attention 消失了,而是它不再对每个 Decode Step 的完整前缀重复构造 Q/K/V 和 Score Matrix。

本实验中单 Sequence 的逻辑 K/V 容量为:

SKV=T×Dk×8+T×Dv×8.S_{KV} = T \times D_k \times 8 + T \times D_v \times 8.

当 D_k=D_v=64 时:

SKV=2×T×64×8=1024T bytes=T KiB.S_{KV}=2\times T\times64\times8 =1024T\ \text{bytes} =T\ \text{KiB}.

因此 T=256 时只保存 256 KiB K/V。这个公式验证了容量随 TT 线性增长,但它不包含模型层数、Multi-Head/GQA、Allocator 元数据或设备侧 Block。

6. 实验 B:顺序执行与 B=2

6.1 结果

TTSequential / msBatch / msSpeedupPositions / s
160.3800.2261.683x141,656
320.7800.4291.817x149,032
641.2530.7771.613x164,766
1282.6441.7391.521x147,242
2566.1224.4961.362x113,866

Sequential / ms 与 Batch / ms 都包含两个 Sequence 的完整 Decode。Sequential 和 Batched 两条路径都使用动态拼接 Cache,因此这里比较的是执行组织方式,不是“动态 Cache vs. 预分配 Cache”。

Positions / s 使用

B×TTbatch\frac{B\times T}{T_{\mathrm{batch}}}

计算,只表示 Toy Attention 每秒处理多少个输出位置。它没有请求排队、Tokenizer、模型层、Sampling 或网络时间,不能称为真实 Serving 的 Token Throughput。

6.2 Batch 节省了什么?

H3 得到支持。 在五个长度上,Batched 总耗时都低于顺序执行两次。它把每个位置的两次 Python/NumPy 调用合并为一次,并减少了中间数组和函数调度开销。

H4 得到有条件支持。 Speedup 并没有在全部五个点上单调下降:它从 T=16 的 1.683x 上升到 T=32 的 1.817x,随后才随长度增加逐步降到 T=256 的 1.362x。当前证据支持“中长 Sequence 中收益收窄”,不支持“从最短长度开始严格单调收窄”。

原因可以用工作量的增长速度解释:

顺序路径:2 × T 次 Python Decode Step
Batched: T 次 Python Decode Step

节省的调用开销:大致随 T 增长
仍然存在的 Attention 与 Cache 工作:大致随 B × T² 增长

短 Sequence 的单次结果容易受函数调用、分配和亚毫秒噪声共同影响;从 T=32 到 T=256,Attention 与 Cache 复制逐渐主导,总计算没有减半,所以加速整体向 1x 收窄。

这个结果支持“Batch 能摊薄执行开销”,但还不能证明 GPU 利用率提高或模型权重带宽被更好地分摊:当前实现运行在 CPU/NumPy 上,也没有采集硬件计数器。

6.3 M1:固定 T 的 Batch Size Scan

固定 T=128 后,只改变 B∈{1,2,4,8}。每个 B 共用同一份最大输入的前缀、同一组权重和单线程 BLAS;Sequential 与 Batched 各保留 30 条原始计时样本。

BBBatch P50 / msBatch P95 / msP50/B / msPositions/sSequential/Batch
11.4421.4671.44288,7500.938x
21.7891.8080.894143,1291.521x
42.6272.6730.657194,9282.045x
85.2645.3120.658194,5132.064x

相邻一级 B 的吞吐增幅由原始 CSV 计算:

B 变化Positions/s Gain
1 → 261.27%
2 → 436.19%
4 → 8-0.21%

操作性定义为相邻吞吐增幅低于 10% 时收益明显收窄,因此结果是 Knee: B=8,不是 Not reached。

Batch Wall Time 从 1.442 ms 增长到 5.264 ms,说明吞吐提高不等于整个 Batch 更快完成。P50/B 从 B=1 的 1.442 ms 降至 B=4 的 0.657 ms,但 B=8 时为 0.658 ms,摊销收益已经停止改善。Positions/s 同样在 B=4 后进入平台。

B=1 时 Sequential/Batch 为 0.938x,说明没有可摊薄的多 Sequence 调用时,Batched 形状处理本身存在开销;B≥2 后 Batched 才取得净收益。B=8 的 2.064x 仍远低于 8x,因为每个 Sequence 的 Q/K/V、Attention 与 Cache 工作都没有消失。

平台可能来自 CPU/NumPy 调用、内存访问和动态 Cache 复制共同占据主导,但当前实验没有硬件计数器,不能把 Knee 唯一归因于某一种机制。它也不是在线请求延迟或 Continuous Batching 的调度拐点。

7. 一次无效结果:为什么曾经出现 40x?

早期实验没有固定 BLAS 线程。随着矩阵 Shape 改变,OpenBLAS 可能跨过不同执行策略或线程阈值,No Cache 路径的额外调度成本被一起算进结果,表面加速一度超过 40x。

异常怎样变成实验约束?

Observation

No Cache 的加速比远高于机制模型能够解释的范围,并且复跑波动明显。

Competing explanation

变化可能来自 BLAS 线程与执行策略,而不只是 KV Cache。

Controlled change

固定 OPENBLAS_NUM_THREADS=1 与 OMP_NUM_THREADS=1,保持其他条件不变后重跑。

New conclusion

Cache 趋势仍然存在,但具体倍数下降;因此旧的 40x 结果不能作为 KV Cache 的独立证据。

保留原始样本也暴露了中位数看不到的噪声。例如当前 T=256 的 Batched 路径中位数为 4.496 ms,但 30 次样本中的最大值达到 5.707 ms。报告中位数能够降低单次尖峰的影响,却不能替代原始分布、随机化顺序和更多环境观测。

8. 结果总览与证据边界

假设状态当前证据结论边界
H1:Cache 保持语义Supported当前单测 + 历史逐长度 Oracle单层、单 Head、CPU/NumPy
H2:预分配减少成本历史结论:Supported五个长度的历史中位数汇总;当前缺少 450 条逐样本数据当前复现性:Inconclusive
H3:B=2 快于顺序两次Supported独立 Oracle + 当前 300 条 Batch Length 样本等长、同时开始、动态 Cache
H4:中长 T 下收益收窄SupportedT=32 时 1.817x,T=256 时 1.362x全部五个点并非严格单调
H5:Batch 收益出现拐点Supported当前 240 条样本;gain(8)=-0.21%,Knee 为 B=8固定 T=128、CPU/NumPy Toy Lab

这次实验真正证明了什么?

KV Cache 用线性增长的状态换掉了更昂贵的历史重算;历史 Cache Strategy 测量显示预分配减少了动态扩容与复制,但当前复现状态仍为 Inconclusive;Batched 执行摊薄了调用开销,却没有消除每个 Sequence 的 Attention 工作。固定 T=128 时,Positions/s 在 B=4 后进入平台并于 B=8 满足 Knee 定义。这些结论都来自 CPU/NumPy Toy Lab,不能直接外推成 vLLM 或 GPU 上的固定加速倍数。

M2-B 已额外覆盖 Q/K/V=[B,H,T,D_head] 下的缩放点积、Causal Mask、稳定 Softmax、逐 Head Output 与 Batch/Head 隔离;定向评分 60/60、整份评分 100/100,私人仓库 16 项回归通过。当前还没有覆盖:

  • Cached MHA、MQA 或 GQA,以及 Head 合并和 Output Projection;
  • 不同长度 Sequence 的 Padding、Mask 与动态退出;
  • GPU Kernel、显存带宽、Kernel Launch 与同步;
  • Continuous Batching、PagedAttention 与 Scheduler;
  • Client Concurrency、Queue、TTFT、TPOT、P99 与真实 Token Throughput。

以下学习证据层级沿用作者的实验记录;“已达到”不表示对应实现或数据已公开,也不代表外部复核已经完成:

层级当前状态证据
Explained已达到能区分 Batch Wall Time、摊销成本、吞吐和在线请求延迟
Implemented已达到Cache、预分配、Batched Decode、Batch Size CLI 与无 Cache MHA Oracle
Measured部分达到Batch Length/M1 完整;Cache Strategy 缺少逐样本 CSV
Reviewed尚未达到正确性回归与构建检查已存在;还没有独立复现或外部 Review
Transferred尚未达到尚未把 Toy 机制迁移到真实 vLLM Scheduler 与 Serving

9. 历史复跑流程与材料边界

以下保留 M0/M1 的复跑命令。执行前需要取得对应版本的实验代码和依赖环境,并进入保存这些代码的私人作答仓库根目录;当前公开 Starter 不包含这些 Benchmark CLI 和结果目录,不能直接运行下面的流程。当前练习的启动方式以公开 Lab 使用说明为准。

历史命令:需对应实验代码与环境

# 在包含 M0/M1 Benchmark CLI 的私人作答仓库根目录执行
make setup
make test

# 重建 B=2 Length Scan
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run p2-inference-systems \
  --experiment batch-length \
  --csv results/m0-length-scan/raw.csv

OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run p2-inference-systems \
  --experiment batch-size \
  --csv results/m1-batch-size-scan/raw.csv

OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run p2-inference-systems-summary \
  --input results/m1-batch-size-scan/raw.csv \
  --output-dir results/m1-batch-size-scan \
  --sequence-length 128 \
  --dtype float64 \
  --blas-threads 1

若重新开展 Cache Strategy 测量,应在具备对应 Benchmark CLI 的环境中单独执行下面的命令,并把结果作为新实验保存。这个流程采用 10 次 Warm-up,不能补写成早期表格的原始样本:

OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run p2-inference-systems \
  --experiment cache \
  --csv results/cache-strategy/raw.csv

下面是本文实验记录涉及的目录结构,用于说明私人实验材料的组织方式,不是当前公开下载包的文件清单。公开内容以 p2-inference-systems 材料目录为准;Cache Strategy 缺失的原始样本不在下列已记录产物中。

p2-inference-systems/
├── src/inference_lab/
│   ├── attention.py
│   ├── benchmark.py
│   ├── summarize_batch_size_scan.py
│   └── multi_head_attention.py
├── tests/
│   ├── test_attention.py
│   ├── test_batch_size_summary.py
│   └── test_multi_head_attention.py
└── results/
    ├── m0-length-scan/
    │   └── raw.csv
    └── m1-batch-size-scan/
        ├── raw.csv
        ├── summary.csv
        ├── latency-p50-p95.svg
        ├── throughput-positions-per-second.svg
        └── knee-analysis.md

10. 结论到哪里结束?

本文的结论停在 CPU/NumPy Toy Attention:它已经验证 Cache 复用、预分配、固定形状 Batched Execution,以及无 Cache MHA 的数值语义和隔离边界,但没有验证 Cached MHA/GQA、真实 vLLM Scheduler、GPU Kernel、显存带宽或在线请求指标。后续 Gate 只有在产生新的原始证据后才补充到相应实验文章;当前任务和未来计划始终留在阶段 2 共学页及其执行材料中。