This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 2c8c19c30e [python] Batch vector writes when building global indexes 
(#9752)
2c8c19c30e is described below

commit 2c8c19c30e2fc7757207cf56b3fd9051510c7ab8
Author: chaoyang <[email protected]>
AuthorDate: Sun Sep 13 16:24:18 2026 +0800

    [python] Batch vector writes when building global indexes (#9752)
---
 .../pypaimon/globalindex/create_global_index.py    |  38 +++-
 .../vindex/vindex_vector_index_writer.py           |  66 +++++++
 .../pypaimon/tests/vindex_batch_write_test.py      | 195 +++++++++++++++++++++
 3 files changed, 292 insertions(+), 7 deletions(-)

diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py 
b/paimon-python/pypaimon/globalindex/create_global_index.py
index bd9d34a05a..b2e125e421 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -21,6 +21,7 @@ from functools import cmp_to_key
 from typing import Dict, List, Optional, Sequence, Union
 
 import pyarrow as pa
+import pyarrow.compute as pc
 
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.options.options import Options
@@ -51,6 +52,7 @@ from 
pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
     VINDEX_IDENTIFIERS,
 )
 from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
+    ADD_BATCH_SIZE,
     VindexVectorIndexWriter,
 )
 from pypaimon.index.index_file_meta import IndexFileMeta
@@ -302,13 +304,20 @@ class GlobalIndexBuilder:
 
             writer = self._create_generic_index_writer(index_path, index_field)
             try:
