乐于分享
好东西不私藏

nano-vllm 源码解析(三):BlockManager 与 PagedAttention

nano-vllm 源码解析(三):BlockManager 与 PagedAttention

nano-vllm 源码解析(三):BlockManager 与 PagedAttention

如果说调度器是 “大脑”,那么 BlockManager 就是 “海马体”(记忆管理中枢)。

这一章解决的是 LLM 推理中最昂贵的资源——显存的管理问题。这是 nano-vllm 能比朴素实现快几倍甚至几十倍的核心黑科技,也是 vLLM 项目成名的绝技:PagedAttention

1. 为什么需要“页式”管理?

在没有 BlockManager 之前,显存管理有两个巨大的痛点:

❌ 痛点 1:预分配浪费

  • • 传统做法:为了防止显存不够,系统会给每个请求预分配 最大长度(比如 4096)的显存。
  • • 现实:用户可能只说了“你好”两个字。
  • • 后果:就像为了住一晚,强行包下整个总统套房。99% 的空间被锁死,别人进不来。

❌ 痛点 2:碎片化

  • • 传统做法:请求结束了,释放显存。
  • • 现实:长长短短的请求来了又走,显存里留下了无数个“小空洞”。
  • • 后果:虽然总空闲显存有 1GB,但都是碎的,连不成片,存不下一个大的 KV Cache。

✅ 解决方案:分页 (Paging)

  • • 思路:模仿操作系统的虚拟内存。
  • • 做法:把 KV Cache 切成 固定大小的小块 (Block)(比如一块存 16 个 Token)。
  • • 好处
    1. 1. 按需分配:写满一页纸,再拿下一页。
    2. 2. 零碎片:物理上不连续的块,逻辑上可以是连续的。

2. 显存的最小单位:Block

Block 是一个显存格子的“身份证”。

📝 代码显微镜:Block 类



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

class Block:
    def __init__(self, block_id):
        self.block_id = block_id  # 物理门牌号 (例如: 1024号柜子)

        # 🌟【核心机制 1:引用计数】
        # 有几个人正在用这个柜子?
        # 0 = 空闲
        # 1 = 独占
        # >1 = 共享 (比如多个人都在问同样的问题前缀)
        self.ref_count = 0

        # 🌟【核心机制 2:指纹】
        # 柜子里存的数据的 Hash 值。用于 Prefix Cache 查重。
        # -1 表示“还没写满”或者“脏了”,不能作为指纹。
        self.hash = -1

        # 这里的 token_ids 只是为了校验防撞车,不参与模型计算
        self.token_ids = []   

    def update(self, hash, token_ids):
        # 只有写满一个块时,才会调用这个,打上指纹
        self.hash = hash
        self.token_ids = token_ids

    def reset(self):
        # 归还柜子时,清空信息
        self.ref_count = 1  # 刚分配出去时,肯定有1人在用
        self.hash = -1
        self.token_ids = []



3. 核心黑科技:链式哈希 (Chain Hashing)

怎么判断两个请求能不能共享显存?看它们的 内容(Token IDs) 是否一样。
但直接比对 Token 列表太慢了,我们用 哈希(Hash)

⚠️ 为什么简单哈希不行?(上下文陷阱)

Attention 是 因果 (Causal) 的。一个词的意思,取决于它前面所有的词。

  • • 情况 A[我, 爱] -> [苹果]
  • • 情况 B[我, 恨] -> [苹果]

虽然最后一个块里装的都是 [苹果],但因为前缀不同,算出来的 KV Cache 是完全不同的!绝对不能复用!

🔗 解决方案:区块链式哈希

我们在计算当前块的哈希时,把 上一个块的哈希 也加进去算。



1
2
3
4
5
6
7
8
9
10
11
12
13
14

