GH-50429: [Python] Convert maps to dicts without per-element Scalars in to_pylist#50430
GH-50429: [Python] Convert maps to dicts without per-element Scalars in to_pylist#50430viirya wants to merge 8 commits into
Conversation
|
|
There was a problem hiding this comment.
Pull request overview
This PR extends the scalar-free Array.to_pylist() conversion path to support maps_as_pydicts for MapArray, enabling direct Map→dict materialization without per-element Scalar allocation while preserving existing MapScalar.as_py semantics.
Changes:
- Add a
cdef _getitem_py(i, maps_as_pydicts)mechanism and updateArray.to_pylist()to use it for scalar-free conversions. - Implement
_getitem_pyspecializations for common leaf and nested array types (numeric, boolean, string/binary, list, struct, map), with caching of wrapped child arrays. - Add tests comparing
to_pylist()results against per-scalar conversion, includingmaps_as_pydictsbehaviors and nested compositions.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| python/pyarrow/array.pxi | Implements _getitem_py and adds scalar-free to_pylist specializations, including MapArray dict conversion for maps_as_pydicts. |
| python/pyarrow/lib.pxd | Declares new Array Cython API surface (_children_cache, _getitem_py). |
| python/pyarrow/tests/test_array.py | Adds regression/differential tests for scalar-free to_pylist and maps_as_pydicts semantics. |
| cdef dict result = {} | ||
| for j in range(start, end): | ||
| result[keys._getitem_py(j, None)] = items._getitem_py(j, maps_as_pydicts) | ||
| if len(result) == end - start: | ||
| return result | ||
| # Duplicate keys: redo the row with the per-key loop so the 'lossy' | ||
| # warnings and 'strict' KeyError match MapScalar.as_py exactly. | ||
| result = {} | ||
| for j in range(start, end): | ||
| key = keys._getitem_py(j, None) | ||
| if key in result: | ||
| if maps_as_pydicts == "strict": | ||
| raise KeyError( | ||
| "Converting to Python dictionary is not supported in strict mode " | ||
| f"when duplicate keys are present (duplicate key was '{key}')." | ||
| ) | ||
| else: | ||
| warnings.warn( | ||
| f"Encountered key '{key}' which was already encountered.") | ||
| result[key] = items._getitem_py(j, maps_as_pydicts) | ||
| return result |
bca5c9a to
0a1fee8
Compare
| cdef dict result = {} | ||
| for j in range(start, end): | ||
| result[keys._getitem_py(j, None)] = items._getitem_py(j, maps_as_pydicts) | ||
| if len(result) == end - start: | ||
| return result | ||
| # Duplicate keys: redo the row with the per-key loop so the 'lossy' | ||
| # warnings and 'strict' KeyError match MapScalar.as_py exactly. | ||
| result = {} | ||
| for j in range(start, end): | ||
| key = keys._getitem_py(j, None) | ||
| if key in result: | ||
| if maps_as_pydicts == "strict": | ||
| raise KeyError( | ||
| "Converting to Python dictionary is not supported in strict mode " | ||
| f"when duplicate keys are present (duplicate key was '{key}')." | ||
| ) | ||
| else: | ||
| warnings.warn( | ||
| f"Encountered key '{key}' which was already encountered.") | ||
| result[key] = items._getitem_py(j, maps_as_pydicts) | ||
| return result |
There was a problem hiding this comment.
Good catch — this was a real ordering difference. Fixed in 8626b97: all keys are now converted first (matching MapScalar.as_py, which converts them via keys()), and duplicates are detected before any value conversion, so the 'strict' KeyError and the 'lossy' warning are emitted at the same point as the Scalar path even when a later value conversion raises. Added tests with a raising value placed after the duplicate key in both modes, and verified the exception/warning sequences match the Scalar path exactly (including nested-map warnings order).
8626b97 to
e66acd7
Compare
| if self._children_cache is None: | ||
| self._children_cache = pyarrow_wrap_array(arr.values()) | ||
| cdef Array values = <Array> self._children_cache | ||
| cdef int64_t j, start = arr.value_offset(i), end = arr.value_offset(i + 1) | ||
| return [values._getitem_py(j, maps_as_pydicts) for j in range(start, end)] |
There was a problem hiding this comment.
Good catch — the C++ signatures all take int64_t i, so the int declarations would let Cython silently truncate 64-bit indices on the new call paths. Fixed in the base PR #50327 (9b1863d), which owns these declarations and the list/map _getitem_py call sites; this branch is rebased on it. All list-like, binary and dense-union value_offset/value_length declarations now match the C++ headers.
e66acd7 to
7e49564
Compare
Per review: drop the as_dicts flag and use the same membership-test validation form as MapScalar.as_py; no measurable perf difference on either map path. Co-authored-by: Isaac
| for arr in arrays: | ||
| expected = [x.as_py(maps_as_pydicts="strict") for x in arr] | ||
| assert arr.to_pylist(maps_as_pydicts="strict") == expected | ||
|
|
There was a problem hiding this comment.
Right — ListScalar.as_py delegates to Array.to_pylist, so that reference wasn't independent for the list<map> case. Fixed in ffbb17c by writing the expected rows out literally for all five cases, which keeps the oracle fully independent of the code under test.
…ydicts test ListScalar.as_py delegates to Array.to_pylist, so the scalar-built reference for the list<map> case exercised the code under test; write the expected rows out literally to keep the oracle independent. Co-authored-by: Isaac
|
Nice work — the parity coverage here is careful (the poison-value-after-duplicate-key ordering test in particular). One small test gap: arrays = [
flat,
flat.slice(1),
pa.array([[[('k', 1)], None], None], type=pa.list_(map_type)),
+ pa.array([[[('k', 1)], None], None], type=pa.large_list(map_type)),
+ pa.array([[[('k', 1)], None], None], type=pa.list_(map_type, 2)),
pa.array([[("o", [("i", 5)])]],
type=pa.map_(pa.string(), map_type)),
pa.array([{"m": [("k", 1)]}, None],
type=pa.struct([("m", map_type)])),
]The same data works for all three (each non-null outer element has exactly 2 children, so it's valid for the fixed-size list too), and it mirrors how |
…maps parity matrix Per review: LargeListArray._getitem_py and FixedSizeListArray._getitem_py thread maps_as_pydicts to their values but were not exercised by the parity cases; add both with the same data and literal expected rows. Co-authored-by: Isaac
|
Added in a650d5a — both variants with the same data and literal expected rows. Thanks for the careful review! |
Rationale for this change
Follow-up of #50326, building on the merged #50327.
GH-50327 converts arrays to Python objects without per-element Scalars, but the
maps_as_pydictsoption still routes to the Scalar-based path: every map row allocates aMapScalar, converts its keys via per-elementas_py, and builds the dict in Python. Map→dict is the natural consumption pattern for engines whose map values are Python dicts (e.g. Spark's Arrow-serialized Python UDFs currently receive association lists and rebuild a dict per row in pure Python — one of the dominant remaining costs in that path).What changes are included in this PR?
Thread
maps_as_pydictsthrough the scalar-free_getitem_pymechanism:MapArray._getitem_pyconverts the row's keys first (asMapScalar.as_pydoes viakeys()), then builds the dict with a per-key loop that checks each key immediately before converting its value — so the'lossy'warnings, the'strict'KeyError, and theTypeErrorfor unhashable keys are raised at exactly the same points asMapScalar.as_py(including messages and warning-per-duplicate behavior).StructArray._getitem_pyalso reproducesStructScalar.as_py's translation of nestedKeyErrors into the duplicate-field-namesValueError.ValueErrorwhen a map value is converted — including null map rows, matching the Scalar path — while non-map arrays keep ignoring the option.Benchmark (macOS arm64, M4 Max; 1M rows of 2-entry
map<string,int64>, 10% nulls):to_pylist(maps_as_pydicts='lossy')Notably the dict form is now also faster than the default association-list form (0.49 s), which allocates a 2-tuple per entry.
Are these changes tested?
New
test_to_pylist_maps_as_pydictscompares against the per-scalar conversion for flat maps,list<map>,map<string, map<...>>andstruct<map>(plain and sliced) in both modes, and asserts the duplicate-key semantics ('lossy'warns and keeps the last value;'strict'raisesKeyError), the exact warning sequence when a raising value follows a duplicate key, the unhashable-keyTypeErrorparity (struct-keyed maps), the nestedKeyError→ValueErrortranslation inside structs, the invalid-valueValueError(including on null map rows), and that non-map arrays ignore the option. Randomized differential tests against the Scalar path (exact type equality) andpytest test_array.py test_scalars.py test_convert_builtin.py test_table.py(1210 passed) also pass.Are there any user-facing changes?
No behavior changes, only performance.
This pull request and its description were written by Isaac.