-                for value, row_id in _extract_index_rows(
-                    table,
-                    self._index_columns[0],
-                    SpecialFields.ROW_ID.name,
-                    index_range,
-                ):
-                    writer.write(value, row_id - index_range.from_)
+                if self._index_type in VINDEX_IDENTIFIERS:
+                    if table.column(SpecialFields.ROW_ID.name).null_count:
+                        raise ValueError("Cannot build global index because 
_ROW_ID is null.")
+                    for batch in 
table.to_batches(max_chunksize=ADD_BATCH_SIZE):
+                        _write_vector_batch(
+                            writer, batch, self._index_columns[0], index_range)
+                else:
+                    for value, row_id in _extract_index_rows(
+                        table,
+                        self._index_columns[0],
+                        SpecialFields.ROW_ID.name,
+                        index_range,
+                    ):
+                        writer.write(value, row_id - index_range.from_)
 
                 index_adds = _to_index_manifest_entries(
                     self._table,
@@ -444,6 +453,21 @@ def _extract_sorted_rows(
     return sorted(rows, key=cmp_to_key(compare))
 
 
+def _write_vector_batch(writer, batch, index_column, row_range):
+    row_ids = batch.column(SpecialFields.ROW_ID.name)
+    if row_ids.null_count:
+        raise ValueError("Cannot build global index because _ROW_ID is null.")
+    vectors = batch.column(index_column)
+    selected = pc.and_(
+        pc.greater_equal(row_ids, row_range.from_),
+        pc.less_equal(row_ids, row_range.to),
+    )
+    if not pc.all(selected).as_py():
+        row_ids = pc.filter(row_ids, selected)
+        vectors = pc.filter(vectors, selected)
+    writer.write_batch(vectors, pc.subtract(row_ids, row_range.from_))
+
+
 def _extract_index_rows(
     table: pa.Table,
     index_column: str,
diff --git 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
index aa56a8ab59..87c334fa1b 100644
--- a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
+++ b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
@@ -86,6 +86,52 @@ class VindexVectorIndexWriter:
         self._vector_temp.write(array("f", materialized).tobytes())
         self._vector_count += 1
 
+    def write_batch(self, vectors, relative_row_ids) -> None:
+        """Write Arrow arrays without materializing valid float32 vectors as 
lists.
+
+        Unsupported layouts and invalid vectors use the scalar path so that
+        validation errors and the order of successfully written rows match 
write().
+        """
+        if self._closed:
+            raise RuntimeError("VindexVectorIndexWriter is already closed.")
+        if len(vectors) != len(relative_row_ids):
+            raise ValueError("Vector and row ID batch lengths differ.")
+        if relative_row_ids.null_count:
+            raise ValueError("Cannot build global index because _ROW_ID is 
null.")
+        if len(vectors) == 0:
+            return
+
+        import numpy as np
+        import pyarrow as pa
+        import pyarrow.compute as pc
+
+        valid_vectors, valid_ids = vectors, relative_row_ids
+        if vectors.null_count:
+            valid = pc.is_valid(vectors)
+            valid_vectors = pc.filter(vectors, valid)
+            valid_ids = pc.filter(relative_row_ids, valid)
+        if len(valid_vectors) == 0:
+            self._row_count += len(vectors)
+            return
+
+        values = _float32_batch_values(np, pa, valid_vectors, self._dimension)
+        if (values is not None and values.null_count == 0
+                and valid_ids.type == pa.int64()):
+            data = values.to_numpy(zero_copy_only=True)
+            if np.isfinite(data).all():
+                ids = np.ascontiguousarray(
+                    valid_ids.to_numpy(zero_copy_only=False), dtype=np.int64)
+                data = np.ascontiguousarray(data, dtype=np.float32)
+                self._row_count += len(vectors)
+                self._ensure_temp_files()
+                self._row_id_temp.write(memoryview(ids).cast('B'))
+                self._vector_temp.write(memoryview(data).cast('B'))
+                self._vector_count += len(valid_vectors)
+                return
+
+        for vector, row_id in zip(vectors.to_pylist(), 
relative_row_ids.to_pylist()):
+            self.write(vector, row_id)
+
     def finish(self) -> List[ResultEntry]:
         if self._closed:
             raise RuntimeError("VindexVectorIndexWriter is already closed.")
@@ -355,6 +401,26 @@ def _sample_training_vectors(np, vectors, sample_ratio: 
float):
     return np.ascontiguousarray(vectors[indexes])
 
 
+def _float32_batch_values(np, pa, vectors, dimension):
+    vector_type = vectors.type
+    if not (
+        pa.types.is_list(vector_type)
+        or pa.types.is_large_list(vector_type)
+        or pa.types.is_fixed_size_list(vector_type)
+    ) or vector_type.value_type != pa.float32():
+        return None
+
+    if pa.types.is_fixed_size_list(vector_type):
+        if vector_type.list_size != dimension:
+            return None
+        return vectors.values.slice(vectors.offset * dimension, len(vectors) * 
dimension)
+
+    offsets = vectors.offsets.to_numpy(zero_copy_only=True)
+    if not np.all(np.diff(offsets) == dimension):
+        return None
+    return vectors.values.slice(int(offsets[0]), int(offsets[-1] - offsets[0]))
+
+
 def _materialize_vector(
     value, dimension: int, relative_row_id: int
 ) -> List[float]:
diff --git a/paimon-python/pypaimon/tests/vindex_batch_write_test.py 
b/paimon-python/pypaimon/tests/vindex_batch_write_test.py
new file mode 100644
index 0000000000..72da3bb745
--- /dev/null
+++ b/paimon-python/pypaimon/tests/vindex_batch_write_test.py
@@ -0,0 +1,195 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import unittest
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pyarrow as pa
+
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder, 
_write_vector_batch
+from pypaimon.globalindex.vindex.vindex_vector_index_writer import 
VindexVectorIndexWriter
+from pypaimon.schema.data_types import ArrayType, AtomicType
+from pypaimon.utils.range import Range
+
+
+class VindexBatchWriteTest(unittest.TestCase):
+
+    def _writer(self):
+        writer = VindexVectorIndexWriter(
+            Mock(), '/unused', ArrayType(True, AtomicType('FLOAT')),
+            'ivf-flat', {'ivf-flat.dimension': '2'}, 'embedding')
+        self.addCleanup(writer.close)
+        return writer
+
+    def _contents(self, writer):
+        writer._close_temp_files()
+        contents = []
+        for path in (writer._row_id_temp_path, writer._vector_temp_path):
+            if path is None:
+                contents.append(b'')
+            else:
+                with open(path, 'rb') as stream:
+                    contents.append(stream.read())
+        return writer._row_count, writer._vector_count, contents
+
+    def _assert_parity(self, vectors, row_ids, fast=True):
+        scalar, batch = self._writer(), self._writer()
+        for vector, row_id in zip(vectors.to_pylist(), row_ids.to_pylist()):
+            scalar.write(vector, row_id)
+        with patch.object(batch, 'write', wraps=batch.write) as write:
+            batch.write_batch(vectors, row_ids)
+            if fast:
+                write.assert_not_called()
+        self.assertEqual(self._contents(scalar), self._contents(batch))
+
+    def test_sliced_list_large_list_and_fixed_size_list(self):
+        for array_type in (pa.list_(pa.float32()), pa.large_list(pa.float32()),
+                           pa.list_(pa.float32(), 2)):
+            with self.subTest(array_type=array_type):
+                vectors = pa.array([
+                    [99, 99], [1, 2], None, [-0.0, 1e-40], [3, 4], [88, 88],
+                ], type=array_type).slice(1, 4)
+                row_ids = pa.array([77, 0, 3, 8, 2 ** 62, 99], 
type=pa.int64()).slice(1, 4)
+                self._assert_parity(vectors, row_ids)
+
+    def test_null_parent_ignores_invalid_child_values(self):
+        values = pa.array([1, 2, float('nan'), None, 3, 4], type=pa.float32())
+        vectors = pa.Array.from_buffers(pa.list_(pa.float32()), 3, [
+            pa.py_buffer(b'\x05'),
+            pa.py_buffer(np.array([0, 2, 4, 6], dtype=np.int32)),
+        ], children=[values])
+        self._assert_parity(vectors, pa.array([0, 1, 2], type=pa.int64()))
+
+    def test_empty_and_all_null_batches_do_not_create_files(self):
+        for data in ([], [None, None]):
+            with self.subTest(data=data):
+                writer = self._writer()
+                writer.write_batch(pa.array(data, type=pa.list_(pa.float32())),
+                                   pa.array(range(len(data)), type=pa.int64()))
+                self.assertEqual((len(data), 0, [b'', b'']), 
self._contents(writer))
+
+    def test_multiple_batches_and_scalar_writes_can_be_interleaved(self):
+        writer = self._writer()
+        writer.write([1, 2], 0)
+        writer.write_batch(pa.array([[3, 4], None], 
type=pa.list_(pa.float32())),
+                           pa.array([2, 3], type=pa.int64()))
+        writer.write([5, 6], 4)
+        writer.write_batch(pa.array([[7, 8]], type=pa.list_(pa.float32(), 2)),
+                           pa.array([6], type=pa.int64()))
+        count, valid_count, contents = self._contents(writer)
+        self.assertEqual((5, 4), (count, valid_count))
+        self.assertEqual([0, 2, 4, 6], np.frombuffer(contents[0], 
dtype=np.int64).tolist())
+        self.assertEqual(list(range(1, 9)), np.frombuffer(contents[1], 
dtype=np.float32).tolist())
+
+    def test_invalid_vectors_preserve_scalar_error_and_written_prefix(self):
+        cases = [
+            [[1, 2], [3]],
+            [[1, 2], [None, 4]],
+            [[1, 2], [float('nan'), 4]],
+            [[1, 2], [3, float('inf')]],
+            [[1, 2], [3, float('-inf')]],
+            [[float('nan'), 2], [3]],
+            [[1, 2], [float('nan'), None]],
+            [[1, 2], [None, float('nan')]],
+        ]
+        for data in cases:
+            with self.subTest(data=data):
+                vectors = pa.array(data, type=pa.list_(pa.float32()))
+                ids = pa.array([5, 9], type=pa.int64())
+                scalar, batch = self._writer(), self._writer()
+                with self.assertRaises(ValueError) as old_error:
+                    for vector, row_id in zip(vectors.to_pylist(), 
ids.to_pylist()):
+                        scalar.write(vector, row_id)
+                with self.assertRaises(ValueError) as new_error:
+                    batch.write_batch(vectors, ids)
+                self.assertEqual(str(old_error.exception), 
str(new_error.exception))
+                self.assertEqual(self._contents(scalar), self._contents(batch))
+
+    def test_float64_and_non_int64_ids_fall_back_to_scalar(self):
+        self._assert_parity(
+            pa.array([[1.1, 2.2], None], type=pa.list_(pa.float64())),
+            pa.array([0, 1], type=pa.int64()), fast=False)
+        self._assert_parity(
+            pa.array([[1, 2], [3, 4]], type=pa.list_(pa.float32())),
+            pa.array([0, 1], type=pa.int32()), fast=False)
+
+    def test_batch_length_and_null_row_id_validation(self):
+        vectors = pa.array([[1, 2]], type=pa.list_(pa.float32()))
+        writer = self._writer()
+        with self.assertRaisesRegex(ValueError, 'batch lengths differ'):
+            writer.write_batch(vectors, pa.array([], type=pa.int64()))
+        with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
+            writer.write_batch(vectors, pa.array([None], type=pa.int64()))
+        self.assertEqual((0, 0, [b'', b'']), self._contents(writer))
+
+    def test_close_removes_batch_files_and_rejects_further_writes(self):
+        writer = self._writer()
+        vectors = pa.array([[1, 2]], type=pa.list_(pa.float32()))
+        ids = pa.array([0], type=pa.int64())
+        writer.write_batch(vectors, ids)
+        paths = writer._row_id_temp_path, writer._vector_temp_path
+        writer.close()
+        self.assertTrue(all(not os.path.exists(path) for path in paths))
+        with self.assertRaisesRegex(RuntimeError, 'already closed'):
+            writer.write_batch(vectors, ids)
+
+    def test_builder_filters_ranges_before_vector_validation(self):
+        vectors = pa.array([[float('nan'), 0], [1, 2], None, [3, 4], [5]],
+                           type=pa.list_(pa.float32()))
+        ids = pa.array([9, 10, 11, 19, 20], type=pa.int64())
+        batch = pa.RecordBatch.from_arrays([vectors, ids], ['embedding', 
'_ROW_ID'])
+        writer = self._writer()
+        _write_vector_batch(writer, batch, 'embedding', Range(10, 19))
+        count, valid_count, contents = self._contents(writer)
+        self.assertEqual((3, 2), (count, valid_count))
+        self.assertEqual([0, 9], np.frombuffer(contents[0], 
dtype=np.int64).tolist())
+        self.assertEqual([1, 2, 3, 4], np.frombuffer(contents[1], 
dtype=np.float32).tolist())
+        batch = pa.RecordBatch.from_arrays([
+            pa.array([[1, 2]], type=pa.list_(pa.float32())),
+            pa.array([None], type=pa.int64()),
+        ], ['embedding', '_ROW_ID'])
+        with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
+            _write_vector_batch(self._writer(), batch, 'embedding', Range(10, 
19))
+
+    def test_null_row_ids_are_rejected_before_writing_any_batch(self):
+        builder = object.__new__(GlobalIndexBuilder)
+        builder._core_options = Mock()
+        builder._core_options.global_index_row_count_per_shard.return_value = 
10
+        builder._index_type = 'ivf-flat'
+        builder._index_columns = ['embedding']
+        writer = Mock()
+        builder._create_generic_index_writer = Mock(return_value=writer)
+        read = Mock()
+        read.to_arrow.return_value = pa.table({
+            'embedding': pa.array([[1], [3, 4]], type=pa.list_(pa.float32())),
+            '_ROW_ID': pa.array([0, None], type=pa.int64()),
+        })
+        module = 'pypaimon.globalindex.create_global_index'
+        with patch(module + '._split_by_global_index_shard', return_value=[
+            (Mock(), Range(0, 9)),
+        ]), patch(module + '.ADD_BATCH_SIZE', 1):
+            with self.assertRaisesRegex(ValueError, '_ROW_ID is null'):
+                builder._build_generic_index([], [], Mock(), read, '/unused')
+        writer.write_batch.assert_not_called()
+        writer.finish.assert_not_called()
+        writer.close.assert_called_once()
+
+
+if __name__ == '__main__':
+    unittest.main()

Reply via email to