[Feature][Multimodal] Add cross-encoder caching to Mooncake P2P (#56242)
Signed-off-by: Zhou ziheng <jiaranran2@gmail.com> Co-authored-by: Tianyu Guo <guoty@inferact.ai> Co-authored-by: jiangkuaixue123 <jiangxiaozhou111@163.com> Co-authored-by: Codex <noreply@openai.com>
Zhou ziheng committed
Sep 17, 2026 at 13:16 UTC
2743dc4f92275128700888be273610d7d145fd70
15 files changed
+1863
-154
docs/features/cross_encoder_cache.md
new
+109
@@ -0,0 +1,109 @@
1
+# Cross-Encoder Output Reuse
2
+
3
+Cross-Encoder output reuse lets an Encoder load image embeddings computed by
4
+another Encoder from a shared Mooncake Store. Local cache misses check the Store
5
+before encoding; newly computed outputs are published asynchronously. Both paths
6
+use the existing `ECMooncakeConnector` P2P delivery to Prefill.
7
+
8
+## Requirements
9
+
10
+- A working [disaggregated Encoder](disagg_encoder.md) deployment using
11
+ `ECMooncakeConnector`. See the [Mooncake integration example](../../tests/v1/ec_connector/integration/run_epd_mooncake_ec_full_pipeline.sh)
12
+ for the base E/PD configuration.
13
+- Model Runner V2 (`VLLM_USE_V2_MODEL_RUNNER=1`), Encoder TP=1, and no dynamic LoRA.
14
+- `--mm-processor-cache-gb` greater than zero to preserve content identifiers.
15
+- Mooncake 0.3.12 or later with a running RAM Store. Disk offloading
16
+ (`enable_offload: true`) is not supported.
17
+
18
+Shared reuse supports images with contiguous 2D FP16, BF16 or FP32 encoder outputs.
19
+Other modalities use normal encoding; non-contiguous outputs are not published.
20
+
21
+## Usage
22
+
23
+On each Encoder, add `cross_encoder_cache: true` to the existing producer
24
+`--ec-transfer-config`, preserving its P2P settings:
25
+
26
+```json
27
+{
28
+ "ec_connector": "ECMooncakeConnector",
29
+ "ec_role": "ec_producer",
30
+ "ec_connector_extra_config": {
31
+ "cross_encoder_cache": true
32
+ }
33
+}
34
+```
35
+
36
+Prefill keeps its existing `ECMooncakeConnector` consumer configuration and does
37
+not need a Store client.
38
+
39
+Create a JSON file pointing to an existing Store. For an independently managed
40
+RAM pool, use `standalone-store` with `global_segment_size` set to zero:
41
+
42
+```json
43
+{
44
+ "metadata_server": "http://STORE_HOST:2379/metadata",
45
+ "master_server_address": "STORE_HOST:50051",
46
+ "protocol": "tcp",
47
+ "device_name": "",
48
+ "mode": "standalone-store",
49
+ "global_segment_size": 0,
50
+ "local_buffer_size": "4GB"
51
+}
52
+```
53
+
54
+Replace the addresses with your Store endpoints and set the path on each Encoder
55
+before starting vLLM:
56
+
57
+```bash
58
+export MOONCAKE_CONFIG_PATH=/path/to/mooncake_config.json
59
+```
60
+
61
+This connects to an existing Store; it does not start the storage services.
62
+For Store setup and tenant configuration, see the
63
+[Mooncake Store guide](mooncake_store_connector_usage.md#prerequisites).
64
+
65
+## Configuration
66
+
67
+These options belong in the producer's `ec_connector_extra_config`:
68
+
69
+| Option | Default | Description |
70
+| --- | --- | --- |
71
+| `cross_encoder_cache` | `false` | Enable shared output reuse. |
72
+| `embedding_cache_prefix` | `""` | Namespace prefix for shared embeddings. |
73
+| `embedding_model_identity` | Configured model path | Override the model field in Store keys. Matching multimodal identifiers are still required. |
74
+| `store_max_pending_items` | `32` | Maximum pending publications per Encoder. Must be positive. |
75
+| `store_max_pending_bytes` | `2147483648` (2 GiB) | Maximum retained tensor storage for pending publications per Encoder. Must be positive. |
76
+| `store_read_buffer_bytes` | `134217728` (128 MiB) | Maximum reusable CPU staging buffer per Encoder; pinned for CUDA outputs. Must be positive. |
77
+
78
+Encoders sharing outputs must use the same immutable weights, compatible
79
+preprocessing and matching multimodal identifiers. Use the same configured model
80
+path for automatic identifiers: `embedding_model_identity` alone does not enable
81
+reuse across different paths. Change `embedding_cache_prefix` when replacing
82
+weights in place or changing output-affecting settings. Caller-provided UUIDs
83
+must consistently identify the same input; see [cached inputs](multimodal_inputs.md#cached-inputs).
84
+
85
+## Limitations
86
+
87
+- Store reads are synchronous. Reuse skips Encoder computation, but not
88
+ preprocessing, P2P delivery or the scheduler's Encoder budget reservation.
89
+- Hits are read in batches through one lazily allocated, registered CPU staging
90
+ buffer. Each chunk is copied to independent output tensors before the buffer
91
+ is reused. Reads are split by `store_read_buffer_bytes`; an individual object
92
+ larger than this capacity (including its 24-byte header) falls back to encoding.
93
+ Staging memory is additional to the Store's `local_buffer_size` and is released
94
+ on healthy shutdown. The first load includes allocation and registration costs.
95
+- Publication is best-effort. A request can finish before its outputs reach the
96
+ Store, so concurrent cold requests may still encode the same image.
97
+- Publication budgets count each retained backing storage once across pending
98
+ views. Its charge is released after the last view is safely reclaimed.
99
+ Exceeding a budget skips the write. Store client buffers and the P2P pool
100
+ consume additional memory.
101
+- Cache misses and recoverable read errors fall back to encoding. Recoverable
102
+ publication errors skip the write. Unexpected native errors or unconfirmed
103
+ I/O completion or buffer release can fail the worker.
104
+- Incompatible objects are rejected without replacement; they can trigger
105
+ repeated fallback until evicted.
106
+- An independent Store can retain embeddings across Encoder restarts. Capacity,
107
+ eviction and resilience to Store failures depend on the Store deployment.
108
+- The `protocol:v3` namespace isolates the compact embedding format from older
109
+ cache objects. Encoders using different format versions do not share hits.
docs/features/disagg_encoder.md
+3
@@ -45,6 +45,9 @@ Below ready-to-run scripts shows the workflow:
45
46
## 3 Test Script
47
48
+For optional cross-Encoder output reuse while retaining Mooncake P2P delivery,
49
+see [Cross-encoder output reuse](cross_encoder_cache.md).
50
+
51
Please refer to the directories `tests/v1/ec_connector`
52
53
## 4 Development
tests/v1/ec_connector/unit/test_cross_encoder_cache.py
new
+726
@@ -0,0 +1,726 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import ctypes
5
+import gc
6
+import struct
7
+import threading
8
+import weakref
9
+from concurrent.futures import Future, ThreadPoolExecutor
10
+from dataclasses import replace
11
+from types import SimpleNamespace
12
+from unittest.mock import MagicMock, patch
13
+
14
+import pytest
15
+import torch
16
+
17
+from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
18
+from vllm.config import MultiModalConfig
19
+from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
20
+from vllm.distributed.ec_transfer.ec_connector.mooncake.config import MooncakeECConfig
21
+from vllm.distributed.ec_transfer.ec_connector.mooncake.metadata import (
22
+ ECMooncakeConnectorMetadata,
23
+ ECMooncakePushSpec,
24
+)
25
+from vllm.distributed.ec_transfer.ec_connector.mooncake_ec_connector import (
26
+ ECMooncakeConnector,
27
+)
28
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding import (
29
+ backend as store_backend,
30
+)
31
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding import (
32
+ store_client,
33
+)
34
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.data import (
35
+ TensorSpec,
36
+ build_embedding_namespace,
37
+ make_embedding_key,
38
+)
39
+
40
+pytestmark = pytest.mark.cpu_test
41
+KEY = (
42
+ "test@embedding@model:model@revision:revision"
43
+ "@encoder:encoder@dtype:torch.float32@protocol:v3"
44
+)
45
+SPEC = TensorSpec((2, 2), "torch.float32", 16)
46
+
47
+
48
+def test_embedding_key_combines_namespace_and_identifier():
49
+ config = create_ec_vllm_config(ec_role="ec_producer")
50
+ config.ec_transfer_config.ec_connector_extra_config.update(
51
+ embedding_cache_prefix="team@cache", embedding_model_identity="shared/model"
52
+ )
53
+ config.model_config.revision = "rev:1"
54
+ config.model_config.dtype = torch.float32
55
+ config.model_config.multimodal_config = None
56
+ assert make_embedding_key(build_embedding_namespace(config), "image@1") == (
57
+ "team@cache@embedding@model:shared/model@revision:rev:1"
58
+ "@encoder:encoder:default@dtype:torch.float32@protocol:v3@id:image@1"
59
+ )
60
+
61
+
62
+def test_shared_reuse_requires_content_stable_identifiers():
63
+ """Encoder-only cache-off rendering uses process-local counter identifiers."""
64
+ config = create_ec_vllm_config(ec_role="ec_producer")
65
+ config.ec_transfer_config.ec_connector = "ECMooncakeConnector"
66
+ config.ec_transfer_config.ec_connector_extra_config["cross_encoder_cache"] = True
67
+ config.model_config.multimodal_config = MultiModalConfig(mm_processor_cache_gb=0)
68
+ config.use_v2_model_runner = True
69
+ config.lora_config = None
70
+ with pytest.raises(ValueError, match="mm_processor_cache_gb"):
71
+ MooncakeECConfig.from_vllm_config(config)
72
+
73
+
74
+class MemoryStore:
75
+ """Only the native boundary is faked; codec, resolver and publisher are real."""
76
+
77
+ def __init__(self):
78
+ self.objects: dict[str, bytes] = {}
79
+ self.registered: dict[int, int] = {}
80
+
81
+ def close(self):
82
+ return 0
83
+
84
+ def batch_is_exist(self, keys):
85
+ return [int(k in self.objects) for k in keys]
86
+
87
+ def register_buffer(self, addr, size):
88
+ self.registered[addr] = size
89
+ return 0
90
+
91
+ def unregister_buffer(self, addr):
92
+ del self.registered[addr]
93
+ return 0
94
+
95
+ def batch_put_from_multi_buffers(self, keys, pointers, sizes, config):
96
+ [key], [ptrs], [lengths] = keys, pointers, sizes
97
+ self.objects[key] = b"".join(
98
+ ctypes.string_at(p, n) for p, n in zip(ptrs, lengths, strict=True)
99
+ )
100
+ return [0]
101
+
102
+ def batch_get_into(self, keys, pointers, sizes):
103
+ results = []
104
+ for key, ptr, size in zip(keys, pointers, sizes, strict=True):
105
+ assert any(
106
+ base <= ptr and ptr + size <= base + capacity
107
+ for base, capacity in self.registered.items()
108
+ )
109
+ obj = self.objects.get(key)
110
+ if obj is None:
111
+ results.append(-704)
112
+ elif len(obj) > size:
113
+ results.append(-600)
114
+ else:
115
+ ctypes.memmove(ptr, obj, len(obj))
116
+ results.append(len(obj))
117
+ return results
118
+
119
+
120
+@pytest.fixture
121
+def backend():
122
+ instance = store_backend.MooncakeEmbeddingStoreBackend(
123
+ store_client.MooncakeEmbeddingStoreClient(
124
+ MemoryStore(), read_buffer_bytes=1024
125
+ ),
126
+ KEY,
127
+ max_pending_items=32,
128
+ max_pending_bytes=2 * 1024**3,
129
+ )
130
+ yield instance
131
+ # Some tests deliberately poison I/O; always stop the Python executor.
132
+ instance._executor.shutdown(wait=True)
133
+ if not instance.store_client._poisoned and (
134
+ instance.store_client._read_buffer is not None
135
+ or instance.store_client._put_header is not None
136
+ ):
137
+ instance.store_client.close()
138
+
139
+
140
+def test_tensor_round_trip(backend):
141
+ client, native = backend.store_client, backend.store_client.store
142
+ native.register_buffer = MagicMock(wraps=native.register_buffer)
143
+ header_ptr = None
144
+ for index, dtype in enumerate((torch.float16, torch.bfloat16, torch.float32)):
145
+ tensor = torch.arange(6, dtype=dtype).reshape(index + 1, -1)
146
+ key = make_embedding_key(KEY, str(dtype))
147
+ client.put_tensor(key, tensor)
148
+ pointer = ctypes.addressof(client._put_header)
149
+ header_ptr = header_ptr or pointer
150
+ assert pointer == header_ptr
151
+ expected = TensorSpec(tuple(tensor.shape), str(dtype), tensor.nbytes)
152
+ with torch.device("meta"):
153
+ loaded = client.load_tensors({key: expected}, "cpu")[key]
154
+ assert client._read_buffer.device.type == "cpu"
155
+ assert loaded.dtype == dtype and torch.equal(loaded, tensor)
156
+ assert len(native.objects[key]) == 24 + tensor.nbytes
157
+ assert (
158
+ sum(c.args[0] == header_ptr for c in native.register_buffer.call_args_list) == 1
159
+ )
160
+ assert len(native.registered) == 2
161
+
162
+
163
+@pytest.mark.parametrize("capacity", [80, 120])
164
+def test_batch_reads_reuse_registration_without_aliasing_outputs(backend, capacity):
165
+ client, native = backend.store_client, backend.store_client.store
166
+ client._read_buffer_bytes = capacity
167
+ tensors = {key: torch.full((2, 2), float(i)) for i, key in enumerate("abc")}
168
+ for key, tensor in tensors.items():
169
+ client.put_tensor(key, tensor)
170
+ native.register_buffer = MagicMock(wraps=native.register_buffer)
171
+ native.unregister_buffer = MagicMock(wraps=native.unregister_buffer)
172
+ native.batch_get_into = MagicMock(wraps=native.batch_get_into)
173
+ expected = dict.fromkeys(tensors, SPEC)
174
+ first = client.load_tensors(expected, "cpu")
175
+ for key, tensor in tensors.items():
176
+ native.objects[key] = native.objects[key][:24] + struct.pack(
177
+ "<4f", *[tensor[0, 0].item() + 10] * 4
178
+ )
179
+ second = client.load_tensors(expected, "cpu")
180
+ assert all(torch.equal(first[key], tensor) for key, tensor in tensors.items())
181
+ assert all(torch.equal(second[key], tensor + 10) for key, tensor in tensors.items())
182
+ assert native.batch_get_into.call_count == (4 if capacity == 80 else 2)
183
+ native.register_buffer.assert_called_once()
184
+ native.unregister_buffer.assert_not_called()
185
+ client.close()
186
+ assert native.unregister_buffer.call_count == 2
187
+ assert not native.registered
188
+
189
+
190
+@pytest.mark.parametrize("mode", ["miss", "oversized"])
191
+def test_unloadable_inputs_do_not_allocate_staging(backend, mode):
192
+ client, native = backend.store_client, backend.store_client.store
193
+ client._read_buffer_bytes = 39
194
+ if mode == "oversized":
195
+ client.put_tensor(make_embedding_key(KEY, "a"), torch.ones(2, 2))
196
+ native.register_buffer = MagicMock(wraps=native.register_buffer)
197
+ assert not backend.resolve_inputs({"a": SPEC}, {}, "cpu")
198
+ assert client._read_buffer is None
199
+ if mode == "miss":
200
+ assert client._put_header is None
201
+ client.close()
202
+ native.register_buffer.assert_not_called()
203
+
204
+
205
+@pytest.mark.parametrize(
206
+ "results", [[40, -800], [40], [40, True], [[40], [40]], None, [40, 41]]
207
+)
208
+def test_unsafe_batch_result_retains_whole_staging(backend, results):
209
+ client, native = backend.store_client, backend.store_client.store
210
+ native.batch_get_into = MagicMock(return_value=results)
211
+ native.unregister_buffer = MagicMock(wraps=native.unregister_buffer)
212
+ for _ in range(2):
213
+ with pytest.raises(store_client.EmbeddingStoreError, match="unconfirmed"):
214
+ client.load_tensors(dict.fromkeys("ab", SPEC), "cpu")
215
+ native.batch_get_into.assert_called_once()
216
+ with pytest.raises(store_client.EmbeddingStoreError, match="unconfirmed"):
217
+ client.close()
218
+ native.unregister_buffer.assert_not_called()
219
+ assert client._read_buffer is client._unsafe_owners[0]
220
+
221
+
222
+@pytest.mark.parametrize("failure", ["allocation", "copy", "record", "completion"])
223
+def test_copy_failure_preserves_staging_lifetime(backend, failure):
224
+ """Emulate CUDA allocation/event boundaries without requiring a GPU."""
225
+ client = backend.store_client
226
+ for key in "ab":
227
+ client.put_tensor(key, torch.ones(2, 2))
228
+ allocate, copy = torch.empty, torch.Tensor.copy_
229
+ targets: list[torch.Tensor] = []
230
+ copies: list[torch.Tensor] = []
231
+ ready = MagicMock()
232
+ if failure == "record":
233
+ ready.record.side_effect = RuntimeError("record failure")
234
+ elif failure == "completion":
235
+ ready.synchronize.side_effect = RuntimeError("copy failure")
236
+
237
+ def allocate_on_cpu(*args, **kwargs):
238
+ is_target = kwargs.get("device") == torch.device("cuda")
239
+ if is_target and targets and failure == "allocation":
240
+ raise torch.OutOfMemoryError("allocation failure")
241
+ kwargs.update(device="cpu", pin_memory=False)
242
+ tensor = allocate(*args, **kwargs)
243
+ if is_target:
244
+ targets.append(tensor)
245
+ return tensor
246
+
247
+ def record_copy(target, source, **kwargs):
248
+ if copies and failure == "copy":
249
+ raise RuntimeError("copy failure")
250
+ copies.append(target)
251
+ return copy(target, source, **kwargs)
252
+
253
+ with (
254
+ patch.object(torch, "empty", side_effect=allocate_on_cpu),
255
+ patch.object(torch, "Event", return_value=ready),
256
+ patch.object(torch.accelerator, "current_stream"),
257
+ patch.object(torch.Tensor, "copy_", new=record_copy),
258
+ pytest.raises(RuntimeError, match="allocation failure|unconfirmed"),
259
+ ):
260
+ client.load_tensors(dict.fromkeys("ab", SPEC), "cuda")
261
+ if failure == "allocation":
262
+ assert not copies and not client._poisoned
263
+ else:
264
+ assert len(copies) == (1 if failure == "copy" else 2)
265
+ assert client._poisoned
266
+ assert any(owner is client._read_buffer for owner in client._unsafe_owners)
267
+ assert any(owner is ready for owner in client._unsafe_owners)
268
+ assert all(
269
+ any(owner is target for owner in client._unsafe_owners)
270
+ for target in targets
271
+ )
272
+ with pytest.raises(store_client.EmbeddingStoreError, match="unconfirmed"):
273
+ client.close()
274
+
275
+
276
+def test_non_matrix_output_is_rejected_before_registration(backend):
277
+ client = backend.store_client
278
+ client.store.register_buffer = MagicMock()
279
+ with pytest.raises(store_client.EmbeddingStoreOperationError, match="2D"):
280
+ client.put_tensor(make_embedding_key(KEY, "a"), torch.ones(2, 2, 2))
281
+ client.store.register_buffer.assert_not_called()
282
+
283
+
284
+def make_worker(backend):
285
+ from vllm.distributed.ec_transfer.ec_connector.mooncake.worker import (
286
+ ECMooncakeWorker,
287
+ )
288
+
289
+ worker = object.__new__(ECMooncakeWorker)
290
+ worker.is_producer = True
291
+ worker.is_consumer = False
292
+ worker._output_store = backend
293
+ worker._buffer_device = "cpu"
294
+ worker._producer_pushes = MagicMock()
295
+ worker._producer_pushes.reserve.return_value = (MagicMock(), True)
296
+ worker._producer_pushes.poll.return_value = []
297
+ worker._producer_pushes.cancel_requests.return_value = []
298
+ worker._producer_pushes.pending = False
299
+ worker._control_executor = MagicMock()
300
+ worker._consumer_memory = MagicMock()
301
+ worker._consumer_memory.drain_reclaimed.return_value = set()
302
+ worker._completed_loads = set()
303
+ worker._failed_loads = set()
304
+ worker._failed_saves = set()
305
+ worker._push_ready = threading.Event()
306
+ worker._flush_pending_pushes = MagicMock()
307
+ worker._bind_push_source = MagicMock()
308
+ connector = object.__new__(ECMooncakeConnector)
309
+ connector._worker = worker
310
+ connector._role = ECConnectorRole.WORKER
311
+ connector._is_producer = True
312
+ connector._is_consumer = False
313
+ connector._connector_metadata = None
314
+ return connector
315
+
316
+
317
+@pytest.mark.parametrize(
318
+ "mode",
319
+ [
320
+ "miss",
321
+ "legacy",
322
+ "hit",
323
+ "partial",
324
+ "get-failure",
325
+ "short-data",
326
+ "lease-expired",
327
+ "registration-failure",
328
+ ],
329
+)
330
+def test_reuse_pipeline(backend, mode):
331
+ from vllm.multimodal.inputs import (
332
+ MultiModalFeatureSpec,
333
+ MultiModalKwargsItem,
334
+ PlaceholderRange,
335
+ )
336
+ from vllm.v1.worker.gpu.ec_connector import ActiveECConnector
337
+ from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
338
+ from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
339
+ from vllm.v1.worker.gpu.model_states.interface import ModelState
340
+
341
+ native = backend.store_client.store
342
+ values = {"a": 1.0, "b": 2.0, "c": 3.0}
343
+ if mode == "legacy":
344
+ old_namespace = KEY.replace("@protocol:v3", "@protocol:v2")
345
+ for key in "abc":
346
+ native.objects[make_embedding_key(old_namespace, key)] = b"old format"
347
+ if mode == "partial":
348
+ hits = {"a", "c"}
349
+ elif mode not in ("miss", "legacy"):
350
+ hits = set("abc")
351
+ else:
352
+ hits = set()
353
+ for key in hits:
354
+ backend.store_client.put_tensor(
355
+ make_embedding_key(KEY, key), torch.full((2, 2), values[key])
356
+ )
357
+ if mode == "short-data":
358
+ key = make_embedding_key(KEY, "b")
359
+ native.objects[key] = native.objects[key][:-1]
360
+ hits.remove("b")
361
+ if mode in ("get-failure", "lease-expired"):
362
+ read = native.batch_get_into
363
+
364
+ def failed_read(keys, pointers, sizes):
365
+ results = read(keys, pointers, sizes)
366
+ results[keys.index(make_embedding_key(KEY, "b"))] = (
367
+ -707 if mode == "lease-expired" else -704
368
+ )
369
+ return results
370
+
371
+ native.batch_get_into = failed_read
372
+ hits.remove("b")
373
+ if mode == "registration-failure":
374
+ native.register_buffer = MagicMock(return_value=-500)
375
+ hits.clear()
376
+ connector = make_worker(backend)
377
+ metadata = ECMooncakeConnectorMetadata(
378
+ store_candidates=dict.fromkeys("abc", SPEC),
379
+ pushes=[
380
+ ECMooncakePushSpec(
381
+ mm_hash=k,
382
+ shape=(2, 2),
383
+ dtype="float32",
384
+ nbytes=16,
385
+ consumer_zmq="ipc:///tmp/test",
386
+ transfer_id=k,
387
+ request_id="req",
388
+ )
389
+ for k in "abc"
390
+ ],
391
+ )
392
+ cache = EncoderCache()
393
+ cache.mm_features["req"] = [
394
+ MultiModalFeatureSpec(
395
+ data=MultiModalKwargsItem({}),
396
+ modality="image",
397
+ identifier=k,
398
+ mm_position=PlaceholderRange(offset=i * 2, length=2),
399
+ )
400
+ for i, k in enumerate("abc")
401
+ ]
402
+ runner = EncoderRunner(
403
+ model=None,
404
+ max_num_tokens=8,
405
+ hidden_size=2,
406
+ encoder_cache=cache,
407
+ dtype=torch.float32,
408
+ device=torch.device("cpu"),
409
+ )
410
+ runner.enable_timing = False
411
+ missing = [k for k in "abc" if k not in hits]
412
+ runner.execute_mm_encoder = MagicMock(
413
+ return_value=[torch.full((2, 2), values[k] + 10) for k in missing]
414
+ )
415
+ state = SimpleNamespace(encoder_runner=runner, encoder_cache=cache)
416
+ with patch(
417
+ "vllm.v1.worker.gpu.ec_connector.get_ec_transfer", return_value=connector
418
+ ):
419
+ adapter = ActiveECConnector(SimpleNamespace(), cache.encoder_outputs)
420
+ with adapter.maybe_get_output(
421
+ SimpleNamespace(ec_connector_metadata=metadata, finished_req_ids=set())
422
+ ) as output:
423
+ assert runner.prepare_mm_inputs({"req": [0, 1, 2]})[0] == missing
424
+ ModelState.execute_mm_encoder(state, {"req": [0, 1, 2]})
425
+ assert runner.execute_mm_encoder.call_count == bool(missing)
426
+ assert output.ec_connector_worker_meta.pending_saves is False
427
+ assert all(
428
+ torch.equal(
429
+ cache.encoder_outputs[k],
430
+ torch.full((2, 2), values[k] + (0 if k in hits else 10)),
431
+ )
432
+ for k in "abc"
433
+ )
434
+ assert {
435
+ c.args[1] for c in connector._worker._bind_push_source.call_args_list
436
+ } == set("abc")
437
+ backend.shutdown()
438
+ assert not native.registered
439
+ if mode in ("miss", "legacy"):
440
+ other = store_backend.MooncakeEmbeddingStoreBackend(
441
+ store_client.MooncakeEmbeddingStoreClient(native, read_buffer_bytes=1024),
442
+ KEY,
443
+ max_pending_items=32,
444
+ max_pending_bytes=2 * 1024**3,
445
+ )
446
+ try:
447
+ loaded: dict[str, torch.Tensor] = {}
448
+ assert other.resolve_inputs(
449
+ dict.fromkeys("abc", SPEC),
450
+ loaded,
451
+ "cpu",
452
+ ) == set("abc")
453
+ assert all(torch.equal(loaded[k], cache.encoder_outputs[k]) for k in "abc")
454
+ finally:
455
+ other.shutdown()
456
+
457
+
458
+@pytest.mark.parametrize(
459
+ "mismatch",
460
+ ["revision", "shape", "dtype", "magic", "short-header", "short-data", "long-data"],
461
+)
462
+def test_incompatible_output_is_a_miss(backend, mismatch):
463
+ key = make_embedding_key(KEY, "a")
464
+ dtype = torch.float16 if mismatch == "dtype" else torch.float32
465
+ backend.store_client.put_tensor(key, torch.ones(2, 2, dtype=dtype))
466
+ if mismatch == "revision":
467
+ backend.namespace = KEY.replace("@revision:revision", "@revision:other")
468
+ objects = backend.store_client.store.objects
469
+ if mismatch == "magic":
470
+ objects[key] = b"\0" * 4 + objects[key][4:]
471
+ elif mismatch == "short-header":
472
+ objects[key] = objects[key][:23]
473
+ elif mismatch == "short-data":
474
+ objects[key] = objects[key][:-1]
475
+ elif mismatch == "long-data":
476
+ objects[key] += b"extra"
477
+ expected = replace(SPEC, shape=(4, 1)) if mismatch == "shape" else SPEC
478
+ outputs: dict[str, torch.Tensor] = {}
479
+ assert not backend.resolve_inputs({"a": expected}, outputs, "cpu")
480
+ assert not outputs
481
+ tensor = torch.ones(2, 2)
482
+ backend.store_client.put_tensor(
483
+ make_embedding_key(backend.namespace, "valid"), tensor
484
+ )
485
+ assert backend.resolve_inputs({"valid": SPEC}, outputs, "cpu") == {"valid"}
486
+ assert torch.equal(outputs["valid"], tensor)
487
+ assert len(backend.store_client.store.registered) == 2
488
+
489
+
490
+def test_partial_registration_rejection_releases_publication(backend):
491
+ """A rejected header registration must not prevent a later publication."""
492
+ native = backend.store_client.store
493
+ register = native.register_buffer
494
+ put = native.batch_put_from_multi_buffers
495
+ native.register_buffer = lambda addr, size: (
496
+ -500 if size == 24 else register(addr, size)
497
+ )
498
+ native.batch_put_from_multi_buffers = MagicMock()
499
+ tensor = torch.ones(2, 2)
500
+ owner = weakref.ref(tensor)
501
+ assert backend._enqueue_save("a", tensor)
502
+ storage_owner = weakref.ref(tensor.untyped_storage())
503
+ future = backend._pending["a"][0]
504
+ with pytest.raises(store_client.EmbeddingStoreOperationError, match="register"):
505
+ future.result(timeout=5)
506
+ del tensor
507
+ gc.collect()
508
+ assert owner() is None and not native.registered
509
+ native.batch_put_from_multi_buffers.assert_not_called()
510
+ backend.resolve_inputs({}, {}, "cpu")
511
+ assert backend._pending_bytes == 0
512
+ gc.collect()
513
+ assert storage_owner() is None # The failed Future is still alive.
514
+ assert backend.store_client._put_header is None
515
+ native.register_buffer = register
516
+ native.batch_put_from_multi_buffers = put
517
+ backend.store_client.put_tensor("retry", torch.ones(2, 2))
518
+ assert len(native.registered) == 1
519
+
520
+
521
+@pytest.mark.parametrize(
522
+ "operation",
523
+ ["read-unregister", "header-unregister", "put", "rejected-put", "unregister"],
524
+)
525
+def test_native_io_owners(backend, operation):
526
+ """Uncertain I/O retains owners; completed rejection frees them even via Future."""
527
+ native, client = backend.store_client.store, backend.store_client
528
+ put = native.batch_put_from_multi_buffers
529
+ backend.max_pending_bytes = SPEC.nbytes
530
+ tensor = torch.ones(2, 2)
531
+ owner = weakref.ref(tensor)
532
+ rejected = operation == "rejected-put"
533
+ if operation in ("read-unregister", "header-unregister"):
534
+ key = make_embedding_key(KEY, "a")
535
+ client.put_tensor(key, tensor)
536
+ if operation == "read-unregister":
537
+ client.load_tensors({key: SPEC}, "cpu")
538
+ native.unregister_buffer = MagicMock(return_value=-500)
539
+ with pytest.raises(store_client.EmbeddingStoreError, match="unregister"):
540
+ client.close()
541
+ owner = weakref.ref(
542
+ client._read_buffer
543
+ if operation == "read-unregister"
544
+ else client._put_header
545
+ )
546
+ else:
547
+ if operation == "unregister":
548
+ native.unregister_buffer = MagicMock(return_value=-500)
549
+ else:
550
+ native.batch_put_from_multi_buffers = MagicMock(
551
+ return_value=[-1700 if rejected else -800]
552
+ )
553
+ assert backend._enqueue_save("a", tensor)
554
+ future = backend._pending["a"][0]
555
+ with pytest.raises(store_client.EmbeddingStoreError):
556
+ future.result(timeout=5)
557
+ del tensor
558
+ gc.collect()
559
+ if rejected:
560
+ assert owner() is None # A live Future must not retain the tensor.
561
+ tensor = torch.ones(4, 4) # Too large for admission after reaping.
562
+ caller_owner = weakref.ref(tensor)
563
+ backend._step_candidates = {"next"}
564
+ backend.save_output("next", tensor)
565
+ del tensor
566
+ gc.collect()
567
+ assert caller_owner() is None
568
+ assert backend._pending_bytes == 0 and len(native.registered) == 1
569
+ header = client._put_header
570
+ native.batch_put_from_multi_buffers = put
571
+ client.put_tensor("retry", torch.ones(1, 4))
572
+ assert client._put_header is header
573
+ else:
574
+ assert owner() is not None and native.registered
575
+ header = bytes(client._put_header)
576
+ with pytest.raises(store_client.EmbeddingStoreError, match="unconfirmed"):
577
+ client.put_tensor("retry", torch.ones(1, 4))
578
+ assert bytes(client._put_header) == header
579
+ with pytest.raises(store_client.EmbeddingStoreError, match="unconfirmed"):
580
+ client.close()
581
+
582
+
583
+def test_publication_budget_charges_and_releases_backing_storage(backend):
584
+ tensor = torch.arange(16, dtype=torch.float32)[:4].view(2, 2)
585
+ owner = weakref.ref(tensor)
586
+ future: Future[None] = Future()
587
+ backend.max_pending_items = 1
588
+ backend.max_pending_bytes = tensor.nbytes
589
+ with patch.object(backend._executor, "submit", return_value=future):
590
+ assert not backend._enqueue_save("a", tensor)
591
+ backend.max_pending_bytes = 64
592
+ assert backend._enqueue_save("a", tensor)
593
+ storage_owner = weakref.ref(tensor.untyped_storage())
594
+ assert not backend._enqueue_save("b", tensor)
595
+ del tensor
596
+ gc.collect()
597
+ backend.reap() # The final step-end poll precedes publication completion.
598
+ assert owner() is not None
599
+ backend._save(make_embedding_key(KEY, "a"), backend._pending["a"][1])
600
+ future.set_result(None)
601
+ assert owner() is None and storage_owner() is not None
602
+
603
+ def load_after_reaping(*args):
604
+ assert not backend._pending and backend._pending_bytes == 0
605
+ assert storage_owner() is None
606
+ return {}
607
+
608
+ with patch.object(backend.store_client, "load_tensors", load_after_reaping):
609
+ assert not backend.resolve_inputs({"b": SPEC}, {}, "cpu")
610
+
611
+
612
+@pytest.mark.parametrize("rejected", [False, True])
613
+def test_shared_storage_budget_is_released_after_last_publication(backend, rejected):
614
+ batch = torch.arange(16, dtype=torch.float32).view(4, 4)
615
+ first: Future[None] = Future()
616
+ last: Future[None] = Future()
617
+ separate: Future[None] = Future()
618
+ backend.max_pending_items = 2
619
+ backend.max_pending_bytes = batch.nbytes
620
+ with patch.object(backend._executor, "submit", side_effect=[first, last, separate]):
621
+ assert backend._enqueue_save("a", batch[:2])
622
+ assert backend._enqueue_save("b", batch[2:])
623
+ assert backend._pending_bytes == batch.nbytes
624
+ assert not backend._enqueue_save("a", batch[:2])
625
+ assert not backend._enqueue_save("extra", batch[:1])
626
+ if rejected:
627
+ first.set_exception(store_client.EmbeddingStoreOperationError("rejected"))
628
+ else:
629
+ first.set_result(None)
630
+ backend.reap()
631
+ assert backend._pending_bytes == batch.nbytes
632
+ assert not backend._enqueue_save("c", torch.ones(2, 2))
633
+ last.set_result(None)
634
+ backend.reap()
635
+ assert backend._pending_bytes == 0
636
+ assert backend._enqueue_save("c", torch.ones(2, 2))
637
+ assert backend._pending_bytes == 16
638
+ separate.set_result(None)
639
+ backend.reap()
640
+ assert backend._pending_bytes == 0
641
+
642
+
643
+@pytest.mark.parametrize("shared_storage", [False, True])
644
+def test_completion_race_preserves_failure_and_reclaims_other_items(
645
+ backend, shared_storage
646
+):
647
+ class RacingFuture(Future[None]):
648
+ def done(self):
649
+ observed = super().done()
650
+ if not observed:
651
+ self.set_exception(store_client.EmbeddingStoreError("uncertain"))
652
+ return observed
653
+
654
+ racing = RacingFuture()
655
+ other: Future[None] = Future()
656
+ tensor = torch.ones(2, 2)
657
+ other_tensor = tensor[:1] if shared_storage else torch.ones(2, 2)
658
+ with patch.object(backend._executor, "submit", side_effect=[racing, other]):
659
+ assert backend._enqueue_save("race", tensor)
660
+ assert backend._enqueue_save("other", other_tensor)
661
+ backend.reap()
662
+ assert backend._pending_bytes == (16 if shared_storage else 32)
663
+ other.set_result(None)
664
+ with pytest.raises(store_client.EmbeddingStoreError):
665
+ backend.reap()
666
+ assert backend._pending_bytes == 16
667
+ with pytest.raises(store_client.EmbeddingStoreError):
668
+ backend._enqueue_save("later", torch.ones(2, 2))
669
+
670
+
671
+def test_readiness_and_shutdown_do_not_block_p2p_completion(backend):
672
+ ready, waiting, entered = [threading.Event() for _ in range(3)]
673
+
674
+ original = backend._executor.shutdown
675
+
676
+ def closing(*args, **kwargs):
677
+ entered.set()
678
+ return original(*args, **kwargs)
679
+
680
+ native = backend.store_client.store
681
+
682
+ def close_store():
683
+ assert ready.is_set() and not native.registered
684
+ assert backend._pending_bytes == 0
685
+ return 0
686
+
687
+ native.close = MagicMock(side_effect=close_store)
688
+ put = native.batch_put_from_multi_buffers
689
+
690
+ def waiting_put(*args):
691
+ waiting.set()
692
+ assert ready.wait(5)
693
+ assert backend.store_client._put_header is not None and native.registered
694
+ return put(*args)
695
+
696
+ native.batch_put_from_multi_buffers = waiting_put
697
+ connector = make_worker(backend)
698
+ connector.bind_connector_metadata(
699
+ ECMooncakeConnectorMetadata(store_candidates={"a": SPEC})
700
+ )
701
+ cache: dict[str, torch.Tensor] = {}
702
+ with (
703
+ patch.object(
704
+ store_backend, "_record_tensor_ready_event", return_value=MagicMock()
705
+ ),
706
+ patch.object(backend._executor, "shutdown", side_effect=closing),
707
+ ThreadPoolExecutor(max_workers=1) as closer,
708
+ ):
709
+ connector.start_save_caches(encoder_cache=cache)
710
+ cache["a"] = torch.arange(4, dtype=torch.float32).view(2, 2)
711
+ connector.save_caches(cache, "a")
712
+ assert connector.build_connector_worker_meta().pending_saves is False
713
+ assert waiting.wait(5) and not backend.store_client.store.objects
714
+ close = closer.submit(backend.shutdown)
715
+ try:
716
+ assert entered.wait(5) and not close.done()
717
+ assert not backend._enqueue_save("late", torch.ones(2, 2))
718
+ finally:
719
+ ready.set()
720
+ close.result(timeout=5)
721
+ native.close.assert_called_once_with()
722
+ assert not backend.store_client.store.registered and backend._pending_bytes == 0
723
+
724
+ assert backend.store_client.store.objects[make_embedding_key(KEY, "a")][
725
+ 24:
726
+ ] == struct.pack("<4f", 0, 1, 2, 3)
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+27
-10
@@ -17,6 +17,7 @@ import pytest
17
import torch
18
19
from tests.v1.attention.utils import dense_kv_cache_views
20
+from vllm.distributed import mooncake_store
21
from vllm.distributed.kv_events import KVEventAggregator
22
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import (
23
rdma_utils,
@@ -402,6 +403,7 @@ def _install_fake_mooncake(monkeypatch, store_instance: MagicMock):
403
fake_store_module = types.ModuleType("mooncake.store")
404
fake_store_module.MooncakeDistributedStore = lambda: store_instance # type: ignore[attr-defined]
405
fake_store_module.ReplicateConfig = FakeReplicateConfig # type: ignore[attr-defined]
406
+ fake_store_module.ObjectDataType = SimpleNamespace(TENSOR=1) # type: ignore[attr-defined]
407
fake_mooncake_module = types.ModuleType("mooncake")
408
fake_mooncake_module.store = fake_store_module # type: ignore[attr-defined]
409
monkeypatch.setitem(sys.modules, "mooncake", fake_mooncake_module)
@@ -590,7 +592,7 @@ def test_pool_key_cache_prefix_namespaces_and_disambiguates():
592
def test_default_local_buffer_size_matches_pr40900():
593
"""PR-40900 shipped a 4 GiB default for local_buffer_size; the dual-mode
594
patch preserves it (and the JSON key) so unchanged PR-40900 configs work."""
593
- assert worker.DEFAULT_LOCAL_BUFFER_SIZE == 4 * 1024**3
595
+ assert mooncake_store.DEFAULT_LOCAL_BUFFER_SIZE == 4 * 1024**3
596
597
598
def test_get_requester_local_hostname_prefers_override(monkeypatch):
@@ -2957,6 +2959,7 @@ def test_requester_worker_group_semantics_string_true_enables(
2959
fake_store_module = types.ModuleType("mooncake.store")
2960
fake_store_module.MooncakeDistributedStore = lambda: store # type: ignore[attr-defined]
2961
fake_store_module.ReplicateConfig = FakeReplicateConfig # type: ignore[attr-defined]
2962
+ fake_store_module.ObjectDataType = SimpleNamespace(TENSOR=1) # type: ignore[attr-defined]
2963
fake_mooncake_module = types.ModuleType("mooncake")
2964
fake_mooncake_module.store = fake_store_module # type: ignore[attr-defined]
2965
monkeypatch.setitem(sys.modules, "mooncake", fake_mooncake_module)
@@ -4377,10 +4380,10 @@ def test_config_defaults_to_embedded():
4380
"""A JSON without explicit mode parses as embedded with 4 GiB segment."""
4381
cfg = _make_config()
4382
assert cfg.mode == "embedded"
4380
- assert cfg.global_segment_size == worker.DEFAULT_GLOBAL_SEGMENT_SIZE
4381
- assert cfg.local_buffer_size == worker.DEFAULT_LOCAL_BUFFER_SIZE
4383
+ assert cfg.global_segment_size == mooncake_store.DEFAULT_GLOBAL_SEGMENT_SIZE
4384
+ assert cfg.local_buffer_size == mooncake_store.DEFAULT_LOCAL_BUFFER_SIZE
4385
assert cfg.enable_offload is False
4383
- assert cfg.tenant_id == worker.DEFAULT_TENANT_ID
4386
+ assert cfg.tenant_id == mooncake_store.DEFAULT_TENANT_ID
4387
4388
4389
def test_config_pr40900_unchanged(tmp_path):
@@ -4402,7 +4405,7 @@ def test_config_pr40900_unchanged(tmp_path):
4405
assert cfg.global_segment_size == 4 * 1024**3
4406
assert cfg.local_buffer_size == 4 * 1024**3
4407
assert cfg.enable_offload is False
4405
- assert cfg.tenant_id == worker.DEFAULT_TENANT_ID
4408
+ assert cfg.tenant_id == mooncake_store.DEFAULT_TENANT_ID
4409
4410
4411
def test_config_from_file_normalizes_tenant_id(tmp_path):
@@ -4432,7 +4435,7 @@ def test_config_from_file_normalizes_empty_tenant_id_to_default(tmp_path):
4435
4436
cfg = worker.MooncakeStoreConfig.from_file(config_path)
4437
4435
- assert cfg.tenant_id == worker.DEFAULT_TENANT_ID
4438
+ assert cfg.tenant_id == mooncake_store.DEFAULT_TENANT_ID
4439
4440
4441
def test_config_from_file_rejects_non_string_tenant_id(tmp_path):
@@ -4584,7 +4587,20 @@ def test_topology_embedded_cpu_only(tmp_path, monkeypatch):
4587
assert w.disk_offload_buffer_budget_bytes is None
4588
4589
4587
-def test_topology_forwards_non_default_tenant_id(tmp_path, monkeypatch):
4590
+def _create_store_client_for_tenant_test(consumer):
4591
+ if consumer == "ec":
4592
+ from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.store_client import ( # noqa: E501
4593
+ create_mooncake_embedding_store_client,
4594
+ )
4595
+
4596
+ return create_mooncake_embedding_store_client()
4597
+ return worker.MooncakeStoreWorker(
4598
+ _make_vllm_config(rank=1), _make_kv_cache_config()
4599
+ )
4600
+
4601
+
4602
+@pytest.mark.parametrize("consumer", ["kv", "ec"])
4603
+def test_topology_forwards_non_default_tenant_id(tmp_path, monkeypatch, consumer):
4604
store = MagicMock()
4605
store.setup.return_value = 0
4606
_install_fake_mooncake(monkeypatch, store)
@@ -4605,12 +4621,13 @@ def test_topology_forwards_non_default_tenant_id(tmp_path, monkeypatch):
4621
),
4622
)
4623
4608
- worker.MooncakeStoreWorker(_make_vllm_config(rank=1), _make_kv_cache_config())
4624
+ _create_store_client_for_tenant_test(consumer)
4625
4626
assert store.setup.call_args.kwargs == {"tenant_id": "tenant-a"}
4627
4628
4613
-def test_non_default_tenant_preserves_setup_type_error(tmp_path, monkeypatch):
4629
+@pytest.mark.parametrize("consumer", ["kv", "ec"])
4630
+def test_non_default_tenant_preserves_setup_type_error(tmp_path, monkeypatch, consumer):
4631
store = MagicMock()
4632
setup_error = TypeError(
4633
"setup(): incompatible function arguments; "
@@ -4636,7 +4653,7 @@ def test_non_default_tenant_preserves_setup_type_error(tmp_path, monkeypatch):
4653
)
4654
4655
with pytest.raises(TypeError) as exc_info:
4639
- worker.MooncakeStoreWorker(_make_vllm_config(rank=1), _make_kv_cache_config())
4656
+ _create_store_client_for_tenant_test(consumer)
4657
4658
assert exc_info.value is setup_error
4659
vllm/config/ec_transfer.py
+2
@@ -23,6 +23,8 @@ class ECTransferConfig:
23
safetensors) and ``ECMooncakeConnector`` (Mooncake TransferEngine RDMA;
24
requires ``mooncake-transfer-engine`` and matching producer/consumer
25
``ec_connector_extra_config``; see ``mooncake_ec_connector`` module docstring).
26
+ Set ``cross_encoder_cache`` in Mooncake extra config to reuse shared
27
+ Encoder outputs from Store before encoding, retaining P2P delivery.
28
"""
29
30
engine_id: str | None = None
vllm/distributed/ec_transfer/ec_connector/mooncake/config.py
+32
@@ -69,6 +69,10 @@ class MooncakeECConfig:
69
control_timeout_ms: int
70
push_wait_timeout_s: float
71
pool_size: int
72
+ cross_encoder_cache: bool = False
73
+ store_max_pending_items: int = 32
74
+ store_max_pending_bytes: int = 2 * 1024**3
75
+ store_read_buffer_bytes: int = 128 * 1024**2
76
77
@classmethod
78
def from_vllm_config(cls, vllm_config: VllmConfig) -> MooncakeECConfig:
@@ -94,6 +98,24 @@ class MooncakeECConfig:
98
"ec_buffer_size", ec_config.ec_buffer_size
99
)
100
get = ec_config.get_from_extra_config
101
+ shared_reuse = get("cross_encoder_cache", False)
102
+ if not isinstance(shared_reuse, bool):
103
+ raise ValueError("cross_encoder_cache must be a boolean")
104
+ if shared_reuse:
105
+ if not ec_config.is_encode_only:
106
+ raise ValueError(
107
+ "cross_encoder_cache requires an Encoder-only producer"
108
+ )
109
+ if not vllm_config.use_v2_model_runner:
110
+ raise ValueError("cross_encoder_cache requires Model Runner V2")
111
+ if vllm_config.lora_config is not None:
112
+ raise ValueError("cross_encoder_cache does not support LoRA")
113
+ mm_config = vllm_config.model_config.multimodal_config
114
+ if mm_config is not None and mm_config.mm_processor_cache_gb == 0:
115
+ raise ValueError(
116
+ "cross_encoder_cache requires mm_processor_cache_gb > 0 "
117
+ "to preserve content identifiers across Encoders and restarts"
118
+ )
119
control_port = int(ec_config.ec_port) + (
120
parallel_config.data_parallel_index * parallel_config.tensor_parallel_size
121
)
@@ -120,4 +142,14 @@ class MooncakeECConfig:
142
"push_wait_timeout_s", get("push_wait_timeout_s", 60)
143
),
144
pool_size=registered_buffer_size,
145
+ cross_encoder_cache=shared_reuse,
146
+ store_max_pending_items=_positive_int(
147
+ "store_max_pending_items", get("store_max_pending_items", 32)
148
+ ),
149
+ store_max_pending_bytes=_positive_int(
150
+ "store_max_pending_bytes", get("store_max_pending_bytes", 2 * 1024**3)
151
+ ),
152
+ store_read_buffer_bytes=_positive_int(
153
+ "store_read_buffer_bytes", get("store_read_buffer_bytes", 128 * 1024**2)
154
+ ),
155
)
vllm/distributed/ec_transfer/ec_connector/mooncake/metadata.py
+4
@@ -10,6 +10,9 @@ from vllm.distributed.ec_transfer.ec_connector.base import (
10
ECConnectorMetadata,
11
ECConnectorWorkerMetadata,
12
)
13
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.data import (
14
+ TensorSpec,
15
+)
16
17
18
@dataclass
@@ -46,6 +49,7 @@ class ECMooncakeConnectorMetadata(ECConnectorMetadata):
49
loads: list[ECMooncakeLoadSpec] = field(default_factory=list)
50
pushes: list[ECMooncakePushSpec] = field(default_factory=list)
51
freed: list[str] | None = None
52
+ store_candidates: dict[str, TensorSpec] = field(default_factory=dict)
53
54
55
@dataclass
vllm/distributed/ec_transfer/ec_connector/mooncake/scheduler.py
+34
-10
@@ -45,6 +45,9 @@ from vllm.distributed.ec_transfer.ec_connector.mooncake.state import (
45
from vllm.distributed.ec_transfer.ec_connector.mooncake.transfer import (
46
ensure_mooncake_available,
47
)
48
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.data import (
49
+ TensorSpec,
50
+)
51
from vllm.distributed.ec_transfer.ec_connector.utils import (
52
PlaceholderMetadataResolver,
53
collect_ec_item_metadata,
@@ -76,6 +79,8 @@ class ECMooncakeScheduler:
79
ensure_mooncake_available()
80
config = MooncakeECConfig.from_vllm_config(vllm_config)
81
82
+ self._store_candidates: list[tuple[str, str, TensorSpec]] = []
83
+ self._cross_encoder_cache = config.cross_encoder_cache
84
self._is_producer = config.is_producer
85
self._is_consumer = config.is_consumer
86
self._control_addr = config.control_addr
@@ -427,28 +432,40 @@ class ECMooncakeScheduler:
432
transfer_id = f"{request.request_id}:{index}"
433
if not consumer_zmq or transfer_id in self._prepared_push_transfer_ids:
434
return
430
- num_tokens = request.get_num_encoder_embeds(index)
431
- dtype = self._model_config.dtype
432
- assert isinstance(dtype, torch.dtype)
433
- assert self._encoder_cache_hidden_dim is not None
434
- dtype_name = str(dtype).split(".")[-1]
435
- shape = (num_tokens, self._encoder_cache_hidden_dim)
436
- nbytes = math.prod(shape) * dtype.itemsize
435
+ output = self._encoder_output_spec(request, index)
436
self._pushes_to_prepare[transfer_id] = ECMooncakePushSpec(
437
mm_hash=mm_hash,
439
- nbytes=nbytes,
440
- shape=shape,
441
- dtype=dtype_name,
438
+ nbytes=output.nbytes,
439
+ shape=output.shape,
440
+ dtype=output.dtype.removeprefix("torch."),
441
consumer_zmq=str(consumer_zmq),
442
transfer_id=transfer_id,
443
request_id=request.request_id,
444
)
445
self._prepared_push_transfer_ids.add(transfer_id)
446
447
+ def _encoder_output_spec(self, request: Any, index: int) -> TensorSpec:
448
+ dtype = self._model_config.dtype
449
+ assert isinstance(dtype, torch.dtype)
450
+ assert self._encoder_cache_hidden_dim is not None
451
+ shape = (request.get_num_encoder_embeds(index), self._encoder_cache_hidden_dim)
452
+ return TensorSpec(shape, str(dtype), math.prod(shape) * dtype.itemsize)
453
+
454
def update_state_after_alloc(self, request: Any, index: int) -> None:
455
self._local_cache.add(request.mm_features[index].identifier)
456
if self._is_producer:
457
self._prepare_push_spec(request, index)
458
+ if (
459
+ self._cross_encoder_cache
460
+ and request.mm_features[index].modality == "image"
461
+ ):
462
+ self._store_candidates.append(
463
+ (
464
+ request.request_id,
465
+ request.mm_features[index].identifier,
466
+ self._encoder_output_spec(request, index),
467
+ )
468
+ )
469
470
def update_state_after_free(self, request: Any, index: int) -> None:
471
if not self._is_consumer:
@@ -474,6 +491,13 @@ class ECMooncakeScheduler:
491
meta = ECMooncakeConnectorMetadata(
492
freed=scheduler_output.free_encoder_mm_hashes
493
)
494
+ preempted = scheduler_output.preempted_req_ids or set()
495
+ meta.store_candidates = {
496
+ identifier: output
497
+ for request_id, identifier, output in self._store_candidates
498
+ if request_id not in preempted
499
+ }
500
+ self._store_candidates.clear()
501
for push_spec in self._pushes_to_prepare.values():
502
meta.pushes.append(push_spec)
503
self._pushes_to_prepare.clear()
vllm/distributed/ec_transfer/ec_connector/mooncake/worker.py
+48
@@ -49,6 +49,9 @@ from vllm.distributed.ec_transfer.ec_connector.mooncake.transfer import (
49
MooncakeTransfer,
50
ensure_mooncake_available,
51
)
52
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.data import (
53
+ build_embedding_namespace,
54
+)
55
from vllm.logger import init_logger
56
from vllm.utils.network_utils import get_ip
57
@@ -59,6 +62,10 @@ _T = TypeVar("_T")
62
if TYPE_CHECKING:
63
from vllm.config import VllmConfig
64
65
+ from ..mooncake_store_embedding.backend import (
66
+ MooncakeEmbeddingStoreBackend,
67
+ )
68
+
69
_RESERVATION_REFRESH_SECONDS = _RESERVATION_TTL_SECONDS / 2
70
_MAX_CANCELLED_TRANSFER_IDS = 1 << 16
71
_CANCEL_ATTEMPTS = 2
@@ -87,6 +94,17 @@ class ECMooncakeWorker:
94
def __init__(self, vllm_config: VllmConfig) -> None:
95
ensure_mooncake_available()
96
config = MooncakeECConfig.from_vllm_config(vllm_config)
97
+ self._store_config = (
98
+ (
99
+ build_embedding_namespace(vllm_config),
100
+ config.store_max_pending_items,
101
+ config.store_max_pending_bytes,
102
+ config.store_read_buffer_bytes,
103
+ )
104
+ if config.cross_encoder_cache
105
+ else None
106
+ )
107
+ self._output_store: MooncakeEmbeddingStoreBackend | None = None
108
self.is_producer = config.is_producer
109
self.is_consumer = config.is_consumer
110
self._buffer_device = config.buffer_device
@@ -173,6 +191,21 @@ class ECMooncakeWorker:
191
self._is_receiving_rank = True
192
193
def start_services(self) -> None:
194
+ if self._store_config is not None and self._output_store is None:
195
+ from ..mooncake_store_embedding.backend import (
196
+ MooncakeEmbeddingStoreBackend,
197
+ )
198
+ from ..mooncake_store_embedding.store_client import (
199
+ create_mooncake_embedding_store_client,
200
+ )
201
+
202
+ namespace, max_items, max_bytes, read_bytes = self._store_config
203
+ self._output_store = MooncakeEmbeddingStoreBackend(
204
+ create_mooncake_embedding_store_client(read_buffer_bytes=read_bytes),
205
+ namespace,
206
+ max_pending_items=max_items,
207
+ max_pending_bytes=max_bytes,
208
+ )
209
if not self.is_consumer or self._control_server is not None:
210
return
211
self._resolve_consumer_rank()
@@ -470,6 +503,14 @@ class ECMooncakeWorker:
503
tensor = encoder_cache.get(mm_hash)
504
if tensor is not None:
505
self._bind_push_source(tensor, mm_hash)
506
+ if self._output_store is not None:
507
+ loaded = self._output_store.resolve_inputs(
508
+ metadata.store_candidates, encoder_cache, self._buffer_device
509
+ )
510
+ # Loaded entries precede the runner's new-output snapshot, so they
511
+ # need explicit P2P source binding here.
512
+ for mm_hash in loaded:
513
+ self._bind_push_source(encoder_cache[mm_hash], mm_hash)
514
515
def _reserve_batch(self, records: list[ProducerPushRecord]) -> None:
516
"""Resolve independent item futures with one reserve RPC per shard."""
@@ -851,6 +892,8 @@ class ECMooncakeWorker:
892
if not self.is_producer:
893
return None, None
894
895
+ if self._output_store is not None:
896
+ self._output_store.reap()
897
for record in self._producer_pushes.cancel_requests(finished_req_ids):
898
self._producer_pushes.submit_cancel(
899
record,
@@ -866,6 +909,8 @@ class ECMooncakeWorker:
909
return
910
tensor = encoder_cache[mm_hash]
911
self._bind_push_source(tensor, mm_hash)
912
+ if self._output_store is not None:
913
+ self._output_store.save_output(mm_hash, tensor)
914
915
def build_connector_worker_meta(self) -> ECMooncakeWorkerMetadata | None:
916
if self.is_consumer and not self._is_receiving_rank:
@@ -938,3 +983,6 @@ class ECMooncakeWorker:
983
)
984
self._producer_memory.close()
985
self._transfer.close()
986
+ if self._output_store is not None:
987
+ self._output_store.shutdown()
988
+ self._output_store = None
vllm/distributed/ec_transfer/ec_connector/mooncake_store_embedding/__init__.py
new
+3
@@ -0,0 +1,3 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Mooncake Store support for cross-Encoder output reuse."""
vllm/distributed/ec_transfer/ec_connector/mooncake_store_embedding/backend.py
new
+214
@@ -0,0 +1,214 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Encoder-side Mooncake Store resolver and publisher."""
4
+
5
+from __future__ import annotations
6
+
7
+import threading
8
+import traceback
9
+from collections.abc import Mapping
10
+from concurrent.futures import Future, ThreadPoolExecutor
11
+from dataclasses import dataclass
12
+
13
+import torch
14
+
15
+from vllm.logger import init_logger
16
+
17
+from .data import TensorSpec, make_embedding_key
18
+from .store_client import (
19
+ EmbeddingStoreError,
20
+ EmbeddingStoreOperationError,
21
+ MooncakeEmbeddingStoreClient,
22
+)
23
+
24
+logger = init_logger(__name__)
25
+
26
+
27
+@dataclass
28
+class _StoreSave:
29
+ tensor: torch.Tensor | None
30
+ ready_event: torch.Event | None
31
+ storage_ptr: int
32
+
33
+ def release(self) -> None:
34
+ self.tensor = None
35
+ self.ready_event = None
36
+
37
+
38
+class MooncakeEmbeddingStoreBackend:
39
+ """Resolve immutable encoder outputs and publish misses asynchronously."""
40
+
41
+ def __init__(
42
+ self,
43
+ store_client: MooncakeEmbeddingStoreClient,
44
+ namespace: str,
45
+ *,
46
+ max_pending_items: int,
47
+ max_pending_bytes: int,
48
+ ) -> None:
49
+ self.store_client = store_client
50
+ self.namespace = namespace
51
+ self.max_pending_items = max_pending_items
52
+ self.max_pending_bytes = max_pending_bytes
53
+ self._executor = ThreadPoolExecutor(
54
+ max_workers=1,
55
+ thread_name_prefix="ec-mooncake-store",
56
+ )
57
+ self._pending: dict[str, tuple[Future[None], _StoreSave]] = {}
58
+ self._pending_storages: dict[int, tuple[torch.UntypedStorage, int]] = {}
59
+ self._pending_lock = threading.Lock()
60
+ self._pending_bytes = 0
61
+ self._step_candidates: set[str] = set()
62
+ self._closed = False
63
+ self._fatal_error: BaseException | None = None
64
+
65
+ def save_output(self, identifier: str, tensor: torch.Tensor) -> None:
66
+ if identifier in self._step_candidates:
67
+ self.reap()
68
+ self._enqueue_save(identifier, tensor)
69
+
70
+ def resolve_inputs(
71
+ self,
72
+ expected_tensors: Mapping[str, TensorSpec],
73
+ encoder_cache: dict[str, torch.Tensor],
74
+ device: torch.device | str,
75
+ ) -> set[str]:
76
+ """Load outputs before encoding; unresolved items retain normal computation."""
77
+ self.reap()
78
+ self._step_candidates = set(expected_tensors)
79
+ candidates = [key for key in expected_tensors if key not in encoder_cache]
80
+ if not candidates:
81
+ return set()
82
+
83
+ pool_keys = [
84
+ make_embedding_key(self.namespace, identifier) for identifier in candidates
85
+ ]
86
+ try:
87
+ exists = self.store_client.batch_exists(pool_keys)
88
+ except (EmbeddingStoreOperationError, OSError):
89
+ logger.warning(
90
+ "Mooncake embedding Store lookup failed; falling back to encoder",
91
+ exc_info=True,
92
+ )
93
+ return set()
94
+
95
+ hits = {
96
+ pool_key: expected_tensors[identifier]
97
+ for identifier, pool_key, hit in zip(
98
+ candidates, pool_keys, exists, strict=True
99
+ )
100
+ if hit
101
+ }
102
+ try:
103
+ tensors = self.store_client.load_tensors(hits, device)
104
+ except (EmbeddingStoreOperationError, OSError):
105
+ logger.warning(
106
+ "Mooncake embedding Store GET failed; falling back to encoder",
107
+ exc_info=True,
108
+ )
109
+ return set()
110
+ loaded: set[str] = set()
111
+ for identifier, pool_key in zip(candidates, pool_keys, strict=True):
112
+ if pool_key in tensors:
113
+ encoder_cache[identifier] = tensors[pool_key]
114
+ loaded.add(identifier)
115
+ return loaded
116
+
117
+ def _enqueue_save(self, identifier: str, tensor: torch.Tensor) -> bool:
118
+ """Admit contiguous outputs, charging each retained storage once."""
119
+ if not tensor.is_contiguous():
120
+ return False
121
+ storage = tensor.untyped_storage()
122
+ storage_ptr = storage.data_ptr()
123
+ with self._pending_lock:
124
+ if self._fatal_error is not None:
125
+ raise EmbeddingStoreError(
126
+ "Store publisher has failed"
127
+ ) from self._fatal_error
128
+ if self._closed or identifier in self._pending:
129
+ return False
130
+ _, ref_count = self._pending_storages.get(storage_ptr, (storage, 0))
131
+ budget_bytes = 0 if ref_count else storage.nbytes()
132
+ if (
133
+ len(self._pending) >= self.max_pending_items
134
+ or self._pending_bytes + budget_bytes > self.max_pending_bytes
135
+ ):
136
+ return False
137
+ # Only admitted work creates an event. Runtime errors propagate.
138
+ save = _StoreSave(
139
+ tensor,
140
+ _record_tensor_ready_event(tensor),
141
+ storage_ptr,
142
+ )
143
+ future = self._executor.submit(
144
+ self._save, make_embedding_key(self.namespace, identifier), save
145
+ )
146
+ self._pending[identifier] = (future, save)
147
+ # Keep the storage alive until reaping so its address cannot be reused.
148
+ self._pending_storages[storage_ptr] = (storage, ref_count + 1)
149
+ self._pending_bytes += budget_bytes
150
+ return True
151
+
152
+ def _save(self, pool_key: str, save: _StoreSave) -> None:
153
+ try:
154
+ if self.store_client.exists(pool_key):
155
+ return
156
+ tensor = save.tensor
157
+ assert tensor is not None
158
+ if save.ready_event is not None:
159
+ save.ready_event.synchronize()
160
+ self.store_client.put_tensor(pool_key, tensor)
161
+ except BaseException as error:
162
+ # Returned client frames can otherwise keep tensors via a Future's
163
+ # traceback. Uncertain I/O owners remain retained by the client.
164
+ traceback.clear_frames(error.__traceback__)
165
+ raise
166
+ finally:
167
+ tensor = None
168
+ save.release()
169
+
170
+ def reap(self) -> None:
171
+ with self._pending_lock:
172
+ for identifier, (future, save) in list(self._pending.items()):
173
+ # Observe once; a racing completion is handled on the next poll.
174
+ if not future.done():
175
+ continue
176
+ try:
177
+ future.result() # Already done: never waits on Store I/O.
178
+ except (EmbeddingStoreOperationError, OSError) as error:
179
+ logger.warning("Store PUT failed for %s: %s", identifier, error)
180
+ # This failure is consumed; do not retain publisher/caller frames.
181
+ error.__traceback__ = None
182
+ except BaseException as error:
183
+ # The client may still own unsafe native-I/O buffers. Keep
184
+ # their charge and fatal result until worker termination.
185
+ if self._fatal_error is None:
186
+ self._fatal_error = error
187
+ continue
188
+ del self._pending[identifier]
189
+ storage, ref_count = self._pending_storages[save.storage_ptr]
190
+ if ref_count == 1:
191
+ del self._pending_storages[save.storage_ptr]
192
+ self._pending_bytes -= storage.nbytes()
193
+ else:
194
+ self._pending_storages[save.storage_ptr] = (storage, ref_count - 1)
195
+ fatal = self._fatal_error
196
+ if fatal is not None:
197
+ raise fatal
198
+
199
+ def shutdown(self) -> None:
200
+ with self._pending_lock:
201
+ if self._closed:
202
+ return
203
+ self._closed = True
204
+ self._executor.shutdown(wait=True, cancel_futures=False)
205
+ self.reap()
206
+ self.store_client.close()
207
+
208
+
209
+def _record_tensor_ready_event(tensor: torch.Tensor) -> torch.Event | None:
210
+ if tensor.device.type != "cuda":
211
+ return None
212
+ event = torch.Event()
213
+ event.record(torch.accelerator.current_stream(tensor.device))
214
+ return event
vllm/distributed/ec_transfer/ec_connector/mooncake_store_embedding/data.py
new
+51
@@ -0,0 +1,51 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Content identities and output contracts for the shared Encoder Store."""
4
+
5
+from __future__ import annotations
6
+
7
+from dataclasses import dataclass
8
+from typing import TYPE_CHECKING
9
+
10
+if TYPE_CHECKING:
11
+ from vllm.config import VllmConfig
12
+
13
+EMBEDDING_CACHE_KEY_VERSION = "v3"
14
+
15
+
16
+def make_embedding_key(namespace: str, identifier: str) -> str:
17
+ return f"{namespace}@id:{identifier}"
18
+
19
+
20
+@dataclass(frozen=True)
21
+class TensorSpec:
22
+ """Contiguous output contract used by the Encoder and Store codec."""
23
+
24
+ shape: tuple[int, ...]
25
+ dtype: str
26
+ nbytes: int
27
+
28
+
29
+def build_embedding_namespace(vllm_config: VllmConfig) -> str:
30
+ model_config = vllm_config.model_config
31
+ ec_config = vllm_config.ec_transfer_config
32
+ assert ec_config is not None
33
+ extra_config = ec_config.ec_connector_extra_config
34
+
35
+ multimodal_config = model_config.multimodal_config
36
+ encoder_hash = (
37
+ multimodal_config.compute_hash()
38
+ if multimodal_config is not None
39
+ else "encoder:default"
40
+ )
41
+ cache_prefix = str(extra_config.get("embedding_cache_prefix", ""))
42
+ model_name = str(extra_config.get("embedding_model_identity", model_config.model))
43
+ prefix = f"{cache_prefix}@" if cache_prefix else ""
44
+ return (
45
+ f"{prefix}embedding"
46
+ f"@model:{model_name}"
47
+ f"@revision:{model_config.revision or 'default'}"
48
+ f"@encoder:{encoder_hash}"
49
+ f"@dtype:{model_config.dtype}"
50
+ f"@protocol:{EMBEDDING_CACHE_KEY_VERSION}"
51
+ )
vllm/distributed/ec_transfer/ec_connector/mooncake_store_embedding/store_client.py
new
+463
@@ -0,0 +1,463 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Thin Mooncake Store client for embedding objects.
4
+
5
+Originally adapted from vLLM PR #47302 (0d1f71f5d36c). The v3 key namespace
6
+uses a compact header for complete 2D embeddings, not that PR's tensor format.
7
+"""
8
+
9
+from __future__ import annotations
10
+
11
+import ctypes
12
+import struct
13
+import threading
14
+from collections.abc import Callable, Iterator, Mapping
15
+from contextlib import contextmanager
16
+from enum import IntEnum
17
+from typing import Any
18
+
19
+import torch
20
+
21
+from vllm.distributed.ec_transfer.ec_connector.mooncake_store_embedding.data import (
22
+ TensorSpec,
23
+)
24
+from vllm.distributed.mooncake_store import MooncakeStoreConfig, setup_mooncake_store
25
+from vllm.logger import init_logger
26
+from vllm.utils.network_utils import get_ip
27
+
28
+logger = init_logger(__name__)
29
+
30
+# A failed transfer can still use registered memory after the binding returns.
31
+# Poisoned clients retain their owners until process exit, including if normal
32
+# worker teardown drops the connector. New operations are rejected.
33
+_UNSAFE_CLIENTS: list[Any] = []
34
+
35
+
36
+# Values from Mooncake v0.3.12 / v0.3.12.post1 types.h (ErrorCode).
37
+# Python exposes integer returns, not this enum.
38
+class _MooncakeErrorCode(IntEnum):
39
+ NO_AVAILABLE_HANDLE = -200
40
+ INVALID_PARAMS = -600
41
+ INVALID_REPLICA = -702
42
+ REPLICA_IS_NOT_READY = -703
43
+ OBJECT_NOT_FOUND = -704
44
+ OBJECT_ALREADY_EXISTS = -705
45
+ LEASE_EXPIRED = -707
46
+ RPC_FAIL = -900
47
+ RPC_TIMEOUT = -901
48
+ TENANT_QUOTA_EXCEEDED = -1700
49
+
50
+
51
+# For RAM batch reads and single-replica writes, these rejections occur
52
+# before submission or after completed I/O. Transfer timeouts can leave I/O
53
+# in flight; all other failures retain registered buffers and poison the client.
54
+_SAFE_IO_REJECTIONS = frozenset(
55
+ {
56
+ _MooncakeErrorCode.NO_AVAILABLE_HANDLE,
57
+ _MooncakeErrorCode.REPLICA_IS_NOT_READY,
58
+ _MooncakeErrorCode.OBJECT_NOT_FOUND,
59
+ _MooncakeErrorCode.OBJECT_ALREADY_EXISTS,
60
+ _MooncakeErrorCode.RPC_FAIL,
61
+ _MooncakeErrorCode.RPC_TIMEOUT,
62
+ }
63
+)
64
+# Batch GET validates capacity/replicas before I/O and checks leases afterwards.
65
+# PUT rejects tenant quotas before allocating replicas or submitting transfers.
66
+_SAFE_GET_REJECTIONS = _SAFE_IO_REJECTIONS | {
67
+ _MooncakeErrorCode.INVALID_PARAMS,
68
+ _MooncakeErrorCode.INVALID_REPLICA,
69
+ _MooncakeErrorCode.LEASE_EXPIRED,
70
+}
71
+_SAFE_PUT_REJECTIONS = _SAFE_IO_REJECTIONS | {_MooncakeErrorCode.TENANT_QUOTA_EXCEEDED}
72
+
73
+
74
+_MOONCAKE_TENSOR_OBJECT_MAGIC = 0x4D4F4F4E
75
+# Magic, dtype, token count, hidden dimension. Format versioning is in the key.
76
+_MOONCAKE_TENSOR_HEADER = struct.Struct("<IIQQ")
77
+_MOONCAKE_DTYPE_TO_TORCH_DTYPE = {
78
+ 0: "torch.float32",
79
+ 11: "torch.float16",
80
+ 12: "torch.bfloat16",
81
+}
82
+_TORCH_DTYPE_TO_MOONCAKE_DTYPE = {
83
+ value: key for key, value in _MOONCAKE_DTYPE_TO_TORCH_DTYPE.items()
84
+}
85
+
86
+
87
+def create_mooncake_embedding_store_client(
88
+ read_buffer_bytes: int = 128 * 1024**2,
89
+) -> MooncakeEmbeddingStoreClient:
90
+ try:
91
+ from mooncake.store import ( # type: ignore
92
+ MooncakeDistributedStore,
93
+ ObjectDataType,
94
+ ReplicateConfig,
95
+ )
96
+ except ImportError as e:
97
+ raise ImportError(
98
+ "Install mooncake with Store support to enable cross_encoder_cache."
99
+ ) from e
100
+
101
+ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import rdma_utils
102
+
103
+ config = MooncakeStoreConfig.load_from_config()
104
+ if config.enable_offload:
105
+ raise ValueError(
106
+ "cross_encoder_cache supports a RAM Store; disable enable_offload"
107
+ )
108
+ store = MooncakeDistributedStore()
109
+ local_ip = get_ip()
110
+ local_hostname = rdma_utils.get_requester_local_hostname(local_ip)
111
+ setup_mooncake_store(store, config, local_hostname)
112
+
113
+ logger.info(
114
+ "Initialized embedding Mooncake store mode=%s global_segment_size=%d "
115
+ "local_buffer_size=%d",
116
+ config.mode,
117
+ config.global_segment_size,
118
+ config.local_buffer_size,
119
+ )
120
+ replicate_config = ReplicateConfig()
121
+ replicate_config.data_type = ObjectDataType.TENSOR
122
+ return MooncakeEmbeddingStoreClient(
123
+ store, replicate_config=replicate_config, read_buffer_bytes=read_buffer_bytes
124
+ )
125
+
126
+
127
+class EmbeddingStoreError(RuntimeError):
128
+ """A Store failure that must propagate to the worker."""
129
+
130
+
131
+class EmbeddingStoreOperationError(EmbeddingStoreError):
132
+ """A rejected or completed operation that permits normal Encoder fallback."""
133
+
134
+
135
+def _batch_get_completed_safely(results: Any, capacities: list[int]) -> bool:
136
+ return (
137
+ isinstance(results, list)
138
+ and len(results) == len(capacities)
139
+ and all(
140
+ type(result) is int
141
+ and (0 <= result <= capacity or result in _SAFE_GET_REJECTIONS)
142
+ for result, capacity in zip(results, capacities, strict=True)
143
+ )
144
+ )
145
+
146
+
147
+class MooncakeEmbeddingStoreClient:
148
+ """Wraps Mooncake object and buffer APIs used by embedding transfer."""
149
+
150
+ def __init__(
151
+ self,
152
+ store: Any,
153
+ replicate_config: Any | None = None,
154
+ *,
155
+ read_buffer_bytes: int = 128 * 1024**2,
156
+ ):
157
+ self._lifetime_lock = threading.Lock()
158
+ self._unsafe_owners: list[Any] = []
159
+ self._poisoned = False
160
+ self.store = store
161
+ self.replicate_config = replicate_config
162
+ self._read_buffer_bytes = read_buffer_bytes
163
+ self._read_buffer: torch.Tensor | None = None
164
+ self._put_header: ctypes.Array[ctypes.c_char] | None = None
165
+
166
+ def _check_healthy(self) -> None:
167
+ if self._poisoned:
168
+ raise EmbeddingStoreError(
169
+ "Mooncake I/O completion is unconfirmed; worker must exit"
170
+ )
171
+
172
+ def _poison(self, owners: list[Any]) -> None:
173
+ with self._lifetime_lock:
174
+ self._unsafe_owners.extend(owners)
175
+ if not self._poisoned:
176
+ self._poisoned = True
177
+ _UNSAFE_CLIENTS.append(self)
178
+
179
+ @contextmanager
180
+ def _registered_io(
181
+ self,
182
+ buffers: list[tuple[Any, int, int]],
183
+ ) -> Iterator[Callable[[int], None]]:
184
+ self._check_healthy()
185
+ registered = []
186
+ submitted = False
187
+ confirmed = False
188
+
189
+ def confirm(result: int) -> None:
190
+ nonlocal confirmed
191
+ if type(result) is not int or (
192
+ result < 0 and result not in _SAFE_PUT_REJECTIONS
193
+ ):
194
+ raise EmbeddingStoreError(
195
+ "Mooncake I/O completion is unconfirmed; retaining buffers"
196
+ )
197
+ confirmed = True
198
+
199
+ try:
200
+ for owner, addr, size in buffers:
201
+ ret = self.store.register_buffer(addr, size)
202
+ if ret != 0:
203
+ raise EmbeddingStoreOperationError(
204
+ f"Failed to register embedding buffer: {ret}"
205
+ )
206
+ registered.append(addr)
207
+ submitted = True
208
+ yield confirm
209
+ except BaseException as error:
210
+ if submitted and not confirmed:
211
+ self._poison([owner for owner, _, _ in buffers])
212
+ raise EmbeddingStoreError(
213
+ "Mooncake I/O completion is unconfirmed; retaining buffers"
214
+ ) from error
215
+ raise
216
+ finally:
217
+ if not submitted or confirmed:
218
+ try:
219
+ for addr in reversed(registered):
220
+ self._unregister_buffer(addr)
221
+ except BaseException:
222
+ self._poison([owner for owner, _, _ in buffers])
223
+ raise
224
+
225
+ def close(self) -> None:
226
+ """Close the supported Mooncake binding after all I/O is drained."""
227
+ self._check_healthy()
228
+ if self._read_buffer is not None:
229
+ try:
230
+ self._unregister_buffer(self._read_buffer.data_ptr())
231
+ except BaseException:
232
+ self._poison([self._read_buffer])
233
+ raise
234
+ self._read_buffer = None
235
+ if self._put_header is not None:
236
+ try:
237
+ self._unregister_buffer(ctypes.addressof(self._put_header))
238
+ except BaseException:
239
+ self._poison([self._put_header])
240
+ raise
241
+ self._put_header = None
242
+ ret = self.store.close()
243
+ if ret != 0:
244
+ raise EmbeddingStoreError(
245
+ f"failed to close embedding Mooncake Store: {ret}"
246
+ )
247
+
248
+ def exists(self, pool_key: str) -> bool:
249
+ return self.batch_exists([pool_key])[0]
250
+
251
+ def batch_exists(self, keys: list[str]) -> list[bool]:
252
+ self._check_healthy()
253
+ if not keys:
254
+ return []
255
+
256
+ states = self.store.batch_is_exist(keys)
257
+ if len(states) != len(keys):
258
+ raise RuntimeError(
259
+ "Mooncake Store returned an unexpected number of lookup results"
260
+ )
261
+ return [state == 1 for state in states]
262
+
263
+ def load_tensors(
264
+ self, expected: Mapping[str, TensorSpec], device: torch.device | str
265
+ ) -> dict[str, torch.Tensor]:
266
+ """Synchronously load runner inputs into independently owned tensors."""
267
+ self._check_healthy()
268
+ device = torch.device(device)
269
+ loaded: dict[str, torch.Tensor] = {}
270
+ chunk: dict[str, TensorSpec] = {}
271
+ chunk_bytes = 0
272
+ for key, spec in expected.items():
273
+ size = _MOONCAKE_TENSOR_HEADER.size + spec.nbytes
274
+ if size > self._read_buffer_bytes:
275
+ logger.warning(
276
+ "Skipping Store GET for %s: object bytes=%d exceed read buffer=%d",
277
+ key,
278
+ size,
279
+ self._read_buffer_bytes,
280
+ )
281
+ continue
282
+ if chunk_bytes + size > self._read_buffer_bytes:
283
+ loaded.update(self._load_chunk(chunk, device))
284
+ chunk = {}
285
+ chunk_bytes = 0
286
+ chunk[key] = spec
287
+ chunk_bytes += size
288
+ if chunk:
289
+ loaded.update(self._load_chunk(chunk, device))
290
+ return loaded
291
+
292
+ def _load_chunk(
293
+ self, expected: dict[str, TensorSpec], device: torch.device
294
+ ) -> dict[str, torch.Tensor]:
295
+ self._check_healthy()
296
+ if self._read_buffer is None:
297
+ buffer = torch.empty(
298
+ self._read_buffer_bytes,
299
+ dtype=torch.uint8,
300
+ device="cpu",
301
+ pin_memory=device.type == "cuda",
302
+ )
303
+ ret = self.store.register_buffer(buffer.data_ptr(), buffer.nbytes)
304
+ if ret != 0:
305
+ raise EmbeddingStoreOperationError(
306
+ f"Failed to register embedding read buffer: {ret}"
307
+ )
308
+ self._read_buffer = buffer
309
+ buffer = self._read_buffer
310
+ base = buffer.data_ptr()
311
+ sizes = [
312
+ _MOONCAKE_TENSOR_HEADER.size + spec.nbytes for spec in expected.values()
313
+ ]
314
+ offsets: list[int] = []
315
+ end = 0
316
+ for size in sizes:
317
+ offsets.append(end)
318
+ end += size
319
+ try:
320
+ results = self.store.batch_get_into(
321
+ list(expected), [base + offset for offset in offsets], sizes
322
+ )
323
+ if not _batch_get_completed_safely(results, sizes):
324
+ raise EmbeddingStoreError("Unsafe batch GET results")
325
+ except BaseException as error:
326
+ self._poison([buffer])
327
+ raise EmbeddingStoreError(
328
+ "Mooncake I/O completion is unconfirmed; retaining read buffer"
329
+ ) from error
330
+
331
+ valid: list[tuple[str, TensorSpec, int]] = []
332
+ for (key, spec), offset, size, result in zip(
333
+ expected.items(), offsets, sizes, results, strict=True
334
+ ):
335
+ if result != size:
336
+ logger.warning(
337
+ "Store GET skipped for %s: result=%d, expected bytes=%d",
338
+ key,
339
+ result,
340
+ size,
341
+ )
342
+ continue
343
+ try:
344
+ _validate_mooncake_tensor_metadata(
345
+ key,
346
+ ctypes.string_at(base + offset, _MOONCAKE_TENSOR_HEADER.size),
347
+ spec,
348
+ )
349
+ except EmbeddingStoreOperationError as error:
350
+ logger.warning("Store GET skipped for %s: %s", key, error)
351
+ continue
352
+ valid.append((key, spec, offset + _MOONCAKE_TENSOR_HEADER.size))
353
+ if not valid:
354
+ return {}
355
+ # Allocate every destination before submitting any asynchronous copy.
356
+ targets = {
357
+ key: torch.empty(
358
+ spec.shape,
359
+ dtype=getattr(torch, spec.dtype.removeprefix("torch.")),
360
+ device=device,
361
+ )
362
+ for key, spec, _ in valid
363
+ }
364
+ ready = torch.Event() if device.type == "cuda" else None
365
+ try:
366
+ for key, spec, offset in valid:
367
+ targets[key].view(torch.uint8).view(-1).copy_(
368
+ buffer[offset : offset + spec.nbytes],
369
+ non_blocking=ready is not None,
370
+ )
371
+ if ready is not None:
372
+ ready.record(torch.accelerator.current_stream(device))
373
+ ready.synchronize()
374
+ except BaseException as error:
375
+ if ready is not None:
376
+ self._poison([buffer, *targets.values(), ready])
377
+ raise EmbeddingStoreError(
378
+ "Embedding copy completion is unconfirmed; retaining buffers"
379
+ ) from error
380
+ raise
381
+ return targets
382
+
383
+ def put_tensor(self, pool_key: str, tensor: torch.Tensor) -> None:
384
+ self._check_healthy()
385
+ if not tensor.is_contiguous():
386
+ raise EmbeddingStoreOperationError("embedding tensor must be contiguous")
387
+ metadata = _encode_mooncake_tensor_metadata(tensor)
388
+ # The single publisher reuses this header only after PUT completion.
389
+ if self._put_header is None:
390
+ header = ctypes.create_string_buffer(len(metadata))
391
+ ret = self.store.register_buffer(ctypes.addressof(header), len(metadata))
392
+ if ret != 0:
393
+ raise EmbeddingStoreOperationError(
394
+ f"Failed to register embedding header: {ret}"
395
+ )
396
+ self._put_header = header
397
+ header_ptr = ctypes.addressof(self._put_header)
398
+ ctypes.memmove(header_ptr, metadata, len(metadata))
399
+ data_size = tensor.numel() * tensor.element_size()
400
+ buffers = [
401
+ (tensor, tensor.data_ptr(), data_size),
402
+ ]
403
+ with self._registered_io(buffers) as confirm:
404
+ [result] = self.store.batch_put_from_multi_buffers(
405
+ [pool_key],
406
+ [[header_ptr, tensor.data_ptr()]],
407
+ [[len(metadata), data_size]],
408
+ self.replicate_config,
409
+ )
410
+ confirm(result)
411
+ if result < 0:
412
+ raise EmbeddingStoreOperationError(
413
+ f"failed to put embedding tensor for {pool_key}"
414
+ )
415
+
416
+ def _unregister_buffer(self, addr: int) -> None:
417
+ try:
418
+ ret = self.store.unregister_buffer(addr)
419
+ except Exception as error:
420
+ raise EmbeddingStoreError(
421
+ f"could not confirm buffer unregistration addr={addr:#x}"
422
+ ) from error
423
+ if ret != 0:
424
+ raise EmbeddingStoreError(
425
+ f"failed to unregister embedding buffer addr={addr:#x}: {ret}"
426
+ )
427
+
428
+
429
+def _validate_mooncake_tensor_metadata(
430
+ pool_key: str,
431
+ metadata: bytes,
432
+ expected: TensorSpec,
433
+) -> None:
434
+ magic, dtype, num_tokens, hidden_dim = _MOONCAKE_TENSOR_HEADER.unpack(metadata)
435
+ if magic != _MOONCAKE_TENSOR_OBJECT_MAGIC:
436
+ raise EmbeddingStoreOperationError(
437
+ f"invalid Mooncake tensor metadata header for {pool_key}"
438
+ )
439
+ if _MOONCAKE_DTYPE_TO_TORCH_DTYPE.get(dtype) != expected.dtype:
440
+ raise EmbeddingStoreOperationError(
441
+ f"unexpected Mooncake tensor dtype for {pool_key}: {dtype}"
442
+ )
443
+ if (num_tokens, hidden_dim) != expected.shape:
444
+ raise EmbeddingStoreOperationError(
445
+ f"embedding tensor shape mismatch for {pool_key}: expected={expected.shape}"
446
+ )
447
+
448
+
449
+def _encode_mooncake_tensor_metadata(tensor: torch.Tensor) -> bytes:
450
+ dtype = str(tensor.dtype)
451
+ if dtype not in _TORCH_DTYPE_TO_MOONCAKE_DTYPE:
452
+ raise EmbeddingStoreOperationError(
453
+ f"unsupported embedding tensor dtype: {dtype}"
454
+ )
455
+ if tensor.ndim != 2:
456
+ raise EmbeddingStoreOperationError(
457
+ f"embedding tensor must be 2D: shape={tuple(tensor.shape)}"
458
+ )
459
+ return _MOONCAKE_TENSOR_HEADER.pack(
460
+ _MOONCAKE_TENSOR_OBJECT_MAGIC,
461
+ _TORCH_DTYPE_TO_MOONCAKE_DTYPE[dtype],
462
+ *tensor.shape,
463
+ )
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+3
-134
@@ -11,7 +11,6 @@ and MooncakeDistributedStore integration.
11
"""
12
13
import dataclasses
14
-import json
14
import math
15
import os
16
import queue
@@ -20,10 +19,8 @@ import threading
19
import time
20
from collections.abc import Callable, Sequence
21
from concurrent.futures import Future, ThreadPoolExecutor
23
-from dataclasses import dataclass
24
-from typing import Any, Literal, TypeVar
22
+from typing import Any, TypeVar
23
26
-import regex as re
24
import torch
25
import zmq
26
@@ -67,6 +64,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
64
decode_lookup_response,
65
encode_lookup_response,
66
)
67
+from vllm.distributed.mooncake_store import MooncakeStoreConfig, setup_mooncake_store
68
from vllm.logger import init_logger
69
from vllm.utils.math_utils import cdiv
70
from vllm.utils.network_utils import get_ip, make_zmq_socket
@@ -94,10 +92,6 @@ from .metrics import MooncakeStoreConnectorStats
92
93
logger = init_logger(__name__)
94
97
-DEFAULT_GLOBAL_SEGMENT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GiB
98
-DEFAULT_LOCAL_BUFFER_SIZE = 4 * 1024 * 1024 * 1024 # 4 GiB
99
-DEFAULT_TENANT_ID = "default"
100
-
95
MOONCAKE_NO_AVAILABLE_HANDLE = -200
96
_T = TypeVar("_T")
97
@@ -149,115 +143,6 @@ _DIRECT_IO_ALIGNMENT = 4096
143
_DIRECT_IO_PADDING_BYTES = 2 * _DIRECT_IO_ALIGNMENT
144
145
152
-MooncakeMode = Literal["embedded", "standalone-store"]
153
-
154
-
155
-@dataclass
156
-class MooncakeStoreConfig:
157
- """Configuration for MooncakeDistributedStore.
158
-
159
- ``mode`` selects the topology: ``embedded`` (each rank contributes
160
- ``global_segment_size`` in-process) or ``standalone-store`` (rank
161
- contributes 0; an external ``mooncake_client`` process owns the pool
162
- and the SSD tier).
163
- """
164
-
165
- metadata_server: str
166
- master_server_address: str
167
- protocol: str
168
- device_name: str
169
- mode: MooncakeMode = "embedded"
170
- global_segment_size: int = DEFAULT_GLOBAL_SEGMENT_SIZE
171
- local_buffer_size: int = DEFAULT_LOCAL_BUFFER_SIZE
172
- enable_offload: bool = False
173
- tenant_id: str = DEFAULT_TENANT_ID
174
-
175
- def __post_init__(self) -> None:
176
- if self.mode not in ("embedded", "standalone-store"):
177
- raise ValueError(f"unknown Mooncake mode: {self.mode!r}")
178
- if self.local_buffer_size <= 0:
179
- raise ValueError("local_buffer_size must be > 0")
180
- if self.mode == "embedded" and self.global_segment_size == 0:
181
- raise ValueError("embedded mode requires global_segment_size > 0")
182
- if self.mode == "standalone-store" and self.global_segment_size != 0:
183
- raise ValueError("standalone-store mode requires global_segment_size == 0")
184
-
185
- @staticmethod
186
- def from_file(file_path: str) -> "MooncakeStoreConfig":
187
- with open(file_path) as file:
188
- config = json.load(file)
189
- return MooncakeStoreConfig(
190
- metadata_server=config.get("metadata_server", ""),
191
- master_server_address=config.get("master_server_address", ""),
192
- protocol=config.get("protocol", "rdma"),
193
- device_name=config.get("device_name", ""),
194
- mode=config.get("mode", "embedded"),
195
- global_segment_size=_parse_size(
196
- config.get("global_segment_size", DEFAULT_GLOBAL_SEGMENT_SIZE)
197
- ),
198
- local_buffer_size=_parse_size(
199
- config.get("local_buffer_size", DEFAULT_LOCAL_BUFFER_SIZE)
200
- ),
201
- enable_offload=bool(config.get("enable_offload", False)),
202
- tenant_id=_normalize_tenant_id(config.get("tenant_id", DEFAULT_TENANT_ID)),
203
- )
204
-
205
- @staticmethod
206
- def load_from_config() -> "MooncakeStoreConfig":
207
- config_path = os.getenv("MOONCAKE_CONFIG_PATH")
208
- if not config_path:
209
- raise ValueError(
210
- "The environment variable 'MOONCAKE_CONFIG_PATH' is not set."
211
- )
212
- return MooncakeStoreConfig.from_file(config_path)
213
-
214
-
215
-def _normalize_tenant_id(value: Any) -> str:
216
- if value is None:
217
- return DEFAULT_TENANT_ID
218
- if not isinstance(value, str):
219
- raise TypeError(
220
- f"tenant_id must be a string or null, got {type(value).__name__}: {value!r}"
221
- )
222
- tenant_id = value.strip()
223
- return tenant_id if tenant_id else DEFAULT_TENANT_ID
224
-
225
-
226
-def _parse_size(value: Any) -> int:
227
- """Parse storage size strings with units: GB, MB, KB, B."""
228
- if isinstance(value, int):
229
- return value
230
- if not isinstance(value, str):
231
- try:
232
- return int(value)
233
- except (TypeError, ValueError) as e:
234
- raise TypeError(f"Unsupported type for size: {type(value)}") from e
235
-
236
- cleaned = value.strip().lower()
237
- if not cleaned:
238
- raise ValueError("Size cannot be empty.")
239
-
240
- unit_multipliers = {
241
- "gb": 1024**3,
242
- "mb": 1024**2,
243
- "kb": 1024,
244
- "b": 1,
245
- }
246
- match = re.match(r"^\s*([\d.]+)\s*(gb|mb|kb|b)?\s*$", cleaned)
247
- if not match:
248
- raise ValueError(f"Invalid format: '{value}'")
249
-
250
- number_str = match.group(1)
251
- unit = match.group(2) or "b"
252
- multiplier = unit_multipliers[unit]
253
-
254
- try:
255
- numeric_value = float(number_str)
256
- except ValueError as exc:
257
- raise ValueError(f"Invalid numeric value '{number_str}' in: '{value}'") from exc
258
- return int(numeric_value * multiplier)
259
-
260
-
146
def _align_up(value: int, alignment: int) -> int:
147
return ((value + alignment - 1) // alignment) * alignment
148
@@ -1595,23 +1480,7 @@ class MooncakeStoreWorker:
1480
self.store = MooncakeDistributedStore()
1481
local_ip = get_ip()
1482
local_hostname = rdma_utils.get_requester_local_hostname(local_ip)
1598
- setup_kwargs: dict[str, str] = {}
1599
- if store_config.tenant_id != DEFAULT_TENANT_ID:
1600
- setup_kwargs["tenant_id"] = store_config.tenant_id
1601
- ret = self.store.setup(
1602
- local_hostname,
1603
- store_config.metadata_server,
1604
- store_config.global_segment_size,
1605
- store_config.local_buffer_size,
1606
- store_config.protocol,
1607
- store_config.device_name,
1608
- store_config.master_server_address,
1609
- **setup_kwargs,
1610
- )
1611
- if ret != 0:
1612
- msg = "Initialize MooncakeDistributedStore failed."
1613
- logger.error(msg)
1614
- raise RuntimeError(msg)
1483
+ setup_mooncake_store(self.store, store_config, local_hostname)
1484
1485
preferred_segment = rdma_utils.get_configured_preferred_segment(extra_config)
1486
self.preferred_segment = preferred_segment
vllm/distributed/mooncake_store.py
new
+144
@@ -0,0 +1,144 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Shared configuration and setup for EC and KV Mooncake Store clients."""
4
+
5
+import json
6
+import os
7
+from dataclasses import dataclass
8
+from typing import Any, Literal
9
+
10
+import regex as re
11
+
12
+DEFAULT_GLOBAL_SEGMENT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GiB
13
+DEFAULT_LOCAL_BUFFER_SIZE = 4 * 1024 * 1024 * 1024 # 4 GiB
14
+DEFAULT_TENANT_ID = "default"
15
+
16
+
17
+MooncakeMode = Literal["embedded", "standalone-store"]
18
+
19
+
20
+@dataclass
21
+class MooncakeStoreConfig:
22
+ """Configuration for MooncakeDistributedStore.
23
+
24
+ ``mode`` selects the topology: ``embedded`` (each rank contributes
25
+ ``global_segment_size`` in-process) or ``standalone-store`` (rank
26
+ contributes 0; an external ``mooncake_client`` process owns the pool
27
+ and the SSD tier).
28
+ """
29
+
30
+ metadata_server: str
31
+ master_server_address: str
32
+ protocol: str
33
+ device_name: str
34
+ mode: MooncakeMode = "embedded"
35
+ global_segment_size: int = DEFAULT_GLOBAL_SEGMENT_SIZE
36
+ local_buffer_size: int = DEFAULT_LOCAL_BUFFER_SIZE
37
+ enable_offload: bool = False
38
+ tenant_id: str = DEFAULT_TENANT_ID
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.mode not in ("embedded", "standalone-store"):
42
+ raise ValueError(f"unknown Mooncake mode: {self.mode!r}")
43
+ if self.local_buffer_size <= 0:
44
+ raise ValueError("local_buffer_size must be > 0")
45
+ if self.mode == "embedded" and self.global_segment_size == 0:
46
+ raise ValueError("embedded mode requires global_segment_size > 0")
47
+ if self.mode == "standalone-store" and self.global_segment_size != 0:
48
+ raise ValueError("standalone-store mode requires global_segment_size == 0")
49
+
50
+ @staticmethod
51
+ def from_file(file_path: str) -> "MooncakeStoreConfig":
52
+ with open(file_path) as file:
53
+ config = json.load(file)
54
+ return MooncakeStoreConfig(
55
+ metadata_server=config.get("metadata_server", ""),
56
+ master_server_address=config.get("master_server_address", ""),
57
+ protocol=config.get("protocol", "rdma"),
58
+ device_name=config.get("device_name", ""),
59
+ mode=config.get("mode", "embedded"),
60
+ global_segment_size=_parse_size(
61
+ config.get("global_segment_size", DEFAULT_GLOBAL_SEGMENT_SIZE)
62
+ ),
63
+ local_buffer_size=_parse_size(
64
+ config.get("local_buffer_size", DEFAULT_LOCAL_BUFFER_SIZE)
65
+ ),
66
+ enable_offload=bool(config.get("enable_offload", False)),
67
+ tenant_id=_normalize_tenant_id(config.get("tenant_id", DEFAULT_TENANT_ID)),
68
+ )
69
+
70
+ @staticmethod
71
+ def load_from_config() -> "MooncakeStoreConfig":
72
+ config_path = os.getenv("MOONCAKE_CONFIG_PATH")
73
+ if not config_path:
74
+ raise ValueError(
75
+ "The environment variable 'MOONCAKE_CONFIG_PATH' is not set."
76
+ )
77
+ return MooncakeStoreConfig.from_file(config_path)
78
+
79
+
80
+def _normalize_tenant_id(value: Any) -> str:
81
+ if value is None:
82
+ return DEFAULT_TENANT_ID
83
+ if not isinstance(value, str):
84
+ raise TypeError(
85
+ f"tenant_id must be a string or null, got {type(value).__name__}: {value!r}"
86
+ )
87
+ tenant_id = value.strip()
88
+ return tenant_id if tenant_id else DEFAULT_TENANT_ID
89
+
90
+
91
+def _parse_size(value: Any) -> int:
92
+ """Parse storage size strings with units: GB, MB, KB, B."""
93
+ if isinstance(value, int):
94
+ return value
95
+ if not isinstance(value, str):
96
+ try:
97
+ return int(value)
98
+ except (TypeError, ValueError) as e:
99
+ raise TypeError(f"Unsupported type for size: {type(value)}") from e
100
+
101
+ cleaned = value.strip().lower()
102
+ if not cleaned:
103
+ raise ValueError("Size cannot be empty.")
104
+
105
+ unit_multipliers = {
106
+ "gb": 1024**3,
107
+ "mb": 1024**2,
108
+ "kb": 1024,
109
+ "b": 1,
110
+ }
111
+ match = re.match(r"^\s*([\d.]+)\s*(gb|mb|kb|b)?\s*$", cleaned)
112
+ if not match:
113
+ raise ValueError(f"Invalid format: '{value}'")
114
+
115
+ number_str = match.group(1)
116
+ unit = match.group(2) or "b"
117
+ multiplier = unit_multipliers[unit]
118
+
119
+ try:
120
+ numeric_value = float(number_str)
121
+ except ValueError as exc:
122
+ raise ValueError(f"Invalid numeric value '{number_str}' in: '{value}'") from exc
123
+ return int(numeric_value * multiplier)
124
+
125
+
126
+def setup_mooncake_store(
127
+ store: Any, config: MooncakeStoreConfig, local_hostname: str
128
+) -> None:
129
+ """Initialize a native Store, preserving explicit tenant configuration."""
130
+ setup_kwargs: dict[str, str] = {}
131
+ if config.tenant_id != DEFAULT_TENANT_ID:
132
+ setup_kwargs["tenant_id"] = config.tenant_id
133
+ ret = store.setup(
134
+ local_hostname,
135
+ config.metadata_server,
136
+ config.global_segment_size,
137
+ config.local_buffer_size,
138
+ config.protocol,
139
+ config.device_name,
140
+ config.master_server_address,
141
+ **setup_kwargs,
142
+ )
143
+ if ret != 0:
144
+ raise RuntimeError("Initialize MooncakeDistributedStore failed.")