fix: roll back partial allocation when alloc_page() fails in KVCacheManager.alloc() - #430
Conversation
6da7849 to
09a0b39
Compare
KVCacheManager._alloc() consumes reserved blocks and blocks from existing pages before requesting new physical pages. Under multi-instance pressure, available_size() can report enough logical capacity while another instance drains the shared physical pool, so PageAllocator.alloc_page() raises RuntimeError after the allocation has already been partially served - and those blocks were leaked. Catch the failure, return page blocks through the regular free() path (the lock is re-entrant), prepend reserved blocks back onto the ledger, and return None so the caller's existing allocation-miss handling applies. Fixes ovg-project#364
09a0b39 to
72651c9
Compare
|
LGTM, thanks for the contribution @rishabhsinha17 ! #365 fixes the same issue with a different approach, so these two can't both land. I'm inclined to take this one, but I'd like @shipiyouniao to check before I merge. Goal. A rollback should leave the manager in the state it was in immediately before the
Case B matters most to me: Case C is the one your If that holds, free() only sees if page.empty(), but the rule we want is "release iff the page did not exist before the call". Since a pre-existing page always has another holder's block still out (never empty) while a page created by this call has none (always empty). So no separate record of which pages the call created is needed, and that equivalence breaks only in case C, which release_empty_pages=False is itself the only code path that can produce. What I'd like @shipiyouniao to check: is there a path I've missed that leaves a fully-free page in |
| # straddle the page boundary. Park it in full_pages so it's | ||
| # not re-handed-out but stays lookupable by free(). | ||
| if page.num_free_blocks() == 0: | ||
| try: |
There was a problem hiding this comment.
Please narrow this RuntimeError handler to the page_allocator.alloc_page() call that this recovery path is designed for. As written, it also catches InternalPage.alloc()'s "Not enough free blocks" invariant failure. By then _pick_avail_page() may already have removed the current page from avail_pages, while that page's blocks are not yet in ret_index, so _rollback_partial_alloc() cannot restore the page and returning None would leave the manager running with lost state. That invariant failure should remain fail-loud; a regression test where page.alloc() raises would pin down the distinction.
|
I checked the
Therefore case C is not reachable through the manager API. For a failed allocation, blocks from a pre-existing page restore that page to its prior partially occupied state, while a page created by the failed call becomes empty and is returned to the shared pool. Restoring consumed reservation entries separately is also the correct contract. I ran the new rollback tests together with the related reservation-order and page-selection tests: 16 passed. I left one inline request to narrow the caught |
The try wrapped the whole page loop, so it also caught InternalPage.alloc()'s "Not enough free blocks in page" invariant failure. That one is not recoverable here: _pick_avail_page() has already removed the page from avail_pages and its blocks have not reached ret_index, so _rollback_partial_alloc() cannot restore it. alloc() would return None -- an ordinary allocation miss to the caller -- while the manager silently lost a page. Wrap only alloc_page()/page.init(), the call this recovery path exists for. Everything else in the loop stays fail-loud. Reported by @shipiyouniao in review.
Bring in the CPU test manifests so the new rollback test can be classified.
The manifests landed with ovg-project#403 after this branch was opened, so the new test was unclassified and the CPU CI gate failed. It stubs kvcached.vmm_ops with pure-Python fakes and needs no device, so it belongs in cpu.txt. run_cpu_tests.sh: 120 passed on Python 3.9, 3.11 (torch 2.8 and 2.13).
The rollback handler added in ovg-project#430 was narrowed to alloc_page() during review so that InternalPage.alloc()'s "Not enough free blocks" invariant failure stays fail-loud: by the time page.alloc() runs, _pick_avail_page() may already have removed the page from avail_pages while its blocks are not yet in ret_index, so rollback could not restore it. The review asked for a regression test where page.alloc() raises to pin that distinction; this adds one for both call sites (a page picked from avail_pages and a freshly allocated page), asserting the error propagates instead of being downgraded to an allocation miss.
Summary
Roll back partial allocations when
PageAllocator.alloc_page()fails mid-alloc, and return an allocation miss instead of leaking blocks.Fixes #364.
Root cause
KVCacheManager._alloc()consumes reserved blocks and blocks from existing pages before it needs a new physical page. Under multi-instance pressure,available_size()can report enough logical capacity while another instance drains the shared physical pool, soalloc_page()raisesRuntimeErrorafter the allocation has already been partially served. Those blocks were removed fromreserved_blocksand page free lists with no owner — leaked.Changes
On
RuntimeErrorfrom the page-consuming loop:free()path — safe to call re-entrantly, since_lockis athreading.RLock/NoOpLockandtry_to_reserve()already nests synchronized calls the same way. This also releases a page back to the shared physical pool if the rollback empties it, which is exactly the resource the competing instance is starved ofreserved_blocks, so atry_to_reserve()caller does not silently lose its reservationalloc()returnsNone, so the caller's existing allocation-miss handling appliesThe success path is unchanged: the loop body is identical, only indented into the
try.Validation
kv_cache_manager.pyhard-imports the compiledkvcached.vmm_opsextension, so the addedtests/test_alloc_rollback.pystubs it with pure-PythonFakePage/FakePageAllocatorinsys.modules(only when the real extension is unavailable) and constructs the manager without__init__— the same approach as the regression test merged in #407. The fake allocator reports ample capacity but failsalloc_page()after N pages, reproducing the race in the issue. Covered: clean miss with no partial state, page blocks rolled back and immediately reusable, reserved blocks restored, mixed reserved + page blocks with the emptied page returned to the physical pool, allocation succeeding after the pool recovers, and a success-path regression guard.The tests fail on the unfixed code (5 of 6; the passing one is the success-path guard):
The full CPU-only suite passes with the stub in place.
ruff check,isort --check-only, and the mypy 3.9–3.13 matrix pass. I have not run the GPU-backedtests/test_kvcache_manager.py, as this machine has no CUDA device.Related: #365 also addresses this issue with a different rollback strategy (it keeps emptied pages held by the instance and does not restore the reservation ledger; this PR releases emptied pages to the shared pool and restores reservations).