This is an automated email from the ASF dual-hosted git repository.
alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 803b51c38d perf: speed up substring_by_char with an ASCII fast path
and single-pass bounds (#10334)
803b51c38d is described below
commit 803b51c38d049b1355c89f592631289c49fbc629
Author: Andy Grove <[email protected]>
AuthorDate: Fri Jul 17 15:16:44 2026 -0600
perf: speed up substring_by_char with an ASCII fast path and single-pass
bounds (#10334)
# Which issue does this PR close?
N/A — performance improvement to an existing kernel.
# Rationale for this change
`substring_by_char` calls `val.chars().count()` on every value, which is
a full UTF-8 decode of the entire string. The result is only used when
`start < 0`, so for the common non-negative `start` case the work is
thrown away. When `start < 0`, the string is then walked a second time
from the front to convert the char index back into a byte offset.
It also decodes UTF-8 unconditionally, even when the array is entirely
ASCII, where a char index is a byte index and the bounds can be computed
arithmetically. The doc comment currently tells users to reach for
`substring` themselves in that case; the kernel can just detect it.
This is a port of the approach taken in apache/datafusion-comet#4903,
which replaced this kernel with a local copy for exactly these reasons.
# What changes are included in this PR?
- `substring_by_char` picks a bounds function once per array:
`ascii_bounds` (integer arithmetic on byte offsets) when `is_ascii()`
holds, `utf8_bounds` otherwise. `substring_by_char_impl` does the shared
buffer building.
- The unconditional `chars().count()` is gone. Negative starts use
`char_indices().nth_back()`, which decodes only as far back as needed
rather than scanning the whole value twice.
- `utf8_bounds` short-circuits the end scan when the requested char
length is at least the remaining byte length, since a char is never
smaller than one byte.
- The value buffer capacity is bounded by an upper estimate of the
output element size, so a short substring of long strings no longer
allocates the full input size.
- Semantics are unchanged, including the clamping of out-of-range starts
in both directions.
The output is still built with the checked `GenericStringArray::new`.
Skipping the redundant UTF-8 validation with `new_unchecked` is possible
(every slice is on a char boundary of an already-valid string) but is
left out of this PR.
Benchmark, 65,536 rows x 1,000-char strings:
| Benchmark | Before | After | Change |
|---|---|---|---|
| substring utf8 by char (existing) | 33.97 ms | 4.84 ms | -85.7% |
| ascii, prefix (start=0, length=10) | 2.61 ms | 2.34 ms | -10.1% |
| ascii, tail (start=-10) | 31.76 ms | 2.41 ms | -92.4% |
| non-ascii, prefix | 3.95 ms | 1.34 ms | -66.1% |
| non-ascii, tail | 65.62 ms | 1.06 ms | -98.4% |
The ASCII prefix case gains the least because it is dominated by the
copy and the output validation, but it still comes out ahead of the
`is_ascii()` scan it now pays for.
# Are these changes tested?
Yes. The existing `substring_by_char` tests all mix ASCII and non-ASCII
values in a single array, so they only ever exercised the UTF-8 path.
Added an ASCII-only test matrix (`ascii_string_by_char` /
`ascii_large_string_by_char`) covering identity, positive and negative
starts, out-of-range starts in both directions, zero length, and a
`u64::MAX` length to pin the saturating-add clamp.
Added four benchmarks to `substring_kernels.rs` (ASCII and non-ASCII,
prefix and tail) plus a non-ASCII array generator; the existing bench
name is unchanged so its history stays comparable.
# Are there any user-facing changes?
No API change. `substring_by_char` is faster, and its `# Performance`
doc note is updated to mention the ASCII fast path.
---
arrow-string/src/substring.rs | 153 ++++++++++++++++++++++++++++++-------
arrow/benches/substring_kernels.rs | 36 +++++++++
2 files changed, 160 insertions(+), 29 deletions(-)
diff --git a/arrow-string/src/substring.rs b/arrow-string/src/substring.rs
index 152f1f152d..c8762499ca 100644
--- a/arrow-string/src/substring.rs
+++ b/arrow-string/src/substring.rs
@@ -126,8 +126,9 @@ pub fn substring(
/// # Performance
///
/// This function is slower than [substring]. Theoretically, the time
complexity
-/// is `O(n)` where `n` is the length of the value buffer. It is recommended to
-/// use [substring] if the input array only contains ASCII chars.
+/// is `O(n)` where `n` is the length of the value buffer. If the array only
+/// contains ASCII chars, a fast path avoids decoding UTF-8 altogether and the
+/// performance is comparable to [substring].
///
/// # Basic usage
/// ```
@@ -142,57 +143,106 @@ pub fn substring_by_char<OffsetSize: OffsetSizeTrait>(
start: i64,
length: Option<u64>,
) -> Result<GenericStringArray<OffsetSize>, ArrowError> {
+ let length = length.map(|len| usize::try_from(len).unwrap_or(usize::MAX));
+
+ if array.is_ascii() {
+ // One char is one byte, so the byte offsets can be computed
arithmetically.
+ Ok(substring_by_char_impl(array, length, |val| {
+ ascii_bounds(val, start, length)
+ }))
+ } else {
+ // A char occupies at most 4 bytes.
+ let max_element_len = length.map(|len| len.saturating_mul(4));
+ Ok(substring_by_char_impl(array, max_element_len, |val| {
+ utf8_bounds(val, start, length)
+ }))
+ }
+}
+
+/// Builds the output of [`substring_by_char`], delegating to `bounds` to
locate the
+/// substring of each value.
+///
+/// * `max_element_len` - an upper bound, in bytes, on the size of a single
output
+/// element, used to avoid over-allocating the value buffer. [None] if
unbounded.
+fn substring_by_char_impl<OffsetSize: OffsetSizeTrait, F: Fn(&str) -> (usize,
usize)>(
+ array: &GenericStringArray<OffsetSize>,
+ max_element_len: Option<usize>,
+ bounds: F,
+) -> GenericStringArray<OffsetSize> {
let mut vals = BufferBuilder::<u8>::new({
let offsets = array.value_offsets();
- (offsets[array.len()] - offsets[0]).to_usize().unwrap()
+ let input_len = (offsets[array.len()] -
offsets[0]).to_usize().unwrap();
+ match max_element_len {
+ Some(len) => input_len.min(array.len().saturating_mul(len)),
+ None => input_len,
+ }
});
let mut new_offsets = BufferBuilder::<OffsetSize>::new(array.len() + 1);
new_offsets.append(OffsetSize::zero());
- let length = length.map(|len| len.to_usize().unwrap());
array.iter().for_each(|val| {
if let Some(val) = val {
- let char_count = val.chars().count();
- let start = if start >= 0 {
- start.to_usize().unwrap()
- } else {
- char_count - (-start).to_usize().unwrap().min(char_count)
- };
- let (start_offset, end_offset) = get_start_end_offset(val, start,
length);
+ let (start_offset, end_offset) = bounds(val);
vals.append_slice(&val.as_bytes()[start_offset..end_offset]);
}
new_offsets.append(OffsetSize::from_usize(vals.len()).unwrap());
});
+
let offsets = OffsetBuffer::new(new_offsets.finish().into());
let values = vals.finish();
let nulls = array
.nulls()
.map(|n| n.inner().sliced())
.and_then(|b| NullBuffer::from_unsliced_buffer(b, array.len()));
- Ok(GenericStringArray::<OffsetSize>::new(
- offsets, values, nulls,
- ))
+ GenericStringArray::<OffsetSize>::new(offsets, values, nulls)
}
-/// * `val` - string
-/// * `start` - the start char index of the substring
-/// * `length` - the char length of the substring
+/// Returns the `start` and `end` byte offset of the substring of an ASCII
`val`, where
+/// one char is one byte.
///
-/// Return the `start` and `end` offset (by byte) of the substring
-fn get_start_end_offset(val: &str, start: usize, length: Option<usize>) ->
(usize, usize) {
+/// * `start` - the start char index of the substring, counted from the end if
negative
+/// * `length` - the char length of the substring, or [None] to take the rest
of `val`
+#[inline]
+fn ascii_bounds(val: &str, start: i64, length: Option<usize>) -> (usize,
usize) {
let len = val.len();
- let mut offset_char_iter = val.char_indices();
- let start_offset = offset_char_iter
- .nth(start)
- .map_or(len, |(offset, _)| offset);
+ let start_offset = if start >= 0 {
+ usize::try_from(start).unwrap_or(usize::MAX).min(len)
+ } else {
+
len.saturating_sub(usize::try_from(start.unsigned_abs()).unwrap_or(usize::MAX))
+ };
+ let end_offset = length.map_or(len, |length|
start_offset.saturating_add(length).min(len));
+ (start_offset, end_offset)
+}
+
+/// Returns the `start` and `end` byte offset of the substring of an arbitrary
UTF-8 `val`.
+///
+/// * `start` - the start char index of the substring, counted from the end if
negative
+/// * `length` - the char length of the substring, or [None] to take the rest
of `val`
+#[inline]
+fn utf8_bounds(val: &str, start: i64, length: Option<usize>) -> (usize, usize)
{
+ let len = val.len();
+ let start_offset = if start >= 0 {
+ val.char_indices()
+ .nth(usize::try_from(start).unwrap_or(usize::MAX))
+ .map_or(len, |(offset, _)| offset)
+ } else {
+ // `start` is negative, so `nth_back` counts back from the last char.
Strings with
+ // fewer than `-start` chars start at 0.
+ let back = usize::try_from(start.unsigned_abs()).unwrap_or(usize::MAX);
+ val.char_indices()
+ .nth_back(back - 1)
+ .map_or(0, |(offset, _)| offset)
+ };
let end_offset = length.map_or(len, |length| {
- if length > 0 {
- offset_char_iter
- .nth(length - 1)
- .map_or(len, |(offset, _)| offset)
- } else {
- start_offset
+ // Every char is at least one byte, so the rest of `val` is shorter
than `length`
+ // chars and there is nothing to scan for.
+ if length >= len - start_offset {
+ return len;
}
+ val[start_offset..]
+ .char_indices()
+ .nth(length)
+ .map_or(len, |(offset, _)| start_offset + offset)
});
(start_offset, end_offset)
}
@@ -828,6 +878,51 @@ mod tests {
without_nulls_generic_string_by_char::<i64>()
}
+ /// The array is all-ASCII, which takes the fast path of
[`substring_by_char`].
+ fn ascii_generic_string_by_char<O: OffsetSizeTrait>() {
+ let input = vec![Some("hello"), None, Some(""), Some("rust")];
+ let cases = gen_test_cases!(
+ input,
+ // identity
+ (0, None, input.clone()),
+ // increase start
+ (1, None, vec![Some("ello"), None, Some(""), Some("ust")]),
+ (4, None, vec![Some("o"), None, Some(""), Some("")]),
+ // high start -> nothing
+ (1000, None, vec![Some(""), None, Some(""), Some("")]),
+ // increase start negatively
+ (-1, None, vec![Some("o"), None, Some(""), Some("t")]),
+ (-4, None, vec![Some("ello"), None, Some(""), Some("rust")]),
+ // high negative start -> identity
+ (-1000, None, input.clone()),
+ // 0 length -> nothing
+ (0, Some(0), vec![Some(""), None, Some(""), Some("")]),
+ // increase length
+ (1, Some(2), vec![Some("el"), None, Some(""), Some("us")]),
+ (-4, Some(2), vec![Some("el"), None, Some(""), Some("ru")]),
+ // high length -> identity
+ (0, Some(1000), input.clone()),
+ // length past the end is clamped, including when it would overflow
+ (
+ 1,
+ Some(u64::MAX),
+ vec![Some("ello"), None, Some(""), Some("ust")]
+ )
+ );
+
+ do_test!(cases, GenericStringArray<O>, substring_by_char);
+ }
+
+ #[test]
+ fn ascii_string_by_char() {
+ ascii_generic_string_by_char::<i32>()
+ }
+
+ #[test]
+ fn ascii_large_string_by_char() {
+ ascii_generic_string_by_char::<i64>()
+ }
+
fn generic_string_by_char_with_non_zero_offset<O: OffsetSizeTrait>() {
let values = "S→T = Πx:S.T";
let offsets = &[
diff --git a/arrow/benches/substring_kernels.rs
b/arrow/benches/substring_kernels.rs
index bf910e7aaf..a3b58988c4 100644
--- a/arrow/benches/substring_kernels.rs
+++ b/arrow/benches/substring_kernels.rs
@@ -38,11 +38,31 @@ fn bench_substring_by_char<O: OffsetSizeTrait>(
substring_by_char(hint::black_box(arr), start, length).unwrap();
}
+/// A non-ASCII variant of [`create_string_array_with_len`]: every fourth char
is a
+/// multi-byte codepoint, so `substring_by_char` has to decode UTF-8 to find a
char index.
+fn create_utf8_array_with_len<O: OffsetSizeTrait>(
+ size: usize,
+ str_len: usize,
+) -> GenericStringArray<O> {
+ create_string_array_with_len::<O>(size, 0.0, str_len)
+ .iter()
+ .map(|val| {
+ val.map(|val| {
+ val.chars()
+ .enumerate()
+ .map(|(i, c)| if i % 4 == 0 { 'é' } else { c })
+ .collect::<String>()
+ })
+ })
+ .collect()
+}
+
fn add_benchmark(c: &mut Criterion) {
let size = 65536;
let val_len = 1000;
let arr_string = create_string_array_with_len::<i32>(size, 0.0, val_len);
+ let arr_utf8 = create_utf8_array_with_len::<i32>(size, val_len);
let arr_fsb = create_fsb_array(size, 0.0, val_len);
c.bench_function("substring utf8 (start = 0, length = None)", |b| {
@@ -57,6 +77,22 @@ fn add_benchmark(c: &mut Criterion) {
b.iter(|| bench_substring_by_char(&arr_string, 1, Some((val_len - 1)
as u64)))
});
+ c.bench_function("substring by char (ascii, prefix)", |b| {
+ b.iter(|| bench_substring_by_char(&arr_string, 0, Some(10)))
+ });
+
+ c.bench_function("substring by char (ascii, tail)", |b| {
+ b.iter(|| bench_substring_by_char(&arr_string, -10, None))
+ });
+
+ c.bench_function("substring by char (non-ascii, prefix)", |b| {
+ b.iter(|| bench_substring_by_char(&arr_utf8, 0, Some(10)))
+ });
+
+ c.bench_function("substring by char (non-ascii, tail)", |b| {
+ b.iter(|| bench_substring_by_char(&arr_utf8, -10, None))
+ });
+
c.bench_function("substring fixed size binary array", |b| {
b.iter(|| bench_substring(&arr_fsb, 1, Some((val_len - 1) as u64)))
});