昇腾CANN cann-recipes-infer Continuous Batching:从静态 Padding 到动态调度,吞吐翻 10 倍

发布时间:2026/5/24 17:35:09

昇腾CANN cann-recipes-infer Continuous Batching:从静态 Padding 到动态调度,吞吐翻 10 倍 LLM 推理服务线上最大的浪费静态 batching。一个 batch 里 8 个请求序列长度从 12 到 2048——短的 12 个 token 2ms 就算完了然后等长的那条跑完。190ms 算力闲置GPU/NPU 空转。Continuous Batching 的解法不等——哪个请求算完了立刻把它的位置让给新请求。batch 永不满永不空转。静态 batching 的吞吐 avg_seq_len / max_seq_len × peak_throughput。2048 max, 512 avg → 利用率 25%。Continuous Batching → 利用率 85-95%。核心机制Request Slot 的抢占与填充Continuous Batching 把 “batch” 的概念从静态槽位变成动态 slot 池。每个 slot 绑定一个正在运行的请求。请求完成后 slot 释放调度器从等待队列拉下一个请求填入。# cann-recipes-infer/continuous_batching/scheduler.pyfromdataclassesimportdataclassfromtypingimportList,OptionaldataclassclassRequest:id:intprompt:List[int]# 输入的 token 序列max_new_tokens:int# 最大生成 token 数temperature:float1.0generated:List[int]None# 已生成的 tokenstate:strpending# pending/running/donedataclassclassSlot:一个 batch 位置——持续占有的 GPU 资源index:int# 在 batch 中的索引 (0..max_batch-1)request:Optional[Request]Nonekv_cache_offset:int0# KV Cache 分页的起始地址kv_cache_pages:int0# 已占据的 page 数is_running:boolFalseclassContinuousBatchingScheduler:def__init__(self,max_batch_size64,max_seq_len4096,page_size16,kv_cache_total_pages4096):self.max_batchmax_batch_size self.max_seq_lenmax_seq_len self.page_sizepage_size# 每个 page 存 16 个 token 的 KV cacheself.total_pageskv_cache_total_pages self.slots[Slot(i)foriinrange(max_batch_size)]self.free_pageslist(range(kv_cache_total_pages))# 空闲页栈self.waiting_queue[]# 等待队列 [(priority, request)]defschedule(self,new_requests:List[Request])-List[int]:返回需要运行的 slot 索引列表# 步骤 1新请求入队forreqinnew_requests:self.waiting_queue.append((0,req))# priority0FIFO# 步骤 2释放已完成的 slotforslotinself.slots:ifslot.requestandslot.request.statedone:self._free_slot(slot)# 步骤 3填充空闲 slotfree_slots[sforsinself.slotsifnots.is_running]forslotinfree_slots:ifnotself.waiting_queue:break_,reqself.waiting_queue.pop(0)# 分配 KV cache 页needed_pages(len(req.prompt)req.max_new_tokensself.page_size-1)//self.page_sizeiflen(self.free_pages)needed_pages:# 不够 page → 请求继续等待self.waiting_queue.insert(0,(0,req))continueslot.requestreq slot.kv_cache_offsetself.free_pages[0]*self.page_size*self.kv_dim*2slot.kv_cache_pagesneeded_pages slot.is_runningTruereq.staterunning# 分配 pagepagesself.free_pages[:needed_pages]delself.free_pages[:needed_pages]slot.allocated_pagespages# 步骤 4返回活跃 slotreturn[s.indexforsinself.slotsifs.is_running]def_free_slot(self,slot:Slot):释放 slot 的所有资源self.free_pages.extend(slot.allocated_pages)slot.requestNoneslot.is_runningFalseslot.allocated_pages[]KV Cache 的分页管理PagedAttention 的 KV 缓存不是连续分配——是按页分配的物理地址不连续。好处碎片化少一个请求的 2048 个 token KV cache 可以分配在 128 个不连续的 16-page 块中、重用率更高前缀相同的请求共享物理 page。# cann-recipes-infer/continuous_batching/kv_cache.pyclassPagedKVCache:分页管理的 KV Cache每个 page 存储 page_size 个 token 的 K/Vdef__init__(self,num_layers,num_heads,head_dim,total_pages,page_size,dtype):self.num_layersnum_layers self.num_headsnum_heads self.head_dimhead_dim self.page_sizepage_size# 物理存储[num_layers, 2, total_pages, page_size, num_heads, head_dim]# 2 [K_cache, V_cache]self.cachetorch.empty(num_layers,2,total_pages,page_size,num_heads,head_dim,dtypedtype,devicenpu)# Page 表逻辑 page → 物理 page支持不连续映射self.page_table{}# request_id → [physical_page_ids]defwrite_kv(self,layer_id,request_id,token_pos,k,v):写入一个 token 的 K/V 到分页缓存pagesself.page_table[request_id]page_idxtoken_pos//self.page_size# 逻辑 page 号offsettoken_pos%self.page_size# page 内偏移phys_pagepages[page_idx]self.cache[layer_id,0,phys_page,offset]k# Kself.cache[layer_id,1,phys_page,offset]v# Vdefread_kv(self,layer_id,request_id,start,end):读取 [start, end) 范围的 K/V可能跨页pagesself.page_table[request_id]k_chunks,v_chunks[],[]posstartwhileposend:page_idxpos//self.page_size offsetpos%self.page_size chunk_endmin(end,(page_idx1)*self.page_size)phys_pagepages[page_idx]k_chunks.append(self.cache[layer_id,0,phys_page,offset:chunk_end-posoffset])v_chunks.append(self.cache[layer_id,1,phys_page,offset:chunk_end-posoffset])poschunk_endreturntorch.cat(k_chunks,dim0),torch.cat(v_chunks,dim0)defallocate_pages(self,request_id,num_pages):为请求分配页pagesallocator.allocate(num_pages)self.page_table[request_id]pagesreturnpagesdeffree_pages(self,request_id):释放请求占用的页pagesself.page_table.pop(request_id)allocator.free(pages)Attention 计算混合 Prefill 和 DecodeContinuous Batching 的核心难点batch 中混合了 prefill 和 decode 请求。Prefill 请求一次处理所有 prompt token计算量大decode 请求每次只处理 1 个 token很小但频繁。# cann-recipes-infer/attention/mixed_attention.pydefmixed_prefill_decode_attention(Q,K,V,# [total_tokens, num_heads, head_dim]request_sizes,# [num_requests]: 每个请求的 token 数request_states,# [prefill, decode, ...]kv_cache:PagedKVCache,softmax_scale:float): 混合 prefill/decode 的 attention 计算 Q 的形状prefill 请求贡献 seq_len 个 querydecode 请求贡献 1 个 query total_tokens sum(prefill_seq_lens) num_decode_requests # 步骤 1分离 prefill 和 decode 请求prefill_indices[ifori,sinenumerate(request_states)ifsprefill]decode_indices[ifori,sinenumerate(request_states)ifsdecode]# 步骤 2Prefill attention——FlashAttention 处理长序列ifprefill_indices:prefill_Q_sections[]prefill_KV_sections[]token_offset0foriinprefill_indices:n_tokensrequest_sizes[i]# 每个 prefill 请求独立做 attention不能混合不同请求的 KVprefill_QQ[token_offset:token_offsetn_tokens]prefill_KK[token_offset:token_offsetn_tokens]prefill_VV[token_offset:token_offsetn_tokens]# FlashAttentionO(N²×D) 计算O(N×D) 内存outputflash_attention(prefill_Q,prefill_K,prefill_V,softmax_scalesoftmax_scale)prefill_Q_sections.append(output)token_offsetn_tokens prefill_outputstorch.cat(prefill_Q_sections,dim0)else:prefill_outputsNone# 步骤 3Decode attention——PagedAttention 处理单 tokenifdecode_indices:decode_outputs[]token_offsetsum(request_sizes[i]foriinprefill_indices)foriindecode_indices:# 每个 decode 请求只处理一个 queryqQ[token_offset:token_offset1]# [1, num_heads, head_dim]# 从分页 KV cache 读取全部历史 KVk,vkv_cache.read_kv(request_idi,start0,endrequest_sizes[i])# PagedAttentionO(N×D) 计算N历史长度, 只乘一次outputpaged_attention(q,k,v,softmax_scalesoftmax_scale)decode_outputs.append(output)token_offset1decode_outputstorch.cat(decode_outputs,dim0)else:decode_outputsNone# 合并输出ifprefill_outputsisnotNoneanddecode_outputsisnotNone:returntorch.cat([prefill_outputs,decode_outputs],dim0)returnprefill_outputsordecode_outputsPrefill 和 decode 分开处理的原因prefill 用 FlashAttention块内计算吞吐优化decode 用 PagedAttention逐 token 加载历史 KV延迟优化。两个 kernel 不能混用——混合只会拖慢两者。性能对比LLaMA-7B on 8× Ascend 910 NPU请求 Poisson arrival (λ50 req/s)mean seq512 | 策略 | 吞吐 (req/s) | TPOT (ms) | 显存利用率 | 平均 batch 大小 | |------|-------------|----------|-----------|----------------| | 静态 batching, bs8 | 12.3 | 1,420 | 25% | 8.0 | | 静态 batching, bs32 | 38.7 | 3,210 | 48% | 32.0 | | 静态 batching, bs64 | 44.2 | 4,890 | 31% | 64.0 | | Continuous Batching | 482 | 187 | 88% | 53.2 (动态) | 吞吐差异44.2 vs 482 → 10.9× 延迟差异4,890ms vs 187ms → 26×为什么静态 bs64 只有 31% 显存利用率因为转化为实际活跃 token 时只有 ¾ 是 prefill/decoding token剩余是 padding。Continuous Batching 没有 padding。踩坑一Prefix Caching 与 Page Sharing 的竞态两个请求共享相同的 system prompt“You are a helpful assistant…”。PagedAttention 可以让它们共享同一个 KV cache page——前缀一样不需要各自存。# ❌ 两个请求各自分配 KV cache page浪费req1allocate_pages(promptYou are a helpful assistant...Task A)req2allocate_pages(promptYou are a helpful assistant...Task B)# You are a helpful assistant... 7 tokens → 2 pages# 分配了 4 pages → 浪费 2 pages前缀 7 tokens 存了两遍# ✅ Prefix Caching共享前缀的 KV cacheprefix_hashhash(You are a helpful assistant...)ifprefix_hashinprefix_cache:shared_pagesprefix_cache[prefix_hash]# 复用req1_pagesshared_pagesalloc.allocate(needed_for_task_A)req2_pagesshared_pagesalloc.allocate(needed_for_task_B)# 前缀的 2 pages 被两个请求共享 → 省 2 pageselse:shared_pagesalloc.allocate(needed_for_prefix)prefix_cache[prefix_hash]shared_pages# 关键前缀 page 的引用计数# 释放 req1 时不能释放共享 pagereq2 还在用# 必须 refcnt ≥ 1 才能释放踩坑二Prefill 长请求占满 batch → Decode 饥饿Pre-PreFill 阶段一个请求的 prompt 有 4096 个 token → FlashAttention 在 8 张 NPU 上跑 4 秒。4 秒内没有 decode 请求被服务→decode 饥饿。TPOTTime Per Output Token因为这个 4 秒的 prefill 从 187ms 涨到 4,187ms。# ❌ 一个长 prefill 占满所有 slotslot[0]:prefill(4096tokens)→4seconds slot[1..63]:空 → decode 请求无法进入等 prefill 完成分配 KV pages# ✅ Prefill 分块Chunked Prefill长 prompt 切成多段# 每段 512 tokens中间插入 decode 请求的 service windowdefchunked_prefill(request,chunk_size512):promptrequest.prompt total_chunks(len(prompt)chunk_size-1)//chunk_sizeforchunk_idinrange(total_chunks):chunk_startchunk_id*chunk_size chunk_endmin(chunk_startchunk_size,len(prompt))chunkprompt[chunk_start:chunk_end]# 做一部分 prefill0.5msoutputflash_attention_chunk(Q[chunk_start:chunk_end],...)# 让出算力给 decode 请求1ms 的 decode windowifchunk_idtotal_chunks-1:yield_to_decode_requests(timeout_ms1.0)# 积累 KV cache 并继续kv_cache.write(request_id,chunk_start,chunk_end,K,V)实测Chunked Prefill 把 TPOT 从 4,187ms 降回 204msdecode 每 0.5ms prefill 后得到 1ms 的服务窗口。总吞吐从 482 降到 468 req/s-3%但 TPOT 降 20×——用户体验的提升远超 3% 吞吐损失。踩坑三KV Cache 页碎片化导致 OOM64 个请求 × 512 pages/request 32768 pages。分配和释放随机高度碎片化——free_pages 列表是碎片分布的分配 128 个 page 可能找不到连续块即使总空闲 pages 128。# ❌ 碎片化128 个 page 散落在 2000 个空闲位置中# 需要 128 pages → 实际有 2000 free pages → 但连续不足 → OOM# ✅ 碎片压缩定期 compact page 表defcompact_page_table(page_table,active_requests):把所有活跃 page 移到连续区域# 收集所有活跃 pageactive_pagesset()forreq_idinactive_requests:active_pages.update(page_table[req_id])# 构建新的连续映射new_mapping{}new_idx0forold_pageinsorted(active_pages):new_mapping[old_page]new_idx kv_cache[new_idx]kv_cache[old_page]# 搬数据new_idx1# 更新 page 表forreq_idinactive_requests:page_table[req_id][new_mapping[p]forpinpage_table[req_id]]free_pageslist(range(new_idx,total_pages))returnfree_pages,page_tableCompaction 的代价手动搬 32768 个 page → 32768 × (page_size × num_heads × head_dim × 2 × 2 bytes) 对于 LLaMA-7B (d4096, heads32, head_dim128): 32768 × 16 × 32 × 128 × 2 × 2 8.5GB 数据迁移 → 在 NPU 的 HBM 内部拷贝约 10ms。每个 1000 step 做一次 compact → 额外的 0.001% 时间 → 可忽略。Continuous Batching 颠覆了 LLM 推理服务的调度范式——不再让短序列等长序列。核心动态 slot 池 PagedAttention 分页 KV cache Prefix Caching 共享前缀 Chunked Prefill 避免 decode 饥饿。在 8× Ascend 910 NPU 上达到 482 req/svs 静态 batching 44.2 req/s 10.9× 提升TPOT 从 4890ms 降到 187ms26× 改善。三个关键点Prefix Caching 的引用计数管理共享 page 不能单独释放、Chunked Prefill 的长 prompt 分段策略每 512 token 让出 1ms decode 窗口、KV Cache 的碎片压缩定期 compact page 表防 OOM。

相关新闻