[XPU] Support EC connector KV Offloading on XPU (#49532)

Signed-off-by: Chaojun Zhang <chaojun.zhang@intel.com>

Chaojun Zhang committed Aug 19, 2026 at 10:25 UTC f485081e8b76135091398818ea63040ffe7e1c5b
5 files changed +93 -58
.buildkite/intel_jobs/misc_intel.yaml
+4 -2
@@ -8,7 +8,7 @@ steps:
8 agent_tags:
9 label: production
10 gpu: 1+
11 - mem: 16+
11 + mem: 24+
12 no_plugin: true
13 working_dir: "."
14 env:
@@ -22,6 +22,7 @@ steps:
22 - tests/v1/kv_offload
23 - tests/v1/worker
24 - tests/v1/kv_connector/unit
25 + - tests/v1/ec_connector/unit
26 - tests/v1/metrics
27 - tests/entrypoints/openai/correctness/test_lmeval.py
28 commands:
@@ -30,7 +31,8 @@ steps:
31 'pip install -r requirements/kv_connectors.txt &&
32 export VLLM_WORKER_MULTIPROC_METHOD=spawn &&
33 cd tests &&
33 - pytest -v -s v1/executor'
34 + pytest -v -s v1/executor &&
35 + VLLM_BATCH_INVARIANT=1 pytest -v -s -m 'not cpu_test' v1/ec_connector/unit'
36
37 - label: V1 Sample + Logits
38 timeout_in_minutes: 90
tests/v1/ec_connector/unit/cpu/worker/test_worker.py
+42 -37
@@ -2,10 +2,10 @@
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.
5 +The byte-level tests exercise real accelerator (CUDA/XPU/...) stream/event
6 +coordination against a real ``ECSharedRegion`` mmap and are skipped on hosts
7 +without an accelerator. The lifecycle tests don't fire any
8 +``current_platform.*`` device primitives and run anywhere.
9
10 Mocking policy
11 --------------
@@ -37,6 +37,7 @@ 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 +from vllm.platforms import current_platform
41
42 # ── shape constants ──────────────────────────────────────────────────────────
43
@@ -47,9 +48,11 @@ _DTYPE = torch.float16
48 _BLOCK_SIZE_BYTES = _HIDDEN_DIM * _DTYPE.itemsize
49 _NUM_BLOCKS = 8
50
50 -_requires_cuda = pytest.mark.skipif(
51 - not torch.cuda.is_available(),
52 - reason="exercises real CUDA stream/event coordination in ECCPUWorker",
51 +DEVICE_TYPE = current_platform.device_type
52 +
53 +_requires_accelerator = pytest.mark.skipif(
54 + not (current_platform.is_cuda() or current_platform.is_xpu()),
55 + reason="exercises real accelerator stream/event coordination in ECCPUWorker",
56 )
57
58
@@ -136,7 +139,7 @@ def make_worker():
139 # ── save_caches ──────────────────────────────────────────────────────────────
140
141
139 -@_requires_cuda
142 +@_requires_accelerator
143 @pytest.mark.parametrize(
144 "n_elements,n_blocks",
145 [
@@ -158,7 +161,7 @@ def test_save_caches_writes_to_assigned_blocks(make_worker, n_elements, n_blocks
161 sentinel = 0x5A
162 worker._region.blocks.fill_(sentinel)
163
161 - src = torch.arange(n_elements, dtype=_DTYPE, device="cuda")
164 + src = torch.arange(n_elements, dtype=_DTYPE, device=DEVICE_TYPE)
165 expected_bytes = src.cpu().reshape(-1).view(torch.uint8)
166 total_bytes = n_elements * _DTYPE.itemsize
167
@@ -228,15 +231,15 @@ def test_save_caches_raises_when_allocated_blocks_too_small(make_worker):
231 worker.save_caches({"h": src}, "h", _meta(saves={"h": [0, 1]}))
232
233
231 -@_requires_cuda
234 +@_requires_accelerator
235 def test_save_caches_batches_multiple_hashes(make_worker):
236 """Multiple save_caches calls are batched into a single flush."""
237 worker = make_worker()
238 sentinel = 0x5A
239 worker._region.blocks.fill_(sentinel)
240
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")
241 + src_a = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
242 + src_b = torch.arange(_HIDDEN_DIM, 2 * _HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
243
244 cache = {"a": src_a, "b": src_b}
245 worker.save_caches(cache, "a", _meta(saves={"a": [1], "b": [3]}))
@@ -258,7 +261,7 @@ def test_save_caches_batches_multiple_hashes(make_worker):
261 # ── start_load_caches ────────────────────────────────────────────────────────
262
263
261 -@_requires_cuda
264 +@_requires_accelerator
265 def test_start_load_caches_copies_with_correct_shape_dtype_and_bytes(make_worker):
266 """Single batched load across all hashes with correct byte→dtype→shape."""
267 worker = make_worker()
@@ -276,20 +279,22 @@ def test_start_load_caches_copies_with_correct_shape_dtype_and_bytes(make_worker
279 worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
280
281 out = encoder_cache["h"]
279 - assert out.is_cuda, "consumer worker must place the tensor on the GPU"
282 + assert out.device.type == DEVICE_TYPE, (
283 + "consumer worker must place the tensor on the accelerator"
284 + )
285 assert out.shape == (n_blocks, _HIDDEN_DIM)
286 assert out.dtype == _DTYPE
287 assert torch.equal(out.cpu(), src_orig)
288
289
285 -@_requires_cuda
290 +@_requires_accelerator
291 def test_start_load_caches_preserves_existing_encoder_cache_entry(make_worker):
292 """If ``encoder_cache`` already holds the ``mm_hash``, the worker must
293 not overwrite it."""
294 worker = make_worker()
295 worker._region.blocks[0].fill_(0x42)
296
292 - sentinel = torch.full((_HIDDEN_DIM,), 7.0, dtype=_DTYPE, device="cuda")
297 + sentinel = torch.full((_HIDDEN_DIM,), 7.0, dtype=_DTYPE, device=DEVICE_TYPE)
298 encoder_cache = {"h": sentinel}
299 worker.start_load_caches(encoder_cache, _meta(loads={"h": [0]}))
300
@@ -298,7 +303,7 @@ def test_start_load_caches_preserves_existing_encoder_cache_entry(make_worker):
303 )
304
305
301 -@_requires_cuda
306 +@_requires_accelerator
307 def test_start_load_caches_noop_when_loads_is_empty(make_worker):
308 """When ``meta.loads`` is empty the early-return must fire."""
309 worker = make_worker()
@@ -308,7 +313,7 @@ def test_start_load_caches_noop_when_loads_is_empty(make_worker):
313 assert encoder_cache == {}
314
315
311 -@_requires_cuda
316 +@_requires_accelerator
317 def test_start_load_caches_skips_cached_and_loads_new_in_same_step(make_worker):
318 """Cached entries are preserved while new ones are loaded."""
319 worker = make_worker()
@@ -321,7 +326,7 @@ def test_start_load_caches_skips_cached_and_loads_new_in_same_step(make_worker):
326 for i, idx in enumerate(new_block_ids):
327 worker._region.blocks[idx].copy_(src_int8[i])
328
324 - cached_tensor = torch.full((1, _HIDDEN_DIM), 99.0, dtype=_DTYPE, device="cuda")
329 + cached_tensor = torch.full((1, _HIDDEN_DIM), 99.0, dtype=_DTYPE, device=DEVICE_TYPE)
330 encoder_cache: dict[str, torch.Tensor] = {"cached_h": cached_tensor}
331 worker.start_load_caches(
332 encoder_cache,
@@ -336,7 +341,7 @@ def test_start_load_caches_skips_cached_and_loads_new_in_same_step(make_worker):
341 assert torch.equal(out.cpu(), src_orig)
342
343
339 -@_requires_cuda
344 +@_requires_accelerator
345 @pytest.mark.parametrize(
346 "tp_rank,pcp_rank",
347 [(0, 0), (1, 0), (0, 1), (1, 1)],
@@ -358,23 +363,23 @@ def test_start_load_caches_works_on_all_ranks(make_worker, tp_rank, pcp_rank):
363 worker.start_load_caches(encoder_cache, _meta(loads={"h": block_ids}))
364
365 out = encoder_cache["h"]
361 - assert out.is_cuda
366 + assert out.device.type == DEVICE_TYPE
367 assert torch.equal(out.cpu(), src_orig)
368
369
370 # ── round-trip ───────────────────────────────────────────────────────────────
371
372
368 -@_requires_cuda
373 +@_requires_accelerator
374 def test_save_then_load_round_trips_bytes(make_worker):
375 """Full producer→mmap→consumer byte path in one shot."""
376 worker = make_worker()
377 n_blocks = 3
378 block_ids = [5, 1, 6]
379
375 - src = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device="cuda").reshape(
376 - n_blocks, _HIDDEN_DIM
377 - )
380 + src = torch.arange(
381 + n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE
382 + ).reshape(n_blocks, _HIDDEN_DIM)
383 worker.save_caches({"h": src}, "h", _meta(saves={"h": block_ids}))
384 worker.flush_saves()
385
@@ -390,12 +395,12 @@ def test_save_then_load_round_trips_bytes(make_worker):
395 # ── buffer recycling ────────────────────────────────────────────────────────
396
397
393 -@_requires_cuda
398 +@_requires_accelerator
399 def test_buffer_pool_is_reused_across_save_steps(make_worker):
400 """After flush_saves, descriptor buffers are returned to the pool and
401 reused on the next flush — no reallocation."""
402 worker = make_worker()
398 - src = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device="cuda")
403 + src = torch.arange(_HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE)
404
405 worker.save_caches({"h": src}, "h", _meta(saves={"h": [0]}))
406 worker.flush_saves()
@@ -411,7 +416,7 @@ def test_buffer_pool_is_reused_across_save_steps(make_worker):
416 assert id(worker._buf_pool._pool[0].src_ptrs) == buf_id
417
418
414 -@_requires_cuda
419 +@_requires_accelerator
420 def test_buffer_pool_is_reused_across_load_steps(make_worker):
421 """After start_load_caches, descriptor buffers are returned to the pool
422 and reused on the next call."""
@@ -435,11 +440,11 @@ def test_buffer_pool_is_reused_across_load_steps(make_worker):
440 # ── stream management ────────────────────────────────────────────────────────
441
442
438 -@_requires_cuda
443 +@_requires_accelerator
444 def test_stream_initialized_at_construction(make_worker):
440 - """``_load_stream`` must be a fully initialized CUDA stream."""
445 + """``_load_stream`` must be a fully initialized accelerator stream."""
446 worker = make_worker()
442 - assert isinstance(worker._load_stream, torch.cuda.Stream)
447 + assert isinstance(worker._load_stream, current_platform.Stream)
448
449
450 # ── lifecycle ────────────────────────────────────────────────────────────────
@@ -518,14 +523,14 @@ def test_shutdown_calls_region_cleanup_and_swallows_errors(caplog_vllm):
523 # ── e2e: scheduler + worker pipeline ────────────────────────────────────────
524
525
521 -@_requires_cuda
526 +@_requires_accelerator
527 def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
528 """Full pipeline: scheduler allocates blocks, worker saves GPU tensor to
529 mmap via flush_saves, scheduler marks ready after step delay, worker
530 loads from mmap back to GPU, and the result matches the original.
531
532 Exercises the real scheduler + worker cooperation through a shared
528 - ECSharedRegion, with real CUDA transfers and stream coordination.
533 + ECSharedRegion, with real accelerator transfers and stream coordination.
534 """
535 import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
536 from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import (
@@ -551,9 +556,9 @@ def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
556
557 # -- Step 1: scheduler allocates, worker saves --
558 n_blocks = 3
554 - src = torch.arange(n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device="cuda").reshape(
555 - n_blocks, _HIDDEN_DIM
556 - )
559 + src = torch.arange(
560 + n_blocks * _HIDDEN_DIM, dtype=_DTYPE, device=DEVICE_TYPE
561 + ).reshape(n_blocks, _HIDDEN_DIM)
562
563 class _Pos:
564 offset = 0
@@ -590,7 +595,7 @@ def test_e2e_scheduler_worker_save_then_load(make_worker, monkeypatch):
595 worker.start_load_caches(load_cache, meta_load)
596
597 out = load_cache["img_001"]
593 - assert out.is_cuda
598 + assert out.device.type == DEVICE_TYPE
599 assert out.shape == src.shape
600 assert out.dtype == src.dtype
601 assert torch.equal(out.cpu(), src.cpu())
tests/v1/ec_connector/unit/test_ec_cpu_connector.py
+7 -4
@@ -7,7 +7,7 @@ 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.
10 +Requires a CUDA or XPU GPU and the Qwen2-VL-2B-Instruct model.
11 """
12
13 import time
@@ -57,8 +57,8 @@ def _wait_for_ec_ready(llm: LLM) -> None:
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")
60 + if not (current_platform.is_cuda() or current_platform.is_xpu()):
61 + pytest.skip("Latency test requires an accelerator (CUDA or XPU)")
62
63 sampling_params = SamplingParams(max_tokens=1, temperature=0)
64 base_image = ImageAsset("stop_sign").pil_image
@@ -134,7 +134,10 @@ def _accuracy_test(llm: LLM) -> None:
134 )
135
136
137 -@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
137 +@pytest.mark.skipif(
138 + not (current_platform.is_cuda() or current_platform.is_xpu()),
139 + reason="Requires an accelerator (CUDA or XPU)",
140 +)
141 def test_ec_cpu_offloading() -> None:
142 """Tests ECCPUConnector accuracy and latency with a VLM model."""
143 ec_transfer_config = ECTransferConfig(
vllm/distributed/ec_transfer/ec_connector/cpu/worker/__init__.py
+9 -7
@@ -102,16 +102,15 @@ class ECCPUWorker:
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
105 + bufs = 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)
112 + bufs.set_ptrs(idx, src_base + start, dst_base + block_idx * block_size)
113 + bufs.sizes[idx] = min(block_size, total_bytes - start)
114 idx += 1
115
116 self._save_count = idx
@@ -123,7 +122,7 @@ class ECCPUWorker:
122
123 bufs = self._save_bufs
124 assert bufs is not None
126 - src_ptrs, dst_ptrs, sizes = bufs
125 + src_ptrs, dst_ptrs, sizes = bufs.src_ptrs, bufs.dst_ptrs, bufs.sizes
126 n = self._save_count
127 swap_blocks_batch(src_ptrs[:n], dst_ptrs[:n], sizes[:n])
128
@@ -173,8 +172,11 @@ class ECCPUWorker:
172 op_idx = 0
173 for block_ids in load_items.values():
174 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
175 + bufs.set_ptrs(
176 + op_idx,
177 + src_base + block_idx * block_size,
178 + dst_buf_base + op_idx * block_size,
179 + )
180 op_idx += 1
181
182 swap_blocks_batch(src_ptrs, dst_ptrs, sizes, is_src_access_order_any=True)
vllm/distributed/ec_transfer/ec_connector/cpu/worker/descriptor_buffers.py
+31 -8
@@ -1,6 +1,6 @@
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.
3 +"""Reusable pool of (src_ptrs, dst_ptrs, sizes) tensor triples.
4
5 Used by ECCPUWorker to batch swap_blocks_batch descriptors without
6 per-step allocation overhead.
@@ -8,20 +8,46 @@ per-step allocation overhead.
8
9 from typing import NamedTuple
10
11 +import numpy as np
12 import torch
13
14 +from vllm.platforms import current_platform
15 +
16 +# CUDA/ROCm cache_kernels.cu requires int64 pointers; the XPU DMA engine
17 +# requires uint64 (see vllm._custom_ops.swap_blocks_batch).
18 +_PTR_DTYPE = torch.uint64 if current_platform.is_xpu() else torch.int64
19 +
20
21 class DescriptorBuffers(NamedTuple):
22 src_ptrs: torch.Tensor
23 dst_ptrs: torch.Tensor
24 sizes: torch.Tensor
25 + # Numpy aliases of src_ptrs/dst_ptrs, written via set_ptrs().
26 + src_np: np.ndarray
27 + dst_np: np.ndarray
28 +
29 + def set_ptrs(self, idx: int, src: int, dst: int) -> None:
30 + """Record the source and destination address of descriptor *idx*.
31 +
32 + TODO(torch>=2.14): drop this indirection and assign the tensors
33 + directly once the minimum supported torch is 2.14. The numpy detour
34 + exists only because torch's setitem unpacks the value as a signed
35 + long long before 2.14 (pytorch#191458), rejecting XPU USM pointers
36 + >= 2**63, while the two's-complement rewrite those versions accept is
37 + in turn rejected by uint64 from 2.14 on. Numpy casts against the
38 + array dtype and so works on either. `sizes` holds byte counts and
39 + needs no such care.
40 + """
41 + self.src_np[idx] = src
42 + self.dst_np[idx] = dst
43
44
45 class DescriptorBufferPool:
46 """Pool of descriptor buffer triples for swap_blocks_batch.
47
23 - Each buffer is a `DescriptorBuffers` namedtuple of three 1-D int64
24 - tensors of equal length. Buffers are recycled across steps; if a
48 + Each buffer is a `DescriptorBuffers` namedtuple of three 1-D tensors
49 + (dtype `_PTR_DTYPE`, platform-dependent) of equal length, paired with
50 + numpy aliases used to fill them. Buffers are recycled across steps; if a
51 returned buffer is too small it is discarded and a fresh one allocated.
52 """
53
@@ -35,11 +61,8 @@ class DescriptorBufferPool:
61 bufs = self._pool.pop()
62 if bufs.src_ptrs.numel() >= n:
63 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 - )
64 + src, dst, sizes = (torch.empty(n, dtype=_PTR_DTYPE) for _ in range(3))
65 + return DescriptorBuffers(src, dst, sizes, src.numpy(), dst.numpy())
66
67 def release(self, bufs: DescriptorBuffers) -> None:
68 """Return a buffer triple to the pool for reuse."""