Skip to content

37. KV Cache Scheduling | KV Cache 调度

难度: Hard | 环境: CPU-first | 标签: 推理优化, KV Cache, 调度 | 目标人群: 推理优化学习者

🚀 云端运行环境

本章节的实战代码可以点击以下链接在免费 GPU 算力平台上直接运行:

Open In ColabOpen In Studio (国内推荐:魔搭社区免费实例)


本节导读

当多个请求共享前缀并持续生成时,KV Cache 会同时面临复用和容量压力。调度器需要记录每个缓存的大小、命中次数和最近访问时间,再根据这些状态决定缓存的保留顺序和驱逐顺序。

本节沿着“请求访问 → 缓存状态 → 价值评分 → 优先级队列 → 容量驱逐”的顺序理解 KV Cache 调度。重点是看清访问热度、缓存容量和驱逐顺序之间如何相互影响,并建立从状态变化到调度决策的完整认知链。

关键词: KV Cache 调度缓存复用容量驱逐


前置阅读

导语: 进入本节前,先能读出一个缓存块的容量、命中和最近访问状态,再观察这些状态如何影响保留与驱逐顺序。


Step 1: 为什么 KV Cache 需要调度

多个请求同时生成时,KV Cache 不仅要保存可复用状态,还要在容量有限时决定保留谁、驱逐谁。前缀缓存提供复用线索,分页管理提供可分配的 block,请求访问过程持续更新命中次数和最近访问时间。

本节的输入是缓存访问事件、单条缓存的大小与访问状态,以及全局容量;输出是可比较的缓存价值、驱逐顺序和容量快照。

观察入口发生了什么形成的调度依据
内容复用同一前缀再次被访问命中次数和复用价值
空间占用新请求需要更多 KV Cache block当前容量和驱逐压力
访问热度命中次数和最近访问不断变化当前缓存价值和优先级

KV Cache 调度概念图:前置机制、缓存状态与容量决策

Step 2: 缓存状态与容量账本

先把每段可复用缓存看成一条状态记录,再把所有记录放进同一份容量账本。单条记录描述一个前缀的占用和访问历史,全局字段描述当前容量;这些字段共同决定下一步的保留与驱逐。

字段层级记录什么对调度的作用
单条缓存:标识prefix 对应的缓存记录判断访问是否命中同一前缀
单条缓存:大小这段缓存占用多少字节计算新增缓存带来的容量压力
单条缓存:命中次数该前缀已经复用多少次估计继续保留的复用价值
单条缓存:最近访问最近一次访问发生的时间反映当前访问热度
全局账本:容量总容量和当前已分配容量判断何时需要触发驱逐

Step 3: 评分、堆队列与过期记录

Step 2 的状态字段进入价值评估:复用次数体现未来命中机会,最近访问体现当前热度,缓存大小体现容量成本。教学实现把三者合成为一个可比较的保留分数,再交给优先级队列处理:score = 复用奖励 + 最近访问奖励 - 容量惩罚。分数变化后不直接修改堆中的旧记录,而是在弹出时核对它是否仍然有效。

机制作用设计时关注
评分把缓存价值变成可比较的数字复用、时间和容量共同影响分数
堆队列让低优先级缓存可以先被找到刷新时追加当前记录,保留可追溯状态
过期检查(stale check)弹出时重新核对当前 entry让驱逐依据跟随最新状态
驱逐容量不足时释放低价值缓存超过容量时选择驱逐或降级路径

KV Cache 调度的评分、堆队列与过期记录

Step 4: 实现缓存评分、刷新与驱逐

把前面的状态账本和评分机制落到代码中,再用访问事件复查缓存状态如何变化。实现链是“访问 → 评分 → 队列刷新 → 过期检查 → 驱逐 → 快照”,表格列出各环节的输入、动作和观察重点。

实现位置输入或状态应完成的动作测试时观察
_score命中次数、容量、最近访问合成可比较的保留分数复用多、访问近的缓存分数更高
_refresh_queue当前 entry追加最新的评分、时间和前缀历史记录保留,等待弹出时核对
_evict_until_fit堆顶记录、当前 entry核对状态后释放所需容量过期记录跳过,有效低分记录参与驱逐
touch / snapshot新旧前缀与当前状态处理新增、复用并输出排序快照snapshot 只展示状态,current_bytes 维持容量约束
python
import heapq
from dataclasses import dataclass, field
from typing import Dict, List, Tuple
python
@dataclass(order=True)
class CacheEntry:
    priority: float
    last_used: int
    prefix: str = field(compare=False)
    hits: int = field(default=0, compare=False)
    bytes: int = field(default=0, compare=False)


class KVCacheSchedulerSim:
    """用优先级堆模拟可复用前缀的容量调度。"""

    def __init__(self, capacity_bytes: int = 1024):
        """初始化容量账本、缓存状态和优先级队列。"""
        if capacity_bytes <= 0:
            raise ValueError('capacity_bytes must be positive')
        self.capacity_bytes = capacity_bytes
        self.current_bytes = 0
        self.time = 0
        self.entries: Dict[str, CacheEntry] = {}
        self.queue: List[Tuple[float, int, str]] = []
        self.log: List[str] = []

    def _score(self, hits: int, size: int, last_used: int) -> float:
        """根据复用次数、访问热度和容量成本计算保留分数。"""
        recency = 1.0 / (1.0 + max(self.time - last_used, 0))
        reuse_bonus = float(hits)
        size_penalty = size / max(self.capacity_bytes, 1)
        # ==========================================
        # TODO 1: 计算 cache entry 的保留优先级
        # 提示: 复用次数越多越该保留,越新越该保留,越大越需要惩罚
        # 可用变量: reuse_bonus、recency、size_penalty;结果应为 float
        # ==========================================
        # score = ???
        return score

    def _refresh_queue(self, prefix: str):
        """把当前 entry 的最新评分追加到优先级堆。"""
        entry = self.entries[prefix]
        entry.priority = self._score(entry.hits, entry.bytes, entry.last_used)
        # ==========================================
        # TODO 2: 把最新优先级写入堆队列
        # 提示: 这里要弹出低 priority 的缓存,因此不要对 priority 取负
        # queue_item 应包含 (priority, last_used, prefix) 三个字段
        # ==========================================
        # queue_item = ???
        heapq.heappush(self.queue, queue_item)

    def _evict_until_fit(self, needed: int):
        """在新增缓存前驱逐低价值 entry,直到容量可以容纳它。"""
        while self.current_bytes + needed > self.capacity_bytes and self.entries:
            while self.queue:
                priority, last_used, prefix = heapq.heappop(self.queue)
                entry = self.entries.get(prefix)
                if entry is None:
                    continue
                # ==========================================
                # TODO 3: 跳过堆中的过期记录
                # 提示: entry 的 priority 或 last_used 已变化时,旧堆项不再有效
                # 可比较堆顶的 priority、last_used 与 entry 的当前字段
                # ==========================================
                # is_stale = ???
                if is_stale:
                    continue
                break
            else:
                entry = min(self.entries.values(), key=lambda e: (e.priority, e.last_used))
                prefix = entry.prefix

            self.current_bytes -= entry.bytes
            self.entries.pop(prefix, None)
            self.log.append(f"evict:{prefix}")

    def touch(self, prefix: str, bytes_: int):
        """访问一个前缀,更新命中状态或创建新的缓存 entry。"""
        if not isinstance(prefix, str) or not prefix:
            raise ValueError('prefix must be a non-empty string')
        if bytes_ <= 0:
            raise ValueError('bytes_ must be positive')
        if bytes_ > self.capacity_bytes:
            raise ValueError("single cache entry exceeds capacity")

        self.time += 1
        if prefix in self.entries:
            entry = self.entries[prefix]
            entry.hits += 1
            entry.last_used = self.time
            self._refresh_queue(prefix)
            self.log.append(f"reuse:{prefix}")
            return

        self._evict_until_fit(bytes_)
        # ==========================================
        # TODO 4: 创建新的 cache entry
        # 提示: 新 entry 的 hits 从 1 开始,last_used 使用当前 time
        # entry 需要保存 prefix、bytes_、当前 time,并先使用 0.0 作为初始 priority
        # ==========================================
        # entry = ???
        self.entries[prefix] = entry
        self.current_bytes += bytes_
        self._refresh_queue(prefix)
        self.log.append(f"add:{prefix}")

    def schedule(self, requests: List[Tuple[str, int]]) -> List[str]:
        """按给定访问顺序处理前缀请求并返回事件日志。"""
        for prefix, bytes_ in requests:
            self.touch(prefix, bytes_)
        return list(self.log)

    def snapshot(self) -> List[Tuple[str, int, float, int]]:
        """按保留价值从高到低导出当前缓存状态。"""
        # ==========================================
        # TODO 5: 按优先级导出当前 cache 状态
        # 提示: 高 priority 在前;priority 相同则按 last_used 和 prefix 稳定排序
        # ordered_entries 应是 CacheEntry 列表,不能修改 self.entries
        # ==========================================
        # ordered_entries = ???
        return [(e.prefix, e.bytes, round(e.priority, 4), e.hits) for e in ordered_entries]

