perf: optimize cast_binary_to_string binary-format styles (up to 27x faster)#4912
Conversation
cast_binary_to_string (up to 7000x faster)
|
Great improvement @andy. |
It looks like a word is missing from that sentence @parthchandra? |
Oops. |
This PR doesn't change any behavior, but yes, this is a known issue and there is already a PR open to fix this. We should merge that one first. Could you take a look? |
…to_string-datafusion-comet-20260713-121057 # Conflicts: # native/spark-expr/Cargo.toml # native/spark-expr/src/conversion_funcs/cast.rs
cast_binary_to_string (up to 7000x faster)cast_binary_to_string binary-format styles (up to 27x faster)
mbutrovich
left a comment
There was a problem hiding this comment.
First pass, thanks @andygrove!
| /// 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) | ||
| } |
There was a problem hiding this 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:
#[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.
| 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) | ||
| } |
There was a problem hiding this 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:
#[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.
| // 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, |
There was a problem hiding this 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.
| let Some(value) = value else { | ||
| builder.append_null(); | ||
| continue; | ||
| }; |
There was a problem hiding this 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.
| 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, |
There was a problem hiding this 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.
Which issue does this PR close?
N/A
Rationale for this change
cast_binary_to_stringhandlesCAST(binary AS string)and Spark 4.0'sToPrettyStringbinary formatting styles (uppercase hex, space-delimited uppercase hex, base64, and the array-of-signed-bytes "basic" style). The previous implementation allocated an ownedStringper row (and, for the hex styles, per byte), which dominated runtime for the format-heavy styles.What changes are included in this PR?
This branch now includes #4763, which replaced the unsafe zero-copy reinterpret of the default
CAST(binary AS string)path with a JVM-compatible lossy UTF-8 decode (ill-formed bytes become U+FFFD, matching Spark'snew String(bytes, UTF_8)). The default and UTF8 paths therefore go throughdecode_utf8_spark_lossyand are not changed by this PR.The optimization targets the
ToPrettyStringstyles:GenericStringBuilderinstead of building a throwawayStringper row.How are these changes tested?
Existing unit tests in
cast.rscover each style, plus the JVM-compatible lossy decoding of invalid UTF-8 on the default and UTF8 paths.Benchmark (criterion), this PR vs
apache/main:Full criterion output (
--baseline main):