README.md

# Phase 2 Lab:Causal Attention、KV Cache 与 Batched Decode

这个 Lab 用最小 NumPy 实现验证三个问题:KV Cache 是否保持 Attention 语义、缓存分配策略如何改变耗时,以及把两个独立 Sequence 放进同一个 Batch 后实际节省了什么。

阶段文档:[LLM 推理优化的系统视角:从 KV Cache 到调度](/blog/llm-inference-optimization-system-view/)

## 实验问题与预测

1. 增量 KV Cache 与每步重算完整前缀是否逐位置数值等价?
2. `np.concatenate` 的反复分配和复制是否会让动态 Cache 慢于预分配?
3. 两个等长 Sequence 共享一次 Batched 执行后,总耗时是否低于顺序执行两次?
4. 当 Sequence 变长时,Batch 的相对加速为什么可能下降?

实验前的预测:Cache 路径应保持数值等价;预分配应减少动态扩容成本;`B=2` 应减少 Python/NumPy 调用和分配次数,但不会减少 Attention 的总 FLOPs,因此加速不会稳定达到 2x,并会随长序列计算占比上升而收窄。

## 项目结构

```text
attention-kv-caceh/
├── src/attention_kv_caceh/
│   ├── attention.py       # Attention、KV Cache 与 Batched Decode 实现
│   └── benchmark.py       # 正确性门禁、计时、汇总和 CSV 输出
├── tests/
│   └── test_attention.py  # 独立数值与 Shape 测试
└── results/
    └── benchmark.csv      # 750 条原始计时样本
```

实现入口:

- [`attention.py`](./src/attention_kv_caceh/attention.py)
- [`benchmark.py`](./src/attention_kv_caceh/benchmark.py)
- [`test_attention.py`](./tests/test_attention.py)
- [`benchmark.csv`](./results/benchmark.csv)

## 实现路径

### 单 Sequence Cache 实验

- `decode_without_cache`:每一步重算完整前缀,只保留最后一个位置的输出;
- `cached_attention`:只计算新 token 的 Q/K/V,通过 `np.concatenate` 扩展 K/V;
- `cached_attention_preallocated`:预分配完整 K/V Buffer,按位置原地写入。

### 两个 Sequence 的执行实验

- `sequential_cached_attention`:在 Python 中分别执行两个 Sequence,作为计时基线;
- `batched_cached_attention`:把输入组织为 `[B, T, D_model]`,每个 Decode Step 用一次 NumPy 调用处理两个 Sequence;
- K/V Cache 形状为 `[B, T, D_k]` 和 `[B, T, D_v]`,Batch 维不会参与 Sequence 内的 Attention。

在第 `t` 个 Decode Step,核心 Shape 为:

| Tensor     | Shape             |
| ---------- | ----------------- |
| `x_t`      | `[B, 1, D_model]` |
| `q_t`      | `[B, 1, D_k]`     |
| `k_cache`  | `[B, t+1, D_k]`   |
| `scores`   | `[B, 1, t+1]`     |
| `output_t` | `[B, 1, D_v]`     |

## 环境与控制变量

| 项目        | 设置                                         |
| ----------- | -------------------------------------------- |
| CPU         | AMD Ryzen 7 9700X,8 Core / 16 Thread        |
| Python      | CPython 3.14.7                               |
| NumPy       | 2.5.2                                        |
| BLAS        | OpenBLAS 0.3.34,实验时固定为单线程          |
| Tensor      | `float64`,单 Head                           |
| Batch       | Cache 实验为 1;Batch 实验固定为 2           |
| Dimension   | `d_model = d_k = d_v = 64`                   |
| Sequence    | 16 / 32 / 64 / 128 / 256                     |
| Random Seed | 42                                           |
| Timing      | Warm-up 3 次,正式运行 30 次,报告耗时中位数 |

两个实验都只扫描 Sequence Length。模型权重、维度、dtype、线程数和随机种子保持不变;Batch 实验额外固定 `B=2`。

## 复现

```bash
cd public/labs/attention-kv-caceh
uv sync

OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run python -m unittest discover -s tests -v

OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \
  uv run attention-kv-caceh --csv results/benchmark.csv
```

正确性测试刻意使用 `d_k=3`、`d_v=5`,避免 K/V Shape 错误被相同维度掩盖。Benchmark 在计时前还会分别以完整前缀重算作为 Oracle:

```python
np.testing.assert_allclose(expected, dynamic_output, atol=1e-8)
np.testing.assert_allclose(expected, preallocated_output, atol=1e-8)
np.testing.assert_allclose(expected, actual, atol=1e-8)
```

只有正确性门禁通过后才输出性能结果。

## 结果

### KV Cache 策略

| $T$ | No Cache / ms | Concatenate Cache / ms | Preallocated Cache / 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 |

- 三条路径在所有长度上逐位置数值等价;
- No Cache 的完整前缀重算随长度增长得更快;
- `T=256` 时,预分配比动态拼接减少约 18.6% 的 Cache 路径耗时;
- 单 Sequence 的 K/V 逻辑容量为 `2 × T × 64 × 8 bytes = T KiB`。

### 两个 Sequence:顺序执行与 Batched 执行

| $T$ | Sequential / ms | Batch / ms | Speedup | Positions / s |
| --: | --------------: | ---------: | ------: | ------------: |
|  16 |           0.303 |      0.180 |  1.687x |       178,072 |
|  32 |           0.618 |      0.374 |  1.653x |       171,096 |
|  64 |           1.274 |      0.791 |  1.611x |       161,871 |
| 128 |           2.688 |      1.793 |  1.499x |       142,755 |
| 256 |           6.109 |      4.540 |  1.346x |       112,788 |

`Sequential / ms` 和 `Batch / ms` 都包含两个 Sequence 的完整 Decode。`Positions / s` 按 `B × T / Batch Time` 计算,只表示这个 Toy 实验处理输出位置的速率,不等于真实 Serving 的 Token Throughput。

Batch 路径减少了 Python 循环、NumPy 调用和数组分配次数,但两个 Sequence 的 Q/K/V、Attention Score 和输出计算仍然存在。节省的调度开销大致随 $T$ 线性增长,而 Attention 与动态 Cache 复制的累计工作接近 $O(T^2)$,因此加速从约 1.69x 收窄到 1.35x。

## 异常、噪声与证据边界

未固定 BLAS 线程的早期实验曾把 No Cache 的表面加速放大到 40x 以上。当前结果固定 OpenBLAS 与 OMP 为单线程,并保留每次计时样本;亚毫秒结果仍容易受系统噪声影响,因此结论关注可复现趋势,不把某一行的精确倍数外推。

当前实验只覆盖 CPU、NumPy、单 Head、等长 Sequence、`B∈{1,2}` 和单层 Attention。它没有实现:

- Multi-Head Attention、MQA 或 GQA;
- 不同长度 Sequence 的 Padding、Mask 或动态退出;
- GPU Kernel、显存带宽和 Kernel Launch;
- Continuous Batching、PagedAttention 与真实请求调度。

这个 Lab 证明的是 Cache 复用、分配策略和最小 Batched 执行机制,不代表真实 vLLM/GPU Serving 的固定收益。

## 下一步

固定 `T=128`,只扫描 `B∈{1,2,4,8}`,分别记录 Batch Wall Time、每 Sequence 平均时间和 Positions/s;完成 Batch Size 曲线后,再把张量扩展为 `[B, H_kv, T, D_head]`,验证 MHA/GQA 的 Head 映射与 KV 容量公式。