This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new 44daf0131f7b [SPARK-58024][PYTHON] Convert Arrow struct and map
columns to Python rows in bulk
44daf0131f7b is described below
commit 44daf0131f7bd8be5e4c6864e19d73b0dd078dca
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Fri Jul 10 22:40:05 2026 -0700
[SPARK-58024][PYTHON] Convert Arrow struct and map columns to Python rows
in bulk
### What changes were proposed in this pull request?
Follow-up of SPARK-58019 (#57099); **only the last commit is new — this PR
is stacked on #57099 and will be rebased once it merges.** (Originally stacked
on SPARK-58023 (#57104), which has been closed in favor of the upstream
apache/arrow#50326 fix; the numbers below are measured without it.)
Extend `ArrowTableToRowsConversion._to_pylist` with bulk paths for struct
and map columns:
- **Struct** columns convert each child field in bulk (recursively reusing
the bulk list paths), then zip the field values into one dict per row, masked
by the validity bitmap. Duplicate field names fall back to `to_pylist`, so they
raise the same `ValueError` that `StructScalar.as_py` raises.
- **Map** columns share the list offsets layout: the flattened keys and
items children are each converted in bulk, and every row becomes a list of
`(key, value)` tuples, matching `MapScalar.as_py` exactly.
### Why are the changes needed?
Struct and map columns still convert one Scalar per row; per-row
Scalar/wrapper allocation dominates (apache/arrow#50326), and maps are the
worst case since every row also wraps its keys/items in per-row Arrays. ASV
microbenchmark (`bench_arrow.ArrowStructMapColumnToRowsBenchmark`, 1M rows, 10%
nulls, PyArrow 24.0.0):
| case | `to_pylist()` | this PR | speedup |
|---|---|---|---|
| `struct<int64,string>` | 970 ms | 474 ms | 2.0x |
| `map<string,int64>` (2 entries/row) | 2.16 s | 799 ms | 2.7x |
Peak memory unchanged.
### Does this PR introduce _any_ user-facing change?
No. Only performance; conversion results are identical (covered by
exact-type tests).
### How was this patch tested?
Extended `ArrowColumnToPylistTests` with struct (incl. nested struct/list
children, empty struct, all-null), map (incl. list values), struct-of-map,
list-of-struct, sliced and chunked views — all compared against
`column.to_pylist()` with exact element-type assertions — plus dedicated tests
that duplicate struct field names still raise `ValueError` and that
empty-struct rows are distinct dict objects. New ASV benchmark class
`ArrowStructMapColumnToRowsBenchmark`.
### Was this patch authored or co-authored using generative AI tooling?
Yes. This pull request and its description were written by Isaac (Claude
Code).
Closes #57105 from viirya/arrow-to-pylist-maps-structs.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
(cherry picked from commit 9c6e57bf7e0c6f5ab4870d28611449d36ab7c99c)
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
python/benchmarks/bench_arrow.py | 43 ++++++++++++++++++
python/pyspark/sql/conversion.py | 68 ++++++++++++++++++++++++++---
python/pyspark/sql/tests/test_conversion.py | 46 +++++++++++++++++++
3 files changed, 152 insertions(+), 5 deletions(-)
diff --git a/python/benchmarks/bench_arrow.py b/python/benchmarks/bench_arrow.py
index b59a43d42072..4ed1c91d579c 100644
--- a/python/benchmarks/bench_arrow.py
+++ b/python/benchmarks/bench_arrow.py
@@ -170,3 +170,46 @@ class ArrowListColumnToRowsBenchmark:
def peakmem_array_of_structs_to_rows(self, n_rows, method):
self.convert(self.array_of_structs)
+
+
+class ArrowStructMapColumnToRowsBenchmark:
+ """
+ Benchmark for converting Arrow struct and map columns to Python rows.
+
+ ``baseline`` measures plain ``column.to_pylist()``; ``bulk`` measures
+ ``ArrowTableToRowsConversion._to_pylist`` with the struct/map bulk paths.
+ """
+
+ params = [
+ [100000, 1000000],
+ ["baseline", "bulk"],
+ ]
+ param_names = ["n_rows", "method"]
+
+ def setup(self, n_rows, method):
+ from pyspark.sql.conversion import ArrowTableToRowsConversion
+
+ self.structs = pa.array(
+ [{"a": i, "b": f"s{i}"} if i % 10 != 0 else None for i in
range(n_rows)],
+ type=pa.struct([("a", pa.int64()), ("b", pa.string())]),
+ )
+ self.maps = pa.array(
+ [
+ [(f"k{i % 3}", i), (f"q{i % 5}", i + 1)] if i % 10 != 0 else
None
+ for i in range(n_rows)
+ ],
+ type=pa.map_(pa.string(), pa.int64()),
+ )
+ if method == "bulk":
+ self.convert = ArrowTableToRowsConversion._to_pylist
+ else:
+ self.convert = lambda column: column.to_pylist()
+
+ def time_structs_to_rows(self, n_rows, method):
+ self.convert(self.structs)
+
+ def time_maps_to_rows(self, n_rows, method):
+ self.convert(self.maps)
+
+ def peakmem_structs_to_rows(self, n_rows, method):
+ self.convert(self.structs)
diff --git a/python/pyspark/sql/conversion.py b/python/pyspark/sql/conversion.py
index e22915b5a065..0550ff4bb56f 100644
--- a/python/pyspark/sql/conversion.py
+++ b/python/pyspark/sql/conversion.py
@@ -947,8 +947,11 @@ class ArrowTableToRowsConversion:
@staticmethod
def _to_pylist(column: Union["pa.Array", "pa.ChunkedArray"]) -> List[Any]:
"""
- Equivalent to ``column.to_pylist()``, but converts (nested) list
columns in bulk
- instead of one scalar at a time.
+ Equivalent to ``column.to_pylist()``, but converts (nested) list,
struct and map
+ columns in bulk instead of one scalar at a time. Structs become dicts
(with
+ a fallback to ``to_pylist`` for duplicate field names, which raise
``ValueError``
+ there) and maps become lists of ``(key, value)`` tuples, matching
+ ``StructScalar.as_py`` and ``MapScalar.as_py`` exactly.
Internal helper for the worker and ``convert`` call sites; do not use
externally.
@@ -979,9 +982,44 @@ class ArrowTableToRowsConversion:
result.extend(ArrowTableToRowsConversion._to_pylist(chunk))
return result
- if (pa.types.is_list(column.type) or
pa.types.is_large_list(column.type)) and len(
- column
- ) > 0:
+ if len(column) == 0:
+ return []
+
+ if pa.types.is_map(column.type):
+ # Maps have the same offsets layout as lists; each row becomes a
+ # list of (key, value) tuples, matching MapScalar.as_py.
+ n = len(column)
+ offsets = column.offsets.to_numpy(zero_copy_only=True).tolist()
+ start = offsets[0]
+ length = offsets[-1] - start
+ keys =
ArrowTableToRowsConversion._to_pylist(column.keys.slice(start, length))
+ items =
ArrowTableToRowsConversion._to_pylist(column.items.slice(start, length))
+ if column.null_count == 0:
+ return [
+ list(
+ zip(
+ keys[offsets[i] - start : offsets[i + 1] - start],
+ items[offsets[i] - start : offsets[i + 1] - start],
+ )
+ )
+ for i in range(n)
+ ]
+ valid = column.is_valid().to_numpy(zero_copy_only=False).tolist()
+ return [
+ (
+ list(
+ zip(
+ keys[offsets[i] - start : offsets[i + 1] - start],
+ items[offsets[i] - start : offsets[i + 1] - start],
+ )
+ )
+ if valid[i]
+ else None
+ )
+ for i in range(n)
+ ]
+
+ elif pa.types.is_list(column.type) or
pa.types.is_large_list(column.type):
n = len(column)
# List offset buffers never carry a validity bitmap, so this
conversion is
# always zero-copy; zero_copy_only=True asserts that invariant and
would
@@ -999,6 +1037,26 @@ class ArrowTableToRowsConversion:
for i in range(n)
]
+ elif pa.types.is_struct(column.type):
+ n = len(column)
+ names = [column.type.field(i).name for i in
range(column.type.num_fields)]
+ if len(set(names)) != len(names):
+ # StructScalar.as_py raises ValueError on duplicate field
names;
+ # let the generic path surface the same error.
+ return column.to_pylist()
+ fields = [
+ ArrowTableToRowsConversion._to_pylist(column.field(i))
+ for i in range(column.type.num_fields)
+ ]
+ if column.null_count == 0:
+ if not names:
+ return [{} for _ in range(n)]
+ return [dict(zip(names, row)) for row in zip(*fields)]
+ valid = column.is_valid().to_numpy(zero_copy_only=False).tolist()
+ if not names:
+ return [{} if m else None for m in valid]
+ return [dict(zip(names, row)) if m else None for row, m in
zip(zip(*fields), valid)]
+
return column.to_pylist()
@staticmethod
diff --git a/python/pyspark/sql/tests/test_conversion.py
b/python/pyspark/sql/tests/test_conversion.py
index c5ce79b9d188..872a0c1e248f 100644
--- a/python/pyspark/sql/tests/test_conversion.py
+++ b/python/pyspark/sql/tests/test_conversion.py
@@ -15,6 +15,7 @@
# limitations under the License.
#
import datetime
+import decimal
import unittest
import unittest.mock
from zoneinfo import ZoneInfo
@@ -843,6 +844,37 @@ class ArrowColumnToPylistTests(unittest.TestCase):
pa.array([], type=pa.list_(pa.int32())),
pa.array([None, None], type=pa.list_(pa.string())),
pa.array([[1, 2], None], type=pa.list_(pa.int64(), 2)),
+ # non-list leaves keep as_py semantics (native to_pylist)
+ pa.array([b"", None, b"\x00\xff"], type=pa.binary()),
+ pa.array([datetime.date(2020, 1, 2), None], type=pa.date32()),
+ pa.array([decimal.Decimal("1.23"), None], type=pa.decimal128(10,
2)),
+ pa.array([[b"x", None], None, [b""]], type=pa.list_(pa.binary())),
+ pa.array([[True, None], [False]], type=pa.list_(pa.bool_())),
+ # struct and map bulk paths
+ pa.array(
+ [{"a": 1, "b": "x"}, None, {"a": None, "b": None}],
+ type=pa.struct([("a", pa.int64()), ("b", pa.string())]),
+ ),
+ pa.array(
+ [{"s": {"a": 1}, "l": [1, None]}, None],
+ type=pa.struct(
+ [("s", pa.struct([("a", pa.int32())])), ("l",
pa.list_(pa.int64()))]
+ ),
+ ),
+ pa.array([{}, None, {}], type=pa.struct([])),
+ pa.array([None] * 4, type=pa.struct([("a", pa.int32())])),
+ pa.array(
+ [[("k1", [1, None]), ("k2", None)], None, []],
+ type=pa.map_(pa.string(), pa.list_(pa.int32())),
+ ),
+ pa.array(
+ [{"m": [("k", 1)]}, None],
+ type=pa.struct([("m", pa.map_(pa.string(), pa.int64()))]),
+ ),
+ pa.array(
+ [[{"a": 1}, None], None],
+ type=pa.list_(pa.struct([("a", pa.int64())])),
+ ),
]
for column in columns:
views = [column, column.slice(1), column.slice(0, max(len(column)
- 1, 0))]
@@ -866,6 +898,20 @@ class ArrowColumnToPylistTests(unittest.TestCase):
self.assertEqual(result, [[1, None, 3]])
self.assertEqual([type(v) for v in result[0]], [int, type(None), int])
+ def test_struct_duplicate_field_names_still_raises(self):
+ import pyarrow as pa
+
+ dup = pa.StructArray.from_arrays([pa.array([1, 2]), pa.array(["a",
"b"])], names=["x", "x"])
+ with self.assertRaises(ValueError):
+ ArrowTableToRowsConversion._to_pylist(dup)
+
+ def test_struct_rows_are_distinct_dicts(self):
+ import pyarrow as pa
+
+ result = ArrowTableToRowsConversion._to_pylist(pa.array([{}, {}],
type=pa.struct([])))
+ self.assertEqual(result, [{}, {}])
+ self.assertIsNot(result[0], result[1])
+
def test_convert_table_with_list_columns(self):
import pyarrow as pa
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]