Mooncake EPD: Multimodal and Agent-State Disaggregation#2836
Mooncake EPD: Multimodal and Agent-State Disaggregation#2836Pinoeer-kingxi wants to merge 5 commits into
Conversation
Keep registered GPU buffers and Store lifecycle semantics suitable for repeated Encoder/Prefill/Decode handoff while preserving existing ownership rules. Constraint: EPD hot paths must reuse Mooncake transport and Store APIs without introducing a new transport dependency. Rejected: Copying through transient host buffers | it adds registration churn and prevents direct peer-buffer reuse. Confidence: high Scope-risk: moderate Directive: Preserve borrowed-registration ownership and same-device CUDA stream ordering. Tested: Mooncake Store API 72 passed; EPD regression 371 passed, 1 skipped; compileall and diff checks passed. Not-tested: RDMA hardware was unavailable on the evaluation host.
Introduce Encoder-Prefill-Decode state, direct FeatureHandles, layered KV handoff, workflow ownership, Agent cloning, Copy-on-Write, A2A transitions, and scheduling/backpressure as integrated Mooncake serving semantics. Constraint: vLLM integration must use real save/load and multimodal processor paths while remaining opt-in for ordinary Mooncake processes. Rejected: Proxy-only metadata simulation | it cannot skip Vision Encoder work or preserve paged-KV ownership in the serving hot path. Confidence: high Scope-risk: broad Directive: Keep sitecustomize activation explicit and maintain prepare/commit/rollback/release lifecycle invariants. Tested: Full EPD regression 371 passed, 1 skipped with real model and dataset environment; runtime boundary subset 52 passed. Not-tested: Multi-host RDMA and long-duration production failure injection were not available in this environment.
Add portable dataset builders, real vLLM serving runners, baseline and ablation execution, artifact gates, and focused regression coverage for transfer, cache, state, scheduler, and lifecycle semantics. Constraint: Performance claims must come from real services and carry resource, output, fallback, and transfer evidence. Rejected: Summarizing pre-existing artifacts without executing workloads | it cannot prove the current code exercised EPD hot paths. Confidence: high Scope-risk: broad Directive: Keep benchmark defaults repository-relative and require explicit environment variables for external model, dataset, and runtime locations. Tested: Full EPD regression 371 passed, 1 skipped; all public runner --help entry points and Python compile checks passed. Not-tested: Equal-resource 4-GPU baseline and multi-host RDMA benchmark remain outside the recorded matrix.
Document one cohesive Mooncake EPD integration PR covering task completion, architecture, portable operation, evaluation scope, benchmark results, and subsystem-oriented review guidance with a compact digest-backed evidence record. Constraint: Public material must exclude machine-local paths, generated configuration, internal engineering logs, and large raw artifacts. Rejected: Publishing internal coding plans and temporary serving logs | they obscure the contribution and expose non-portable environment details. Confidence: high Scope-risk: narrow Directive: Keep the main EPD contract in one PR, tie performance language to benchmark_summary.json, and separate scale-out capacity from equal-resource efficiency. Tested: Relative links, English-only public docs, Bash fence syntax, JSON validity, CLI help, compileall, and git diff checks passed. Not-tested: Mermaid was manually validated; no browser-side Mermaid renderer was installed.
There was a problem hiding this comment.
Code Review
This pull request introduces a sophisticated Encoder-Prefill-Decode (EPD) disaggregation framework for multimodal LLMs and Agent workflows. Key features include an event-driven multimodal store with asynchronous prefetch, a three-tier KV reuse pipeline, and a distributed KV directory for cross-shard state management. My review identified several critical issues, including a memory leak in the MMStore handle management, a race condition in the page manager's lease mechanism, and potential runtime crashes due to missing defensive checks in state advancement and configuration loading. I have provided actionable suggestions to address these stability and performance concerns.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| self.queue_put_timeout_s = max(0.0, float(queue_put_timeout_s)) | ||
| self.inline_fallback_on_queue_full = ( |
There was a problem hiding this comment.
Storing all historical MMStoreHandle objects in self._handles without any eviction or cleanup mechanism introduces a severe memory leak on long-running servers. Since the caller receives the MMStoreHandle directly and can wait on it asynchronously, self._handles is not needed for coordination. We can completely eliminate self._handles by storing the MMStoreHandle objects directly in self._waiters_by_event instead of string UUIDs, allowing them to be garbage collected naturally once resolved.
| self.queue_put_timeout_s = max(0.0, float(queue_put_timeout_s)) | |
| self.inline_fallback_on_queue_full = ( | |
| self._waiters_by_event: Dict[str, List[MMStoreHandle]] = {} |
| if new_refcount <= 0: | ||
| del self._pages[physical_id] | ||
| self.kv_directory.release_physical(gid) | ||
| return True |
There was a problem hiding this comment.
In decref, when new_refcount <= 0, the physical page is deleted from self._pages immediately, even if lease_count > 0. This breaks the lease mechanism: leased pages are meant to be kept alive during asynchronous offloading or handoff. If the physical tensor is deleted from self._pages while leased, any concurrent read/write or restore operation on that page will fail with a KeyError. The physical page should only be removed from self._pages when the directory record is fully dropped (i.e., both refcount and lease_count are 0, and physical_released is True).
| if new_refcount <= 0: | |
| del self._pages[physical_id] | |
| self.kv_directory.release_physical(gid) | |
| return True | |
| if new_refcount <= 0: | |
| self.kv_directory.release_physical(gid) | |
| if self.kv_directory.get_record(gid) is None: | |
| self._pages.pop(physical_id, None) | |
| return True | |
| return False |
| value = await request.read() | ||
| if value is None: |
There was a problem hiding this comment.
In handle_put_bytes, request.read() returns a bytes object (or b'' if empty), never None. Therefore, the check value is None will always evaluate to False, failing to catch empty request bodies. Using if not value: is more robust as it handles both None and empty bytes (b'').
| value = await request.read() | |
| if value is None: | |
| value = await request.read() | |
| if not value: |
| def advance( | ||
| self, | ||
| new_token_ids: Sequence[int], | ||
| new_pixel_values: Optional[torch.Tensor] = None, | ||
| new_image_grid_thw: Optional[torch.Tensor] = None, | ||
| new_image_hashes: Optional[Sequence[str]] = None, | ||
| divergence_threshold: float = 0.6, | ||
| ) -> WorkflowStep: |
There was a problem hiding this comment.
The advance method relies heavily on self.sl (the StateLayer instance) to register and manage states. However, state_layer is optional in __init__ and defaults to None. If self.sl is None, calling advance will result in an unhandled AttributeError at line 260. Adding a defensive guard at the beginning of advance to raise a clear ValueError when self.sl is None will prevent unexpected crashes.
| def advance( | |
| self, | |
| new_token_ids: Sequence[int], | |
| new_pixel_values: Optional[torch.Tensor] = None, | |
| new_image_grid_thw: Optional[torch.Tensor] = None, | |
| new_image_hashes: Optional[Sequence[str]] = None, | |
| divergence_threshold: float = 0.6, | |
| ) -> WorkflowStep: | |
| def advance( | |
| self, | |
| new_token_ids: Sequence[int], | |
| new_pixel_values: Optional[torch.Tensor] = None, | |
| new_image_grid_thw: Optional[torch.Tensor] = None, | |
| new_image_hashes: Optional[Sequence[str]] = None, | |
| divergence_threshold: float = 0.6, | |
| ) -> WorkflowStep: | |
| """Run the next step, reusing KV from the predecessor where possible.""" | |
| if self.sl is None: | |
| raise ValueError("state_layer must be provided to advance the workflow") |
| relay_refs = self._apply_tier3_fallback( | ||
| relay_refs=relay_refs, | ||
| prev_refs=prev_refs, | ||
| tier3_candidates=tier3_candidates, | ||
| stats=stats, | ||
| ) |
There was a problem hiding this comment.
The tier-3 fallback similarity evaluation (_apply_tier3_fallback) is an optimization rather than a strict correctness dependency. If any unexpected error occurs during the tensor slicing or similarity computation (e.g., shape mismatches or out-of-bounds indices), it is safer to wrap this call in a try...except block to log a warning and fall back gracefully to the unoptimized relay_refs instead of crashing the entire serving request.
| relay_refs = self._apply_tier3_fallback( | |
| relay_refs=relay_refs, | |
| prev_refs=prev_refs, | |
| tier3_candidates=tier3_candidates, | |
| stats=stats, | |
| ) | |
| # -- Tier 3: attention-similarity fallback on recomputed reusable pages --- | |
| try: | |
| relay_refs = self._apply_tier3_fallback( | |
| relay_refs=relay_refs, | |
| prev_refs=prev_refs, | |
| tier3_candidates=tier3_candidates, | |
| stats=stats, | |
| ) | |
| except Exception as exc: | |
| # Fallback gracefully to unoptimized refs on any unexpected error | |
| pass |
| rc = setup(config) | ||
| except TypeError: | ||
| rc = setup( | ||
| config["local_hostname"], | ||
| config["metadata_server"], | ||
| int(config["global_segment_size"]), | ||
| int(config["local_buffer_size"]), | ||
| config["protocol"], | ||
| config.get("device_name", ""), | ||
| config["master_server_address"], | ||
| ) |
There was a problem hiding this comment.
In _python_store, if setup(config) raises a TypeError (indicating an older version of the SDK), the fallback positional setup is called. However, if the JSON config file loaded from config_path is missing any of the expected keys (like local_hostname or protocol), accessing them directly via config["key"] will raise a KeyError and crash. Using config.get("key", default) is much safer and prevents unexpected crashes.
| rc = setup(config) | |
| except TypeError: | |
| rc = setup( | |
| config["local_hostname"], | |
| config["metadata_server"], | |
| int(config["global_segment_size"]), | |
| int(config["local_buffer_size"]), | |
| config["protocol"], | |
| config.get("device_name", ""), | |
| config["master_server_address"], | |
| ) | |
| try: | |
| rc = setup(config) | |
| except TypeError: | |
| rc = setup( | |
| config.get("local_hostname", "127.0.0.1"), | |
| config.get("metadata_server", ""), | |
| int(config.get("global_segment_size", 16 * 1024 * 1024)), | |
| int(config.get("local_buffer_size", 16 * 1024 * 1024)), | |
| config.get("protocol", "tcp"), | |
| config.get("device_name", ""), | |
| config.get("master_server_address", ""), | |
| ) |
| for i in range(n): | ||
| best = 0 | ||
| best_off = -1 | ||
| for j in prev_index.get(int(new_tokens[i]), []): | ||
| k = 0 | ||
| while (i + k < n) and (j + k < len(prev_tokens)) and new_tokens[i + k] == prev_tokens[j + k]: | ||
| k += 1 | ||
| if k > best: | ||
| best = k | ||
| best_off = j | ||
| match_len[i] = best | ||
| match_off[i] = best_off |
There was a problem hiding this comment.
The greedy longest-common-substring matching algorithm in split_segments has a worst-case time complexity of new_tokens and prev_tokens. On long sequences with highly repetitive tokens (e.g., common punctuation, padding, or frequent words), prev_index.get(int(new_tokens[i])) can return a very large list of indices, causing the nested while loop to execute frequently and severely degrade performance. Consider optimizing this matching logic or limiting the search depth to avoid quadratic complexity on long sequences.
Update the public branch and upstream PR references after creating the cohesive integration PR. Constraint: Submission links must resolve to the final public branch and PR. Confidence: high Scope-risk: narrow Tested: Markdown links and repository-relative paths checked. Not-tested: GitHub rendering beyond the PR body preview.
Mooncake EPD: Multimodal and Agent-State Disaggregation
Summary
This integration PR adds Encoder-Prefill-Decode disaggregation to Mooncake
and vLLM. It adds real Vision Hidden State transfer, layered paged-KV handoff,
multimodal hidden-state reuse, Agent KV state cloning, state-aware scheduling,
backpressure, and artifact-gated evaluation.
The implementation is intentionally submitted as one cohesive PR because the
data-plane fast paths, ownership protocol, vLLM hooks, scheduler, and strict
evaluation gates share end-to-end contracts. The commits and sections follow
existing Mooncake ownership boundaries so each subsystem can still be reviewed
independently within the same PR.
Motivation
Multimodal and Agent workloads expose reusable state that is not represented
by a conventional colocated request lifecycle:
modified pages.
Decode, priority, and latency policies.
Mooncake provides the transport and shared-state foundation needed to make
these states first-class serving objects.
Task coverage
Foundation
then paged KV cache moves from Prefill to Decode.
and explicit lifecycle management.
scale-out runner, metrics, and strict direct-path gates.
Advanced
layer grouping, topology affinity, and transport telemetry.
backpressure, and affinity-aware dispatch.
vLLM hidden-state injection, and Vision Encoder skip.
subsystem-oriented review structure within one integration PR.
Architecture
flowchart LR Client --> Proxy[EPD Proxy and Scheduler] Proxy --> Encoder[Vision Encoder] Encoder -->|FeatureHandle and peer buffer| Prefill[vLLM Prefill] Prefill -->|Grouped layered KV| Decode[vLLM Decode] Decode --> Client Proxy <--> Registry[Workflow Registry and WAL] Proxy <--> Directory[Owner-sharded KV Directory] Registry <--> Agent[Agent Clone and CoW] Encoder <--> MMCache[MM Hidden State Cache]See
DESIGN.mdfor interfaces,lifecycles, data flow, and tradeoffs.
Main changes
Mooncake Transfer Engine
Encoder-to-Prefill FeatureHandle
bounded persistent cache, and explicit release.
intermediates, and
grid_thw.epd-direct://materialization and precomputedimage_embedsinjection.Prefill-to-Decode layered KV
path.
strict transfer counters.
Agent state and scheduling
Benchmarks and documentation
evaluation-matrix runners.
resource scope, output evidence, and lifecycle cleanup.
README.md,DESIGN.md,EVALUATION.md, submission material,and a compact evidence snapshot.
Compatibility and security
MOONCAKE_EPD_ENABLE_VLLM_PATCHES=1.X-Mooncake-EPD-Token.explicit launcher and connector configuration.
nvlink_intraare covered by public measurements. SHM andRDMA remain selectable through the transport policy for compatible hosts.
Validation
From the Mooncake repository root:
Benchmark summary
Real multimodal EPD
Qwen3-VL-8B-Instruct, 1 Encoder + 1 Prefill + 2 Decode GPUs, 8 warmups,
16 measured requests, concurrency 8:
Scale-out capacity
The recorded comparison uses four GPUs for 2P2D EPD and one GPU for the
colocated baseline. It demonstrates scale-out capacity, not equal-resource
efficiency.
Agent State Cloning
for deep copy.
Full methodology and interpretation are in
EVALUATION.md. The compact evidencerecord is
artifacts/2026-07-10/benchmark_summary.json.Review guide
This is the primary integration PR. Review can proceed by subsystem without
splitting the end-to-end contract:
and telemetry.
backpressure.
Small follow-up PRs may extract narrowly scoped transport or framework adapters
after the integrated interfaces are agreed, without changing the public EPD
state and lifecycle semantics established here.
Checklist