Skip to content

Commit 136e63c

Browse files
committed
[SPARK-58023][PYTHON] Fast paths for converting Arrow string, binary, numeric and boolean columns to Python rows
Extend ArrowTableToRowsConversion._to_pylist with leaf fast paths: * string/large_string/binary/large_binary columns use Arrow's object-dtype NumPy conversion, which produces exactly str/bytes and None - no other type can come out of it. * integer/float32/float64/boolean columns without nulls convert via a zero-copy (except bit-packed booleans) NumPy view and ndarray.tolist, which materializes exact Python ints/floats/bools. With nulls, the values are filled with a placeholder first (pc.fill_null) and nulls are restored to None from the validity bitmap, so ints are always materialized from the original int buffer, never through a float representation. Types whose as_py returns non-primitive objects (dates, timestamps, decimals, ...) keep using to_pylist. Since list columns convert their flattened child values through _to_pylist, list-typed columns get the leaf speedup on top of the bulk offsets slicing. ASV microbenchmark (bench_arrow.ArrowLeafColumnToRowsBenchmark, 1M rows): string with nulls 196ms -> 20ms (9.7x); int64 with nulls 99ms -> 28ms (3.6x); float64 without nulls 100ms -> 9ms (11x). End-to-end list<string> conversion (ArrowListColumnToRowsBenchmark, 1M rows) improves from 507ms to 118ms on top of SPARK-58019. Co-authored-by: Isaac
1 parent 691885a commit 136e63c

3 files changed

Lines changed: 108 additions & 10 deletions

File tree

python/benchmarks/bench_arrow.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,46 @@ def time_nested_ints_with_nulls_to_rows(self, n_rows, method):
154154

155155
def peakmem_list_of_strings_to_rows(self, n_rows, method):
156156
self.convert(self.list_of_strings)
157+
158+
159+
class ArrowLeafColumnToRowsBenchmark:
160+
"""
161+
Benchmark for converting flat (leaf) Arrow columns to Python rows.
162+
163+
``baseline`` measures plain ``column.to_pylist()``; ``bulk`` measures
164+
``ArrowTableToRowsConversion._to_pylist`` with the string/binary/numeric
165+
fast paths.
166+
"""
167+
168+
params = [
169+
[100000, 1000000],
170+
["baseline", "bulk"],
171+
]
172+
param_names = ["n_rows", "method"]
173+
174+
def setup(self, n_rows, method):
175+
from pyspark.sql.conversion import ArrowTableToRowsConversion
176+
177+
self.strings = pa.array(
178+
[f"s{i}" if i % 10 != 0 else None for i in range(n_rows)], type=pa.string()
179+
)
180+
self.longs_with_nulls = pa.array(
181+
[i if i % 10 != 0 else None for i in range(n_rows)], type=pa.int64()
182+
)
183+
self.doubles = pa.array([float(i) for i in range(n_rows)], type=pa.float64())
184+
if method == "bulk":
185+
self.convert = ArrowTableToRowsConversion._to_pylist
186+
else:
187+
self.convert = lambda column: column.to_pylist()
188+
189+
def time_strings_with_nulls_to_rows(self, n_rows, method):
190+
self.convert(self.strings)
191+
192+
def time_longs_with_nulls_to_rows(self, n_rows, method):
193+
self.convert(self.longs_with_nulls)
194+
195+
def time_doubles_to_rows(self, n_rows, method):
196+
self.convert(self.doubles)
197+
198+
def peakmem_strings_with_nulls_to_rows(self, n_rows, method):
199+
self.convert(self.strings)