@classmethod
def compute_hash(cls, token_ids, prefix=-1):
    # 初始化一个哈希计算器 (这里用的是 xxhash 算法,因为它的计算速度极快)
    h = xxhash.xxh64()

    # 🌟 关键:把前缀的指纹也喂进去
    if prefix != -1:
        h.update(prefix.to_bytes(8, "little"))

    # 再喂当前的内容
    h.update(np.array(token_ids).tobytes())

    return h.intdigest()


这样,情况 A 的“苹果”和情况 B 的“苹果”,哈希值就会截然不同。


4. 管理员:BlockManager

它是整个仓库的调度员。它手里维护着四本账簿,每一本都有特定的用途。

📝 代码显微镜:BlockManager 初始化



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

class BlockManager:
    def __init__(self, num_blocks: int, block_size: int):
        self.block_size = block_size

        # 1.【物理账本】:Block 对象池
        # 真实的物理柜子(元数据)。
        # 初始化时就建好所有的 Block 对象,避免运行时反复创建销毁对象。
        self.blocks: list[Block] = [Block(i) for i in range(num_blocks)]

        # 2.【索引账本】:Hash -> Block ID (Prefix Cache 核心)
        # 这是一个查找表。
        # 作用:新请求进来,算一下哈希,查查这里有没有?有就是 Cache Hit。
        # 例子:{982734: 5} 表示指纹为 982734 的内容存在 5 号柜子。
        self.hash_to_block_id: dict[int, int] = dict()

        # 3.【空闲账本】:可用柜子的钥匙堆
        # 数据结构:双端队列 (deque)。
        # 作用:要分配新块时,从这里 pop 一个;回收时,append 回去。
        # 初始状态:所有的块 ID (0 到 num_blocks-1) 都在这里。
        self.free_block_ids: deque[int] = deque(range(num_blocks))

        # 4.【占用账本】:正在使用的柜子名单
        # 数据结构:集合 (set)。
        # 作用:快速判断(O(1)复杂度)某个 ID 是不是正在被使用。
        # 关键逻辑:它和 free_block_ids 是【互斥】的。
        # 一个 ID 要么在 free 里,要么在 used 里,绝不可能同时存在。
        self.used_block_ids: set[int] = set()


💡 为什么要多维护一个 used_block_ids

你可能会问:“如果 free_block_ids 里没了,不就是 used 吗?为什么还要专门存一个 set?”

这在工程上有两个重要意义:

  1. 1. O(1) 极速查询
  • • 在 allocate(分配)逻辑中,当我们通过哈希命中了一个 block_id 时,我们必须再确认一眼:这个 block_id 真的是在使用中吗?
  • • 如果是 list 或 deque,查询需要遍历,速度慢。
  • • 用 set 查询 if block_id in self.used_block_ids 只需要  时间,极其高效。
  1. 2. 状态校验
  • • 它构成了系统的安全围栏。在分配和回收时,我们可以断言(Assert):
  • • 分配时:确保 ID 不在used 里。
  • • 回收时:确保 ID 一定在used 里。
  • • 这能防止出现“双重释放”或“野指针”等严重的内存 Bug。

互斥关系图解:

状态
free_block_ids

 (Deque)
used_block_ids

 (Set)
物理含义
初始 [0, 1, 2, ... N] {}

 (空)
仓库全空
分配 ID=0 [1, 2, ... N] {0}
0号柜子被占用
回收 ID=0 [1, 2, ... N, 0] {}

 (空)
0号柜子被清空并归还

5. 核心逻辑一:Prefill 阶段分配 (Allocate)

当新请求进来做 Prefill 时,首先要检查显存够不够,然后尝试复用已有的块(Prefix Cache)。

📝 代码显微镜:资源检查与分配

首先是准入检查。在分配之前,必须先看一眼仓库里的库存够不够。



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

def can_allocate(self, seq):
    # 【库存检查】
    # 问:我手里空闲的柜子钥匙数量 (len(free_block_ids))
    # 是否大于等于 这个请求总共需要的柜子数 (seq.num_blocks)?
    # 只有库存充足,才允许进入下一步的 allocate。
    return len(self.free_block_ids) >= seq.num_blocks

