夜雨聆风学习资料网

ARTICLE · 1208256

Mini-Sglang:KVCache源码管理2

Mini-Sglang:KVCache源码管理2
上一章讲解了Mini-Sglang的物理显存分配,它是一块按物理槽位号编址的大数组,物理KV值保存在槽位 s = page × page_size + offset中。而在推理的过程中,模型的请求永远都是以token为单位,因此我们需要在这两层之间添加一个逻辑层,将模型对token的请求,间接索引到物理KV槽,而Mini-Sglang用CacheManager/TableManager/PageTable三个数据结构进行管理
1.PageTable
# engine/engine.py:注意 +1 是给 CUDA graph 的 dummy 请求留的行self.page_table = torch.zeros((config.max_running_req + 1, aligned_max_seq_len),dtype=torch.int32, device=self.device)
PageTable是Mini-Sglang进行逻辑位置到物理KV槽位映射的核心数据结构,它总共有max_running_req + 1行,一行表示一个请求槽位(引擎容忍max_running_req个请求),其中加1是给CUDA graph的dummy请求所保留的行,总共有aligned_max_seq_len(对齐到32位)列,表示每个请求内部token的逻辑位置(序列最长为aligned_max_seq_len),而页表内部填充的,则是KV物理槽位号
核心函数:
1.CacheManager.allocate_paged()函数:
Scheduler在前向开始之前,会调用CacheManager.allocate_paged函数对页表进行统一填充
def allocate_paged(self, reqs: List[Req]) -> None:    needed_pages = 0 ## 计算总共需要的页面数目    allocation_info: List[Tuple[int, int, int]] = []    for req in reqs:        first_page = div_ceil(req.cached_len, self.page_size) ## 已缓存的前缀,按页管理        last_page = div_ceil(req.device_len, self.page_size) ## 请求总长折算成的页面数量        ##  div_ceil()向上取整,不足一页的算作一页        if last_page > first_page: ## 需要prefiil的页面数             needed_pages += last_page - first_page             allocation_info.append((req.table_idx, first_page, last_page))    if needed_pages > 0:        allocated = self._page_to_token(self._allocate(needed_pages))        _write_page_table(self.page_table, allocated, allocation_info, self.page_size)
计算完总共需要的页面数目,调用CacheManager的allocate函数判断显存是否足够,是否需要进行页面驱逐
 def _allocate(self, needed_pages: int) -> torch.Tensor:     if needed_pages > (free_pages := len(self.free_slots)): ## 判断是否需要驱逐页面        evicted = self.prefix_cache.evict((needed_pages - free_pages) * self.page_size) ## 驱逐页面        self.free_slots = torch.cat([self.free_slots, evicted[:: self.page_size]]) ## ::按pages_size采样进行驱逐        assert len(self.free_slots) >= needed_pages, "Eviction did not free enough space."     allocated = self.free_slots[:needed_pages] ## 分配需要的页面     self.free_slots = self.free_slots[needed_pages:] ## 总是从头部进行切片     return allocated
free_slots代表了KV池里所有空闲物理页的清单,它只保存每一个页面的起始位置,例如当page_size = 4时,free_slot = [0,4,8,12,.....]
self.free_slots = torch.arange(num_pages, dtype=torch.int32, device=device) * page_size
当needed_pages > free_pages(也就是free_slot的长度时),则需要进行缓存驱逐,调用prefix_cache.evict()函数驱逐 needed_pages - free_pages个缓存页,一般是使用LRU策略,再将驱逐出的页面按照page_size的步长进行采样,拼接到free_slots尾部,而需要的页面,则从头部(self.free_slots[:needed_pages])进行切片也就是说,free_slots的使用是FIFO的,淘汰出的空闲页面被追加到尾部,而对空闲页面的取用总是从头部进行
获取分配到的空闲页槽位之后,调用_page_to_token(),传入[16,20,24]这样的物理KV槽
def _page_to_token(self, pages: torch.Tensor) -> torch.Tensor:        if self.page_size == 1:            return pages        # [X * page_size] -> [X * page_size, ..., X * page_size + page_size - 1]        offsets = torch.arange(self.page_size, device=self.device, dtype=torch.int32) ## 生成页内偏移量        return (pages.unsqueeze(1) + offsets).flatten() ## 生成页偏移量的token索引        ## unsqueeze(1)将pages的形状从[X]变为[X, 1],然后加上offsets得到[X, page_size],最后flatten()将其展平为一维数组,得到每个页面对应的所有token索引。
offsets是页面的内部偏移量([0,1....page_size-1]),unsqueeze(1)将pages的形状从[x]变为[x,1],然后加上页内偏移量(广播机制),得到[x,pages_size],再将其展平为一维向量:
ex: pages = [4,8],offsets = [0,1,2,3]    pages.unsqueeze(1) = [[4],[8]]    pages.unsqueeze(1) + offsets = [[4,5,6,7],[8,9,10,11]]    allocated = flatten() = [4,5,6,7,8,9,10,11]
2.CacheManager._write_page_table()
获取到allocated张量之后,调用CacheManager._write_page_table函数,将分配好的物理槽位写入页表写入页表
def _write_page_table(    page_table: torch.Tensor,    allocated: torch.Tensor,    allocation_info: List[Tuple[int, int, int]],    page_size: int,) -> None:    needed_tokens = len(allocated)    table_idx_host = torch.empty(needed_tokens, dtype=torch.int64, pin_memory=True)     ## host 侧索引张量,专门存放每个待写入物理槽位在 page_table 中的行坐标。    positions_host = torch.empty(needed_tokens, dtype=torch.int64, pin_memory=True)     ##  host 侧索引张量,专门存放每个待写入物理槽位在 page_table 中的列坐标(token 逻辑位置)。    offset = 0    for table_idx, first_page, last_page in allocation_info: ## table_Idx:哪个请求         first_pos, last_pos = first_page * page_size, last_page * page_size        length = last_pos - first_pos ## 需要新填充的长度        table_idx_host[offset : offset + length].fill_(table_idx)        torch.arange(first_pos, last_pos, out=positions_host[offset : offset + length])        # first_pos=12, last_pos=20        # → positions_host[offset:offset+8] = [12,13,14,15,16,17,18,19]        offset += length    assert offset == needed_tokens, "Mismatch in allocated tokens and filled tokens."    table_idxs = table_idx_host.to(page_table.device, non_blocking=True)    offsets = positions_host.to(page_table.device, non_blocking=True)    page_table[table_idxs, offsets] = allocated     ## 逻辑 token 位置到物理 KV Cache 槽位的映射表    # 行:请求(或请求槽位)。每一行对应一个正在运行的请求。    # 列:该请求内的 token 逻辑位置(第几个 token)    # 值:该逻辑位置对应的物理 KV 槽位索引,也就是 K/V 实际存储在 KV Pool 中的地址    ## position_host:是token在token序列中的位置    ## allocated:是KVCache的物理页号
needed_tokens是总共要分配的token数目,table_idx_host负责存放每个待写物理槽位在page_table中的行坐标,positions_host负责存放待写物理槽位在page_table中的列坐标,之后遍历每一个分配请求:
1.length = last_pos - first_pos:计算出需要新填充的长度
2.table_Idx_host [offset:offset + length].fill_(table_idx):从table_idx_host前切出length长度,代表这一部分的物理槽位被分配给了table_idx这个请求
3.torch.arrange(first_pos,last_pos,out=positions_host[offset:offset + length]):positions_host的[offset,offset+length]部分填写token在请求中的逻辑位置,也就是这个位置的物理槽位被分配给了这个请求的哪个token
4.offset += length:每一次分配出了length长度的部分,记入offset中,不断累加,最终offset的长度要等于needed_tokens
5.page_table[table_idxs, positions_host] = allocated:最终将分配的物理KV槽位一次性写入页表之中,页表每一行代表一个请求,每一列代表token的逻辑位置,每一个页表值代表了分配到的物理KV槽
2.CacheManager
在上面的介绍中,其实已经介绍了CacheManager的一部分功能,除了进行页表填充之外,CacheManager也负责前缀复用的职责
核心函数
1.前缀匹配
def __init__(self, num_pages: int, page_size: int, page_table: torch.Tensor, type: str):  # The `_free_slots` follows a page-aligned manner. For example, if page_size = 2,  # the `_free_slots` may look like [0, 2, 4, 6, ...], and each slot represents a page.  device = page_table.device  self.free_slots = torch.arange(num_pages, dtype=torch.int32, device=device) * page_size ##存储空闲页面的页偏移  self.prefix_cache = create_prefix_cache(device=device, type=type)  self.device = device  self.num_pages = num_pages  self.page_table = page_table  self.page_size = page_size
在进行初始化时,CacheManager会初始化一个prefix_cache,用来进行前缀匹配和复用,在具体的匹配中,它会调用match_prefix函数进行匹配,返回一个MatchResult类型,本质上是对一个CacheHandle基类的包装,主要包含cache_len字段和一个需要孩子类型实现的获取可复用的张量索引的函数,我们之后在RadixCache中具体介绍
def match_req(self, req: PendingReq) -> MatchResult:  input_len = req.input_len  assert input_len > 0, "Input length must be greater than 0."  return self.prefix_cache.match_prefix(req.input_ids[: input_len - 1]) ##需要去掉最后一个token
2.前缀复用
 def cache_req(self, req: Req, *, finished: bool) -> None:        # ==================================== valid cache region ====================================        # [0, req.cached_len)                       This part is valid for attention kernel read/write.        # [0, old_handle.cached_len)                This part is in the prefix cache before prefill.        # [old_handle.cached_len, req.cached_len)   This part is allocated by cache manager for this request.        # ================================== allocated cache region ==================================        # [old_handle.cached_len, cached_len)       This part was not in the prefix cache when prefill,        #                                           but later cached by other requests.        #                                           We must free them to avoid memory leak.        # [cached_len, new_handle.cached_len)       This part is newly inserted into the prefix cache.        # [new_handle.cached_len, req.cached_len)   This part is tailing part that can not inserted into the prefix cache.        #                                           We should free it if the request has finished.        # [0, old_handle.cached_len)                prefill 前就在前缀缓存里(已命中)        # [old_handle.cached_len, cached_len)       prefill 时新分配,但之后被别的请求缓存了        # [cached_len, new_handle.cached_len)       本次新插入前缀缓存的部分        # [new_handle.cached_len, req.cached_len)   尾部,无法插入前缀缓存(未页对齐)        insert_ids = req.input_ids[: req.cached_len]        page_indices = self.page_table[req.table_idx, : req.cached_len]        old_handle = req.cache_handle        cached_len, new_handle = self.prefix_cache.insert_prefix(insert_ids, page_indices) ## 把经过prefill和前向计算后已经计算出KV的token缓存进树        # unlock until all operations on handle is done        self.unlock(old_handle)        # this part is already in the prefix cache, free it        self._free(page_indices[old_handle.cached_len : cached_len])        if finished:  # this tail part should be freed            self._free(page_indices[new_handle.cached_len :])        else:  # keep the tail part, update the handle            req.cache_handle = new_handle            self.lock(new_handle)
要理解这个函数,我们要首先理解三个长度:
1.old_handle.cached_len:代表着在这个请求prefill之前,已经在radixTree里存储的前缀长度
2.req.cached_len:这个请求中需要被插入到前缀树中的部分,也就是已经做完prefill的token
3.new_handle.cached_len:insert_prefix完成之后,成功新插入的前缀部分
4.cached_len:在prefill之后,在前缀树上成功匹配的前缀
这几个长度的关系如图所示:
[0,old_handle.cached_len]部分表示在prefill之前,在前缀树中能被当前请求利用的部分,也是在整个prefill期间一直被锁定的部分,
而cached_len表示在prefill之后,把要插入的序列重新在RadixTree上跑了一遍之后得到的共享前缀长度,而整个prefill阶段隐含了一个风险——并发的树插入
[old_handle.cached_len,cached_len]在Prefill发生前未命中,因此系统通过 allocate_paged 为它分配了显存。但在当前请求计算期间,别的并发请求抢先一步把这部分完全相同的Prompt算完并插进了树中,而prefill之后调用 insert_prefix 时发现树里已经存在这部分了,导致当前请求刚刚自己分配的这块显存冗余了,因此这一部分显存必须要释放,否则会造成显存泄露的风险
[cached_len,new_handle.cached_len]则是本次insert_prefix成功插入的部分,而且一定是页对齐的
[new_handle.cached_len,req.cached_len]则是这次插入中未凑满一页的部分,这一部分KV则会被系统抛弃,也就是当请求结束时释放的这一部分
if finished:  # this tail part should be freed  self._free(page_indices[new_handle.cached_len :])
如果请求未结束,则将新的cache_handle加锁,并保留尾部未满一页的KV,继续进行decode计算,直到请求结束
3.TableManager

