Skip to content

Commit 82950d6

Browse files
TheR1sing3unXiaoHongbo-Hope
authored andcommitted
[python] Read native vector index ranges with bounded concurrency (#9756)
1 parent 7edc707 commit 82950d6

3 files changed

Lines changed: 342 additions & 17 deletions

File tree

‎paimon-python/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,19 @@ unsupported platform such as Windows), `pypaimon` automatically falls
286286
back to the `pyarrow` (`libhdfs`/JVM) path and logs a warning. Disable
287287
the fallback with `hdfs.client.fallback-to-pyarrow=false` if you want
288288
hard failures instead.
289+
290+
291+
# Vector index range reads
292+
293+
Native vector indexes (`ivf-flat`, `ivf-pq`, `ivf-sq`, `ivf-rq`, and `diskann`)
294+
read multiple file ranges concurrently when the input stream supports
295+
thread-safe positional reads. Set the table option `vindex.read.parallelism`
296+
to a positive integer to control the maximum number of concurrent reads per
297+
index reader, including reads from concurrent native query callbacks.
298+
299+
The default is **4** for remote index paths and **1** for local paths (including
300+
`file://`). Setting it to **1** disables range-level concurrency. Streams that
301+
only support `seek` and `read` remain serialized. Workers are created lazily
302+
and released when the index reader closes; separate readers have separate
303+
budgets. This option controls index I/O, not shard search or native compute
304+
threads.

‎paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py‎

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
import os
2121
import threading
22+
from concurrent.futures import ThreadPoolExecutor, wait
23+
from urllib.parse import urlparse
2224

2325
import numpy as np
2426

@@ -30,26 +32,69 @@
3032

3133
NPROBE_PARAMETER = "ivf.nprobe"
3234
L_SEARCH_PARAMETER = "diskann.l_search"
35+
READ_PARALLELISM_PARAMETER = "vindex.read.parallelism"
3336

3437

3538
class PaimonVindexInput:
3639
"""Input adapter required by paimon_vindex.VectorIndexReader."""
3740

38-
def __init__(self, stream):
41+
def __init__(self, stream, parallelism=1):
42+
if isinstance(parallelism, bool) or not isinstance(parallelism, int) or parallelism < 1:
43+
raise ValueError("Vector index read parallelism must be a positive integer")
3944
self._stream = stream
45+
self._parallelism = parallelism
46+
self._read_slots = threading.BoundedSemaphore(parallelism)
4047
self._supports_pread = supports_pread(stream)
4148
self._lock = threading.Lock()
49+
self._executor = None
50+
self._closed = False
4251

4352
def pread_many(self, ranges):
44-
if self._supports_pread:
45-
return [pread(self._stream, length, offset) for offset, length in ranges]
46-
47-
chunks = []
53+
ranges = list(ranges)
4854
with self._lock:
55+
if self._closed:
56+
raise ValueError("Vector index input is closed")
57+
if not self._supports_pread:
58+
chunks = []
59+
for offset, length in ranges:
60+
self._stream.seek(offset)
61+
chunks.append(self._stream.read(length))
62+
return chunks
63+
if self._parallelism > 1 and len(ranges) > 1:
64+
if self._executor is None:
65+
self._executor = ThreadPoolExecutor(
66+
max_workers=self._parallelism,
67+
thread_name_prefix="paimon-vindex-io")
68+
executor = self._executor
69+
70+
if self._parallelism == 1:
71+
with self._read_slots:
72+
return [pread(self._stream, length, offset) for offset, length in ranges]
73+
if executor is None or len(ranges) <= 1:
74+
return [self._pread(offset, length) for offset, length in ranges]
75+
76+
futures = []
77+
try:
4978
for offset, length in ranges:
50-
self._stream.seek(offset)
51-
chunks.append(self._stream.read(length))
52-
return chunks
79+
futures.append(executor.submit(self._pread, offset, length))
80+
return [future.result() for future in futures]
81+
finally:
82+
# A failed range must not leave reads using a stream the caller may close.
83+
wait(futures)
84+
85+
def _pread(self, offset, length):
86+
# Native query workers may also issue single-range callbacks concurrently.
87+
with self._read_slots:
88+
return pread(self._stream, length, offset)
89+
90+
def close(self):
91+
"""Release workers; the owner remains responsible for closing the stream."""
92+
with self._lock:
93+
self._closed = True
94+
executor = self._executor
95+
self._executor = None
96+
if executor is not None:
97+
executor.shutdown(wait=True)
5398

5499

55100
class VindexVectorGlobalIndexReader(GlobalIndexReader):
@@ -165,10 +210,12 @@ def _ensure_loaded(self):
165210
file_path = (self._io_meta.external_path
166211
if self._io_meta.external_path
167212
else os.path.join(self._index_path, self._io_meta.file_name))
213+
parallelism = _read_parallelism(self._options, file_path)
168214
stream = self._file_io.new_input_stream(file_path)
169215
reader = None
216+
index_input = None
170217
try:
171-
index_input = PaimonVindexInput(stream)
218+
index_input = PaimonVindexInput(stream, parallelism)
172219
reader = VectorIndexReader(index_input)
173220
self._metadata = reader.metadata()
174221
reader.optimize_for_search()
@@ -177,9 +224,15 @@ def _ensure_loaded(self):
177224
self._search_params_type = SearchParams
178225
self._stream = stream
179226
except Exception:
180-
if reader is not None:
181-
reader.close()
182-
stream.close()
227+
try:
228+
if reader is not None:
229+
reader.close()
230+
finally:
231+
try:
232+
if index_input is not None:
233+
index_input.close()
234+
finally:
235+
stream.close()
183236
raise
184237

185238
def __enter__(self):
@@ -190,12 +243,35 @@ def __exit__(self, exc_type, exc_val, exc_tb):
190243
return False
191244

192245
def close(self):
193-
if self._reader is not None:
194-
self._reader.close()
246+
try:
247+
if self._reader is not None:
248+
self._reader.close()
249+
finally:
195250
self._reader = None
196-
if self._stream is not None:
197-
self._stream.close()
198-
self._stream = None
251+
try:
252+
if self._index_input is not None:
253+
self._index_input.close()
254+
finally:
255+
self._index_input = None
256+
if self._stream is not None:
257+
self._stream.close()
258+
self._stream = None
259+
260+
261+
def _read_parallelism(options, file_path):
262+
value = options.get(READ_PARALLELISM_PARAMETER)
263+
if value is None:
264+
# Avoid thread scheduling overhead for local files, including Windows paths.
265+
scheme = urlparse(file_path).scheme
266+
return 1 if scheme in ("", "file") or len(scheme) == 1 else 4
267+
try:
268+
parallelism = int(str(value))
269+
except (ValueError, TypeError):
270+
parallelism = 0
271+
if parallelism < 1:
272+
raise ValueError("'%s' must be a positive integer, got: %s"
273+
% (READ_PARALLELISM_PARAMETER, value))
274+
return parallelism
199275

200276

201277
def _filter_bytes(include_row_ids):

0 commit comments

Comments
 (0)