块大小与显存碎片:--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) 浪费比例很小,块大小影响有限 单条是否放得下
高前缀复用 块越小前缀命中粒度越细 复用率与命中边界

现象

定位步骤

  1. 读启动日志中 KV cache 相关的行,取出每块 token 数与设备块数,相乘得到池子的总 token 容量。
  2. 换一组 --block-size 再启动一次,核对池子总 token 容量是否基本不变。若变化很大,说明问题不在块大小,而在预算或权重占用。
  3. 读 vllm:gpu_cache_usage_perc,确认池子占用是否长期贴近 1;同时读 vllm:num_requests_waiting 是否持续大于零。
  4. 按请求长度分布估算块内浪费:把各条请求的 block_size - (S mod block_size) 累加,得到末块空置的 token 数;与池子总 token 容量相除,得到浪费占比。
  5. 做单变量对照:固定请求集与并发,只改 --block-size,记录可驻留序列数与吞吐。块调小若能多容纳序列,说明此前受末块与块对齐的浪费约束;若几乎无差别,说明瓶颈不在块粒度。
  6. 若开了 --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 容量是否随块大小变化,再按请求长度分布估浪费,最后用只改块的单变量对照判断瓶颈是否真在块粒度上。