Skip to content

Commit 30f051a

Browse files
TheR1sing3unXiaoHongbo-Hope
authored andcommitted
[python] Batch vector writes when building global indexes (#9752)
1 parent 0c89f40 commit 30f051a

3 files changed

Lines changed: 292 additions & 7 deletions

File tree

‎paimon-python/pypaimon/globalindex/create_global_index.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from typing import Dict, List, Optional, Sequence, Union
2222

2323
import pyarrow as pa
24+
import pyarrow.compute as pc
2425

2526
from pypaimon.common.options.core_options import CoreOptions
2627
from pypaimon.common.options.options import Options
@@ -51,6 +52,7 @@
5152
VINDEX_IDENTIFIERS,
5253
)
5354
from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
55+
ADD_BATCH_SIZE,
5456
VindexVectorIndexWriter,
5557
)
5658
from pypaimon.index.index_file_meta import IndexFileMeta
@@ -302,13 +304,20 @@ def _build_generic_index(
302304

303305
writer = self._create_generic_index_writer(index_path, index_field)
304306
try:
305-
for value, row_id in _extract_index_rows(
306-
table,
307-
self._index_columns[0],
308-
SpecialFields.ROW_ID.name,
309-
index_range,
310-
):
311-
writer.write(value, row_id - index_range.from_)
307+
if self._index_type in VINDEX_IDENTIFIERS:
308+
if table.column(SpecialFields.ROW_ID.name).null_count:
309+
raise ValueError("Cannot build global index because _ROW_ID is null.")
310+
for batch in table.to_batches(max_chunksize=ADD_BATCH_SIZE):
311+
_write_vector_batch(
312+
writer, batch, self._index_columns[0], index_range)
313+
else:
314+
for value, row_id in _extract_index_rows(
315+
table,
316+
self._index_columns[0],
317+
SpecialFields.ROW_ID.name,
318+
index_range,
319+
):
320+
writer.write(value, row_id - index_range.from_)
312321

313322
index_adds = _to_index_manifest_entries(
314323
self._table,
@@ -444,6 +453,21 @@ def compare(left, right):
444453
return sorted(rows, key=cmp_to_key(compare))
445454

446455

456+
def _write_vector_batch(writer, batch, index_column, row_range):
457+
row_ids = batch.column(SpecialFields.ROW_ID.name)
458+
if row_ids.null_count:
459+
raise ValueError("Cannot build global index because _ROW_ID is null.")
460+
vectors = batch.column(index_column)
461+
selected = pc.and_(
462+
pc.greater_equal(row_ids, row_range.from_),
463+
pc.less_equal(row_ids, row_range.to),
464+
)
465+
if not pc.all(selected).as_py():
466+
row_ids = pc.filter(row_ids, selected)
467+
vectors = pc.filter(vectors, selected)
468+
writer.write_batch(vectors, pc.subtract(row_ids, row_range.from_))
469+
470+
447471
def _extract_index_rows(
448472
table: pa.Table,
449473
index_column: str,

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,52 @@ def write(self, vector, relative_row_id: int) -> None:
8686
self._vector_temp.write(array("f", materialized).tobytes())
8787
self._vector_count += 1
8888

89+
def write_batch(self, vectors, relative_row_ids) -> None:
90+
"""Write Arrow arrays without materializing valid float32 vectors as lists.
91+
92+
Unsupported layouts and invalid vectors use the scalar path so that
93+
validation errors and the order of successfully written rows match write().
94+
"""
95+
if self._closed:
96+
raise RuntimeError("VindexVectorIndexWriter is already closed.")
97+
if len(vectors) != len(relative_row_ids):
98+
raise ValueError("Vector and row ID batch lengths differ.")
99+
if relative_row_ids.null_count:
100+
raise ValueError("Cannot build global index because _ROW_ID is null.")
101+
if len(vectors) == 0:
102+
return
103+
104+
import numpy as np
105+
import pyarrow as pa
106+
import pyarrow.compute as pc
107+
108+
valid_vectors, valid_ids = vectors, relative_row_ids
109+
if vectors.null_count:
110+
valid = pc.is_valid(vectors)
111+
valid_vectors = pc.filter(vectors, valid)
112+
valid_ids = pc.filter(relative_row_ids, valid)
113+
if len(valid_vectors) == 0:
114+
self._row_count += len(vectors)
115+
return
116+
117+
values = _float32_batch_values(np, pa, valid_vectors, self._dimension)
118+
if (values is not None and values.null_count == 0
119+
and valid_ids.type == pa.int64()):
120+
data = values.to_numpy(zero_copy_only=True)
121+
if np.isfinite(data).all():
122+
ids = np.ascontiguousarray(
123+
valid_ids.to_numpy(zero_copy_only=False), dtype=np.int64)
124+
data = np.ascontiguousarray(data, dtype=np.float32)
125+
self._row_count += len(vectors)
126+
self._ensure_temp_files()
127+
self._row_id_temp.write(memoryview(ids).cast('B'))
128+
self._vector_temp.write(memoryview(data).cast('B'))
129+
self._vector_count += len(valid_vectors)
130+
return
131+
132+
for vector, row_id in zip(vectors.to_pylist(), relative_row_ids.to_pylist()):
133+
self.write(vector, row_id)
134+
89135
def finish(self) -> List[ResultEntry]:
90136
if self._closed:
91137
raise RuntimeError("VindexVectorIndexWriter is already closed.")
@@ -355,6 +401,26 @@ def _sample_training_vectors(np, vectors, sample_ratio: float):
355401
return np.ascontiguousarray(vectors[indexes])
356402

357403

404+
def _float32_batch_values(np, pa, vectors, dimension):
405+
vector_type = vectors.type
406+
if not (
407+
pa.types.is_list(vector_type)
408+
or pa.types.is_large_list(vector_type)
409+
or pa.types.is_fixed_size_list(vector_type)
410+
) or vector_type.value_type != pa.float32():
411+
return None
412+
413+
if pa.types.is_fixed_size_list(vector_type):
414+
if vector_type.list_size != dimension:
415+
return None
416+
return vectors.values.slice(vectors.offset * dimension, len(vectors) * dimension)
417+
418+
offsets = vectors.offsets.to_numpy(zero_copy_only=True)
419+
if not np.all(np.diff(offsets) == dimension):
420+
return None
421+
return vectors.values.slice(int(offsets[0]), int(offsets[-1] - offsets[0]))
422+
423+
358424
def _materialize_vector(
359425
value, dimension: int, relative_row_id: int
360426
) -> List[float]:
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import os
19+
import unittest
20+
from unittest.mock import Mock, patch
21+
22+
import numpy as np
23+
import pyarrow as pa
24+
25+
from pypaimon.globalindex.create_global_index import GlobalIndexBuilder, _write_vector_batch
26+
from pypaimon.globalindex.vindex.vindex_vector_index_writer import VindexVectorIndexWriter
27+
from pypaimon.schema.data_types import ArrayType, AtomicType
28+
from pypaimon.utils.range import Range
29+
30+
31+
class VindexBatchWriteTest(unittest.TestCase):
32+
33+
def _writer(self):
34+
writer = VindexVectorIndexWriter(
35+
Mock(), '/unused', ArrayType(True, AtomicType('FLOAT')),
36+
'ivf-flat', {'ivf-flat.dimension': '2'}, 'embedding')
37+
self.addCleanup(writer.close)
38+
return writer
39+
40+
def _contents(self, writer):
41+
writer._close_temp_files()
42+
contents = []
43+
for path in (writer._row_id_temp_path, writer._vector_temp_path):
44+
if path is None:
45+
contents.append(b'')
46+
else:
47+
with open(path, 'rb') as stream:
48+
contents.append(stream.read())
49+
return writer._row_count, writer._vector_count, contents
50+
51+
def _assert_parity(self, vectors, row_ids, fast=True):
52+
scalar, batch = self._writer(), self._writer()
53+
for vector, row_id in zip(vectors.to_pylist(), row_ids.to_pylist()):
54+
scalar.write(vector, row_id)
55+
with patch.object(batch, 'write', wraps=batch.write) as write:
56+
batch.write_batch(vectors, row_ids)
57+
if fast:
58+
write.assert_not_called()
59+
self.assertEqual(self._contents(scalar), self._contents(batch))
60+
61+
def test_sliced_list_large_list_and_fixed_size_list(self):
62+
for array_type in (pa.list_(pa.float32()), pa.large_list(pa.float32()),
63+
pa.list_(pa.float32(), 2)):
64+
with self.subTest(array_type=array_type):
65+
vectors = pa.array([
66+
[99, 99], [1, 2], None, [-0.0, 1e-40], [3, 4], [88, 88],
67+
], type=array_type).slice(1, 4)
68+
row_ids = pa.array([77, 0, 3, 8, 2 ** 62, 99], type=pa.int64()).slice(1, 4)
69+
self._assert_parity(vectors, row_ids)
70+
71+
def test_null_parent_ignores_invalid_child_values(self):
72+
values = pa.array([1, 2, float('nan'), None, 3, 4], type=pa.float32())
73+
vectors = pa.Array.from_buffers(pa.list_(pa.float32()), 3, [
74+
pa.py_buffer(b'\x05'),
75+
pa.py_buffer(np.array([0, 2, 4, 6], dtype=np.int32)),
76+
], children=[values])
77+
self._assert_parity(vectors, pa.array([0, 1, 2], type=pa.int64()))
78+
79+
def test_empty_and_all_null_batches_do_not_create_files(self):
80+
for data in ([], [None, None]):
81+
with self.subTest(data=data):
82+
writer = self._writer()
83+
writer.write_batch(pa.array(data, type=pa.list_(pa.float32())),
84+
pa.array(range(len(data)), type=pa.int64()))
85+
self.assertEqual((len(data), 0, [b'', b'']), self._contents(writer))
86+
87+
def test_multiple_batches_and_scalar_writes_can_be_interleaved(self):
88+
writer = self._writer()
89+
writer.write([1, 2], 0)
90+
writer.write_batch(pa.array([[3, 4], None], type=pa.list_(pa.float32())),
91+
pa.array([2, 3], type=pa.int64()))
92+
writer.write([5, 6], 4)
93+
writer.write_batch(pa.array([[7, 8]], type=pa.list_(pa.float32(), 2)),
94+
pa.array([6], type=pa.int64()))
95+
count, valid_count, contents = self._contents(writer)
96+
self.assertEqual((5, 4), (count, valid_count))
97+
self.assertEqual([0, 2, 4, 6], np.frombuffer(contents[0], dtype=np.int64).tolist())
98+
self.assertEqual(list(range(1, 9)), np.frombuffer(contents[1], dtype=np.float32).tolist())
99+
100+
def test_invalid_vectors_preserve_scalar_error_and_written_prefix(self):
101+
cases = [
102+
[[1, 2], [3]],
103+
[[1, 2], [None, 4]],
104+
[[1, 2], [float('nan'), 4]],
105+
[[1, 2], [3, float('inf')]],
106+
[[1, 2], [3, float('-inf')]],
107+
[[float('nan'), 2], [3]],
108+
[[1, 2], [float('nan'), None]],
109+
[[1, 2], [None, float('nan')]],
110+
]
111+
for data in cases:
112+
with self.subTest(data=data):
113+
vectors = pa.array(data, type=pa.list_(pa.float32()))
114+
ids = pa.array([5, 9], type=pa.int64())
115+
scalar, batch = self._writer(), self._writer()
116+
with self.assertRaises(ValueError) as old_error:
117+
for vector, row_id in zip(vectors.to_pylist(), ids.to_pylist()):
118+
scalar.write(vector, row_id)
119+
with self.assertRaises(ValueError) as new_error:
120+
batch.write_batch(vectors, ids)
121+
self.assertEqual(str(old_error.exception), str(new_error.exception))
122+
self.assertEqual(self._contents(scalar), self._contents(batch))
123+
124+
def test_float64_and_non_int64_ids_fall_back_to_scalar(self):
125+
self._assert_parity(
126+
pa.array([[1.1, 2.2], None], type=pa.list_(pa.float64())),
127+
pa.array([0, 1], type=pa.int64()), fast=False)
128+
self._assert_parity(
129+
pa.array([[1, 2], [3, 4]], type=pa.list_(pa.float32())),
130+
pa.array([0, 1], type=pa.int32()), fast=False)
131+
132+
def test_batch_length_and_null_row_id_validation(self):
133+
vectors = pa.array([[1, 2]], type=pa.list_(pa.float32()))
134+
writer = self._writer()
135+
with self.assertRaisesRegex(ValueError, 'batch lengths differ'):
136+
writer.write_batch(vectors, pa.array([], type=pa.int64()))
137+
with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
138+
writer.write_batch(vectors, pa.array([None], type=pa.int64()))
139+
self.assertEqual((0, 0, [b'', b'']), self._contents(writer))
140+
141+
def test_close_removes_batch_files_and_rejects_further_writes(self):
142+
writer = self._writer()
143+
vectors = pa.array([[1, 2]], type=pa.list_(pa.float32()))
144+
ids = pa.array([0], type=pa.int64())
145+
writer.write_batch(vectors, ids)
146+
paths = writer._row_id_temp_path, writer._vector_temp_path
147+
writer.close()
148+
self.assertTrue(all(not os.path.exists(path) for path in paths))
149+
with self.assertRaisesRegex(RuntimeError, 'already closed'):
150+
writer.write_batch(vectors, ids)
151+
152+
def test_builder_filters_ranges_before_vector_validation(self):
153+
vectors = pa.array([[float('nan'), 0], [1, 2], None, [3, 4], [5]],
154+
type=pa.list_(pa.float32()))
155+
ids = pa.array([9, 10, 11, 19, 20], type=pa.int64())
156+
batch = pa.RecordBatch.from_arrays([vectors, ids], ['embedding', '_ROW_ID'])
157+
writer = self._writer()
158+
_write_vector_batch(writer, batch, 'embedding', Range(10, 19))
159+
count, valid_count, contents = self._contents(writer)
160+
self.assertEqual((3, 2), (count, valid_count))
161+
self.assertEqual([0, 9], np.frombuffer(contents[0], dtype=np.int64).tolist())
162+
self.assertEqual([1, 2, 3, 4], np.frombuffer(contents[1], dtype=np.float32).tolist())
163+
batch = pa.RecordBatch.from_arrays([
164+
pa.array([[1, 2]], type=pa.list_(pa.float32())),
165+
pa.array([None], type=pa.int64()),
166+
], ['embedding', '_ROW_ID'])
167+
with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
168+
_write_vector_batch(self._writer(), batch, 'embedding', Range(10, 19))
169+
170+
def test_null_row_ids_are_rejected_before_writing_any_batch(self):
171+
builder = object.__new__(GlobalIndexBuilder)
172+
builder._core_options = Mock()
173+
builder._core_options.global_index_row_count_per_shard.return_value = 10
174+
builder._index_type = 'ivf-flat'
175+
builder._index_columns = ['embedding']
176+
writer = Mock()
177+
builder._create_generic_index_writer = Mock(return_value=writer)
178+
read = Mock()
179+
read.to_arrow.return_value = pa.table({
180+
'embedding': pa.array([[1], [3, 4]], type=pa.list_(pa.float32())),
181+
'_ROW_ID': pa.array([0, None], type=pa.int64()),
182+
})
183+
module = 'pypaimon.globalindex.create_global_index'
184+
with patch(module + '._split_by_global_index_shard', return_value=[
185+
(Mock(), Range(0, 9)),
186+
]), patch(module + '.ADD_BATCH_SIZE', 1):
187+
with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
188+
builder._build_generic_index([], [], Mock(), read, '/unused')
189+
writer.write_batch.assert_not_called()
190+
writer.finish.assert_not_called()
191+
writer.close.assert_called_once()
192+
193+
194+
if __name__ == '__main__':
195+
unittest.main()

0 commit comments

Comments
 (0)