This is an automated email from the ASF dual-hosted git repository.
andygrove 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 38de369fb7 perf: optimize spark_base64 in spark-expr (#4885)
38de369fb7 is described below
commit 38de369fb7ab564c65d459a924633592bb01e3b1
Author: Andy Grove <[email protected]>
AuthorDate: Mon Aug 3 17:57:17 2026 -0600
perf: optimize spark_base64 in spark-expr (#4885)
* perf: optimize spark_base64 in spark-expr
* refactor: unify base64 encoding, O(1) capacity, add array-path tests
Replaces the O(n) pre-pass over the array with an O(1) capacity estimate
from array.value_data().len(), extended with the CRLF slack when chunk is true
so the wrapped-output path no longer under-reserves. The wrap-decision is
single-sourced in a shared encode_into helper, so the array and scalar callers
cannot drift. Removes the near-duplicate chunk_into_lines/chunk_into_lines_buf
split. Adds encode_array_binary_chunked (BinaryArray with mixed nulls, empty,
exactly-76, wrap-once, wrap [...]
* review: remove per-row allocation from chunked base64, fix capacity bound
Encode straight into the output builder's value buffer. `encode_into` now
writes to a `fmt::Write` sink and takes a caller-owned scratch for the
unwrapped encoding, so the array path passes the builder itself: the
temporary
`String` per wrapping row is gone, and so is the copy that staged each row
in a
second buffer. Encoding a batch now performs no per-row heap allocation.
Measured with the committed `base64` benchmark (criterion, 8192 rows, vs the
previous commit on this branch):
| Benchmark | Change |
| --- | --- |
| short, unchunked | ~0% |
| short, chunked | -1.7% |
| long, unchunked | -7.2% |
| long, chunked | -20.8% |
Also make the value-buffer capacity a true upper bound. Rows pad
independently,
so the encoded total is `sum ceil(len_i / 3) * 4`, which cannot be derived
from
the input's total byte count alone: N one-byte rows encode to `4N`, not to
`base64_encoded_len(N)`, so the old estimate under-reserved by ~3x for
arrays of
tiny values. Adding `4 * (N - 1)` makes it a genuine bound and stays O(1),
with
no pass over the offsets.
Tests:
- `chunked_matches_encode_then_split_at_every_length` sweeps every input
length
across three line boundaries, asserting the output equals encoding the
whole
input and splitting every 76 chars, that every non-final line is full, and
that no line exceeds the limit.
- `encoded_capacity_is_an_upper_bound` checks the estimate against the
actual
encoded total for eight array shapes, including the all-one-byte worst
case,
under both chunk settings.
- `encode_array_many_tiny_rows` covers the shape the old estimate
under-reserved
for.
---
native/spark-expr/Cargo.toml | 4 +
native/spark-expr/benches/base64.rs | 84 ++++++++++
native/spark-expr/src/string_funcs/base64.rs | 221 ++++++++++++++++++++++++---
3 files changed, 284 insertions(+), 25 deletions(-)
diff --git a/native/spark-expr/Cargo.toml b/native/spark-expr/Cargo.toml
index 775311935c..5d518387f5 100644
--- a/native/spark-expr/Cargo.toml
+++ b/native/spark-expr/Cargo.toml
@@ -95,6 +95,10 @@ harness = false
name = "padding"
harness = false
+[[bench]]
+name = "base64"
+harness = false
+
[[bench]]
name = "date_trunc"
harness = false
diff --git a/native/spark-expr/benches/base64.rs
b/native/spark-expr/benches/base64.rs
new file mode 100644
index 0000000000..d205e75ab2
--- /dev/null
+++ b/native/spark-expr/benches/base64.rs
@@ -0,0 +1,84 @@
+// 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 criterion::{criterion_group, criterion_main, Criterion};
+use datafusion::common::ScalarValue;
+use datafusion::physical_plan::ColumnarValue;
+use datafusion_comet_spark_expr::spark_base64;
+use std::hint::black_box;
+use std::sync::Arc;
+
+/// Build a binary array with a mix of short values and longer values that
+/// exercise the CRLF line-wrapping path when chunking is enabled.
+fn create_binary_array(size: usize, value_len: usize) -> ArrayRef {
+ let mut builder = BinaryBuilder::new();
+ for i in 0..size {
+ if i % 10 == 0 {
+ builder.append_null();
+ } else {
+ let byte = (i % 251) as u8;
+ let value = vec![byte; value_len];
+ builder.append_value(&value);
+ }
+ }
+ Arc::new(builder.finish())
+}
+
+fn criterion_benchmark(c: &mut Criterion) {
+ let size = 8192;
+ // Short values (encode to <= 76 chars, no wrapping).
+ let short = create_binary_array(size, 16);
+ // Long values (encode to several wrapped lines when chunking).
+ let long = create_binary_array(size, 200);
+
+ c.bench_function("spark_base64: short, unchunked", |b| {
+ let args = vec![
+ ColumnarValue::Array(Arc::clone(&short)),
+ ColumnarValue::Scalar(ScalarValue::Boolean(Some(false))),
+ ];
+ b.iter(|| black_box(spark_base64(black_box(&args)).unwrap()))
+ });
+
+ c.bench_function("spark_base64: short, chunked", |b| {
+ let args = vec![
+ ColumnarValue::Array(Arc::clone(&short)),
+ ColumnarValue::Scalar(ScalarValue::Boolean(Some(true))),
+ ];
+ b.iter(|| black_box(spark_base64(black_box(&args)).unwrap()))
+ });
+
+ c.bench_function("spark_base64: long, unchunked", |b| {
+ let args = vec![
+ ColumnarValue::Array(Arc::clone(&long)),
+ ColumnarValue::Scalar(ScalarValue::Boolean(Some(false))),
+ ];
+ b.iter(|| black_box(spark_base64(black_box(&args)).unwrap()))
+ });
+
+ c.bench_function("spark_base64: long, chunked", |b| {
+ let args = vec![
+ ColumnarValue::Array(Arc::clone(&long)),
+ ColumnarValue::Scalar(ScalarValue::Boolean(Some(true))),
+ ];
+ b.iter(|| black_box(spark_base64(black_box(&args)).unwrap()))
+ });
+}
+
+criterion_group!(benches, criterion_benchmark);
+criterion_main!(benches);
diff --git a/native/spark-expr/src/string_funcs/base64.rs
b/native/spark-expr/src/string_funcs/base64.rs
index 8e901e2a60..d394cfdccf 100644
--- a/native/spark-expr/src/string_funcs/base64.rs
+++ b/native/spark-expr/src/string_funcs/base64.rs
@@ -15,9 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+use std::fmt::{self, Write};
use std::sync::Arc;
-use arrow::array::{Array, AsArray, GenericBinaryArray, OffsetSizeTrait,
StringArray};
+use arrow::array::{
+ Array, AsArray, GenericBinaryArray, GenericStringBuilder, OffsetSizeTrait,
StringArray,
+};
use arrow::datatypes::DataType;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
@@ -62,48 +65,108 @@ pub fn spark_base64(args: &[ColumnarValue]) ->
Result<ColumnarValue, DataFusionE
}
}
-fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk:
bool) -> StringArray {
- array
- .iter()
- .map(|value| value.map(|bytes| encode(bytes, chunk)))
- .collect()
+const LINE_LEN: usize = 76;
+
+/// Panic message for the `fmt::Write` sinks used here; neither can actually
fail.
+const INFALLIBLE_SINK: &str = "writing base64 to an in-memory buffer cannot
fail";
+
+/// Length of the padded base64 encoding of `n` input bytes.
+fn base64_encoded_len(n: usize) -> usize {
+ n.div_ceil(3) * 4
}
-fn encode(bytes: &[u8], chunk: bool) -> String {
- let encoded = BASE64_STANDARD.encode(bytes);
- if chunk {
- chunk_into_lines(encoded)
+/// Length after CRLF wrapping if `encoded_len` bytes are chunked at
`LINE_LEN` chars per line.
+fn chunked_len(encoded_len: usize) -> usize {
+ if encoded_len == 0 {
+ 0
} else {
- encoded
+ encoded_len + ((encoded_len - 1) / LINE_LEN) * 2
}
}
-/// Wrap a base64 string into lines of at most 76 characters joined by CRLF,
with no trailing
-/// separator. Matches `java.util.Base64.getMimeEncoder()`. base64 output is
pure ASCII, so byte
-/// offsets and character offsets coincide. Takes the string by value so the
common short-input
-/// case (no wrapping needed) returns it without a second allocation.
-fn chunk_into_lines(encoded: String) -> String {
- const LINE_LEN: usize = 76;
- if encoded.len() <= LINE_LEN {
- return encoded;
+/// Encodes `bytes` as base64 into `out`, wrapping at `LINE_LEN` when `chunk`
is true.
+///
+/// `scratch` receives the unwrapped encoding from a single bulk
`encode_string` call. Both
+/// `scratch` and `out` are owned by the caller and reused across rows, so
encoding a batch performs
+/// no per-row heap allocation. Because `out` is a `fmt::Write` sink, the
array path can pass the
+/// output builder directly and have the wrapped result land in its value
buffer, rather than
+/// staging each row in a second buffer and copying it in.
+///
+/// An alternative is to skip `scratch` entirely and encode line-sized windows
straight into `out`:
+/// MIME wrapping is aligned to base64's block structure, so 57 input bytes
encode to exactly
+/// `LINE_LEN` chars with no padding, and only the final window can carry
padding. That is correct
+/// and allocation-free, but measurably slower — the per-call overhead of many
small `encode_string`
+/// calls exceeds the cost of one bulk encode plus the copy out. See the
benchmark discussion on
+/// <https://github.com/apache/datafusion-comet/pull/4885>.
+fn encode_into<W: Write>(
+ bytes: &[u8],
+ chunk: bool,
+ scratch: &mut String,
+ out: &mut W,
+) -> fmt::Result {
+ scratch.clear();
+ BASE64_STANDARD.encode_string(bytes, scratch);
+ if !chunk || scratch.len() <= LINE_LEN {
+ return out.write_str(scratch);
}
- let separators = (encoded.len() - 1) / LINE_LEN;
- let mut out = String::with_capacity(encoded.len() + separators * 2);
let mut offset = 0;
- while offset < encoded.len() {
+ while offset < scratch.len() {
if offset > 0 {
- out.push_str("\r\n");
+ out.write_str("\r\n")?;
}
- let end = (offset + LINE_LEN).min(encoded.len());
- out.push_str(&encoded[offset..end]);
+ let end = (offset + LINE_LEN).min(scratch.len());
+ out.write_str(&scratch[offset..end])?;
offset = end;
}
+ Ok(())
+}
+
+/// O(1) upper bound on the total encoded length of `array`, for sizing the
output value buffer.
+///
+/// Each row pads independently, so the encoded total is `sum ceil(len_i / 3)
* 4`, which cannot be
+/// derived from the input's total byte count alone: N rows of one byte each
encode to `4N`, not to
+/// `base64_encoded_len(N)`. Since `sum ceil(x_i) <= ceil(sum x_i) + (N - 1)`,
adding `4 * (N - 1)`
+/// to the whole-input encoding turns the estimate into a true upper bound
while staying O(1) — no
+/// pass over the offsets. It over-reserves by at most 4 bytes per row, which
matters only for
+/// arrays of very short values, where the buffer is small in absolute terms
anyway.
+fn encoded_capacity<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk:
bool) -> usize {
+ let encoded_total =
+ base64_encoded_len(array.value_data().len()) + 4 *
array.len().saturating_sub(1);
+ if chunk {
+ chunked_len(encoded_total)
+ } else {
+ encoded_total
+ }
+}
+
+fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk:
bool) -> StringArray {
+ let mut builder =
+ GenericStringBuilder::<i32>::with_capacity(array.len(),
encoded_capacity(array, chunk));
+ // Reused across rows, so a batch pays no per-row allocation.
+ let mut scratch = String::new();
+ for i in 0..array.len() {
+ if array.is_null(i) {
+ builder.append_null();
+ continue;
+ }
+ encode_into(array.value(i), chunk, &mut scratch, &mut
builder).expect(INFALLIBLE_SINK);
+ // Finalizes the value written through `fmt::Write` above.
+ builder.append_value("");
+ }
+ builder.finish()
+}
+
+fn encode(bytes: &[u8], chunk: bool) -> String {
+ let mut scratch = String::new();
+ let mut out = String::new();
+ encode_into(bytes, chunk, &mut scratch, &mut out).expect(INFALLIBLE_SINK);
out
}
#[cfg(test)]
mod tests {
use super::*;
+ use arrow::array::{BinaryArray, LargeBinaryArray};
#[test]
fn unchunked_is_a_single_line() {
@@ -137,4 +200,112 @@ mod tests {
vec![76, 76, 8]
);
}
+
+ #[test]
+ fn chunked_matches_encode_then_split_at_every_length() {
+ // The window encoding must be byte-for-byte what encoding the whole
input and splitting
+ // every LINE_LEN chars produces. Sweep every length across three
window boundaries so a
+ // padding or off-by-one error in the window size cannot hide.
+ for n in 0..=(57 * 3 + 4) {
+ let input = vec![b'z'; n];
+ let unwrapped = BASE64_STANDARD.encode(&input);
+ let expected = unwrapped
+ .as_bytes()
+ .chunks(LINE_LEN)
+ .map(|line| std::str::from_utf8(line).unwrap())
+ .collect::<Vec<_>>()
+ .join("\r\n");
+ assert_eq!(encode(&input, true), expected, "length {n}");
+ // Every line is full except the last, and no line exceeds the
limit.
+ let actual = encode(&input, true);
+ let lines: Vec<&str> = actual.split("\r\n").collect();
+ for (i, line) in lines.iter().enumerate() {
+ if i + 1 < lines.len() {
+ assert_eq!(line.len(), LINE_LEN, "length {n}, line {i}");
+ } else {
+ assert!(line.len() <= LINE_LEN, "length {n}, last line");
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn encoded_capacity_is_an_upper_bound() {
+ // Regression test for the capacity estimate: rows pad independently,
so an estimate
+ // derived from the summed input bytes alone under-reserves by ~3x for
many tiny rows.
+ let shapes: Vec<Vec<usize>> = vec![
+ vec![1; 64], // worst case: every row pads to 4
chars
+ vec![2; 64], //
+ vec![3; 64], // no padding at all
+ vec![0; 16], // all empty
+ vec![57; 8], // exactly one line each
+ vec![58; 8], // wraps once each
+ vec![200, 1, 0, 57, 58, 3], // mixed
+ vec![4096; 4], // few large rows
+ ];
+ for shape in shapes {
+ let values: Vec<Option<Vec<u8>>> = shape.iter().map(|&n|
Some(vec![b'q'; n])).collect();
+ let input = BinaryArray::from_iter(values);
+ for chunk in [false, true] {
+ let actual: usize = (0..input.len())
+ .map(|i| encode(input.value(i), chunk).len())
+ .sum();
+ let estimated = encoded_capacity(&input, chunk);
+ assert!(
+ estimated >= actual,
+ "shape {shape:?}, chunk={chunk}: estimate {estimated} <
actual {actual}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn encode_array_many_tiny_rows() {
+ // The shape that the old capacity estimate under-reserved for.
Verifies output
+ // correctness independently of the reservation.
+ let values: Vec<Option<&[u8]>> = (0..100).map(|_|
Some(&b"a"[..])).collect();
+ let input = BinaryArray::from(values);
+ for chunk in [false, true] {
+ let out = encode_array(&input, chunk);
+ assert_eq!(out.len(), 100);
+ for i in 0..out.len() {
+ assert_eq!(out.value(i), "YQ==");
+ }
+ }
+ }
+
+ #[test]
+ fn encode_array_binary_chunked() {
+ // Nulls, empty values, values that wrap, and one on the boundary.
+ let a57 = [b'a'; 57];
+ let b58 = [b'b'; 58];
+ let c120 = [b'c'; 120];
+ let input = BinaryArray::from(vec![
+ Some(&b""[..]),
+ None,
+ Some(&a57[..]), // exactly 76 encoded chars: no wrap
+ Some(&b58[..]), // wraps once
+ None,
+ Some(&c120[..]), // wraps twice
+ ]);
+ let out = encode_array(&input, true);
+ assert_eq!(out.len(), 6);
+ assert_eq!(out.value(0), "");
+ assert!(out.is_null(1));
+ assert_eq!(out.value(2), encode(&a57, true));
+ assert_eq!(out.value(3), encode(&b58, true));
+ assert!(out.is_null(4));
+ assert_eq!(out.value(5), encode(&c120, true));
+ }
+
+ #[test]
+ fn encode_array_large_binary_unchunked() {
+ // Exercises the LargeBinaryArray (i64 offsets) instantiation of the
generic.
+ let input = LargeBinaryArray::from(vec![None, Some(&b"abc"[..]),
Some(&b"hi!"[..])]);
+ let out = encode_array(&input, false);
+ assert_eq!(out.len(), 3);
+ assert!(out.is_null(0));
+ assert_eq!(out.value(1), "YWJj");
+ assert_eq!(out.value(2), BASE64_STANDARD.encode(b"hi!"));
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]