块大小与显存碎片:--block-size 取值、块内浪费与块对齐
在一组 8 卡 **** ****(单卡 64GB HBM,卡间互联 392 GB/s)上,用 **** 的基础软件栈配合 vLLM 0.4.x 的 v0 引擎起服务,入口是 python -m vllm.entrypoints.openai.api_server --model <model-path>。负载是一批长度参差的短请求,序列长度多落在 300 到 800 token 之间。默认 --block-size 为 16;把它改成 32 后,同样并发下 vllm:gpu_cache_usage_perc 更快贴近 1,能同时驻留的序列数下降,而启动日志里 KV 池的总 token 容量几乎没变,变的只是块数与每块 token 数。这一篇区分三件事:块大小怎样改变池子总容量,块内浪费怎么随块大小变化,块对齐怎样把成本转到块表与每步元数据上。
block 与块内布局
PagedAttention 把 KV cache 切成固定 token 数的块,每块存 block_size 个 token 的 K 与 V。缓存张量的形状在注意力后端里写成:
# vllm/attention/ops/paged_attn.py(原文)
return (2, num_blocks, block_size * num_kv_heads * head_size)
首维是 K 与 V 两份,第二维是块数,第三维把块内 token 数与每个 KV 头的元素数展平。块大小只出现在第三维里,改变它等于改变单块装多少 token。
单块字节数由 CacheEngine.get_cache_block_size 算出,公式里 block_size 只乘在每块的 K/V 元素数上:
# vllm/worker/cache_engine.py(原文)
key_cache_block = cache_config.block_size * num_heads * head_size # num_heads = num_kv_heads
value_cache_block = key_cache_block
total = num_layers * (key_cache_block + value_cache_block)
return dtype_size * total
即单块字节数 = dtype_size × num_layers × 2 × block_size × num_kv_heads × head_size。
块大小不改变池子总容量
设备侧块数不是配置项,而是按剩余预算除以单块字节数算出来的:
# vllm/worker/worker.py,初始化缓存时的块数计算(原文)
cache_block_size = self.get_cache_block_size_bytes()
num_gpu_blocks = int(
(total_gpu_memory * self.cache_config.gpu_memory_utilization -
peak_memory) // cache_block_size)
单块字节数与 block_size 成正比,因此 num_gpu_blocks 与 block_size 成反比,两者相乘近似恒定:
num_gpu_blocks × block_size ≈ 常数 # 常数 = 预算字节数 ÷ (dtype_size × num_layers × 2 × num_kv_heads × head_size)
结论是 --block-size 不改变 KV 池能装多少 token,只改变切分粒度:块调大一档,块数大约减半,每块装两倍的 token,池子总容量不变。启动日志里 KV 池的总 token 数(block_size × num_gpu_blocks)在两组取值下基本一致,核对时应以这一行为准,而不是只看块数。
单序列长度上限同样与块大小无关。raise_if_cache_size_invalid 用 max_seq_len = block_size * num_gpu_blocks 做校验,这个乘积正是池子的总 token 容量,超过即报 The model's max seq len (...) is larger than the maximum number of tokens that can be stored in KV cache (...)。块大小在这个式子里被约掉,调它既不放宽也不收紧 --max-model-len。
块对齐与块内浪费
一条长度为 S 的序列占用 ceil(S / block_size) 个块。整数块之外,最后一块只填了 S mod block_size 个位置,若不能整除,其余位置空置但按整块计费,这就是块内浪费。单条序列的期望浪费约为半个块:
单条序列块内浪费(token 数)≈ (block_size - S mod block_size) mod block_size
平均值 ≈ block_size / 2
浪费占该序列的比例 ≈ block_size / (2 × S)
浪费比例与 S 成反比、与 block_size 成正比:长序列(S 远大于 block_size)时比例很小,块大小影响有限;短序列多、长度参差时,块调大一档浪费比例随之上升。并发 N 条序列时,每条都有自己的末块,瞬时浪费叠加为 N × block_size / 2 个 token 的 KV。取一组常见规格作估算(非实测):block_size = 32、N = 64 时,末块浪费约 64 × 16 = 1024 个 token 的 KV;block_size = 16 时约 512。相对池子总量占比不大,但池子贴近满时,正是这半个块的差额决定还能不能塞进下一条序列,表现为 vllm:gpu_cache_usage_perc 优先顶到 1。
块表与每步元数据
每条序列要有一张块表,记录它用了哪些物理块。块表以 SequenceGroupMetadata.block_tables 的形态存在,类型是 Dict[int, List[int]],键是序列 id,值是物理块号列表。块表长度等于 ceil(S / block_size),与块大小成反比:S = 8192、block_size = 8 时长度 1024,block_size = 32 时长度 256。
块表随每个调度步随 batch 传给 worker。块越小,块表越长,CPU 侧构造与传输的元数据越大,每步的固定开销越高。这是块粒度取舍的另一半代价:块小换来更细的对齐与更小的末块浪费,代价是块数与块表变长。
取值与约束
| 项 | 取值 | 说明 |
|---|---|---|
--block-size 可选项 |
8 / 16 / 32 | 这一版 CLI 的 choices 只允许这三个值 |
--block-size 默认值 |
16 | |
--gpu-memory-utilization 默认值 |
0.90 | 划给模型执行器的显存比例,决定块数预算 |
--num-gpu-blocks-override 默认值 |
无 | 显式指定设备块数,覆盖按预算算出的值 |
块大小在这一版不参与注意力后端的选择:选择函数只按 dtype 与设备能力挑后端,不看块大小。因此改块大小不会把服务切到另一条注意力实现上。唯一与块大小有关的内核约束是分块路径里的 assert _PARTITION_SIZE % block_size == 0,8、16、32 三个取值都满足。
开 --enable-prefix-caching 时,块大小同时是前缀命中的粒度:前缀缓存按整块匹配并只缓存满块,块越小,命中粒度越细,尾部越容易对齐;块越大,命中粒度越粗。这与共享前缀的复用率直接相关。
触发场景
| 负载形态 | 对块内浪费与元数据的影响 | 先受阻的方面 |
|---|---|---|
| 短序列多、长度参差 | 末块浪费比例 block_size / (2S) 高,块调大更明显 |
池子可驻留序列数 |
| 高并发短请求 | 瞬时浪费按 N × block_size / 2 叠加 |
池子贴近满时先塞不进新序列 |
超长单序列(S 远大于 block_size) |
浪费比例很小,块大小影响有限 | 单条是否放得下 |
| 高前缀复用 | 块越小前缀命中粒度越细 | 复用率与命中边界 |
现象
- 同样并发下把
--block-size调大,vllm:gpu_cache_usage_perc更快贴近 1,可驻留序列数下降。 - 启动日志里 KV 池的总 token 容量在两组块大小下基本一致,变化的只有
num_gpu_blocks。 - 把
--block-size调小,块数与块表变长,调度每步的固定开销上升,短序列吞吐不一定改善。 - 开前缀缓存时,改块大小会改变命中边界:共享前缀长度不是新块大小整数倍的部分整块作废。
定位步骤
- 读启动日志中 KV cache 相关的行,取出每块 token 数与设备块数,相乘得到池子的总 token 容量。
- 换一组
--block-size再启动一次,核对池子总 token 容量是否基本不变。若变化很大,说明问题不在块大小,而在预算或权重占用。 - 读
vllm:gpu_cache_usage_perc,确认池子占用是否长期贴近 1;同时读vllm:num_requests_waiting是否持续大于零。 - 按请求长度分布估算块内浪费:把各条请求的
block_size - (S mod block_size)累加,得到末块空置的 token 数;与池子总 token 容量相除,得到浪费占比。 - 做单变量对照:固定请求集与并发,只改
--block-size,记录可驻留序列数与吞吐。块调小若能多容纳序列,说明此前受末块与块对齐的浪费约束;若几乎无差别,说明瓶颈不在块粒度。 - 若开了
--enable-prefix-caching,把块大小与共享前缀长度一起看:前缀长度是否为块大小的整数倍,决定命中落在哪个边界上。
# 示意:只改块大小做单变量对照的启动命令(参数名取自框架,取值按本机规格填)
python -m vllm.entrypoints.openai.api_server --model <model-path> \
--tensor-parallel-size 8 \
--max-model-len 32768 \
--gpu-memory-utilization 0.9 \
--max-num-seqs 64 \
--block-size 16
处置
| 动作 | 作用 | 代价 / 前提 |
|---|---|---|
--block-size 调小 |
末块浪费比例下降,前缀命中粒度变细 | 块数与块表变长,每步元数据开销上升 |
--block-size 调大 |
块数与块表变短,元数据开销下降 | 末块浪费比例上升,短序列多时先塞不进池子 |
--gpu-memory-utilization 调大 |
直接增大块数预算,总容量上升 | 挤占激活与工作区,prefill 峰值上升时可能失败 |
--num-gpu-blocks-override 指定块数 |
跳过按预算的估算,用于复现实验 | 取值过大时启动即失败,须与实测预算对齐 |
开 --enable-prefix-caching |
共享前缀只算一次,减少实际占用 | 前缀须满块对齐,块大小改变命中边界 |
处置顺序:--block-size 只改变切分粒度,不改变池子总容量,先确认瓶颈是不是块对齐引起的浪费。只有在短序列多、池子贴近满时,把块调小才可能换来更多可驻留序列;序列普遍较长时,块大小对浪费占比影响很小,改它收益有限。要增加容量应调 --gpu-memory-utilization 或压 --max-num-seqs,这两项才直接改变可用块数。
记录字段
# 一条块大小取舍记录(字段名示意)
block:
block_size: # --block-size,取值 8 / 16 / 32,默认 16
num_gpu_blocks: # 设备块数(派生)
block_bytes: # 单块字节数 = dtype_size × num_layers × 2 × block_size × num_kv_heads × head_size
pool_total_tokens: # block_size × num_gpu_blocks,池子总 token 容量
use_v2_block_manager: # 是否启用 V2 块管理器
waste:
avg_block_waste_ratio: # 估算的末块浪费占比 ≈ block_size / (2 × S)
concurrent_waste_tokens: # 瞬时浪费 ≈ N × block_size / 2
block_table_len: # ceil(S / block_size)
cache:
gpu_cache_usage_perc: # vllm:gpu_cache_usage_perc
num_requests_waiting: # vllm:num_requests_waiting
enable_prefix_caching:
workload:
model_name:
input_len_distribution: # 序列长度分布,决定浪费比例
concurrency:
max_num_seqs:
perf:
resident_seqs: # 可同时驻留的序列数
throughput:
status: # 受块对齐约束 / 受容量约束 / 块大小无影响
小结
--block-size 在 v0 引擎里只决定切分粒度,不决定总容量:单块字节数与块大小成正比,设备块数与它成反比,两者相乘即池子总 token 容量,与块大小无关,max_seq_len = block_size × num_gpu_blocks 的单序列上限也与之无关。块大小真正影响两处:一是末块浪费,单条期望浪费约半个块、占比约 block_size / (2S),并发下叠加为 N × block_size / 2;二是块表长度 ceil(S / block_size) 与随之每步上升的元数据开销。取值受 CLI 的 choices 限定在 8、16、32,这一版不参与注意力后端选择。定位时先核对池子总 token 容量是否随块大小变化,再按请求长度分布估浪费,最后用只改块的单变量对照判断瓶颈是否真在块粒度上。