def allocate(self, seq):
    # 【安全断言】确保这个请求是新的,之前没分配过显存。
    assert not seq.block_table

    h = -1          # 初始前缀哈希 (第一个块没有前缀,所以是 -1)
    cache_miss = False  # 标记:是否发生过未命中。一旦断过一次,后面全都不认。

    # 【逐块遍历】
    # 比如这个请求需要 3 个块,我们一个一个处理
    for i in range(seq.num_blocks):
        # 取出当前块的 token 数据 (例如 [1, 2, ... 16])
        token_ids = seq.block(i)

        # 1. 计算哈希 (只有满块才配拥有指纹)
        # 如果是最后一个块且没填满,它的哈希强制为 -1 (Open Block)
        h = self.compute_hash(token_ids, h) if len(token_ids) == self.block_size else -1

        # 2. 查字典 (Look up)
        # 看看这个指纹 h 以前有没有记录过?
        # 如果没记录,get 返回 -1。
        block_id = self.hash_to_block_id.get(h, -1)

        # 3. 校验:防哈希碰撞 (Double Check)
        # 条件 A: block_id == -1 (字典里没查到)
        # 条件 B: 查到了,但在物理块里存的内容(token_ids)跟现在的对不上 (哈希碰撞)
        if block_id == -1 or self.blocks[block_id].token_ids != token_ids:
            cache_miss = True # 标记:未命中!

        # 4. 决策分支:根据是否命中来决定怎么分配
        if cache_miss:
            # 【分支 A:未命中】
            # 必须拿新柜子。由于之前做了 can_allocate 检查,这里一定有空闲块。
            block_id = self.free_block_ids[0]
            # 调用底层分配函数 (见下文 _allocate_block)
            block = self._allocate_block(block_id)
        else:
            # 【分支 B:命中】
            # 复用旧柜子。不仅省了显存,还省了计算。
            seq.num_cached_tokens += self.block_size  # 记账:省了多少算力

            # 这里有个细节:查到的块可能是在用状态(used)也可能是刚释放但还没被覆盖(free)
            if block_id in self.used_block_ids:
                # 块正在被别人用:直接共享,引用计数 +1
                block = self.blocks[block_id]
                block.ref_count += 1
            else:
                # 块虽然有数据但在 free 列表里(可能是刚被回收):重新捞回来用
                block = self._allocate_block(block_id)

        # 5. 更新元数据
        # 只有满块(h != -1)才需要登记到哈希字典里
        if h != -1:
            block.update(h, token_ids)
            self.hash_to_block_id[h] = block_id

        # 将最终确定的 block_id 加入请求的页表
        seq.block_table.append(block_id)


🗺️ 逻辑流程图 (Decision Flow)

这张图展示了对每一个 Block 的处理决策链:

Yes

No (尾巴)

No (没找到/不一致)

Yes (找到了)

Yes (断过 或 刚断)

No (一路绿灯)

In Used

In Free

开始 Allocate

遍历 seq 的每一个逻辑块 i

是满块吗?len == block_size

计算当前链式哈希 h

h = -1

步骤 1: 查字典 & 校验1. 字典里有 h 吗?2. 内容 token_ids 一致吗?

标记: 无法命中 (h=-1)

更新状态cache_miss = True

状态汇合点

步骤 2: 最终决策if cache_miss == True?

分支 A: 分配新块block = _allocate_block

分支 B: 复用旧块block = blocks[id]

Block 在 used 集合?

共享 (ref++)

捞回 (allocate)

更新 hash 映射加入 block_table

📊 状态处理决策表

代码里那个复杂的 if-else 逻辑,其实就是在处理下面这三种情况:

情况
显存块状态
处理方式
算力节省
情况 1 完美命中 (Hot) 共享 (Share) += block_size
哈希命中 + 内容一致 + 块在 used 集合
ref_count += 1
(这块不用算了)
大家一起用,不占新显存
情况 2 死而复生 (Warm) 捞回 (Resurrect) += block_size
哈希命中 + 内容一致 + 块在 free 队列
调 _allocate_block
(这块不用算了)
把它从回收站捡回来重新用
情况 3 未命中 (Cold) 分配新块 (New) 不变
哈希未命中 或 内容不一致 或 之前断过
调 `_allocate_block
(老老实实算吧)
拿一个全新的空白块

🦠 关键概念:cache_miss 的传染性

在循环里,有一个变量叫 cache_miss,它起到了 “熔断器” 的作用。

  • • 链式哈希的特性:第 N 个块的哈希值,依赖于第 N-1 个块的哈希。
  • • 后果:只要第 1 个块没命中(或者不一样),第 2 个块即使内容完全一样,它的链式哈希也绝对算不对。
  • • 代码逻辑


1
2
3
4
5
6
7

if block_id == -1 ... :
    cache_miss = True  # 一旦开启,无法关闭

if cache_miss:
    # 只要 cache_miss 为 True,哪怕后面查到了也不认,强制分配新块
    ...


  • • 通俗理解:这就好比背诵课文。如果你第一句就背错了,后面背得再对,在老师眼里也是错的,必须从第一句错的地方开始重新背(重新计算)。

6. 核心逻辑二:块回收 (Deallocate)

有借有还,再借不难。回收逻辑分为“高层逻辑”(逻辑引用减少)和“底层操作”(物理资源归还)。

📝 代码显微镜:引用计数回收法



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

def deallocate(self, seq):
    # 【逆序遍历】
    # 为什么要 reversed?虽然在这段代码里看起来没区别,但在某些涉及
    # 父子块依赖的场景下,先释放后面的块更安全。
    for block_id in reversed(seq.block_table):
        block = self.blocks[block_id]

        # 引用计数减 1 (我不占用了)
        block.ref_count -= 1

        # 【真正的回收】
        # 只有当减到 0 时,说明没人用这个块了,才真正把它还给 free 队列。
        # 如果 ref_count > 0,说明还有其他请求在共享它,不能回收!
        if block.ref_count == 0:
            self._deallocate_block(block_id)

    # 清理请求对象的状态
    seq.num_cached_tokens = 0
    seq.block_table.clear()

# --- 底层辅助函数 ---

def _allocate_block(self, block_id):
    # 从 free 移动到 used 的底层操作
    block = self.blocks[block_id]
    assert block.ref_count == 0  # 确保拿出来的块是干净的

    block.reset() # 重置块状态 (ref=1, hash=-1)

    self.free_block_ids.remove(block_id) # 移出空闲账本
    self.used_block_ids.add(block_id)    # 加入已用账本
    return block

def _deallocate_block(self, block_id):
    # 从 used 移动到 free 的底层操作
    assert self.blocks[block_id].ref_count == 0 # 确保没人用了

    self.used_block_ids.remove(block_id) # 移出已用账本
    self.free_block_ids.append(block_id) # 归还给空闲账本


🎭 场景演示:共享块的回收 (Case Study)

为了理解引用计数的精髓,我们看一个双人共享的例子。

设定:请求 A 和请求 B 共享了 Block 100

  • • 初始状态:Block 100 (ref_count = 2, 在 used 集合中)。

第一幕:请求 A 完成 (Deallocate A)

  1. 1. 遍历 A 的页表,找到 Block 100。
  2. 2. block[100].ref_count 从 2 减为 1
  3. 3. 检查 ref_count == 0False
  4. 4. 结果:Block 100 仍然保留在 used 集合里,数据未丢失。请求 B 继续跑,完全不受影响。

第二幕:请求 B 完成 (Deallocate B)

  1. 1. 遍历 B 的页表,找到 Block 100。
  2. 2. block[100].ref_count 从 1 减为 0
  3. 3. 检查 ref_count == 0True
  4. 4. 结果:触发 _deallocate_block
    • • Block 100 从 used 移回 free
    • • 这块显存正式变为空闲,可以分配给全新的请求 C 了。

7. 核心逻辑三:Decode 阶段分配 (Append)

Decode 阶段是逐个 Token 生成的,需要更精细的显存判断。

📝 代码显微镜:追加检查与执行

首先检查显存够不够追加一个 Token:



1
2
3
4
5
6
7
8

def can_append(self, seq):
    # 逻辑判断:
    # len(seq) % self.block_size == 1 意味着:
    # "我现在是不是刚好要开启一个新块?" (比如 block_size=4,现在长 5,余数是 1)
    # 是 -> 需要 1 个空闲块 -> 检查 len(free) >= 1
    # 否 -> 还在当前块里写 -> 需要 0 个空闲块 -> 检查 len(free) >= 0 (恒为真)
    return len(self.free_block_ids) >= (len(seq) % self.block_size == 1)


然后执行追加操作:



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

def may_append(self, seq):
    block_table = seq.block_table
    # 取出最后一个物理块对象
    last_block = self.blocks[block_table[-1]]

    # 【情况 1:刚翻页,需要新块】
    # 比如 BlockSize=4, 刚写了第 5 个字 (len=5, 5%4=1)
    if len(seq) % self.block_size == 1:
        # 断言:上一个块必须已经定稿(Hash != -1),否则这一页不能翻过去
        assert last_block.hash != -1

        # 分配新块 (can_append 保证了这里一定有空闲块)
        block_id = self.free_block_ids[0]
        self._allocate_block(block_id)

        # 加到页表里
        block_table.append(block_id)

    # 【情况 2:刚填满,需要定稿】
    # 比如 BlockSize=4, 刚写了第 4 个字 (len=4, 4%4=0)
    elif len(seq) % self.block_size == 0:
        # 断言:刚满还没定稿,hash 应该是 -1
        assert last_block.hash == -1

        # 取出这一块的数据
        token_ids = seq.block(seq.num_blocks - 1)

        # 找前缀哈希 (如果是第0块,前缀就是 -1;否则是倒数第2块的哈希)
        prefix = self.blocks[block_table[-2]].hash if len(block_table) > 1 else -1

        # 计算当前块的哈希
        h = self.compute_hash(token_ids, prefix)

        # 更新块状态:变为定稿块 (Closed Block)
        last_block.update(h, token_ids)

        # 登记到查重字典,供后续请求复用
        self.hash_to_block_id[h] = last_block.block_id

    # 【情况 3:写在中间】
    # 比如 BlockSize=4, 写第 2,3 个字
    else:
        # 保持开放状态 (Open Block)
        assert last_block.hash == -1


📊 具体案例演示 (Step-by-Step)

为了彻底理解 len % block_size 的判定逻辑,我们来看一个慢动作回放。
假设 block_size = 4,当前 Block ID 序列为 [0]

seq 总长度
len % 4
状态含义
动作 (Action)
4 0 块刚满 定稿 (Finalize)

计算 Block 0 的哈希,登记到字典。
5 1 新块首 分配 (Allocate)

申请新块 Block 1。block_table 变为 [0, 1]
此时 Block 1 是开放块
6 2
写中间
无操作

直接把 token 写入 Block 1。
7 3
写中间
无操作

直接把 token 写入 Block 1。
8 0 块刚满 定稿 (Finalize)

计算 Block 1 的哈希 (依赖 Block 0),登记到字典。
9 1 新块首 分配 (Allocate)

申请新块 Block 2。block_table 变为 [0, 1, 2]

🔓 核心概念:开放块 vs 定稿块 (Open vs Closed)

代码中反复出现的 hash == -1 断言,其实是在维护两种状态的严格界限:

  1. 1. 开放块 (Open Block)
    • • 特征hash = -1ref_count = 1
    • • 状态:正在被当前请求“独占写入”。内容还不确定,可能会变。
    • • 规则绝对禁止共享! 别的请求不能复用它,因为它还没写完。
  2. 2. 定稿块 (Closed/Finalized Block)
    • • 特征hash != -1
    • • 状态:已经写满 block_size 个字,且计算出了指纹。内容永久固定。
    • • 规则可以共享! 放入 hash_to_block_id 字典,供 Prefix Cache 匹配。

🔄 块的生命周期状态转换:

填满

回收

分配时Open, Hash=-1

定稿时Closed, Hash=H

回收时Ref=0, 归还

🔢 综合演练:S1 与 S2 的故事 (数值化例子)

最后,我们通过一个完整的数值例子,把 Prefill 和 Decode 的所有逻辑串起来。

设定block_size = 256
场景:两个请求 S1 和 S2 先后到来。

  • • S1: 长度 600。
  • • S2: 长度 520 (前 512 个字和 S1 一模一样)。

第一幕:S1 先进行 Prefill

  • • 切分600 = 256 + 256 + 88
  • • 分配过程
    1. 1. Block 0 (0-255): 填满 -> 算出哈希  -> 分配 ID=a -> 登记 {H_0: a}
    2. 2. Block 1 (256-511): 填满 -> 算出哈希  (依赖 ) -> 分配 ID=b -> 登记 {H_1: b}
    3. 3. Block 2 (512-599)没满 (88字) -> 哈希 -1 -> 分配 ID=c (开放块)。
  • • S1 结果block_table = [a, b, c]num_cached = 0

第二幕:S2 后进行 Prefill

  • • 切分520 = 256 + 256 + 8
  • • 分配过程
    1. 1. Block 0: 算哈希  -> 查字典命中 ID=a -> 复用!(a.ref_count=2) -> cached += 256
    2. 2. Block 1: 算哈希  -> 查字典命中 ID=b -> 复用!(b.ref_count=2) -> cached += 256
    3. 3. Block 2没满 (8字) -> 哈希 -1 -> 只能分配新块 ID=d
  • • S2 结果block_table = [a, b, d]num_cached = 512

总结
S2 仅仅因为前缀相同,就“免费”获得了 512 个 token 的计算结果(KV Cache),这就是 PagedAttention 的威力!


8. 物理存储布局与预算计算

BlockManager 在逻辑上玩的是“数字游戏”(Block ID),但这一切最终都要落地到 GPU 的物理显存上。这一节我们揭开 KV Cache 在显存中的真实面目。

🏗️ 1. 物理存储布局 (The Big Tensor)

实际的 KV Cache 不是散乱的小张量,而在初始化时就一次性申请好的一个巨大的、连续的 6 维张量

代码显微镜:全局大张量



1
2
3
4
5
6
7
8
9
10
11
12

# 这是一个预分配的巨大张量,占据了显存的大部分空间
kv_cache = torch.empty(
    2,                          # 维度 0: 区分 K 和 V (Key=0, Value=1)
    num_layers,                 # 维度 1: 模型层数 (例如 LLaMA-7B 是 32 层)
    num_blocks,                 # 维度 2: 系统能容纳的总块数 (由预算计算得出,例如 10240)
    block_size,                 # 维度 3: 每块的容量 (例如 16 个 token)
    num_kv_heads // tp_size,    # 维度 4: 本卡负责的 KV Head 数 (考虑张量并行切分)
    head_dim                    # 维度 5: 每个 Head 的向量维度 (例如 128)
    dtype=dtype,                # 数据类型 (通常是 FP16 或 BF16)
    device="cuda"
)


代码显微镜:层级切片 (Layer Slicing)

模型在运行每一层(Layer)时,不需要传入整个大张量,而是只拿走属于那一层的 “切片引用”(View)。这不会产生内存拷贝,非常高效。



1
2
3
4
5
6
7
8

# 在初始化 Model 时,把对应的切片分发给每一层 Attention 模块
# layer_id: 当前层的索引
module.k_cache = kv_cache[0, layer_id] 
# shape 变为: [num_blocks, block_size, local_kv_heads, head_dim]

module.v_cache = kv_cache[1, layer_id]
# shape 同上



🗺️ 2. 逻辑与物理的映射回顾

还记得 block_table 吗?它就是连接“逻辑序列”和这个“物理大张量”的桥梁。

示例解析
假设 seq.block_table = [5, 12, 3]
这意味着:

  • • 逻辑第 0 页  物理大张量的 第 5 号块 (kv_cache[:, :, 5, ...])
  • • 逻辑第 1 页  物理大张量的 第 12 号块 (kv_cache[:, :, 12, ...])
  • • 逻辑第 2 页  物理大张量的 第 3 号块 (kv_cache[:, :, 3, ...])

ModelRunner 会根据这个映射,把计算出的 Key/Value 填入对应的物理位置。


🧮 3. 显存预算计算 (Memory Budget)

系统启动时,必须精确计算出 num_blocks(总块数)到底设为多少。设多了会 OOM(显存溢出),设少了浪费显卡性能。

代码显微镜:精打细算的分配逻辑



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

def allocate_kv_cache(self):
    # 1. 摸底:显卡现在还有多少地?
    # free: 当前空闲字节数, total: 总显存字节数
    free, total = torch.cuda.mem_get_info()

    # used: 已经被静态占用的 (模型权重、PyTorch CUDA context 等)
    used = total - free

    # 2. 探底:刚才加载模型时,显存峰值飙到了多少?
    # 这一步是为了捕捉那些“临时占用”但现在已经释放的显存(比如加载时的中间变量)
    peak = torch.cuda.memory_stats()["allocated_bytes.all.peak"]
    current = torch.cuda.memory_stats()["allocated_bytes.all.current"]

    # 3. 算单价:一个 Block 到底占多少字节?
    # 公式解析:
    # 2       : K 和 V
    # layers  : 所有层都要存
    # block_size : 一块存多少字 (e.g. 16)
    # heads // tp : 张量并行下,单卡只存一部分 head
    # dim     : 向量长度
    # itemsize: FP16 是 2 字节
    block_bytes = (2 * num_layers * block_size * num_kv_heads // tp_size * head_dim * dtype.itemsize)

    # 4. 算总价:留给 KV Cache 的安全预算
    # logic: (总显存 * 设定利用率 0.9) - 静态占用 - 动态波动的安全垫
    # (peak - current) 代表了推理过程中“激活值(Activation)”可能产生的显存波动峰值。
    # 我们必须把这部分空间预留出来,剩下的才能全部分给 KV Cache。
    available = total * gpu_memory_utilization - used - (peak - current)

    # 5. 最终定案:能分多少块?
    num_blocks = int(available) // block_bytes

    # 安全检查:如果一块都分不出来,说明显卡太烂或模型太大,直接报错
    assert num_blocks > 0


💡 核心思想
这种计算方式体现了 “吃干榨净” 的原则。除了模型权重和必要的激活值缓冲区外,所有剩余显存全部转化 KV Cache Block,从而最大化系统的并发处理能力(Batch Size)。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » nano-vllm 源码解析(三):BlockManager 与 PagedAttention

评论 抢沙发

7 + 8 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
本站作品均采用知识共享署名-非商业性使用-相同方式共享 4.0进行许可,资源收集于网络仅供用于学习和交流,本站一切资源不代表本站立场,我们尊重软件和教程作者的版权,如有不妥请联系本站处理!

 沪ICP备2023009708号

© 2017-2026 夜雨聆风   | sitemap | 网站地图