GPU 显存速度快,但是又贵又小,CPU 内存速度慢,但便宜量又大
如何能让 cpu kv cache 平替宝贵的 GPU kv cache 呢,这就是 offloading KV cache。
- prefill 是计算密集型操作,速度取决于GPU浮点运算速度
- kv cache 从 CPU 到 GPU 的交换,无论是cpu->GPU的 swap in (H2D) 还是 GPU -> CPU 的 swap out (D2H),都是IO密集型操作,受限于pcie总线带宽
- GPU IO 和计算可以通过使用多个 cuda.Stream 结合 non_blocking 并行
如果 prefill 速度小于IO速度,GPU 计算开销可以将 kv cache swap 开销完全 cover. 实现 Computation-Communication Overlap.
python -m benchmarks.offloading_KV_cache.baseline.test_prefills
python -m benchmarks.offloading_KV_cache.baseline.test_swap
首先测试 prefills 的速度, swap in 速度, swap out速度
- input_len = 8192
- max_num_batched_tokens = 1024
| model | num_attention_layers | num_kv_heads | head_size | kv_cache_dtype | prefill (s) | swap in (s) | swap out (s) |
|---|---|---|---|---|---|---|---|
| Qwen2.5-32B-Instruct-GPTQ-Int4 | 64 | 8 | 128 | float16 | 3.69 | 0.19 | 0.21 |
| Qwen2.5-7B-Instruct-bf16 | 28 | 4 | 128 | bfloat16 | 0.84 | 0.06 | 0.06 |
| Qwen2.5-7B-Instruct-fp8 | 28 | 4 | 128 | bfloat16 | 0.53 | 0.06 | 0.06 |
| Qwen2.5-3B-Instruct-bf16 | 36 | 2 | 128 | bfloat16 | 0.40 | 0.08 | 0.08 |
| glm-4-9b-chat-1m-bf16 | 40 | 4 | 128 | bfloat16 | 1.08 | 0.09 | 0.09 |
| glm-4-9b-chat-1m-fp8 | 40 | 4 | 128 | bfloat16 | 0.68 | 0.09 | 0.09 |
| Llama-3.1-8B-bf16 | 32 | 8 | 128 | bfloat16 | 0.89 | 0.09 | 0.10 |
| Llama-3.1-8B-fp8 | 32 | 8 | 128 | bfloat16 | 0.56 | 0.09 | 0.10 |
表1 可以看到常用的模型,甚至最小的 Qwen2.5-3B,一个 prefill GPU 计算时间都显著小于 kv cache 交换时间,这使得 offloading KV cache 成为可能.
让我们进一步看看 swap in 和 swap out 具体是怎么执行的。
python -m benchmarks.offloading_KV_cache.profiler.swap_out
图1 Qwen2.5-3B-Instruct-bf16 swap out 1024 个 token 花费 16ms, 8192 个token 花费大概 128 ms,跟表1 里的 0.08s 对比, 估计 swap 速度还是有一些改进空间.
图2 放大,swap out 对每层,每个kv block都 launch 一个 Memcpy DtoH cuda kernel,比较低效。但是暂时没有什么更好的办法。
kv cache 布局
- 为了支持 Pipeline-Parallelism, gpu kvcache 使用 layerwise 布局,也就是 num_attention_layers * (2, num_blocks, block_size, num_kv_heads, head_size)
- 为了方便 block 交换,CPU kvcache 使用 blockwise 布局, 也就是 (num_blocks, num_attention_layers, 2, block_size, num_kv_heads, head_size)
python -m benchmarks.offloading_KV_cache.profiler.swap_in
- input_len = 1024 * 2
- output_len = 16
- num_prompts = 4
- max_num_batched_tokens = 1024
- 关闭 gpu prefix_caching
运行4次相同的 2048 个 token 的 prompt, 其中第一次需要计算并swap out, 后面三次命中offloading_KV_cache, swap in
图3 计算和swap使用两个stream, swap 几乎不干扰计算, 看上去挺不错的
这个测试为了进一步验证 异步 swap_out 几乎没有 overhead,不会拖慢系统。
python -m benchmarks.offloading_KV_cache.test_swap_out
- input_len = 8192
- output_len = 1
- num_prompts = 4
- hit_rate = 0.
- max_num_batched_tokens_list = [1024, 768, 512, 384, 256, 128, 64, 32]
也就是 4 个 完全不同的 8192 个 token 的 prompt,是否使用 offloading 也就是是否 swap_out 有没有速度差异
开启 gpu_cache
| num_batched_tokens | sync | async-2 | offloading-sync | offloading-async-2 | Delta-sync | Delta-async-2 |
|---|---|---|---|---|---|---|
| 1024 | 2.48 | 2.58 | 2.45 | 2.59 | 0.99 | 1.00 |
| 768 | 2.32 | 2.52 | 2.28 | 2.53 | 0.99 | 1.00 |
| 512 | 2.40 | 2.57 | 2.31 | 2.50 | 0.96 | 0.97 |
| 384 | 2.12 | 2.36 | 2.03 | 2.31 | 0.96 | 0.98 |
| 256 | 1.83 | 2.16 | 1.74 | 2.20 | 0.95 | 1.02 |
| 128 | 1.14 | 1.44 | 1.08 | 1.42 | 0.95 | 0.99 |
| 64 | 0.58 | 0.75 | 0.56 | 0.74 | 0.96 | 0.98 |
| 32 | 0.30 | 0.39 | 0.29 | 0.38 | 0.96 | 0.98 |
关闭 gpu_cache,模拟 gpu_cache 被击穿
| num_batched_tokens | sync | async-2 | offloading-sync | offloading-async-2 | Delta-sync | Delta-async-2 |
|---|---|---|---|---|---|---|
| 1024 | 2.49 | 2.56 | 2.44 | 2.57 | 0.98 | 1.00 |
| 768 | 2.32 | 2.54 | 2.29 | 2.51 | 0.99 | 0.99 |
| 512 | 2.40 | 2.54 | 2.37 | 2.52 | 0.99 | 0.99 |
| 384 | 2.11 | 2.37 | 2.07 | 2.30 | 0.98 | 0.97 |
| 256 | 1.81 | 2.18 | 1.74 | 2.10 | 0.96 | 0.96 |
| 128 | 1.11 | 1.44 | 1.08 | 1.41 | 0.98 | 0.98 |
| 64 | 0.58 | 0.75 | 0.56 | 0.74 | 0.96 | 0.99 |
| 32 | 0.31 | 0.39 | 0.29 | 0.38 | 0.94 | 0.98 |
表2 无论开启 gpu_cache 还是关闭 gpu_cache, swap_out overhead 都不大
- 也就是几乎没有开销的可以把 kv cache 转移到更大的 cpu 内存里
- num_batched_tokens 越大 overhead 越小
- 使用 异步调度可以进一步降低 overhead,当然也可以进一步提高QPS
这个测试为了进一步验证 cpu kv cache 异步 swap in,可以多大程度平替宝贵的 GPU kv cache
python -m benchmarks.offloading_KV_cache.test_swap_in
- input_len = 1000
- output_len = 24
- num_prompts = 1000
- max_num_batched_tokens = 1024
- swap_space = 40
- hit_rate = [0.1 * x for x in range(0, 11)]
也就是 1000 个 输入 1000 token, 输出 24 个 token 的 prompt,随着前缀匹配的比例从0% 加到100%,QPS变化
图4
- 前缀匹配的比例 <80%, Computation-Communication Overlap, cpu kv cache 和 cpu kv cache QPS几乎一样,也就是 cpu kv cache 平替 GPU kv cache
- 超过 80%,也就是先从 cpu kv cache swap_in 80% 的内容,然后 prefill 20%内容, decoding 24 个 token,后一半计算时间没办法Overlap前一段swap_in的时间
- 总之,offloading KV cache 是非常好用的
python -m benchmarks.offloading_KV_cache.profiler.test_long_prefill
- Qwen/Qwen2.5-7B-Instruct
- input_len = 1024 * 10
- num_prompts = 10
- max_num_batched_tokens = 1024
- output_len = N
这里模拟 1024 * 10 token 长度的 10 个不同 prompt 运行多轮,其中第一轮需要计算,第二轮开始命中cache
分别测试 naive(不使用prefix_caching),使用 prefix_caching, offloading+prefix_caching, offloading不使用prefix_caching 四种场景
| output_len: | 2 | 4 | 8 | 16 | 32 | 64 | 128 |
|---|---|---|---|---|---|---|---|
| 1. naive | 0.91 | 0.90 | 0.89 | 0.84 | 0.75 | 0.62 | 0.45 |
| 2. prefix_caching-1 | 0.90 | 0.90 | 0.88 | 0.84 | 0.74 | 0.61 | 0.44 |
| 3. prefix_caching-2 | 49.50 | 25.48 | 12.86 | 6.43 | 3.23 | 1.62 | 0.81 |
| 4. prefix_caching-3 | 49.96 | 25.46 | 12.86 | 6.39 | 3.23 | 1.62 | 0.81 |
| 5. offloading+prefix_caching-1 | 0.90 | 0.87 | 0.86 | 0.81 | 0.72 | 0.59 | 0.43 |
| 6. offloading+prefix_caching-2 | 48.86 | 25.04 | 12.67 | 6.36 | 3.19 | 1.60 | 0.80 |
| 7. offloading+prefix_caching-3 | 49.22 | 25.01 | 12.66 | 6.36 | 3.19 | 1.60 | 0.80 |
| 8. offloading+no_gpu_caching-1 | 0.90 | 0.88 | 0.86 | 0.82 | 0.72 | 0.59 | 0.43 |
| 9. offloading+no_gpu_caching-2 | 9.23 | 7.69 | 5.79 | 4.69 | 2.92 | 1.52 | 0.78 |
| 10. offloading+no_gpu_caching-3 | 9.23 | 7.69 | 5.79 | 4.69 | 2.90 | 1.52 | 0.78 |
表2
- 第1行 naive 为不使用 gpu cache,硬算的速度,作为比较的基线
- 第2-4行 为使用 prefix_caching,第2行需要计算,第3-4行 命中gpu_cache,速度有明显提升
- 第5-7行 为使用 offloading+prefix_caching,第5行需要计算,第3-4行 命中gpu_cache,速度跟prefix_caching几乎相同
- 第8-10行 offloading不使用gpu cache,模拟gpu cache被击穿的情况,第8行需要计算, 第3-4行 命中cpu_cache,速度跟 gpu_cache 肯定是 没法比,但随着输出token的增加,Computation-Communication Overlap优化更明显,速度差异不是那么显著
- 对比 naive、prefix_caching-1、offloading+prefix_caching-1、offloading+no_prefix_caching-1:prefix_caching 和 offloading 几乎没有 overhead
- 对比 prefix_caching-2 和 offloading+prefix_caching-2, 如果内命中gpu的prefix_caching,offloading 几乎没有 overhead
- 对比 offloading+prefix_caching-2 和 offloading+no_prefix_caching-2, 关闭 gpu 的 prefix_caching,也就是命中cache 需要 swap in,qps明显变低
cpu cache 和 gpu cache 具体速度差异从哪里来?
-
当第一轮没有命中cache时,计算同时 swap out
-
input_len = 1024 * 10, max_num_batched_tokens = 1024, 一个请求需要10个 prefill step
-
执行模型之前kv cache还没有填充, swap out 是从执行模型结束才开始,采样和后处理比swap out慢,所以 swap out 结束,并可以让其他请求命中 swap in是下一个 step 结束
-
swap out 可以一边 计算,一边 swap out,几乎不影响
-
swap in 需要将所有命中 cache 的 block 都 swap in 才能开始执行
-
所以 swap in 有一些 overhead
-
总之 使用 cpu kv cache 速度比 gpu kv cache慢,有等待block swap,以及将所有需要的 block swap in 的 overhead
暂时使用 hash full blocks 实现 cpu kv cache,也就是只管写满了的block。
hash full blocks 是工程上实现 Prefix Caching 比较好的选择。



