GH-50326: [Python] Convert arrays to Python objects without per-element Scalars in to_pylist#50327
GH-50326: [Python] Convert arrays to Python objects without per-element Scalars in to_pylist#50327viirya wants to merge 5 commits into
Conversation
…arrays Array.to_pylist() converts one element at a time: each row allocates a C++ Scalar (Array::GetScalar), a Python Scalar wrapper and, for list types, a Python Array wrapper for the row's values slice plus a fresh generator, before recursing per element. On top of the allocation cost itself, these GC-tracked wrappers repeatedly trigger collections that traverse the growing result list (~20% of runtime). This makes to_pylist on list-typed arrays several times slower than the bulk to_pandas conversion path. Add bulk to_pylist overrides: * ListArray / LargeListArray / FixedSizeListArray convert the referenced range of child values with a single recursive to_pylist call, then slice the resulting Python list per row using the raw offsets and the validity bitmap. MapArray keeps the generic scalar-based path (association-tuple / maps_as_pydicts duplicate-key semantics), as do the list-view types (overlapping views must not share sublist objects). * StringArray / LargeStringArray decode values directly from the data buffer (GetValue + PyUnicode_DecodeUTF8), matching StringScalar.as_py (= str(buf, 'utf8')) exactly. Semantics are unchanged; values inside numeric lists stay Python ints/None. Benchmarks (M4 Max, 2M rows of 2-element lists / 1M rows nested): list<string> 1.93s -> 0.34s, list<list<int32>> 2.10s -> 0.65s, flat string (4M) 0.83s -> 0.05s. Co-authored-by: Isaac
|
|
There was a problem hiding this comment.
Pull request overview
This PR improves pyarrow.Array.to_pylist() performance for list-like arrays and (large) string arrays by adding specialized bulk conversion implementations that avoid per-element Scalar allocation and wrapper overhead, while keeping output semantics unchanged.
Changes:
- Add bulk
to_pylist()implementations forListArray,LargeListArray, andFixedSizeListArraythat convert child values once and slice per row using offsets. - Add fast
to_pylist()implementations forStringArrayandLargeStringArraythat decode directly from the value buffer. - Add a new test validating bulk-path results against the scalar-based reference across nested, sliced, empty, and all-null inputs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| python/pyarrow/array.pxi | Adds type-specific to_pylist() fast paths for list-like and string arrays. |
| python/pyarrow/tests/test_array.py | Adds a regression/differential test to ensure bulk paths match scalar-based conversion. |
| n = arr.length() | ||
| result = [] | ||
| # Decode values straight from the data buffer instead of creating | ||
| # a C++ Scalar and a Python Scalar wrapper per value (see GH-28694). | ||
| if arr.null_count() == 0: | ||
| for i in range(n): | ||
| data = arr.GetValue(i, &length) | ||
| result.append( | ||
| cp.PyUnicode_DecodeUTF8(<const char*> data, length, NULL)) | ||
| else: | ||
| for i in range(n): | ||
| if arr.IsNull(i): | ||
| result.append(None) | ||
| else: | ||
| data = arr.GetValue(i, &length) | ||
| result.append( | ||
| cp.PyUnicode_DecodeUTF8(<const char*> data, length, NULL)) | ||
| return result |
There was a problem hiding this comment.
null_count() is a one-time vectorized popcount over the validity bitmap (~n/8 bytes, well under a millisecond for 2M rows), computed and cached per ArrayData. In exchange, the no-null branch skips the per-element IsNull() check entirely. Branching on null_bitmap_data() == NULL instead would save that single scan but degrade the common case of a sliced/combined array that has a bitmap yet contains no nulls in range — that would take the per-element IsNull() path forever. So the current form should be the better trade-off in practice.
|
I'm not an expert in Cython but curious about how It would be an interesting experiment to do a full-allocation for the list before assigning the data, as we already know the length of the list. An extra step forward is to declare the return value as a list in Cython so it can optimize Something like cdef list result = [None] * n
cdef Py_ssize_t i
for i in range(n):
result[i] = ...
return resultFor a long list this might push the performance even further. |
|
Good idea — I tried exactly that ( Two reasons, I think: Cython already lowers |
|
Okay if the benchmark is similar this is good. Would defining result as a list help? Like a cdef list for it and do append. Just curious whether cython knows it's a list already - maybe it does and that's why it's fast. |
|
Good question — Cython already knows. Its type inference marks |
I agree this is extremely wasteful, but the approach here seems very ad hoc and also solves the performance issue for a very limited set of types, while the performance problem is more general: In [5]: a = np.arange(10_0000)
In [6]: %timeit a.tolist()
722 μs ± 43.8 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
In [7]: b = pa.array(a)
In [8]: %timeit b.to_pylist()
17.5 ms ± 147 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
In [9]: b.to_pylist() == a.tolist()
Out[9]: TrueHow about we do something like the following:
|
|
Thanks for the review, @pitrou! Agreed — the per-type
Types with non-trivial One question before I rework: do you prefer this mechanism in Cython as sketched, or as a C++ |
Doing this in Cython would probably be more maintainable, right? Wrangling CPython API calls from C++ is generally tedious and error-prone. |
…to_pylist Per review feedback, replace the per-type to_pylist overrides with a general mechanism: * Array gains a cdef _getitem_py(i) returning self[i] as a Python object. The base implementation is GetScalar + Scalar.as_py, so any type without a specialization behaves exactly as before. * The baseline Array.to_pylist becomes a single loop over _getitem_py. maps_as_pydicts != None keeps the Scalar-based path (map->dict conversion has per-entry duplicate-key semantics). * Specializations avoid all per-element Scalar and per-row Array wrapper allocation: integers/floats (type_id switch on NumericArray; dates/times/timestamps fall through to the exact base), boolean, string/binary (+ large variants), list/large_list/fixed_size_list (per-row list built from the child's _getitem_py over the offset range, child wrapper cached on the array), map (list of key/value tuples), struct (dict per row; duplicate field names fall back so they raise ValueError like StructScalar.as_py). Benchmarks (M4 Max): flat int64 with nulls 4M ~0.39s -> 0.028s (~7ns per element, on par with ndarray.tolist); flat string 4M 0.83s -> 0.06s; list<string> 2M 1.93s -> 0.46s; list<list<int32>> 1M 2.10s -> 0.40s; struct<int64,string> 1M 0.91s -> 0.07s; map<string,int64> 1M 2.77s -> 0.74s. Co-authored-by: Isaac
|
Reworked as suggested — the PR now adds a scalar-free Your Net diff is smaller than the previous approach (−104 lines) since the per-type |
Co-authored-by: Isaac
| cdef: | ||
| shared_ptr[CArray] sp_array | ||
| CArray* ap | ||
| # Lazily wrapped child array(s) reused by _getitem_py (see GH-50326) | ||
| object _children_cache |
There was a problem hiding this comment.
Good point — moved the declaration after the pre-existing attributes in 728584f, so type/_name offsets stay stable and the new field follows the append-only convention that Cython's size check assumes.
… tests Move the _children_cache declaration after the pre-existing attributes so their offsets stay stable for extensions compiled against an older pyarrow, and extend test_to_pylist_bulk_paths with binary/large_binary (including embedded NUL bytes), list<binary>, wide-range integers, floats, boolean and struct coverage plus a duplicate-field-name ValueError assertion. Co-authored-by: Isaac
|
Note on the two red CI jobs — both are unrelated to this change:
|
|
Follow-up idea: reuse decoded strings for dictionary-encoded arrays The new
Not blocking for this PR — just flagging |
|
Follow-up ideas: further performance opportunities The structural win here (no per-element Scalar/Array allocation + child-array caching) is solid. A few places that still leave performance on the table, roughly by impact:
None of these are blocking — the PR is a clear improvement as-is. #1 and #2 are the two with real leverage. |
|
@dbtsai For the record, are these comments AI-generated? Did you vet them for correctness? |
| if tid == _Type_INT64: | ||
| return (<CInt64Array*> self.ap).Value(i) | ||
| elif tid == _Type_INT32: | ||
| return (<CInt32Array*> self.ap).Value(i) |
There was a problem hiding this comment.
Why the peculiar ordering of types? I would rather have something more regular for readability.
There was a problem hiding this comment.
Reordered to the regular int8..uint64, float, double sequence in 3d303ce.
| cdef object _getitem_py(self, int64_t i): | ||
| cdef: | ||
| int64_t length | ||
| const uint8_t* data | ||
| if self.ap.IsNull(i): | ||
| return None | ||
| data = (<CLargeStringArray*> self.ap).GetValue(i, &length) | ||
| return cp.PyUnicode_DecodeUTF8(<const char*> data, length, NULL) |
There was a problem hiding this comment.
We do support 32-bit platforms actually. But obviously a string size couldn't be larger than 4GiB on such a platform anyway, so the AI's comment is moot.
| if len(set(names)) != len(names): | ||
| # StructScalar.as_py raises ValueError on duplicate field | ||
| # names; mark the cache so we take the Scalar path below. | ||
| self._children_cache = (None, None) |
There was a problem hiding this comment.
Why not raise ValueError here instead of adding this weird fallback path?
There was a problem hiding this comment.
Done — it now raises the same ValueError as StructScalar.as_py directly (the cache/fallback dance is gone), and the test asserts the message.
| return [x.as_py(maps_as_pydicts=maps_as_pydicts) for x in self] | ||
| return [self._getitem_py(i) for i in range(n)] | ||
|
|
||
| cdef object _getitem_py(self, int64_t i): |
There was a problem hiding this comment.
Not necessary for this PR, but as the AI hinted it might be better to replace this with a cdef list _getitem_range_py(self, int64_t offset, int64_t length). This would cut down on function call and prologue overhead.
Perhaps add a TODO or open a separate issue?
There was a problem hiding this comment.
Filed GH-50448 for the per-range conversion (folding in the dispatch-hoisting and null-check ideas from this thread as well), and added a TODO pointing at it.
| cdef: | ||
| int64_t length | ||
| const uint8_t* data | ||
| if self.ap.IsNull(i): | ||
| return None | ||
| data = (<CLargeStringArray*> self.ap).GetValue(i, &length) |
There was a problem hiding this comment.
You may be able to call GetView(i) which will give you a std::string_view.
There was a problem hiding this comment.
Nice — done. Switched string/binary (+ large variants) to GetView(i); it also made StringViewArray/BinaryViewArray specializations trivial, so those are included now too.
| type=pa.struct([("a", pa.int32()), ("b", pa.string())])), | ||
| pa.array([], type=pa.list_(pa.int32())), | ||
| pa.array([None, None], type=pa.list_(pa.string())), | ||
| ] |
There was a problem hiding this comment.
Can we do pa.binary_view as well?
There was a problem hiding this comment.
Added binary_view and string_view cases — and since GetView covers the view types, they now take real fast paths instead of the Scalar fallback.
| # Duplicate struct field names raise like StructScalar.as_py does | ||
| dup = pa.StructArray.from_arrays( | ||
| [pa.array([1, 2]), pa.array(["a", "b"])], names=["x", "x"]) | ||
| with pytest.raises(ValueError): |
There was a problem hiding this comment.
Can you test (part of) the error message as well? Something like:
| with pytest.raises(ValueError): | |
| with pytest.raises(ValueError, match='some regex'): |
* Order the numeric type-id dispatch regularly (int8..uint64, float, double) instead of by expected hotness. * Use GetView(i) (std::string_view) instead of GetValue with an out parameter for string/binary values, and add StringViewArray / BinaryViewArray specializations on top of it. * Raise the duplicate-field-names ValueError directly in StructArray._getitem_py instead of falling back to the Scalar path, and assert the message in the test. * Add binary_view/string_view test coverage. * Add a TODO pointing at apacheGH-50448 (per-range conversion follow-up). Co-authored-by: Isaac
Those were AI-generated based on my prompts for specific areas where I had questions. |
### 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>
### 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>
### 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>
|
@pitrou Hi, can you take another look at this PR? We are looking forward to get this in and a new pyarrow release with it. Can you help on this? Thanks! |
Rationale for this change
pa.Array.to_pylist()converts one element at a time throughArray::GetScalarplus a PythonScalarwrapper; for list types each row additionally allocates a PythonArraywrapper for the row's values slice and a fresh generator before recursing per element. Asampleprofile shows ~20% of runtime in CPython GC (triggered by the per-row GC-tracked allocations), ~25% inGetScalar, and only ~7% doing the useful work of creating the output objects — makingto_pylistseveral times slower than converting viato_pandas()and back, and ~24x slower thanndarray.tolist()for plain int64. Details in #50326; this hit Apache Spark's Arrow-serialized Python UDFs (apache/spark#56940, apache/spark#56943).What changes are included in this PR?
Following review feedback, this adds a general scalar-free conversion mechanism instead of per-type
to_pylistoverrides:Arraygainscdef object _getitem_py(self, int64_t i), returningself[i]as a Python object. The base implementation isGetScalar+Scalar.as_py, so any type without a specialization behaves exactly as today (dates, times, timestamps, durations, decimals, dictionary, extension, unions, views, ...).Array.to_pylistbecomes a single loop over_getitem_py.maps_as_pydicts != Nonekeeps the Scalar-based path, since map→dict conversion has per-entry duplicate-key semantics.type_idswitch onNumericArray; date/time/timestamp subclasses fall through to the exact base),GetValue+PyUnicode_DecodeUTF8/PyBytes_FromStringAndSize, matchingstr(buf, 'utf8')/to_pybytes()exactly),_getitem_pyover the offset range; the wrapped child is cached on the parent array),MapScalar.as_py),ValueErrorlikeStructScalar.as_py).Nested types compose without any per-row wrappers.
ChunkedArray.to_pylist,Table.to_pylistandListScalar.as_pydelegate here and speed up automatically. Follow-up candidates: string/binary views, run-end-encoded, dictionary, a fast path for date32.Benchmarks (macOS arm64, M4 Max):
int64with nulls (4M)ndarray.tolist)string(4M)list<string>(2M rows)list<list<int32>>(1M rows)struct<int64,string>(1M rows)map<string,int64>(1M rows)Are these changes tested?
test_to_pylist_bulk_paths(added here) compares against the per-scalar conversion with exact element types for representative arrays including sliced views. Additionally verified with a randomized differential test against[x.as_py() for x in arr]with exact-type equality: all integer widths (incl. values beyond 2^62), floats (NaN/inf), boolean, string/binary (+large, multibyte), all list kinds, nested lists, struct (incl. empty struct, duplicate-field-nameValueError), map (incl. strict-mode duplicate-keyKeyError), dictionary/null fallbacks, sliced/chunked arrays, and bothmaps_as_pydictsmodes — no differences.pytest test_array.py test_scalars.py test_convert_builtin.py test_table.py test_types.py: 1295 passed.Are there any user-facing changes?
No behavior changes, only performance.
This pull request and its description were written by Isaac.