Skip to content

[C++] Unifying float16 dictionaries replaces every value with its bit pattern read as a number (combine_chunks, concat_arrays, unify_dictionaries, Feather) #55503

Open

Description

@hanke580

Describe the bug, including details regarding any error messages, version, and platform.

When the chunks of a dictionary<halffloat> column carry different dictionaries, every operation that unifies the dictionaries replaces each value with the number spelled by its bit pattern. For example, 1.0 (bits 0x3C00) becomes 15360.0, -0.0 (0x8000) becomes 32768.0, and 65504.0 (0x7BFF) becomes 31744.0 (31743 rounded to half precision). No error or warning is raised.

Affected operations:

  • ChunkedArray.combine_chunks() / Table.combine_chunks()
  • ChunkedArray.unify_dictionaries()
  • pa.concat_arrays()
  • Table.sort_by() and Table.take() on such a column (they concatenate the chunks first)
  • pc.unique() / pc.value_counts() of the chunked column
  • pyarrow.feather.write_feather() with default options (Feather V2 always unifies dictionaries)
  • the IPC file writer with IpcWriteOptions(unify_dictionaries=True)

Feather and IPC files written this way store the wrong values, and so does any file written after combine_chunks(). Chunks with different dictionaries are what dictionary_encode() per batch, IPC streams with replacement dictionaries, and datasets of several files produce. Dictionaries of other value types (float32, float64, integers, strings) unify correctly.

Reproducer

import io
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.feather as feather

vals = pa.array([5.960464477539063e-08, 1.0, 65504.0, -0.0, 2.5, 0.0999755859375]).cast(pa.float16())
# one column, two chunks, each chunk with its own dictionary
c1 = pa.DictionaryArray.from_arrays(pa.array([0, 1, 2], pa.int32()), vals.slice(0, 3))
c2 = pa.DictionaryArray.from_arrays(pa.array([0, 1, 2], pa.int32()), vals.slice(3, 3))
col = pa.chunked_array([c1, c2])

print("decoded per chunk:", col.cast(pa.float16()).to_pylist())
print("combine_chunks:   ", col.combine_chunks().dictionary_decode().to_pylist())
print("concat_arrays:    ", pa.concat_arrays([c1, c2]).dictionary_decode().to_pylist())

buf = io.BytesIO()
feather.write_feather(pa.table({"x": col}), buf)
print("Feather round trip:", feather.read_table(io.BytesIO(buf.getvalue()))["x"].cast(pa.float16()).to_pylist())

h = pa.array([1.0, 1.0009765625, 1.001953125, 2.5, 2.501953125, 65504.0, float("inf"), -0.0, 0.0]).cast(pa.float16())
print("count_distinct(float16):", pc.count_distinct(h).as_py(), " as float32:", pc.count_distinct(h.cast(pa.float32())).as_py())

Output with the nightly wheel 27.0.0.dev105 (built from main, uploaded 2026-11-08):

decoded per chunk: [5.960464477539063e-08, 1.0, 65504.0, -0.0, 2.5, 0.0999755859375]
combine_chunks:    [1.0, 15360.0, 31744.0, 32768.0, 16640.0, 11880.0]
concat_arrays:     [1.0, 15360.0, 31744.0, 32768.0, 16640.0, 11880.0]
Feather round trip: [1.0, 15360.0, 31744.0, 32768.0, 16640.0, 11880.0]
count_distinct(float16): 5  as float32: 9

The values after unification are the inputs' bit patterns (1, 15360, 31743, 32768, 16640, 11878) rounded to the nearest half float. The same happens with unify_dictionaries(), sort_by()/take(), pc.unique() of the chunked column, the IPC file writer with unify_dictionaries=True, and combine_chunks() followed by pyarrow.parquet.write_table(). The released 26.0.1 and 21.0.0 print the same output.

The last line shows a second symptom with the same cause, and it needs no dictionary. pc.count_distinct of a plain halffloat array returns 5 for nine distinct values. Half floats whose bit patterns round to the same half-float number are counted once, for example 1.0, 1.0009765625 and 1.001953125 (bits 0x3C00, 0x3C01, 0x3C02). count_distinct of the same values cast to float32 returns 9, as does the grouped count_distinct aggregation (Table.group_by(...).aggregate([(col, "count_distinct")])).

Expected behavior: dictionary unification and concatenation keep the values (every line equal to the first one), and count_distinct returns 9.

Versions: pyarrow 27.0.0.dev105 (nightly wheel, built from main, uploaded 2026-11-08) and the released 26.0.1 and 21.0.0, Python 3.12, Linux x86_64 (Ubuntu 22.04). The code below is unchanged on the current main (6c976ac, 2026-11-08).

Where it comes from (links at main 7f1f38d)

  • Half floats are hashed with ScalarMemoTable<util::Float16> (hashing.h#L938-L941). This came with [C++][Python] support is_nan for float16 #45083 (GH-45083: [C++] Add HalfFloat kernels for is_nan, is_inf, is_finite, negate, negate_checked, sign #46866, first released in 21.0.0). Before that change, half floats used the generic ScalarMemoTable<uint16_t> on their bits.
  • ScalarMemoTable::Get / GetOrInsert build the key with const Scalar value(std::forward<Value>(v)); from whatever the caller passes (hashing.h#L426-L444).
  • DictionaryUnifierImpl<T>::Unify passes values.GetView(i) (array_dict.cc#L366, #L372). For a HalfFloatArray that is the raw uint16_t storage value (array_primitive.h#L124).
  • For a uint16_t argument, that direct-initialization selects Float16's converting constructor template <typename T, ...> explicit Float16(T v) : Float16(static_cast<double>(v)) (float16.h#L47-L49). So 0x3C00 becomes Float16(15360.0) instead of Float16::FromBits(0x3C00).
  • The memo table's values become the unified dictionary. Equal bit patterns still map to the same entry, so the indices stay consistent and nothing fails. Concatenate of dictionary arrays with different dictionaries, DictionaryUnifier::UnifyChunkedArray / UnifyTable, and the Feather writer (which sets unify_dictionaries = true, feather.cc#L827) all go through this unifier.
  • CountDistinctImpl hands the same memo table the visitor's uint16_t storage values, and UnboxScalar<HalfFloatType>::Unbox(...) for scalar input (aggregate_basic.cc#L161-L186, float16 kernel registered at #L250). It therefore hits the same numeric conversion.

The float16 hash kernels on plain halffloat arrays (dictionary_encode, unique and value_counts, added in GH-50512 / #50513) give correct results, because they pass Float16 values to the memo table. A fix is to pass util::Float16::FromBits(...) at the two call sites above, or to give the Float16 memo table an overload that treats a uint16_t argument as bits.

Component(s)

C++

Activity

  1. AnuragRaut08 commented on Nov 12, 2026

    @AnuragRaut08
    Contributor

    I'd like to take this issue. I'll investigate the Float16 bit-pattern handling in dictionary unification and the count_distinct aggregation, address the incorrect conversions, and add regression tests covering both cases.

  2. AnuragRaut08 commented on Nov 14, 2026

    @AnuragRaut08
    Contributor

    @hanke580 I've submitted a fix for this issue in PR #<PR_NUMBER>.

    The changes address the incorrect Float16 bit-pattern handling in dictionary unification and count_distinct. I've also added regression tests covering both cases and verified the fix against the original failing behavior.

    Please take a look when you get a chance. Feedback is welcome!

  3. AnuragRaut08 commented on Nov 14, 2026

    @AnuragRaut08
    Contributor

    @hanke580 @Reranko05
    I investigated this issue and submitted a fix in PR #55610
    The changes address the raw-bit interpretation in Float16 dictionary unification and count_distinct, including scalar unboxing. I also added regression tests that reproduce the incorrect behavior with the original implementation.

    The PR was closed without merging due to concerns about AI-assisted contributions. I've acknowledged that feedback and would appreciate guidance on how best to proceed with this fix in accordance with the project's contribution expectations.

    I'm happy to walk through the implementation and make any required changes.

  4. AnuragRaut08 commented on Nov 14, 2026

    @AnuragRaut08
    Contributor

    Latest fix is in #55611
    Please have a look

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions