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 f5efd61d60 [python] Vectorize selected-key reconstruction for 
shared-shredding MAPs (#10143)
f5efd61d60 is described below

commit f5efd61d6036ac0ca8243e34d0093c4781b9e1c1
Author: zhigang <[email protected]>
AuthorDate: Thu Sep 24 10:58:46 2026 +0800

    [python] Vectorize selected-key reconstruction for shared-shredding MAPs 
(#10143)
---
 .../pypaimon/data/map_shared_shredding.py          | 259 ++++++++++++++-------
 .../format_pyarrow_shared_shredding_map_test.py    | 195 +++++++++++++++-
 2 files changed, 372 insertions(+), 82 deletions(-)

diff --git a/paimon-python/pypaimon/data/map_shared_shredding.py 
b/paimon-python/pypaimon/data/map_shared_shredding.py
index 1fe0a61627..42994543fd 100644
--- a/paimon-python/pypaimon/data/map_shared_shredding.py
+++ b/paimon-python/pypaimon/data/map_shared_shredding.py
@@ -19,7 +19,7 @@
 import json
 import struct
 from copy import copy
-from typing import Dict, List
+from typing import Dict, List, Optional, Sequence, Set, Tuple, Union, cast
 
 import pyarrow as pa
 import pyarrow.compute as pc
@@ -39,6 +39,11 @@ _OVERFLOW = "__overflow"
 _PHYSICAL_COLUMN_PREFIX = "__col_"
 _SELECTED_KEYS_PREFIX = "__PAIMON_MAP_SELECTED_KEYS:"
 _SELECTED_KEYS_DELIMITER = ";"
+_VECTORIZED_MIN_ROWS = 1024
+_VECTORIZED_MAX_CANDIDATE_PAIRS = 16
+
+_SelectedKeyMetadata = Tuple[Dict[int, str], Dict[int, List[int]], Set[int], 
int]
+_FieldMappings = List[Optional[List[Optional[int]]]]
 
 
 def is_shared_shredding(field: pa.Field) -> bool:
@@ -379,7 +384,7 @@ def assemble_shared_shredding_selected_keys(
         column: pa.StructArray,
         selected_keys: List[str],
         value_type: pa.DataType,
-        metadata) -> pa.StructArray:
+        metadata: _SelectedKeyMetadata) -> pa.StructArray:
     """Materialize selected MAP values from a pruned physical struct."""
     if not pa.types.is_struct(column.type):
         raise TypeError("Shared-shredding MAP must be stored as a struct")
@@ -389,105 +394,196 @@ def assemble_shared_shredding_selected_keys(
     field_names = [field.name for field in column.type]
     if not field_names or field_names[0] != _FIELD_MAPPING:
         raise ValueError(
-            "Shared-shredding physical struct must start with {}".format(
-                _FIELD_MAPPING))
+            "Shared-shredding physical struct must start with 
{}".format(_FIELD_MAPPING))
     physical_columns = {}
-    overflow = None
+    overflow: Optional[pa.MapArray] = None
     for position, field_name in enumerate(field_names[1:], 1):
+        child = column.field(position)
         if field_name == _OVERFLOW:
-            overflow = column.field(position)
+            if not isinstance(child, pa.MapArray):
+                raise TypeError("Shared-shredding overflow field must be a 
map")
+            overflow = child
         elif field_name.startswith(_PHYSICAL_COLUMN_PREFIX):
             try:
-                physical_columns[int(
-                    field_name[len(_PHYSICAL_COLUMN_PREFIX):])] = column.field(
-                        position)
+                
physical_columns[int(field_name[len(_PHYSICAL_COLUMN_PREFIX):])] = child
             except ValueError:
-                raise ValueError(
-                    "Unexpected shared-shredding physical field: {}".format(
-                        field_name))
+                raise ValueError("Unexpected shared-shredding physical field: 
{}".format(field_name))
         else:
-            raise ValueError(
-                "Unexpected shared-shredding physical field: {}".format(
-                    field_name))
+            raise ValueError("Unexpected shared-shredding physical field: 
{}".format(field_name))
 
-    mapping = column.field(0).to_pylist()
-    null_rows = column.is_null().to_pylist()
-    overflow_offsets = overflow_keys = overflow_values = overflow_nulls = None
-    if overflow is not None:
-        overflow_offsets, overflow_start, overflow_end = _normalized_offsets(
-            overflow)
-        overflow_keys = overflow.keys.slice(
-            overflow_start, overflow_end - overflow_start).to_pylist()
-        overflow_values = _restore_orc_temporal_values(
-            overflow.items.slice(
-                overflow_start, overflow_end - overflow_start), value_type)
-        overflow_nulls = overflow.is_null().to_pylist()
-
-    sources = []
-    source_by_column = {}
-    for physical_index in sorted(physical_columns):
-        source_by_column[physical_index] = len(sources)
-        sources.append(_restore_orc_temporal_values(
-            physical_columns[physical_index], value_type))
-    overflow_source = None
-    if overflow_values is not None:
-        overflow_source = len(sources)
-        sources.append(overflow_values)
+    mapping_column = column.field(0)
+    requested_ids = [id_by_name[key] for key in selected_keys if key in 
id_by_name]
+    candidate_count = sum(len(field_to_columns.get(field_id, ())) for field_id 
in requested_ids)
+    mapping_rows: _FieldMappings = []
+    null_rows = []
+    if (isinstance(mapping_column, (pa.ListArray, pa.LargeListArray))
+            and _use_vectorized_selected_keys(value_type, len(column), 
candidate_count)):
+        vectorized = True
+        valid_rows = column.is_valid()
+        if requested_ids:
+            _validate_selected_key_mapping(mapping_column, valid_rows, 
num_columns)
+        # Sliced offsets still address the original values. Mask null parents
+        # before gathering, since their child lists may be empty.
+        mapping_offsets = pc.if_else(valid_rows, mapping_column.offsets[:-1], 
None)
+        mapping_values = mapping_column.values
+        row_indices = pa.array(range(len(column)), type=pa.int64())
+    else:
+        vectorized = False
+        mapping_rows = column.field(0).to_pylist()
+        null_rows = column.is_null().to_pylist()
 
-    source_bases = []
+    # Use one value pool, with a direct physical-column -> starting-offset map.
     value_arrays = []
+    column_bases = {}
     next_base = 0
-    for source in sources:
-        source_bases.append(next_base)
-        value_arrays.append(source)
-        next_base += len(source)
-    value_pool = (
-        pa.concat_arrays(value_arrays)
-        if value_arrays else pa.array([], type=value_type)
-    )
+    for physical_index in sorted(physical_columns):
+        values = 
_restore_orc_temporal_values(physical_columns[physical_index], value_type)
+        column_bases[physical_index] = next_base
+        value_arrays.append(values)
+        next_base += len(values)
+
+    overflow_base = None
+    overflow_offsets: List[int] = []
+    overflow_keys: List[Optional[int]] = []
+    overflow_nulls: List[Optional[bool]] = []
+    if overflow is not None:
+        # Unlike mapping values, overflow values are sliced into the pool, so
+        # their offsets must be relative to that slice.
+        overflow_offsets, start, end = _normalized_offsets(overflow)
+        overflow_keys = overflow.keys.slice(start, end - start).to_pylist()
+        overflow_nulls = overflow.is_null().to_pylist()
+        overflow_base = next_base
+        value_arrays.append(_restore_orc_temporal_values(
+            overflow.items.slice(start, end - start), value_type))
+    value_pool = (pa.concat_arrays(value_arrays)
+                  if value_arrays else pa.array([], type=value_type))
 
     children = []
     for key in selected_keys:
         field_id = id_by_name.get(key)
-        indices = []
-        candidate_columns = (
-            field_to_columns.get(field_id, ()) if field_id is not None else ())
-        for row in range(len(column)):
-            selected = None
-            if not null_rows[row] and field_id is not None:
-                row_mapping = mapping[row]
-                if row_mapping is None or len(row_mapping) != num_columns:
-                    raise ValueError(
-                        "Shared-shredding field mapping length must equal 
{}".format(
-                            num_columns))
-                for physical_index in candidate_columns:
-                    if row_mapping[physical_index] == field_id:
-                        source = source_by_column.get(physical_index)
-                        if source is None:
-                            raise ValueError(
-                                "Missing shared-shredding physical column 
{}".format(
-                                    physical_index))
-                        selected = source_bases[source] + row
+        if field_id is None:
+            children.append(pc.take(value_pool, pa.nulls(len(column), 
type=pa.int64())))
+            continue
+        candidate_columns = field_to_columns.get(field_id, ())
+        for physical_index in candidate_columns:
+            if not 0 <= physical_index < num_columns:
+                raise ValueError(
+                    "Shared-shredding physical column {} is out of range for 
{} columns".format(
+                        physical_index, num_columns))
+        selected_rows: List[Optional[int]]
+        if vectorized:
+            indices = _selected_key_indices_vectorized(
+                mapping_values, mapping_offsets, row_indices,
+                field_id, candidate_columns, column_bases)
+            if field_id not in overflow_set or overflow_base is None:
+                children.append(pc.take(value_pool, indices))
+                continue
+            selected_rows = indices.to_pylist()
+        else:
+            selected_rows = _selected_key_indices(
+                mapping_rows, null_rows, num_columns,
+                field_id, candidate_columns, column_bases)
+
+        if field_id in overflow_set and overflow_base is not None:
+            # Both paths use the same first-match overflow lookup. A physical
+            # match already has an index, even when its value is null.
+            if not null_rows:
+                null_rows = column.is_null().to_pylist()
+            for row, selected in enumerate(selected_rows):
+                if selected is not None or null_rows[row] or 
overflow_nulls[row]:
+                    continue
+                for item_index in range(overflow_offsets[row], 
overflow_offsets[row + 1]):
+                    if overflow_keys[item_index] == field_id:
+                        selected_rows[row] = overflow_base + item_index
                         break
-                if (selected is None
-                        and field_id in overflow_set
-                        and overflow_source is not None
-                        and not overflow_nulls[row]):
-                    for item_index in range(
-                            overflow_offsets[row], overflow_offsets[row + 1]):
-                        if overflow_keys[item_index] == field_id:
-                            selected = (
-                                source_bases[overflow_source] + item_index)
-                            break
-            indices.append(selected)
-        children.append(pc.take(
-            value_pool, pa.array(indices, type=pa.int64())))
+        children.append(pc.take(value_pool, pa.array(selected_rows, 
type=pa.int64())))
 
     fields = [pa.field(key, value_type) for key in selected_keys]
     mask = column.is_null() if column.null_count else None
     return pa.StructArray.from_arrays(children, fields=fields, mask=mask)
 
 
+def _use_vectorized_selected_keys(value_type: pa.DataType, rows: int, 
candidate_count: int) -> bool:
+    # Conservative limits from local benchmarks, not universal crossover 
points.
+    # Count key/column pairs: shared columns still need a match for each key.
+    if rows < _VECTORIZED_MIN_ROWS or candidate_count > 
_VECTORIZED_MAX_CANDIDATE_PAIRS:
+        return False
+    # Other types retain the original path pending performance validation.
+    return (pa.types.is_string(value_type)
+            or pa.types.is_binary(value_type)
+            or pa.types.is_boolean(value_type)
+            or pa.types.is_signed_integer(value_type)
+            or pa.types.is_floating(value_type)
+            or pa.types.is_decimal128(value_type)
+            or pa.types.is_date32(value_type)
+            or pa.types.is_time32(value_type)
+            or pa.types.is_timestamp(value_type))
+
+
+def _validate_selected_key_mapping(
+        mapping: Union[pa.ListArray, pa.LargeListArray],
+        valid_rows: pa.BooleanArray,
+        num_columns: int) -> None:
+    lengths = pc.list_value_length(mapping)
+    invalid_lengths = pc.not_equal(lengths, pa.scalar(num_columns))
+    invalid_rows = pc.and_(valid_rows, pc.fill_null(invalid_lengths, 
pa.scalar(True)))
+    if pc.any(invalid_rows).as_py():
+        raise ValueError("Shared-shredding field mapping length must equal 
{}".format(num_columns))
+
+
+def _selected_key_indices(
+        mapping_rows: _FieldMappings,
+        null_rows: List[Optional[bool]],
+        num_columns: int,
+        field_id: int,
+        candidate_columns: Sequence[int],
+        column_bases: Dict[int, int]) -> List[Optional[int]]:
+    """Find the first physical match in each row, as an index into the value 
pool."""
+    indices: List[Optional[int]] = []
+    for row, row_mapping in enumerate(mapping_rows):
+        selected = None
+        if not null_rows[row]:
+            if row_mapping is None or len(row_mapping) != num_columns:
+                raise ValueError(
+                    "Shared-shredding field mapping length must equal 
{}".format(num_columns))
+            for physical_index in candidate_columns:
+                if row_mapping[physical_index] == field_id:
+                    base = column_bases.get(physical_index)
+                    if base is None:
+                        raise ValueError(
+                            "Missing shared-shredding physical column 
{}".format(physical_index))
+                    selected = base + row
+                    break
+        indices.append(selected)
+    return indices
+
+
+def _selected_key_indices_vectorized(
+        mapping_values: pa.Array,
+        mapping_offsets: pa.Array,
+        row_indices: pa.Array,
+        field_id: int,
+        candidate_columns: Sequence[int],
+        column_bases: Dict[int, int]) -> pa.Array:
+    """Find the same first-match indices using Arrow operations across rows."""
+    indices = pa.nulls(len(row_indices), type=pa.int64())
+    field_id_scalar = pa.scalar(field_id)
+    false_scalar = pa.scalar(False)
+    for physical_index in candidate_columns:
+        mapped_ids = pc.take(mapping_values, pc.add(mapping_offsets, 
physical_index))
+        matches = pc.equal(mapped_ids, field_id_scalar)
+        # A null mapping entry is not a match; later candidates cannot replace
+        # an index chosen by an earlier candidate.
+        matches = pc.and_(pc.fill_null(matches, false_scalar), 
pc.is_null(indices))
+        base = column_bases.get(physical_index)
+        if base is None:
+            if pc.any(matches).as_py():
+                raise ValueError("Missing shared-shredding physical column 
{}".format(physical_index))
+            continue
+        indices = pc.if_else(matches, pc.add(row_indices, base), indices)
+    return indices
+
+
 def assemble_normal_map_selected_keys(
         column: pa.MapArray,
         selected_keys: List[str],
@@ -615,7 +711,7 @@ def _append_entry(keys, entry_sources, entry_positions, 
selected_indices,
     selected_indices[source].append(source_index)
 
 
-def _normalized_offsets(column):
+def _normalized_offsets(column) -> Tuple[List[int], int, int]:
     offsets_array = getattr(column, "offsets", None)
     if offsets_array is None:
         offsets_array = pa.Array.from_buffers(
@@ -624,7 +720,8 @@ def _normalized_offsets(column):
             [None, column.buffers()[1]],
             offset=column.offset,
         )
-    offsets = offsets_array.to_pylist()
+    # Arrow list offsets are non-null integers, even for null parent rows.
+    offsets = cast(List[int], offsets_array.to_pylist())
     start = offsets[0]
     normalized = [value - start for value in offsets]
     return normalized, start, offsets[-1]
diff --git 
a/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py 
b/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
index 4fd2922032..9bddd6d7ec 100644
--- a/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
+++ b/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
@@ -22,6 +22,7 @@ import shutil
 import struct
 import tempfile
 import unittest
+from typing import Dict, List, Optional
 from unittest import mock
 
 import pyarrow as pa
@@ -29,7 +30,11 @@ import pyarrow.fs as pafs
 import pyarrow.orc as orc
 import pyarrow.parquet as pq
 
-from pypaimon.data.map_shared_shredding import map_selected_keys_field
+from pypaimon.data import map_shared_shredding
+from pypaimon.data.map_shared_shredding import (
+    assemble_shared_shredding_selected_keys,
+    map_selected_keys_field,
+)
 from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
 from pypaimon.schema.data_types import (
     ArrayType,
@@ -73,6 +78,194 @@ def _metadata(compression):
 
 
 class SharedShreddingMapReaderTest(unittest.TestCase):
+    def test_selected_keys_preserve_sliced_mapping_and_overflow(self):
+        # Exercise both sides of the batch/candidate limits with the same
+        # sliced data, including null parents, moving keys and duplicate 
overflow.
+        # Keep explicit boundaries to catch accidental changes to the policy.
+        cases = [
+            (pa.string(), 6, 2, False),
+            (pa.string(), 1023, 8, False),
+            (pa.string(), 1024, 8, True),
+            (pa.string(), 1025, 8, True),
+            (pa.string(), 1024, 9, False),
+            (pa.struct([("v", pa.int64())]), 1024, 8, False),
+            (pa.int64(), 1024, 8, True),
+            (pa.float64(), 1024, 8, True),
+            (pa.bool_(), 1024, 8, True),
+            (pa.binary(), 1024, 8, True),
+        ]
+        for value_type, rows, width, vectorized in cases:
+            with self.subTest(value_type=value_type, rows=rows, width=width):
+
+                def value(number: int):
+                    if pa.types.is_string(value_type):
+                        return str(number)
+                    if pa.types.is_binary(value_type):
+                        return number.to_bytes(2, "little")
+                    if pa.types.is_integer(value_type):
+                        return number
+                    if pa.types.is_floating(value_type):
+                        return number / 8.0
+                    if pa.types.is_boolean(value_type):
+                        return bool(number % 2)
+                    return {"v": number}
+
+                mappings = [[0, 1], [], [-1, -1], [0, 1], [-1, -1], [1, 0]]
+                mappings = [m + [-1] * (width - 2) if m else [] for m in 
mappings]
+                repeats = (rows + 5) // 6
+                # Prefix is excluded by the slice but shifts both child 
offsets.
+                mapping = [[0, 1] + [-1] * (width - 2)] + mappings * repeats
+                first = [value(99)] + [
+                    value(1),
+                    None,
+                    None,
+                    None,
+                    None,
+                    value(15),
+                ] * repeats
+                second = [value(199)] + [
+                    value(11),
+                    None,
+                    None,
+                    None,
+                    None,
+                    value(5),
+                ] * repeats
+                overflow = [[(0, value(999))]] + [
+                    [],
+                    [],
+                    [(0, value(2)), (0, value(3)), (1, value(12))],
+                    [(0, value(4)), (1, value(14))],
+                    [],
+                    None,
+                ] * repeats
+                physical = pa.StructArray.from_arrays(
+                    [
+                        pa.array(mapping, type=pa.list_(pa.int32())),
+                        pa.array(first, type=value_type),
+                        pa.array(second, type=value_type),
+                    ]
+                    + [
+                        pa.nulls(len(mapping), type=value_type)
+                        for _ in range(width - 2)
+                    ]
+                    + [pa.array(overflow, type=pa.map_(pa.int32(), 
value_type))],
+                    names=["__field_mapping"]
+                    + ["__col_%d" % i for i in range(width)]
+                    + ["__overflow"],
+                    mask=pa.array(
+                        [False] + [False, True, False, False, False, False] * 
repeats
+                    ),
+                )
+                expected = [
+                    {"k": value(1), "other": value(11), "missing": None},
+                    None,
+                    {"k": value(2), "other": value(12), "missing": None},
+                    {"k": None, "other": None, "missing": None},
+                    {"k": None, "other": None, "missing": None},
+                    {"k": value(5), "other": value(15), "missing": None},
+                ] * repeats
+                with mock.patch.object(
+                    map_shared_shredding.pc,
+                    "list_value_length",
+                    wraps=map_shared_shredding.pc.list_value_length,
+                ) as vectorized_lengths:
+                    selected = assemble_shared_shredding_selected_keys(
+                        physical.slice(1, rows),
+                        ["k", "other", "missing"],
+                        value_type,
+                        (
+                            {0: "k", 1: "other"},
+                            {0: list(range(width)), 1: list(range(min(width, 
8)))},
+                            {0, 1},
+                            width,
+                        ),
+                    )
+                    self.assertEqual(vectorized_lengths.call_count, 
int(vectorized))
+                expected_array = pa.array(expected[:rows], type=selected.type)
+                assert isinstance(expected_array, pa.StructArray)
+                self.assertTrue(selected.equals(expected_array))
+
+    def test_selected_keys_mapping_boundaries(self):
+        for rows in (1023, 1024):
+            for case in (
+                "tail_null", "null_id", "short_mapping", "unknown_key",
+                "missing_column", "fixed_size_list", "large_list",
+            ):
+                with self.subTest(rows=rows, case=case):
+                    mappings: List[List[Optional[int]]] = [[0, 1] for _ in 
range(rows + 1)]
+                    mask = [False] * (rows + 1)
+                    expected: List[Optional[Dict[str, Optional[str]]]] = [
+                        {"k": str(i)} for i in range(1, rows + 1)
+                    ]
+                    selected_keys = ["k"]
+                    mapping_type = pa.list_(pa.int32())
+                    if case == "tail_null":
+                        mappings[-1] = []
+                        mask[-1] = True
+                        expected[-1] = None
+                    elif case == "null_id":
+                        # A later null candidate must not erase an earlier 
match.
+                        mappings[7] = [0, None]
+                    elif case in ("short_mapping", "unknown_key"):
+                        mappings[7] = []
+                        if case == "unknown_key":
+                            selected_keys = ["missing"]
+                            expected = [{"missing": None}] * rows
+                    elif case == "missing_column":
+                        mappings[7] = [1, 0]
+                    elif case == "fixed_size_list":
+                        mapping_type = pa.list_(pa.int32(), 2)
+                    elif case == "large_list":
+                        mapping_type = pa.large_list(pa.int32())
+                    arrays = [
+                        pa.array(mappings, type=mapping_type),
+                        pa.array([str(i) for i in range(rows + 1)]),
+                    ]
+                    names = ["__field_mapping", "__col_0"]
+                    if case != "missing_column":
+                        arrays.append(pa.nulls(rows + 1, type=pa.string()))
+                        names.append("__col_1")
+                    physical = pa.StructArray.from_arrays(
+                        arrays, names=names, mask=pa.array(mask)
+                    ).slice(1)
+                    metadata = ({0: "k", 1: "other"}, {0: [0, 1]}, set(), 2)
+                    if case in ("short_mapping", "missing_column"):
+                        message = (
+                            "field mapping length must equal 2"
+                            if case == "short_mapping"
+                            else "Missing shared-shredding physical column 1"
+                        )
+                        with self.assertRaisesRegex(ValueError, message):
+                            assemble_shared_shredding_selected_keys(
+                                physical, selected_keys, pa.string(), metadata
+                            )
+                    else:
+                        selected = assemble_shared_shredding_selected_keys(
+                            physical, selected_keys, pa.string(), metadata
+                        )
+                        expected_array = pa.array(expected, type=selected.type)
+                        assert isinstance(expected_array, pa.StructArray)
+                        self.assertTrue(selected.equals(expected_array))
+
+    def test_selected_keys_reject_out_of_range_candidates(self):
+        for rows in (1023, 1024):
+            for physical_index in (-1, 1):
+                with self.subTest(rows=rows, physical_index=physical_index):
+                    # Keep adjacent rows in the backing array so an invalid
+                    # candidate can cross a row boundary without take failing.
+                    physical = pa.StructArray.from_arrays(
+                        [
+                            pa.array([[0]] * (rows + 2), 
type=pa.list_(pa.int32())),
+                            pa.array(["value"] * (rows + 2)),
+                        ],
+                        names=["__field_mapping", 
"__col_{}".format(physical_index)],
+                    ).slice(1, rows)
+                    with self.assertRaisesRegex(ValueError, "physical column 
.* out of range"):
+                        assemble_shared_shredding_selected_keys(
+                            physical, ["k"], pa.string(),
+                            ({0: "k"}, {0: [physical_index]}, set(), 1),
+                        )
 
     def setUp(self):
         self.tmp = tempfile.mkdtemp()

Reply via email to