测试

运行下面的测试单元,确认缓存评分、驱逐和快照输出都符合预期。

python
# 测试你的实现
def test_kv_cache_scheduler():
    try:
        def expect_value_error(action, label):
            try:
                action()
            except ValueError:
                return
            raise AssertionError(f'{label} 应拒绝非法输入')

        expect_value_error(lambda: KVCacheSchedulerSim(capacity_bytes=0), 'capacity_bytes=0')
        sim = KVCacheSchedulerSim(capacity_bytes=128)
        requests = [
            ('a', 40),
            ('b', 48),
            ('a', 40),
            ('c', 56),
            ('d', 48),
            ('a', 40),
        ]
        log = sim.schedule(requests)
        snap = sim.snapshot()

        assert len(log) >= len(requests)
        assert any(item.startswith('reuse:a') for item in log)
        assert any(item.startswith('evict:') for item in log)
        assert sim.current_bytes <= sim.capacity_bytes
        assert isinstance(snap, list)
        assert all(len(item) == 4 for item in snap)
        priorities = [item[2] for item in snap]
        assert priorities == sorted(priorities, reverse=True)

        # 评分应体现三条机制:更多复用、更近访问和更小容量成本。
        hot_score = sim._score(hits=3, size=16, last_used=sim.time)
        cold_score = sim._score(hits=1, size=16, last_used=0)
        small_score = sim._score(hits=1, size=16, last_used=sim.time)
        large_score = sim._score(hits=1, size=64, last_used=sim.time)
        assert hot_score > cold_score
        assert small_score > large_score

        # 同一前缀被访问后,旧堆记录必须被识别为 stale,而不是重复驱逐同一 entry。
        stale_sim = KVCacheSchedulerSim(capacity_bytes=80)
        stale_sim.touch('hot', 32)
        stale_sim.touch('hot', 32)
        assert len(stale_sim.queue) >= 2
        stale_sim.touch('cold', 64)
        assert stale_sim.current_bytes <= stale_sim.capacity_bytes
        assert len(stale_sim.entries) == len(set(stale_sim.entries))
        assert stale_sim.current_bytes == sum(item.bytes for item in stale_sim.entries.values())

        # 输入校验属于容量账本的边界条件,不应被宽泛异常处理吞掉。
        expect_value_error(lambda: sim.touch('', 8), '空 prefix')
        expect_value_error(lambda: sim.touch('bad-size', 0), '非正 bytes')
        expect_value_error(lambda: sim.touch('too-large', 129), '超过容量的 entry')

        print('✅ KVCacheSchedulerSim 测试通过')
    except NotImplementedError as e:
        raise NotImplementedError('请先完成 TODO 代码!') from e
    except (NameError, AttributeError) as e:
        raise NotImplementedError('请先完成 TODO 代码或检查字段名!') from e


test_kv_cache_scheduler()

🛑 STOP HERE 🛑









请先尝试自己完成代码并跑通测试。
如果你正在 Colab 中运行,并且遇到困难没有思路,可以向下滚动查看参考答案。










参考代码与解析

代码

python
# TODO:下面是题目区的参考实现。

@dataclass(order=True)
class CacheEntry:
    priority: float
    last_used: int
    prefix: str = field(compare=False)
    hits: int = field(default=0, compare=False)
    bytes: int = field(default=0, compare=False)


class KVCacheSchedulerSim:
    """用优先级堆模拟可复用前缀的容量调度。"""

    def __init__(self, capacity_bytes: int = 1024):
        """初始化容量账本、缓存状态和优先级队列。"""
        if capacity_bytes <= 0:
            raise ValueError('capacity_bytes must be positive')
        self.capacity_bytes = capacity_bytes
        self.current_bytes = 0
        self.time = 0
        self.entries: Dict[str, CacheEntry] = {}
        self.queue: List[Tuple[float, int, str]] = []
        self.log: List[str] = []

    def _score(self, hits: int, size: int, last_used: int) -> float:
        """根据复用次数、访问热度和容量成本计算保留分数。"""
        recency = 1.0 / (1.0 + max(self.time - last_used, 0))
        reuse_bonus = float(hits)
        size_penalty = size / max(self.capacity_bytes, 1)
        # ==========================================
        # TODO 1: 计算 cache entry 的保留优先级
        # 提示: 复用次数越多越该保留,越新越该保留,越大越需要惩罚
        # 可用变量: reuse_bonus、recency、size_penalty;结果应为 float
        # ==========================================
        score = reuse_bonus + 0.5 * recency - 0.25 * size_penalty
        return score

    def _refresh_queue(self, prefix: str):
        """把当前 entry 的最新评分追加到优先级堆。"""
        entry = self.entries[prefix]
        entry.priority = self._score(entry.hits, entry.bytes, entry.last_used)
        # ==========================================
        # TODO 2: 把最新优先级写入堆队列
        # 提示: 这里要弹出低 priority 的缓存,因此不要对 priority 取负
        # queue_item 应包含 (priority, last_used, prefix) 三个字段
        # ==========================================
        queue_item = (entry.priority, entry.last_used, prefix)
        heapq.heappush(self.queue, queue_item)

    def _evict_until_fit(self, needed: int):
        """在新增缓存前驱逐低价值 entry,直到容量可以容纳它。"""
        while self.current_bytes + needed > self.capacity_bytes and self.entries:
            while self.queue:
                priority, last_used, prefix = heapq.heappop(self.queue)
                entry = self.entries.get(prefix)
                if entry is None:
                    continue
                # ==========================================
                # TODO 3: 跳过堆中的过期记录
                # 提示: entry 的 priority 或 last_used 已变化时,旧堆项不再有效
                # 可比较堆顶的 priority、last_used 与 entry 的当前字段
                # ==========================================
                is_stale = (priority, last_used) != (entry.priority, entry.last_used)
                if is_stale:
                    continue
                break
            else:
                entry = min(self.entries.values(), key=lambda e: (e.priority, e.last_used))
                prefix = entry.prefix

            self.current_bytes -= entry.bytes
            self.entries.pop(prefix, None)
            self.log.append(f"evict:{prefix}")

    def touch(self, prefix: str, bytes_: int):
        """访问一个前缀,更新命中状态或创建新的缓存 entry。"""
        if not isinstance(prefix, str) or not prefix:
            raise ValueError('prefix must be a non-empty string')
        if bytes_ <= 0:
            raise ValueError('bytes_ must be positive')
        if bytes_ > self.capacity_bytes:
            raise ValueError("single cache entry exceeds capacity")

        self.time += 1
        if prefix in self.entries:
            entry = self.entries[prefix]
            entry.hits += 1
            entry.last_used = self.time
            self._refresh_queue(prefix)
            self.log.append(f"reuse:{prefix}")
            return

        self._evict_until_fit(bytes_)
        # ==========================================
        # TODO 4: 创建新的 cache entry
        # 提示: 新 entry 的 hits 从 1 开始,last_used 使用当前 time
        # entry 需要保存 prefix、bytes_、当前 time,并先使用 0.0 作为初始 priority
        # ==========================================
        entry = CacheEntry(priority=0.0, last_used=self.time, prefix=prefix, hits=1, bytes=bytes_)
        self.entries[prefix] = entry
        self.current_bytes += bytes_
        self._refresh_queue(prefix)
        self.log.append(f"add:{prefix}")

    def schedule(self, requests: List[Tuple[str, int]]) -> List[str]:
        """按给定访问顺序处理前缀请求并返回事件日志。"""
        for prefix, bytes_ in requests:
            self.touch(prefix, bytes_)
        return list(self.log)

    def snapshot(self) -> List[Tuple[str, int, float, int]]:
        """按保留价值从高到低导出当前缓存状态。"""
        # ==========================================
        # TODO 5: 按优先级导出当前 cache 状态
        # 提示: 高 priority 在前;priority 相同则按 last_used 和 prefix 稳定排序
        # ordered_entries 应是 CacheEntry 列表,不能修改 self.entries
        # ==========================================
        ordered_entries = sorted(self.entries.values(), key=lambda e: (-e.priority, e.last_used, e.prefix))
        return [(e.prefix, e.bytes, round(e.priority, 4), e.hits) for e in ordered_entries]

