1919
2020import os
2121import threading
22+ from concurrent .futures import ThreadPoolExecutor , wait
23+ from urllib .parse import urlparse
2224
2325import numpy as np
2426
3032
3133NPROBE_PARAMETER = "ivf.nprobe"
3234L_SEARCH_PARAMETER = "diskann.l_search"
35+ READ_PARALLELISM_PARAMETER = "vindex.read.parallelism"
3336
3437
3538class 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
55100class 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
201277def _filter_bytes (include_row_ids ):
0 commit comments