fix(block_manager): prevent hash_to_block_id unbounded growth on deallocate - #62
fix(block_manager): prevent hash_to_block_id unbounded growth on deallocate#62wzgrx wants to merge 1 commit into
Conversation
…locate When a KV block is freed, _deallocate_block previously never removed the block's hash from hash_to_block_id. Over long-running deployments (hours/days) this accumulated one stale dict entry per unique KV-block prefix that ever existed, degrading hash-lookup speed and GC behaviour. Fix by popping the stale hash entry when the block is deallocated. This is safe because a freed block has ref_count == 0 and its token_ids have been cleared by Block.reset(), so even if a future request produces the same hash the token_ids comparison in allocate() would correctly treat it as a cache miss.
补充分析:CUDA graph 累积的根因(来自现场测试)精确责任链
代码定位
def run_model(self, inputs, is_prefill):
if (
is_prefill # ← PREFILL 走此分支
or self.enforce_eager
or inputs["positions"].size(0) > 512
or (has_active_lora and not has_lora_graph)
):
return self.model(**inputs) # ← 触发 PyTorch 动态 CUDA graph 录制
# decode: 走预录制 graph.replay() ✅每个 TTS 请求文本长度不同 → 每次 prefill 输入不同 → 每个请求录制一个新 CUDA graph → 累积 1000+ 录制 → GPU 显存碎片 → 推理质量下降 可能的修复方向
已有 #61 完整记录,这里是补充分析。 |
|
Thanks for looking into this. I agree that the issue described here is real: However, I think the current fix may change the prefix-cache semantics more than intended. Today, a freed block is not reset in if block_id in self.used_block_ids:
block = self.blocks[block_id]
block.ref_count += 1
else:
block = self._allocate_block(block_id)So a free block can still be reused as a prefix-cache hit, as long as its KV memory has not been overwritten. Removing the hash entry during deallocation disables that reuse path for sequential repeated prompts. That may be an acceptable policy change, but it should be called out explicitly and covered by tests. There is also a smaller correctness concern: the pop should probably be conditional, otherwise deallocating one block could remove a mapping that now points to another block with the same hash value: if block.hash != -1 and self.hash_to_block_id.get(block.hash) == block_id:
self.hash_to_block_id.pop(block.hash, None)A possibly safer approach would be to remove a stale hash when a free block is actually reallocated for a cache miss / overwritten, rather than when it is merely released. That would still bound stale entries over time while preserving the current “free blocks may still be prefix-cache candidates” behavior. Suggested follow-ups before merging:
So I agree with the problem statement, but I would not merge this exact change without clarifying the intended cache-retention semantics and adding tests around that behavior. |
Summary
When a KV block is freed via
_deallocate_block, its hash was never removed fromhash_to_block_id. Over long-running deployments (hours/days of continuous TTS inference) this dict grows by one entry per unique KV-block prefix that ever existed, slowly degrading hash-lookup speed and Python GC performance.Reported in: #61, #58
The Fix
Pop the stale
block.hashentry fromhash_to_block_idwhen the block is freed. This is a 1-line semantic change (expanded with a guard + comment for clarity).Safety Analysis
The operation is safe because a freed block has:
ref_count == 0— no sequence references ittoken_ids == []— cleared byBlock.reset()When
allocate()later searcheshash_to_block_idfor a cache hit, the hit entrys block will have emptytoken_ids, so thetoken_ids != token_idscomparison (line 135) will force a cache miss — the same behaviour as before. The only difference is that we now proactively remove the stale dead entry instead of letting it accumulate.In practice this means a freed blocks prefix can never trigger a future cache hit via its stale hash entry. But since
reset()already cleared the content, such a hit would have been a false positive anyway (it would compare empty[]against realtoken_idsand still miss), so there is zero behavioural change in cache-hit logic.Context
This is part of a family of long-running stability issues affecting VoxCPM2 + nano-vllm deployments on consumer GPUs. See also #58 (process unresponsive after days —
block_idandlistaccumulation), #61 (progressive audio quality degradation under CUDA graphs + LoRA on Blackwell), and OpenBMB/VoxCPM#269 (cudagraph race on RTX 5090).Open Questions for Reviewers
hash_to_block_id(e.g. LRU capped at N entries) for deployments with very high request volumes?lora_runtimestate, scheduler callbacks)?