This is an automated email from the ASF dual-hosted git repository.

Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 6f51fb1b33 perf(arrow-cast): gate Dictionary -> View fast path on 
cardinality (#10436)
6f51fb1b33 is described below

commit 6f51fb1b333a55469a4ffb9c5d9bc740cb9c1d9e
Author: Abhishek <[email protected]>
AuthorDate: Tue Aug 18 10:32:57 2026 +0530

    perf(arrow-cast): gate Dictionary -> View fast path on cardinality (#10436)
    
    # Which issue does this PR close?
    
    <!--
    We generally require a GitHub issue to be filed for all bug fixes and
    enhancements and this helps us generate change logs for our releases.
    You can link an issue to this PR using the GitHub syntax.
    -->
    
    - Closes #8985
    
    Supersedes #9768, which I closed it implemented these fast paths
    ungated, and benchmarking showed that made things significantly slower.
    Details below.
    
    # Rationale for this change
    
    <!--
    Why are you proposing this change? If this is already explained clearly
    in the issue then this section is not needed.
    Explaining clearly why changes are proposed helps reviewers understand
    your changes and offer better suggestions for fixes.
    -->
      `dictionary_cast` can build a view array from a dictionary two ways:
    
    - (a) `unpack_dictionary` builds one view per dictionary *value*, then
    gathers with `take`
    - (b) `view_from_dict_values` builds one view per *row* directly against
    the values buffer
    
    The comment above (b) says (a) "incurs unnecessary data copy of the
    value buffer". That
    isn't the case. `impl From<&GenericByteArray> for GenericByteViewArray`
    reuses the buffer
    (it only falls back to copying when the values exceed the `u32` offset a
    view can hold),
    and `take_byte_view` passes `data_buffers().to_vec()` through untouched.
    Neither step
      copies value data, so there was no copy to remove.
    
    What the two actually trade is a bandwidth-bound `u128` gather in `take`
    against a
    per-row `make_view` that reads each row's payload out of the values
    buffer. The gather
    wins once rows reach roughly 0.6x the dictionary size so (b) is a large
    pessimisation in
    the common case, and only pays off when the dictionary is substantially
    larger than the
    array is long, where (a) spends most of its time building views no row
    references.
    
    This means the existing `Utf8 -> Utf8View` and `Binary -> BinaryView`
    fast paths on main
    are currently **5-8x slower** than `unpack_dictionary` at typical batch
    shapes.
    
    # What changes are included in this PR?
    
    <!--
    There is no need to duplicate the description in the issue here but it
    is sometimes worth providing a summary of the individual changes in this
    PR.
    -->
    - Gate the direct path on `keys.len() < values.len() / 2`; everything
    else falls through
    to `unpack_dictionary`. The measured crossover is near `rows ≈ 0.6 *
    values`; the gate
    switches short of it, at `rows = values / 2`, where the direct path is
    still ahead by
    15-30%. That margin covers the crossover moving with cache size or
    microarchitecture.
    Being wrong in that direction only forgoes a win; being wrong the other
    way is a
        regression on the common case.
      - Extend the direct path to the remaining combinations #8985 asks for:
    `LargeUtf8`/`LargeBinary` -> `Utf8View`/`BinaryView`, and the `Utf8` <->
    `Binary` cross
    casts with the offset-fit check and UTF-8 validation the issue calls
    out.
    - Fix `view_from_dict_values` dropping null dictionary *values*: they
    became empty strings
    rather than nulls, where `unpack_dictionary` and `impl
    From<&GenericByteArray>` both
        produce nulls.
    - Bounds-check the dictionary key before indexing the offsets, turning
    an out-of-range key
    from undefined behaviour into an error. The gate confines this loop to
    short row counts,
    so it is within noise when the dictionary holds no nulls. When the
    dictionary does hold
    nulls, the null check costs 25-40% on the direct path an unavoidable
    random bitmap
    lookup per row, and the price of emitting nulls rather than the empty
    strings the
        previous code produced.
    
    
    ## Benchmarks
    
    Existing fast paths on main:
    
    | cast | rows | dict values | main | this PR | change |
    |---|---|---|---|---|---|
    | `Utf8->Utf8View` | 8,192 | 100 | 26.3 µs | 3.4 µs | **-87.2%** |
    | `Utf8->Utf8View` | 1,000,000 | 1,000 | 3188.9 µs | 554.6 µs |
    **-82.6%** |
    | `Binary->BinaryView` | 8,192 | 100 | 24.9 µs | 3.3 µs | **-86.6%** |
    | `Binary->BinaryView` | 1,000,000 | 1,000 | 3296.0 µs | 593.6 µs |
    **-82.0%** |
    
    New arms, sparse shapes (dictionary larger than the array):
    
    | cast | rows | dict values | main | this PR | change |
    |---|---|---|---|---|---|
    | `LargeUtf8->Utf8View` | 1,000 | 100,000 | 271.2 µs | 3.9 µs |
    **-98.6%** |
    | `LargeUtf8->BinaryView` | 10,000 | 1,000,000 | 15752.5 µs | 78.6 µs |
    **-99.5%** |
    | `LargeBinary->BinaryView` | 1,000 | 100,000 | 276.1 µs | 3.9 µs |
    **-98.6%** |
    | `Utf8->BinaryView` | 10,000 | 1,000,000 | 3005.8 µs | 73.1 µs |
    **-97.6%** |
    | `Binary->Utf8View` | 1,000 | 100,000 | 351.5 µs | 88.6 µs | **-74.8%**
    |
    | `LargeBinary->Utf8View` | 10,000 | 1,000,000 | 4570.7 µs | 1713.9 µs |
    **-62.5%** |
    
    (`Binary`/`LargeBinary -> Utf8View` gain less because UTF-8 validation
    of the dictionary
    values dominates.)
    
    Dense shapes on the new arms are unchanged, -2.9% to +1.2% the gate
    routes them to
    `unpack_dictionary`. Worst cell across the whole matrix is +5.0%, on a
    shape where both
    versions take the direct path and the delta is the null and bounds
    checks above.
    
    <details>
    <summary>Method</summary>
    
    Both implementations were compiled into a single binary and timed in
    alternating rounds so
    thermal drift cancels out of the ratio. Cells whose code is identical in
    both versions come
    out at -2.9% to +1.2%, which bounds the method's noise; a two-run
    criterion comparison of
    those same cells reported up to +47% drift, which is why it wasn't used.
    Outputs are
    asserted equal per cell before timing. Measured on an i7-11700F; a
    second independent run
    reproduced every headline result within 3.2 points and produced the
    identical set of cells
    above 50%; the largest shift on any cell was 8.3 points, on a cell that
    is flat in both runs.
    
    
    </details>
    
    # Are these changes tested?
    
    <!--
    We typically require tests for all PRs in order to:
    1. Prevent the code from being accidentally broken by subsequent changes
    2. Serve as another way to document the expected behavior of the code
    
    If tests are not included in your PR, please explain why (for example,
    are they covered by existing tests)?
    
    If this PR claims a performance improvement, please include evidence
    such as benchmark results.
    -->
    Yes. Every arm is exercised through **both** implementations (row counts
    either side of the
    gate), with results asserted equal to the `unpack_dictionary` reference
    in each case. Null
    dictionary values, null keys, and invalid UTF-8 under both `safe` and
    strict `CastOptions`
     are covered by dedicated tests.
    
    
    # Are there any user-facing changes?
    Casting `Dictionary<_, Utf8> -> Utf8View` and `Dictionary<_, Binary> ->
    BinaryView` becomes
    substantially faster at typical batch shapes 5-8x on the shapes
    benchmarked above.
    
    `Dictionary<_, LargeUtf8/LargeBinary> -> Utf8View/BinaryView` and the
    `Utf8` <-> `Binary`
    cross casts gain a fast path when the dictionary holds more than twice
    as many values as the
    array has rows. Outside that they behave as before, going through
    `unpack_dictionary`.
    
      Two behaviour changes, called out explicitly:
    
    1. A null dictionary *value* now casts to null rather than an empty
    string:
    
      ```rust
      // values ["aa", NULL, "cc"], keys [0, 1, 2]
      cast(&dict, &DataType::Utf8View)   // before: ["aa", "",   "cc"]
                                         // after:  ["aa", null, "cc"]
    ```
      2. An out-of-range dictionary key now returns an `InvalidArgumentError` 
instead of being
      undefined behaviour. Only reachable for a dictionary built without 
validation.
    
      Happy to split either into its own PR if you'd prefer this one stay 
purely about the fast path.
    
    ---------
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-cast/src/cast/dictionary.rs | 288 ++++++++++++++++++++++++++++++++++++--
 1 file changed, 278 insertions(+), 10 deletions(-)

diff --git a/arrow-cast/src/cast/dictionary.rs 
b/arrow-cast/src/cast/dictionary.rs
index 367084cbdc..0acf12922a 100644
--- a/arrow-cast/src/cast/dictionary.rs
+++ b/arrow-cast/src/cast/dictionary.rs
@@ -28,25 +28,98 @@ pub(crate) fn dictionary_cast<K: ArrowDictionaryKeyType>(
 ) -> Result<ArrayRef, ArrowError> {
     use DataType::*;
 
+    /// Whether the dictionary is sparse; gates short of the measured 0.6x 
crossover for margin.
+    #[inline]
+    fn is_sparse<K: ArrowDictionaryKeyType>(array: &DictionaryArray<K>) -> 
bool {
+        array.keys().len() < array.values().len() / 2
+    }
+
+    #[inline]
+    fn values_buffer_fits_in_view<T: ByteArrayType>(values: 
&GenericByteArray<T>) -> bool {
+        values.values().len() < i32::MAX as usize
+    }
+
     let array = array.as_dictionary::<K>();
     let from_child_type = array.values().data_type();
     match (from_child_type, to_type) {
         (_, Dictionary(to_index_type, to_value_type)) => {
             dictionary_to_dictionary_cast(array, to_index_type, to_value_type, 
cast_options)
         }
-        // `unpack_dictionary` can handle Utf8View/BinaryView types, but 
incurs unnecessary data
-        // copy of the value buffer. Fast path which avoids copying underlying 
values buffer.
-        // TODO: handle LargeUtf8/LargeBinary -> View (need to check offsets 
can fit)
-        // TODO: handle cross types (String -> BinaryView, Binary -> 
StringView)
-        //       (need to validate utf8?)
-        (Utf8, Utf8View) => view_from_dict_values::<K, Utf8Type, 
StringViewType>(
-            array.keys(),
-            array.values().as_string::<i32>(),
-        ),
-        (Binary, BinaryView) => view_from_dict_values::<K, BinaryType, 
BinaryViewType>(
+        // `unpack_dictionary` operates per dictionary value before using take 
kernel to form
+        // final view. `view_from_dict_values` builds output view directly per 
row (key index).
+        // Based on benchmarking, `view_from_dict_values` is more efficient 
for sparse dictionaries
+        // (more dictionary values than there are rows/keys), whilst 
`unpack_dictionary` is
+        // more efficient for dense dictionaries, where a sparse dictionary is 
when rows reach
+        // roughly 0.6x the dictionary size.
+        //
+        // Therefore delegate to the faster method based on the density of the 
input dictionary.
+        (Utf8, Utf8View) if is_sparse(array) => {
+            view_from_dict_values::<K, Utf8Type, StringViewType>(
+                array.keys(),
+                array.values().as_string::<i32>(),
+            )
+        }
+        (Binary, BinaryView) if is_sparse(array) => {
+            view_from_dict_values::<K, BinaryType, BinaryViewType>(
+                array.keys(),
+                array.values().as_binary::<i32>(),
+            )
+        }
+        // `view_from_dict_values` directly appends the values buffer as a 
block using
+        // `GenericByteViewBuilder::append_block` which asserts length of the 
buffer; we must
+        // ensure this assertion holds to use it for large variants which may 
exceed the max allowable length.
+        // If we exceed the length we can simply fallback to 
`unpack_dictionary` which still builds it
+        // correctly.
+        (LargeUtf8, Utf8View)
+            if is_sparse(array)
+                && 
values_buffer_fits_in_view(array.values().as_string::<i64>()) =>
+        {
+            view_from_dict_values::<K, LargeUtf8Type, StringViewType>(
+                array.keys(),
+                array.values().as_string::<i64>(),
+            )
+        }
+        (LargeBinary, BinaryView)
+            if is_sparse(array)
+                && 
values_buffer_fits_in_view(array.values().as_binary::<i64>()) =>
+        {
+            view_from_dict_values::<K, LargeBinaryType, BinaryViewType>(
+                array.keys(),
+                array.values().as_binary::<i64>(),
+            )
+        }
+        // Cross casts to a binary view need no validation: valid UTF-8 is 
valid binary.
+        (Utf8, BinaryView) if is_sparse(array) => {
+            view_from_dict_values::<K, Utf8Type, BinaryViewType>(
+                array.keys(),
+                array.values().as_string::<i32>(),
+            )
+        }
+        (LargeUtf8, BinaryView)
+            if is_sparse(array)
+                && 
values_buffer_fits_in_view(array.values().as_string::<i64>()) =>
+        {
+            view_from_dict_values::<K, LargeUtf8Type, BinaryViewType>(
+                array.keys(),
+                array.values().as_string::<i64>(),
+            )
+        }
+        // Cross casts to a string view require UTF-8 validation of the 
dictionary values.
+        (Binary, Utf8View) if is_sparse(array) => 
binary_dict_to_string_view::<K, i32>(
             array.keys(),
             array.values().as_binary::<i32>(),
+            cast_options,
         ),
+        (LargeBinary, Utf8View)
+            if is_sparse(array)
+                && 
values_buffer_fits_in_view(array.values().as_binary::<i64>()) =>
+        {
+            binary_dict_to_string_view::<K, i64>(
+                array.keys(),
+                array.values().as_binary::<i64>(),
+                cast_options,
+            )
+        }
         _ => unpack_dictionary(array, to_type, cast_options),
     }
 }
@@ -126,6 +199,73 @@ fn dictionary_to_dictionary_cast<K: 
ArrowDictionaryKeyType>(
     Ok(new_array)
 }
 
+/// Cast `Dict<K, Binary>` or `Dict<K, LargeBinary>` to `Utf8View`, validating 
UTF-8 for each
+/// dictionary value.
+///
+/// Fast path when all values are valid UTF-8: reuses the values buffer 
without copying.
+/// When some values are invalid and `cast_options.safe` is true, rows 
pointing to those
+/// values become null. When `cast_options.safe` is false, returns an error 
immediately.
+fn binary_dict_to_string_view<K: ArrowDictionaryKeyType, O: OffsetSizeTrait>(
+    keys: &PrimitiveArray<K>,
+    values: &GenericByteArray<GenericBinaryType<O>>,
+    cast_options: &CastOptions,
+) -> Result<ArrayRef, ArrowError> {
+    match GenericStringArray::<O>::try_from_binary(values.clone()) {
+        Ok(_) => {
+            // All dictionary values are valid UTF-8: reuse the buffer 
zero-copy.
+            view_from_dict_values::<K, GenericBinaryType<O>, 
StringViewType>(keys, values)
+        }
+        Err(e) => {
+            if !cast_options.safe {
+                return Err(e);
+            }
+            // safe=true: validate each dictionary value individually so we 
can nullify
+            // only the rows whose key points to a null or invalid UTF-8 value.
+            let valid: Vec<bool> = (0..values.len())
+                .map(|i| !values.is_null(i) && 
std::str::from_utf8(values.value(i)).is_ok())
+                .collect();
+
+            let value_buffer = values.values();
+            let value_offsets = values.value_offsets();
+            let mut builder = StringViewBuilder::with_capacity(keys.len());
+            builder.append_block(value_buffer.clone());
+
+            for key in keys {
+                match key {
+                    Some(v) => {
+                        let idx = v.to_usize().ok_or_else(|| {
+                            ArrowError::ComputeError("Invalid dictionary 
index".to_string())
+                        })?;
+                        let is_valid = *valid.get(idx).ok_or_else(|| {
+                            ArrowError::InvalidArgumentError(format!(
+                                "Dictionary key {idx} out of bounds for 
dictionary values of length {}",
+                                valid.len()
+                            ))
+                        })?;
+                        if is_valid {
+                            // Safety:
+                            // (1) `idx` and `idx + 1` are in bounds, checked 
above
+                            // (2) offsets are monotonically increasing, so 
end >= offset
+                            // (3) the slice [offset..end] is within the buffer
+                            // (4) the bytes are valid UTF-8, checked above
+                            unsafe {
+                                let offset = 
value_offsets.get_unchecked(idx).as_usize();
+                                let end = value_offsets.get_unchecked(idx + 
1).as_usize();
+                                let length = end - offset;
+                                builder.append_view_unchecked(0, offset as 
u32, length as u32);
+                            }
+                        } else {
+                            builder.append_null();
+                        }
+                    }
+                    None => builder.append_null(),
+                }
+            }
+            Ok(Arc::new(builder.finish()))
+        }
+    }
+}
+
 fn view_from_dict_values<K: ArrowDictionaryKeyType, V: ByteArrayType, T: 
ByteViewType>(
     keys: &PrimitiveArray<K>,
     values: &GenericByteArray<V>,
@@ -561,3 +701,131 @@ where
     }
     Ok(Arc::new(b.finish()))
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use DataType::*;
+
+    // Too few keys for the dictionary, so `is_sparse` holds and views are 
built per row.
+    // Covers an inlined value, a buffer backed one, a null key and a null 
dictionary value.
+    fn sparse_keys(values: &ArrayRef) -> Int32Array {
+        let keys = Int32Array::from(vec![Some(0), Some(2), None, Some(3)]);
+        assert!(
+            keys.len() < values.len() / 2,
+            "keys must reach the direct path"
+        );
+        keys
+    }
+
+    // One key per value, so `is_sparse` fails and `unpack_dictionary` runs.
+    fn dense_keys(values: &ArrayRef) -> Int32Array {
+        let keys = Int32Array::from(vec![
+            Some(0),
+            Some(2),
+            None,
+            Some(3),
+            Some(1),
+            Some(0),
+            Some(4),
+            Some(2),
+            Some(3),
+            Some(5),
+        ]);
+        assert!(
+            keys.len() >= values.len() / 2,
+            "keys must reach unpack_dictionary"
+        );
+        keys
+    }
+
+    fn make_dict(keys: &Int32Array, values: &ArrayRef) -> 
DictionaryArray<Int32Type> {
+        DictionaryArray::try_new(keys.clone(), values.clone()).unwrap()
+    }
+
+    #[test]
+    fn test_dict_to_view_matches_take_then_cast() {
+        let long = "a value over twelve bytes";
+        let utf8: ArrayRef = Arc::new(StringArray::from(vec![
+            Some("aa"),
+            Some("bb"),
+            Some(long),
+            None,
+            Some("ee"),
+            Some("ff"),
+            Some("gg"),
+            Some("hh"),
+            Some("ii"),
+            Some("jj"),
+        ]));
+
+        for from in [Utf8, LargeUtf8, Binary, LargeBinary] {
+            let values = cast(&utf8, &from).unwrap();
+            for to in [Utf8View, BinaryView] {
+                for keys in [sparse_keys(&values), dense_keys(&values)] {
+                    let expected = cast(&take(&values, &keys, None).unwrap(), 
&to).unwrap();
+                    let casted = cast(&make_dict(&keys, &values), 
&to).unwrap();
+                    assert_eq!(casted.as_ref(), expected.as_ref(), "{from:?} 
-> {to:?}");
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn test_dict_binary_to_utf8view_invalid_utf8() {
+        let bytes: Vec<&[u8]> = vec![
+            b"aa",
+            b"bb",
+            &[0xFF, 0xFE],
+            b"dd",
+            b"ee",
+            b"ff",
+            b"gg",
+            b"hh",
+            b"ii",
+            b"jj",
+        ];
+        let strict = CastOptions {
+            safe: false,
+            ..Default::default()
+        };
+        let safe = CastOptions {
+            safe: true,
+            ..Default::default()
+        };
+
+        for values in [
+            Arc::new(BinaryArray::from_vec(bytes.clone())) as ArrayRef,
+            Arc::new(LargeBinaryArray::from_vec(bytes.clone())) as ArrayRef,
+        ] {
+            // index 2 holds the invalid value, so keys pointing at it are 
nullified when safe
+            for (keys, expected) in [
+                (
+                    sparse_keys(&values),
+                    vec![Some("aa"), None, None, Some("dd")],
+                ),
+                (
+                    dense_keys(&values),
+                    vec![
+                        Some("aa"),
+                        None,
+                        None,
+                        Some("dd"),
+                        Some("bb"),
+                        Some("aa"),
+                        Some("ee"),
+                        None,
+                        Some("dd"),
+                        Some("ff"),
+                    ],
+                ),
+            ] {
+                let dict = make_dict(&keys, &values);
+                assert!(cast_with_options(&dict, &Utf8View, &strict).is_err());
+
+                let casted = cast_with_options(&dict, &Utf8View, 
&safe).unwrap();
+                assert_eq!(casted.as_string_view(), 
&StringViewArray::from(expected));
+            }
+        }
+    }
+}

Reply via email to