LLM 推理机制实验:KV Cache、预分配与 Batch Size
1. 从“应该更快”到可验证问题
先说明实验边界
这不是 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;随着 增长,真正的矩阵计算与 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 的外层 Decode 循环不断调用完整 Causal Attention。单次前缀 Attention 的 Score Matrix 随 增长,把所有 Decode Step 累加后,核心工作接近 。
两条 Cache 路径每步只有一个新 Query 与 个历史 Key 做 Attention,累计工作接近 。Dynamic Cache 还要反复复制逐渐增长的 K/V,Preallocated Cache 则用一次容量分配换掉这部分动态扩容。
3.2 两个 Sequence 怎样组成 Batch?
顺序路径在 Python 中分别执行两个 Sequence;Batched 路径把输入从 [T, D_model] 变成 [B, T, D_model],然后在每个 Decode Step 同时取出 个位置:
x_t = x[:, t : t + 1, :]
当 B=2 时,核心 Shape 为:
| Tensor | Shape | 含义 |
|---|---|---|
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] 只访问同一个 下的 K/V;测试再把 Batched 输出与两个独立 No-Cache Oracle 对照,防止状态跨 Sequence 泄漏。
3.3 这个 Batch 还不是什么?
4. 环境、控制变量与正确性门禁
4.1 环境快照
| 项目 | 当前设置 |
|---|---|
| CPU | AMD Ryzen 7 9700X,8 Core / 16 Thread |
| Python | CPython 3.14.7 |
| NumPy | 2.5.2 |
| BLAS | OpenBLAS 0.3.34 |
| Thread | OPENBLAS_NUM_THREADS=1、OMP_NUM_THREADS=1 |
| Tensor | float64,Single Head |
| Dimension | d_model = d_k = d_v = 64 |
| Sequence Length | Length Scan 为 16/32/64/128/256;Batch Size Scan 固定为 128 |
| Batch | Cache 为 1;Length Scan 为 2;Size Scan 为 1/2/4/8 |
| Random Seed | 42 |
模型权重、维度、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 Strategy | 0 | 仅保留正文中的历史中位数汇总 | 历史结论保留;当前复现性为 Inconclusive |
Batch Length,B=2 | 300 | 5 个 T × 2 条路径 × 30 次 | 重算依赖私人仓库的 raw.csv;未公开 |
Batch Size,T=128 | 240 | 汇总 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 结果
| No Cache / ms | Concatenate / ms | Preallocated / ms | No Cache / Preallocated | |
|---|---|---|---|---|
| 16 | 0.271 | 0.194 | 0.180 | 1.51x |
| 32 | 0.567 | 0.307 | 0.282 | 2.01x |
| 64 | 1.740 | 0.648 | 0.577 | 3.02x |
| 128 | 7.765 | 1.366 | 1.174 | 6.61x |
| 256 | 40.592 | 3.083 | 2.509 | 16.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 容量为:
当 D_k=D_v=64 时:
因此 T=256 时只保存 256 KiB K/V。这个公式验证了容量随 线性增长,但它不包含模型层数、Multi-Head/GQA、Allocator 元数据或设备侧 Block。
6. 实验 B:顺序执行与 B=2
6.1 结果
| Sequential / ms | Batch / ms | Speedup | Positions / s | |
|---|---|---|---|---|
| 16 | 0.380 | 0.226 | 1.683x | 141,656 |
| 32 | 0.780 | 0.429 | 1.817x | 149,032 |
| 64 | 1.253 | 0.777 | 1.613x | 164,766 |
| 128 | 2.644 | 1.739 | 1.521x | 147,242 |
| 256 | 6.122 | 4.496 | 1.362x | 113,866 |
Sequential / ms 与 Batch / ms 都包含两个 Sequence 的完整
Decode。Sequential 和 Batched 两条路径都使用动态拼接 Cache,因此这里比较的是执行组织方式,不是“动态
Cache vs. 预分配 Cache”。
Positions / s 使用
计算,只表示 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 条原始计时样本。
| Batch P50 / ms | Batch P95 / ms | P50/B / ms | Positions/s | Sequential/Batch | |
|---|---|---|---|---|---|
| 1 | 1.442 | 1.467 | 1.442 | 88,750 | 0.938x |
| 2 | 1.789 | 1.808 | 0.894 | 143,129 | 1.521x |
| 4 | 2.627 | 2.673 | 0.657 | 194,928 | 2.045x |
| 8 | 5.264 | 5.312 | 0.658 | 194,513 | 2.064x |
相邻一级 B 的吞吐增幅由原始 CSV 计算:
| B 变化 | Positions/s Gain |
|---|---|
| 1 → 2 | 61.27% |
| 2 → 4 | 36.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。
保留原始样本也暴露了中位数看不到的噪声。例如当前 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 下收益收窄 | Supported | T=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 共学页及其执行材料中。