解析

题目区和答案区保留同样的 5 个 TODO;答案区只补全实现,不改变函数接口。下面的解析按 TODO 顺序解释机制,测试函数则用可观察的不变量检查实现是否正确。

TODO题目区要完成的机制测试函数中的对应检查
1评分函数把复用、热度和容量成本合成保留分数评分方向单调性
2将最新状态追加到优先级堆复用后堆中保留历史记录
3跳过过期堆记录,避免旧状态参与驱逐stale record 与容量不变量
4创建并登记新的缓存条目新增、复用和非法输入
5以稳定顺序导出当前缓存快照priority 降序与账本一致性

1. TODO 1: 计算缓存保留优先级

  • 实现方式score = reuse_bonus + 0.5 * recency - 0.25 * size_penalty
  • 关键点:复用次数越多、最近访问越近,优先级越高;缓存越大,保留成本越高
  • 技术细节recency = 1 / (1 + time_gap) 会随时间间隔衰减,避免长期未访问的缓存一直占据热路径

2. TODO 2: 刷新堆队列

  • 实现方式queue_item = (entry.priority, entry.last_used, prefix),再调用 heapq.heappush
  • 关键点:这里的堆用于驱逐,因此低 priority 应该更早被弹出,不需要对 priority 取负
  • 技术细节:同一个 prefix 可能多次刷新优先级,堆里会残留旧记录,后续需要用 stale check 跳过

3. TODO 3: 跳过过期堆项

  • 实现方式is_stale = (priority, last_used) != (entry.priority, entry.last_used)
  • 关键点:堆中的记录不一定代表当前最新状态,必须和 entries 里的 entry 再核对一次
  • 技术细节:这是懒删除策略:刷新时只压入新记录,不立即删除旧记录;弹出时再判断是否过期

4. TODO 4: 创建新的缓存条目

  • 实现方式entry = CacheEntry(priority=0.0, last_used=self.time, prefix=prefix, hits=1, bytes=bytes_)
  • 关键点:新缓存第一次写入时,命中次数从 1 开始,最近访问时间就是当前 time
  • 技术细节:新 entry 加入 entries 后再调用 _refresh_queue,由统一逻辑计算 priority 并写入堆

5. TODO 5: 导出缓存状态

  • 实现方式ordered_entries = sorted(self.entries.values(), key=lambda e: (-e.priority, e.last_used, e.prefix))
  • 关键点:快照按高 priority 在前排序,便于观察当前哪些缓存最应该保留
  • 技术细节round(e.priority, 4) 只影响展示,不影响内部调度精度