TableManager 的职责就是维护一个 [0, max_running_reqs - 1] 的槽位池(_free_slots)当一个新请求进入引擎调度时,调用allocate方法给它分配一个空闲行号,原理很简单,就是从列表尾部弹出即可

def allocate(self) -> int:   return self._free_slots.pop()
而当请求结束时,调用free方法收回这个行号,利用append函数追加到尾部:
def free(self, slot: int) -> None:  self._free_slots.append(slot)
而TableManager也同时持有两个相同形状的二维张量,Page_Table不多介绍,TokenPool与PageTable形状相同,但它存储的是TokenId,也就是经过分词器分词之后,每个单词对应的ID,而持有这个信息的原因:

1.在Decode阶段或算子内部,Attention Kernel、PagedAttention Kernel 或后续自定义算子经常需要根据 (table_idx, pos) 直接获取对应的 Token ID,而无需频繁通过Host-to-Device(CPU 到 GPU)拷贝

2.某些静态编译或 CUDA Graph 调度中可能存在占位的虚拟请求(Dummy Request).将整个表用合法的值初始化,可以避免非法显存越界访问或因拿到脏数据导致算子崩溃

4.总结

Mini-Sglang使用巧妙的页表管理,成功将物理KV槽位与逻辑的token位置关联在了一起,而完整的生命周期如下:

1. 调度阶段 

└─► TableManager.allocate() 分配空闲 table_idx (占用页表的一行)

└─► prefix_cache.match_prefix() 匹配可复用前缀 (命中旧的物理页)

2. Prefill 准备阶段 

└─► allocate_paged()从free_slots分配缺少的新物理页

└─►_write_page_table()向页表写入逻辑位置到物理KV槽的映射

3. Kernel 计算阶段

└─► PagedAttention 算子读取 page_table[table_idx, :len],将分散物理页还原为连续注意力

4. Prefill 提交与保护阶段

└─► 从 page_table[table_idx, :cached_len] 抽取物理页索引

└─► prefix_cache.insert_prefix() 挂入前缀树 (共享复用)

└─► lock 锁定该路径物理页,防止推理中途被置换

└─► 尾部未凑满的物理页予以保留,供随后的 Decode 追加写入

5. Decode 生成阶段 

└─► 新生成的 Token 沿用已分配好的尾部页槽位;跨页时动态增补新页

6. 结束回收阶段

└─► prefix_cache 插入最终凑整的页;解锁不再加锁

└─► 释放未满整页的尾部物理槽位

└─► TableManager.free(table_idx) 归还行号槽位

相关学习资料