mbutrovich commented on code in PR #4912:
URL: https://github.com/apache/datafusion-comet/pull/4912#discussion_r3591015828
##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -803,77 +856,59 @@ 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. Base64 rounds up
+ // to a multiple of four per row, hence the extra `num_rows` slack. The
default and UTF8 paths
+ // decode JVM-compatibly-lossily, which can expand each byte to a 3-byte
U+FFFD replacement.
+ let capacity = match spark_cast_options.binary_output_style {
+ None | Some(BinaryOutputStyle::Utf8) => 3 * value_bytes,
+ Some(BinaryOutputStyle::Basic) => 6 * value_bytes + 2 * num_rows,
+ Some(BinaryOutputStyle::Base64) => 4 * value_bytes.div_ceil(3) +
num_rows,
+ 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 {
+ builder.append_null();
+ continue;
+ };
Review Comment:
Correctness depends on every non-null row ending with `append_value("")` so
the null branch's early `continue` never inherits pending bytes from a prior
row. This holds today, but a future edit that adds a `continue` or an early
return inside the match would silently merge one row's bytes into the next. A
one-line comment at the null branch stating "the previous iteration always
finalized its row with `append_value(\"\")`, so no bytes are pending here"
documents the invariant.
##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -803,77 +856,59 @@ 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. Base64 rounds up
+ // to a multiple of four per row, hence the extra `num_rows` slack. The
default and UTF8 paths
+ // decode JVM-compatibly-lossily, which can expand each byte to a 3-byte
U+FFFD replacement.
+ let capacity = match spark_cast_options.binary_output_style {
+ None | Some(BinaryOutputStyle::Utf8) => 3 * value_bytes,
Review Comment:
`None | Some(Utf8) => 3 * value_bytes` sizes for the worst case where every
byte expands to a 3-byte U+FFFD. For the common all-valid-UTF-8 column this
reserves 3x the needed value buffer up front and never uses it. The default
path is the least-improved case (1.03x), so the extra reservation is pure cost
there.
Consider sizing at `value_bytes` (exact for valid UTF-8, the builder grows
on the rare invalid byte) or leaving the default/UTF8 path on the plain builder
capacity. Measure the default case before and after so this is evidence-based
rather than a guess.
##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -794,6 +793,60 @@ 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 {
+ out.write_char(UPPER_HEX_DIGITS[(byte >> 4) as usize] as char)?;
+ out.write_char(UPPER_HEX_DIGITS[(byte & 0x0f) as usize] as char)
+}
Review Comment:
Two `write_char` calls per byte go through `char::encode_utf8` twice. Since
both digits are ASCII, building a `[u8; 2]` and writing it once as a `&str` is
one `extend_from_slice` of two bytes and removes the `as char` casts.
Suggested change:
```rust
#[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 are ASCII hex digits.
out.write_str(unsafe { std::str::from_utf8_unchecked(&buf) })
}
```
If the `unsafe` is unwelcome, `str::from_utf8(&buf).unwrap()` is fine since
the table is ASCII. Hex is the hottest style (27x, called twice per byte), so
this is the one place a byte-slice write is worth it over `write_char`.
##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -794,6 +793,60 @@ 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 {
+ out.write_char(UPPER_HEX_DIGITS[(byte >> 4) as usize] as char)?;
+ out.write_char(UPPER_HEX_DIGITS[(byte & 0x0f) as usize] as char)
+}
+
+/// 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 {
+ // Widened to `i16` so that negating `i8::MIN` does not overflow.
+ let value = i8::from_ne_bytes([byte]) as i16;
+ let magnitude = if value < 0 {
+ out.write_char('-')?;
+ -value
+ } else {
+ value
+ };
+ if magnitude >= 100 {
+ out.write_char((b'0' + (magnitude / 100) as u8) as char)?;
+ }
+ if magnitude >= 10 {
+ out.write_char((b'0' + (magnitude / 10 % 10) as u8) as char)?;
+ }
+ out.write_char((b'0' + (magnitude % 10) as u8) as char)
+}
Review Comment:
The hand-rolled three-digit expansion is correct but is more code than
needed and is easy to get subtly wrong (the `i16` widening exists solely to
guard `-i8::MIN`). Rust's formatter writes an `i8` straight into any
`fmt::Write` target with no heap allocation, so the whole function collapses to
one line and drops the widening reasoning.
Suggested change:
```rust
#[inline]
fn write_i8<W: Write>(out: &mut W, byte: u8) -> std::fmt::Result {
write!(out, "{}", byte as i8)
}
```
This still writes directly into the builder value buffer (no per-row
`String`), so it keeps the win. If a measurement shows `write!`'s formatting
machinery costs enough to matter on the `basic` case (2.5x today), keep the
manual version but add a benchmark note justifying it, otherwise the manual
code is unjustified.
##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -803,77 +856,59 @@ 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. Base64 rounds up
+ // to a multiple of four per row, hence the extra `num_rows` slack. The
default and UTF8 paths
+ // decode JVM-compatibly-lossily, which can expand each byte to a 3-byte
U+FFFD replacement.
+ let capacity = match spark_cast_options.binary_output_style {
+ None | Some(BinaryOutputStyle::Utf8) => 3 * value_bytes,
+ Some(BinaryOutputStyle::Basic) => 6 * value_bytes + 2 * num_rows,
+ Some(BinaryOutputStyle::Base64) => 4 * value_bytes.div_ceil(3) +
num_rows,
Review Comment:
`4 * value_bytes.div_ceil(3) + num_rows` already rounds each row up to a
full 4-char group via `div_ceil(3)`, then adds another `num_rows`. Since the
encoder is no-pad, the true max is `value_bytes.div_ceil(3) * 4` summed per
row, and the `+ num_rows` is redundant headroom. Harmless (capacity is a hint)
but the comment claims the `+ num_rows` covers the round-up that `div_ceil`
already covers. Drop the `+ num_rows` or fix the comment.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]