Skip to content

perf: remove per-row String allocation from Spark soundex and quote - #23880

Open
andygrove wants to merge 3 commits into
apache:mainfrom
andygrove:opt/spark-string-per-row-alloc
Open

perf: remove per-row String allocation from Spark soundex and quote#23880
andygrove wants to merge 3 commits into
apache:mainfrom
andygrove:opt/spark-string-per-row-alloc

Conversation

@andygrove

@andygrove andygrove commented Jul 25, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A

Rationale for this change

Spark's soundex and quote both allocated a fresh String for every row and
collected the results into a StringArray.

soundex allocated twice per row: once for the code buffer, and again for the
format!("{soundex_code:0<4}") that zero-pads it. quote allocated a String
sized to the input, then copied the input into it one char at a time.

Neither function needs to allocate per row. A soundex code is always exactly
four ASCII characters, so it fits in a stack buffer. quote only ever wraps the
input and escapes embedded quotes, so it can write straight into the output
buffer and copy the runs between quotes rather than character by character.

What changes are included in this PR?

soundex.rs:

  • compute_soundex(&str) -> String becomes append_soundex(&mut StringBuilder, &str),
    building the four-character code in a [u8; 4] initialised to b'0' — which is
    the zero-padding, so the trailing format! disappears.
  • Strings that do not start with an ASCII letter are passed through unchanged;
    previously this path called s.to_string(), now it appends by reference.
  • The Utf8/LargeUtf8 and Utf8View entry points share one soundex_impl
    over Option<&str>, pre-sizing the builder at 4 bytes per row.

quote.rs:

  • compute_quote(&str) -> String becomes append_quoted(&mut StringBuilder, &str),
    writing into the builder's buffer via the fmt::Write impl and finalising with
    append_value("").
  • Escaping uses str::split('\'') to copy the runs between quotes in one memcpy
    each, instead of pushing every char individually.
  • The builder is pre-sized from the input's value_data() length plus two bytes
    per row for the surrounding quotes.

Output is unchanged in both cases.

Are these changes tested?

Existing coverage pins the behaviour: spark/string/soundex.slt and
spark/string/quote.slt assert concrete outputs, including the non-alphabetic
passthrough, codes shorter than four characters (padding), codes truncated at
four, and strings with and without embedded quotes. All 59 spark/string
sqllogictest files and the 258 datafusion-spark unit tests pass.

The benchmark used below, datafusion/spark/benches/soundex_quote.rs, is added
separately in #23882 so the baseline can be measured on main before this
change lands. It covers both functions over Utf8 and Utf8View at 1024 and
8192 rows with 20% nulls; the quote input is a mix of strings with and without
embedded quotes so the escaping path is exercised without dominating.

Benchmarks

Criterion, apache/main @ f1ab86dad as baseline. Median of the reported
change interval.

Benchmark 1024 8192
soundex/utf8 −52.9% −49.5%
soundex/utf8view −54.5% −50.2%
quote/utf8 −60.7% −61.5%
quote/utf8view −60.5% −61.3%

Are there any user-facing changes?

No. Both functions produce byte-identical output; this is purely an allocation
change.

Both functions built a fresh String for every row and collected the results
into a StringArray. soundex allocated twice per row -- once for the code
buffer and once more for the format! that zero-pads it -- and quote allocated
a String sized to the input before copying it in character at a time.

Neither needs to allocate. A soundex code is always exactly four ASCII
characters, so it is built in a stack buffer. quote writes straight into the
builder and copies the runs between quotes rather than one char at a time.

soundex -50%, quote -61% against the benchmarks added in apache#23882.
@andygrove
andygrove force-pushed the opt/spark-string-per-row-alloc branch from 812da2f to 3e80e81 Compare July 25, 2026 14:08
@codecov-commenter

codecov-commenter commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.81657% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.17%. Comparing base (f1ab86d) to head (f622ed1).
⚠️ Report is 235 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/spark/src/function/string/soundex.rs 97.72% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23880      +/-   ##
==========================================
+ Coverage   80.65%   81.17%   +0.52%     
==========================================
  Files        1091     1109      +18     
  Lines      371031   388161   +17130     
  Branches   371031   388161   +17130     
==========================================
+ Hits       299256   315109   +15853     
- Misses      53935    54508     +573     
- Partials    17840    18544     +704     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@neilconway neilconway left a comment

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.

Overall looks reasonable!

Comment thread datafusion/spark/src/function/string/quote.rs Outdated
Comment thread datafusion/spark/src/function/string/quote.rs Outdated
`value_data().len()` spans the entire values buffer even when the array
is a slice, and `get_buffer_memory_size()` reports buffer capacities
while ignoring inlined view values. Take the length from the sliced
offsets and from `total_bytes_len()` instead.
Comment on lines +96 to +99
let data_len = match (offsets.first(), offsets.last()) {
(Some(first), Some(last)) => last.as_usize() - first.as_usize(),
_ => 0,
};

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.

offset buffers are guaranteed to be non-empty so we can unwrap instead of matching here

@comphead comphead left a comment

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.

Thanks @andygrove the PR looks solid to me.

Couple of tests could be added though

  • quote. No LargeUtf8 case (e.g. arrow_cast(..., 'LargeUtf8')). The quote_array::<i64> branch of SparkQuote::return_type is unreached by quote.slt.
  • quote. No Utf8View case exercises quote_view, whose capacity hint uses total_bytes_len(). That is a different path from quote_array's last - first computation.
  • quote. No SELECT quote(NULL) and no SELECT quote('') appear in quote.slt. The rewritten quote_impl's new append_null() branch and empty-run fallback are unexercised. Sibling soundex.slt does cover both.
  • quote. All 29 existing cases are SELECT quote(<scalar>); (single-row arrays). Cross-row StringBuilder reuse across many rows is not directly exercised. Add a SELECT quote(col) FROM (VALUES ...) batch test.
  • soundex. No LargeUtf8 or Utf8View case exercises the soundex_array::<i64> or soundex_view branches.
  • soundex. No non-ASCII alphabetic first-character case (e.g. Ñoño, Éclair) confirms the passthrough append_value(s) route. The from_utf8_unchecked SAFETY invariant is not test-guarded against future first-char broadening.
  • soundex. No sliced-array, empty-array, or multi-row batch case. All existing tests are single-row scalar.

- `quote`/`soundex` now preserve the input's `LargeUtf8` type instead of
  narrowing to `Utf8`, which had triggered a planner assertion. The
  `soundex.slt` and `quote.slt` files exercised only `Utf8` inputs, so
  the mismatch went unnoticed.
- Drop the `from_utf8_unchecked` in `soundex`; the four-byte validation
  is cheap and no longer relies on an invariant that only tests could
  guard.
- Unwrap the offset first/last directly, per @Jefffrey.
- Add unit tests and sqllogictests for `LargeUtf8`, `Utf8View`,
  `NULL`, empty values, non-ASCII alphabetic first characters, sliced
  arrays, and multi-row batches.
@github-actions github-actions Bot added the sqllogictest SQL Logic Tests (.slt) label Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

spark sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants