Skip to content

[SPARK-58019][PYTHON] Convert Arrow list columns to Python rows in bulk#57099

Closed
viirya wants to merge 9 commits into
apache:masterfrom
viirya:arrow-to-pylist-shim
Closed

[SPARK-58019][PYTHON] Convert Arrow list columns to Python rows in bulk#57099
viirya wants to merge 9 commits into
apache:masterfrom
viirya:arrow-to-pylist-shim

Conversation

@viirya

@viirya viirya commented Jul 7, 2026

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Add ArrowTableToRowsConversion._to_pylist, which converts Arrow list-typed columns to Python values in bulk: the flattened child values are converted with a single recursive to_pylist call, and the resulting Python list is sliced per row using the offsets and the validity bitmap. It is used in the Arrow-to-rows conversion paths (Spark Connect collect(), Arrow batch UDF inputs, Arrow UDTF inputs). Non-list columns, map columns and environments without NumPy fall back to plain column.to_pylist().

Leaf values are still converted by Arrow's own to_pylist, so results are exactly identical to column.to_pylist(): None stays None and values inside numeric lists stay Python ints. NumPy is used only for the offsets (non-null integers) and the validity bitmap (booleans), never for the values, so the type coercion problems of a pandas round trip (list<int32> of [1, None, 3] becoming [1.0, nan, 3.0]) cannot occur.

This is an interim measure for a PyArrow-side inefficiency: Array.to_pylist() materializes one Scalar per element, and for list types each row additionally allocates a C++ scalar, a Python Scalar wrapper and a Python Array wrapper before converting elements one by one (root-caused in apache/arrow#50326, fix proposed in apache/arrow#50327). The helper documents this and can be removed once the minimum supported PyArrow version includes the upstream fix.

Why are the changes needed?

Converting Arrow list columns to Python rows is the hot path of Arrow-optimized Python UDF inputs and Spark Connect collect(). With plain to_pylist() it is several times slower than necessary, which caused a performance regression on array columns when Arrow serialization became the default for regular Python UDFs.

ASV microbenchmark (python/benchmarks/bench_arrow.py::ArrowListColumnToRowsBenchmark, added in this PR; 1M rows, macOS arm64, PyArrow 24.0.0):

case to_pylist() this PR speedup
list<string> 769 ms 507 ms 1.5x
list<list<int32>> with nulls 1.86 s 537 ms 3.5x

Peak memory is unchanged.

Does this PR introduce any user-facing change?

No. Only performance; conversion results are byte-identical (covered by exact-type tests).

How was this patch tested?

New ArrowColumnToPylistTests in python/pyspark/sql/tests/test_conversion.py, comparing _to_pylist against column.to_pylist() with exact element-type assertions across list/large_list/nested/struct/map/fixed-size-list columns, sliced and chunked variants, plus a dedicated test that list<int32> of [1, None, 3] stays ints, and an end-to-end ArrowTableToRowsConversion.convert test with array columns. Full test_conversion.py passes. The new ASV benchmark class parametrizes baseline vs bulk, so the comparison above is reproducible with ./python/asv run --python=same --quick -b 'bench_arrow.ArrowListColumnToRowsBenchmark'.

Was this patch authored or co-authored using generative AI tooling?

Yes. This pull request and its description were written by Isaac (Claude Code).

PyArrow's Array.to_pylist() materializes one Scalar per element; for
list-typed columns each row additionally allocates a C++ scalar, a
Python Scalar wrapper and a Python Array wrapper before converting
elements one by one. This makes Arrow-optimized Python UDF inputs and
Spark Connect collect() several times slower on array columns than
necessary (apache/arrow#50326; a fix is proposed upstream in
apache/arrow#50327 but will only be available in a future PyArrow
release).

Add ArrowTableToRowsConversion._to_pylist, which converts the flattened
child values of a list column in a single pass and slices the resulting
Python list per row using the offsets and the validity bitmap, and use
it in the Arrow-to-rows conversion paths (Spark Connect collect, Arrow
batch UDF inputs, Arrow UDTF inputs). Leaf values are still converted by
Arrow's own to_pylist, so results are exactly identical - None stays
None and values inside numeric lists stay Python ints, unlike a pandas
round trip which coerces them to floats/NaN. NumPy is only used for the
offsets and validity bitmap, never for the values.

ASV microbenchmark (python/benchmarks/bench_arrow.py, 1M rows):
list<string> 769ms -> 507ms (1.5x); list<list<int32>> with nulls
1.86s -> 537ms (3.5x). Peak memory unchanged.

The helper can be removed once the minimum supported PyArrow version
includes the upstream fix.

Co-authored-by: Isaac
@HyukjinKwon

Copy link
Copy Markdown
Member

cc @Yicong-Huang FYI

@dbtsai dbtsai left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Nice, well-scoped optimization. I traced the correctness-critical _to_pylist path against to_pylist() semantics and it holds up on the edges that matter:

  • Sliced arrays: column.values returns the full unsliced child and column.offsets are absolute into it, so reading start = offsets[0], slicing values.slice(start, offsets[-1]-start), and rebasing every per-row slice by -start is correct. Using .values rather than .flatten() is the right call here -- .flatten() would drop null-slot ranges and break the rebasing.
  • Null slots: Arrow does not guarantee a null list slot has an empty offset range, and keying None-ness off the validity bitmap (valid[i]) rather than off an empty range handles that correctly.
  • Exact types: leaf values still go through Arrow's own to_pylist; NumPy only touches offsets and the validity bitmap, so list<int32> [1, None, 3] stays ints -- the pandas round-trip float/NaN coercion is genuinely avoided.
  • Fallbacks: map / fixed_size_list / list_view don't match is_list/is_large_list and fall through to to_pylist(). list_view is the important one (non-monotonic offsets would break the slicing), and it correctly does not take the fast path.

The test matrix -- comparing _to_pylist vs to_pylist() with exact element-type assertions across list/large_list/nested/struct/map/fixed_size, and across sliced and chunked views -- is exactly what's needed for a function that reimplements Arrow semantics.

Approve from my side. Two minor comments inline, both non-blocking.

column
) > 0:
n = len(column)
offsets = column.offsets.to_numpy(zero_copy_only=True).tolist()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: zero_copy_only=True here is load-bearing and works only because list offset buffers never carry a validity bitmap. That invariant is correct, but non-obvious -- a one-line comment would stop a future reader from "fixing" it to zero_copy_only=False (and would flag the assumption if some new Arrow list variant ever violated it).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — added the comment in eebbc4f. zero_copy_only=True also doubles as an assertion: if a future Arrow list variant ever produced offsets with a validity bitmap, this would fail loudly instead of silently copying.

if column.null_count == 0:
return [flat[offsets[i] - start : offsets[i + 1] - start] for i in range(n)]
valid = column.is_valid().to_numpy(zero_copy_only=False).tolist()
return [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (no change needed): the per-row slicing here is still O(rows) Python (offsets.tolist() + a comprehension slicing flat), so the win is fewer object allocations, not vectorization -- consistent with the benchmark numbers. Fine as the documented interim tradeoff; noting it so the "bulk" framing isn't read as fully vectorized.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — row assembly is still an O(rows) Python loop; the win is that each row costs one list slice instead of a C++ scalar + Python Scalar wrapper + Python Array wrapper + generator. "Bulk" refers to the child-values conversion being a single call, not to vectorized row assembly. The fully vectorized fix is the upstream one (apache/arrow#50327); this stays intentionally simple as the interim.

viirya added 2 commits July 7, 2026 18:11
The files were formatted with black 26.3.1 (dev/requirements.txt), but
the CI linter checks with ruff format, which disagrees on hunks
unrelated to this change. Reformat with ruff 0.14.8 to match CI.

Co-authored-by: Isaac
@@ -844,6 +844,83 @@ def test_geometry_convert_numpy(self):
self.assertEqual(len(result), 0)


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need skipIf like the other arrow-based test cases.

Suggested change
@unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added in 784974c, thanks!

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, LGTM (with one comment about skipIf).

Comment thread python/pyspark/sql/conversion.py Outdated
Comment thread python/benchmarks/bench_arrow.py
…<struct> benchmarks

Address review: cache the NumPy availability check in a module-level
helper instead of re-running try/import on every (recursive)
invocation, and extend the ASV benchmark with an array<struct> case
plus peakmem variants for the nested cases (1M rows: nested list
862M -> 862M, array<struct> 885M -> 921M, ~4% peak increase from the
transient flattened pointer list; leaf objects are shared).

Co-authored-by: Isaac
…out per-element Scalars

Spark 4.2 ships with this code frozen, so gate the pure-Python bulk
conversion on the installed PyArrow version: releases containing the
apache/arrow#50326 fix (planned for 25.0.1) convert natively without
per-element Scalars and keep improving (apache/arrow#50448), so
_to_pylist short-circuits to column.to_pylist() there and only uses
the bulk paths on older PyArrow. The version constant can be bumped
in a patch release if the fix ships elsewhere. Tests force the gate
off so the bulk paths stay covered on any PyArrow, plus a gate
pass-through test.

Co-authored-by: Isaac
@viirya

viirya commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Added one more commit (2e405eb) after discussion: since Spark 4.2 ships with this code frozen, _to_pylist now checks the installed PyArrow version and short-circuits to the native column.to_pylist() when it contains the apache/arrow#50326 fix (planned for PyArrow 25.0.1 per the dev-list discussion). This way Spark 4.2 users automatically pick up the native fast path — and any future PyArrow-side improvements — just by upgrading PyArrow, while older PyArrow keeps the bulk conversion here. The version constant is documented and can be bumped in a patch release if the fix ships in a different version. Tests pin the gate off so the bulk paths stay covered regardless of the CI's PyArrow.

@viirya viirya requested a review from gaogaotiantian July 10, 2026 02:09
@viirya

viirya commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

I will go to merge this. @gaogaotiantian Do you want to do a review before that?

@gaogaotiantian

Copy link
Copy Markdown
Contributor

Please give me some time to review this. Will do this asap.

@huaxingao

Copy link
Copy Markdown
Contributor

Thanks @viirya for the fix! I will cut RC6 as soon as this is in.

Comment thread python/pyspark/sql/conversion.py Outdated
return _pyarrow_native_to_pylist_is_fast


_numpy_available: Optional[bool] = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change to has_numpy and np to align with other files? for reference: #57163

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the cached flag to has_numpy in 8e24c2f to align with #57163. I didn't cache an np module global though: unlike stateful_processor_api_client.py, this file never references the numpy module directly — numpy is only needed because pyarrow.Array.to_numpy() requires it — so np would have no user here.

@gaogaotiantian gaogaotiantian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I think this approach is good and the code is maintainable (also discardable in the future). The ROI of the patch is nice.

I have a few suggestions.

  1. Move all the numpy and pyarrow version check into a staticmethod of LocalDataToArrowConversion. Name it something like _should_manual_bulk() (or whatever you think is proper). Do a @functools.cache on the function so the actual import path is only triggered once. Let's leave no garbage in the module level block so it's easier to cleanly remove it in the future.
  2. Comment wise, for both method, let's emphasize that this is an internal function that should not be used externally, and it should (not can) be removed when the minumum version contains the patch.

Comment thread python/pyspark/sql/conversion.py Outdated
result.extend(ArrowTableToRowsConversion._to_pylist(chunk))
return result

column_type = column.type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is really necessary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is actually needed: ArrowTableToRowsConversion.convert passes table.columns, and pa.Table.columns are ChunkedArrays (the Spark Connect collect path). Without this branch a chunked list column falls into the list path and fails with AttributeError: 'pyarrow.lib.ChunkedArray' object has no attribute 'offsets'. The worker.py call sites pass RecordBatch columns (plain Arrays), so only the convert path exercises it; the chunked variants in ArrowColumnToPylistTests cover it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant assigning column.type to column_type. I think it's also some optimization that tries to save attribute access cost.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I misread your comment as being about the ChunkedArray block above. Removed in a96c82bcolumn.type is used inline now.

Comment thread python/pyspark/sql/conversion.py Outdated
Comment thread python/pyspark/sql/conversion.py Outdated
…undant annotation, align has_numpy naming

Co-authored-by: Isaac
@viirya

viirya commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Thanks @huaxingao! Addressing review comments now — will keep this moving so RC6 isn't blocked.

…cached _should_manual_bulk staticmethod

Co-authored-by: Isaac
@viirya

viirya commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

@gaogaotiantian Both done in 5ce205e:

  1. The module-level block is gone — the version and NumPy checks are folded into a single ArrowTableToRowsConversion._should_manual_bulk() staticmethod with @functools.cache (I put it on ArrowTableToRowsConversion since that's the class that owns _to_pylist). Tests patch the method directly instead of poking module globals, so removing the whole thing later is a clean two-site deletion.
  2. Both docstrings now state the helpers are internal-only and that they (together with the manual bulk paths) should be removed once the minimum supported PyArrow version contains the fix.

@viirya viirya closed this in 484342a Jul 10, 2026
viirya added a commit that referenced this pull request Jul 10, 2026
### What changes were proposed in this pull request?

Add `ArrowTableToRowsConversion._to_pylist`, which converts Arrow list-typed columns to Python values in bulk: the flattened child values are converted with a single recursive `to_pylist` call, and the resulting Python list is sliced per row using the offsets and the validity bitmap. It is used in the Arrow-to-rows conversion paths (Spark Connect `collect()`, Arrow batch UDF inputs, Arrow UDTF inputs). Non-list columns, map columns and environments without NumPy fall back to plain `column.to_pylist()`.

Leaf values are still converted by Arrow's own `to_pylist`, so results are exactly identical to `column.to_pylist()`: `None` stays `None` and values inside numeric lists stay Python ints. NumPy is used only for the offsets (non-null integers) and the validity bitmap (booleans), never for the values, so the type coercion problems of a pandas round trip (`list<int32>` of `[1, None, 3]` becoming `[1.0, nan, 3.0]`) cannot occur.

This is an interim measure for a PyArrow-side inefficiency: `Array.to_pylist()` materializes one Scalar per element, and for list types each row additionally allocates a C++ scalar, a Python Scalar wrapper and a Python Array wrapper before converting elements one by one (root-caused in apache/arrow#50326, fix proposed in apache/arrow#50327). The helper documents this and can be removed once the minimum supported PyArrow version includes the upstream fix.

### Why are the changes needed?

Converting Arrow list columns to Python rows is the hot path of Arrow-optimized Python UDF inputs and Spark Connect `collect()`. With plain `to_pylist()` it is several times slower than necessary, which caused a performance regression on array columns when Arrow serialization became the default for regular Python UDFs.

ASV microbenchmark (`python/benchmarks/bench_arrow.py::ArrowListColumnToRowsBenchmark`, added in this PR; 1M rows, macOS arm64, PyArrow 24.0.0):

| case | `to_pylist()` | this PR | speedup |
|---|---|---|---|
| `list<string>` | 769 ms | 507 ms | 1.5x |
| `list<list<int32>>` with nulls | 1.86 s | 537 ms | 3.5x |

Peak memory is unchanged.

### Does this PR introduce _any_ user-facing change?

No. Only performance; conversion results are byte-identical (covered by exact-type tests).

### How was this patch tested?

New `ArrowColumnToPylistTests` in `python/pyspark/sql/tests/test_conversion.py`, comparing `_to_pylist` against `column.to_pylist()` with exact element-type assertions across list/large_list/nested/struct/map/fixed-size-list columns, sliced and chunked variants, plus a dedicated test that `list<int32>` of `[1, None, 3]` stays ints, and an end-to-end `ArrowTableToRowsConversion.convert` test with array columns. Full `test_conversion.py` passes. The new ASV benchmark class parametrizes `baseline` vs `bulk`, so the comparison above is reproducible with `./python/asv run --python=same --quick -b 'bench_arrow.ArrowListColumnToRowsBenchmark'`.

### 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 #57099 from viirya/arrow-to-pylist-shim.

Authored-by: Liang-Chi Hsieh <viirya@gmail.com>
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
(cherry picked from commit 484342a)
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
viirya added a commit that referenced this pull request Jul 10, 2026
### What changes were proposed in this pull request?

Add `ArrowTableToRowsConversion._to_pylist`, which converts Arrow list-typed columns to Python values in bulk: the flattened child values are converted with a single recursive `to_pylist` call, and the resulting Python list is sliced per row using the offsets and the validity bitmap. It is used in the Arrow-to-rows conversion paths (Spark Connect `collect()`, Arrow batch UDF inputs, Arrow UDTF inputs). Non-list columns, map columns and environments without NumPy fall back to plain `column.to_pylist()`.

Leaf values are still converted by Arrow's own `to_pylist`, so results are exactly identical to `column.to_pylist()`: `None` stays `None` and values inside numeric lists stay Python ints. NumPy is used only for the offsets (non-null integers) and the validity bitmap (booleans), never for the values, so the type coercion problems of a pandas round trip (`list<int32>` of `[1, None, 3]` becoming `[1.0, nan, 3.0]`) cannot occur.

This is an interim measure for a PyArrow-side inefficiency: `Array.to_pylist()` materializes one Scalar per element, and for list types each row additionally allocates a C++ scalar, a Python Scalar wrapper and a Python Array wrapper before converting elements one by one (root-caused in apache/arrow#50326, fix proposed in apache/arrow#50327). The helper documents this and can be removed once the minimum supported PyArrow version includes the upstream fix.

### Why are the changes needed?

Converting Arrow list columns to Python rows is the hot path of Arrow-optimized Python UDF inputs and Spark Connect `collect()`. With plain `to_pylist()` it is several times slower than necessary, which caused a performance regression on array columns when Arrow serialization became the default for regular Python UDFs.

ASV microbenchmark (`python/benchmarks/bench_arrow.py::ArrowListColumnToRowsBenchmark`, added in this PR; 1M rows, macOS arm64, PyArrow 24.0.0):

| case | `to_pylist()` | this PR | speedup |
|---|---|---|---|
| `list<string>` | 769 ms | 507 ms | 1.5x |
| `list<list<int32>>` with nulls | 1.86 s | 537 ms | 3.5x |

Peak memory is unchanged.

### Does this PR introduce _any_ user-facing change?

No. Only performance; conversion results are byte-identical (covered by exact-type tests).

### How was this patch tested?

New `ArrowColumnToPylistTests` in `python/pyspark/sql/tests/test_conversion.py`, comparing `_to_pylist` against `column.to_pylist()` with exact element-type assertions across list/large_list/nested/struct/map/fixed-size-list columns, sliced and chunked variants, plus a dedicated test that `list<int32>` of `[1, None, 3]` stays ints, and an end-to-end `ArrowTableToRowsConversion.convert` test with array columns. Full `test_conversion.py` passes. The new ASV benchmark class parametrizes `baseline` vs `bulk`, so the comparison above is reproducible with `./python/asv run --python=same --quick -b 'bench_arrow.ArrowListColumnToRowsBenchmark'`.

### 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 #57099 from viirya/arrow-to-pylist-shim.

Authored-by: Liang-Chi Hsieh <viirya@gmail.com>
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
(cherry picked from commit 484342a)
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
@viirya

viirya commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Merge Summary:

Posted by merge_spark_pr.py

@viirya viirya deleted the arrow-to-pylist-shim branch July 10, 2026 23:25
@viirya

viirya commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Thanks @dbtsai @dongjoon-hyun @Yicong-Huang @huaxingao @gaogaotiantian

viirya added a commit that referenced this pull request Jul 11, 2026
…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 <viirya@gmail.com>
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
viirya added a commit that referenced this pull request Jul 11, 2026
…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 <viirya@gmail.com>
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
(cherry picked from commit 9c6e57b)
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
viirya added a commit that referenced this pull request Jul 11, 2026
…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 <viirya@gmail.com>
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
(cherry picked from commit 9c6e57b)
Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants