Nano-vLLM 源码解读 - 9. 抢占机制

news2026/5/20 23:10:21
nano-vllm 用千行代码拆解 vLLM 核心,是读懂大模型推理最快的捷径。L07 第 5 节讲过schedule()的 decode 分支大致结构,其中提到一句:“decode 在块边界处可能装不下,装不下就走preempt”,当时把细节明确推迟到本节。那段代码不到 10 行,却同时回答三个问题:decode 在什么时刻会装不下?装不下时谁先被释放?被释放的 seq 接下来去哪里?第一个问题落在block_manager.can_append——它的判定写成一行表达式,左边是空闲块数、右边是一个布尔比较的结果,合起来表达何时需要新块,以及池子里是否真有。第二个和第三个问题落在schedule()decode 分支内的while-else控制流,以及preempt函数的四个动作。读完本节,读者可以:解释can_append那一行表达式右边为什么是一个布尔比较(len(seq) % self.block_size 1),并判断它在len的不同取值下分别返回什么在给定 running 队列与 free_block_ids 数量时,列出不抢 / 抢队尾 / 抢自己三种情形分别在什么条件下被触发解释 Pythonwhile-else在 decode 分支里如何让被抢自己的 seq 不进入本轮scheduled_seqs说出preempt把被抢 seq 放到 waiting 队列的哪一端、为什么这样选解释被抢 seq 下一轮 prefill 时不必从零重算的条件——L05 的 prefix cache 在什么场景下能恢复一部分块1. decode 在块边界处装不下时怎么办L03 讲过 nano-vllm 的 KV cache 不连续:每条 seq 的block_table列出它持有的若干物理块编号,每块固定容纳block_size个 token 的 KV(默认 256)。decode 阶段每个 step 给 seq 追加 1 个新 token。这个 token 的 KV 要写到哪一块?分两种情况:当前块还没写满:新 token 继续写当前块的下一个空位,block_table不变,不需要新块。当前块刚好写满:新 token 装不进当前块,必须写入一个新块,需要从free_block_ids取一块追加到block_table末尾。第二种情况就是块边界。一条 seq 的 decode 沿块边界走时,每block_size个 step 才需要 1 个新块;其余block_size − 1个 step 都在已有块内继续写,不需要新块。但只要 KV cache 池(由 L06 的显存预算确定)被 running 全员占满,free_block_ids为空,某条 seq 走到块边界时就装不下。这时系统不能凭空多造一块,只能从别的 seq 持有的块中取——这就是抢占。扩容不行——KV cache 池大小由 L06 的显存预算定死,运行期不能再多分配显存;凭空取块也不行——物理块是有限的。剩下唯一的做法是让别的 seq 释放它持有的块。代价是被释放的 seq 已经计算过的 KV 必须被丢弃,下次再被调度时要重新算一遍。问题在于:系统怎么知道某条 seq 此刻正好处在块边界、需要新块?答案就在 can_append 这一行表达式里。2.can_append:只在块边界时查池子判定块边界与池子状态的逻辑写在block_manager.py里。最自然的写法是分两步——先判要不要新块,再判有没有空闲块:# 朴素写法(并非 nano-vllm 实际代码)defcan_append(self,seq:Sequence)-bool:iflen(seq)%self.block_size1:returnlen(self.free_block_ids)1else:returnTrue这种写法每个 decode step 都要执行一段判断。nano-vllm 把两件事合并为一行,利用了 Python 布尔可强转为整型的特性——下面拆开看为什么这样写避免了大多数 step 的检查:# block_manager.pydefcan_append(self,seq:Sequence)-bool:returnlen(self.free_block_ids)(len(seq)%self.block_size1)一行表达式把两件事合并为一个比较。先单独解析两边:右边len(seq) % self.block_size 1是一个布尔表达式。len(seq)是 seq 当前的 token 总数(prompt 长度加上已 decode 出的部分),block_size是每块容量。%取余,判等。整个表达式只在余数恰好为 1 时为True,其他取值都为False。这个 1 来自can_append的调用时机。L07 第 1 节讲过:每个 step 末尾postprocess调用seq.append_token(new_token),把新 token 追加到token_ids,len(seq)因此加 1。按这个时序,一次 decode step 的时间线是:① 上一轮 forward 写完当前块的最末位置 → ②postprocess让len(seq)加 1(对应 seq 内部num_tokens字段;token 已存在于 seq,但它的 KV 还没有任何物理块容纳)→ ③ 本轮 schedule 看到len % block_size 1,触发分配新块。假设block_size 16,seq 的block_table [b0],b0 已写满 16 个 token。len(seq) 16意味着 16 个 token 全部落在 b0(刚好写满),还没有第 17 个 token。下一轮 forward 会算出第 17 个 token,但 schedule 在此刻读到的len还是 16,余数为 0,不要新块。上一个 decode step 跑完之后,postprocess把第 17 个 token 追加到token_ids,len由 16 变 17,本轮 schedule 看到len 17。这个 token 的 KV 还没被任何块容纳——下一轮 forward 才会写。所以len 17是token 已经存在、但 KV 还没落块的瞬间,必须为它准备一块。所以len % block_size 1表达的物理含义是:“当前 seq 的最末 1 个 token 已经处在新块的第 1 个位置,但还没有任何物理块容纳它”。这个时刻必须有空闲块。其他取值都对应当前块还能继续写,不需要新块。左边len(self.free_block_ids)是空闲块数,整数。两边通过比较。Python 在做整型比较时会把布尔值强转为整型(True→ 1,False→ 0)。两种取值组合如下:len(seq) % block_size右边布尔表达式比较条件can_append返回值物理含义0False(转为 0)len(free) 0永真True上一轮 token 写入当前块的最末 1 个位置,本轮还不需要新块1True(转为 1)len(free) 1,需至少 1 块取决于池子本轮即将写新块,必须有空闲块2 到block_size − 1False(转为 0)len(free) 0永真True当前块还没写满,继续写第二行是唯一可能返回False的情形。其他block_size − 1种余数全部返回True且不查池子——因为 0永远成立。这里有一个反直觉的地方:绝大多数 decode step,can_append不做是否有空闲块的判定。按默认block_size 256,255 / 256 ≈ 99.6%的 decode step 直接返回True、无需触发抢占检查;只有1 / 256 ≈ 0.4%的 step 真正执行 1的判定。一行 (布尔)表达式把何时需要检查与是否真有合并为单次整型比较,避免了在大多数 step 显式判if (need_block): ...。补一个收尾:can_append只判断够不够,真正分配新块的是may_append,条件正好与can_append右边对齐:# block_manager.pydefmay_append(self,seq:Sequence):iflen(seq)%self.block_size1:seq.block_table.append(self._allocate_block())schedule()在确认can_append通过、决定调度该 seq 后立刻调may_append(见 scheduler.py:69)。两者的条件一致:can_append判断需不需要 有没有,may_append在两者都成立后取一块写入block_table。何时触发已经清楚,接下来看触发后怎么办。3. 三种调度结果与while-else控制流这段代码要解决的事:遍历 running 队列,把每条 seq 加入本轮调度;遇到装不下的 seq 时,先抢队尾,实在不行抢自己。decode 分支的处置逻辑写在一段双层 while 里,外层遍历 running,内层处理can_append失败的情形。先看完整代码,再拆解控制流。# scheduler.py:schedule() decode 分支whileself.runningandlen(scheduled_seqs)self.max_num_seqs:seqself.running.popleft()# 取出队首,逐条尝试调度whilenotself.block_manager.can_append(seq):# 装不下时进入抢占循环ifself.running:self.preempt(self.running.pop())# 抢队尾(最近加入的 seq)else:self.preempt(seq)# 队尾已抢光 → 抢自己break# break 退出,下面 else 不执行else:# ← 本节焦点:while 自然退出才走 elseseq.num_scheduled_tokens1seq.is_prefillFalseself.block_manager.may_append(seq)# 边界态(% 1)分配新块scheduled_seqs.append(seq)# 调度成功,加入本轮assertscheduled_seqs# 不变式:每轮至少调度 1 条self.running.extendleft(reversed(scheduled_seqs))# 保持原顺序放回 running 队首returnscheduled_seqs,False外层while从self.running队首逐条取 seq,加入本轮调度。内层while not can_append(seq)处理can_append失败的情形。为什么抢的是队尾self.running.pop()取的是 deque 的右端——即 running 队列的队尾,也就是最近 append 进来的 seq。直觉上 FIFO 是先来先服务、先来先走,但抢占恰好反过来:先释放最近加入的那一条。为什么?running 里每条 seq 都已经在过去若干 step 中算过 KV、累积了上下文。被抢者的全部 KV 会被释放(下一节preempt详解),加入 waiting 后需要重新 prefill 才能续上。被抢者最近加入,累积的 KV 越少,重算成本越低;加入早的,重算成本越高。所以抢队尾是用已投入算力最少的标准选取被抢对象。while-else如何让自抢的 seq 自动出局看懂内层 while 的三种结局,需要先讲清楚 Python 的while-else语义,因为内层while ... else的else才是调度成功那一支。Pythonwhile-else语义:while 条件: ... else: ...是 Python 控制流的一个少见结构。else块只在while因条件失败而自然退出时执行,因break中断退出时不执行。while退出方式else是否执行条件由 True 变 False,自然退出是循环体内执行break否用类比理解:循环顺利跑完才走 else 分支;break 中途打断,跳过 else。在 decode 分支里,自然退出 can_append转 True 装得下。把这条语义套回 decode 分支:while not can_append(seq)表示只要装不下就继续触发抢占;退出条件是can_append(seq)变 True,即装得下了。自然退出 → 装得下 →else分支执行 → 把 seq 加入scheduled_seqs。break退出 → 抢光也装不下、只能放弃自己 →else不执行 → seq 不加入scheduled_seqs。nano-vllm 在这里用while-else处理自抢情形:第三种结局里,seq 被preempt(seq)后立刻break出内层。break跳过else分支,意味着scheduled_seqs.append(seq)这一句不执行——seq 不进入本轮被调度的列表。这与自抢语义对齐:seq 已经被preempt置为 WAITING、加入 waiting 队首,本轮 forward 它就不该参加。else分支天然把它排除,无需在break之后额外加if not preempted_self: ...判断。Pythonwhile-else的设计在这里恰好作为自然退出执行主路径、异常退出执行旁路的开关使用。三种结局汇总。内层while加上if / else内分支,组合出三种结局。下表把进入内层while时的状态对应到结局:进入内层时的状态内层while行为退出方式else是否执行当前 seq 命运can_append(seq)第一次就True循环体一次都不执行条件由 True 变 False(指not can_append由 True 变 False)是调度成功,加入scheduled_seqscan_append(seq)失败,self.running非空preempt(self.running.pop())抢队尾,然后重检can_append抢一条或多条队尾后,池子有空块、can_append转 True,条件失败退出是调度成功can_append(seq)失败,self.running空preempt(seq)抢自己,然后breakbreak否不加入scheduled_seqs,seq 状态置为 WAITING,加入 waiting 队首三种结局对应不抢 / 抢队尾 / 抢自己。上面把preempt(seq)当作单步操作处理;它内部其实做了四件事,每一件都对应一个状态变化。4.preempt的四个动作与 prefix cache 的部分恢复机制被抢者在 preempt 内部经历了什么、被抢之后是否真的全部丢失,需要拆开 preempt 函数看。preempt函数本身只有四行:# scheduler.pydefpreempt(self,seq:Sequence):seq.statusSequenceStatus.WAITING seq.is_prefillTrueself.block_manager.deallocate(seq)self.waiting.appendleft(seq)四个动作各对应一个语义层面的状态变化。下面逐条看:动作 1:status WAITINGL07 第 1 节讲过 SequenceStatus 的三态机:WAITING → RUNNING → FINISHED。preempt 对应这个三态机上的反向转换——从 RUNNING 退回 WAITING。这一行的物理含义是:seq 从正在被推进的活跃序列变回等待被调度的候选序列。动作 2:is_prefill Trueis_prefill是 L07 第 1 节讲过的该 seq 下次被调度时走 prefill 路径还是 decode 路径的指示位。preempt 把它重置为True,意味着 seq 下次被调度时必须走 prefill 路径。为什么必须走 prefill 而不能直接续 decode?第 3 个动作给出答案。动作 3:deallocate(seq)deallocate要做两件事:把 seq 持有的每块的引用计数减 1(降到 0 才真正归还);清空 seq 自己对这些块的索引(block_table与num_cached_tokens)。L04 讲过细节,这里只回顾要点。# block_manager.pydefdeallocate(self,seq:Sequence):forblock_idinreversed(seq.block_table):blockself.blocks[block_id]block.ref_count-1ifblock.ref_count0:self._deallocate_block(block_id)seq.num_cached_tokens0seq.block_table.clear()L04 已讲过deallocate的内部机制:把block_table中每一块的ref_count减 1,降到 0 的块由_deallocate_block真正放回free_block_ids。然后num_cached_tokens清零、block_table清空。物理含义是:被抢者持有的全部物理块归还(或释放引用),被抢者已经计算的 KV 在物理上仍可能留在块内,但被抢者已失去对它们的引用——block_table.clear()之后,该 seq 与那些块之间的关联断了。这同时回答了动作 2 的问题:被抢者的block_table已清空,下次被调度时没有任何块可续,只能从头按 prefill 路径重新分配块、重新计算 KV。动作 4:waiting.appendleft(seq)一个反直觉的地方:被抢的 seq 不像新请求那样加入 waiting 队尾,而是置于队首。普通 add 请求是waiting.append(放队尾);preempt 用appendleft(放队首)。两者的物理含义对照如下:操作用途seq 在 waiting 中的位置waiting.append(seq)上层接入的新请求队尾(等队列里所有比它先来的都进了 running 再轮到自己)waiting.appendleft(seq)preempt 把被抢者加入 waiting队首(下次schedule()进 prefill 分支第一个就是它)为什么 preempt 要把被抢者置于队首?原因是:被抢者已经投入过算力(算过 KV、推进过若干 step),把它再排到普通新请求之后会让被抢→再次被调度的延迟变得极长——这与抢队尾选取最近加入者的原则一致:preempt 让已投入算力最多的 seq 损失最小。appendleft让被抢者下一轮就优先重启,把被抢→再次开始 prefill的间隔降到最短。这个选择还配合了 prefix cache 的部分恢复机制——把 hash 失效窗口降到最短。prefix cache 的部分恢复机制被抢者下一轮是从零开始还是能省一部分?答案取决于一个被 L04 与 L05 提到、但在抢占场景里至关重要的细节:deallocate不会立即清除hash_to_block_id字典中被抢者的 hash 条目。先看理想情形:被抢者持有的块都已写满,且被抢→再次被调度之间没有别的 seq 取走这些块。这时被抢者下一轮 prefill 时,沿 token 序列重新算 hash,在字典里全部命中,所有满块的 KV 都不必重算。但现实里有两个边界让这个理想情形不一定成立:边界 1:末块未满。L05 讲过:hash_to_block_id 是一个字典,键是某块 16 个 token 序列的 hash,值是该 token 序列当前所在的 block_id;它是 prefix cache 找历史块的反向索引。L05 同时讲过:只有已写满的块才会被hash_blocks写入hash_to_block_id(hash_blocks只处理完整块)。被抢者最末那一块若未满,从未被 hash,不在恢复范围内——这部分 token 的 KV 必须重算。边界 2:被抢→再次被调度之间发生了新分配。先看 L04 讲过的_allocate_block。_allocate_block在分配空闲块时,同时检查这块原来的所属 seq 是否仍持有它的引用(hash_to_block_id是否仍指向自己),只在仍指向自己时才删条目。下面是相关片段:# block_manager.pydef_allocate_block(self)-int:block_idself.free_block_ids.popleft()# 从 free pool 取一块blockself.blocks[block_id]assertblock.ref_count0# 不变式:free 中的块 ref_count 必为 0ifblock.hash!-1andself.hash_to_block_id.get(block.hash)block_id:# 旧主的 hash 条目仍指向自己delself.hash_to_block_id[block.hash]# ← 本节焦点:覆写前清条目,旧主的恢复链断开block.reset()# ref_count1, hash-1, token_ids[]...hash_to_block_id中的条目只在该块真正被其他 seq 取走时才删除(且仅当 hash 字段仍指向自己——避免误删覆写情况)。preempt 调用的deallocate只动ref_count和block_table,不动hash_to_block_id。这意味着:被抢者的 hash 条目在deallocate之后仍然存在;只要那些块没被其他 seq 取走、_allocate_block没触发过删除逻辑,它们就一直在字典里指向原来的 block_id。下一轮被抢者作为 prefill 重新被调度时,L05 讲过can_allocate沿 seq 的完整块依次算 hash、查字典;命中则累加num_cached_blocks(不用重算的部分),并在合适时机扣减num_new_blocks:# block_manager.py:can_allocate(seq)defcan_allocate(self,seq:Sequence)-int:h-1num_cached_blocks0num_new_blocksseq.num_blocks# 上限:假设所有块都要新分配foriinrange(seq.num_blocks-1):# 只走前缀完整块,最末未满块跳过token_idsseq.block(i)hself.compute_hash(token_ids,h)# 沿前缀链算 hashblock_idself.hash_to_block_id.get(h,-1)ifblock_id-1orself.blocks[block_id].token_ids!token_ids:break# 字典里没条目或内容不符 → 后续也不必再查num_cached_blocks1# 命中:这块 KV 不必重算ifblock_idinself.used_block_ids:num_new_blocks-1# 命中且仍被占 → 共享,不需要新块iflen(self.free_block_ids)num_new_blocks:# free pool 不够 → 整体分配失败return-1returnnum_cached_blocks# 返回命中数,后续 allocate 据此安排num_new_blocks初值是seq.num_blocks——先按所有块都要新分配算上限。循环只遍历seq.num_blocks - 1个前缀完整块(最末未满块不入 hash 表,跳过)。对每个前缀块算 hash 查字典:命中且token_ids匹配 →num_cached_blocks 1,表示这块 KV 不用重算;再判断该 block_id 是否仍在used_block_ids中,分两种情形扣减num_new_blocks:命中且仍被占:其他 seq 也持有这块物理 block,本 seq 与之共享,num_new_blocks - 1——不需要从free_block_ids取新块。命中但已归还free_block_ids:这块物理 block 还在,但used_block_ids不含它,所以不扣减num_new_blocks——下面_allocate_block仍要从free_block_ids取一块,只不过取的就是这块命中的 block,语义上相当于先归还、再领回。最后比较len(free_block_ids) num_new_blocks,不足则返回-1(不能分配),足够则返回num_cached_blocks。被抢者部分恢复的物理基础就在于此:block 物理上还在,只是被抢者一度失去对它的引用;hash 表使被抢者重新定位到它之前持有的块。若被抢者之前的 hash 条目还在,且对应 block 的 token_ids 仍然匹配,就计入num_cached_blocks,命中的部分不需要重算 KV。若被抢者的某些块在它再次被调度前已被其他 seq 通过_allocate_block取走,那条 hash 条目就被del了——这部分也丢失。在抢占场景中,A 触发抢占 → C 被抢出 → A 的may_append接着取一块,正好可能取走 C 刚释放的块。这就是prefix cache 部分恢复的真实形态:是否能恢复、能恢复多少都不固定——取决于被抢者末块写满程度、以及被抢→再次被调度之间发生了多少新分配。最坏情况(全部块被复用)等价于从零重算;最好情况(无新分配介入)能省下整段 prompt 的 prefill 算力。用一组具体数值跑一遍第 3、4 节描述的机制,可以直观确认三种处置情形如何依次触发、部分恢复机制如何起作用。5. running[A, B, C] 在块边界的一次调度前四节给出规则,本节用一组具体数值端到端验证。设定:block_size 16(教学用值;默认 256,模式相同但表格冗长),max_num_seqs 16,KV cache 池总容量 6 块。running 队列初始[A, B, C],free_block_ids 为空。三条 seq 的状态:seqlen(seq)block_tablelen % block_size是否边界态说明A17[b0, b1]1是b0 已满 16 token,b1 已写 1 token,本轮需要新块B18[b2, b3]2否b3 已写 2 token,本轮在 b3 继续写C17[b4, b5]1是b4 已满,b5 已写 1 token,本轮需要新块hash_to_block_id状态(L05 已讲:只有满块进字典):满块字典条目b0hash(A 第 0 块的 16 token)→ b0b2hash(B 第 0 块的 16 token)→ b2b4hash(C 第 0 块的 16 token)→ b4b1、b3、b5 未满,不在字典中。free_block_ids [],used_block_ids {b0, b1, b2, b3, b4, b5}。进入schedule()。waiting 为空,跳过 prefill 分支,直接进 decode 分支。迭代 1:处理 A —— 抢队尾seq self.running.popleft()取出 A。self.running [B, C]。检查can_append(A):右边17 % 16 1为 True,布尔强转为 1;左边len(free) 0;0 1为 False。can_append返回 False,进入内层 while。内层迭代:if self.running:为真(running 还有 B、C)→preempt(self.running.pop())preempt(C)。preempt(C)的四个动作:C.status WAITINGC.is_prefill Truedeallocate(C):遍历reversed([b4, b5])[b5, b4]。b5:ref_count - 1 0 →_deallocate_block(b5)→used_block_ids去掉 b5、free_block_ids.append(b5)。b4:ref_count - 1 0 →_deallocate_block(b4)→free_block_ids.append(b4)。末尾C.num_cached_tokens 0、C.block_table.clear()。waiting.appendleft(C)→ waiting [C]。注意此时hash_to_block_id中 b4 的条目未删(deallocate不动 hash 字典)。deallocate的循环用reversed是为了从block_table末尾向前归还(实现细节,本节不关心);每归还一块就free_block_ids.append(...)加入队尾。所以 b5 先归还、先加入队尾;b4 后归还、加入更靠后的队尾。结果 deque 头到尾是[b5, b4]。回到内层 while 重检can_append(A):17 % 16 1为 True(1);len(free) 2;2 1为 True。can_append返回 True,内层 while 条件失败,自然退出 → else 分支执行。else 分支:A.num_scheduled_tokens 1A.is_prefill Falsemay_append(A):17 % 16 1为真 →seq.block_table.append(self._allocate_block())。_allocate_block:free_block_ids.popleft()取 b5(队首)。b5 的hash -1(从未满过、从未写入 hash 字典)→ 跳过del。b5.reset()。used_block_ids.add(b5)。返回 b5。A.block_table.append(b5)→[b0, b1, b5]。scheduled_seqs.append(A)→[A]。末态:free_block_ids [b4],used_block_ids {b0, b1, b2, b3, b5}。hash_to_block_id仍含 b4 的旧条目。迭代 2:处理 B —— 不抢外层while继续。seq self.running.popleft()取出 B。self.running []。检查can_append(B):右边18 % 16 2 1为 False(0);左边len(free) 1;1 0为 True。can_append返回 True,内层 while 一次都不进入,直接走 else 分支。else 分支:B.num_scheduled_tokens 1B.is_prefill Falsemay_append(B):18 % 16 2,不等于 1 →不分配新块。B.block_table仍为[b2, b3]。scheduled_seqs.append(B)→[A, B]。末态不变:free_block_ids [b4]。迭代 3:running 空 —— 退出外层while self.running条件[] and ...为假,退出外层 while。assert scheduled_seqs检查[A, B]非空,通过。self.running.extendleft(reversed([A, B]))extendleft([B, A]),等价于先appendleft(B)再appendleft(A),结果self.running [A, B],顺序保留。return ([A, B], False),decode 分支返回。末态汇总与下轮命中分析字段末态running[A, B]waiting[C]scheduled_seqs[A, B](本轮 forward 处理)A.block_table[b0, b1, b5]B.block_table[b2, b3]C.block_table[]free_block_ids[b4]hash_to_block_id{hash(b0): b0, hash(b2): b2, hash(b4): b4}C 当时持有的 b4(已满块)、b5(未满块):b5 被 A 的may_append取走、reset,b5 从未在 hash 字典里;b4 仍在free_block_ids中,hash 条目保留。下一轮 schedule 走 prefill 分支处理 C 时,can_allocate(C)沿 C 的num_blocks - 1 1个完整块走 hash 比对:计算C.block(0)的 hash,在hash_to_block_id中查到 b4_id,且blocks[b4_id].token_ids与 C.block(0) 匹配 →num_cached_blocks 1,b4 命中。num_new_blocks初值是seq.num_blocks 2(C 有 17 个 token,ceil(17/16) 2块);b4 命中但不在used_block_ids(已归还free_block_ids),所以num_new_blocks不扣减,仍为 2。检查len(free_block_ids) 1 2→can_allocate返回-1,C 本轮无法被调度——free_block_ids只剩 1 块,装不下 C 需要的 2 块。但hash 条目并未失效:只要 b4 在 C 被再次调度前没被其他 seq 通过_allocate_block取走,hash_to_block_id中 b4 的条目就一直存在。等到free_block_ids恢复(例如后续某条 running seq 自然完成、释放它持有的块,或 A、B 又被抢出),can_allocate(C)再次被调用时仍会命中 b4,num_cached_blocks仍为 1——C 第 0 块直接复用 b4,无需重算,只有第 1 块(原 b5 的位置)需要重新分配并重算 KV。这就是prefix cache 部分恢复在本例中的具体体现:hash 条目的存活与free_block_ids是否够用是两件事——前者决定命中能省多少,后者决定本轮能不能调度。最理想情况(b4 hash 条目存活、free_block_ids足够)能省下 1 块的 prefill 算力,仅丢失最后那个未满块的 1 个 token 的 KV。若 A 的may_append不是取走 b5 而是取走 b4(假设free_block_ids顺序相反),则 b4 的 hash 条目会被_allocate_block中的del删除,C 的部分恢复机制完全失效——这正是恢复多少不固定的具体含义。下面这段视频把本节走查跑了一遍——running [A, B, C],block_size 16,free_block_ids为空。三次迭代依次触发抢队尾、“不抢”、“running 空退出”;末态汇总后再演示下一轮can_allocate(C)的命中判定与失效边界。状态面板(running / waiting / scheduled_seqs / free pool / hash_to_block_id)与判定面板随每一步同步变化:preempt-flow6. 思考题请先独立作答,再阅读下方提示。如果某个工程师把block_size改成 1(每块只能存 1 个 token),can_append表达式的右边会一直为 0 还是 1?抢占频率会怎么变化?为什么 nano-vllm 选择默认 256 而不是 1?若 running 队列只剩 1 条 seq A(len 17,block_table [b0, b1],边界态),KV cache 池只有 2 块、free_block_ids为空,schedule()调用如何走?最终会触发assert scheduled_seqs吗?走完执行轨迹后,主要回答:为什么 nano-vllm 在显存预算里留余量?把preempt的self.waiting.appendleft(seq)改成self.waiting.append(seq)(放队尾),其他不动。在长时间运行的场景下,被抢者会出现什么观测得到的现象?给出一个具体场景(可借用第 5 节的设定)说明改动前后调度结果的差异。思考题参考答案block_size 1时,每个 token 都是块边界。len(seq) % 1 0永远成立,所以len(seq) % 1 1永远为False(转为 0)——这与直觉相反:不是一直为 1,而是一直为 0。但这不意味着不抢占——block_size 1意味着每个 decode token 都要单独占一块,所以实际每个 step 都需要新块。问题出在表达式本身:当block_size 1时,len % block_size只能取 0,余数永远不会等于 1,can_append永远不查池子、永远返回 True;但may_append也用同一个条件len % block_size 1,所以永远不分配新块——seq 持有的块不增,新 token 的 KV 没地方写,程序行为出错。换句话说,block_size 1让整个% 1的判定逻辑失效。nano-vllm 选 256 而非 1 的原因之二在性能层面:大块降低了block_table长度、降低了can_append触发的频率(每 256 个 step 才触发一次抢占检查)、也降低了 hash 表条目数;block_size 1会让上述所有结构退化成 per-token 维护,完全失去块抽象的意义。会触发 assert,程序崩溃。执行轨迹:schedule()进 decode 分支(waiting 空、running 非空),外层 while 进入。seq self.running.popleft() A,self.running []。内层while not can_append(A):17 % 16 1,布尔强转 1,len(free) 0 1为 False → 进入内层。if self.running:为假(空)→else分支 →preempt(A):A 状态置为 WAITING、deallocate A 的 2 块(b0、b1 都进 free,但此时 A 已经从 running 移出,running 没有别的 seq 可继续)→waiting.appendleft(A)→break。内层 break,else不执行,scheduled_seqs 不变(仍为空)。外层while self.running[]为假,退出外层。assert scheduled_seqs检查空列表 → 触发AssertionError。该 assert 守护的不变式是每一轮 schedule 必产出至少一条可推进的 seq;这是 forward 循环能继续的最低条件,违反这条不变式说明显存预算公式被违反,继续运行只会产生未定义行为。正常运行时num_kvcache_blocks由 L06 的显存预算公式留出余量,保证 running 中至少有 1 条 seq 可以装下;一旦余量被耗尽,系统直接崩溃而不返回空调度,避免下游 model_runner 收到无效输入。被抢者再次被调度延迟极长,prefix cache 的部分恢复机制容易失效。借用第 5 节设定:C 被抢后用append排到 waiting 队尾。假设上层连续接入 10 条新请求(都append到 waiting 队尾),则 waiting 变成[新1, 新2, ..., 新10, C]。下一轮 prefill 时,schedule()从waiting[0]开始处理,优先调度 10 条新请求中的一部分(取决于max_num_batched_tokens与max_num_seqs)。每调度一条新请求都会调_allocate_block取空闲块——这正是会触发del hash_to_block_id[block.hash]的路径。等轮到 C 时,它当时持有的 b4 的 hash 条目大概率已被某条新请求触发_allocate_block删除,部分恢复机制失效,C 必须从头 prefill。改用原来的appendleft,C 下一轮就是waiting[0],b4 的 hash 条目几乎不可能在这么短的时间内被覆写。appendleft与 prefix cache 部分恢复机制相互配合:把被抢者置于队首,就是为了把 hash 条目失效窗口降到最短。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2629677.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SpringBoot-17-MyBatis动态SQL标签之常用标签

文章目录 1 代码1.1 实体User.java1.2 接口UserMapper.java1.3 映射UserMapper.xml1.3.1 标签if1.3.2 标签if和where1.3.3 标签choose和when和otherwise1.4 UserController.java2 常用动态SQL标签2.1 标签set2.1.1 UserMapper.java2.1.2 UserMapper.xml2.1.3 UserController.ja…

wordpress后台更新后 前端没变化的解决方法

使用siteground主机的wordpress网站,会出现更新了网站内容和修改了php模板文件、js文件、css文件、图片文件后,网站没有变化的情况。 不熟悉siteground主机的新手,遇到这个问题,就很抓狂,明明是哪都没操作错误&#x…

网络编程(Modbus进阶)

思维导图 Modbus RTU(先学一点理论) 概念 Modbus RTU 是工业自动化领域 最广泛应用的串行通信协议,由 Modicon 公司(现施耐德电气)于 1979 年推出。它以 高效率、强健性、易实现的特点成为工业控制系统的通信标准。 包…

UE5 学习系列(二)用户操作界面及介绍

这篇博客是 UE5 学习系列博客的第二篇,在第一篇的基础上展开这篇内容。博客参考的 B 站视频资料和第一篇的链接如下: 【Note】:如果你已经完成安装等操作,可以只执行第一篇博客中 2. 新建一个空白游戏项目 章节操作,重…

IDEA运行Tomcat出现乱码问题解决汇总

最近正值期末周,有很多同学在写期末Java web作业时,运行tomcat出现乱码问题,经过多次解决与研究,我做了如下整理: 原因: IDEA本身编码与tomcat的编码与Windows编码不同导致,Windows 系统控制台…

利用最小二乘法找圆心和半径

#include <iostream> #include <vector> #include <cmath> #include <Eigen/Dense> // 需安装Eigen库用于矩阵运算 // 定义点结构 struct Point { double x, y; Point(double x_, double y_) : x(x_), y(y_) {} }; // 最小二乘法求圆心和半径 …

使用docker在3台服务器上搭建基于redis 6.x的一主两从三台均是哨兵模式

一、环境及版本说明 如果服务器已经安装了docker,则忽略此步骤,如果没有安装,则可以按照一下方式安装: 1. 在线安装(有互联网环境): 请看我这篇文章 传送阵>> 点我查看 2. 离线安装(内网环境):请看我这篇文章 传送阵>> 点我查看 说明&#xff1a;假设每台服务器已…

XML Group端口详解

在XML数据映射过程中&#xff0c;经常需要对数据进行分组聚合操作。例如&#xff0c;当处理包含多个物料明细的XML文件时&#xff0c;可能需要将相同物料号的明细归为一组&#xff0c;或对相同物料号的数量进行求和计算。传统实现方式通常需要编写脚本代码&#xff0c;增加了开…

LBE-LEX系列工业语音播放器|预警播报器|喇叭蜂鸣器的上位机配置操作说明

LBE-LEX系列工业语音播放器|预警播报器|喇叭蜂鸣器专为工业环境精心打造&#xff0c;完美适配AGV和无人叉车。同时&#xff0c;集成以太网与语音合成技术&#xff0c;为各类高级系统&#xff08;如MES、调度系统、库位管理、立库等&#xff09;提供高效便捷的语音交互体验。 L…

(LeetCode 每日一题) 3442. 奇偶频次间的最大差值 I (哈希、字符串)

题目&#xff1a;3442. 奇偶频次间的最大差值 I 思路 &#xff1a;哈希&#xff0c;时间复杂度0(n)。 用哈希表来记录每个字符串中字符的分布情况&#xff0c;哈希表这里用数组即可实现。 C版本&#xff1a; class Solution { public:int maxDifference(string s) {int a[26]…

【大模型RAG】拍照搜题技术架构速览:三层管道、两级检索、兜底大模型

摘要 拍照搜题系统采用“三层管道&#xff08;多模态 OCR → 语义检索 → 答案渲染&#xff09;、两级检索&#xff08;倒排 BM25 向量 HNSW&#xff09;并以大语言模型兜底”的整体框架&#xff1a; 多模态 OCR 层 将题目图片经过超分、去噪、倾斜校正后&#xff0c;分别用…

【Axure高保真原型】引导弹窗

今天和大家中分享引导弹窗的原型模板&#xff0c;载入页面后&#xff0c;会显示引导弹窗&#xff0c;适用于引导用户使用页面&#xff0c;点击完成后&#xff0c;会显示下一个引导弹窗&#xff0c;直至最后一个引导弹窗完成后进入首页。具体效果可以点击下方视频观看或打开下方…

接口测试中缓存处理策略

在接口测试中&#xff0c;缓存处理策略是一个关键环节&#xff0c;直接影响测试结果的准确性和可靠性。合理的缓存处理策略能够确保测试环境的一致性&#xff0c;避免因缓存数据导致的测试偏差。以下是接口测试中常见的缓存处理策略及其详细说明&#xff1a; 一、缓存处理的核…

龙虎榜——20250610

上证指数放量收阴线&#xff0c;个股多数下跌&#xff0c;盘中受消息影响大幅波动。 深证指数放量收阴线形成顶分型&#xff0c;指数短线有调整的需求&#xff0c;大概需要一两天。 2025年6月10日龙虎榜行业方向分析 1. 金融科技 代表标的&#xff1a;御银股份、雄帝科技 驱动…

观成科技:隐蔽隧道工具Ligolo-ng加密流量分析

1.工具介绍 Ligolo-ng是一款由go编写的高效隧道工具&#xff0c;该工具基于TUN接口实现其功能&#xff0c;利用反向TCP/TLS连接建立一条隐蔽的通信信道&#xff0c;支持使用Let’s Encrypt自动生成证书。Ligolo-ng的通信隐蔽性体现在其支持多种连接方式&#xff0c;适应复杂网…

铭豹扩展坞 USB转网口 突然无法识别解决方法

当 USB 转网口扩展坞在一台笔记本上无法识别,但在其他电脑上正常工作时,问题通常出在笔记本自身或其与扩展坞的兼容性上。以下是系统化的定位思路和排查步骤,帮助你快速找到故障原因: 背景: 一个M-pard(铭豹)扩展坞的网卡突然无法识别了,扩展出来的三个USB接口正常。…

未来机器人的大脑:如何用神经网络模拟器实现更智能的决策?

编辑&#xff1a;陈萍萍的公主一点人工一点智能 未来机器人的大脑&#xff1a;如何用神经网络模拟器实现更智能的决策&#xff1f;RWM通过双自回归机制有效解决了复合误差、部分可观测性和随机动力学等关键挑战&#xff0c;在不依赖领域特定归纳偏见的条件下实现了卓越的预测准…

Linux应用开发之网络套接字编程(实例篇)

服务端与客户端单连接 服务端代码 #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> #include <pthread.h> …

华为云AI开发平台ModelArts

华为云ModelArts&#xff1a;重塑AI开发流程的“智能引擎”与“创新加速器”&#xff01; 在人工智能浪潮席卷全球的2025年&#xff0c;企业拥抱AI的意愿空前高涨&#xff0c;但技术门槛高、流程复杂、资源投入巨大的现实&#xff0c;却让许多创新构想止步于实验室。数据科学家…

深度学习在微纳光子学中的应用

深度学习在微纳光子学中的主要应用方向 深度学习与微纳光子学的结合主要集中在以下几个方向&#xff1a; 逆向设计 通过神经网络快速预测微纳结构的光学响应&#xff0c;替代传统耗时的数值模拟方法。例如设计超表面、光子晶体等结构。 特征提取与优化 从复杂的光学数据中自…