GH-37476: [C++][Python] Preserve unsigned dictionary index types when building from values - #50475
Conversation
…s when building from values
|
Friendly ping on this one. The workflow runs look like they're still waiting on @jorisvandenbossche you diagnosed this back in 2023 and suggested letting it pick Happy to rebase if it's gone stale. |
| } | ||
| } | ||
|
|
||
| TEST(TestDictionaryBuilderIndexType, TypePreservesSignedIndexType) { |
There was a problem hiding this comment.
You could fold this test into the above, no need for duplicate code here IMHO.
| std::shared_ptr<Array> result; | ||
| ASSERT_OK(builder.Finish(&result)); |
There was a problem hiding this comment.
Can use the Result-returning Finish version:
| std::shared_ptr<Array> result; | |
| ASSERT_OK(builder.Finish(&result)); | |
| ASSERT_OK_AND_ASSIGN(auto result, builder.Finish()); |
| auto& builder = checked_cast<DictionaryBuilder<StringType>&>(*boxed_builder); | ||
|
|
||
| // More distinct values than a uint8 index start width accommodates. | ||
| for (int i = 0; i < 200; ++i) { |
There was a problem hiding this comment.
Hmm, is that right? A uint8 index should be able to represent 256 distinct dictionary entries (or 255 depending on how it's coded internally :-)).
| TEST(TestDictionaryBuilderIndexType, SuppliedDictionaryPreservesUnsignedIndexType) { | ||
| // The supplied-dictionary constructor starts the adaptive indices builder at its | ||
| // default width rather than at the requested one, so the requested *width* is not | ||
| // honoured on this path. That predates this change: a requested int32 reports int8 |
There was a problem hiding this comment.
"this change" will not be understandable to a future reader of this comment, can you reword it to be less context-dependent?
There was a problem hiding this comment.
Also, if we already have a test for this on the signed index side, I wonder what it brings to add this new test.
AIs tend to produce very verbose testing, which gives the illusion of better quality, but also makes the code less maintainable in the long term (why all those very similar test functions)?
| *checked_cast<const DictionaryType&>(*unsigned_type).index_type()); | ||
|
|
||
| ASSERT_OK_AND_ASSIGN(auto signed_builder, | ||
| MakeDictionaryBuilder(dictionary(int32(), utf8()), dict_values)); |
There was a problem hiding this comment.
So we're also testing for a signed index type here? Wasn't it already tested? If not, at least the test name and comment should be fixed.
| /// width have identical memory layout and reporting one as the other is | ||
| /// value-preserving (GH-37476). The width itself stays adaptive, exactly as it is | ||
| /// for signed index types; only the signedness of the requested type is preserved. | ||
| inline std::shared_ptr<DataType> MaybeUnsignedIndexType( |
There was a problem hiding this comment.
I don't think this has to be inlined in this header file, as this function shouldn't be performance-critical.
| MemoryPool* pool = default_memory_pool(), | ||
| int64_t alignment = kDefaultBufferAlignment, bool ordered = false) | ||
| int64_t alignment = kDefaultBufferAlignment, bool ordered = false, | ||
| bool unsigned_index = false) |
There was a problem hiding this comment.
Can we call this use_unsigned_index?
|
|
||
| @pytest.mark.parametrize("index_type", [pa.int8(), pa.int16(), | ||
| pa.int32(), pa.int64()]) | ||
| def test_dictionary_array_signed_index_type_unchanged(index_type): |
There was a problem hiding this comment.
Can you fold this into the above test?
| def test_dictionary_array_index_width_still_adapts(): | ||
| # The index width remains adaptive, as it is for signed index types; only | ||
| # the signedness of the requested index type is preserved. |
There was a problem hiding this comment.
Same comment wrt comments being too much context-dependent ("still adapts", "remains adaptive").
Also, can we check both signed and unsigned here?
| # silently downgraded to int64. Arrow deliberately does not support converting | ||
| # them to pandas (pandas categorical codes are signed), so that pre-existing | ||
| # limitation is now reachable, where the silent downgrade used to mask it. |
There was a problem hiding this comment.
Hmm, that sounds like a potential regression for third-party code. Can we support converting uint64 indices to Pandas?
You can take a look at CategoricalWriter in arrow_to_pandas.cc. It seems that WriteIndicesUniform is the main place that needs to be changed.
Consolidate the dictionary index-type tests down to the distinct code paths and reword the context-dependent comments. Move MaybeUnsignedIndexType out of the header into builder.cc and rename the flag to use_unsigned_index. Support converting uint64 dictionary indices to pandas: they map to int64 categorical codes, which is safe because the indices are bounds-checked below the dictionary length. This removes the previous TypeError.
|
Thanks for the review. I folded the index-type tests down to the distinct code paths - type and finish preservation now run in one loop over signed and unsigned, and I dropped the ordered and fixed-size-binary ones since they don't exercise a separate path. Switched to ASSERT_OK_AND_ASSIGN and reworded the context-dependent comments. Moved MaybeUnsignedIndexType into builder.cc and renamed the flag to use_unsigned_index. On uint64 to pandas: I added support for it instead of keeping the error. uint64 indices map to int64 codes, which is safe because the indices are bounds-checked below the dictionary length, so they never reach the int64 limit. WriteIndicesUniform handles it the same way as uint32, and I updated test_dictionary_with_pandas to check the conversion instead of the raise. On the missing top bit: you're right, the adaptive builder is signed so a uint8 index widens after 128 rather than 256. I left that as-is and noted it in the builder.cc comment - happy to change the builder to track the unsigned range if you'd prefer. |
pitrou
left a comment
There was a problem hiding this comment.
Thanks for the update, a couple minor additional suggestions
| case Type::INT64: | ||
| return ::arrow::uint64(); | ||
| default: | ||
| return index_type; |
There was a problem hiding this comment.
Can we use arrow::Unreachable here? Or can the index_type argument actually be unsigned?
- MaybeUnsignedIndexType: the default switch case is unreachable (the adaptive index builder only produces signed int8/16/32/64), so mark it with arrow::Unreachable instead of returning the argument - SuppliedDictionary/ExactIndexBuilder index-type tests: build the array and assert the finished array's index type, not just the builder's reported type - rename WidthStillAdaptsWhenUnsigned -> WidthAdaptsWhenUnsigned (drop the context-dependent "Still")
|
Thanks, done:
The uint8-widens-at-128 bit is still a documented limitation; happy to do the unsigned-range |
It's probably desirable though not critical, so feel free to work on it if you'd like to.
Probably, let's wait for the updated CI results. |
|
The remaining CI failures are unrelated, I'll merge. |
|
After merging your PR, Conbench analyzed the 4 benchmarking runs that have been run so far on merge-commit bda98c4. There were no benchmark performance regressions. 🎉 The full Conbench report has more details. It also includes information about 20 possible false positives for unstable benchmarks that are known to sometimes produce them. |
Rationale for this change
An unsigned dictionary index type is silently replaced by the signed one of the same width:
It also makes
pa.chunked_array(values, dict_type)fail withArray chunks must all be same type, since the chunk built internally comes back a different type than requested.DictionaryBuilderCase::CreateFor()reduces the requested index type to its byte width and hands that toAdaptiveIntBuilder, so everything but the width is dropped and the type is rebuilt from what the indices builder reports, which is always signed.@jorisvandenbossche suggested
AdaptiveUIntBuilderfor unsigned types. That returns a different builder class, whichutil/converter.h,json/from_string.ccand the R binding all cast toDictionaryBuilder<T>, andTestDictionaryUnifier.ChunkedArrayNestedDicthits achecked_pointer_castDCHECK. So this keeps one builder class and just reports the requested signedness. Indices are non-negative and same-width signed and unsigned types have the same layout, so it is value-preserving and free, and the width stays adaptive.What changes are included in this PR?
DictionaryBuilderBaseand itsNullTypespecialization record the requested signedness and map the signed index type to the unsigned one of the same width intype(),FinishInternal()andFinishDelta();CreateFor()passes it through. The mapping helper lives inbuilder.cc, not the header.util/converter.h,python_to_arrow.ccandr_to_arrow.cppare untouched, so R is fixed rather than broken.uint64 indices also convert to pandas now, mapped to int64 codes. That is safe because the indices are bounds-checked below the dictionary length, so they never reach the int64 limit;
WriteIndicesUniformhandles uint64 like it already handles uint32.Are these changes tested?
Six tests in
array_dict_test.cccover the distinct paths: type and finish preservation across signed and unsigned, the width adapting while staying unsigned, theNullTypebuilder,FinishDelta, the supplied-dictionary constructor and the exact-index builder. Three parametrized tests intest_array.pycover the eight index types, the width adapting and the uint64-to-pandas conversion, andtest_dictionary_with_pandasnow checks that conversion instead of the old error.A
uint8grows touint16once more than 128 distinct values are added and stays unsigned; the underlying builder is signed, so it widens after 128 rather than the 256 a real uint8 could hold.arrow-array-test,arrow-ipc-read-write-test,arrow-compute-scalar-cast-test,arrow-c-bridge-testand the pyarrowtest_arrayandtest_pandassuites pass.Are there any user-facing changes?
pa.arrayandpa.chunked_arrayreturn the requested unsigned index type.dictionary(uint64(), ...)produces real uint64 indices, and converting such an array to pandas now works where it used to raise.take,filter,cast,dictionary_decode, IPC and scalar access all keep the index type.