Motivation.
reset_prefix_cache() is explicitly documented as intended for use in RLHF flows (to invalidate prefix caching after weight updates) and for benchmarking. In those flows it is natural to call it between logical rounds / rollout iterations while no requests are active. However, the current implementation only clears the prefix cache hash table; it does not restore the allocation order of free_block_queue. Over many rounds the linked list becomes progressively scrambled, which in turn scatters newly allocated block_table[req, :] entries across the physical KV pool and degrades attention-kernel locality on subsequent prefills. The net effect on long-running sessions is a monotonic prefill slowdown even when no recompilation, no new cache entries, and no KV pressure changes are occurring between rounds.
How the scrambling happens
BlockPool.__init__ initializes free_block_queue as the strictly increasing sequence [1, 2, ..., N-1] (block 0 is the null block).
popleft() takes blocks off the head at allocation time; on request completion, a request's blocks are appended back to the tail in reverse order of the request's own blocks (as described in FreeKVCacheBlockQueue's docstring: "we maintain this order by reversing the block order when free blocks of a request").
- After a handful of alloc/free rounds, the linked-list position of a block is fully decoupled from its
block_id. Future popleft()s then hand out non-contiguous block_ids even when plenty of blocks are free.
Why this matters on the attention side
Downstream, block_table[req_id, logical_block_idx] stores the physical block_id that backs each logical KV block of a request. When allocations produce non-contiguous block_ids, successive logical KV blocks of the same request land on physically distant pages of the KV pool. Attention kernels that gather K/V through the block table (e.g. FlexAttention with paged KV) lose memory coalescing and effectively pay worse L2/TLB hit rates during prefill, where the Q × KV access volume is large. Decode is far less affected because each step only touches a small number of new KV pages.
Why reset_prefix_cache() alone is not enough
BlockPool.reset_prefix_cache() currently does the following and nothing else (confirmed against main at the time of writing):
self.cached_block_hash_to_block = BlockHashToBlockMap()
for block in self.blocks:
block.reset_hash()
# ... metrics reset, kv_event ...
The linked list free_block_queue is untouched. Callers that rely on reset_prefix_cache() between rounds (RLHF/GRPO rollouts, benchmarking drivers) therefore still observe cumulative fragmentation of the allocation order across the lifetime of the LLM instance. There is no existing API to restore a canonical order.
Measured impact
We hit this while running a GRPO-style offline rollout driver (vLLM V1 offline LLM, FlexAttention backend, prefix caching on, chunked prefill off, 2 prompts with rollout.n=4 to share prefixes within each round, reset_prefix_cache() called between rounds). Same 8-request shape every round, same model, no recompiles in the middle of the run. Prefill latency (first_token_ts − scheduled_ts, batch-aggregate) per round:
| Round |
Prefill before |
Prefill after |
| 1 |
3.51 s |
3.56 s |
| 2 |
5.61 s |
2.81 s |
| 3 |
8.00 s |
3.03 s |
| 4 |
13.73 s |
3.31 s |
| 5 |
9.42 s |
2.33 s |
| 6 |
14.36 s |
3.45 s |
Before: ~5.7× prefill slowdown over 6 rounds. After rebuilding free_block_queue in canonical order on each inter-round reset: prefill becomes flat at ~3 s (round-to-round variation is now driven by prompt length, not session age). Decode latency is essentially unchanged by the fix, which is consistent with the locality story above.
Scope of affected users
Any caller that runs a long-lived LLM / AsyncLLM instance with enable_prefix_caching=True and periodically calls reset_prefix_cache() between logical rounds. The two canonical scenarios:
- RLHF / GRPO rollouts.
reset_prefix_cache() is already the recommended way to invalidate the cache after a weight update (#9744, #12284). GRPO and RLOO-style samplers naturally produce repeated rollout rounds on the same engine.
- Benchmarking drivers that reset between trials.
Proposed Change.
Proposed Change
Add a narrowly-scoped way for callers to restore the canonical allocation order of free_block_queue when no requests are active. We see two reasonable shapes and would like maintainer input on which to land.
Option A — New standalone API (single-responsibility)
Add BlockPool.reset_free_block_queue_order() with the same "no live requests" precondition as reset_prefix_cache():
def reset_free_block_queue_order(self) -> bool:
"""Rebuild the free_block_queue linked list in canonical block_id order.
Long-running sessions accumulate fragmentation in the free-block linked
list; subsequent popleft()s produce non-contiguous block_ids and scatter
block_tables across the physical KV pool, hurting attention-kernel
locality. Call this when no requests are active (same precondition as
reset_prefix_cache) to restore the canonical order.
"""
num_used_blocks = self.num_gpu_blocks - self.get_num_free_blocks()
if num_used_blocks != 1: # null block counts as 1
logger.warning(
"Failed to reset free_block_queue order because some "
"blocks (%d) are not freed yet",
num_used_blocks - 1,
)
return False
for block in self.blocks:
block.prev_free_block = None
block.next_free_block = None
self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)
self.free_block_queue.popleft() # skip null block (id=0)
return True
Then plumb it up through the same chain reset_prefix_cache already uses:
BlockPool
└── KVCacheManager.reset_free_block_queue_order()
└── Scheduler.reset_free_block_queue_order() (abstract on SchedulerInterface)
└── EngineCore.reset_free_block_queue_order()
└── EngineCoreClient.{reset_free_block_queue_order,
reset_free_block_queue_order_async}()
└── LLMEngine / AsyncLLM.reset_free_block_queue_order()
└── LLM.reset_free_block_queue_order()
Pros: single responsibility, zero behavioral change to reset_prefix_cache, callers that only want hash invalidation (e.g. hot-swap scenarios that may care about block-id stability) keep current semantics.
Cons: one extra method on the public LLM surface; RLHF/benchmark callers typically want both and must call them in sequence.
Option B — Opt-in flag on reset_prefix_cache
Extend reset_prefix_cache() with a keyword-only parameter, default-off for backward compatibility:
def reset_prefix_cache(
self,
reset_running_requests: bool = False,
reset_connector: bool = False,
reorder_free_queue: bool = False,
) -> bool:
...
When reorder_free_queue=True, after the existing hash-clearing logic, rebuild free_block_queue in canonical order (same body as Option A).
Pros: one call, no extra public API surface; RLHF/benchmark callers flip one flag.
Cons: conflates two distinct concerns on a single entry point; propagating the new kwarg all the way through core_client.call_utility("reset_prefix_cache", ...) (including the async variant) means touching every layer anyway.
Our preference
We implemented Option A in our fork because it keeps reset_prefix_cache semantically unchanged and lets the reorder operation be composed with sleep() / wake_up() / benchmark harnesses independently. We are happy to rework as Option B (or a combination — standalone API plus a flag on reset_prefix_cache that delegates to it) based on maintainer preference.
Preconditions and safety
- The
num_used_blocks != 1 guard is identical to the one reset_prefix_cache already relies on; calling the new API between rollout rounds (where all requests have finished and LLM.generate() has returned) always satisfies it.
- No GPU-side state is touched. The operation only rewrites the Python-side doubly-linked list of
KVCacheBlock objects. Physical KV memory layout is unchanged; the benefit comes from future allocations drawing contiguous block_ids again.
- No interaction with
cached_block_hash_to_block: Option A explicitly does not clear hashes. Callers that want both should call reset_prefix_cache() first (so there are no live hashes that still reference the about-to-be-reordered list), then the new API.
- Compatible with both
Scheduler and AsyncScheduler (the latter inherits).
- Works for both
InprocExecutor (offline LLM) and the sync/async multi-process core clients via call_utility("reset_free_block_queue_order") / call_utility_async(...).
Testing plan
- Unit test against
BlockPool in isolation: allocate and free in scrambled patterns across many simulated requests, assert that after reset_free_block_queue_order() a sequence of popleft()s returns strictly increasing block_ids starting from 1.
- Regression test that existing
reset_prefix_cache semantics are untouched when the new API is not called.
- An end-to-end benchmark showing the flat-vs-monotonic prefill curve (we can contribute our repro, stripped of model-specific pieces).
Feedback Period.
Two weeks, or until maintainers indicate a preferred API shape.
CC List.
@WoosukKwon @zhuohan123 @youkaichao @robertgshaw2-redhat @comaniac @simon
Any Other Things.
Happy to open the PR directly against main once an API shape is agreed on. A reference implementation (Option A) is already working in our downstream fork with the plumbing path listed above.
Before submitting a new issue...
Motivation.
reset_prefix_cache()is explicitly documented as intended for use in RLHF flows (to invalidate prefix caching after weight updates) and for benchmarking. In those flows it is natural to call it between logical rounds / rollout iterations while no requests are active. However, the current implementation only clears the prefix cache hash table; it does not restore the allocation order offree_block_queue. Over many rounds the linked list becomes progressively scrambled, which in turn scatters newly allocatedblock_table[req, :]entries across the physical KV pool and degrades attention-kernel locality on subsequent prefills. The net effect on long-running sessions is a monotonic prefill slowdown even when no recompilation, no new cache entries, and no KV pressure changes are occurring between rounds.How the scrambling happens
BlockPool.__init__initializesfree_block_queueas the strictly increasing sequence[1, 2, ..., N-1](block 0 is the null block).popleft()takes blocks off the head at allocation time; on request completion, a request's blocks are appended back to the tail in reverse order of the request's own blocks (as described inFreeKVCacheBlockQueue's docstring: "we maintain this order by reversing the block order when free blocks of a request").block_id. Futurepopleft()s then hand out non-contiguousblock_ids even when plenty of blocks are free.Why this matters on the attention side
Downstream,
block_table[req_id, logical_block_idx]stores the physicalblock_idthat backs each logical KV block of a request. When allocations produce non-contiguousblock_ids, successive logical KV blocks of the same request land on physically distant pages of the KV pool. Attention kernels that gather K/V through the block table (e.g. FlexAttention with paged KV) lose memory coalescing and effectively pay worse L2/TLB hit rates during prefill, where the Q × KV access volume is large. Decode is far less affected because each step only touches a small number of new KV pages.Why
reset_prefix_cache()alone is not enoughBlockPool.reset_prefix_cache()currently does the following and nothing else (confirmed againstmainat the time of writing):The linked list
free_block_queueis untouched. Callers that rely onreset_prefix_cache()between rounds (RLHF/GRPO rollouts, benchmarking drivers) therefore still observe cumulative fragmentation of the allocation order across the lifetime of theLLMinstance. There is no existing API to restore a canonical order.Measured impact
We hit this while running a GRPO-style offline rollout driver (vLLM V1 offline
LLM, FlexAttention backend, prefix caching on, chunked prefill off, 2 prompts with rollout.n=4 to share prefixes within each round,reset_prefix_cache()called between rounds). Same 8-request shape every round, same model, no recompiles in the middle of the run. Prefill latency (first_token_ts − scheduled_ts, batch-aggregate) per round:Before: ~5.7× prefill slowdown over 6 rounds. After rebuilding
free_block_queuein canonical order on each inter-round reset: prefill becomes flat at ~3 s (round-to-round variation is now driven by prompt length, not session age). Decode latency is essentially unchanged by the fix, which is consistent with the locality story above.Scope of affected users
Any caller that runs a long-lived
LLM/AsyncLLMinstance withenable_prefix_caching=Trueand periodically callsreset_prefix_cache()between logical rounds. The two canonical scenarios:reset_prefix_cache()is already the recommended way to invalidate the cache after a weight update (#9744, #12284). GRPO and RLOO-style samplers naturally produce repeated rollout rounds on the same engine.Proposed Change.
Proposed Change
Add a narrowly-scoped way for callers to restore the canonical allocation order of
free_block_queuewhen no requests are active. We see two reasonable shapes and would like maintainer input on which to land.Option A — New standalone API (single-responsibility)
Add
BlockPool.reset_free_block_queue_order()with the same "no live requests" precondition asreset_prefix_cache():Then plumb it up through the same chain
reset_prefix_cachealready uses:Pros: single responsibility, zero behavioral change to
reset_prefix_cache, callers that only want hash invalidation (e.g. hot-swap scenarios that may care about block-id stability) keep current semantics.Cons: one extra method on the public
LLMsurface; RLHF/benchmark callers typically want both and must call them in sequence.Option B — Opt-in flag on
reset_prefix_cacheExtend
reset_prefix_cache()with a keyword-only parameter, default-off for backward compatibility:When
reorder_free_queue=True, after the existing hash-clearing logic, rebuildfree_block_queuein canonical order (same body as Option A).Pros: one call, no extra public API surface; RLHF/benchmark callers flip one flag.
Cons: conflates two distinct concerns on a single entry point; propagating the new kwarg all the way through
core_client.call_utility("reset_prefix_cache", ...)(including theasyncvariant) means touching every layer anyway.Our preference
We implemented Option A in our fork because it keeps
reset_prefix_cachesemantically unchanged and lets the reorder operation be composed withsleep()/wake_up()/ benchmark harnesses independently. We are happy to rework as Option B (or a combination — standalone API plus a flag onreset_prefix_cachethat delegates to it) based on maintainer preference.Preconditions and safety
num_used_blocks != 1guard is identical to the onereset_prefix_cachealready relies on; calling the new API between rollout rounds (where all requests have finished andLLM.generate()has returned) always satisfies it.KVCacheBlockobjects. Physical KV memory layout is unchanged; the benefit comes from future allocations drawing contiguousblock_ids again.cached_block_hash_to_block: Option A explicitly does not clear hashes. Callers that want both should callreset_prefix_cache()first (so there are no live hashes that still reference the about-to-be-reordered list), then the new API.SchedulerandAsyncScheduler(the latter inherits).InprocExecutor(offlineLLM) and the sync/async multi-process core clients viacall_utility("reset_free_block_queue_order")/call_utility_async(...).Testing plan
BlockPoolin isolation: allocate and free in scrambled patterns across many simulated requests, assert that afterreset_free_block_queue_order()a sequence ofpopleft()s returns strictly increasingblock_ids starting from 1.reset_prefix_cachesemantics are untouched when the new API is not called.Feedback Period.
Two weeks, or until maintainers indicate a preferred API shape.
CC List.
@WoosukKwon @zhuohan123 @youkaichao @robertgshaw2-redhat @comaniac @simon
Any Other Things.
Happy to open the PR directly against
mainonce an API shape is agreed on. A reference implementation (Option A) is already working in our downstream fork with the plumbing path listed above.Before submitting a new issue...