python/pyspark/sql/conversion.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -984,18 +984,23 @@ class ArrowTableToRowsConversion:
984984
def _to_pylist(column: Union["pa.Array", "pa.ChunkedArray"]) -> List[Any]:
985985
"""
986986
Equivalent to ``column.to_pylist()``, but converts (nested) list columns in bulk
987-
instead of one scalar at a time.
987+
instead of one scalar at a time, with fast paths for string, binary, integral,
988+
floating point and boolean leaves.
988989
989990
``Array.to_pylist()`` materializes one Scalar per element; for list types each row
990991
additionally allocates a C++ scalar, a Python Scalar wrapper and a Python Array
991992
wrapper for the row's values before converting elements one by one, which is
992993
several times slower than converting the flattened child values in a single pass
993-
and slicing the resulting Python list per row (see apache/arrow#50326). The values
994-
themselves are still converted by Arrow's own ``to_pylist``, so results are exactly
995-
identical: ``None`` stays ``None`` and values inside numeric lists stay Python ints,
996-
unlike a pandas round trip which would coerce them to floats/NaN. NumPy is used
997-
only for the offsets (non-null integers) and the validity bitmap (booleans), so no
998-
value coercion can occur.
994+
and slicing the resulting Python list per row (see apache/arrow#50326). Results
995+
are exactly identical to ``to_pylist``: ``None`` stays ``None`` and values inside
996+
numeric lists stay Python ints, unlike a pandas round trip which would coerce
997+
them to floats/NaN. In particular the leaf fast paths cannot coerce: string and
998+
binary columns use Arrow's object-dtype conversion, which only produces ``str`` /
999+
``bytes`` / ``None``; nullable numeric and boolean columns are converted from the
1000+
original values (nulls filled with a placeholder and restored to ``None`` from the
1001+
validity bitmap afterwards), so ints are materialized from the int buffer, never
1002+
via a float representation. Types whose ``as_py`` returns non-primitive objects
1003+
(dates, timestamps, decimals, ...) keep using ``to_pylist``.
9991004
10001005
This can be removed once the minimum supported PyArrow version includes the fix
10011006
for apache/arrow#50326.
@@ -1014,10 +1019,38 @@ def _to_pylist(column: Union["pa.Array", "pa.ChunkedArray"]) -> List[Any]:
10141019
result.extend(ArrowTableToRowsConversion._to_pylist(chunk))
10151020
return result
10161021

1022+
if len(column) == 0:
1023+
return []
1024+
10171025
column_type = column.type
1018-
if (pa_types.is_list(column_type) or pa_types.is_large_list(column_type)) and len(
1019-
column
1020-
) > 0:
1026+
1027+
if (
1028+
pa_types.is_string(column_type)
1029+
or pa_types.is_large_string(column_type)
1030+
or pa_types.is_binary(column_type)
1031+
or pa_types.is_large_binary(column_type)
1032+
):
1033+
# The object-dtype conversion produces exactly str/bytes and None.
1034+
return column.to_numpy(zero_copy_only=False).tolist()
1035+
1036+
if (
1037+
pa_types.is_integer(column_type)
1038+
or pa_types.is_float32(column_type)
1039+
or pa_types.is_float64(column_type)
1040+
or pa_types.is_boolean(column_type)
1041+
):
1042+
# Booleans are bit-packed, so their conversion to NumPy is never zero-copy.
1043+
zero_copy = not pa_types.is_boolean(column_type)
1044+
if column.null_count == 0:
1045+
return column.to_numpy(zero_copy_only=zero_copy).tolist()
1046+
import pyarrow.compute as pc
1047+
1048+
valid = column.is_valid().to_numpy(zero_copy_only=False).tolist()
1049+
fill_value = False if pa_types.is_boolean(column_type) else 0
1050+
values = pc.fill_null(column, fill_value).to_numpy(zero_copy_only=zero_copy).tolist()
1051+
return [v if m else None for v, m in zip(values, valid)]
1052+
1053+
if pa_types.is_list(column_type) or pa_types.is_large_list(column_type):
10211054
n = len(column)
10221055
# List offset buffers never carry a validity bitmap, so this conversion is
10231056
# always zero-copy; zero_copy_only=True asserts that invariant and would

python/pyspark/sql/tests/test_conversion.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# limitations under the License.
1616
#
1717
import datetime
18+
import decimal
1819
import unittest
1920
from zoneinfo import ZoneInfo
2021

@@ -876,6 +877,27 @@ def test_matches_to_pylist(self):
876877
pa.array([], type=pa.list_(pa.int32())),
877878
pa.array([None, None], type=pa.list_(pa.string())),
878879
pa.array([[1, 2], None], type=pa.list_(pa.int64(), 2)),
880+
# leaf fast paths: exact str/bytes/int/float/bool types, None for nulls
881+
pa.array(["", None, "日本語", "\N{GRINNING FACE}", "x" * 40], type=pa.string()),
882+
pa.array(["a", None, ""], type=pa.large_string()),
883+
pa.array([b"", None, b"\x00\xff"], type=pa.binary()),
884+
pa.array([b"a", None], type=pa.large_binary()),
885+
pa.array([1, None, -(2**62), 3], type=pa.int64()),
886+
pa.array([0, None, 2**63 + 7], type=pa.uint64()),
887+
pa.array([-128, 127, None], type=pa.int8()),
888+
pa.array([1.5, None, float("nan"), float("inf")], type=pa.float64()),
889+
pa.array([1.5, None], type=pa.float32()),
890+
pa.array([True, None, False], type=pa.bool_()),
891+
pa.array([True, False] * 5, type=pa.bool_()),
892+
pa.array(list(range(10)), type=pa.int32()),
893+
# non-primitive leaves must keep as_py semantics (fallback path)
894+
pa.array([datetime.date(2020, 1, 2), None], type=pa.date32()),
895+
pa.array([datetime.datetime(2020, 1, 2, 3, 4, 5), None], type=pa.timestamp("us")),
896+
pa.array([decimal.Decimal("1.23"), None], type=pa.decimal128(10, 2)),
897+
# lists of fast-path leaves
898+
pa.array([[b"x", None], None, [b""]], type=pa.list_(pa.binary())),
899+
pa.array([[True, None], [False]], type=pa.list_(pa.bool_())),
900+
pa.array([[1.5, None], None], type=pa.list_(pa.float32())),
879901
]
880902
for column in columns:
881903
views = [column, column.slice(1), column.slice(0, max(len(column) - 1, 0))]

0 commit comments

Comments
 (0)