KV Cache Scheduling 核心机制

  • 复用价值:命中次数高的前缀代表未来继续复用的概率更高,通常应该提高保留优先级
  • 容量压力:KV Cache 会随上下文长度和并发请求增长,容量不足时必须选择低价值缓存驱逐
  • 懒删除堆:优先级队列允许重复记录,通过 stale check 保证真正驱逐的是最新的低优先级 entry

工程优化要点

  • 驱逐策略:真实系统通常会混合 LRU、LFU、prefix sharing、租户优先级和请求 deadline
  • 显存安全:单个 cache block 不能超过容量,否则应直接拒绝或走降级路径
  • 调度联动:KV Cache 调度通常要和 decode scheduling、prefix caching、PagedAttention block 管理一起设计

Step 5: 可选 GPU:观察代表性 KV block 的显存占用

本步把 Step 2 的容量账本连接到一次可复核的 CUDA 分配实验。它不加载模型,也不复现 vLLM / SGLang 的真实 KV Cache 调度;实验只回答一个问题:在给定 block 形状和 dtype 下,block 数量增加时,理论字节数与实际分配峰值如何变化。评分、堆队列和驱逐顺序仍由 CPU 题目区验证。

开始实验前,先确认当前 Notebook 使用的是 GPU 内核;配置单元默认关闭实验,改为 True 后自动检查 CUDA、原生 BF16、参数范围、项目根目录和结果路径,并将运行条件与结果保存为 JSON。

实验路径使用资产学习者操作可以验证 / 不能直接推出
CPU 机制验证题目区 KVCacheSchedulerSim运行评分、堆队列、过期记录、驱逐顺序和容量账本测试可以验证调度逻辑;不能推出 CUDA allocator 峰值或真实 KV block 布局
GPU 环境预检tools/environment_preflight.py、当前 Notebook runtime检查 CUDA、GPU、显存和 dtype 支持;需要时执行 python tools/environment_preflight.py --gpu --packages torch判断当前环境能否执行;不产生 backend 性能结论
GPU block probe配置单元、执行单元和 JSON 输出固定 block 形状,单独改变 block 数量、token 数或 dtype;将 RUN_GPU_BLOCK_PROBE 改为 True可以比较代表性 block 的理论大小与实际分配;不能推出命中率、驱逐策略、TTFT 或吞吐
真实 backend 扩展vLLM / SGLang、69 和 70在匹配的单 GPU backend 中测量 KV Cache、并发和请求延迟可以验证 backend 行为;本节的 synthetic probe 不能替代 backend 对比
结果登记本节最后的 GPU 实验记录表每组新配置新增一行,不覆盖已有结果形成可比较的 block footprint 记录

真实 backend 数据应转到 69 Prefix Caching Benchmark70 Serving Scheduler Benchmark;本步结果只作为容量账本和实验设计的补充证据。

配置与测量

配置单元只负责选择实验参数;执行单元负责环境检查、分配代表性 block、同步 CUDA、生成 JSON。每次只改变一个主要变量,便于把显存变化归因到 block 数量、token 数或 dtype。

实验内容固定或改变的条件观察目的
代表性 blockGPU_BLOCK_TOKENS、KV head 数、head dim对照理论字节数和实际分配
容量压力GPU_BLOCK_COUNT观察 block 数量与峰值显存的关系
dtypeGPU_DTYPE比较 dtype 对 block footprint 的影响
证据范围synthetic GPU block probe不推导真实 backend 的命中率、驱逐策略或吞吐
python
"""配置单元:默认只检查配置,不启动 GPU。"""
RUN_GPU_BLOCK_PROBE = False  # 改为 True 才会分配代表性 KV block。
GPU_RESULT_PATH = 'benchmarks/results/37_kv_cache_block_probe.json'  # 相对项目根目录。
GPU_BLOCK_COUNT = 16  # 只改变容量压力时修改;先从小规模开始。
GPU_BLOCK_TOKENS = 128  # 每个 block 代表的 token 数。
GPU_KV_HEADS = 8
GPU_HEAD_DIM = 64
GPU_DTYPE = 'float16'  # 可改为 bfloat16,但必须确认硬件原生支持。
GPU_REPEATS = 3
python
"""执行单元:只测代表性 block 的分配,不启动模型或 backend。"""
if RUN_GPU_BLOCK_PROBE:
    import json
    import time
    from pathlib import Path
    import torch

    if not torch.cuda.is_available():
        raise RuntimeError('GPU block probe 需要 CUDA;请先切换到 GPU runtime。')
    if GPU_DTYPE not in {'float16', 'bfloat16'}:
        raise ValueError('GPU_DTYPE 只能是 float16 或 bfloat16。')
    if GPU_DTYPE == 'bfloat16' and not torch.cuda.is_bf16_supported(including_emulation=False):
        raise RuntimeError('当前 GPU 不支持原生 BF16,请改用 float16。')
    if any(value < 1 for value in (GPU_BLOCK_COUNT, GPU_BLOCK_TOKENS, GPU_KV_HEADS, GPU_HEAD_DIM, GPU_REPEATS)):
        raise ValueError('block 数量、token 数、KV head 数、head dim 和 repeats 必须为正数。')

    dtype = getattr(torch, GPU_DTYPE)
    block_shape = (2, GPU_BLOCK_TOKENS, GPU_KV_HEADS, GPU_HEAD_DIM)
    bytes_per_block = 1
    for dim in block_shape:
        bytes_per_block *= dim
    bytes_per_block *= torch.empty((), dtype=dtype).element_size()
    device = torch.device('cuda')
    runs = []
    for repeat_index in range(GPU_REPEATS):
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        start = time.perf_counter()
        blocks = [torch.empty(block_shape, device=device, dtype=dtype) for _ in range(GPU_BLOCK_COUNT)]
        torch.cuda.synchronize()
        elapsed_ms = (time.perf_counter() - start) * 1000
        peak_mb = torch.cuda.max_memory_allocated() / (1024 ** 2)
        runs.append({'repeat': repeat_index, 'allocation_ms': round(elapsed_ms, 3), 'peak_memory_mb': round(peak_mb, 2), 'block_count': GPU_BLOCK_COUNT})
        del blocks

    project_root = next((p for p in [Path.cwd(), *Path.cwd().parents] if (p / 'benchmarks').is_dir()), Path.cwd())
    output_path = project_root / GPU_RESULT_PATH
    output_path.parent.mkdir(parents=True, exist_ok=True)
    report = {
        'task': 'kv_cache_block_footprint_probe',
        'evidence_level': 'synthetic_gpu_block_probe',
        'runtime': {'device': torch.cuda.get_device_name(0), 'gpu_memory_gb': round(torch.cuda.get_device_properties(0).total_memory / (1024 ** 3), 2), 'torch': torch.__version__, 'torch_cuda': torch.version.cuda, 'native_bf16_supported': torch.cuda.is_bf16_supported(including_emulation=False)},
        'config': {'block_shape': block_shape, 'bytes_per_block_theoretical': bytes_per_block, 'total_bytes_theoretical': bytes_per_block * GPU_BLOCK_COUNT, 'block_count': GPU_BLOCK_COUNT, 'dtype': GPU_DTYPE, 'repeats': GPU_REPEATS},
        'runs': runs,
        'decision': {'decision': 'measure', 'reason': '仅观察代表性 KV block 的显存占用,不代表真实 backend 调度结论。'},
    }
    output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
    print(json.dumps(report, ensure_ascii=False, indent=2))
else:
    print('GPU block probe 未启动:将 RUN_GPU_BLOCK_PROBE 改为 True 后运行。')

GPU 实验记录

完成执行单元后,每次改变 block 数量、token 数或 dtype 都新增一行;不要覆盖已有记录。

配置组GPU / dtypeblock shapeblock 数量理论单 block 字节数峰值显存(MB)结果文件
例:本机待填写待填写待填写待填写待填写待填写

相关阅读

完成缓存状态、价值评分和容量驱逐的模拟后,可以继续阅读 PagedAttention、前缀缓存和服务调度的实现。

Released under the MIT License.