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 4 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.
| 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.
PyArrow only supports 64-bit platforms, where Py_ssize_t and int64_t have the same width; and on a hypothetical 32-bit build a single value longer than PY_SSIZE_T_MAX could not exist in the first place, since the child data buffer itself is bounded by the process address space. So the implicit conversion cannot truncate in practice.
| arrays = [ | ||
| pa.array([[1, None, 3], None, [], [4]], type=pa.list_(pa.int32())), | ||
| pa.array([["a", None], None, [], ["bcd", ""]], | ||
| type=pa.list_(pa.string())), | ||
| pa.array([["a", None], None, [], ["bcd", ""]], | ||
| type=pa.large_list(pa.large_string())), | ||
| pa.array([[1, None], None, [3, 4]], type=pa.list_(pa.int32(), 2)), | ||
| pa.array([[[1], [2, None]], None, [None, [3]]], | ||
| type=pa.list_(pa.list_(pa.int32()))), | ||
| pa.array([[("k1", 1), ("k2", None)], None, []], | ||
| type=pa.map_(pa.string(), pa.int32())), | ||
| pa.array(["a", None, "", "\N{GRINNING FACE} \N{SNOWMAN}"], | ||
| type=pa.string()), | ||
| pa.array(["a", None, "", "\N{GRINNING FACE} \N{SNOWMAN}"], | ||
| type=pa.large_string()), | ||
| pa.array([], type=pa.list_(pa.int32())), | ||
| pa.array([None, None], type=pa.list_(pa.string())), | ||
| ] |
There was a problem hiding this comment.
Added binary/large_binary (including embedded NUL bytes), list, wide-range integers, floats, boolean and struct coverage in 728584f, plus an assertion that duplicate struct field names still raise ValueError.
… 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. |
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.