Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions datafusion/spark/benches/hex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,21 @@ fn criterion_benchmark(c: &mut Criterion) {
run_benchmark(c, "hex_utf8", size, Arc::new(data));
}

for &size in &sizes {
let data = generate_utf8_data(size, 0.0);
run_benchmark(c, "hex_utf8_no_nulls", size, Arc::new(data));
}

for &size in &sizes {
let data = generate_binary_data(size, null_density);
run_benchmark(c, "hex_binary", size, Arc::new(data));
}

for &size in &sizes {
let data = generate_binary_data(size, 0.0);
run_benchmark(c, "hex_binary_no_nulls", size, Arc::new(data));
}

for &size in &sizes {
let data = generate_int64_dict_data(size, null_density);
run_benchmark(c, "hex_int64_dict", size, Arc::new(data));
Expand Down
174 changes: 107 additions & 67 deletions datafusion/spark/src/function/math/hex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use std::str::from_utf8_unchecked;
use std::sync::Arc;

use arrow::array::{Array, ArrayRef, NullBufferBuilder, StringArray, StringBuilder};
use arrow::array::{Array, ArrayAccessor, ArrayRef, StringArray, StringBuilder};
use arrow::buffer::{Buffer, OffsetBuffer};
use arrow::datatypes::DataType;
use arrow::{
Expand Down Expand Up @@ -158,21 +158,42 @@ fn hex_int64(num: i64, buffer: &mut [u8; 16]) -> &[u8] {
&buffer[i..]
}

#[inline]
fn append_hex_bytes(
values: &mut Vec<u8>,
bytes: &[u8],
lookup: &[[u8; 2]; 256],
) -> Result<i32, DataFusionError> {
let additional = bytes
.len()
.checked_mul(2)
.ok_or_else(|| exec_datafusion_err!("hex output size overflow"))?;
values.try_reserve(additional).map_err(|e| {
exec_datafusion_err!("failed to reserve {additional} bytes for hex output: {e}")
})?;
for &byte in bytes {
values.extend_from_slice(&lookup[byte as usize]);
}
i32::try_from(values.len())
.map_err(|_| exec_datafusion_err!("hex output exceeds i32 offset range"))
}

/// Generic hex encoding for byte array types
fn hex_encode_bytes<'a, I, T>(
iter: I,
fn hex_encode_bytes<'a, A, T>(
array: &A,
lowercase: bool,
len: usize,
) -> Result<ArrayRef, DataFusionError>
where
I: Iterator<Item = Option<T>>,
T: AsRef<[u8]> + 'a,
A: ArrayAccessor<Item = &'a T>,
T: AsRef<[u8]> + ?Sized + 'a,
{
let lookup = if lowercase {
&HEX_LOOKUP_LOWER
} else {
&HEX_LOOKUP_UPPER
};
let len = array.len();
let nulls = array.nulls().cloned();

// Write hex digits directly into one growing value buffer, tracking offsets
// ourselves. Each input byte becomes exactly two output bytes, so there is
Expand All @@ -181,32 +202,25 @@ where
let mut values: Vec<u8> = Vec::with_capacity(len * 64);
let mut offsets: Vec<i32> = Vec::with_capacity(len + 1);
offsets.push(0);
let mut nulls = NullBufferBuilder::new(len);

for v in iter {
if let Some(b) = v {
let bytes = b.as_ref();
let additional = bytes
.len()
.checked_mul(2)
.ok_or_else(|| exec_datafusion_err!("hex output size overflow"))?;
values.try_reserve(additional).map_err(|e| {
exec_datafusion_err!(
"failed to reserve {additional} bytes for hex output: {e}"
)
})?;
for &byte in bytes {
values.extend_from_slice(&lookup[byte as usize]);
if let Some(ref nulls) = nulls {
for i in 0..len {
if nulls.is_valid(i) {
// SAFETY: `i` is in bounds and the validity buffer marks it valid.
let bytes = unsafe { array.value_unchecked(i) }.as_ref();
offsets.push(append_hex_bytes(&mut values, bytes, lookup)?);
} else {
offsets.push(i32::try_from(values.len()).map_err(|_| {
exec_datafusion_err!("hex output exceeds i32 offset range")
})?);
}
nulls.append_non_null();
} else {
nulls.append_null();
}
offsets.push(
i32::try_from(values.len()).map_err(|_| {
exec_datafusion_err!("hex output exceeds i32 offset range")
})?,
);
} else {
for i in 0..len {
// SAFETY: `i` is in bounds and no null buffer means every value is valid.
let bytes = unsafe { array.value_unchecked(i) }.as_ref();
offsets.push(append_hex_bytes(&mut values, bytes, lookup)?);
}
}

// SAFETY: the value buffer contains only ASCII hex digits (valid UTF-8) and
Expand All @@ -217,7 +231,7 @@ where
StringArray::new_unchecked(
OffsetBuffer::new(offsets.into()),
Buffer::from_vec(values),
nulls.finish(),
nulls,
)
};
Ok(Arc::new(array))
Expand Down Expand Up @@ -276,51 +290,27 @@ pub fn compute_hex(
}
DataType::Utf8 => {
let array = as_string_array(array);
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::Utf8View => {
let array = as_string_view_array(array)?;
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::LargeUtf8 => {
let array = as_largestring_array(array);
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::Binary => {
let array = as_binary_array(array)?;
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::LargeBinary => {
let array = as_large_binary_array(array)?;
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::FixedSizeBinary(_) => {
let array = as_fixed_size_binary_array(array)?;
Ok(ColumnarValue::Array(hex_encode_bytes(
array.iter(),
lowercase,
array.len(),
)?))
Ok(ColumnarValue::Array(hex_encode_bytes(&array, lowercase)?))
}
DataType::Dictionary(key_type, _) => {
if **key_type != DataType::Int32 {
Expand All @@ -340,27 +330,27 @@ pub fn compute_hex(
}
DataType::Utf8 => {
let arr = as_string_array(dict_values);
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
DataType::LargeUtf8 => {
let arr = as_largestring_array(dict_values);
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
DataType::Utf8View => {
let arr = as_string_view_array(dict_values)?;
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
DataType::Binary => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a small regression test for a dictionary-backed Binary or LargeBinary array where dict.values() itself contains a null, rather than only testing null dictionary keys? The new byte-array path preserves the values array NullBuffer directly. We already cover this shape for Int64 in test_dict_values_null, so matching binary coverage would help pin down this updated path. This is optional and does not need to block the PR.

let arr = as_binary_array(dict_values)?;
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
DataType::LargeBinary => {
let arr = as_large_binary_array(dict_values)?;
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
DataType::FixedSizeBinary(_) => {
let arr = as_fixed_size_binary_array(dict_values)?;
hex_encode_bytes(arr.iter(), lowercase, arr.len())?
hex_encode_bytes(&arr, lowercase)?
}
_ => {
return exec_err!(
Expand All @@ -385,7 +375,7 @@ mod test {
use std::sync::Arc;

use arrow::array::{
BinaryArray, DictionaryArray, Int32Array, Int64Array, StringArray,
Array, BinaryArray, DictionaryArray, Int32Array, Int64Array, StringArray,
};
use arrow::{
array::{
Expand Down Expand Up @@ -554,6 +544,56 @@ mod test {
assert_eq!(strings.value(0), expected);
}

#[test]
fn test_spark_hex_binary_no_nulls() {
let input = BinaryArray::from(vec![
b"".as_slice(),
b"\x00\x7f\x80\xff".as_slice(),
b"DataFusion".as_slice(),
]);

let result = super::spark_hex(&[ColumnarValue::Array(Arc::new(input))]).unwrap();
let array = match result {
ColumnarValue::Array(array) => array,
_ => panic!("Expected array"),
};
let strings = as_string_array(&array);

assert_eq!(strings.nulls(), None);
assert_eq!(
strings,
&StringArray::from(vec!["", "007F80FF", "44617461467573696F6E"])
);
}

#[test]
fn test_spark_hex_binary_reuses_input_nulls() {
let input = BinaryArray::from(vec![
Some(b"skip".as_slice()),
None,
Some(b"\x00\xff".as_slice()),
Some(b"hex".as_slice()),
None,
])
.slice(1, 4);
let input_nulls = input.nulls().unwrap().clone();

let result = super::spark_hex(&[ColumnarValue::Array(Arc::new(input))]).unwrap();
let array = match result {
ColumnarValue::Array(array) => array,
_ => panic!("Expected array"),
};
let strings = as_string_array(&array);
let output_nulls = strings.nulls().unwrap();

assert_eq!(output_nulls, &input_nulls);
assert!(output_nulls.inner().ptr_eq(input_nulls.inner()));
assert_eq!(
strings,
&StringArray::from(vec![None, Some("00FF"), Some("686578"), None])
);
}

#[test]
fn test_spark_hex_int64() {
let int_array = Int64Array::from(vec![Some(1), Some(2), None, Some(3)]);
Expand Down
Loading