[EC Connector] CPU Offloading EC Connector (#47423)

Signed-off-by: omerpaz95 <omerpaz95@gmail.com> Signed-off-by: Or Ozeri <oro@il.ibm.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>

omerpaz95 committed Jul 13, 2026 at 20:09 UTC 43c8cbf79b75a583ef870b13e82dd33e68cf32b1
18 files changed +3008
tests/v1/ec_connector/unit/cpu/scheduler/test_embedding_cache.py new
+306
@@ -0,0 +1,306 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for EmbeddingCache.
4 +
5 +Pure integer bookkeeping — no mmap, torch, or CUDA — so these run anywhere.
6 +"""
7 +
8 +import threading
9 +
10 +import pytest
11 +
12 +from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
13 + EmbeddingCache,
14 +)
15 +
16 +
17 +def _cache(num_blocks: int = 8) -> EmbeddingCache:
18 + return EmbeddingCache(num_blocks)
19 +
20 +
21 +# ── alloc ────────────────────────────────────────────────────────────────────
22 +
23 +
24 +def test_alloc_returns_unique_block_ids():
25 + cache = _cache()
26 + entry = cache.alloc("a", 3)
27 + assert entry is not None
28 + assert len(entry.block_ids) == 3
29 + assert len(set(entry.block_ids)) == 3
30 + assert all(0 <= i < 8 for i in entry.block_ids)
31 +
32 +
33 +def test_alloc_returns_tuple():
34 + cache = _cache()
35 + entry = cache.alloc("a", 2)
36 + assert isinstance(entry.block_ids, tuple)
37 +
38 +
39 +def test_alloc_returns_none_when_full():
40 + cache = _cache(num_blocks=4)
41 + cache.alloc("a", 4)
42 + assert cache.alloc("b", 1) is None
43 +
44 +
45 +def test_alloc_asserts_duplicate_key():
46 + cache = _cache()
47 + cache.alloc("a", 2)
48 + with pytest.raises(AssertionError, match="duplicate alloc"):
49 + cache.alloc("a", 2)
50 +
51 +
52 +def test_alloc_asserts_n_blocks_exceeds_capacity():
53 + cache = _cache(num_blocks=4)
54 + with pytest.raises(AssertionError, match="capacity"):
55 + cache.alloc("a", 5)
56 +
57 +
58 +def test_alloc_zero_blocks():
59 + cache = _cache()
60 + entry = cache.alloc("a", 0)
61 + assert entry is not None
62 + assert entry.block_ids == ()
63 +
64 +
65 +def test_alloc_starts_not_ready():
66 + cache = _cache()
67 + entry = cache.alloc("a", 2)
68 + assert not entry.ready
69 + assert not entry.evictable
70 +
71 +
72 +# ── get ──────────────────────────────────────────────────────────────────────
73 +
74 +
75 +def test_get_missing_returns_none():
76 + cache = _cache()
77 + assert cache.get("a") is None
78 +
79 +
80 +def test_get_returns_entry():
81 + cache = _cache()
82 + alloc_entry = cache.alloc("a", 2)
83 + got = cache.get("a")
84 + assert got is alloc_entry
85 +
86 +
87 +def test_get_returns_not_ready_entry():
88 + cache = _cache()
89 + cache.alloc("a", 2)
90 + entry = cache.get("a")
91 + assert entry is not None
92 + assert not entry.ready
93 +
94 +
95 +# ── mark_ready ───────────────────────────────────────────────────────────────
96 +
97 +
98 +def test_mark_ready_transitions():
99 + cache = _cache()
100 + cache.alloc("a", 2)
101 + entry = cache.get("a")
102 + assert not entry.ready
103 + cache.mark_ready("a")
104 + assert entry.ready
105 + assert entry.evictable
106 +
107 +
108 +def test_mark_ready_asserts_already_ready():
109 + cache = _cache()
110 + cache.alloc("a", 2)
111 + cache.mark_ready("a")
112 + with pytest.raises(AssertionError, match="already-ready"):
113 + cache.mark_ready("a")
114 +
115 +
116 +def test_mark_ready_asserts_missing_key():
117 + cache = _cache()
118 + with pytest.raises(KeyError):
119 + cache.mark_ready("nonexistent")
120 +
121 +
122 +# ── pin / unpin ──────────────────────────────────────────────────────────────
123 +
124 +
125 +def test_pin_asserts_not_ready():
126 + cache = _cache()
127 + cache.alloc("a", 2)
128 + with pytest.raises(AssertionError, match="not-ready"):
129 + cache.pin("a")
130 +
131 +
132 +def test_pin_unpin_basic():
133 + cache = _cache()
134 + cache.alloc("a", 2)
135 + cache.mark_ready("a")
136 + cache.pin("a")
137 + entry = cache.get("a")
138 + assert not entry.evictable
139 + cache.unpin("a")
140 + assert entry.evictable
141 +
142 +
143 +def test_nested_pin():
144 + cache = _cache()
145 + cache.alloc("a", 2)
146 + cache.mark_ready("a")
147 + cache.pin("a")
148 + cache.pin("a")
149 + cache.unpin("a")
150 + entry = cache.get("a")
151 + assert not entry.evictable # still pinned (count=1)
152 + cache.unpin("a")
153 + assert entry.evictable
154 +
155 +
156 +def test_unpin_asserts_on_unpinned():
157 + cache = _cache()
158 + cache.alloc("a", 2)
159 + cache.mark_ready("a")
160 + with pytest.raises(AssertionError, match="unpinned"):
161 + cache.unpin("a")
162 +
163 +
164 +# ── eviction ─────────────────────────────────────────────────────────────────
165 +
166 +
167 +def test_eviction_frees_ready_unpinned_fifo():
168 + cache = _cache(num_blocks=4)
169 + cache.alloc("a", 2)
170 + cache.mark_ready("a")
171 + cache.alloc("b", 2)
172 + cache.mark_ready("b")
173 + # Pool full. Alloc should evict "a" (oldest).
174 + entry_c = cache.alloc("c", 2)
175 + assert entry_c is not None
176 + assert cache.get("a") is None # evicted
177 + assert cache.get("b") is not None # still there
178 +
179 +
180 +def test_eviction_skips_pinned():
181 + cache = _cache(num_blocks=4)
182 + cache.alloc("a", 2)
183 + cache.mark_ready("a")
184 + cache.pin("a")
185 + cache.alloc("b", 2)
186 + cache.mark_ready("b")
187 + # "a" is pinned, so "b" should be evicted instead.
188 + entry_c = cache.alloc("c", 2)
189 + assert entry_c is not None
190 + assert cache.get("a") is not None # pinned, survived
191 + assert cache.get("b") is None # evicted
192 + cache.unpin("a")
193 +
194 +
195 +def test_eviction_skips_not_ready():
196 + cache = _cache(num_blocks=4)
197 + cache.alloc("a", 2) # not ready
198 + cache.alloc("b", 2)
199 + cache.mark_ready("b")
200 + # "a" is not ready (non-evictable), "b" is evictable.
201 + entry_c = cache.alloc("c", 2)
202 + assert entry_c is not None
203 + assert cache.get("a") is not None # not ready, survived
204 + assert cache.get("b") is None # evicted
205 + cache.mark_ready("a") # clean up
206 +
207 +
208 +def test_eviction_returns_none_if_all_non_evictable():
209 + cache = _cache(num_blocks=4)
210 + cache.alloc("a", 2)
211 + cache.mark_ready("a")
212 + cache.pin("a")
213 + cache.alloc("b", 2) # not ready
214 + # Both entries are non-evictable (a=pinned, b=not-ready).
215 + assert cache.alloc("c", 1) is None
216 + # Fail-fast: nothing was evicted.
217 + assert cache.get("a") is not None
218 + assert cache.get("b") is not None
219 + cache.unpin("a")
220 +
221 +
222 +def test_fail_fast_no_wasted_eviction():
223 + cache = _cache(num_blocks=6)
224 + cache.alloc("a", 2)
225 + cache.mark_ready("a") # evictable: 2 blocks
226 + cache.alloc("b", 2) # not ready
227 + cache.alloc("c", 2) # not ready
228 + # free=0, evictable=2, need 3 -> cannot succeed
229 + assert cache.alloc("d", 3) is None
230 + # "a" was NOT evicted despite being evictable
231 + assert cache.get("a") is not None
232 +
233 +
234 +def test_eviction_multiple_entries():
235 + cache = _cache(num_blocks=6)
236 + cache.alloc("a", 2)
237 + cache.mark_ready("a")
238 + cache.alloc("b", 2)
239 + cache.mark_ready("b")
240 + cache.alloc("c", 2)
241 + cache.mark_ready("c")
242 + # Need 4 blocks: must evict both "a" and "b".
243 + entry_d = cache.alloc("d", 4)
244 + assert entry_d is not None
245 + assert cache.get("a") is None
246 + assert cache.get("b") is None
247 + assert cache.get("c") is not None
248 +
249 +
250 +# ── thread safety ────────────────────────────────────────────────────────────
251 +
252 +
253 +def test_concurrent_pin_unpin():
254 + cache = _cache(num_blocks=8)
255 + cache.alloc("a", 4)
256 + cache.mark_ready("a")
257 + errors: list[Exception] = []
258 + barrier = threading.Barrier(8)
259 +
260 + def worker():
261 + barrier.wait()
262 + try:
263 + for _ in range(500):
264 + cache.pin("a")
265 + cache.unpin("a")
266 + except Exception as exc:
267 + errors.append(exc)
268 +
269 + threads = [threading.Thread(target=worker) for _ in range(8)]
270 + for t in threads:
271 + t.start()
272 + for t in threads:
273 + t.join()
274 +
275 + assert not errors, errors
276 + entry = cache.get("a")
277 + assert entry.evictable # all pins balanced
278 +
279 +
280 +def test_concurrent_alloc_evict():
281 + cache = _cache(num_blocks=4)
282 + errors: list[Exception] = []
283 + barrier = threading.Barrier(4)
284 + counter = {"i": 0}
285 + counter_lock = threading.Lock()
286 +
287 + def worker():
288 + barrier.wait()
289 + try:
290 + for _ in range(200):
291 + with counter_lock:
292 + key = f"k{counter['i']}"
293 + counter["i"] += 1
294 + entry = cache.alloc(key, 1)
295 + if entry is not None:
296 + cache.mark_ready(key)
297 + except Exception as exc:
298 + errors.append(exc)
299 +
300 + threads = [threading.Thread(target=worker) for _ in range(4)]
301 + for t in threads:
302 + t.start()
303 + for t in threads:
304 + t.join()
305 +
306 + assert not errors, errors
tests/v1/ec_connector/unit/cpu/scheduler/test_scheduler.py new
+480
@@ -0,0 +1,480 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import uuid
4 +
5 +import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
6 +from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
7 + ECSharedRegion,
8 +)
9 +from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
10 +
11 +_N = 16
12 +_BS = 64
13 +
14 +
15 +class _Pos:
16 + def __init__(self, offset, length):
17 + self.offset = offset
18 + self.length = length
19 +
20 +
21 +class _Feature:
22 + def __init__(self, mm_hash, length=1, identifier=None):
23 + self.mm_hash = mm_hash
24 + self.identifier = identifier if identifier is not None else mm_hash
25 + self.mm_position = _Pos(0, length)
26 +
27 +
28 +class _Request:
29 + _counter = 0
30 +
31 + def __init__(self, features, request_id=None):
32 + self.mm_features = features
33 + if request_id is None:
34 + _Request._counter += 1
35 + self.request_id = f"req_{_Request._counter}"
36 + else:
37 + self.request_id = request_id
38 +
39 +
40 +def _make_scheduler(
41 + monkeypatch,
42 + num_blocks=_N,
43 + max_concurrent_batches=1,
44 + *,
45 + is_producer=True,
46 + is_consumer=True,
47 +) -> ECCPUScheduler:
48 + region = ECSharedRegion(
49 + engine_id=str(uuid.uuid4()),
50 + num_blocks=num_blocks,
51 + block_size_bytes=_BS,
52 + )
53 + monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
54 +
55 + _is_prod = is_producer
56 + _is_cons = is_consumer
57 + _mcb = max_concurrent_batches
58 +
59 + class _EC:
60 + is_ec_producer = _is_prod
61 + is_ec_consumer = _is_cons
62 +
63 + class _Cfg:
64 + ec_transfer_config = _EC()
65 +
66 + @property
67 + def max_concurrent_batches(self):
68 + return _mcb
69 +
70 + return ECCPUScheduler(_Cfg())
71 +
72 +
73 +class _SchedulerOutput:
74 + def __init__(self, finished_req_ids=None):
75 + self.finished_req_ids = finished_req_ids or set()
76 +
77 +
78 +def _seed_cached(s: ECCPUScheduler, mm_hash: str, n_blocks: int):
79 + """Pre-populate a ready cache entry backed by real blocks."""
80 + s._cache.alloc(mm_hash, n_blocks)
81 + s._cache.mark_ready(mm_hash)
82 +
83 +
84 +def test_offload_reuse_cycle(monkeypatch):
85 + s = _make_scheduler(monkeypatch)
86 + req = _Request([_Feature("h1", length=1)])
87 +
88 + # Step A: first sight — allocate and save.
89 + assert s.has_cache_item("h1") is False
90 + assert s.ensure_cache_available(req, 0) is True
91 + s.update_state_after_alloc(req, 0)
92 + meta_a = s.build_connector_meta(scheduler_output=None)
93 + assert "h1" in meta_a.saves
94 + assert meta_a.loads == {}
95 + # Not ready yet (max_concurrent_batches=1 means ready next step).
96 + assert s.has_cache_item("h1") is False
97 +
98 + # Step B: _step_readiness marks h1 ready (no new allocs).
99 + s.build_connector_meta(scheduler_output=None)
100 + assert s.has_cache_item("h1") is True
101 +
102 + # Step C: reload now works — entry is ready at update_state_after_alloc time.
103 + s.update_state_after_alloc(req, 0)
104 + meta_c = s.build_connector_meta(scheduler_output=None)
105 + assert "h1" in meta_c.loads
106 + assert meta_c.loads["h1"] == meta_a.saves["h1"]
107 +
108 + s.shutdown()
109 +
110 +
111 +def test_has_cache_item_false_when_not_consumer(monkeypatch):
112 + s = _make_scheduler(monkeypatch, is_consumer=False)
113 + assert s.has_cache_item("anything") is False
114 + s.shutdown()
115 +
116 +
117 +def test_connector_keys_on_identifier_not_mm_hash(monkeypatch):
118 + """The connector must key the encoder cache on feature.identifier (what
119 + has_cache_item is called with), NOT feature.mm_hash."""
120 + s = _make_scheduler(monkeypatch)
121 + req = _Request([_Feature("PROC_KEY", length=1, identifier="ENC_KEY")])
122 + s.update_state_after_alloc(req, 0)
123 + meta = s.build_connector_meta(scheduler_output=None)
124 + assert "ENC_KEY" in meta.saves
125 + assert "PROC_KEY" not in meta.saves
126 + # Not ready yet (1 step delay).
127 + assert s.has_cache_item("ENC_KEY") is False
128 + # After another step it becomes ready — keyed on identifier.
129 + s.build_connector_meta(scheduler_output=None)
130 + assert s.has_cache_item("ENC_KEY") is True
131 + assert s.has_cache_item("PROC_KEY") is False
132 + s.shutdown()
133 +
134 +
135 +def test_cpu_region_fifo_eviction(monkeypatch):
136 + # Region holds exactly 2 one-block encodings.
137 + s = _make_scheduler(monkeypatch, num_blocks=2)
138 +
139 + # Fill with A and B, marking each ready.
140 + for h in ("A", "B"):
141 + s.update_state_after_alloc(_Request([_Feature(h, length=1)]), 0)
142 + s.build_connector_meta(scheduler_output=None)
143 + # After the step above, A's step was called in B's build. A is ready.
144 + # B was submitted in the last build, so one more step makes it ready.
145 + s.build_connector_meta(scheduler_output=None) # marks B ready
146 + assert s.has_cache_item("A") is True
147 + assert s.has_cache_item("B") is True
148 +
149 + # Allocate C: must evict A (oldest) to make room.
150 + s.update_state_after_alloc(_Request([_Feature("C", length=1)]), 0)
151 + s.build_connector_meta(scheduler_output=None)
152 + assert s.has_cache_item("A") is False # evicted
153 + assert s.has_cache_item("B") is True
154 +
155 +
156 +def test_multiple_mm_items_per_request(monkeypatch):
157 + s = _make_scheduler(monkeypatch)
158 + req = _Request([_Feature("h1", length=1), _Feature("h2", length=1)])
159 + s.update_state_after_alloc(req, 0)
160 + s.update_state_after_alloc(req, 1)
161 + meta = s.build_connector_meta(scheduler_output=None)
162 + assert set(meta.saves) == {"h1", "h2"}
163 +
164 + # Step delay: mark both ready.
165 + s.build_connector_meta(scheduler_output=None)
166 + assert s.has_cache_item("h1") is True
167 + assert s.has_cache_item("h2") is True
168 +
169 + # Now reload both.
170 + s.update_state_after_alloc(req, 0)
171 + s.update_state_after_alloc(req, 1)
172 + meta2 = s.build_connector_meta(scheduler_output=None)
173 + assert set(meta2.loads) == {"h1", "h2"}
174 + s.shutdown()
175 +
176 +
177 +def test_load_returns_correct_block_ids(monkeypatch):
178 + """meta.loads must contain the same block IDs that meta.saves
179 + allocated — verified through the public API only."""
180 + s = _make_scheduler(monkeypatch)
181 + req = _Request([_Feature("a", length=2)])
182 +
183 + s.update_state_after_alloc(req, 0)
184 + meta_save = s.build_connector_meta(scheduler_output=None)
185 + assert "a" in meta_save.saves
186 +
187 + # Readiness delay.
188 + s.build_connector_meta(scheduler_output=None)
189 +
190 + # Reload.
191 + s.update_state_after_alloc(req, 0)
192 + meta_load = s.build_connector_meta(scheduler_output=None)
193 + assert meta_load.loads["a"] == meta_save.saves["a"]
194 + s.shutdown()
195 +
196 +
197 +def test_loads_only_serves_hashes_touched_this_step(monkeypatch):
198 + s = _make_scheduler(monkeypatch)
199 + _seed_cached(s, "a", n_blocks=2)
200 + _seed_cached(s, "b", n_blocks=2)
201 +
202 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
203 + meta = s.build_connector_meta(scheduler_output=None)
204 + assert "a" in meta.loads
205 + assert "b" not in meta.loads
206 + s.shutdown()
207 +
208 +
209 +def test_repeated_reload_same_step_loads_once(monkeypatch):
210 + """The same mm_hash requested twice in one step must appear in
211 + meta.loads exactly once."""
212 + s = _make_scheduler(monkeypatch)
213 + _seed_cached(s, "a", n_blocks=2)
214 +
215 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
216 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
217 + meta = s.build_connector_meta(scheduler_output=None)
218 + assert list(meta.loads.keys()).count("a") == 1
219 + s.shutdown()
220 +
221 +
222 +def test_load_not_emitted_for_uncached_entry(monkeypatch):
223 + """A feature that was never saved must not appear in meta.loads."""
224 + s = _make_scheduler(monkeypatch)
225 + s.update_state_after_alloc(_Request([_Feature("missing")]), 0)
226 + meta = s.build_connector_meta(scheduler_output=None)
227 + assert meta.loads == {}
228 + s.shutdown()
229 +
230 +
231 +def test_delayed_unpin_protects_blocks_during_worker_read(monkeypatch):
232 + """Blocks being loaded must not be evictable until the delayed unpin
233 + fires. Observable: a save that needs eviction cannot reclaim a
234 + still-pinned loaded entry."""
235 + # 2 blocks, both occupied by "a" (ready).
236 + s = _make_scheduler(monkeypatch, num_blocks=2)
237 + _seed_cached(s, "a", n_blocks=2)
238 +
239 + # Load "a" — pins it.
240 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
241 + meta = s.build_connector_meta(scheduler_output=None)
242 + assert "a" in meta.loads
243 +
244 + # Try to save "b" (needs 2 blocks, but "a" is pinned) — must fail.
245 + s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
246 + meta2 = s.build_connector_meta(scheduler_output=None)
247 + assert "b" not in meta2.saves # can't evict pinned "a"
248 +
249 + # After the delay, "a" unpins. Now "b" can evict it.
250 + s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
251 + meta3 = s.build_connector_meta(scheduler_output=None)
252 + assert "b" in meta3.saves
253 + s.shutdown()
254 +
255 +
256 +def test_delayed_unpin_depth_2(monkeypatch):
257 + """With max_concurrent_batches=2, loaded blocks stay pinned for 2 steps
258 + after build. Observable via eviction failure."""
259 + s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=2)
260 + _seed_cached(s, "a", n_blocks=2)
261 +
262 + # Load "a" → pinned.
263 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
264 + s.build_connector_meta(scheduler_output=None)
265 +
266 + # Step 1 after load: still pinned — can't evict for "b".
267 + s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
268 + meta1 = s.build_connector_meta(scheduler_output=None)
269 + assert "b" not in meta1.saves
270 +
271 + # Step 2 after load: still pinned.
272 + s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
273 + meta2 = s.build_connector_meta(scheduler_output=None)
274 + assert "b" not in meta2.saves
275 +
276 + # Step 3: unpin fires, now eviction works.
277 + s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
278 + meta3 = s.build_connector_meta(scheduler_output=None)
279 + assert "b" in meta3.saves
280 + s.shutdown()
281 +
282 +
283 +def test_shutdown_unpins_pending_loads(monkeypatch):
284 + """Shutdown must unpin entries still in _pending_loads (not yet drained
285 + to the unpin deque). Observable: after shutdown, the entry is evictable
286 + (alloc can reclaim its blocks)."""
287 + s = _make_scheduler(monkeypatch, num_blocks=2)
288 + _seed_cached(s, "a", n_blocks=2)
289 +
290 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
291 + # Don't call build_connector_meta — "a" stays in _pending_loads.
292 + s.shutdown()
293 +
294 + # After shutdown, scheduler reports no items (consumer role is off).
295 + assert s.has_cache_item("a") is False
296 +
297 +
298 +def test_shutdown_unpins_deferred_unpin_queue(monkeypatch):
299 + """Shutdown must unpin entries queued in the deferred unpin deque
300 + (already drained from _pending_loads but not yet expired)."""
301 + s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=2)
302 + _seed_cached(s, "a", n_blocks=2)
303 +
304 + # Load "a" and build — moves to unpin deque.
305 + s.update_state_after_alloc(_Request([_Feature("a")]), 0)
306 + s.build_connector_meta(scheduler_output=None)
307 +
308 + # "a" is in the deferred unpin deque (depth=2, not yet expired).
309 + # Shutdown must unpin it.
310 + s.shutdown()
311 + assert s.has_cache_item("a") is False
312 +
313 +
314 +def test_eviction_skips_entry_pinned_by_pending_load(monkeypatch):
315 + """A loaded entry still within the unpin delay window must survive
316 + eviction attempts from new saves."""
317 + # 2 blocks: A occupies both, ready.
318 + s = _make_scheduler(monkeypatch, num_blocks=2)
319 + _seed_cached(s, "A", n_blocks=2)
320 +
321 + # Load A → pinned.
322 + s.update_state_after_alloc(_Request([_Feature("A")]), 0)
323 + meta_load = s.build_connector_meta(scheduler_output=None)
324 + assert "A" in meta_load.loads
325 +
326 + # Try to save C (needs 2 blocks) — can't evict pinned A.
327 + s.update_state_after_alloc(_Request([_Feature("C", length=2)]), 0)
328 + meta_save = s.build_connector_meta(scheduler_output=None)
329 + assert "C" not in meta_save.saves
330 + assert s.has_cache_item("A") is True # survived
331 + s.shutdown()
332 +
333 +
334 +def test_region_full_skips_save_and_never_blocks(monkeypatch):
335 + """When the region is fully occupied by pinned entries, new saves are
336 + silently skipped and ensure_cache_available never blocks."""
337 + s = _make_scheduler(monkeypatch, num_blocks=1)
338 + _seed_cached(s, "pinned", n_blocks=1)
339 + s._cache.pin("pinned")
340 +
341 + req = _Request([_Feature("new", length=1)])
342 + assert s.ensure_cache_available(req, 0) is True
343 +
344 + s.update_state_after_alloc(req, 0)
345 + meta = s.build_connector_meta(scheduler_output=None)
346 + assert "new" not in meta.saves
347 + assert s.has_cache_item("new") is False
348 +
349 + s._cache.unpin("pinned")
350 + s.shutdown()
351 +
352 +
353 +def test_step_readiness_depth_2(monkeypatch):
354 + """With max_concurrent_batches=2, entries need 2 steps to become ready."""
355 + s = _make_scheduler(monkeypatch, max_concurrent_batches=2)
356 + req = _Request([_Feature("h1", length=1)])
357 +
358 + s.update_state_after_alloc(req, 0)
359 + meta_0 = s.build_connector_meta(scheduler_output=None)
360 + assert "h1" in meta_0.saves
361 + assert s.has_cache_item("h1") is False
362 +
363 + # One step later: still not ready (depth=2).
364 + s.build_connector_meta(scheduler_output=None)
365 + assert s.has_cache_item("h1") is False
366 +
367 + # Two steps later: now ready.
368 + s.build_connector_meta(scheduler_output=None)
369 + assert s.has_cache_item("h1") is True
370 + s.shutdown()
371 +
372 +
373 +def test_producer_only_never_emits_loads(monkeypatch):
374 + """A producer-only scheduler must never populate meta.loads, even when
375 + entries are ready."""
376 + s = _make_scheduler(monkeypatch, is_consumer=False)
377 + req = _Request([_Feature("h1", length=1)])
378 +
379 + s.update_state_after_alloc(req, 0)
380 + meta = s.build_connector_meta(scheduler_output=None)
381 + assert "h1" in meta.saves
382 + assert meta.loads == {}
383 +
384 + # Mark ready.
385 + s.build_connector_meta(scheduler_output=None)
386 +
387 + # Even if we re-encounter the same feature, no load.
388 + s.update_state_after_alloc(req, 0)
389 + meta2 = s.build_connector_meta(scheduler_output=None)
390 + assert meta2.loads == {}
391 + s.shutdown()
392 +
393 +
394 +def test_consumer_only_never_emits_saves(monkeypatch):
395 + """A consumer-only scheduler must never populate meta.saves."""
396 + s = _make_scheduler(monkeypatch, is_producer=False)
397 + _seed_cached(s, "a", n_blocks=1)
398 +
399 + req = _Request([_Feature("a", length=1)])
400 + s.update_state_after_alloc(req, 0)
401 + meta = s.build_connector_meta(scheduler_output=None)
402 + assert meta.saves == {}
403 + assert "a" in meta.loads
404 + s.shutdown()
405 +
406 +
407 +def test_consumer_only_has_cache_item(monkeypatch):
408 + """Consumer-only: has_cache_item works normally for ready entries."""
409 + s = _make_scheduler(monkeypatch, is_producer=False)
410 + assert s.has_cache_item("a") is False
411 + _seed_cached(s, "a", n_blocks=1)
412 + assert s.has_cache_item("a") is True
413 + s.shutdown()
414 +
415 +
416 +def test_save_not_emitted_for_already_cached_entry(monkeypatch):
417 + """An entry already in the cache (from a prior save) must not trigger
418 + a second allocation or appear in meta.saves again."""
419 + s = _make_scheduler(monkeypatch)
420 + req = _Request([_Feature("h1", length=1)])
421 +
422 + # First encounter → save.
423 + s.update_state_after_alloc(req, 0)
424 + meta1 = s.build_connector_meta(scheduler_output=None)
425 + assert "h1" in meta1.saves
426 +
427 + # Second encounter (same step or later) → no save.
428 + s.update_state_after_alloc(req, 0)
429 + meta2 = s.build_connector_meta(scheduler_output=None)
430 + assert "h1" not in meta2.saves
431 + s.shutdown()
432 +
433 +
434 +# ── first-finish fast-path (idle engine) ────────────────────────────────────
435 +
436 +
437 +def test_first_finish_marks_ready_immediately(monkeypatch):
438 + """When the originating request finishes, the save entry becomes ready
439 + without waiting for the step-count delay."""
440 + s = _make_scheduler(monkeypatch, max_concurrent_batches=5)
441 + req = _Request([_Feature("h1", length=1)], request_id="req_x")
442 +
443 + s.update_state_after_alloc(req, 0)
444 + s.build_connector_meta(scheduler_output=None)
445 + assert s.has_cache_item("h1") is False
446 +
447 + # Request finishes — readiness should fire immediately.
448 + s.build_connector_meta(_SchedulerOutput(finished_req_ids={"req_x"}))
449 + assert s.has_cache_item("h1") is True
450 + s.shutdown()
451 +
452 +
453 +def test_first_finish_unpins_immediately(monkeypatch):
454 + """When the originating request finishes, the load entry is unpinned
455 + without waiting for the step-count delay."""
456 + s = _make_scheduler(monkeypatch, num_blocks=2, max_concurrent_batches=5)
457 + _seed_cached(s, "a", n_blocks=2)
458 +
459 + req = _Request([_Feature("a")], request_id="req_y")
460 + s.update_state_after_alloc(req, 0)
461 + meta = s.build_connector_meta(scheduler_output=None)
462 + assert "a" in meta.loads
463 +
464 + # "a" is pinned. Try to evict — must fail.
465 + s.update_state_after_alloc(
466 + _Request([_Feature("b", length=2)], request_id="req_z"), 0
467 + )
468 + meta2 = s.build_connector_meta(scheduler_output=None)
469 + assert "b" not in meta2.saves
470 +
471 + # req_y finishes — unpin fires in build_connector_meta (fast-path).
472 + s.build_connector_meta(_SchedulerOutput(finished_req_ids={"req_y"}))
473 +
474 + # Next step: "a" is now evictable, so "b" can be allocated.
475 + s.update_state_after_alloc(
476 + _Request([_Feature("b", length=2)], request_id="req_w"), 0
477 + )
478 + meta3 = s.build_connector_meta(scheduler_output=None)
479 + assert "b" in meta3.saves
480 + s.shutdown()
tests/v1/ec_connector/unit/cpu/scheduler/test_step_tracker.py new
+162
@@ -0,0 +1,162 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for StepTracker."""
4 +
5 +from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.step_tracker import (
6 + StepTracker,
7 +)
8 +
9 +
10 +def test_basic_step_count_expiry_depth_1():
11 + """With max_concurrent_batches=1, entry expires after 1 step."""
12 + t = StepTracker(max_concurrent_batches=1)
13 + t.add("h1", "req_a")
14 +
15 + # Step 1: entry was added, committed to slot. Deque fills to maxlen=1,
16 + # but no expiry yet (the entry is in the slot just committed).
17 + result = t.step(set())
18 + assert result == []
19 +
20 + # Step 2: deque is full, oldest slot (containing h1) expires.
21 + result = t.step(set())
22 + assert result == ["h1"]
23 +
24 +
25 +def test_basic_step_count_expiry_depth_2():
26 + """With max_concurrent_batches=2, entry expires after 2 steps."""
27 + t = StepTracker(max_concurrent_batches=2)
28 + t.add("h1", "req_a")
29 +
30 + assert t.step(set()) == [] # step 1: committed
31 + assert t.step(set()) == [] # step 2: deque fills but h1 in newer slot
32 + assert t.step(set()) == ["h1"] # step 3: h1's slot is oldest, expires
33 +
34 +
35 +def test_first_finish_fast_path():
36 + """Entry is returned immediately when request_id appears in finished."""
37 + t = StepTracker(max_concurrent_batches=3)
38 + t.add("h1", "req_a")
39 +
40 + result = t.step({"req_a"})
41 + assert result == ["h1"]
42 +
43 +
44 +def test_no_double_processing_fast_path_then_expiry():
45 + """Entry fast-pathed should NOT fire again on deque expiry."""
46 + t = StepTracker(max_concurrent_batches=1)
47 + t.add("h1", "req_a")
48 +
49 + # Fast-path fires h1.
50 + result = t.step({"req_a"})
51 + assert result == ["h1"]
52 +
53 + # Normal expiry would fire now, but h1 is already processed.
54 + result = t.step(set())
55 + assert result == []
56 +
57 +
58 +def test_no_double_processing_expiry_then_fast_path():
59 + """Entry expired via step-count should NOT fire again via fast-path."""
60 + t = StepTracker(max_concurrent_batches=1)
61 + t.add("h1", "req_a")
62 +
63 + t.step(set()) # commit
64 + result = t.step(set()) # expiry fires h1
65 + assert result == ["h1"]
66 +
67 + # req_a finishes later — must not re-fire.
68 + result = t.step({"req_a"})
69 + assert result == []
70 +
71 +
72 +def test_multiplicity_same_hash_different_requests():
73 + """Two adds of the same mm_hash (different requests) → returned twice."""
74 + t = StepTracker(max_concurrent_batches=3)
75 + t.add("h1", "req_a")
76 + t.add("h1", "req_b")
77 +
78 + # Both requests finish in the same step.
79 + result = t.step({"req_a", "req_b"})
80 + assert sorted(result) == ["h1", "h1"]
81 +
82 +
83 +def test_mixed_expiry_and_fast_path():
84 + """Some entries expire, others fast-path, in the same step."""
85 + t = StepTracker(max_concurrent_batches=1)
86 + t.add("h1", "req_a")
87 +
88 + t.step(set()) # commit h1's slot
89 +
90 + # Add h2 in the next step.
91 + t.add("h2", "req_b")
92 +
93 + # This step: h1's slot expires AND req_b finishes.
94 + result = t.step({"req_b"})
95 + assert set(result) == {"h1", "h2"}
96 +
97 +
98 +def test_drain_all_returns_unprocessed():
99 + """drain_all returns everything pending and clears state."""
100 + t = StepTracker(max_concurrent_batches=3)
101 + t.add("h1", "req_a")
102 + t.add("h2", "req_b")
103 + t.step(set()) # commit both
104 + t.add("h3", "req_c") # in _current, not yet committed
105 +
106 + result = t.drain_all()
107 + assert set(result) == {"h1", "h2", "h3"}
108 +
109 + # After drain, everything is empty.
110 + assert t.step(set()) == []
111 + assert t.drain_all() == []
112 +
113 +
114 +def test_drain_all_skips_already_processed():
115 + """drain_all should not re-return entries already fast-pathed."""
116 + t = StepTracker(max_concurrent_batches=3)
117 + t.add("h1", "req_a")
118 + t.add("h2", "req_b")
119 +
120 + # Fast-path h1.
121 + result = t.step({"req_a"})
122 + assert result == ["h1"]
123 +
124 + # drain should only return h2.
125 + result = t.drain_all()
126 + assert result == ["h2"]
127 +
128 +
129 +def test_empty_step_returns_nothing():
130 + """step() with nothing pending returns empty list."""
131 + t = StepTracker(max_concurrent_batches=2)
132 + assert t.step(set()) == []
133 + assert t.step({"req_x"}) == []
134 +
135 +
136 +def test_add_after_step_goes_to_next_slot():
137 + """Entries added after a step go into the next slot, not the current."""
138 + t = StepTracker(max_concurrent_batches=1)
139 +
140 + t.step(set()) # empty step
141 + t.add("h1", "req_a")
142 +
143 + # h1 is in _current. Next step commits it.
144 + t.step(set())
145 +
146 + # Now the deque is full (1 slot with h1). Next step expires it.
147 + result = t.step(set())
148 + assert result == ["h1"]
149 +
150 +
151 +def test_fast_path_on_current_entries():
152 + """Entries still in _current (not yet committed) can be fast-pathed."""
153 + t = StepTracker(max_concurrent_batches=3)
154 + t.add("h1", "req_a")
155 +
156 + # req_a finishes in the same step h1 was added.
157 + result = t.step({"req_a"})
158 + assert result == ["h1"]
159 +
160 + # Entry is committed to slot but marked processed — won't re-fire.
161 + for _ in range(5):
162 + assert t.step(set()) == []
tests/v1/ec_connector/unit/cpu/test_connector.py new
+47
@@ -0,0 +1,47 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from unittest.mock import MagicMock
4 +
5 +from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
6 +from vllm.distributed.ec_transfer.ec_connector.cpu.connector import ECCPUConnector
7 +from vllm.distributed.ec_transfer.ec_connector.factory import ECConnectorFactory
8 +
9 +
10 +def _cfg():
11 + ec = MagicMock()
12 + ec.is_ec_producer = True
13 + ec.is_ec_consumer = True
14 + cfg = MagicMock()
15 + cfg.ec_transfer_config = ec
16 + return cfg
17 +
18 +
19 +def test_scheduler_role_builds_only_scheduler(monkeypatch):
20 + fake_sched = MagicMock()
21 + monkeypatch.setattr(ECCPUConnector, "_make_scheduler", lambda self, cfg: fake_sched)
22 + c = ECCPUConnector(_cfg(), ECConnectorRole.SCHEDULER)
23 + assert c.connector_scheduler is fake_sched
24 + assert c.connector_worker is None
25 + c.has_cache_item("x")
26 + fake_sched.has_cache_item.assert_called_once_with("x")
27 +
28 +
29 +def test_worker_role_builds_only_worker(monkeypatch):
30 + fake_worker = MagicMock()
31 + monkeypatch.setattr(ECCPUConnector, "_make_worker", lambda self, cfg: fake_worker)
32 + c = ECCPUConnector(_cfg(), ECConnectorRole.WORKER)
33 + assert c.connector_worker is fake_worker
34 + assert c.connector_scheduler is None
35 +
36 +
37 +def test_request_finished_inherited_noop(monkeypatch):
38 + monkeypatch.setattr(
39 + ECCPUConnector, "_make_scheduler", lambda self, cfg: MagicMock()
40 + )
41 + c = ECCPUConnector(_cfg(), ECConnectorRole.SCHEDULER)
42 + assert c.request_finished(MagicMock()) == (False, None)
43 +
44 +
45 +def test_factory_registered():
46 + cls = ECConnectorFactory._registry["ECCPUConnector"]()
47 + assert cls is ECCPUConnector
tests/v1/ec_connector/unit/cpu/test_ec_shared_region.py new
+170
@@ -0,0 +1,170 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ECSharedRegion (the mmap substrate)."""
4 +
5 +import os
6 +import uuid
7 +from unittest.mock import MagicMock, patch
8 +
9 +import pytest
10 +import torch
11 +
12 +from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
13 + ECSharedRegion,
14 + _wait_for_file_size,
15 +)
16 +
17 +
18 +def _make_region(num_blocks: int = 8, block_size_bytes: int = 64) -> ECSharedRegion:
19 + return ECSharedRegion(
20 + engine_id=str(uuid.uuid4()),
21 + num_blocks=num_blocks,
22 + block_size_bytes=block_size_bytes,
23 + )
24 +
25 +
26 +@pytest.fixture
27 +def region() -> ECSharedRegion:
28 + r = _make_region()
29 + yield r
30 + r.cleanup()
31 +
32 +
33 +# ── mmap sharing between two instances ───────────────────────────────────────
34 +
35 +
36 +def test_second_instance_opens_existing_file_and_shares_memory():
37 + instance_id = str(uuid.uuid4())
38 + r1 = ECSharedRegion(
39 + engine_id=instance_id,
40 + num_blocks=4,
41 + block_size_bytes=64,
42 + )
43 + try:
44 + r2 = ECSharedRegion(
45 + engine_id=instance_id,
46 + num_blocks=4,
47 + block_size_bytes=64,
48 + )
49 + try:
50 + assert r1._is_creator
51 + assert not r2._is_creator
52 + # Both map the same physical pages; writes via r1 are visible via r2.
53 + r1.blocks[0, :4] = torch.tensor([10, 20, 30, 40], dtype=torch.int8)
54 + assert r2.blocks[0, :4].tolist() == [10, 20, 30, 40]
55 + finally:
56 + r2.cleanup()
57 + finally:
58 + r1.cleanup()
59 +
60 +
61 +def test_only_creator_unlinks_file_on_cleanup():
62 + """Critical contract: if the non-creator unlinks, the creator's mmap path
63 + becomes a dangling backing file and a third opener would create a new one
64 + out from under the creator."""
65 + instance_id = str(uuid.uuid4())
66 + r1 = ECSharedRegion(
67 + engine_id=instance_id,
68 + num_blocks=4,
69 + block_size_bytes=64,
70 + )
71 + path = r1._mmap_path
72 + r2 = ECSharedRegion(
73 + engine_id=instance_id,
74 + num_blocks=4,
75 + block_size_bytes=64,
76 + )
77 +
78 + # Non-creator goes away first — file must still be on disk for r1.
79 + r2.cleanup()
80 + assert os.path.exists(path), "non-creator cleanup must not unlink the file"
81 +
82 + # Creator goes away — file is removed.
83 + r1.cleanup()
84 + assert not os.path.exists(path), "creator cleanup must unlink the file"
85 +
86 +
87 +# ── _wait_for_file_size ──────────────────────────────────────────────────────
88 +
89 +
90 +def test_wait_for_file_size_returns_when_already_big_enough(tmp_path):
91 + """The fast path: file already at expected size — return immediately."""
92 + p = tmp_path / "f.bin"
93 + p.write_bytes(b"\x00" * 128)
94 + fd = os.open(str(p), os.O_RDONLY)
95 + try:
96 + _wait_for_file_size(fd, expected_size=128, timeout=1.0) # must not raise
97 + finally:
98 + os.close(fd)
99 +
100 +
101 +def test_wait_for_file_size_times_out_when_file_stays_empty(tmp_path):
102 + p = tmp_path / "f.bin"
103 + p.write_bytes(b"")
104 + fd = os.open(str(p), os.O_RDONLY)
105 + try:
106 + with pytest.raises(TimeoutError):
107 + _wait_for_file_size(fd, expected_size=4096, timeout=0.05)
108 + finally:
109 + os.close(fd)
110 +
111 +
112 +# ── pin_memory ───────────────────────────────────────────────────────────────
113 +
114 +
115 +def test_pin_memory_success_sets_flag(region):
116 + """When cudaHostRegister returns 0, _is_pinned flips to True
117 + and cleanup will correspondingly call cudaHostUnregister."""
118 + fake_cudart = MagicMock()
119 + success = MagicMock()
120 + success.value = 0
121 + fake_cudart.cudaHostRegister.return_value = success
122 + fake_cudart.cudaHostUnregister.return_value = success
123 +
124 + with (
125 + patch("torch.cuda.is_available", return_value=True),
126 + patch("torch.cuda.cudart", return_value=fake_cudart),
127 + ):
128 + region.pin_memory()
129 + assert region._is_pinned is True
130 + # cleanup must pair with cudaHostUnregister exactly once.
131 + region.cleanup()
132 + fake_cudart.cudaHostUnregister.assert_called_once()
133 +
134 +
135 +def test_pin_memory_failure_leaves_flag_false():
136 + """If cudaHostRegister fails (non-zero), don't pretend it succeeded —
137 + cleanup must NOT call cudaHostUnregister on memory we never registered."""
138 + r = _make_region()
139 + try:
140 + fake_cudart = MagicMock()
141 + fail = MagicMock()
142 + fail.value = 1 # non-zero == error
143 + fake_cudart.cudaHostRegister.return_value = fail
144 +
145 + with (
146 + patch("torch.cuda.is_available", return_value=True),
147 + patch("torch.cuda.cudart", return_value=fake_cudart),
148 + ):
149 + r.pin_memory()
150 + assert r._is_pinned is False
151 + # Now run cleanup and verify cudaHostUnregister was NOT called.
152 + r.cleanup()
153 + fake_cudart.cudaHostUnregister.assert_not_called()
154 + finally:
155 + r.cleanup()
156 +
157 +
158 +def test_pin_memory_noop_without_cuda(region):
159 + """pin_memory is a no-op when CUDA is not available."""
160 + with patch("torch.cuda.is_available", return_value=False):
161 + region.pin_memory()
162 + assert region._is_pinned is False
163 +
164 +
165 +# ── cleanup idempotency ───────────────────────────────────────────────────────
166 +
167 +
168 +def test_cleanup_is_idempotent(region):
169 + region.cleanup()
170 + region.cleanup() # fixture calls a third time — must not raise
tests/v1/ec_connector/unit/cpu/worker/test_worker.py new
+598
@@ -0,0 +1,598 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for ECCPUWorker.
4 +
5 +The byte-level tests exercise real CUDA stream/event coordination
6 +against a real ``ECSharedRegion`` mmap and are skipped on hosts without
7 +CUDA. The lifecycle tests don't fire any ``torch.cuda.*`` primitives and
8 +run anywhere.
9 +
10 +Mocking policy
11 +--------------
12 +- ``create_ec_shared_region`` is patched to inject a real ``ECSharedRegion``
13 + (mmap backed) with deterministic small dimensions, so byte assertions
14 + hit the actual mmap. The real responsibility of the worker — block
15 + index → mmap byte mapping, stream coordination, dtype/shape — is
16 + exercised end to end against a real GPU.
17 +- ``is_pin_memory_available`` is patched per-test so the rank-gating
18 + path can be probed deterministically without depending on host caps.
19 +- The shutdown test bypasses ``__init__`` via ``object.__new__`` and
20 + injects a ``MagicMock`` region.
21 +"""
22 +
23 +import contextlib
24 +import logging
25 +import uuid
26 +from unittest.mock import MagicMock, Mock, patch
27 +
28 +import pytest
29 +import torch
30 +
31 +from vllm.config import VllmConfig
32 +from vllm.config.parallel import ParallelConfig
33 +from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
34 + ECCPUConnectorMetadata,
35 +)
36 +from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
37 + ECSharedRegion,
38 +)
39 +from vllm.distributed.ec_transfer.ec_connector.cpu.worker import ECCPUWorker
40 +
41 +# ── shape constants ──────────────────────────────────────────────────────────
42 +
43 +# hidden_dim=8, dtype=fp16 (2 bytes) → block_size_bytes = 16, so one row of
44 +# encoder output fits in exactly one block.
45 +_HIDDEN_DIM = 8
46 +_DTYPE = torch.float16
47 +_BLOCK_SIZE_BYTES = _HIDDEN_DIM * _DTYPE.itemsize
48 +_NUM_BLOCKS = 8
49 +
50 +_requires_cuda = pytest.mark.skipif(
51 + not torch.cuda.is_available(),
52 + reason="exercises real CUDA stream/event coordination in ECCPUWorker",
53 +)
54 +
55 +
56 +def _make_region() -> ECSharedRegion:
57 + """Fresh region backed by a real per-test mmap file."""
58 + return ECSharedRegion(
59 + engine_id=str(uuid.uuid4()),
60 + num_blocks=_NUM_BLOCKS,
61 + block_size_bytes=_BLOCK_SIZE_BYTES,
62 + )
63 +
64 +
65 +def _vllm_config(rank: int = 0) -> Mock:
66 + cfg = Mock(spec=VllmConfig)
67 + cfg.parallel_config = Mock(spec=ParallelConfig)
68 + cfg.parallel_config.rank = rank
69 + cfg.model_config = Mock()
70 + cfg.model_config.dtype = _DTYPE
71 + return cfg
72 +
73 +
74 +def _meta(
75 + *, saves: dict | None = None, loads: dict | None = None
76 +) -> ECCPUConnectorMetadata:
77 + return ECCPUConnectorMetadata(saves=saves or {}, loads=loads or {})
78 +
79 +
80 +# ── fixtures ─────────────────────────────────────────────────────────────────
81 +
82 +
83 +@pytest.fixture
84 +def make_worker():
85 + """Factory that builds an ``ECCPUWorker`` over a real ``ECSharedRegion``.
86 +
87 + Tracks the workers and regions it creates and tears them down at the
88 + end of the test, so each test starts from a fresh /dev/shm file.
89 + """
90 + workers: list[ECCPUWorker] = []
91 + regions: list[ECSharedRegion] = []
92 +
93 + def factory(
94 + *,
95 + rank: int = 0,
96 + tp_rank: int = 0,
97 + pcp_rank: int = 0,
98 + pin_memory_available: bool = False,
99 + ) -> ECCPUWorker:
100 + region = _make_region()
101 + regions.append(region)
102 + pcp_group = Mock()
103 + pcp_group.rank_in_group = pcp_rank
104 + with (
105 + patch(
106 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.create_ec_shared_region",
107 + return_value=region,
108 + ),
109 + patch(
110 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.is_pin_memory_available",
111 + return_value=pin_memory_available,
112 + ),
113 + patch(
114 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.get_tensor_model_parallel_rank",
115 + return_value=tp_rank,
116 + ),
117 + patch(
118 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.get_pcp_group",
119 + return_value=pcp_group,
120 + ),
121 + ):
122 + worker = ECCPUWorker(_vllm_config(rank=rank))
123 + workers.append(worker)
124 + return worker
125 +
126 + yield factory
127 +
128 + for worker in workers:
129 + with contextlib.suppress(Exception):
130 + worker.shutdown()
131 + for region in regions:
132 + with contextlib.suppress(Exception):
133 + region.cleanup()
134 +
135 +
136 +# ── save_caches ──────────────────────────────────────────────────────────────
137 +
138 +
139 +@_requires_cuda
140 +@pytest.mark.parametrize(
141 + "n_elements,n_blocks",
142 + [
143 + (3 * _HIDDEN_DIM, 3), # exact fit: 3 full blocks, no remainder
144 + (3 * _HIDDEN_DIM + 4, 4), # partial last block: 4 fp16 trail in slot 3
145 + ],
146 + ids=["exact-fit", "partial-last-block"],
147 +)
148 +def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks):
149 + """``save_caches`` + ``flush_saves`` copies the source GPU tensor's bytes
150 + into the block IDs named by ``meta.saves``, in the order they appear,
151 + via a single batched GPU→CPU copy.
152 +
153 + When ``total_bytes`` is not a multiple of ``block_size_bytes`` the last
154 + block is partially written; the unwritten tail must remain whatever
155 + was there before. Blocks not named in ``meta.saves`` must be untouched.
156 + """
157 + worker = make_worker()
158 + sentinel = 0x5A
159 + worker._region.blocks.fill_(sentinel)
160 +
161 + src = torch.arange(n_elements, dtype=_DTYPE, device="cuda")
162 + expected_bytes = src.cpu().reshape(-1).view(torch.uint8)
163 + total_bytes = n_elements * _DTYPE.itemsize
164 +
165 + block_ids = [7, 2, 5, 0][:n_blocks]
166 + worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
167 + worker.flush_saves()
168 +
169 + for slot, block_idx in enumerate(block_ids):
170 + block_byte_start = slot * _BLOCK_SIZE_BYTES
171 + block_byte_end = min(block_byte_start + _BLOCK_SIZE_BYTES, total_bytes)
172 + n_written = block_byte_end - block_byte_start
173 + actual_bytes = worker._region.blocks[block_idx, :n_written].view(torch.uint8)
174 + assert torch.equal(
175 + actual_bytes, expected_bytes[block_byte_start:block_byte_end]
176 + ), f"block {block_idx} (slot {slot}) bytes mismatch"
177 + if n_written < _BLOCK_SIZE_BYTES:
178 + tail = worker._region.blocks[block_idx, n_written:]
179 + assert torch.all(tail == sentinel), (
180 + f"block {block_idx} tail was overwritten"
181 + )
182 +
183 + for idx in set(range(_NUM_BLOCKS)) - set(block_ids):
184 + assert torch.all(worker._region.blocks[idx] == sentinel), (
185 + f"block {idx} (unassigned) was overwritten"
186 + )
187 +
188 +
189 +def test_save_caches_noop_when_mm_hash_not_in_saves(make_worker):
190 + """When the scheduler hasn't pre-allocated blocks for ``mm_hash``,
191 + ``save_caches`` + ``flush_saves`` is a pure no-op."""
192 + worker = make_worker()
193 + sentinel = 0x42
194 + worker._region.blocks.fill_(sentinel)
195 +
196 + worker.save_caches({}, "h", _meta(saves={}))
197 + worker.flush_saves()
198 +
199 + assert torch.all(worker._region.blocks == sentinel)
200 +
201 +
202 +@pytest.mark.parametrize(
203 + "tp_rank,pcp_rank",
204 + [(1, 0), (0, 1), (1, 1)],
205 + ids=["tp1-pcp0", "tp0-pcp1", "tp1-pcp1"],
206 +)
207 +def test_save_caches_noop_for_non_save_rank(make_worker, tp_rank, pcp_rank):
208 + """Only TP rank 0 + PCP rank 0 writes to mmap. All other combinations
209 + must skip the save entirely."""
210 + worker = make_worker(tp_rank=tp_rank, pcp_rank=pcp_rank)
211 + sentinel = 0x42
212 + worker._region.blocks.fill_(sentinel)
213 +
214 + worker.save_caches(
215 + {"h": torch.zeros(8, dtype=_DTYPE)}, "h", _meta(saves={"h": [0]})
216 + )
217 + worker.flush_saves()
218 +
219 + assert torch.all(worker._region.blocks == sentinel)
220 +
221 +
222 +def test_save_caches_raises_when_allocated_blocks_too_small(make_worker):
223 + """``save_caches`` must raise ``AssertionError`` when the encoder output
224 + is larger than the allocated block space."""
225 + worker = make_worker()
226 + src = torch.zeros(3 * _HIDDEN_DIM, dtype=_DTYPE)
227 + with pytest.raises(AssertionError, match="exceeds allocated blocks"):
228 + worker.save_caches({"h": src}, "h", _meta(saves={"h": [0, 1]}))
229 +
230 +
231 +@_requires_cuda
232 +def test_save_caches_batches_multiple_hashes(make_worker):
233 + """Multiple save_caches calls are batched into a single flush."""
234 + worker = make_worker()
235 + sentinel = 0x5A
236 + worker._region.blocks.fill_(sentinel)
237 +
238 + src_a = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device="cuda")
239 + src_b = torch.arange(_HIDDEN_DIM, 2 * _HIDDEN_DIM, dtype=_DTYPE, device="cuda")
240 +
241 + cache = {"a": src_a, "b": src_b}
242 + worker.save_caches(cache, "a", _meta(saves={"a": [1], "b": [3]}))
243 + worker.save_caches(cache, "b", _meta(saves={"a": [1], "b": [3]}))
244 + worker.flush_saves()
245 +
246 + expected_a = src_a.cpu().view(torch.uint8)
247 + expected_b = src_b.cpu().view(torch.uint8)
248 + actual_a = worker._region.blocks[1].view(torch.uint8)
249 + actual_b = worker._region.blocks[3].view(torch.uint8)
250 + assert torch.equal(actual_a, expected_a)
251 + assert torch.equal(actual_b, expected_b)
252 +
253 + # Unassigned blocks untouched.
254 + for idx in (0, 2, 4, 5, 6, 7):
255 + assert torch.all(worker._region.blocks[idx] == sentinel)
256 +
257 +
258 +# ── start_load_caches ────────────────────────────────────────────────────────
259 +
260 +
261 +@_requires_cuda
262 +def test_start_load_caches_copies_with_correct_shape_dtype_and_bytes(make_worker):
263 + """Single batched load across all hashes with correct byte→dtype→shape."""
264 + worker = make_worker()
265 + n_blocks = 3
266 + src_orig = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE).reshape(
267 + n_blocks, _HIDDEN_DIM
268 + )
269 + src_int8 = src_orig.view(torch.int8).reshape(n_blocks, _BLOCK_SIZE_BYTES)
270 +
271 + block_ids = [3, 1, 6]
272 + for i, idx in enumerate(block_ids):
273 + worker._region.blocks[idx].copy_(src_int8[i])
274 +
275 + encoder_cache: dict[str, torch.Tensor] = {}
276 + worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
277 +
278 + out = encoder_cache["h"]
279 + assert out.is_cuda, "consumer worker must place the tensor on the GPU"
280 + assert out.shape == (n_blocks, _HIDDEN_DIM)
281 + assert out.dtype == _DTYPE
282 + assert torch.equal(out.cpu(), src_orig)
283 +
284 +
285 +@_requires_cuda
286 +def test_start_load_caches_preserves_existing_encoder_cache_entry(make_worker):
287 + """If ``encoder_cache`` already holds the ``mm_hash``, the worker must
288 + not overwrite it."""
289 + worker = make_worker()
290 + worker._region.blocks[0].fill_(0x42)
291 +
292 + sentinel = torch.full((_HIDDEN_DIM,), 7.0, dtype=_DTYPE, device="cuda")
293 + encoder_cache = {"h": sentinel}
294 + worker.start_load_caches(encoder_cache, _meta(loads={"h": [0]}))
295 +
296 + assert encoder_cache["h"] is sentinel, (
297 + "existing encoder_cache entry must not be replaced"
298 + )
299 +
300 +
301 +@_requires_cuda
302 +def test_start_load_caches_noop_when_loads_is_empty(make_worker):
303 + """When ``meta.loads`` is empty the early-return must fire."""
304 + worker = make_worker()
305 + encoder_cache: dict[str, torch.Tensor] = {}
306 + worker.start_load_caches(encoder_cache, _meta(loads={}))
307 +
308 + assert encoder_cache == {}
309 +
310 +
311 +@_requires_cuda
312 +def test_start_load_caches_skips_cached_and_loads_new_in_same_step(make_worker):
313 + """Cached entries are preserved while new ones are loaded."""
314 + worker = make_worker()
315 + n_blocks = 2
316 + src_orig = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE).reshape(
317 + n_blocks, _HIDDEN_DIM
318 + )
319 + src_int8 = src_orig.view(torch.int8).reshape(n_blocks, _BLOCK_SIZE_BYTES)
320 + new_block_ids = [4, 2]
321 + for i, idx in enumerate(new_block_ids):
322 + worker._region.blocks[idx].copy_(src_int8[i])
323 +
324 + cached_tensor = torch.full((1, _HIDDEN_DIM), 99.0, dtype=_DTYPE, device="cuda")
325 + encoder_cache: dict[str, torch.Tensor] = {"cached_h": cached_tensor}
326 + worker.start_load_caches(
327 + encoder_cache,
328 + _meta(loads={"cached_h": [0], "new_h": new_block_ids}),
329 + )
330 +
331 + assert encoder_cache["cached_h"] is cached_tensor
332 + assert "new_h" in encoder_cache
333 + out = encoder_cache["new_h"]
334 + assert out.shape == (n_blocks, _HIDDEN_DIM)
335 + assert out.dtype == _DTYPE
336 + assert torch.equal(out.cpu(), src_orig)
337 +
338 +
339 +@_requires_cuda
340 +@pytest.mark.parametrize(
341 + "tp_rank,pcp_rank",
342 + [(0, 0), (1, 0), (0, 1), (1, 1)],
343 + ids=["tp0-pcp0", "tp1-pcp0", "tp0-pcp1", "tp1-pcp1"],
344 +)
345 +def test_start_load_caches_works_on_all_ranks(make_worker, tp_rank, pcp_rank):
346 + """All TP/PCP ranks must load from mmap — loads are NOT gated like saves."""
347 + worker = make_worker(tp_rank=tp_rank, pcp_rank=pcp_rank)
348 + n_blocks = 2
349 + src_orig = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE).reshape(
350 + n_blocks, _HIDDEN_DIM
351 + )
352 + src_int8 = src_orig.view(torch.int8).reshape(n_blocks, _BLOCK_SIZE_BYTES)
353 + block_ids = [1, 3]
354 + for i, idx in enumerate(block_ids):
355 + worker._region.blocks[idx].copy_(src_int8[i])
356 +
357 + encoder_cache: dict[str, torch.Tensor] = {}
358 + worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
359 +
360 + out = encoder_cache["h"]
361 + assert out.is_cuda
362 + assert torch.equal(out.cpu(), src_orig)
363 +
364 +
365 +# ── round-trip ───────────────────────────────────────────────────────────────
366 +
367 +
368 +@_requires_cuda
369 +def test_save_then_load_round_trips_bytes(make_worker):
370 + """Full producer→mmap→consumer byte path in one shot."""
371 + worker = make_worker()
372 + n_blocks = 3
373 + block_ids = [5, 1, 6]
374 +
375 + src = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device="cuda").reshape(
376 + n_blocks, _HIDDEN_DIM
377 + )
378 + worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
379 + worker.flush_saves()
380 +
381 + encoder_cache: dict[str, torch.Tensor] = {}
382 + worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
383 +
384 + out = encoder_cache["h"]
385 + assert out.shape == src.shape
386 + assert out.dtype == src.dtype
387 + assert torch.equal(out.cpu(), src.cpu())
388 +
389 +
390 +# ── buffer recycling ────────────────────────────────────────────────────────
391 +
392 +
393 +@_requires_cuda
394 +def test_buffer_pool_is_reused_across_save_steps(make_worker):
395 + """After flush_saves, descriptor buffers are returned to the pool and
396 + reused on the next flush — no reallocation."""
397 + worker = make_worker()
398 + src = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device="cuda")
399 +
400 + worker.save_caches({"h": src}, "h", _meta(saves={"h": [0]}))
401 + worker.flush_saves()
402 +
403 + assert len(worker._buf_pool._pool) == 1
404 + buf_id = id(worker._buf_pool._pool[0].src_ptrs)
405 +
406 + # Second step reuses the same buffer.
407 + worker.save_caches({"h": src}, "h", _meta(saves={"h": [1]}))
408 + worker.flush_saves()
409 +
410 + assert len(worker._buf_pool._pool) == 1
411 + assert id(worker._buf_pool._pool[0].src_ptrs) == buf_id
412 +
413 +
414 +@_requires_cuda
415 +def test_buffer_pool_is_reused_across_load_steps(make_worker):
416 + """After start_load_caches, descriptor buffers are returned to the pool
417 + and reused on the next call."""
418 + worker = make_worker()
419 + worker._region.blocks[0].fill_(0x01)
420 + worker._region.blocks[1].fill_(0x02)
421 +
422 + encoder_cache: dict[str, torch.Tensor] = {}
423 + worker.start_load_caches(encoder_cache, _meta(loads={"a": [0]}))
424 +
425 + assert len(worker._buf_pool._pool) == 1
426 + buf_id = id(worker._buf_pool._pool[0].src_ptrs)
427 +
428 + encoder_cache2: dict[str, torch.Tensor] = {}
429 + worker.start_load_caches(encoder_cache2, _meta(loads={"b": [1]}))
430 +
431 + assert len(worker._buf_pool._pool) == 1
432 + assert id(worker._buf_pool._pool[0].src_ptrs) == buf_id
433 +
434 +
435 +# ── stream management ────────────────────────────────────────────────────────
436 +
437 +
438 +@_requires_cuda
439 +def test_stream_initialized_at_construction(make_worker):
440 + """``_load_stream`` must be a fully initialized CUDA stream."""
441 + worker = make_worker()
442 + assert isinstance(worker._load_stream, torch.cuda.Stream)
443 +
444 +
445 +# ── lifecycle ────────────────────────────────────────────────────────────────
446 +
447 +
448 +@pytest.mark.parametrize(
449 + "pin_available,expected_pinned",
450 + [
451 + (True, True),
452 + (False, False),
453 + ],
454 + ids=["available", "unavailable"],
455 +)
456 +def test_init_pins_memory_when_available(pin_available, expected_pinned):
457 + """Every worker must call ``pin_memory()`` when the platform allows it."""
458 + region = _make_region()
459 + region.pin_memory = MagicMock() # type: ignore[method-assign]
460 +
461 + pcp_group = Mock()
462 + pcp_group.rank_in_group = 0
463 + with (
464 + patch(
465 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.create_ec_shared_region",
466 + return_value=region,
467 + ),
468 + patch(
469 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.is_pin_memory_available",
470 + return_value=pin_available,
471 + ),
472 + patch(
473 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.get_tensor_model_parallel_rank",
474 + return_value=0,
475 + ),
476 + patch(
477 + "vllm.distributed.ec_transfer.ec_connector.cpu.worker.get_pcp_group",
478 + return_value=pcp_group,
479 + ),
480 + ):
481 + ECCPUWorker(_vllm_config(rank=0))
482 +
483 + try:
484 + if expected_pinned:
485 + region.pin_memory.assert_called_once()
486 + else:
487 + region.pin_memory.assert_not_called()
488 + finally:
489 + region.cleanup()
490 +
491 +
492 +def test_shutdown_calls_region_cleanup_and_swallows_errors(caplog_vllm):
493 + """``shutdown`` must always call ``region.cleanup`` — and must never
494 + raise."""
495 + worker = object.__new__(ECCPUWorker)
496 + mock_region = Mock(spec=ECSharedRegion)
497 + worker._region = mock_region
498 + worker._load_stream = MagicMock()
499 + worker._save_bufs = None
500 + worker._save_count = 0
501 +
502 + worker.shutdown()
503 + worker._load_stream.synchronize.assert_called_once()
504 + mock_region.cleanup.assert_called_once()
505 +
506 + mock_region.cleanup.side_effect = RuntimeError("boom")
507 + with caplog_vllm.at_level(logging.DEBUG, logger="vllm"):
508 + worker.shutdown() # exception must be swallowed
509 +
510 + assert mock_region.cleanup.call_count == 2
511 + assert any(
512 + "worker region cleanup failed" in r.message
513 + for r in caplog_vllm.records
514 + if r.levelno == logging.DEBUG
515 + )
516 +
517 +
518 +# ── e2e: scheduler + worker pipeline ────────────────────────────────────────
519 +
520 +
521 +@_requires_cuda
522 +def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
523 + """Full pipeline: scheduler allocates blocks, worker saves GPU tensor to
524 + mmap via flush_saves, scheduler marks ready after step delay, worker
525 + loads from mmap back to GPU, and the result matches the original.
526 +
527 + Exercises the real scheduler + worker cooperation through a shared
528 + ECSharedRegion, with real CUDA transfers and stream coordination.
529 + """
530 + import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
531 + from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import (
532 + ECCPUScheduler,
533 + )
534 +
535 + # Build worker first (gets us a real region).
536 + worker = make_worker()
537 + region = worker._region
538 +
539 + # Build scheduler sharing the same region.
540 + monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
541 +
542 + class _EC:
543 + is_ec_producer = True
544 + is_ec_consumer = True
545 +
546 + class _Cfg:
547 + ec_transfer_config = _EC()
548 + max_concurrent_batches = 1
549 +
550 + scheduler = ECCPUScheduler(_Cfg())
551 +
552 + # -- Step 1: scheduler allocates, worker saves --
553 + n_blocks = 3
554 + src = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device="cuda").reshape(
555 + n_blocks, _HIDDEN_DIM
556 + )
557 +
558 + class _Pos:
559 + offset = 0
560 + length = n_blocks
561 +
562 + class _Feature:
563 + identifier = "img_001"
564 + mm_position = _Pos()
565 +
566 + class _Request:
567 + request_id = "req_e2e"
568 + mm_features = [_Feature()]
569 +
570 + scheduler.update_state_after_alloc(_Request(), 0)
571 + meta_save = scheduler.build_connector_meta(scheduler_output=None)
572 + assert "img_001" in meta_save.saves
573 +
574 + encoder_cache = {"img_001": src}
575 + worker.save_caches(encoder_cache, "img_001", meta_save)
576 + worker.flush_saves()
577 +
578 + # -- Step 2: readiness delay (max_concurrent_batches=1) --
579 + meta_step2 = scheduler.build_connector_meta(scheduler_output=None)
580 + assert scheduler.has_cache_item("img_001") is True
581 + assert meta_step2.loads == {} # no load requested yet
582 +
583 + # -- Step 3: scheduler emits load, worker loads --
584 + scheduler.update_state_after_alloc(_Request(), 0)
585 + meta_load = scheduler.build_connector_meta(scheduler_output=None)
586 + assert "img_001" in meta_load.loads
587 + assert meta_load.loads["img_001"] == meta_save.saves["img_001"]
588 +
589 + load_cache: dict[str, torch.Tensor] = {}
590 + worker.start_load_caches(load_cache, meta_load)
591 +
592 + out = load_cache["img_001"]
593 + assert out.is_cuda
594 + assert out.shape == src.shape
595 + assert out.dtype == src.dtype
596 + assert torch.equal(out.cpu(), src.cpu())
597 +
598 + scheduler.shutdown()
tests/v1/ec_connector/unit/test_ec_cpu_connector.py new
+158
@@ -0,0 +1,158 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +E2E test for the ECCPUConnector.
5 +
6 +Verifies:
7 +- Accuracy: outputs from EC CPU cache match fresh encoder computation.
8 +- Latency: loading from EC CPU cache is faster than a cold encoder run.
9 +
10 +Requires a CUDA GPU and the Qwen2-VL-2B-Instruct model.
11 +"""
12 +
13 +import time
14 +
15 +import pytest
16 +from tqdm import tqdm
17 +
18 +from vllm import LLM, SamplingParams
19 +from vllm.assets.image import ImageAsset
20 +from vllm.config import ECTransferConfig
21 +from vllm.platforms import current_platform
22 +
23 +MODEL = "Qwen/Qwen2-VL-2B-Instruct"
24 +EC_CPU_BYTES = 500 << 20 # 500 MiB
25 +
26 +
27 +def _build_image_prompt(image):
28 + """Build a Qwen2-VL prompt with a single image."""
29 + return {
30 + "prompt": (
31 + "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
32 + "<|im_start|>user\n"
33 + "<|vision_start|><|image_pad|><|vision_end|>"
34 + "Describe this image briefly.<|im_end|>\n"
35 + "<|im_start|>assistant\n"
36 + ),
37 + "multi_modal_data": {"image": image},
38 + }
39 +
40 +
41 +def _make_unique_image(base_image, iteration: int):
42 + """Create a unique image by modifying a pixel to change the mm_hash."""
43 + img = base_image.copy()
44 + img.putpixel((0, 0), (iteration % 256, iteration // 256 % 256, 0))
45 + return img
46 +
47 +
48 +def _wait_for_ec_ready(llm: LLM) -> None:
49 + """Send a dummy request to force a scheduler step.
50 +
51 + After generate() returns, the EC entry's data is in the mmap but
52 + readiness may not have fired yet (first-finish fires in the NEXT
53 + scheduler step). This forces that step.
54 + """
55 + llm.generate("hi", SamplingParams(max_tokens=1), use_tqdm=False)
56 +
57 +
58 +def _latency_test(llm: LLM) -> None:
59 + """Verify EC CPU cache hit is faster than cold encoder computation."""
60 + if not current_platform.is_cuda():
61 + pytest.skip("Latency test requires CUDA")
62 +
63 + sampling_params = SamplingParams(max_tokens=1, temperature=0)
64 + base_image = ImageAsset("stop_sign").pil_image
65 +
66 + num_times_ec_better_than_cold = 0
67 + num_tests = 10
68 + total_cold_time = 0.0
69 + total_ec_hit_time = 0.0
70 +
71 + for i in tqdm(range(num_tests), desc="EC latency test"):
72 + # Use a unique image each iteration so the cold run is truly cold
73 + # (EC has never seen this mm_hash before).
74 + image = _make_unique_image(base_image, i)
75 + prompt = _build_image_prompt(image)
76 +
77 + # Cold run: encoder computes from scratch, saves to EC CPU.
78 + llm.llm_engine.reset_encoder_cache()
79 + start_time = time.time()
80 + llm.generate([prompt], sampling_params, use_tqdm=False)
81 + cold_time = time.time() - start_time
82 + total_cold_time += cold_time
83 +
84 + # Ensure EC entry is marked ready.
85 + _wait_for_ec_ready(llm)
86 +
87 + # Clear GPU encoder cache but keep EC CPU cache intact.
88 + llm.llm_engine.reset_encoder_cache()
89 +
90 + # EC hit: scheduler loads from CPU mmap instead of re-encoding.
91 + start_time = time.time()
92 + llm.generate([prompt], sampling_params, use_tqdm=False)
93 + ec_hit_time = time.time() - start_time
94 + total_ec_hit_time += ec_hit_time
95 +
96 + if ec_hit_time < cold_time:
97 + num_times_ec_better_than_cold += 1
98 +
99 + print("Average times:")
100 + print(f" Cold: {total_cold_time * 1000 / num_tests:.2f}ms")
101 + print(f" EC hit: {total_ec_hit_time * 1000 / num_tests:.2f}ms")
102 +
103 + assert num_times_ec_better_than_cold >= 0.8 * num_tests, (
104 + f"EC hit was faster only {num_times_ec_better_than_cold}/{num_tests} "
105 + f"times (expected >= 80%)"
106 + )
107 +
108 +
109 +def _accuracy_test(llm: LLM) -> None:
110 + """Verify EC CPU cache produces correct outputs."""
111 + sampling_params = SamplingParams(max_tokens=8, temperature=0)
112 + image = ImageAsset("stop_sign").pil_image
113 + prompt = _build_image_prompt(image)
114 +
115 + # Seed: generate with fresh encoder to establish baseline output.
116 + llm.llm_engine.reset_encoder_cache()
117 + baseline = llm.generate([prompt], sampling_params, use_tqdm=False)
118 + baseline_text = baseline[0].outputs[0].text
119 +
120 + # Ensure EC entry is marked ready.
121 + _wait_for_ec_ready(llm)
122 +
123 + # Clear GPU encoder cache so subsequent runs go through EC CPU path.
124 + llm.llm_engine.reset_encoder_cache()
125 +
126 + # Generate multiple times from EC cache and check correctness.
127 + test_count = 20
128 + results = llm.generate([prompt] * test_count, sampling_params, use_tqdm=False)
129 +
130 + success_count = sum(1 for r in results if r.outputs[0].text == baseline_text)
131 + assert success_count >= 0.5 * test_count, (
132 + f"Only {success_count}/{test_count} outputs matched baseline "
133 + f"(expected >= 50%). Baseline: {baseline_text!r}"
134 + )
135 +
136 +
137 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
138 +def test_ec_cpu_offloading() -> None:
139 + """Tests ECCPUConnector accuracy and latency with a VLM model."""
140 + ec_transfer_config = ECTransferConfig(
141 + ec_connector="ECCPUConnector",
142 + ec_role="ec_both",
143 + ec_connector_extra_config={"ec_cpu_bytes": EC_CPU_BYTES},
144 + )
145 +
146 + llm = LLM(
147 + model=MODEL,
148 + max_model_len=2048,
149 + gpu_memory_utilization=0.5,
150 + enforce_eager=True,
151 + ec_transfer_config=ec_transfer_config,
152 + )
153 +
154 + try:
155 + _latency_test(llm)
156 + _accuracy_test(llm)
157 + finally:
158 + del llm
tests/v1/ec_connector/unit/test_metadata.py new
+96
@@ -0,0 +1,96 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ECCPUConnectorMetadata."""
4 +
5 +from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
6 +from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
7 + ECCPUConnectorMetadata,
8 +)
9 +
10 +
11 +def test_metadata_is_ec_connector_metadata():
12 + """ECCPUConnectorMetadata must be a subclass of ECConnectorMetadata."""
13 + meta = ECCPUConnectorMetadata()
14 + assert isinstance(meta, ECConnectorMetadata)
15 +
16 +
17 +def test_metadata_initialization():
18 + """ECCPUConnectorMetadata initializes with empty saves and loads dicts."""
19 + meta = ECCPUConnectorMetadata()
20 + assert hasattr(meta, "saves")
21 + assert hasattr(meta, "loads")
22 + assert meta.saves == {}
23 + assert meta.loads == {}
24 +
25 +
26 +def test_metadata_saves_dict_operations():
27 + """The saves dict supports standard dict operations."""
28 + meta = ECCPUConnectorMetadata()
29 +
30 + # Add multiple entries
31 + meta.saves["mm_hash_1"] = [0, 1, 2]
32 + meta.saves["mm_hash_2"] = [10, 11]
33 +
34 + assert "mm_hash_1" in meta.saves
35 + assert "mm_hash_2" in meta.saves
36 + assert len(meta.saves) == 2
37 + assert meta.saves["mm_hash_1"] == [0, 1, 2]
38 + assert meta.saves["mm_hash_2"] == [10, 11]
39 +
40 +
41 +def test_metadata_loads_dict_operations():
42 + """The loads dict supports standard dict operations."""
43 + meta = ECCPUConnectorMetadata()
44 +
45 + # Add multiple entries
46 + meta.loads["mm_hash_1"] = [5, 6, 7]
47 + meta.loads["mm_hash_2"] = [100]
48 +
49 + assert "mm_hash_1" in meta.loads
50 + assert "mm_hash_2" in meta.loads
51 + assert len(meta.loads) == 2
52 + assert meta.loads["mm_hash_1"] == [5, 6, 7]
53 + assert meta.loads["mm_hash_2"] == [100]
54 +
55 +
56 +def test_metadata_saves_and_loads_are_independent():
57 + """saves and loads dicts are independent."""
58 + meta = ECCPUConnectorMetadata()
59 +
60 + meta.saves["key"] = [1, 2]
61 + meta.loads["key"] = [3, 4]
62 +
63 + # Same key but different dicts should not interfere
64 + assert meta.saves["key"] == [1, 2]
65 + assert meta.loads["key"] == [3, 4]
66 +
67 + # Modifying one should not affect the other
68 + meta.saves["other"] = [99]
69 + assert "other" not in meta.loads
70 +
71 +
72 +def test_metadata_block_indices_are_integers():
73 + """Block indices in saves/loads lists should be integers."""
74 + meta = ECCPUConnectorMetadata()
75 +
76 + meta.saves["hash"] = [0, 100, 999, 65535]
77 + meta.loads["hash"] = [1, 2, 3]
78 +
79 + for idx in meta.saves["hash"]:
80 + assert isinstance(idx, int)
81 + for idx in meta.loads["hash"]:
82 + assert isinstance(idx, int)
83 +
84 +
85 +def test_metadata_multiple_instances_are_independent():
86 + """Different ECCPUConnectorMetadata instances should not share state."""
87 + meta1 = ECCPUConnectorMetadata()
88 + meta2 = ECCPUConnectorMetadata()
89 +
90 + meta1.saves["x"] = [1]
91 + meta2.saves["y"] = [2]
92 +
93 + assert "x" in meta1.saves
94 + assert "x" not in meta2.saves
95 + assert "y" not in meta1.saves
96 + assert "y" in meta2.saves
vllm/distributed/ec_transfer/ec_connector/cpu/__init__.py
vllm/distributed/ec_transfer/ec_connector/cpu/common.py new
+89
@@ -0,0 +1,89 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Shared types for the ECCPUConnector scheduler and worker delegates."""
4 +
5 +from dataclasses import dataclass, field
6 +from typing import TYPE_CHECKING
7 +
8 +import torch
9 +
10 +from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
11 +from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
12 + ECSharedRegion,
13 +)
14 +
15 +if TYPE_CHECKING:
16 + from vllm.config import VllmConfig
17 +
18 +
19 +@dataclass
20 +class ECCPUConnectorMetadata(ECConnectorMetadata):
21 + """Per-step scheduler → worker payload for the ECCPUConnector.
22 +
23 + Populated by `ECCPUScheduler.build_connector_meta`; consumed by
24 + `ECCPUWorker` via the mixin's `bind_connector_metadata`.
25 + """
26 +
27 + # Producer role: mm_hashes the scheduler has just allocated CPU
28 + # blocks for this step; the worker's save_caches copies
29 + # encoder_cache[mm_hash] → mmap at these block IDs.
30 + saves: dict[str, list[int]] = field(default_factory=dict)
31 +
32 + # Consumer role: mm_hashes whose bytes are available in the local mmap;
33 + # the worker's start_load_caches copies mmap[block_ids] → GPU
34 + # encoder_cache.
35 + loads: dict[str, list[int]] = field(default_factory=dict)
36 +
37 +
38 +def _get_encoder_cache_hidden_dim(vllm_config: "VllmConfig") -> int:
39 + """Return the per-token hidden dimension for encoder cache entries.
40 +
41 + For most models this equals the LLM's hidden size. Qwen3-VL (and any
42 + future model with deepstack visual encoding) is an exception: the ViT
43 + concatenates its own output with features from N decoder layers before
44 + storing in encoder_cache, producing a tensor of width
45 + ``out_hidden_size * (1 + N)`` per visual token. Using the plain LLM
46 + hidden size would under-allocate EC blocks and silently truncate the
47 + transferred data, leading to a shape mismatch on the consumer.
48 + """
49 + model_config = vllm_config.model_config
50 + hf_config = getattr(model_config, "hf_config", None)
51 + vision_config = (
52 + getattr(hf_config, "vision_config", None) if hf_config is not None else None
53 + )
54 + if vision_config is not None:
55 + out_hidden_size = getattr(vision_config, "out_hidden_size", None)
56 + deepstack_indexes = getattr(vision_config, "deepstack_visual_indexes", None)
57 + if out_hidden_size is not None and deepstack_indexes:
58 + return out_hidden_size * (1 + len(deepstack_indexes))
59 + return model_config.get_inputs_embeds_size()
60 +
61 +
62 +def create_ec_shared_region(vllm_config: "VllmConfig") -> ECSharedRegion:
63 + """Build the EC mmap region from `vllm_config`.
64 +
65 + Both `ECCPUScheduler` and `ECCPUWorker` call this to get the same
66 + shared region (same engine_id, same block_size_bytes).
67 + """
68 + ec_config = vllm_config.ec_transfer_config
69 + assert ec_config is not None, "ec_transfer_config required to build region"
70 +
71 + dp_rank = vllm_config.parallel_config.data_parallel_rank
72 + engine_id = f"{vllm_config.instance_id}_dp{dp_rank}"
73 +
74 + dtype = vllm_config.model_config.dtype
75 + hidden_dim = _get_encoder_cache_hidden_dim(vllm_config)
76 + element_size = torch.empty(0, dtype=dtype).element_size()
77 + block_size_bytes = hidden_dim * element_size
78 +
79 + cpu_bytes = ec_config.ec_connector_extra_config.get("ec_cpu_bytes")
80 + if not cpu_bytes:
81 + raise ValueError("ec_cpu_bytes must be specified in ec_connector_extra_config")
82 + cpu_bytes = int(cpu_bytes)
83 + num_blocks = cpu_bytes // block_size_bytes
84 +
85 + return ECSharedRegion(
86 + engine_id=engine_id,
87 + num_blocks=num_blocks,
88 + block_size_bytes=block_size_bytes,
89 + )
vllm/distributed/ec_transfer/ec_connector/cpu/connector.py new
+121
@@ -0,0 +1,121 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""ECCPUConnector — CPU encoder-cache offloading.
4 +
5 +A role-routed shell: one instance per process. The scheduler delegate owns the
6 +mmap region and offload bookkeeping; the worker delegate owns the mmap view and
7 +GPU copy plumbing. An ec_both instance reuses encoder outputs it has already
8 +offloaded to CPU instead of recomputing them.
9 +"""
10 +
11 +from typing import TYPE_CHECKING
12 +
13 +import torch
14 +
15 +from vllm.distributed.ec_transfer.ec_connector.base import (
16 + ECConnectorBase,
17 + ECConnectorRole,
18 +)
19 +from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
20 + ECCPUConnectorMetadata,
21 +)
22 +from vllm.logger import init_logger
23 +
24 +if TYPE_CHECKING:
25 + from vllm.config import VllmConfig
26 + from vllm.v1.core.sched.output import SchedulerOutput
27 + from vllm.v1.request import Request
28 +
29 +logger = init_logger(__name__)
30 +
31 +
32 +class ECCPUConnector(ECConnectorBase):
33 + """EC connector that offloads encoder cache to a shared CPU mmap region."""
34 +
35 + def __init__(self, vllm_config: "VllmConfig", role: ECConnectorRole) -> None:
36 + super().__init__(vllm_config=vllm_config, role=role)
37 +
38 + self.connector_worker = None
39 + self.connector_scheduler = None
40 +
41 + if role == ECConnectorRole.WORKER:
42 + self.connector_worker = self._make_worker(vllm_config)
43 + elif role == ECConnectorRole.SCHEDULER:
44 + self.connector_scheduler = self._make_scheduler(vllm_config)
45 + else:
46 + raise ValueError(f"Unknown ECConnectorRole: {role}")
47 +
48 + # Construction seams.
49 + def _make_worker(self, vllm_config: "VllmConfig"):
50 + # Deferred import: the worker module touches torch/CUDA at import time
51 + # via the region, so keep that cost off the scheduler path.
52 + from vllm.distributed.ec_transfer.ec_connector.cpu.worker import (
53 + ECCPUWorker,
54 + )
55 +
56 + return ECCPUWorker(vllm_config)
57 +
58 + def _make_scheduler(self, vllm_config: "VllmConfig"):
59 + from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import (
60 + ECCPUScheduler,
61 + )
62 +
63 + return ECCPUScheduler(vllm_config)
64 +
65 + # Worker-side forwarders.
66 + def start_load_caches(
67 + self, encoder_cache: dict[str, torch.Tensor], **kwargs
68 + ) -> None:
69 + assert self.connector_worker is not None
70 + metadata = self._get_connector_metadata()
71 + assert isinstance(metadata, ECCPUConnectorMetadata)
72 + self.connector_worker.start_load_caches(
73 + encoder_cache, connector_metadata=metadata
74 + )
75 +
76 + def save_caches(
77 + self, encoder_cache: dict[str, torch.Tensor], mm_hash: str, **kwargs
78 + ) -> None:
79 + assert self.connector_worker is not None
80 + metadata = self._get_connector_metadata()
81 + assert isinstance(metadata, ECCPUConnectorMetadata)
82 + self.connector_worker.save_caches(
83 + encoder_cache, mm_hash, connector_metadata=metadata
84 + )
85 +
86 + # Scheduler-side forwarders.
87 + def has_cache_item(self, identifier: str) -> bool:
88 + assert self.connector_scheduler is not None
89 + return self.connector_scheduler.has_cache_item(identifier)
90 +
91 + def ensure_cache_available(
92 + self, request: "Request", num_computed_tokens: int
93 + ) -> bool:
94 + assert self.connector_scheduler is not None
95 + return self.connector_scheduler.ensure_cache_available(
96 + request, num_computed_tokens
97 + )
98 +
99 + def update_state_after_alloc(self, request: "Request", index: int) -> None:
100 + assert self.connector_scheduler is not None
101 + self.connector_scheduler.update_state_after_alloc(request, index)
102 +
103 + def build_connector_meta(
104 + self, scheduler_output: "SchedulerOutput"
105 + ) -> ECCPUConnectorMetadata:
106 + assert self.connector_scheduler is not None
107 + return self.connector_scheduler.build_connector_meta(scheduler_output)
108 +
109 + def get_finished(
110 + self, finished_req_ids: set[str]
111 + ) -> tuple[set[str] | None, set[str] | None]:
112 + if self.connector_worker is not None:
113 + self.connector_worker.flush_saves()
114 + return None, None
115 +
116 + # Shared.
117 + def shutdown(self) -> None:
118 + if self.connector_scheduler is not None:
119 + self.connector_scheduler.shutdown()
120 + if self.connector_worker is not None:
121 + self.connector_worker.shutdown()
vllm/distributed/ec_transfer/ec_connector/cpu/ec_shared_region.py new
+159
@@ -0,0 +1,159 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +Lightweight mmap-backed shared memory region for encoder cache (EC) data.
5 +
6 +Modeled after SharedOffloadRegion (vllm/v1/kv_offload/cpu/) but simplified
7 +for EC: flat shared layout, no multi-tensor cursor, no block_size_factor.
8 +"""
9 +
10 +import mmap
11 +import os
12 +import time
13 +
14 +import torch
15 +
16 +from vllm.logger import init_logger
17 +
18 +logger = init_logger(__name__)
19 +
20 +
21 +def _wait_for_file_size(fd: int, expected_size: int, timeout: float = 30.0):
22 + """Spin-wait until the file reaches expected_size (creator truncated it)."""
23 + deadline = time.monotonic() + timeout
24 + while True:
25 + if os.fstat(fd).st_size >= expected_size:
26 + return
27 + if time.monotonic() > deadline:
28 + raise TimeoutError(
29 + f"Timed out waiting for EC mmap file to reach {expected_size} bytes"
30 + )
31 + time.sleep(0.005)
32 +
33 +
34 +class ECSharedRegion:
35 + """Flat mmap-backed memory region shared across TP workers for
36 + encoder cache blocks.
37 +
38 + Layout: (num_blocks, block_size_bytes) — contiguous, no per-worker
39 + interleaving. All workers map the same file and see identical data.
40 +
41 + File path: /dev/shm/vllm_ec_{engine_id}.mmap
42 +
43 + This class owns only the shared memory substrate (mmap lifecycle, the
44 + `blocks` view, CUDA host registration). Block allocation and eviction
45 + are tracked by `EmbeddingCache` in the scheduler process.
46 + """
47 +
48 + def __init__(
49 + self,
50 + engine_id: str,
51 + num_blocks: int,
52 + block_size_bytes: int,
53 + ) -> None:
54 + self.num_blocks = num_blocks
55 + self.block_size_bytes = block_size_bytes
56 +
57 + total_size_bytes = num_blocks * block_size_bytes
58 + # Path in /dev/shm (tmpfs); unique per engine instance.
59 + self._mmap_path = f"/dev/shm/vllm_ec_{engine_id}.mmap"
60 + # True for the process that created the file (responsible for unlink).
61 + self._is_creator = False
62 + # True after successful cudaHostRegister (cleanup must unregister).
63 + self._is_pinned = False
64 +
65 + # File descriptor for the shared memory backing file.
66 + try:
67 + self._fd: int | None = os.open(
68 + self._mmap_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600
69 + )
70 + os.ftruncate(self._fd, total_size_bytes)
71 + self._is_creator = True
72 + logger.info(
73 + "Created EC mmap file %s (%.2f MiB)",
74 + self._mmap_path,
75 + total_size_bytes / (1 << 20),
76 + )
77 + except FileExistsError:
78 + self._fd = os.open(self._mmap_path, os.O_RDWR)
79 + try:
80 + _wait_for_file_size(self._fd, total_size_bytes)
81 + except Exception:
82 + os.close(self._fd)
83 + self._fd = None
84 + raise
85 + logger.info("Opened existing EC mmap file %s", self._mmap_path)
86 +
87 + # MAP_SHARED mmap over _fd; all processes see the same pages.
88 + self._mmap_obj: mmap.mmap | None = mmap.mmap(
89 + self._fd,
90 + total_size_bytes,
91 + flags=mmap.MAP_SHARED,
92 + prot=mmap.PROT_READ | mmap.PROT_WRITE,
93 + )
94 +
95 + if self._is_creator:
96 + _MADV_POPULATE_WRITE = getattr(mmap, "MADV_POPULATE_WRITE", 23)
97 + self._mmap_obj.madvise(_MADV_POPULATE_WRITE, 0, total_size_bytes)
98 +
99 + # (num_blocks, block_size_bytes) int8 tensor over the mmap buffer.
100 + self.blocks: torch.Tensor = torch.frombuffer(
101 + memoryview(self._mmap_obj), dtype=torch.int8
102 + ).view(num_blocks, block_size_bytes)
103 + # Cached for cudaHostRegister/Unregister and pointer math.
104 + self._blocks_ptr: int = self.blocks.data_ptr()
105 + self._blocks_nbytes: int = self.blocks.nbytes
106 +
107 + def pin_memory(self) -> None:
108 + """Register the entire mmap as CUDA pinned memory for fast DMA.
109 +
110 + Each TP worker process owns its own virtual address for the same
111 + shared physical pages, so every process must register independently.
112 + No-op when CUDA is not available or already pinned.
113 + """
114 + if self._is_pinned or not torch.cuda.is_available():
115 + return
116 + result = torch.cuda.cudart().cudaHostRegister(
117 + self._blocks_ptr, self._blocks_nbytes, 0
118 + )
119 + if result.value != 0:
120 + logger.warning(
121 + "cudaHostRegister failed (code=%d) — "
122 + "transfers will still work but may be slower (unpinned DMA)",
123 + result.value,
124 + )
125 + else:
126 + logger.debug("cudaHostRegister %.2f MB", self._blocks_nbytes / 1e6)
127 + self._is_pinned = True
128 +
129 + def cleanup(self) -> None:
130 + """Tear down the region. Lifecycle method; no concurrent access."""
131 + logger.info("Starting ECSharedRegion cleanup...")
132 + if self._is_creator:
133 + try:
134 + os.unlink(self._mmap_path)
135 + logger.info("Removed EC mmap file %s", self._mmap_path)
136 + except Exception:
137 + logger.warning(
138 + "Failed to unlink path %s", self._mmap_path, exc_info=True
139 + )
140 + self._is_creator = False
141 + if self._is_pinned:
142 + result = torch.cuda.cudart().cudaHostUnregister(self._blocks_ptr)
143 + if result.value != 0:
144 + logger.warning("cudaHostUnregister failed (code=%d)", result)
145 + self._is_pinned = False
146 + if hasattr(self, "blocks"):
147 + del self.blocks
148 + if self._mmap_obj:
149 + try:
150 + self._mmap_obj.close()
151 + except Exception:
152 + logger.warning("Failed to close mmap_obj", exc_info=True)
153 + self._mmap_obj = None
154 + if self._fd is not None:
155 + try:
156 + os.close(self._fd)
157 + except Exception:
158 + logger.warning("Failed to close fd %s", self._fd, exc_info=True)
159 + self._fd = None
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/__init__.py new
+122
@@ -0,0 +1,122 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""ECCPUScheduler — CPU offload scheduler delegate.
4 +
5 +Owns the mmap region and the embedding cache, and handles the producer
6 +(GPU->CPU offload) and consumer (CPU->GPU reload) scheduler-side logic
7 +for the ECCPUConnector.
8 +"""
9 +
10 +from typing import TYPE_CHECKING
11 +
12 +from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
13 + ECCPUConnectorMetadata,
14 + create_ec_shared_region,
15 +)
16 +from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
17 + EmbeddingCache,
18 +)
19 +from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.step_tracker import (
20 + StepTracker,
21 +)
22 +from vllm.logger import init_logger
23 +
24 +if TYPE_CHECKING:
25 + from vllm.config import VllmConfig
26 + from vllm.v1.core.sched.output import SchedulerOutput
27 + from vllm.v1.request import Request
28 +
29 +logger = init_logger(__name__)
30 +
31 +
32 +class ECCPUScheduler:
33 + """Scheduler delegate for the ECCPUConnector."""
34 +
35 + def __init__(self, vllm_config: "VllmConfig") -> None:
36 + ec_config = vllm_config.ec_transfer_config
37 + assert ec_config is not None
38 + self._is_producer: bool = ec_config.is_ec_producer
39 + self._is_consumer: bool = ec_config.is_ec_consumer
40 +
41 + self._region = create_ec_shared_region(vllm_config)
42 + # Block allocator + LRU eviction policy for the shared region.
43 + self._cache = EmbeddingCache(self._region.num_blocks)
44 +
45 + max_batches = vllm_config.max_concurrent_batches
46 + # Delays mark_ready until the GPU→mmap DMA is guaranteed complete.
47 + self._ready_tracker = StepTracker(max_batches)
48 + # Delays unpin until the mmap→GPU DMA is guaranteed complete.
49 + self._unpin_tracker = StepTracker(max_batches)
50 +
51 + # mm_hash → block IDs allocated this step for GPU→mmap saves.
52 + self._pending_saves: dict[str, list[int]] = {}
53 + # mm_hash → block IDs to load from mmap→GPU this step.
54 + self._pending_loads: dict[str, list[int]] = {}
55 +
56 + def has_cache_item(self, identifier: str) -> bool:
57 + if not self._is_consumer:
58 + return False
59 + entry = self._cache.get(identifier)
60 + return entry is not None and entry.ready
61 +
62 + def ensure_cache_available(
63 + self, request: "Request", num_computed_tokens: int
64 + ) -> bool:
65 + return True
66 +
67 + def update_state_after_alloc(self, request: "Request", index: int) -> None:
68 + feature = request.mm_features[index]
69 + mm_hash = feature.identifier
70 +
71 + if self._is_producer and self._cache.get(mm_hash) is None:
72 + entry = self._cache.alloc(mm_hash, feature.mm_position.length)
73 + if entry is not None:
74 + self._pending_saves[mm_hash] = list(entry.block_ids)
75 + self._ready_tracker.add(mm_hash, request.request_id)
76 +
77 + if self._is_consumer and mm_hash not in self._pending_loads:
78 + entry = self._cache.get(mm_hash)
79 + if entry is not None and entry.ready:
80 + self._cache.pin(mm_hash)
81 + self._pending_loads[mm_hash] = list(entry.block_ids)
82 + self._unpin_tracker.add(mm_hash, request.request_id)
83 +
84 + def build_connector_meta(
85 + self, scheduler_output: "SchedulerOutput"
86 + ) -> ECCPUConnectorMetadata:
87 + finished = (
88 + scheduler_output.finished_req_ids if scheduler_output is not None else set()
89 + )
90 +
91 + for key in self._ready_tracker.step(finished):
92 + entry = self._cache.get(key)
93 + if entry is not None and not entry.ready:
94 + self._cache.mark_ready(key)
95 +
96 + for key in self._unpin_tracker.step(finished):
97 + self._cache.unpin(key)
98 +
99 + meta = ECCPUConnectorMetadata()
100 + if self._is_producer:
101 + meta.saves = self._pending_saves
102 + self._pending_saves = {}
103 + if self._is_consumer:
104 + meta.loads = self._pending_loads
105 + self._pending_loads = {}
106 + return meta
107 +
108 + def shutdown(self) -> None:
109 + # drain_all() covers both entries still in _current (never
110 + # consumed by build_connector_meta) and entries in slots.
111 + self._pending_loads.clear()
112 + for mm_hash in self._unpin_tracker.drain_all():
113 + self._cache.unpin(mm_hash)
114 + self._ready_tracker.drain_all()
115 +
116 + self._is_producer = False
117 + self._is_consumer = False
118 +
119 + try:
120 + self._region.cleanup()
121 + except Exception:
122 + logger.debug("ec: region cleanup failed", exc_info=True)
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/embedding_cache.py new
+131
@@ -0,0 +1,131 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""EmbeddingCache — named-entry block cache with FIFO eviction.
4 +
5 +Manages a fixed pool of block IDs keyed by content identity (mm_hash).
6 +Entries transition through: not-ready → ready (evictable) → pinned.
7 +Eviction targets ready + unpinned entries in FIFO order.
8 +
9 +All public methods are thread-safe.
10 +"""
11 +
12 +import threading
13 +from collections import OrderedDict
14 +
15 +
16 +class CacheEntry:
17 + """A single cache entry. Read `.block_ids` and `.ready` freely;
18 + mutations only through EmbeddingCache methods."""
19 +
20 + __slots__ = ("block_ids", "_pin_count")
21 +
22 + def __init__(self, block_ids: tuple[int, ...]) -> None:
23 + self.block_ids = block_ids
24 + self._pin_count = -1 # not ready
25 +
26 + @property
27 + def ready(self) -> bool:
28 + return self._pin_count >= 0
29 +
30 + @property
31 + def evictable(self) -> bool:
32 + return self._pin_count == 0
33 +
34 + def mark_ready(self):
35 + self._pin_count = 0
36 +
37 + def pin(self):
38 + self._pin_count += 1
39 +
40 +
41 +class EmbeddingCache:
42 + """Fixed-size block cache with FIFO eviction.
43 +
44 + Entries are keyed by content identity (e.g. mm_hash). Blocks are
45 + allocated from a free-set; when space is needed, ready + unpinned
46 + entries are evicted oldest-first.
47 +
48 + The caller is responsible for deciding *when* to call ``mark_ready``
49 + (e.g. after enough engine steps have elapsed for the worker to have
50 + completed the write).
51 + """
52 +
53 + def __init__(self, num_blocks: int) -> None:
54 + self._num_blocks = num_blocks
55 + self._lock = threading.Lock()
56 + # Available block IDs, LIFO stack.
57 + self._free_blocks: list[int] = list(range(num_blocks))
58 + # Key → live entry (all states: not-ready, ready, pinned).
59 + self._entries: dict[str, CacheEntry] = {}
60 + # Ready + unpinned entries in insertion order (FIFO eviction).
61 + self._entries_free_list: OrderedDict[str, None] = OrderedDict()
62 + # Total blocks reclaimable via eviction (fail-fast check).
63 + self._evictable_block_count: int = 0
64 +
65 + def get(self, key: str) -> CacheEntry | None:
66 + """Return the entry for *key*, or None if not present."""
67 + with self._lock:
68 + return self._entries.get(key)
69 +
70 + def alloc(self, key: str, n_blocks: int) -> CacheEntry | None:
71 + """Allocate *n_blocks* for *key*, evicting as needed.
72 +
73 + The entry starts not-ready. Returns None if there is not enough
74 + space even after evicting all evictable entries.
75 + """
76 + with self._lock:
77 + assert key not in self._entries, (
78 + f"EmbeddingCache: duplicate alloc for {key!r}"
79 + )
80 + assert n_blocks <= self._num_blocks, (
81 + f"EmbeddingCache: {n_blocks} blocks requested but capacity "
82 + f"is {self._num_blocks}"
83 + )
84 + if len(self._free_blocks) + self._evictable_block_count < n_blocks:
85 + return None
86 + self._evict_until(n_blocks)
87 + block_ids = tuple(self._free_blocks.pop() for _ in range(n_blocks))
88 + entry = CacheEntry(block_ids)
89 + self._entries[key] = entry
90 + return entry
91 +
92 + def mark_ready(self, key: str) -> None:
93 + """Mark an entry as ready (data is CPU-visible)."""
94 + with self._lock:
95 + entry = self._entries[key]
96 + assert entry._pin_count == -1, (
97 + f"EmbeddingCache: mark_ready on already-ready entry {key!r}"
98 + )
99 + entry.mark_ready()
100 + self._entries_free_list[key] = None
101 + self._evictable_block_count += len(entry.block_ids)
102 +
103 + def pin(self, key: str) -> None:
104 + """Pin an entry (prevent eviction)."""
105 + with self._lock:
106 + entry = self._entries[key]
107 + assert entry.ready, f"EmbeddingCache: pin of not-ready entry {key!r}"
108 + if entry.evictable:
109 + del self._entries_free_list[key]
110 + self._evictable_block_count -= len(entry.block_ids)
111 + entry.pin()
112 +
113 + def unpin(self, key: str) -> None:
114 + """Unpin an entry. Asserts currently pinned."""
115 + with self._lock:
116 + entry = self._entries[key]
117 + assert entry._pin_count > 0, (
118 + f"EmbeddingCache: unpin of unpinned entry {key!r}"
119 + )
120 + entry._pin_count -= 1
121 + if entry._pin_count == 0:
122 + self._entries_free_list[key] = None
123 + self._evictable_block_count += len(entry.block_ids)
124 +
125 + def _evict_until(self, n_blocks: int) -> None:
126 + """Evict ready+unpinned entries FIFO until enough space. Lock held."""
127 + while len(self._free_blocks) < n_blocks:
128 + key, _ = self._entries_free_list.popitem(last=False)
129 + entry = self._entries.pop(key)
130 + self._evictable_block_count -= len(entry.block_ids)
131 + self._free_blocks.extend(entry.block_ids)
vllm/distributed/ec_transfer/ec_connector/cpu/scheduler/step_tracker.py new
+115
@@ -0,0 +1,115 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""StepTracker — step-counting delay with first-finish fast-path.
4 +
5 +Manages a collection of (mm_hash, request_id) entries that need to be
6 +processed after a configurable number of engine steps have elapsed.
7 +Additionally, if the originating request finishes before the step-count
8 +expires, the entry is processed immediately (the GPU transfer is
9 +guaranteed complete by request finish time).
10 +
11 +Used by ECCPUScheduler: one instance for readiness marking, one for
12 +unpins.
13 +"""
14 +
15 +from collections import deque
16 +from dataclasses import dataclass
17 +
18 +
19 +@dataclass(slots=True)
20 +class _PendingEntry:
21 + mm_hash: str
22 + request_id: str
23 + processed: bool = False
24 +
25 +
26 +class StepTracker:
27 + """Track pending operations with step-count delay and first-finish.
28 +
29 + Each ``add(mm_hash, request_id)`` registers an operation that will be
30 + returned from ``step()`` either when:
31 + (a) ``max_concurrent_batches`` steps elapse (deque expiry), OR
32 + (b) ``request_id`` appears in ``finished_req_ids`` (first-finish).
33 +
34 + Each add is processed exactly once — never both (a) and (b).
35 + """
36 +
37 + def __init__(self, max_concurrent_batches: int) -> None:
38 + # One slot per past step; oldest at the right. When the deque is
39 + # full the rightmost slot is popped and its entries expire.
40 + self._slots: deque[list[_PendingEntry]] = deque(maxlen=max_concurrent_batches)
41 + # Reverse index: request_id → entries across all slots + _current.
42 + # Enables O(1) lookup for the first-finish fast-path.
43 + self._req_index: dict[str, list[_PendingEntry]] = {}
44 + # Entries added this step (via add()), committed to a new slot at
45 + # the start of the next step() call.
46 + self._current: list[_PendingEntry] = []
47 +
48 + def add(self, mm_hash: str, request_id: str) -> None:
49 + """Register an mm_hash needing processing after a delay."""
50 + entry = _PendingEntry(mm_hash=mm_hash, request_id=request_id)
51 + self._current.append(entry)
52 + self._req_index.setdefault(request_id, []).append(entry)
53 +
54 + def step(self, finished_req_ids: set[str]) -> list[str]:
55 + """Advance one step. Return mm_hashes ready for processing.
56 +
57 + Returns:
58 + List of mm_hashes to process. May contain duplicates if the
59 + same mm_hash was added multiple times (from different requests).
60 + """
61 + result: list[str] = []
62 +
63 + # Phase 1: first-finish fast-path.
64 + for req_id in finished_req_ids:
65 + entries = self._req_index.pop(req_id, None)
66 + if entries is None:
67 + continue
68 + for entry in entries:
69 + if not entry.processed:
70 + entry.processed = True
71 + result.append(entry.mm_hash)
72 +
73 + # Phase 2: step-count expiry.
74 + if len(self._slots) == self._slots.maxlen:
75 + expired_slot = self._slots.pop()
76 + for entry in expired_slot:
77 + if not entry.processed:
78 + entry.processed = True
79 + result.append(entry.mm_hash)
80 + self._cleanup_expired(expired_slot)
81 +
82 + # Phase 3: commit current entries to new slot.
83 + self._slots.appendleft(self._current)
84 + self._current = []
85 +
86 + return result
87 +
88 + def drain_all(self) -> list[str]:
89 + """Return all unprocessed entries and clear internal state."""
90 + result: list[str] = []
91 + for entry in self._current:
92 + if not entry.processed:
93 + entry.processed = True
94 + result.append(entry.mm_hash)
95 + self._current = []
96 +
97 + for slot in self._slots:
98 + for entry in slot:
99 + if not entry.processed:
100 + entry.processed = True
101 + result.append(entry.mm_hash)
102 + self._slots.clear()
103 + self._req_index.clear()
104 + return result
105 +
106 + def _cleanup_expired(self, slot: list[_PendingEntry]) -> None:
107 + """Prune reverse index for entries in an expired slot."""
108 + for entry in slot:
109 + entries_for_req = self._req_index.get(entry.request_id)
110 + if entries_for_req is None:
111 + # Already removed by fast-path (pop in step()).
112 + continue
113 + entries_for_req.remove(entry)
114 + if not entries_for_req:
115 + del self._req_index[entry.request_id]
vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py new
+202
@@ -0,0 +1,202 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Worker-side of the ECCPUConnector.
4 +
5 +Thin, stateless across steps: opens the shared mmap region and uses the
6 +per-step connector metadata (`ECCPUConnectorMetadata`) to decide which
7 +blocks to copy in each direction.
8 +"""
9 +
10 +from typing import TYPE_CHECKING
11 +
12 +import torch
13 +
14 +from vllm._custom_ops import swap_blocks_batch
15 +from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
16 + ECCPUConnectorMetadata,
17 + create_ec_shared_region,
18 +)
19 +from vllm.distributed.ec_transfer.ec_connector.cpu.worker.descriptor_buffers import (
20 + DescriptorBufferPool,
21 + DescriptorBuffers,
22 +)
23 +from vllm.distributed.parallel_state import (
24 + get_pcp_group,
25 + get_tensor_model_parallel_rank,
26 +)
27 +from vllm.logger import init_logger
28 +from vllm.platforms import current_platform
29 +from vllm.utils.platform_utils import is_pin_memory_available
30 +
31 +if TYPE_CHECKING:
32 + from vllm.config import VllmConfig
33 +
34 +logger = init_logger(__name__)
35 +
36 +
37 +class ECCPUWorker:
38 + """Worker-side delegate for the ECCPUConnector.
39 +
40 + - Producer role: copies `encoder_cache[mm_hash]` → `mmap[block_ids]`
41 + for each entry in `metadata.saves`. Descriptor buffers are filled
42 + directly in `save_caches`; the actual DMA is issued as a single
43 + batched call in `flush_saves`.
44 + - Consumer role: copies `mmap[block_ids]` → `encoder_cache[mm_hash]`
45 + for all entries in `metadata.loads` via a single `swap_blocks_batch`
46 + call on the load stream.
47 + - On `ec_both` nodes both paths run back-to-back in a single step.
48 + """
49 +
50 + def __init__(self, vllm_config: "VllmConfig") -> None:
51 + self._region = create_ec_shared_region(vllm_config)
52 + # Model dtype; used to reinterpret raw int8 blocks on load.
53 + self._dtype = vllm_config.model_config.dtype
54 +
55 + if is_pin_memory_available():
56 + self._region.pin_memory()
57 +
58 + # All TP/PCP ranks hold identical encoder output. Only one rank
59 + # per mmap needs to write — saves host memory bandwidth.
60 + # DCP is a subdivision of TP, so tp_rank==0 covers it.
61 + self._is_save_rank = (
62 + get_tensor_model_parallel_rank() == 0 and get_pcp_group().rank_in_group == 0
63 + )
64 +
65 + # Dedicated stream for async mmap→GPU loads (overlaps with compute).
66 + self._load_stream = current_platform.Stream()
67 +
68 + # Descriptor buffer pool (recycled across steps, shared by both paths).
69 + self._buf_pool = DescriptorBufferPool()
70 +
71 + # Active save buffer being filled during save_caches calls this step.
72 + self._save_bufs: DescriptorBuffers | None = None
73 + self._save_count: int = 0
74 +
75 + def save_caches(
76 + self,
77 + encoder_cache: dict[str, torch.Tensor],
78 + mm_hash: str,
79 + connector_metadata: ECCPUConnectorMetadata,
80 + ) -> None:
81 + """Fill descriptor buffers directly for batched flush."""
82 + if not self._is_save_rank:
83 + return
84 + block_ids = connector_metadata.saves.get(mm_hash)
85 + if block_ids is None:
86 + return
87 +
88 + src = encoder_cache[mm_hash]
89 + total_bytes = src.numel() * src.element_size()
90 + block_size = self._region.block_size_bytes
91 + allocated_bytes = len(block_ids) * block_size
92 + assert total_bytes <= allocated_bytes, (
93 + f"EC: encoder output exceeds allocated blocks for mm_hash={mm_hash}: "
94 + f"{total_bytes} bytes but only {allocated_bytes} allocated "
95 + f"({len(block_ids)} blocks × {block_size}). "
96 + f"shape={list(src.shape)}"
97 + )
98 +
99 + if self._save_bufs is None:
100 + total = sum(len(v) for v in connector_metadata.saves.values())
101 + self._save_bufs = self._buf_pool.acquire(total)
102 +
103 + assert self._save_count + len(block_ids) <= self._save_bufs.src_ptrs.numel()
104 +
105 + src_ptrs, dst_ptrs, sizes = self._save_bufs
106 + src_base = src.view(-1).view(torch.uint8).data_ptr()
107 + dst_base = self._region.blocks.data_ptr()
108 + idx = self._save_count
109 +
110 + for i, block_idx in enumerate(block_ids):
111 + start = i * block_size
112 + src_ptrs[idx] = src_base + start
113 + dst_ptrs[idx] = dst_base + block_idx * block_size
114 + sizes[idx] = min(block_size, total_bytes - start)
115 + idx += 1
116 +
117 + self._save_count = idx
118 +
119 + def flush_saves(self) -> None:
120 + """Flush all accumulated saves in a single swap_blocks_batch call."""
121 + if self._save_count == 0:
122 + return
123 +
124 + bufs = self._save_bufs
125 + assert bufs is not None
126 + src_ptrs, dst_ptrs, sizes = bufs
127 + n = self._save_count
128 + swap_blocks_batch(src_ptrs[:n], dst_ptrs[:n], sizes[:n])
129 +
130 + self._buf_pool.release(bufs)
131 + self._save_bufs = None
132 + self._save_count = 0
133 +
134 + def start_load_caches(
135 + self,
136 + encoder_cache: dict[str, torch.Tensor],
137 + connector_metadata: ECCPUConnectorMetadata,
138 + ) -> None:
139 + """Consumer path: single batched copy of all loads from mmap→GPU."""
140 + if not connector_metadata.loads:
141 + return
142 +
143 + block_size = self._region.block_size_bytes
144 + blocks = self._region.blocks
145 + dtype = self._dtype
146 + device_type = current_platform.device_type
147 + src_base = blocks.data_ptr()
148 +
149 + # Pre-filter: only hashes not already in encoder_cache.
150 + load_items = {
151 + h: idxs
152 + for h, idxs in connector_metadata.loads.items()
153 + if h not in encoder_cache
154 + }
155 + if not load_items:
156 + return
157 +
158 + total_blocks = sum(len(idxs) for idxs in load_items.values())
159 +
160 + with current_platform.stream(self._load_stream):
161 + # Single contiguous destination buffer for all loads.
162 + dst_buf = torch.empty(
163 + total_blocks, block_size, dtype=torch.int8, device=device_type
164 + )
165 + dst_buf_base = dst_buf.data_ptr()
166 +
167 + bufs = self._buf_pool.acquire(total_blocks)
168 + src_ptrs = bufs.src_ptrs[:total_blocks]
169 + dst_ptrs = bufs.dst_ptrs[:total_blocks]
170 + sizes = bufs.sizes[:total_blocks]
171 + sizes[:] = block_size
172 +
173 + op_idx = 0
174 + for block_ids in load_items.values():
175 + for block_idx in block_ids:
176 + src_ptrs[op_idx] = src_base + block_idx * block_size
177 + dst_ptrs[op_idx] = dst_buf_base + op_idx * block_size
178 + op_idx += 1
179 +
180 + swap_blocks_batch(src_ptrs, dst_ptrs, sizes, is_src_access_order_any=True)
181 +
182 + self._buf_pool.release(bufs)
183 +
184 + # Slice contiguous buffer into per-hash views.
185 + offset = 0
186 + for mm_hash, block_ids in load_items.items():
187 + n = len(block_ids)
188 + encoder_cache[mm_hash] = (
189 + dst_buf[offset : offset + n].view(dtype).reshape(n, -1)
190 + )
191 + offset += n
192 +
193 + current_platform.current_stream().wait_stream(self._load_stream)
194 +
195 + def shutdown(self) -> None:
196 + self._load_stream.synchronize()
197 + self._save_bufs = None
198 + self._save_count = 0
199 + try:
200 + self._region.cleanup()
201 + except Exception:
202 + logger.debug("EC: worker region cleanup failed", exc_info=True)
vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py new
+46
@@ -0,0 +1,46 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Reusable pool of (src_ptrs, dst_ptrs, sizes) int64 tensor triples.
4 +
5 +Used by ECCPUWorker to batch swap_blocks_batch descriptors without
6 +per-step allocation overhead.
7 +"""
8 +
9 +from typing import NamedTuple
10 +
11 +import torch
12 +
13 +
14 +class DescriptorBuffers(NamedTuple):
15 + src_ptrs: torch.Tensor
16 + dst_ptrs: torch.Tensor
17 + sizes: torch.Tensor
18 +
19 +
20 +class DescriptorBufferPool:
21 + """Pool of descriptor buffer triples for swap_blocks_batch.
22 +
23 + Each buffer is a `DescriptorBuffers` namedtuple of three 1-D int64
24 + tensors of equal length. Buffers are recycled across steps; if a
25 + returned buffer is too small it is discarded and a fresh one allocated.
26 + """
27 +
28 + def __init__(self) -> None:
29 + # LIFO stack of idle buffer triples.
30 + self._pool: list[DescriptorBuffers] = []
31 +
32 + def acquire(self, n: int) -> DescriptorBuffers:
33 + """Get a buffer triple with capacity >= *n*."""
34 + if self._pool:
35 + bufs = self._pool.pop()
36 + if bufs.src_ptrs.numel() >= n:
37 + return bufs
38 + return DescriptorBuffers(
39 + torch.empty(n, dtype=torch.int64),
40 + torch.empty(n, dtype=torch.int64),
41 + torch.empty(n, dtype=torch.int64),
42 + )
43 +
44 + def release(self, bufs: DescriptorBuffers) -> None:
45 + """Return a buffer triple to the pool for reuse."""
46 + self._pool.append(bufs)
vllm/distributed/ec_transfer/ec_connector/factory.py
+6
@@ -83,3 +83,9 @@ ECConnectorFactory.register_connector(
83 "vllm.distributed.ec_transfer.ec_connector.example_connector",
84 "ECExampleConnector",
85 )
86 +
87 +ECConnectorFactory.register_connector(
88 + "ECCPUConnector",
89 + "vllm.distributed.ec_transfer.ec_connector.cpu.connector",
90 + "ECCPUConnector",
91 +)