前阵子在 dev.to 上看到一个俄罗斯方块小游戏。玩了两把,我直接愣住了——这玩意儿把 LLM serving 里的显存管理画得明明白白。
我第一版自己画显存这东西的时候,画的是一条线:模型要生成 token → 提前分一块连续的 VRAM → 写进去 → 生成完释放。挺顺。
等等等等,先别急。问题出在"提前"和"连续"上。
你不知道用户这次的 prompt 会生出多少个 token,但系统必须提前给人家留座。万一人家生成了几千个 token 呢?你只能按最坏情况预留一块巨大的连续空间。
画张图看看:
[ 请求 A (预留大块) ...... ]
[ 请求 B (预留大块) ...... ]
[ 请求 C (预留大块) ...... ]
[ 零散的空隙 ][ 零散的空隙 ]
就像玩俄罗斯方块,你预判下一个是长条,结果只掉下来一个小方块。请求跑完了,整行清空。但你看看剩下的那些窟窿——这就是外部内存碎片。
最搞人的地方在这:你显存总容量明明够,但全碎成了零散的小块,下一个稍微大点的请求进来,CUDA out-of-memory 直接糊你脸上。因为底层的分配器没法像电脑磁盘那样动态给你整理碎片。
vLLM 搞的 PagedAttention 就是为了干掉这件事。这东西其实是个分页机制。在这个游戏里它怎么体现的?当你的方块下落时,组成方块的每一个格子,会直接穿透落到它所在列的最底部的空位。不需要连续,见缝插针。
KV-cache 被拆成了固定大小的逻辑块,映射到物理显存里。管你物理显存碎成什么样,逻辑上大家觉得自己的数据是连续的。
物理显存:
[A块_1] [B块_1] [空] [A块_2] [C块_1] [B块_2]
逻辑映射:
请求A ──► [A块_1] ──► [A块_2]
请求B ──► [B块_1] ──► [B块_2]
把内存浪费砍掉了 96%,硬件不动,并发直接翻了 4 倍。
看到这个数字的时候,我咔哒一下扣上了。💡
以前总看到大家吹 vLLM 的吞吐量,我总以为是什么极其复杂的调度魔法。结果剥到底,核心竟然就是一个操作系统里几十年前的老概念——分页。
不过这个"分页"的比喻在这里漏风了。操作系统分页是为了应对物理内存不够去借用磁盘,慢得要死;PagedAttention 纯粹是为了在一张物理显卡里治碎片,去掉了"必须连续"的紧箍咒。而且评论区有个哥们点出来了,正因为不连续,相同前缀的请求可以搞 copy-on-write 共享……这简直就是买一送一。
扯远了,但这其实和它有点关系……算了先拉回来。
下期想画开哪个概念,你们说了算。embedding 的计算逻辑,还是 KV cache 的底层结构?
