Skip to content

[Feature][KV Connector] Add UMBP distributed mode - #59276

Draft
YukioZzz wants to merge 9 commits into
vllm-project:mainfrom
YukioZzz:yichaozhu/umbp-distributed
Draft

YukioZzz wants to merge 9 commits into
vllm-project:mainfrom
YukioZzz:yichaozhu/umbp-distributed

Conversation

@YukioZzz

@YukioZzz YukioZzz commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Adds the distributed mode of the UMBP KV connector (RFC #57187): a umbp_master
indexes KV objects cluster-wide, and every vLLM worker is a pool node that serves its
host-DRAM slice to peers over MORI-IO RDMA. Any engine attached to the master reuses
KV stored by any other, on the same host or another one.

Stacked on the standalone PR (#59275) and #58568, rebased onto #58568's current
head 37832fa99. Please review only the last two commits:

  • [UMBP] Add distributed mode backed by a cluster-wide master - runtime, option
    validation in the adapter, docs (docs/features/umbp_distributed.md), unit and
    real-RDMA tests.
  • [UMBP] Treat objects that just failed to load as misses for a while - workers
    report failed load keys, and the scheduler treats them as misses for
    load_failure_quarantine_ms (30 s in distributed mode, off elsewhere). Without it
    a crashed node's objects caused 691 load-fail/reschedule cycles for one request
    until the master expired the node. Loads refused without being attempted are not
    quarantined.

The rebase dropped [UMBP] Keep a timed-out store's source pinned until MORI returns: #58568's new worker already keeps stores pending across steps under a
store event, and the runtime-side stall handling moved into the standalone PR's
shared-pool worker handle, which this mode reuses.

Design

  • Scheduler lookups use a MORI client with no pool, no I/O engine and no peer service,
    so the scheduler allocates nothing and the master never places objects there.
  • Each worker registers under a unique identity (host, GPU, pid, random suffix) and
    binds peer_service_port + physical GPU index.
  • Lookups are bounded by lookup_timeout_ms: MORI's lookup RPC has no deadline, and a
    hung master would otherwise stall the scheduler.
  • Resetting one engine does not clear the cluster pool.
  • Failure of the master, a peer, or a transfer degrades to recomputation
    (kv_load_failure_policy=recompute).

Test Plan

  • Unit: fake master/node cluster, tests/v1/kv_connector/unit/test_umbp_distributed.py.
  • Integration with a real umbp_master and two RDMA pool nodes on two GPUs:
    tests/v1/kv_connector/umbp_integration/test_mori_distributed_gpu.py (byte-exact
    cross-node restore, node exit, master loss / restart / SIGSTOP). Peer ports are
    now drawn below the ephemeral range so concurrent suites cannot take them.
  • Multi-host MI355X e2e (Pollara RDMA) and Kimi-Linear GSM8K, see below.

Test Result

  • This head: unit 281 passed on this branch (282 on the full stack); integration 19
    passed, 1 skipped (needs four GPUs).
  • Measured on the pre-rebase head (same runtime code), not yet repeated on this head:
    • Two-host e2e: 37/37 (Qwen3-0.6B); greedy outputs identical to cold computation;
      99.2% of prompt tokens restored externally on average.
    • Three engine hosts plus a master host, Kimi-Linear GSM8K (1319 questions, conc
      64): cold on host 1 89.23%, RDMA hits from host 2 88.78%, host 3 reading host 2's
      replicas after host 1 is stopped 88.93%, a fresh host-1 engine reading host 3's
      copies after host 2 is killed 88.86%; zero failed loads.

Known limits

  • On NICs with small translation caches (AMD Pollara), host memory beyond a few GiB
    registers only when backed by reserved 2 MiB hugepages
    (dram_use_hugepages=true plus vm.nr_hugepages); MORI aborts the worker when a
    registration fails. The same limit blocks Mooncake's segment registration.
  • MORI fixes proposed separately: master RPC deadlines, re-registration of expired
    nodes, reconnect backoff.

Draft until the stack below it lands.

@mergify

mergify Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--59276.org.readthedocs.build/en/59276/

@mergify mergify Bot added documentation Improvements or additions to documentation kv-connector labels Sep 29, 2026
@YukioZzz

Copy link
Copy Markdown
Contributor Author

Multi-host validation (MI355X, AMD Pollara RDMA): three engine hosts and the
umbp_master on a fourth host. Kimi-Linear-48B-A3B-Instruct, GSM8K 25-shot, 1319
questions, conc 64, 512 MiB pool per worker. Each phase can only hit through another
host:

Phase Hits come from Accuracy External hit tokens Same answer as baseline
baseline (no connector) - 88.86% - -
host 1 cold - 89.23% 0 1265/1319
host 2 replay host 1 over RDMA 88.78% 245760 1252/1319
host 1 stopped; host 3 replay host 2's copies only 88.93% 245760 1261/1319
host 2 kill -9; fresh engine on host 1 host 3's copies 88.86% 245760 1257/1319

Replicas made by readers (cache_remote_fetches) keep objects served after their
writer leaves; every phase stays within the cold run's spread. No tracebacks besides
shutdown, no RDMA registration failures.

charxwu and others added 5 commits September 30, 2026 15:46
Add a shared KV connector and an embedded runtime for single-engine,
single-node offloading to MORI-managed host DRAM.

Shared connector / base:
- Register UMBPStoreConnector and separate scheduler, worker, transfer
  metadata, runtime contract, and statistics responsibilities.
- Plan hash-keyed, cache-group-scoped objects with rank-local identities
  and logical completeness checks across participating workers.
- Integrate external lookup, asynchronous load completion, ranged and
  partial-prefix transfer plans, and hybrid cache hit-window semantics.
- Implement eager stores and free-queue/store-event-driven lazy offload,
  retaining GPU block ownership until transfer completion.
- Track request preemption, cancellation, partial failures, cache resets,
  eviction events, and transfer metrics through the connector lifecycle.

Embedded runtime:
- Register GPU KV buffers and use MORI ranged pointer APIs for stores
  to host DRAM and restores to GPU memory.
- Keep DRAM allocation, residency, publication, capacity, and eviction
  authoritative in MORI rather than maintaining a connector-owned cache.
- Configure per-rank or total DRAM capacity, watermarks, NUMA placement,
  huge pages, prefaulting, shared-memory backing, and bounded execution.
- Handle asynchronous lookup and transfer completion, timeouts, retries
  under DRAM pressure, and per-object errors.
- Limit embedded scope to DRAM offloading; standalone and distributed
  runtimes, cross-engine sharing, and SSD tiers are outside this change.
- Keep pipeline parallelism rejected and layer-wise stores disabled until
  a safe native full-object publication protocol exists.

Tests and documentation:
- Include shared and embedded unit suites, runtime contracts, MORI GPU
  range and eviction tests, token-restore integration tests, and a status
  guide describing configuration, supported scope, and remaining work.
- Test coverage does not imply every model or topology case passes.

This consolidates the functional snapshot at
264c17e without changing tracked contents.
Subsequent simplification and performance work remains a separate commit.
History and message changes are verified by exact Git tree equality; no
new unit tests, GPU tests, or model evaluations are claimed by this rewrite.
AI assistance was used in development and commit-history consolidation.

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Cursor Agent <agent@cursor.com>
Signed-off-by: CharlesWu <Charles.Wu@amd.com>
Simplify the connector and lifecycle handling, and reduce repeated work
in external lookup and batched load preparation. MORI remains authoritative
for object residency; no potentially stale hit-state cache is introduced.

Shared connector / base:
- Remove unused identity and capability scaffolding and consolidate
  transfer state, completion accounting, and materialization fallbacks.
- Preserve lazy free-queue/store-event ownership and all-rank completion;
  finalize stores exactly once even when a no-forward step skips the
  normal model-runner save hook.
- Reuse request-local lookup identities. Apply the unchanged core
  KVCacheCoordinator to a read-only per-query hit view instead of
  allocating, indexing, and freeing shadow cache blocks.
- Carry whole-block loads in compact columns with shared request and
  generation metadata. Preserve rank localization, failure-to-block
  mapping, byte accounting, and fallbacks for special layouts.
- Filter null GPU source blocks before eager residency checks and store
  plan construction. Valid sources still require authoritative MORI lookup.

Embedded runtime:
- Keep DRAM-specific option validation and rank-capacity resolution in
  the adapter, and move the CPU byte-copy fake into test utilities.
- Reuse registered per-group layouts to construct batched load addresses
  directly and consume compact load metadata through native completion.
- Retain transfer buffer ownership across timeout and cancellation, and
  preserve safe teardown and scheduler/worker lookup communication.
- Do not change native MORI, model code, kernels, or core cache algorithms.
  Native scoped write admission remains unimplemented.

Add precise annotations, avoid conflicting local variable types, use
monkeypatch for test method replacement, and test metadata serialization
through vLLM Msgpack rather than a direct pickle import.

Historical validation of the GPU-tested snapshot before lint cleanup,
using MI355X hardware, a pinned ROCm development image, and real weights:
- Unit and runtime contracts: 208 passed, 1 skipped. The skipped case
  requires four visible GPUs for TP2/PP2.
- Differential checks: 206 passed, including 1,600 changing-residency
  windows. This overlaps the unit suite; unsupported DCP+SWA cases are
  rejection tests, not evidence of support.
- Llama-3.2-1B TP1 eager, lazy, synchronous lookup, and TP2: actual external
  restore; identical 32 decoded tokens, text, and finish reason;
  chosen-logprob error below 0.02; clean engine exits.
- GPT-OSS C8/C32: 240 replay requests with external hits and no failed load.
- Same-GPU GPT-OSS eager restore without diagnostic hooks, 20 replays per
  aggregate: baseline 25.8554 ms, candidate 24.2346 ms, Simple 18.1520 ms.
  Each candidate replay restores 10,000 tokens, 633 objects, and 248,905,728
  bytes. This is not the full serving test plan or a claim of Simple parity.

Historical validation after lint cleanup, on the committed source tree:
- Combined unit, contract, and differential suites: 252 passed,
  5 GPU-required tests skipped; run without GPU device access.
- Pre-commit checks, including mypy 3.10 and forbidden imports, passed;
  the manual mypy 3.12 hook passed. Only Actionlint was skipped because an
  unrelated Go dependency download failed TLS certificate verification.
  This change does not modify workflow files.
- The preceding GPU and performance results describe the pre-lint
  snapshot, not a post-lint rerun of those same cases.

Known limitation: strict GPT-OSS cold/replay numerical checks still fail
the unchanged 0.02 logprob tolerance. Simple and old UMBP controls also
fail, but the cause is unresolved and an additional regression is not
ruled out. A diagnostic verifies identical stored/restored bytes for all
39 objects (15,335,424 bytes); added HIP synchronization means it does not
prove uninstrumented async ordering or strict output equivalence.

This preserves the tracked tree and optimization diff of
954d661. History and message changes are
verified by exact Git tree equality; the validation above is historical,
not a new test run performed for this rewrite.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: CharlesWu <Charles.Wu@amd.com>
vLLM copies a restored Mamba state into the request's running slot while
it prepares inputs, before a synchronous load in start_load_kv could have
landed, so the copy reads stale state. With load_async=false, Kimi-Linear
GSM8K (256 questions, conc 64) fell from 89% to 52% on restored prompts,
for both layer-wise and bulk loads, while single requests still matched,
since a lone load finished in time. Waiting for the load inside
start_load_kv does not help because the copy has already happened.

The scheduler now loads asynchronously whenever the KV cache has a Mamba
group and warns that load_async=false is ignored. With it the same run
restores at 89.8% (layer-wise), 88.7% (bulk) and 89.8% (async).

Signed-off-by: Yichao Zhu <Yichao.Zhu@amd.com>
With load_async, a request whose KV is loading is not scheduled until
get_finished reports its load, so no forward pass reads the blocks being
filled. wait_for_layer_load still waited for those loads: the batched
load of each request is kept under the job key None, and
_drain_load_jobs selects None jobs for every layer name. The first
attention layer of each step therefore blocked until every in-flight
asynchronous load had finished, putting other requests' restores on the
running batch's critical path.

wait_for_layer_load now returns at once for asynchronous loads. Preemption
without request ids, which must settle loads before vLLM reuses their
blocks, drains the load jobs directly instead of going through it.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
The embedded lookup socket path hashed only the key namespace and the
rank's (tp, pcp, dcp, pp) coordinates. The namespace identifies the model
and layout, not the engine, so with data_parallel_size > 1 on one host,
or two engines serving the same model, worker tp0 of each resolved to the
same /tmp path. The second one unlinked the first one's socket and bound
its own, so one engine's scheduler silently queried the other engine's
private pool: hits it could not load, while its own pool went unused.

The path now also hashes the engine's data-parallel index and an optional
lookup_instance, which separates independent engines on one host. A
lookup server that finds its path served by a live listener fails
startup and names lookup_instance instead of taking the socket over; a
stale socket file left by a dead process is still replaced.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
Loads of whole blocks already skip plan materialization: the embedded
handle computes every block's addresses from the registered per-group
layouts with numpy. Stores still materialized one BlockTransferPlan per
block on the forward thread, building one KVRange per layer. For a
Llama-3.1-8B 8192-token prefill that is 512 plans and 16384 ranges,
8.6 ms of Python in wait_for_save before the step can finish.

Stores of whole blocks now take the same path through store_blocks: the
worker only localizes the keys, and the handle builds the put arguments
from the registered layouts, with the object size of each block taken
from its layout. Plans with token ranges, sub-block layouts or a missing
layout still fall back to materialized ranges. The MORI call receives
identical arguments either way.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
Part of vllm-project#57187. Standalone mode stores KV in one umbp_standalone_server
per host. Every engine, data-parallel rank, and worker on the host
attaches to it over a Unix-domain socket, so a prefix stored by one of
them is restored by any other and survives engine restarts.

Workers reuse the MORI worker handle from embedded mode, including its
batched block loads, with a client configured for the server; they
register GPU KV allocations through HIP IPC and serve no lookup socket.
The scheduler handle queries the server directly. Runtime failures
degrade instead of propagating: lookups report misses, loads mark their
blocks for recompute, stores fail without raising, and publish, clear,
and close tolerate a server that is gone. An unreachable server still
fails engine startup.

A stalled server must not turn into corrupted KV or a failed step. A
store that outlives timeout_ms stays pending rather than raising from
poll(): MORI still reads its source blocks, and the store event keeps
them pinned until the server answers. While such a store is blocked,
new loads and stores fail immediately with "transfer not attempted"
instead of queueing behind it, and shutdown does not wait for it.

The adapter validates its own options: it rejects TCP endpoints,
total_capacity_bytes, and DRAM sizing options unless auto_start spawns
the server, since the server owns its pool. Pipeline parallelism is
rejected as in embedded mode. reset_cache clears the shared pool for
every attached engine.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
Distributed mode lets engines on different hosts reuse each other's KV:
a umbp_master indexes objects cluster-wide, and every worker is a pool
node that serves its own host-DRAM slice to peers over MORI-IO RDMA.

Scheduler lookups ask the master through a client with no pool, no I/O
engine and no peer service, so the scheduler process allocates nothing
and the master never routes a put to it. Workers join under an identity
that includes the pid, because the master refuses to re-register a node
id until its previous holder expires, which would otherwise block an
engine restart. Each worker binds peer_service_port (and io_engine_port
when set) offset by its physical GPU index, which is unique per host
across engines and DP ranks. node_address defaults to the vLLM host IP.

The adapter rejects options distributed nodes would silently ignore
(shared-memory and watermark DRAM knobs) and local-mode options,
validates the master address and ports, splits total_capacity_bytes
across an engine's ranks, and fails at startup when ranged_scratch_size
cannot hold one KV object, since every off-node transfer would then
fail. Warnings flag page padding and large 4 KiB-paged pools, which NICs
with small translation caches cannot register for RDMA. The DRAM option
checks shared with embedded mode move into helpers, and the local modes
reject every distributed-only option.

Resetting one engine does not clear the shared pool. An unreachable
master degrades lookups to misses and loads/stores to failed transfers.
MORI's lookup RPC has no deadline, so against a master that accepts but
never answers it blocks until the master recovers; lookups run on the
scheduler's critical path, so each is bounded by lookup_timeout_ms and
reported as all-miss, and while one is still blocked later lookups miss
without asking the master. Workers reuse the standalone shared-pool
handle, so a store stalled on the master stays pending with its source
pinned. Pipeline parallelism is rejected in every mode.

Unit tests use a fake master/node cluster; integration tests run a real
umbp_master with two RDMA pool nodes on two GPUs, covering byte-exact
restore, index cleanup when a node exits, master loss, a restarted
master relearning every live node, and a SIGSTOPped master costing one
bounded lookup.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
A distributed lookup can keep reporting an object that no longer loads:
the master lists a crashed node's objects until it expires that node
(30 s by default). vLLM recovers a failed load by rescheduling the
request, whose next lookup hits the same objects, so the request loaded,
failed and rescheduled every step until the node expired: 691 recoveries
and a 8.9 s TTFT for one 2k-token request in a two-host run with a 9 s
expiry.

Workers now report the rank-local keys of failed loads with their error,
and the scheduler treats those keys as misses for
load_failure_quarantine_ms. It defaults to 30 s in distributed mode and
to 0 (off) otherwise, since local pools forget such objects at once. A
load the runtime refused without attempting it ("transfer not
attempted", e.g. while a store is blocked) says nothing about the object
and is not quarantined. A later store of the same key does not lift the
quarantine, because a pool that still lists the dead copy reports that
store as already done. With it, the same run recovers once and serves
the request with a 0.05 s TTFT.

Signed-off-by: Yichao Zhu <yichao.zhu@amd.com>
@mergify

mergify Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @YukioZzz.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation kv-connector needs-rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants