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 9 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. |
bca5c9a to
0a1fee8
Compare
8626b97 to
e66acd7
Compare
e66acd7 to
7e49564
Compare
…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! |
|
Thank you for review @rok. I marked the addressed comments as resolved. |
|
@pitrou could you take a look at this please? |
You or your agent? |
both! :) |
|
@viirya , is this the second blocker for the possible patch release that was mentioned on the Arrow 25.0.0 ML thread? I haven't been following the development of the issues causing spark performance regressions but I haven't seen any follow up on the ML. What would be your current expectations? |
|
@raulcd I don't think this is blocking anything. |
| if maps_as_pydicts not in (None, "lossy", "strict"): | ||
| # Matches MapScalar.as_py, which validates before the null check. | ||
| raise ValueError( | ||
| "Invalid value for 'maps_as_pydicts': " | ||
| + "valid values are 'lossy', 'strict' or `None` (default). " | ||
| + f"Received {maps_as_pydicts!r}." | ||
| ) |
There was a problem hiding this comment.
Done in 93b8043 — factored into _check_maps_as_pydicts(), now shared by MapScalar.as_py and MapArray._getitem_py.
| for j in range(start, end) | ||
| ] | ||
| # MapScalar.as_py converts every key before processing values, then | ||
| # checks each key immediately before converting its corresponding value. |
There was a problem hiding this comment.
This looks a bit tedious. I think you could reuse the "association list" above, build a dict from it and check that the dict length is equal to the list length.
There was a problem hiding this comment.
I simplified it to a single interleaved loop in 93b8043 (no more separate keys pass), which I think addresses the tedium. I didn't go through the association list though: its per-entry 2-tuples are exactly what the direct dict build avoids, and it measures accordingly on 1M rows of 2-entry maps — 0.12 s for the direct dict vs 0.48 s for the association list (≈0.56 s with a dict built on top). The duplicate handling (warn/raise) still needs the explicit loop either way.
| # Duplicate keys must be detected before converting values: with a | ||
| # poison value *after* the duplicate, strict mode raises the outer | ||
| # duplicate-key error, and lossy mode warns before converting that value. |
There was a problem hiding this comment.
Why are we testing for this? I don't think this is part of the contract.
There was a problem hiding this comment.
It pins that duplicate detection happens before converting that entry's value, matching MapScalar.as_py — the first implementation got this wrong (values converted before duplicate detection) and review caught it, so the test guards the regression. The exact-sequence form was requested in an earlier review pass; if you don't consider the ordering part of the contract I'm happy to reduce it to a plain pytest.warns.
| # Invalid values are only rejected when a map value is converted. | ||
| assert pa.array([1, 2]).to_pylist(maps_as_pydicts="bogus") == [1, 2] |
There was a problem hiding this comment.
It pins that the rework doesn't newly reject previously-accepted calls: the Scalar path only validates the option when a map value is actually converted, so to_pylist(maps_as_pydicts="bogus") on a non-map array succeeds today, and eager validation would be a (small) behavior change. Can drop it if you don't think it's worth pinning.
|
@rok I'd appreciate if you did actual human code reviews instead of letting your agent post for you. |
|
@raulcd To confirm what Antoine said: this is not a blocker. #50327 (already merged) is the fix Spark needs — it covers the native |
Factor the maps_as_pydicts validation into _check_maps_as_pydicts(), shared by MapScalar.as_py and MapArray._getitem_py, and collapse the dict conversion to a single interleaved loop (no separate keys pass). Building the dict directly stays ~4x faster than going through the association list, whose per-entry tuples the dict form avoids. Co-authored-by: Isaac
There was a problem hiding this comment.
🟡 Not ready to approve
Map dict-mode key conversion order in MapArray._getitem_py can still differ from MapScalar.as_py (keys converted lazily vs all upfront), potentially changing exception/warning ordering in key-conversion-failure cases.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Comments suppressed due to low confidence (1)
python/pyarrow/array.pxi:3647
- MapArray._getitem_py (dict modes) converts keys lazily inside the per-entry loop, whereas MapScalar.as_py converts all keys first via keys() before doing any duplicate detection / warnings. This can change exception ordering if a later key conversion raises (e.g. invalid UTF-8 in a string key): MapScalar.as_py would raise during key conversion before any duplicate-key KeyError/warning, but the array path can raise/warn on an earlier duplicate key first. To match MapScalar.as_py more closely, pre-convert the full key list before the duplicate-check/value-conversion loop.
for j in range(start, end):
key = keys._getitem_py(j, None)
if key in result:
if maps_as_pydicts == "strict":
raise KeyError(
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
|
Note on the Copilot assessment above: the ordering difference it describes (lazy vs upfront key conversion) is real but deliberate — the keys-first pass existed precisely to mirror |
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.