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. 按需分配:写满一页纸,再拿下一页。 -
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. O(1) 极速查询:
-
• 在 allocate(分配)逻辑中,当我们通过哈希命中了一个block_id时,我们必须再确认一眼:这个block_id真的是在使用中吗? -
• 如果是 list或deque,查询需要遍历,速度慢。 -
• 用 set查询if block_id in self.used_block_ids只需要 时间,极其高效。
-
2. 状态校验:
-
• 它构成了系统的安全围栏。在分配和回收时,我们可以断言(Assert): -
• 分配时:确保 ID 不在 used里。 -
• 回收时:确保 ID 一定在 used里。 -
• 这能防止出现“双重释放”或“野指针”等严重的内存 Bug。
互斥关系图解:
|
|
free_block_ids
|
used_block_ids
|
|
|---|---|---|---|
| 初始 | [0, 1, 2, ... N] |
{}
|
|
| 分配 ID=0 | [1, 2, ... N] |
{0} |
|
| 回收 ID=0 | [1, 2, ... N, 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
📊 状态处理决策表
代码里那个复杂的 if-else 逻辑,其实就是在处理下面这三种情况:
|
|
|
|
|
|---|---|---|---|
| 情况 1 | 完美命中 (Hot) | 共享 (Share) | += block_size |
|
|
used 集合 |
ref_count += 1 |
|
|
|
|
|
|
| 情况 2 | 死而复生 (Warm) | 捞回 (Resurrect) | += block_size |
|
|
free 队列 |
_allocate_block |
|
|
|
|
|
|
| 情况 3 | 未命中 (Cold) | 分配新块 (New) | 不变 |
|
|
|
|
|
|
|
|
|
|
🦠 关键概念: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. 遍历 A 的页表,找到 Block 100。 -
2. block[100].ref_count从 2 减为 1。 -
3. 检查 ref_count == 0? False。 -
4. 结果:Block 100 仍然保留在 used集合里,数据未丢失。请求 B 继续跑,完全不受影响。
第二幕:请求 B 完成 (Deallocate B)
-
1. 遍历 B 的页表,找到 Block 100。 -
2. block[100].ref_count从 1 减为 0。 -
3. 检查 ref_count == 0? True。 -
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]。
|
|
|
|
|
|---|---|---|---|
| 4 | 0 | 块刚满 | 定稿 (Finalize)
|
| 5 | 1 | 新块首 | 分配 (Allocate)
block_table 变为 [0, 1]。此时 Block 1 是开放块。 |
| 6 | 2 |
|
无操作
|
| 7 | 3 |
|
无操作
|
| 8 | 0 | 块刚满 | 定稿 (Finalize)
|
| 9 | 1 | 新块首 | 分配 (Allocate)
block_table 变为 [0, 1, 2]。 |
🔓 核心概念:开放块 vs 定稿块 (Open vs Closed)
代码中反复出现的 hash == -1 断言,其实是在维护两种状态的严格界限:
-
1. 开放块 (Open Block): -
• 特征: hash = -1,ref_count = 1。 -
• 状态:正在被当前请求“独占写入”。内容还不确定,可能会变。 -
• 规则:绝对禁止共享! 别的请求不能复用它,因为它还没写完。 -
2. 定稿块 (Closed/Finalized Block): -
• 特征: hash != -1。 -
• 状态:已经写满 block_size个字,且计算出了指纹。内容永久固定。 -
• 规则:可以共享! 放入 hash_to_block_id字典,供 Prefix Cache 匹配。
🔄 块的生命周期状态转换:
填满
回收
🔢 综合演练:S1 与 S2 的故事 (数值化例子)
最后,我们通过一个完整的数值例子,把 Prefill 和 Decode 的所有逻辑串起来。
设定:block_size = 256。
场景:两个请求 S1 和 S2 先后到来。
-
• S1: 长度 600。 -
• S2: 长度 520 (前 512 个字和 S1 一模一样)。
第一幕:S1 先进行 Prefill
-
• 切分: 600 = 256 + 256 + 88。 -
• 分配过程: -
1. Block 0 (0-255): 填满 -> 算出哈希 -> 分配 ID=a -> 登记 {H_0: a}。 -
2. Block 1 (256-511): 填满 -> 算出哈希 (依赖 ) -> 分配 ID=b -> 登记 {H_1: b}。 -
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. Block 0: 算哈希 -> 查字典命中 ID=a -> 复用!( a.ref_count=2) ->cached += 256。 -
2. Block 1: 算哈希 -> 查字典命中 ID=b -> 复用!( b.ref_count=2) ->cached += 256。 -
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)。
夜雨聆风
