[KV-offload][FS] : Batch store/load_block in C (#49152)

Signed-off-by: <> Co-authored-by: Varun Sundar Rabindranath <varun-sundar-rabindranath@h100-01.nemg-001.lab.rdu2.dc.redhat.com>

Varun Sundar Rabindranath committed Jul 27, 2026 at 23:21 UTC d18ed2304a2703e3211fc384a58607e754f5b723
4 files changed +452 -58
csrc/fs_io.cpp
+283 -1
@@ -3,19 +3,155 @@
3
4 #include <Python.h>
5
6 +#include <errno.h>
7 +#include <fcntl.h>
8 #include <unistd.h>
9
10 +#include <filesystem>
11 +#include <string>
12 #include <vector>
13
14 +#if defined(O_DIRECT)
15 +constexpr int kODirectFlag = O_DIRECT;
16 +#else
17 +constexpr int kODirectFlag = 0;
18 +#endif
19 +
20 extern "C" {
21
12 -static void _batch_lookup(const std::vector<const char*>& paths,
22 +namespace {
23 +
24 +// Returns 0 on success, or the std::error_code's POSIX-compatible value on
25 +// failure, mirroring the errno convention used by the syscalls below.
26 +inline int ensure_parent_dirs(const std::string& path) {
27 + const auto parent = std::filesystem::path(path).parent_path();
28 + if (parent.empty()) {
29 + return 0;
30 + }
31 + std::error_code ec;
32 + std::filesystem::create_directories(parent, ec);
33 + return ec ? ec.value() : 0;
34 +}
35 +
36 +// Core single-block store: src/size are raw pointer + byte count. Returns 0
37 +// on success, or the errno of the failing step on failure -- captured
38 +// before any subsequent cleanup call can overwrite it. On failure, the temp
39 +// file is removed.
40 +inline int _store_block(const char* tmp_path, const char* dest_path,
41 + const char* src, size_t size, bool use_o_direct) {
42 + if (access(dest_path, F_OK) == 0) {
43 + return 0; // Already present.
44 + }
45 +
46 + if (const int err = ensure_parent_dirs(dest_path); err != 0) {
47 + return err;
48 + }
49 +
50 + const int o_direct_flag = use_o_direct ? kODirectFlag : 0;
51 + const int fd = open(
52 + tmp_path, O_CREAT | O_EXCL | O_WRONLY | O_TRUNC | o_direct_flag, 0644);
53 + if (fd < 0) {
54 + return errno;
55 + }
56 +
57 + const ssize_t written = write(fd, src, size);
58 + if (written < 0 || static_cast<size_t>(written) != size) {
59 + const int err = written < 0 ? errno : EIO;
60 + close(fd); // Best-effort cleanup; the real error is already captured.
61 + unlink(tmp_path);
62 + return err;
63 + }
64 +
65 + if (close(fd) != 0) {
66 + const int err = errno;
67 + unlink(tmp_path);
68 + return err;
69 + }
70 +
71 + if (rename(tmp_path, dest_path) != 0) {
72 + const int err = errno;
73 + unlink(tmp_path);
74 + return err;
75 + }
76 +
77 + return 0;
78 +}
79 +
80 +// Core single-block load: dst/size are raw pointer + byte count. Returns 0
81 +// on success, or the errno of the failing step on failure. On failure,
82 +// the source file is removed since a partially-read block should not be reused.
83 +inline int _load_block(const char* source_path, char* dst, size_t size,
84 + bool use_o_direct) {
85 + const int o_direct_flag = use_o_direct ? kODirectFlag : 0;
86 + const int fd = open(source_path, O_RDONLY | o_direct_flag, 0);
87 + if (fd < 0) {
88 + const int err = errno;
89 + unlink(source_path);
90 + return err;
91 + }
92 +
93 + const ssize_t bytes_read = read(fd, dst, size);
94 + if (bytes_read < 0 || static_cast<size_t>(bytes_read) != size) {
95 + const int err = bytes_read < 0 ? errno : EIO;
96 + close(fd);
97 + unlink(source_path);
98 + return err;
99 + }
100 +
101 + if (close(fd) != 0) {
102 + const int err = errno;
103 + unlink(source_path);
104 + return err;
105 + }
106 +
107 + return 0;
108 +}
109 +
110 +inline void _batch_lookup(const std::vector<const char*>& paths,
111 std::vector<int>& exists_flags) {
112 for (size_t i = 0; i < paths.size(); i++) {
113 exists_flags[i] = (access(paths[i], F_OK) == 0) ? 1 : 0;
114 }
115 }
116
117 +// Helper: extract a list[str] of length n into a vector<const char*>.
118 +// Returns false and sets a Python exception on error.
119 +inline bool extract_str_list(PyObject* list, Py_ssize_t n,
120 + std::vector<const char*>& out) {
121 + for (Py_ssize_t i = 0; i < n; i++) {
122 + out[i] = PyUnicode_AsUTF8AndSize(PyList_GetItem(list, i), nullptr);
123 + if (out[i] == nullptr) {
124 + return false;
125 + }
126 + }
127 + return true;
128 +}
129 +
130 +// Helper: extract a Py_buffer per element of a list[bytes-like] of length n.
131 +// On success, `out` holds n acquired buffers (caller must PyBuffer_Release
132 +// each). On failure, any buffers already acquired are released before
133 +// returning false, and a Python exception is set.
134 +inline bool extract_buffer_list(PyObject* list, Py_ssize_t n, int flags,
135 + std::vector<Py_buffer>& out) {
136 + for (Py_ssize_t i = 0; i < n; i++) {
137 + if (PyObject_GetBuffer(PyList_GetItem(list, i), &out[i], flags) != 0) {
138 + for (Py_ssize_t j = 0; j < i; j++) {
139 + PyBuffer_Release(&out[j]);
140 + }
141 + return false;
142 + }
143 + }
144 + return true;
145 +}
146 +
147 +inline void release_buffer_list(std::vector<Py_buffer>& buffers) {
148 + for (auto& buf : buffers) {
149 + PyBuffer_Release(&buf);
150 + }
151 +}
152 +
153 +} // namespace
154 +
155 /// @brief Check file existence for a batch of paths.
156 /// @param paths list[str] – absolute paths to check.
157 /// @return list[bool] – True if the corresponding path exists, False otherwise.
@@ -51,11 +187,157 @@ static PyObject* batch_lookup(PyObject* /*self*/, PyObject* args) {
187 return result;
188 }
189
190 +/// @brief Store a batch of blocks, each from its own buffer, to disk.
191 +/// @param tmp_paths list[str] – one temp path per block.
192 +/// @param dest_paths list[str] – one destination path per block.
193 +/// @param buffers list[bytes-like] – one source buffer per block.
194 +/// @param use_o_direct bool – whether to open files with O_DIRECT
195 +/// (default True). Ignored where O_DIRECT is unsupported
196 +/// by the platform.
197 +/// @note Releases the GIL for the entire batch. Raises on first error.
198 +static PyObject* batch_store_block(PyObject* /*self*/, PyObject* args) {
199 + PyObject* tmp_paths_obj = nullptr;
200 + PyObject* dest_paths_obj = nullptr;
201 + PyObject* buffers_obj = nullptr;
202 + int use_o_direct = 1;
203 +
204 + if (!PyArg_ParseTuple(args, "O!O!O!|p", &PyList_Type, &tmp_paths_obj,
205 + &PyList_Type, &dest_paths_obj, &PyList_Type,
206 + &buffers_obj, &use_o_direct)) {
207 + return nullptr;
208 + }
209 +
210 + const Py_ssize_t n = PyList_Size(tmp_paths_obj);
211 + if (PyList_Size(dest_paths_obj) != n || PyList_Size(buffers_obj) != n) {
212 + PyErr_SetString(
213 + PyExc_ValueError,
214 + "tmp_paths, dest_paths and buffers must have the same length");
215 + return nullptr;
216 + }
217 +
218 + std::vector<const char*> tmp_paths(n);
219 + std::vector<const char*> dest_paths(n);
220 +
221 + if (!extract_str_list(tmp_paths_obj, n, tmp_paths)) return nullptr;
222 + if (!extract_str_list(dest_paths_obj, n, dest_paths)) return nullptr;
223 +
224 + std::vector<Py_buffer> buffers(n);
225 + if (!extract_buffer_list(buffers_obj, n, PyBUF_SIMPLE, buffers)) {
226 + return nullptr;
227 + }
228 +
229 + Py_ssize_t failed_index = -1;
230 + int failure_errno = 0;
231 +
232 + {
233 + Py_BEGIN_ALLOW_THREADS for (Py_ssize_t i = 0; i < n; i++) {
234 + const char* buf = static_cast<const char*>(buffers[i].buf);
235 + const int err =
236 + _store_block(tmp_paths[i], dest_paths[i], buf,
237 + static_cast<size_t>(buffers[i].len), use_o_direct);
238 + if (err != 0) {
239 + failed_index = i;
240 + failure_errno = err;
241 + break;
242 + }
243 + }
244 + Py_END_ALLOW_THREADS
245 + }
246 +
247 + release_buffer_list(buffers);
248 +
249 + if (failed_index >= 0) {
250 + // PyErr_SetFromErrnoWithFilename() reads the errno to format exception.
251 + errno = failure_errno;
252 + return PyErr_SetFromErrnoWithFilename(PyExc_OSError,
253 + dest_paths[failed_index]);
254 + }
255 +
256 + Py_RETURN_NONE;
257 +}
258 +
259 +/// @brief Load a batch of blocks from disk, each into its own buffer.
260 +/// @param source_paths list[str] – one source path per block.
261 +/// @param buffers list[writable bytes-like] – one destination buffer
262 +/// per block.
263 +/// @param use_o_direct bool – whether to open files with O_DIRECT
264 +/// (default True). Ignored where O_DIRECT is unsupported
265 +/// by the platform.
266 +/// @note Releases the GIL for the entire batch. Raises on first error.
267 +static PyObject* batch_load_block(PyObject* /*self*/, PyObject* args) {
268 + PyObject* source_paths_obj = nullptr;
269 + PyObject* buffers_obj = nullptr;
270 + int use_o_direct = 1;
271 +
272 + if (!PyArg_ParseTuple(args, "O!O!|p", &PyList_Type, &source_paths_obj,
273 + &PyList_Type, &buffers_obj, &use_o_direct)) {
274 + return nullptr;
275 + }
276 +
277 + const Py_ssize_t n = PyList_Size(source_paths_obj);
278 + if (PyList_Size(buffers_obj) != n) {
279 + PyErr_SetString(PyExc_ValueError,
280 + "source_paths and buffers must have the same length");
281 + return nullptr;
282 + }
283 +
284 + std::vector<const char*> source_paths(n);
285 + if (!extract_str_list(source_paths_obj, n, source_paths)) return nullptr;
286 +
287 + std::vector<Py_buffer> buffers(n);
288 + if (!extract_buffer_list(buffers_obj, n, PyBUF_WRITABLE, buffers)) {
289 + return nullptr;
290 + }
291 +
292 + Py_ssize_t failed_index = -1;
293 + int failure_errno = 0;
294 +
295 + {
296 + Py_BEGIN_ALLOW_THREADS for (Py_ssize_t i = 0; i < n; i++) {
297 + char* buf = static_cast<char*>(buffers[i].buf);
298 + const int err =
299 + _load_block(source_paths[i], buf, static_cast<size_t>(buffers[i].len),
300 + use_o_direct);
301 + if (err != 0) {
302 + failed_index = i;
303 + failure_errno = err;
304 + break;
305 + }
306 + }
307 + Py_END_ALLOW_THREADS
308 + }
309 +
310 + release_buffer_list(buffers);
311 +
312 + if (failed_index >= 0) {
313 + // PyErr_SetFromErrnoWithFilename() reads the errno to format exception.
314 + errno = failure_errno;
315 + return PyErr_SetFromErrnoWithFilename(PyExc_OSError,
316 + source_paths[failed_index]);
317 + }
318 +
319 + Py_RETURN_NONE;
320 +}
321 +
322 static PyMethodDef fs_io_C_methods[] = {
323 {"batch_lookup", batch_lookup, METH_VARARGS,
324 "batch_lookup(paths: list[str]) -> list[bool]\n"
325 "\n"
326 "Check file existence for a batch of paths."},
327 + {"batch_store_block", batch_store_block, METH_VARARGS,
328 + "batch_store_block(tmp_paths: list[str], dest_paths: list[str],\n"
329 + " buffers: list[bytes-like],\n"
330 + " use_o_direct: bool = True) -> None\n"
331 + "\n"
332 + "Store a batch of blocks, each from its own buffer, to disk. Raises on "
333 + "first error."},
334 + {"batch_load_block", batch_load_block, METH_VARARGS,
335 + "batch_load_block(source_paths: list[str],\n"
336 + " buffers: list[writable bytes-like],\n"
337 + " use_o_direct: bool = True) -> None\n"
338 + "\n"
339 + "Load a batch of blocks from disk into corresponding buffers. "
340 + "Raises on first error."},
341 {nullptr, nullptr, 0, nullptr},
342 };
343
tests/v1/kv_offload/tiering/test_fs_tier.py
+67 -32
@@ -46,6 +46,7 @@ from vllm.v1.kv_offload.tiering.fs.thread_pool import DualQueueThreadPool
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49 +_NUM_BLOCKS = 8
50 _BLOCK_ELEMENTS = 128 * mmap.PAGESIZE # 2MB per block for pagesize 4096.
51 _DTYPE: torch.dtype = torch.float32
52 _CTX = ReqContext(req_id="test")
@@ -162,7 +163,7 @@ def _page_aligned_rand_tensor(
163
164 @pytest.fixture
165 def fs_tier(tmp_path):
165 - tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
166 + tensor = _page_aligned_zero_tensor(_NUM_BLOCKS, _BLOCK_ELEMENTS)
167 mock_view = memoryview(tensor.numpy())
168 tier = FileSystemTierManager(
169 offloading_spec=_MOCK_OFFLOADING_SPEC,
@@ -178,7 +179,7 @@ def fs_tier(tmp_path):
179
180 @pytest.fixture
181 def fs_tier_with_events(tmp_path):
181 - tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
182 + tensor = _page_aligned_zero_tensor(_NUM_BLOCKS, _BLOCK_ELEMENTS)
183 mock_view = memoryview(tensor.numpy())
184 tier = FileSystemTierManager(
185 offloading_spec=_make_offloading_spec(enable_kv_cache_events=True),
@@ -347,33 +348,43 @@ def test_shutdown_discards_pending_tasks(fs_tier):
348 assert all(not t.is_alive() for t in tier._pool._threads)
349
350
350 -def test_store_load_data_integrity(fs_tier):
351 - """Data written by store must be exactly recovered by load."""
351 +@pytest.mark.parametrize("batch_size", [0, 1, 2, 5])
352 +@pytest.mark.parametrize("use_c_ext", [True, False])
353 +def test_store_load_data_integrity(fs_tier, monkeypatch, use_c_ext, batch_size):
354 + """Data written by store must be exactly recovered by load, for batches
355 + of any size -- including the empty batch."""
356 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
357 +
358 + if use_c_ext and not io_mod._HAS_FSIO_C:
359 + pytest.skip("fs_io_C extension not built")
360 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
361 +
362 tier, tensor = fs_tier
363 # Populate tensor with random data
354 - tensor[:] = _page_aligned_rand_tensor(4, _BLOCK_ELEMENTS)
364 + tensor[:] = _page_aligned_rand_tensor(_NUM_BLOCKS, _BLOCK_ELEMENTS)
365
356 - # Store first 2 blocks
357 - num_store = 2
358 - expected = tensor[:num_store].clone()
366 + keys = [key(i) for i in range(batch_size)]
367 + store_block_ids = list(range(batch_size))
368 + load_block_ids = list(range(_NUM_BLOCKS - batch_size, _NUM_BLOCKS))
369 + expected = tensor[:batch_size].clone()
370
360 - store_ids = list(range(num_store))
361 - keys = [key(i) for i in range(num_store)]
362 -
363 - tier.submit_store(make_job(1, keys, store_ids))
364 - results = drain(tier)
365 - assert all(r.success for r in results)
371 + tier.submit_store(make_job(1, keys, store_block_ids))
372 + store_results = drain(tier)
373 + assert len(store_results) == 1
374 + assert store_results[0].success
375 + assert all(os.path.exists(tier.file_mapper.get_file_name(k)) for k in keys)
376
367 - # Overwrite source blocks to prove data is read from disk
368 - tensor[:num_store] = 0.0
377 + # reset tensor to prove data is read from disk
378 + tensor[:] = 0.0
379
370 - # Load into last 2 blocks
371 - load_ids = [2, 3]
372 - tier.submit_load(make_job(2, keys, load_ids, is_promotion=True))
373 - results = drain(tier)
374 - assert all(r.success for r in results)
380 + # Load into a range disjoint by index from the store ids, to also
381 + # exercise loading a block into a different id than it was stored from.
382 + tier.submit_load(make_job(2, keys, load_block_ids, is_promotion=True))
383 + load_results = drain(tier)
384 + assert len(load_results) == 1
385 + assert load_results[0].success
386
376 - for i, bid in enumerate(load_ids):
387 + for i, bid in enumerate(load_block_ids):
388 assert torch.allclose(tensor[bid], expected[i]), (
389 f"Block {bid} data mismatch after store+load"
390 )
@@ -499,6 +510,30 @@ def test_batch_lookup_dispatch(fs_tier, monkeypatch, use_c_ext):
510 assert results == [LookupResult.HIT, LookupResult.MISS]
511
512
513 +@pytest.mark.parametrize("use_c_ext", [True, False])
514 +def test_out_of_bounds_block_id_smoke(fs_tier, monkeypatch, use_c_ext):
515 + """Smoke test: a block id beyond the primary tensor's block count must
516 + fail the job, for both the C extension and the Python fallback."""
517 + import vllm.v1.kv_offload.tiering.fs.io as io_mod
518 +
519 + if use_c_ext and not io_mod._HAS_FSIO_C:
520 + pytest.skip("fs_io_C extension not built")
521 + monkeypatch.setattr(io_mod, "_HAS_FSIO_C", use_c_ext)
522 +
523 + tier, tensor = fs_tier
524 + out_of_bounds_bid = tensor.shape[0] # one past the last valid block
525 +
526 + tier.submit_store(make_job(1, [key(1)], [out_of_bounds_bid]))
527 + store_results = drain(tier)
528 + assert len(store_results) == 1
529 + assert not store_results[0].success
530 +
531 + tier.submit_load(make_job(2, [key(1)], [out_of_bounds_bid], is_promotion=True))
532 + load_results = drain(tier)
533 + assert len(load_results) == 1
534 + assert not load_results[0].success
535 +
536 +
537 # ---------------------------------------------------------------------------
538 # KV events
539 # ---------------------------------------------------------------------------
@@ -571,14 +606,14 @@ def test_mixed_job_results_emit_event_only_for_successful_job(
606
607 tier = fs_tier_with_events
608 failing_path = tier.file_mapper.get_file_name(key(1))
574 - original_store_block = mgr_mod.store_block
609 + original_batch_store_block = mgr_mod.batch_store_block
610
576 - def flaky_store_block(dest_path, *args, **kwargs):
577 - if dest_path == failing_path:
611 + def flaky_batch_store_block(paths, *args, **kwargs):
612 + if failing_path in paths:
613 raise OSError("injected store failure")
579 - return original_store_block(dest_path, *args, **kwargs)
614 + return original_batch_store_block(paths, *args, **kwargs)
615
581 - monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
616 + monkeypatch.setattr(mgr_mod, "batch_store_block", flaky_batch_store_block)
617
618 tier.submit_store(make_job(1, [key(1)], [0]))
619 tier.submit_store(make_job(2, [key(2)], [1]))
@@ -599,14 +634,14 @@ def test_partially_failed_store_emits_no_event(fs_tier_with_events, monkeypatch)
634
635 tier = fs_tier_with_events
636 failing_path = tier.file_mapper.get_file_name(key(2))
602 - original_store_block = mgr_mod.store_block
637 + original_batch_store_block = mgr_mod.batch_store_block
638
604 - def flaky_store_block(dest_path, *args, **kwargs):
605 - if dest_path == failing_path:
639 + def flaky_batch_store_block(paths, *args, **kwargs):
640 + if failing_path in paths:
641 raise OSError("injected store failure")
607 - return original_store_block(dest_path, *args, **kwargs)
642 + return original_batch_store_block(paths, *args, **kwargs)
643
609 - monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
644 + monkeypatch.setattr(mgr_mod, "batch_store_block", flaky_batch_store_block)
645
646 tier.submit_store(make_job(1, [key(1), key(2)], [0, 1]))
647 results = drain(tier)
vllm/v1/kv_offload/tiering/fs/io.py
+80 -2
@@ -8,6 +8,18 @@ import os
8 import random
9 import threading
10
11 +try:
12 + from vllm.fs_io_C import ( # pyright: ignore[reportMissingImports]
13 + batch_load_block as batch_load_block_C,
14 + )
15 + from vllm.fs_io_C import (
16 + batch_store_block as batch_store_block_C,
17 + )
18 +
19 + _HAS_FSIO_C = True
20 +except ImportError:
21 + _HAS_FSIO_C = False
22 +
23 logger = logging.getLogger(__name__)
24
25 # O_DIRECT is Linux-specific and not available on macOS
@@ -59,7 +71,23 @@ def _ensure_dirs(path: str) -> None:
71 os.makedirs(os.path.dirname(path), exist_ok=True)
72
73
62 -def store_block(
74 +def _validate_offsets(view: memoryview, offsets: list[int], block_size: int) -> None:
75 + """Raise if any block would read/write past the bounds of `view`.
76 +
77 + Without this, an out-of-range offset silently clips to a shorter (or
78 + empty) slice instead of failing, since memoryview slicing follows
79 + Python's slice-clamping semantics rather than raising.
80 + """
81 + total_len = len(view.cast("B"))
82 + for offset in offsets:
83 + if offset < 0 or offset + block_size > total_len:
84 + raise ValueError(
85 + f"block offset {offset} (block_size {block_size}) is out of "
86 + f"bounds for a buffer of size {total_len}"
87 + )
88 +
89 +
90 +def _store_block(
91 dest_path: str,
92 buffer: memoryview,
93 offset: int,
@@ -104,7 +132,7 @@ def store_block(
132 raise
133
134
107 -def load_block(
135 +def _load_block(
136 source_path: str,
137 view: memoryview,
138 offset: int,
@@ -117,6 +145,7 @@ def load_block(
145 fd: int | None = None
146 view_slice = view.cast("B")[offset : offset + block_size]
147 o_direct = O_DIRECT if use_o_direct else 0
148 +
149 try:
150 fd = os.open(source_path, os.O_RDONLY | o_direct)
151 bytes_read = os.readv(fd, [view_slice])
@@ -133,3 +162,52 @@ def load_block(
162 finally:
163 if fd is not None:
164 os.close(fd)
165 +
166 +
167 +def batch_store_block(
168 + paths: list[str],
169 + view: memoryview,
170 + offsets: list[int],
171 + block_size: int,
172 + use_o_direct: bool = True,
173 +) -> None:
174 + """
175 + Store a batch of KV blocks from a shared buffer to disk in one call.
176 +
177 + Each block buffer[offsets[i] : offsets[i]+block_size] is written atomically
178 + to dest_paths[i] via a temp-file rename. Raises on first error.
179 + """
180 + _validate_offsets(view, offsets, block_size)
181 +
182 + if _HAS_FSIO_C:
183 + view_B = view.cast("B")
184 + view_slices = [view_B[x : x + block_size] for x in offsets]
185 + tmp_paths = [p + _get_tmp_suffix() for p in paths]
186 + return batch_store_block_C(tmp_paths, paths, view_slices, use_o_direct)
187 + else:
188 + for path, offset in zip(paths, offsets):
189 + _store_block(path, view, offset, block_size, use_o_direct)
190 +
191 +
192 +def batch_load_block(
193 + paths: list[str],
194 + view: memoryview,
195 + offsets: list[int],
196 + block_size: int,
197 + use_o_direct: bool = True,
198 +) -> None:
199 + """
200 + Load a batch of KV blocks from disk into a shared buffer in one call.
201 +
202 + Block i is read from source_paths[i] into view[offsets[i] : offsets[i]+block_size].
203 + Raises on first error and removes the offending file.
204 + """
205 + _validate_offsets(view, offsets, block_size)
206 +
207 + if _HAS_FSIO_C:
208 + view_B = view.cast("B")
209 + view_slices = [view_B[x : x + block_size] for x in offsets]
210 + return batch_load_block_C(paths, view_slices, use_o_direct)
211 + else:
212 + for path, offset in zip(paths, offsets):
213 + _load_block(path, view, offset, block_size, use_o_direct)
vllm/v1/kv_offload/tiering/fs/manager.py
+22 -23
@@ -49,7 +49,11 @@ from vllm.v1.kv_offload.tiering.base import (
49 ScheduleEndContext,
50 SecondaryTierManager,
51 )
52 -from vllm.v1.kv_offload.tiering.fs.io import load_block, probe_o_direct, store_block
52 +from vllm.v1.kv_offload.tiering.fs.io import (
53 + batch_load_block,
54 + batch_store_block,
55 + probe_o_direct,
56 +)
57 from vllm.v1.kv_offload.tiering.fs.thread_pool import DualQueueThreadPool
58
59 if TYPE_CHECKING:
@@ -203,33 +207,28 @@ class FileSystemTierManager(SecondaryTierManager):
207 def submit_store(self, job_metadata: JobMetadata) -> None:
208 if self.events is not None:
209 self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
206 - tasks = (
207 - functools.partial(
208 - store_block,
209 - self.file_mapper.get_file_name(key),
210 - self._primary_kv_view,
211 - int(bid) * self._block_size,
212 - self._block_size,
213 - self._use_o_direct,
214 - )
215 - for key, bid in zip(job_metadata.keys, job_metadata.block_ids)
210 + task = functools.partial(
211 + batch_store_block,
212 + [self.file_mapper.get_file_name(key) for key in job_metadata.keys],
213 + self._primary_kv_view,
214 + [int(bid) * self._block_size for bid in job_metadata.block_ids],
215 + self._block_size,
216 + self._use_o_direct,
217 )
217 - self._pool.enqueue_store(job_metadata.job_id, len(job_metadata.keys), tasks)
218 + self._pool.enqueue_store(job_metadata.job_id, 1, [task])
219
220 @override
221 def submit_load(self, job_metadata: JobMetadata) -> None:
221 - tasks = (
222 - functools.partial(
223 - load_block,
224 - self.file_mapper.get_file_name(key),
225 - self._primary_kv_view,
226 - int(bid) * self._block_size,
227 - self._block_size,
228 - self._use_o_direct,
229 - )
230 - for key, bid in zip(job_metadata.keys, job_metadata.block_ids)
222 + task = functools.partial(
223 + batch_load_block,
224 + [self.file_mapper.get_file_name(key) for key in job_metadata.keys],
225 + self._primary_kv_view,
226 + [int(bid) * self._block_size for bid in job_metadata.block_ids],
227 + self._block_size,
228 + self._use_o_direct,
229 )
232 - self._pool.enqueue_load(job_metadata.job_id, len(job_metadata.keys), tasks)
230 +
231 + self._pool.enqueue_load(job_metadata.job_id, 1, [task])
232
233 @override
234 def get_finished_jobs(self) -> Iterable[JobResult]: