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

mbutrovich pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 7b00c40cd1 perf: optimize `cast_binary_to_string` binary-format styles 
(up to 27x faster) (#4912)
7b00c40cd1 is described below

commit 7b00c40cd1c4f875bee4032d8c34f6c9788650b0
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 22 10:27:28 2026 -0600

    perf: optimize `cast_binary_to_string` binary-format styles (up to 27x 
faster) (#4912)
    
    * perf: optimize cast_binary_to_string in datafusion-comet-spark-expr
    
    * chore: drop now-unused hex dependency from spark-expr
    
    * cast_binary_to_string: simpler write_i8/write_upper_hex, tighter 
capacity, invariant note
    
    write_i8 collapses to write!(out, "{}", byte as i8) so the manual widening 
and three-digit expansion drop out. write_upper_hex builds a two-byte ASCII 
slice and writes it once via write_str + from_utf8_unchecked instead of two 
write_char calls through char::encode_utf8. Sizes the UTF-8 capacity at 
value_bytes (valid UTF-8 fits 1x; the builder grows on rare U+FFFD expansion) 
and drops the redundant + num_rows on the Base64 estimate since div_ceil(3) 
already rounds each row to a full 4- [...]
---
 native/Cargo.lock                                  |   1 -
 native/spark-expr/Cargo.toml                       |   5 +-
 native/spark-expr/benches/cast_binary_to_string.rs | 115 +++++++++
 native/spark-expr/src/conversion_funcs/cast.rs     | 257 +++++++++++++++------
 4 files changed, 306 insertions(+), 72 deletions(-)

diff --git a/native/Cargo.lock b/native/Cargo.lock
index c9ae0b0520..0b5d1dd615 100644
--- a/native/Cargo.lock
+++ b/native/Cargo.lock
@@ -2057,7 +2057,6 @@ dependencies = [
  "datafusion-comet-common",
  "datafusion-comet-jni-bridge",
  "futures",
- "hex",
  "jni 0.22.4",
  "num",
  "rand 0.10.2",
diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index 0c34bb7810..e8b47f8eb5 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -42,7 +42,6 @@ jni = "0.22.4"
 futures = { workspace = true }
 twox-hash = "2.1.2"
 rand = { workspace = true }
-hex = "0.4.3"
 base64 = "0.22.1"
 
 [dev-dependencies]
@@ -140,6 +139,10 @@ harness = false
 name = "parse_url"
 harness = false
 
+[[bench]]
+name = "cast_binary_to_string"
+harness = false
+
 [[bench]]
 name = "get_json_object"
 harness = false
diff --git a/native/spark-expr/benches/cast_binary_to_string.rs 
b/native/spark-expr/benches/cast_binary_to_string.rs
new file mode 100644
index 0000000000..8ae9b61761
--- /dev/null
+++ b/native/spark-expr/benches/cast_binary_to_string.rs
@@ -0,0 +1,115 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use arrow::array::builder::BinaryBuilder;
+use arrow::array::ArrayRef;
+use arrow::datatypes::DataType;
+use criterion::{criterion_group, criterion_main, Criterion};
+use datafusion::physical_plan::ColumnarValue;
+use datafusion_comet_spark_expr::{spark_cast, BinaryOutputStyle, EvalMode, 
SparkCastOptions};
+use std::hint::black_box;
+use std::sync::Arc;
+
+/// Build a Binary array of `size` rows, each row holding `width` 
pseudo-random bytes, with a
+/// null every `null_every` rows (0 = no nulls).
+fn build(size: usize, width: usize, null_every: usize) -> ArrayRef {
+    let mut builder = BinaryBuilder::with_capacity(size, size * width);
+    let mut state: u32 = 0x9e37_79b9;
+    for i in 0..size {
+        if null_every != 0 && i % null_every == 0 {
+            builder.append_null();
+        } else {
+            let value: Vec<u8> = (0..width)
+                .map(|_| {
+                    state = 
state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
+                    (state >> 24) as u8
+                })
+                .collect();
+            builder.append_value(&value);
+        }
+    }
+    Arc::new(builder.finish())
+}
+
+fn options(style: Option<BinaryOutputStyle>) -> SparkCastOptions {
+    let mut options = SparkCastOptions::new(EvalMode::Legacy, "UTC", false);
+    options.binary_output_style = style;
+    options
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+    let size = 8192;
+
+    let short = build(size, 8, 0);
+    let long = build(size, 64, 0);
+    let with_nulls = build(size, 16, 17);
+
+    let mut bench = |name: &str, arr: &ArrayRef, style: 
Option<BinaryOutputStyle>| {
+        let cast_options = options(style);
+        c.bench_function(name, |b| {
+            b.iter(|| {
+                black_box(
+                    spark_cast(
+                        ColumnarValue::Array(Arc::clone(arr)),
+                        black_box(&DataType::Utf8),
+                        black_box(&cast_options),
+                    )
+                    .unwrap(),
+                )
+            })
+        });
+    };
+
+    bench(
+        "cast_binary_to_string: hex discrete, short values",
+        &short,
+        Some(BinaryOutputStyle::HexDiscrete),
+    );
+    bench(
+        "cast_binary_to_string: hex discrete, long values",
+        &long,
+        Some(BinaryOutputStyle::HexDiscrete),
+    );
+    bench(
+        "cast_binary_to_string: hex discrete, with nulls",
+        &with_nulls,
+        Some(BinaryOutputStyle::HexDiscrete),
+    );
+    bench(
+        "cast_binary_to_string: hex, long values",
+        &long,
+        Some(BinaryOutputStyle::Hex),
+    );
+    bench(
+        "cast_binary_to_string: basic, long values",
+        &long,
+        Some(BinaryOutputStyle::Basic),
+    );
+    bench(
+        "cast_binary_to_string: base64, long values",
+        &long,
+        Some(BinaryOutputStyle::Base64),
+    );
+    bench(
+        "cast_binary_to_string: default style, long values",
+        &long,
+        None,
+    );
+}
+
+criterion_group!(benches, criterion_benchmark);
+criterion_main!(benches);
diff --git a/native/spark-expr/src/conversion_funcs/cast.rs 
b/native/spark-expr/src/conversion_funcs/cast.rs
index 8c2e95ab17..37fddb8c11 100644
--- a/native/spark-expr/src/conversion_funcs/cast.rs
+++ b/native/spark-expr/src/conversion_funcs/cast.rs
@@ -63,9 +63,8 @@ use datafusion::common::{internal_err, DataFusionError, 
Result as DataFusionResu
 use datafusion::physical_expr::PhysicalExpr;
 use datafusion::physical_plan::ColumnarValue;
 use datafusion_comet_common::decode_utf8_spark_lossy;
-use std::borrow::Cow;
 use std::{
-    fmt::{Debug, Display, Formatter},
+    fmt::{Debug, Display, Formatter, Write},
     hash::Hash,
     sync::Arc,
 };
@@ -794,6 +793,50 @@ impl PhysicalExpr for Cast {
     }
 }
 
+const UPPER_HEX_DIGITS: [u8; 16] = *b"0123456789ABCDEF";
+
+/// Writes `byte` as two uppercase hex digits.
+#[inline]
+fn write_upper_hex<W: Write>(out: &mut W, byte: u8) -> std::fmt::Result {
+    let buf = [
+        UPPER_HEX_DIGITS[(byte >> 4) as usize],
+        UPPER_HEX_DIGITS[(byte & 0x0f) as usize],
+    ];
+    // SAFETY: both bytes come from the ASCII UPPER_HEX_DIGITS table, so `buf` 
is valid UTF-8.
+    out.write_str(unsafe { std::str::from_utf8_unchecked(&buf) })
+}
+
+/// Writes `byte` reinterpreted as a signed decimal, as Spark does when 
printing a byte array.
+#[inline]
+fn write_i8<W: Write>(out: &mut W, byte: u8) -> std::fmt::Result {
+    write!(out, "{}", byte as i8)
+}
+
+/// Writes the bytes of `value` between square brackets, encoded with `encode` 
and joined by
+/// `separator`.
+fn write_bracketed<W: Write>(
+    out: &mut W,
+    value: &[u8],
+    separator: &str,
+    encode: fn(&mut W, u8) -> std::fmt::Result,
+) -> std::fmt::Result {
+    out.write_char('[')?;
+    for (i, byte) in value.iter().enumerate() {
+        if i > 0 {
+            out.write_str(separator)?;
+        }
+        encode(out, *byte)?;
+    }
+    out.write_char(']')
+}
+
+/// Casts a binary array to a string array. Without a binary output style the 
bytes are
+/// reinterpreted as a string as-is, which is what Spark's `Cast` does.
+///
+/// The other styles mimic the [BinaryFormatter]: 
https://github.com/apache/spark/blob/v4.0.0/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala#L449-L468
+/// used by SparkSQL's ToPrettyString expression.
+/// The BinaryFormatter was [introduced]: 
https://issues.apache.org/jira/browse/SPARK-47911 in Spark 4.0.0
+/// Before Spark 4.0.0, the default is SPACE_DELIMITED_UPPERCASE_HEX
 fn cast_binary_to_string<O: OffsetSizeTrait>(
     array: &dyn Array,
     spark_cast_options: &SparkCastOptions,
@@ -803,77 +846,63 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
         .downcast_ref::<GenericByteArray<GenericBinaryType<O>>>()
         .unwrap();
 
-    // Build with a GenericStringBuilder and append &str straight from the 
decoder's Cow so the
-    // common valid-UTF-8 cast path copies the bytes once (into the Arrow 
value buffer) instead of
-    // twice (an owned String, then a copy into the buffer).
-    let mut builder = GenericStringBuilder::<O>::new();
-    for value in input.iter() {
-        match value {
-            Some(value) => match spark_cast_options.binary_output_style {
-                // ToPrettyString styles (Spark 4.0+) build owned strings, 
which is unavoidable.
-                Some(s) => builder.append_value(spark_binary_formatter(value, 
s)),
-                // Default CAST(binary AS string): borrows in the valid path, 
appends once.
-                None => builder.append_value(cast_binary_formatter(value)),
-            },
-            None => builder.append_null(),
-        }
-    }
-    Ok(Arc::new(builder.finish()))
-}
+    let num_rows = input.len();
+    let offsets = input.value_offsets();
+    let value_bytes = offsets[num_rows].as_usize() - offsets[0].as_usize();
+    // Upper bound on the encoded length so the value buffer is allocated 
once. For the
+    // JVM-lossy UTF-8 decoder path, valid UTF-8 sits at 1x; the builder grows 
on the rare
+    // invalid byte that expands to U+FFFD. Base64 rounds up to a full 4-char 
group via
+    // div_ceil, which already covers the group padding.
+    let capacity = match spark_cast_options.binary_output_style {
+        None | Some(BinaryOutputStyle::Utf8) => value_bytes,
+        Some(BinaryOutputStyle::Basic) => 6 * value_bytes + 2 * num_rows,
+        Some(BinaryOutputStyle::Base64) => 4 * value_bytes.div_ceil(3),
+        Some(BinaryOutputStyle::Hex) => 2 * value_bytes,
+        Some(BinaryOutputStyle::HexDiscrete) => 3 * value_bytes + 2 * num_rows,
+    };
 
-/// This function mimics the [BinaryFormatter]: 
https://github.com/apache/spark/blob/v4.0.0/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ToStringBase.scala#L449-L468
-/// used by SparkSQL's ToPrettyString expression.
-/// The BinaryFormatter was [introduced]: 
https://issues.apache.org/jira/browse/SPARK-47911 in Spark 4.0.0
-/// Before Spark 4.0.0, the default is SPACE_DELIMITED_UPPERCASE_HEX
-fn spark_binary_formatter(value: &[u8], binary_output_style: 
BinaryOutputStyle) -> String {
-    match binary_output_style {
-        // Spark's UTF8 BinaryFormatter renders via 
`UTF8String.fromBytes(bytes).toString`, i.e.
-        // `new String(bytes, UTF_8)`. Route through the shared JVM-compatible 
decoder so invalid
-        // bytes become U+FFFD (matching Spark) instead of panicking on 
non-UTF-8 input (#4488).
-        BinaryOutputStyle::Utf8 => decode_utf8_spark_lossy(value).into_owned(),
-        BinaryOutputStyle::Basic => {
-            format!(
-                "{:?}",
-                value
-                    .iter()
-                    .map(|v| i8::from_ne_bytes([*v]))
-                    .collect::<Vec<i8>>()
-            )
-        }
-        BinaryOutputStyle::Base64 => BASE64_STANDARD_NO_PAD.encode(value),
-        BinaryOutputStyle::Hex => value
-            .iter()
-            .map(|v| hex::encode_upper([*v]))
-            .collect::<String>(),
-        BinaryOutputStyle::HexDiscrete => {
+    let mut builder = GenericStringBuilder::<O>::with_capacity(num_rows, 
capacity);
+    // Base64 is the only style that cannot encode straight into the builder.
+    let mut base64_buffer = String::new();
+    for value in input.iter() {
+        let Some(value) = value else {
+            // The previous iteration always finalized its row with 
`append_value("")`, so no
+            // bytes are pending in the builder here; a future edit that adds 
a `continue` or
+            // an early return inside the match below would break that 
invariant.
+            builder.append_null();
+            continue;
+        };
+        // Encode directly into the builder's value buffer; `append_value("")` 
then terminates
+        // the row. Writing to the builder is infallible.
+        let written = match spark_cast_options.binary_output_style {
+            // Default CAST(binary AS string) and the UTF8 ToPrettyString 
style (Spark 4.0+) both
+            // render via `new String(bytes, UTF_8)`. Route through the shared 
JVM-compatible lossy
+            // decoder so ill-formed bytes become U+FFFD (matching Spark) 
instead of being
+            // reinterpreted unchecked (UB) or panicking on non-UTF-8 input 
(#4488, #4763). The
+            // valid-UTF-8 path borrows, so it copies once (into the Arrow 
value buffer). Divergence
+            // for byte-level round-trips such as CAST(CAST(x AS string) AS 
binary) and value
+            // identity is documented in the compatibility guide and tracked 
by #4764.
+            None | Some(BinaryOutputStyle::Utf8) => {
+                builder.write_str(&decode_utf8_spark_lossy(value))
+            }
+            Some(BinaryOutputStyle::Basic) => write_bracketed(&mut builder, 
value, ", ", write_i8),
+            Some(BinaryOutputStyle::Base64) => {
+                base64_buffer.clear();
+                BASE64_STANDARD_NO_PAD.encode_string(value, &mut 
base64_buffer);
+                builder.write_str(&base64_buffer)
+            }
+            Some(BinaryOutputStyle::Hex) => value
+                .iter()
+                .try_for_each(|byte| write_upper_hex(&mut builder, *byte)),
             // Spark's default SPACE_DELIMITED_UPPERCASE_HEX
-            format!(
-                "[{}]",
-                value
-                    .iter()
-                    .map(|v| hex::encode_upper([*v]))
-                    .collect::<Vec<String>>()
-                    .join(" ")
-            )
-        }
+            Some(BinaryOutputStyle::HexDiscrete) => {
+                write_bracketed(&mut builder, value, " ", write_upper_hex)
+            }
+        };
+        written.expect("writing to a string builder cannot fail");
+        builder.append_value("");
     }
-}
-
-fn cast_binary_formatter(value: &[u8]) -> Cow<'_, str> {
-    // CAST(binary AS string) reinterprets the bytes as UTF-8, like Spark's 
UTF8String.fromBytes.
-    // Spark keeps the raw bytes, but Arrow's Utf8 type requires valid UTF-8, 
and building a String
-    // from non-UTF-8 bytes is undefined behaviour (#4488). Decode 
JVM-compatibly-lossily instead:
-    // `decode_utf8_spark_lossy` replaces ill-formed sequences with U+FFFD 
exactly as Spark's
-    // `new String(bytes, UTF_8)` does (the same decoder Comet's native 
shuffle uses, #4521). The
-    // result is memory-safe valid UTF-8, never feeds invalid bytes into 
downstream native string
-    // kernels, and matches Spark's rendered output byte-for-byte. It diverges 
from Spark for
-    // operations that read the underlying bytes rather than the rendered 
text: byte-level round-trips
-    // such as CAST(CAST(x AS string) AS binary) (Spark still has the original 
bytes), and value
-    // identity, since every ill-formed sequence decodes to the same U+FFFD 
and so two strings that
-    // differ in Spark can compare equal in Comet. Both are documented in the 
compatibility guide and
-    // tracked by #4764. Returning `Cow` lets the valid path borrow so the 
caller appends without an
-    // intermediate allocation.
-    decode_utf8_spark_lossy(value)
+    Ok(Arc::new(builder.finish()))
 }
 
 #[cfg(test)]
@@ -934,6 +963,94 @@ mod tests {
         assert_eq!(strings.value(3), "\u{FFFD}");
     }
 
+    #[test]
+    fn test_cast_binary_to_string_styles() {
+        let input: ArrayRef = Arc::new(BinaryArray::from_opt_vec(vec![
+            Some(b"\x00\x01\xfe".as_slice()),
+            Some(b"".as_slice()),
+            None,
+            Some(b"hi".as_slice()),
+        ]));
+        let cast = |style: Option<BinaryOutputStyle>| {
+            let mut options = SparkCastOptions::new(EvalMode::Legacy, "UTC", 
false);
+            options.binary_output_style = style;
+            let result = spark_cast(
+                ColumnarValue::Array(Arc::clone(&input)),
+                &DataType::Utf8,
+                &options,
+            )
+            .unwrap()
+            .into_array(input.len())
+            .unwrap();
+            let result = result.as_string::<i32>();
+            (0..result.len())
+                .map(|i| (!result.is_null(i)).then(|| 
result.value(i).to_string()))
+                .collect::<Vec<_>>()
+        };
+
+        assert_eq!(
+            cast(Some(BinaryOutputStyle::HexDiscrete)),
+            vec![
+                Some("[00 01 FE]".to_string()),
+                Some("[]".to_string()),
+                None,
+                Some("[68 69]".to_string()),
+            ]
+        );
+        assert_eq!(
+            cast(Some(BinaryOutputStyle::Hex)),
+            vec![
+                Some("0001FE".to_string()),
+                Some("".to_string()),
+                None,
+                Some("6869".to_string()),
+            ]
+        );
+        assert_eq!(
+            cast(Some(BinaryOutputStyle::Basic)),
+            vec![
+                Some("[0, 1, -2]".to_string()),
+                Some("[]".to_string()),
+                None,
+                Some("[104, 105]".to_string()),
+            ]
+        );
+        assert_eq!(
+            cast(Some(BinaryOutputStyle::Base64)),
+            vec![
+                Some("AAH+".to_string()),
+                Some("".to_string()),
+                None,
+                Some("aGk".to_string()),
+            ]
+        );
+    }
+
+    #[test]
+    fn 
test_cast_binary_to_string_default_style_valid_utf8_through_spark_cast() {
+        // Exercises the default cast path through the public `spark_cast` 
entry point. Invalid bytes
+        // decode JVM-compatibly-lossily to U+FFFD (#4763), while valid 
multi-byte UTF-8 ("héllo") is
+        // preserved exactly.
+        let input: ArrayRef = Arc::new(BinaryArray::from_opt_vec(vec![
+            Some(b"\xff\xfe".as_slice()),
+            None,
+            Some("héllo".as_bytes()),
+        ]));
+        let options = SparkCastOptions::new(EvalMode::Legacy, "UTC", false);
+        let result = spark_cast(
+            ColumnarValue::Array(Arc::clone(&input)),
+            &DataType::Utf8,
+            &options,
+        )
+        .unwrap()
+        .into_array(input.len())
+        .unwrap();
+        let result = result.as_string::<i32>();
+        assert_eq!(result.value(0), "\u{FFFD}\u{FFFD}");
+        assert!(result.is_null(1));
+        assert_eq!(result.value(2), "héllo");
+    }
+
     #[test]
     fn test_cast_unsupported_timestamp_to_date() {
         // Since datafusion uses chrono::Datetime internally not all dates 
representable by TimestampMicrosecondType are supported


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to