Persistent local-disk tier and DCP support for the in-process v1 connector - #6
Open
chriswritescode-dev wants to merge 2 commits into
Conversation
LocalDiskBackend keeps its index only in memory: __init__ starts with an empty
dict and current_cache_size == 0 and never enumerates its directory. Chunks
written by earlier server lifetimes are therefore unreachable and uncounted, so
the disk tier loses its whole contents on every restart and max_local_disk_size
degrades into a per-process allowance (measured on a 200 GiB cache: 522 GB on
disk, 345.7 GB of it invisible to the running process).
disk_index_rebuild adopts the chunks already in the directory at startup.
Adoption is refused unless the filename parses back to the identical canonical
path, the model name matches this run, chunk_size matches the config and the
file size matches the byte layout recorded in a sidecar, so another model's
directory contributes nothing instead of producing a false hit. Chunks beyond
the cap are deleted newest-kept-first, which enforces max_local_disk_size
against real on-disk bytes for the first time, and registration order follows
mtime so LRU recency approximates what the cache had before the restart.
Three accounting and correctness fixes in the same file:
* re-storing an already-indexed key overwrites one deterministic path and
consumes no new bytes, but current_cache_size and usage grew on every put.
A prefix-heavy workload re-saves the same chunks constantly, so the counter
climbed without bound until it crossed max_cache_size and evicted the whole
real cache (observed as an eviction storm with 3.3 GB on disk against a
200 GiB cap).
* a vanished chunk file could be served as a hit. read_file caught
FileNotFoundError, popped the index entry and returned, after which
load_bytes_from_disk did self.dict[key] and raised KeyError; without that
lookup it would have returned the never-written staging buffer as cached
KV. read_file now reports success so both single-chunk callers treat a
missing file as a miss, and the async prefetch path names the vanished
path. batched_get_blocking already guarded this correctly.
* eviction can unlink a file whose put task is still in flight, after which
remove() raised FileNotFoundError out of wait_for_save and killed the
engine. The index is the accounting source of truth, so the entry is
dropped either way.
The sidecar is written by the storing path (see the following commit); until
something writes it, rebuild_disk_index adopts nothing and behaviour is
unchanged.
Signed-off-by: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Under decode context parallelism the MLA latent KV is sharded across the DCP ranks, so the default save_only_first_rank path stores rank 0's shard alone and scatters it back to every rank: with DCP > 1 the in-process connector stores and restores nothing usable. The MP-server path was fixed in LMCache#1 (vllm_multi_process_adapter plus kv_cache_groups); vllm_v1_adapter carries no DCP handling at all. dcp_gather gathers each rank's shard before a store and scatters it back on load, and ReqMeta's block-capacity check now compares the rank-local token count rather than the global one. Every hook is a no-op when DCP is inactive. Only the strided layout is supported (dcp_kv_cache_interleave_size == 1); other interleave sizes keep the previous behaviour. The store path also records the chunk byte layout once per process, which is what allows the local-disk tier to adopt its directory after a restart. Signed-off-by: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes needed to run GLM-5.2 MLA prefix offload through the in-process
LMCacheConnectorV1with DCP, both running in production on 4x RTX PRO 6000 (GLM-5.2-EXL3-TR3-3.0bpw, TP4/DCP4/MTP3, fp8 KV, L1 16 GiB DRAM + L2 200 GiB NVMe) on top of0.5.2+glm52dcp.3.1. DCP for the in-process v1 connector
#1 fixed MLA+DCP for the MP-server path (
vllm_multi_process_adapter.py,kv_cache_groups.py).vllm_v1_adapter.pyhas no DCP handling, so withdecode_context_parallel_size > 1the defaultsave_only_first_rankpath stores rank 0's shard alone and scatters it back to every rank: the in-process connector saves and restores nothing usable.dcp_gather.pygathers each rank's latent KV shard before a store and scatters it back on load, andReqMeta.from_request_trackernow checks block capacity against the rank-local token count instead of the global one. Every hook returns immediately when DCP is inactive, so the non-DCP path is untouched.Limitation: only the strided layout is handled, i.e.
--dcp-kv-cache-interleave-size 1. Larger interleave sizes fall through to the previous behaviour rather than storing something wrong.2. The local-disk tier survives a restart
LocalDiskBackendkeeps its index only in memory and never enumerates its directory, so every restart both loses the whole L2 tier and leaks it:max_local_disk_sizebecomes a per-process allowance. Measured here on a 200 GiB cache: 522 GB on disk, 345.7 GB of it invisible to the running process.disk_index_rebuild.pyadopts the existing chunks at startup, keyed off the deterministic_key_to_pathnaming plus a byte-layout sidecar. Adoption requires the filename to parse back to the identical canonical path, the model name to match this run,chunk_sizeto match the config, and the file size to match the recorded layout, so a directory holding another model's chunks contributes nothing instead of producing a false hit. Over-cap chunks are deleted newest-kept-first, which enforces the cap against real on-disk bytes for the first time, and adoption order follows mtime so LRU recency roughly survives the restart.Same file, three fixes the persistent tier made unavoidable:
current_cache_sizeandusagegrew on every put. A prefix-heavy workload re-saves the same chunks constantly, so the counter climbed until it crossedmax_cache_sizeand evicted the entire real cache (observed as an eviction storm with 3.3 GB on disk against a 200 GiB cap).read_filecaughtFileNotFoundError, popped the index entry and returned, after whichload_bytes_from_diskdidself.dict[key]and raisedKeyError; without that lookup it would have handed back the never-written staging buffer as cached KV.read_filenow reports success, both single-chunk callers treat a missing file as a miss, and the async prefetch path raises with the path instead of a bareKeyError.batched_get_blockingalready guarded this correctly.remove()on an already-unlinked file. Eviction can unlink a file whose put task is still in flight; the next eviction raisedFileNotFoundErrorout ofwait_for_saveand killed the engine. The index is the accounting source of truth, so the entry is dropped either way.Tests
tests/v1/test_dcp_gather.py(10 tests, CPU-only, notorch.distributed) covers the interleave/deinterleave round trip against a brute-forced global order, heterogeneous layer widths and dtypes, the DCP4 packed round trip, rank-local token counts, and rank-symmetric collective counts in the store path.tests/v1/storage_backend/test_disk_index_rebuild.py(14 tests) exercises the realinsert_key, the realCacheEngineKeyparser and the real LRU policy: adoption and byte accounting, mtime-ordered LRU,/mangling in model names, rejection of another model's chunks and of wrong-sized files, TP-agnostic key adoption, missing/mismatched/corrupt sidecars, cap enforcement, purge, and a guard that the module only touches attributesLocalDiskBackend.__init__really assigns.tests/v1/storage_backend/test_local_disk_backend.pygainsTestVanishedChunkFile, which uses the existing real-backend fixtures. The two regression cases fail on this branch's parent with exactly theKeyErrordescribed above and pass with the fix; the third is a positive control that a present chunk still loads with its bytes intact._write_keynow delegates to a module-levelregister_chunkhelper the new class shares.Full file: 36 passed.
Notes
dcp_gather.py, so the first commit alone adopts nothing. Happy to move the write intoLocalDiskBackendso the disk tier owns its own layout record and the persistence half becomes independent of the connector, if you prefer that shape.read_file's signature is left unannotated (as upstream) so mypy keeps treating it as untyped; the newboolreturn is documented in its callers instead.