feat: default native replace for non-empty UTF8_BINARY literal search - #5409
feat: default native replace for non-empty UTF8_BINARY literal search#5409sam-1112 wants to merge 1 commit into
Conversation
sunchao
left a comment
There was a problem hiding this comment.
Summary
Reviewed head 3b0d9d405b477218fa6c664919f020ec1ae2bc99 against base f8f9b3356d29e776e02a9fca7a6b3c630790db2c with five independent review scopes. The patch keeps the implementation small and reuses the existing native kernel, but three P2 regressions survive verification: malformed search literals can change results, NULL-source rows can raise previously suppressed replacement-expression errors, and large scalar literals can overflow during batch broadcasting. All three reproduce with spark.comet.expression.StringReplace.allowIncompatible=false.
Prior state and problem
Previously, the default StringReplace path serialized the complete Spark expression into the JVM codegen dispatcher. Native DataFusion execution required explicit incompatibility opt-in because empty-search behavior differs from Spark. Issue #5354 proposes recognizing the non-empty-literal subset at planning time to avoid dispatcher-side JVM allocations.
That motivation is understandable, and ordinary valid-UTF-8 replacement semantics agree. The compatibility boundary also includes literal serialization, conditional evaluation of children, and the scalar-to-array adaptation performed before the native kernel runs. Those boundaries explain the three findings even though this PR does not change the kernel itself.
Design approach
nativeSafeSearchSubset requires three children, a non-empty UTF8String search literal, and no non-default string collation on any child. Both getSupportLevel and convert use this predicate, so eligible expressions become native by default while empty, non-literal, and collated searches retain dispatcher routing.
The replacement remains unrestricted and may be a column or expression. Explicit allowIncompatible=true still selects the existing native route for the other cases. The collation check is conservative and uses the existing version shims rather than introducing Spark-version-specific logic here.
Correctness / compatibility analysis
The pinned DataFusion 54.1.0 UDF matched 9,000 seeded valid-UTF-8 cases against each of Spark 3.4.3, 3.5.8, 4.0.4, and 4.1.3. The corpus included NULL source/replacement, empty source/replacement, overlaps, multibyte and combining characters, NUL bytes, and replacement columns. A focused Spark 4.0.4 probe also checked parsing, routing, and interpreted/generated agreement for UTF8_BINARY, UTF8_LCASE, and UTF8_BINARY_RTRIM.
For the regressions, I separately compiled the exact base and head versions of strings.scala and ran controlled A/B SQL probes on the same cached Spark 4.0.4/Comet support runtime. Both sides executed Comet projections, with the base retaining the dispatcher and the head taking the native route. The malformed-literal wrong result, ANSI division-by-zero failure, and unused-large-literal overflow all appeared only with the head serde. Actual pinned DataFusion/Arrow probes independently corroborated literal replacement and broadcasting behavior. This is focused serde/kernel validation, not a fresh full-head Maven/native build or a full test-suite run.
The exact-head CI workflow, Delta build gate, CodeQL, and PR-title workflows currently report action_required. They provide no executed CI test evidence for this head. The test runs and performance numbers in the PR description remain author-reported.
Key design decisions
Reusing the existing kernel avoids a second replacement implementation, and retaining dispatcher behavior outside the intended subset limits the change. Leaving the incompatibility opt-in intact also preserves the existing configuration surface. However, search shape alone does not establish that the complete expression is compatible with native execution.
The disclosed performance trade-off is explicit: native takes 2.5–2.9 times the dispatcher timing in the supplied benchmark, while eliminating dispatcher-side JVM string allocations. The allocation estimate was not remeasured in that benchmark. I have not treated the disclosed slowdown itself as an additional finding or inferred a production throughput or total-memory improvement from those numbers.
Implementation sketch
The introduced PR diff changes four files: the serde predicate and routing, the replacement audit entry, SQL cases, and the codegen routing test. The existing scalar-function registration and three-string DataFusion signature already provide the required native plumbing, so no new registration is missing.
I also inspected all nine paths in the direct base-to-head tree diff. The other five paths belong to newer base-only commits for signed-zero tests and workflow dependencies, not reversions introduced by this PR. The added SQL tests run through a harness that excludes constant folding, while the Scala routing test uses a column source and dispatcher annotations to distinguish execution paths.
Behavioral changes worth calling out
For the newly native subset, Spark no longer receives the whole expression tree in a serialized dispatcher closure. String literals pass through native literal serialization, replacement expressions are evaluated eagerly as native children, and scalar strings are expanded for the batch before matching. The inline reproducers show observable changes at each of these boundaries.
These are default-behavior regressions rather than changes confined to users who opted into incompatibility. Ordinary replacement-column, overlap, and valid-Unicode controls continued to match, so the findings do not argue against native execution for a more carefully bounded subset.
Suggested improvements
Please preserve byte-level behavior for malformed literals and Spark's NULL short-circuiting for replacement expressions before selecting the native default. The native path also needs scalar-aware handling, or dispatcher retention for affected calls, so a no-match replacement cannot overflow merely from broadcasting an unused literal.
Add targeted regressions for a valid U+FFFD source with a malformed search literal, a NULL source with an ANSI-erroring replacement expression, and a full batch with a large unused search/replacement literal. These tests should assert both the result or expected error behavior and the intended execution path. The three inline comments contain concrete reproducers and the relevant boundaries to address.
| return false | ||
| } | ||
| val searchIsNonEmptyLiteral = children(1) match { | ||
| case Literal(v: UTF8String, _) => v != null && v.numBytes() > 0 |
There was a problem hiding this comment.
[P2] Exclude malformed search literals from the native-safe subset
Could this guard also reject search literals whose bytes change during native serialization? With a valid Parquet source column containing U+FFFD (EF BF BD), replace(s, CAST(X'FF' AS STRING), 'x') returns the source unchanged in Spark and the base dispatcher because byte FF is absent. Catalyst folds the cast to a non-empty UTF8String, so this check accepts it, but CometLiteral serializes it through UTF8String.toString, changing the search to U+FFFD. The head's native path consequently returns x. I reproduced this with allowIncompatible=false and entirely valid scan data. Please retain dispatcher routing for malformed search literals unless their byte semantics can be preserved natively.
| if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSearchSubset(expr)) { | ||
| // Native DataFusion `replace` matches Spark when search is a non-empty UTF8_BINARY | ||
| // literal (the common case, selected by default) and when the user has opted in. | ||
| super.convert(expr, inputs, binding) |
There was a problem hiding this comment.
[P2] Preserve NULL short-circuiting for replacement expressions
With ANSI enabled and Parquet rows (s=NULL, n=0) and (s='a', n=1), SELECT replace(s, 'a', CAST(1 / n AS STRING)) FROM t succeeds in Spark and the base dispatcher, returning NULL and '1.0'. This native conversion instead raises DIVIDE_BY_ZERO with allowIncompatible=false. Spark's ternary expression skips the replacement when the source is NULL, whereas the native scalar-function expression evaluates every child for the batch before replace receives the source null mask. Could the native eligibility check account for this conditional evaluation, or retain dispatcher routing when the replacement can throw? A nullable-source/erroring-replacement regression would protect this behavior.
| // The native DataFusion `replace` avoids the JVM allocations of the codegen | ||
| // dispatcher but is not Spark-compatible for an empty search string, so it is | ||
| // only used when incompatibility is explicitly allowed. | ||
| if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSearchSubset(expr)) { |
There was a problem hiding this comment.
[P2] Avoid broadcasting large literals in the new default path
For an 8,192-row Parquet batch containing only short strings 'a'/'b', SELECT replace(s, 'notfound', repeat('x', 262144)) FROM t should return those inputs unchanged. The base dispatcher does, but the head's native Comet projection fails with CometNativeException: native panic: offset overflow, even though the output is tiny. Pinned DataFusion 54.1.0 broadcasts every Utf8 scalar before matching, so the unused 256 KiB replacement becomes a 2 GiB array and exceeds Arrow's i32 offsets at the default Comet batch size. A large search literal triggers the same problem. Please use scalar-aware native execution or retain dispatcher routing for these cases instead of enabling them solely from a non-empty search.
Which issue does this PR close?
Closes #5354.
Rationale for this change
DataFusion
replaceonly diverges from Spark whensearchis empty: Spark returnssrcunchanged, while DataFusion insertsreplacebetween every character. For the common case—a non-emptyUTF8_BINARYliteral—the native kernel is Spark compatible, so Comet can use it by default instead of the JVM codegen dispatcher. Routing is decided bysearchonly. The replacement argument is not required to be a literal, soreplace(col, 'literal', replacementColumn)also runs natively. Empty literal search, non-literal search, and non-default collations remain on the dispatcher.spark.comet.expression.StringReplace.allowIncompatibleis unchanged and still opts the remaining cases into native execution.This PR does not change the native kernel. Collation support remains out of scope (#4496).
What changes are included in this PR?
CometStringReplaceuses native DataFusionreplaceby default whensearchis a non-emptyUTF8_BINARYliteral.## replacesection indocs/source/contributor-guide/expression-audits/string_funcs.md.How are these changes tested?
SQL tests
File:
spark/src/test/resources/sql-tests/expressions/string/string_replace.sqlExisting unchanged coverage includes:
replace('hello', '', 'x')and related NULL/empty variants('', 'a', 'b')in the table scan('hello', 'xyz', 'abc')('hello world', 'world', 'there')('aaa', 'a', 'bb')This PR adds column-source, literal-search cases that take the native path:
replace(s, 'aa', 'x')on'aaaa'→'xx''你好你好'and'😀a😀'Routing tests
CometCodegenSuiteasserts the following routing behavior:The non-default-collation assertion is gated by
isSpark40Plus.Commands executed
The SQL file and routing tests were run on Spark 4.1, 3.5, and 3.4:
./mvnw test -Dtest=none -Dsuites="org.apache.comet.CometSqlFileTestSuite string_replace"./mvnw test -Dtest=none -Dsuites="org.apache.comet.CometCodegenSuite replace routes native"Spark 4.1 was tested with JDK 17. Spark 3.5 and 3.4 were tested with JDK 11.
Performance trade-off
Native execution is not faster in this microbenchmark. It uses 2.5–2.9× as much CPU time as the dispatcher; at 100% match density,
474.6 / 166.2 ≈ 2.86×.This PR therefore does not claim a throughput improvement. It trades CPU throughput for reduced dispatcher-side JVM heap churn on a scan-bound path. Reviewer feedback on whether that trade-off justifies changing the default is especially welcome.
Benchmark query:
Rows: 1,048,576
All timings are in ns/row; lower is better.
The dispatcher is estimated to allocate approximately 80–136 bytes/row for this path, based on the JVM-heap
UTF8Stringanalysis in #5354. This allocation was not re-measured in this benchmark run, and SparkBenchmark's allocated-bytes column was not recorded.The native path avoids those dispatcher-side per-row JVM string allocations.
Benchmark environment and methodology
make releaseBenchmarkdefaults:minNumIters: 2minTime: 2 secondsAfter this change, a non-empty literal search always takes the native path. Measuring the dispatcher arm therefore required temporarily forcing
nativeSafeSearchSubsettofalsein a local benchmark harness. That harness change is not included in this PR.