feat(pytorch): support mooncake store - #4880
Conversation
|
|
||
| from lmdeploy.pytorch.backends.selector import init_backend | ||
| from lmdeploy.pytorch.config import BackendConfig, CacheConfig, DistConfig, MiscConfig, ModelConfig, SpecDecodeConfig | ||
| from lmdeploy.pytorch.kv_connector.base import KVConnectorMetadata, KVConnectorOutput |
There was a problem hiding this comment.
MPExecutor is deprecated, we don't need to update it.
|
|
||
| def _kv_connector_poll_acknowledgements(self) -> tuple[set[int], set[int]]: | ||
| """Return save/load completions to acknowledge in the next poll.""" | ||
| sending = set(getattr(self, '_kv_connector_acknowledged_sending', set())) |
There was a problem hiding this comment.
_kv_connector_acknowledged_sending is initialized in the init, use getattr is too defensive here. we can just just self._kv_connector_acknowledged_sending
| connector = getattr(self, 'kv_connector', None) | ||
| if connector is None: | ||
| return | ||
| has_saves = getattr(connector, 'has_pending_step_saves', None) |
There was a problem hiding this comment.
These getattr are too defensive.
There was a problem hiding this comment.
we can use something like
if connector.has_pending_step_saves():
event = torch.cuda.Event()
event.record(self.stream)
connector.submit_saves(save_ready_event=event)right?
There was a problem hiding this comment.
Yes, you are right. This version of the code contains some difficult-to-read and overly protected content, which will be fixed later.
| ), | ||
| ) | ||
| if self.kv_connector is not None: | ||
| self.kv_connector.register_kv_caches(self.cache_engine.connector_kv_caches) |
There was a problem hiding this comment.
Do we support register_kv_caches for spec decoding? Spec decoding use different cache manager for now.
There was a problem hiding this comment.
We can explicit reject connector for specdecoding for this PR and add it in the future.
There was a problem hiding this comment.
OK,spec decoding is not yet supported; this version will explicitly refuse to enable spec decoding.
| checker = getattr(getattr(self, 'executor', None), 'has_pending_kv_connector_ack', None) | ||
| return checker is not None and checker() | ||
|
|
||
| def _has_pending_kv_connector_work(self) -> bool: |
There was a problem hiding this comment.
This function has not been used
|
|
||
| def _has_pending_kv_connector_ack(self) -> bool: | ||
| """Return whether the executor owes workers a sticky-completion ACK.""" | ||
| checker = getattr(getattr(self, 'executor', None), 'has_pending_kv_connector_ack', None) |
There was a problem hiding this comment.
Too many getattr, I think we need a cleanup to remove unnecessary getattr.
| token_lens = None | ||
| if inputs is not None and not inputs.is_decoding: | ||
| ready_token_lens = inputs.history_lengths + inputs.seq_length | ||
| token_lens = tuple(int(token_len) for token_len in ready_token_lens.tolist()) |
There was a problem hiding this comment.
Can we use tuple(ready_token_lens.tolist())?
| 0, target_blocks - int(seq.num_blocks)) | ||
| extra_blocks = max( | ||
| 0, target_required_blocks - required_blocks) | ||
| evict_alloc_size += extra_blocks * seq.block_size |
There was a problem hiding this comment.
Do we have to reserve all blocks for request without remote kv?
| def _admit_resources(self): | ||
| if self.scheduler.block_trie.enabled: | ||
| return self._admit_prefix_cache_resources() | ||
| lookup_result = self._query_external_prefix(None) |
There was a problem hiding this comment.
Is this lookup a "prefix matching"? What if we disable prefix caching?
| int(block_id) | ||
| for block_id in self.block_manager.get_block_table(seq) | ||
| ) | ||
| connector.request_finished(seq, block_ids) |
There was a problem hiding this comment.
The docstring of request_finished:
The boolean is true when the connector temporarily takes ownership of
the blocks for an asynchronous save. In that case the scheduler must
defer freeing them until the request is reported by ``get_finished``.
The optional dictionary is reserved for connector-specific response
metadata.
but the return of request_finished has never been read here.
| self, | ||
| request: 'SchedulerSequence', | ||
| num_computed_tokens: int, | ||
| ) -> tuple[int | None, bool]: |
There was a problem hiding this comment.
Do we need load_async in this interface? None already represents an asynchronous lookup, and the scheduler always enters WAITING_FOR_REMOTE_KVS for every positive hit. A positive result with load_async=False is not actually supported. Could we remove the boolean and define every positive hit as requiring the existing asynchronous load lifecycle?
| # Keep accepting an explicit value while generating vLLM-style | ||
| # keys from the model basename by default. | ||
| model_name = extra_config.get('model_namespace', model_name) | ||
| extra_config.setdefault('model_name', model_name) |
There was a problem hiding this comment.
Is it safe If we launch 2 engine with different config(tp/block_size,....)
| return bool(self.completed_save_ids or self.completed_load_ids or self.failed_load_ids) | ||
|
|
||
| @property | ||
| def finished_sending(self) -> set[RequestId]: |
There was a problem hiding this comment.
is these compatible property necessary?
| def __init__(self, role: KVConnectorRole) -> None: | ||
| if not isinstance(role, KVConnectorRole): | ||
| raise TypeError(f'role must be a KVConnectorRole, got {type(role).__name__}') | ||
| self._role = role |
There was a problem hiding this comment.
How many of the apis are shared between different role? Is it a good idea to split them info different class by role? will we have other role in the future?
| contain hashes already computed for the same request identity and token | ||
| prefix; only newly completed blocks are encoded and hashed. | ||
| """ | ||
| if isinstance(block_size, bool) or not isinstance(block_size, int) or block_size <= 0: |
There was a problem hiding this comment.
is this check necessary?
|
|
||
|
|
||
| def _get_local_hostname() -> str: | ||
| """Resolve a routable local address, including on offline hosts.""" |
There was a problem hiding this comment.
We can add a environment variable in pytorch/envs.py so users can choose to manually set the address. It might be helpful for debug.
|
|
||
| save_requests: tuple[MooncakeStoreSaveRequest, ...] = field(default_factory=tuple) | ||
| load_requests: tuple[MooncakeStoreLoadRequest, ...] = field(default_factory=tuple) | ||
| preempted_save_ids: tuple[int, ...] = field(default_factory=tuple) |
| return timeout_ms | ||
|
|
||
|
|
||
| def _make_zmq_socket( |
There was a problem hiding this comment.
Maybe we can reuse our executor rpc so we don't have to add another ipc here.
I can accept the zmq in this PR, and I think we can optimize it in the future.
Motivation
In production LLM serving, a large fraction of requests share common prompt prefixes — system prompts, few-shot examples, RAG context, multi-turn conversation history, etc. LMDeploy's local prefix caching reuses KV cache within a single engine instance, but it cannot help when:
This PR addresses these gaps by integrating Mooncake's distributed store as a shared KV-cache pool. Computed KV blocks are stored with content-addressable keys (block hashes); before computing a prefill, the engine queries the store and, on a hit, loads the blocks directly via zero-copy RDMA, skipping redundant prefill computation. The capability is orthogonal to PD disaggregation and benefits any deployment topology.
The overall design is referenced from vLLM RFC #38474 and the
vllmimplementation.Deploy
This section shows a complete deployment of GLM-5.2-FP8 with TP=8, Ray as the distributed executor, prefix caching enabled, and Mooncake Store acting as a shared KV-cache pool (
kv_role=kv_both).1. Start the Mooncake master
The master coordinates the distributed store metadata. Start it on a host reachable from every LMDeploy worker:
2. Prepare the Mooncake config
Each LMDeploy worker reads this JSON config. It declares the embedded store mode, the master address, the RDMA devices to use, and the global / local buffer sizes:
{ "mode": "embedded", "metadata_server": "P2PHANDSHAKE", "master_server_address": "127.0.0.1:50051", "global_segment_size": "160GB", "local_buffer_size": "4GB", "protocol": "rdma", "device_name": "mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7", "enable_offload": false }3. Launch LMDeploy with
--kv-transfer-configTest
Verified on GLM-5.2-FP8, PyTorch backend, TP=8, Ray executor, prefix caching enabled, Mooncake
kv_both.1. Precision — GSM8K
2. Performance
2.1 GPU KV cache sufficient (no eviction pressure)
Test method: multi-turn dialogue, SWE_Smith dataset, 8 requests, first turn length 32K, generating 512 tokens per turn, increasing by 4K per turn, for a total of 5 turns.
Conclusion: with sufficient GPU KV cache the connector only saves blocks asynchronously and never loads — overhead is ~0.02%, within measurement noise.
2.2 GPU KV cache insufficient (eviction pressure)
Test method: multi-turn dialogue, SWE_Smith dataset, 32 requests, first turn length 32K, generating 512 tokens per turn, increasing by 4K per turn, for a total of 5 turns.
Conclusion: total throughput reaches 2.03× the OFF baseline (+103.3%). The gain comes from external cache hits on subsequent turns — once the local L1 is evicted, blocks are reloaded from Mooncake instead of being recomputed.
BC-breaking
No backward-compatibility break for existing users:
kv_transfer_configdefaults toNoneand the connector is never imported or built when disabled — existing engine configurations and CLI invocations are unchanged.num_replicate_key_value_headsdefaults to1and is only consumed when a connector is enabled.SchedulerOutputfields,Scheduler/ExecutorBase/BaseModelAgentmethods, andCacheEngine.connector_kv_cachesproperty are additive.RayExecutorandZMQMPEngine, and only whenkv_transfer_configis set.Downstream projects that vendor
lmdeploy.pytorch.paging.scheduler.SchedulerOutputor subclassKVConnectorBasewill see new fields / methods, all of which have defaults or no-op base implementations.Use cases (Optional)