feat: run rlike natively by default for Java-equivalent literal patterns - #5415
feat: run rlike natively by default for Java-equivalent literal patterns#5415sam-1112 wants to merge 5 commits into
Conversation
Add a plan-time whitelist so UTF8_BINARY literals the analyzer can prove equivalent to Java regex take the native path without allowIncompatible. Out-of-subset literals stay on the JVM dispatcher.
sunchao
left a comment
There was a problem hiding this comment.
Summary
Reviewed the complete 13-file diff from 367ca64be4e62dd10dc4c932de9ca738355a45fa to 86729123819387bbfa132c5c81956f82ea60b193, including the live discussion and CI state. Five independent full-diff passes were followed by independent source and executable verification of the retained cases.
The intended optimization is narrowly scoped to default-native RLike literals. Three pattern-specific P2 regressions remain: Rust character-class set operations change results, leading-] ranges independently change results, and Java-valid patterns can be admitted despite exceeding Rust's compilation limits. Each is detailed inline; none requires opting into incompatible execution.
Prior state and problem
The native RLike kernel already existed, but Java and Rust regex syntax and semantics differ. At the base revision, ordinary literal patterns used Spark's own generated regex implementation through the JVM dispatcher by default; native execution required spark.comet.expression.RLike.allowIncompatible=true. If the dispatcher was disabled, the default expression could fall back to Spark.
This preserves Java behavior but leaves JNI/dispatcher and per-row Java regex costs on patterns that both engines can evaluate equivalently. The PR aims to remove those costs for an automatically recognized subset while keeping other regex expressions and explicitly incompatible execution behavior separate.
Design approach
CometRegex introduces a recursive-descent admission parser rather than a substring-based filter. It recognizes printable ASCII literals, selected character classes, ordinary greedy quantifiers, capturing/non-capturing groups, and alternation. Unrecognized syntax is rejected from automatic native selection; anchors, wildcard dot, shorthand classes, lookaround, backreferences, and flags are intentionally outside the subset.
CometRLike additionally requires a non-null literal and default string collation on both operands for automatic selection. The existing native kernel and Cargo lock are unchanged, and the pattern is serialized without rewriting. Consequently, the admission decision must establish both equivalent Boolean matching and native compilability.
Correctness / compatibility analysis
The retained cases violate two different parts of that admission contract:
[a~~b]and[a-z--b]are admitted, but Rust interprets class set operations that Java does not interpret the same way. Concrete non-null inputs produce opposite Booleans.[]-a]is also admitted, without either set operator. Java treats the initial]as a range endpoint; Rust treats this spelling differently. Both positive and negated classes can add or remove matching rows.[^;]{20000},a{1000000}, nested counted repetitions, and sufficiently nested groups are admitted even though default Rust compilation rejects them. Native plan construction propagates that rejection instead of returning to the dispatcher.
Verification used the unmodified pinned scanner, the exact regex 1.13.1 dependency graph with archive/source checksums matched to Cargo.lock, and real Spark 3.5.9 and 4.0.4 expressions with non-foldable input. The retained cases were checked in both interpreted evaluation and forced generated projections; NULL-subject controls were also checked. These are component/runtime probes plus source-traced integration, not a claimed full Comet/JNI query run.
The unchanged four-test CometRegexSuite passed in isolated builds on Scala 2.12.18 and 2.13.17, and suite-registration and diff-whitespace checks passed. The existing examples do not cover the counterexamples above. CI started during this review: at the 17:28 UTC refresh, CodeQL and Linux/macOS lint checks had succeeded, while native/JVM builds, Rust tests, Java lint, and the benchmark check were still running. No completed full integration-suite result is claimed.
Key design decisions
- Keep automatic admission distinct from the existing incompatible opt-in. The latter remains an explicit request to use the Rust implementation for applicable literals, not an equivalence guarantee.
- Check both operand collations for the automatic route. Spark 3.x uses the existing no-collation shim; Spark 4.x uses the existing collation-aware type checks. Explicit opt-in can bypass the automatic equivalence gate.
- Exclude NULL and non-literal patterns from native applicability. They continue through the dispatcher, or fall back when the dispatcher is unavailable.
- Preserve the generic expression-enabled gate. Disabling RLike itself still prevents this serializer from converting it; disabling only the dispatcher no longer disables eligible native RLike expressions.
Implementation sketch
The serializer extracts a UTF8String literal, checks the operand types, and calls CometRegex.supportLevel. An admitted pattern becomes Compatible() without a native-opt-in hint. Conversion then emits the existing RLike protobuf through createBinaryExpr; the native builder passes the unchanged literal to RLike::try_new, which uses Regex::new and is_match.
Other applicable literals retain their opt-in hint and default JVM dispatcher route. The patch also adds analyzer and parity suites, expands routing/NULL/collation tests, registers the new suites in both OS workflows, adds SQL cases, and updates documentation and benchmark mode selection. No native engine or dependency change accompanies the newly automatic route.
Behavioral changes worth calling out
For admitted literals, default Comet execution now uses the native engine even with the dispatcher disabled. That is the intended performance change, but it also makes any admission mistake observable without a user accepting regex incompatibility.
Out-of-subset literals continue to use the JVM by default, and non-literal/NULL patterns do not become native. Other regex functions retain their existing routing rules. The compile-limit cases are a change from successful default JVM evaluation to native construction failure; the two character-class cases are silent result changes, not merely different match spans or capture groups.
Suggested improvements
Tighten class admission for both Rust-only set operators and the independent leading-] range boundary. Add positive and negated differential examples, including subjects that distinguish literal operator characters and range interiors, and assert that rejected patterns retain JVM routing by default.
Make native compilability part of the admission contract, with conservative aggregate-expansion/depth bounds or a reliable validation/fallback path. A per-integer bound or source-length limit alone does not cover nested repetition expansion. Add the concrete size/depth cases to routing and error-behavior regression coverage, then verify the full parity/routing suites on the supported Spark profiles as CI completes.
| if (startsWith("&&") || peek == '[') { | ||
| return false | ||
| } |
There was a problem hiding this comment.
[P2] Exclude Rust-only class operators from automatic native routing
This gate rejects && but admits ~~ and subtraction forms such as [a-z--b]. The pinned analyzer returns Compatible for [a~~b]; Spark 3.5.9/4.0.4 return true on subject ~, while the locked Rust regex 1.13.1 returns false because ~~ is symmetric difference. Likewise, [a-z--b] matches b in Spark but not Rust. I verified the Spark results in both interpreted and generated evaluation with a non-foldable input. Since CometRLike.convert now sends these literals to the unchanged native matcher without allowIncompatible=true, existing projections and filters silently change results. Keep these class forms outside automatic admission and add differential/routing regressions before selecting native for them.
There was a problem hiding this comment.
Agreed — the first revision only rejected &&, so [a~~b] / [a-z--b] were wrongly admitted and would have taken the native path without allowIncompatible.
7e51a22 now treats && / ~~ / -- inside a character class as incompatible. Those literals stay on the JVM dispatcher by default. Single ~ in a class (e.g. [a~b]) is still admitted; it is a Java literal, not a Rust set operator.
Regressions:
CometRegexSuite: analyzer returnsIncompatiblefor[a~~b],[^a~~b],[a-z--b],[^a-z--b],[a&&b]CometRegExpJvmSuite: EXPLAIN still shows the dispatcher, andcheckSparkAnswerAndOperatormatches Spark on a non-foldable column (~,a,b,x, andb/a/z/-for the subtraction form), including NULL
No runtime compile-fallback; these stay off automatic native.
| val ranging = lastAtom.isDefined && peek == '-' && peekOffset(1).exists(_ != ']') | ||
| if (ranging) { | ||
| consume() // '-' | ||
| parseClassAtom() match { | ||
| case Some(end) if end >= lastAtom.get => |
There was a problem hiding this comment.
[P2] Reject ranges starting at an unescaped leading closing bracket
The initial literal ] is stored in lastAtom and then accepted as a range endpoint. For the admitted pattern []-a], Java/Spark interpret the range from ] through a, but Rust treats this spelling as the literals ], -, and a. On both Spark 3.5.9 and 4.0.4, interpreted and generated evaluation return true for _ and false for -; the locked native regex returns the opposite results. The negated form also differs. This default-native wrong-answer case contains neither ~~ nor --, so rejecting set operators alone will not fix it. Reject this class-boundary form, or normalize it only after establishing equivalent semantics, and cover both positive and negated variants.
There was a problem hiding this comment.
Good catch — this is a separate Java/Rust split, not covered by rejecting ~~ / --.
Java treats a leading ] as a literal (so []-a] is the range ]–a), while Rust reads it as the closer and then the literals ], -, a. Same mismatch on the negated form. That’s why _ vs - flip between Spark and native.
7e51a22 now never treats an unescaped ] as a class atom or range endpoint, so []-a] and [^]-a] stay off automatic native. Escaped ] ([\\]]) is still admitted.
Tests:
- analyzer:
Incompatiblefor[]-a],[^]-a],[]],[^]] - routing: dispatcher + Spark-equal answers on
_,-,],a,z, and NULL
| val scanner = new Scanner(pattern) | ||
| if (scanner.parseExpr() && !scanner.remaining) { | ||
| Compatible() |
There was a problem hiding this comment.
[P2] Preserve JVM routing for patterns beyond native compile limits
Successful syntax scanning does not ensure Regex::new can compile the pattern. For example, this analyzer admits [^;]{20000} and a{1000000}; real Spark 3.5.9/4.0.4 evaluate them on a column containing a as false, but the locked Rust engine rejects both with Compiled regex exceeds size limit of 10485760 bytes. Nested counted repetitions have the same problem, and 251 nested groups exceed Rust's separate depth limit while succeeding in Spark. The unchanged RLike::try_new and native builder propagate these errors from plan creation, with no dispatcher retry, so previously successful default-config queries now fail without opt-in. Require conservative native size/depth applicability or a reliable compilation-validation/fallback path before returning Compatible.
There was a problem hiding this comment.
Yep — scanning the syntax isn’t the same as “Rust can compile this.” Spark is happy with [^;]{20000} / a{1000000} / nested {n} / deep groups; the locked regex crate then dies at plan time (Compiled regex exceeds size limit / nest depth) and we have no dispatcher retry. That’s a default-config regression.
Didn’t add a compile-then-fallback path here. 7e51a22 just refuses those at plan time:
{n}bigger than 256- nested counted product over 4096 (
(a{100}){100}) - more than 32 nested groups
They stay on the JVM dispatcher. a{256} and 32-deep groups still go native. Analyzer + EXPLAIN/Spark-equal tests cover the cases you listed.
There was a problem hiding this comment.
[P2] {0,} still bypasses the compile-size budget
Rechecked at 0f98989095dee698db71b366998e112bdd1e15a6. The aggregate checks address the earlier sibling cases, but (([^;]{256}){0,}){256} is still classified as Compatible. The {0,} branch returns the lower bound 0, so multiplyWithinBudget reduces the inner cost from 256 to 1. Unlike {0} or {0,0}, this is unbounded repetition and the inner expression still has to be compiled.
I reproduced this with the unmodified current scanner on Scala 2.12 and 2.13. Spark 3.5.9 and 4.0.4 both return true for a non-foldable subject containing a, in interpreted evaluation and forced generated projections. The exact locked Rust regex 1.13.1 instead rejects the pattern with Compiled regex exceeds size limit of 10485760 bytes. The equivalent (([^;]{256})*){256} is correctly rejected by the scanner.
Because the admitted form now selects native execution without opt-in and the native builder propagates the compilation error, this still changes a successful default-config query into a failure. Could we retain the inner compilation cost for unbounded {0,} separately from exact-zero repetitions and add this case to the dispatcher-routing regressions? These checks used real Spark expressions and the locked regex engine plus source-traced native routing, not a full Comet/JNI query run.
There was a problem hiding this comment.
Thanks, that was a real gap. {0,} was using the lower bound 0 as the expansion multiplier, so multiplyWithinBudget collapsed the inner cost to 1 and admitted (([^;]{256}){0,}){256}. That pattern is Java-valid but rejected by the locked Rust regex crate, so the native path would fail a query that Spark currently succeeds.
The unbounded {n,} branch now uses math.max(1, n), so {0,} keeps the inner compile cost. {0} and {0,0} stay exact-zero and are still Compatible.
Coverage:
- analyzer:
(([^;]{256}){0,}){256}is Incompatible;{0}/{0,0}remain Compatible - routing: the unbounded pattern stays on the JVM codegen dispatcher and matches Spark
- exact-zero counterparts stay native and match Spark
Pushed in c62b529a7.
…e budget Reject Rust-only character-class operators and unescaped ] range endpoints so incompatible literals stay on the JVM dispatcher. Cap nested counted expansion and group depth so Java-valid patterns that Rust cannot compile do not fail native plan construction.
| } | ||
| if (peek == '\\') { | ||
| parseEscape(inClass = true) | ||
| } else if (isPrintableAscii(peek)) { |
There was a problem hiding this comment.
[P2] Preserve Spark errors for raw [ range endpoints
The [ guard in parseClass runs before the range branch consumes -, so this helper can still accept an unescaped [ as the range endpoint. The exact scanner returns Compatible for [@-[] (and [^@-[]). With a non-foldable string input, real Spark 3.5.9 and 4.0.4 reject these patterns: interpreted RLIKE raises an invalid-pattern error, and forced generated-projection construction raises PatternSyntaxException for an unclosed character class. The locked Rust regex 1.13.1 instead compiles [@-[] and matches A. Because CometRLike.convert now trusts this admission result without incompatible opt-in, a malformed-pattern failure becomes a successful predicate under the default native route. Please reject raw [ in the range-endpoint helper as well; the escaped [@-\[] control is valid and equivalent in both engines.
There was a problem hiding this comment.
Good catch — the class-loop [ guard runs before - is consumed, so it never saw [@-[] / [^@-[] as a nested class. Java rejects those as an unclosed class; Rust compiles them and matches A. That’s a default-native “error becomes true/false” case. This commit rejects an unescaped [ in parseClassAtom as well, so it cannot be a range endpoint. Escaped [@-\[] is still admitted and covered by Java/native parity.
Tests:
- analyzer:
Incompatiblefor[@-[]/[^@-[];Compatiblefor[@-\[] - routing: dispatcher + Spark regex error on a non-foldable column (
@,[,A, NULL)
Reject unescaped '[' as a character-class range endpoint so malformed Java patterns like [@-[] stay on the JVM dispatcher and preserve Spark regex errors. Apply MaxExpansion across concatenation, alternation, and counted repetition, including zero-count terms, so sibling groups and repeated atoms cannot bypass compile-size admission.
| } | ||
| } | ||
| outOfSubsetPatterns.foreach { p => | ||
| val query = s"select c1 rlike '${p.pattern}' from parquetV1Table" |
There was a problem hiding this comment.
[P2] Preserve the backslash in the out-of-subset benchmark SQL
With the default spark.sql.parser.escapedStringLiterals=false, interpolating the new \d+ pattern here makes Spark parse it as d+ (reproduced with Spark 3.5.9 and 4.0.4). CometRegex admits d+, so the case labelled Comet (Exec, JVM regex) selects the automatic native branch just like the opted-in case. The generated numeric strings also match none of the rows instead of every row. This leaves the new four-way comparison without a JVM-dispatcher measurement. Please preserve the backslash with an escaped or raw SQL literal, as the parity suite already does, before passing this query to runOutOfSubsetModes.
There was a problem hiding this comment.
Good catch — fixed in c62b529a7
The out-of-subset pattern is now escaped before being interpolated into the SQL query:
val escapedPattern = p.pattern.replace("\\", "\\\\")
val query = s"select c1 rlike '$escapedPattern' from parquetV1Table"Keep `{0,}` from collapsing inner cost so nested patterns like
`(([^;]{256}){0,}){256}` stay on the JVM dispatcher. Escape
backslashes in the rlike benchmark SQL so `\d+` is not parsed as `d+`.
Which issue does this PR close?
Closes #5351.
Rationale for this change
rlikealready has a native kernel, but the Rust regex implementation is treated as potentially incompatible with Java regex and is therefore opt-in. Before this PR, literal patterns ran through the JVM codegen dispatcher by default unlessspark.comet.expression.RLike.allowIncompatible=true.#4310 correctly concluded that the Rust engine cannot be made fully compatible with Java regex, so this PR does not change the engine globally. Instead, it introduces a deliberately restricted, conservative whitelist for plan-time
UTF8_BINARYliteral patterns.Patterns admitted by the whitelist use native execution by default and are covered by Java-versus-Rust differential tests. Out-of-subset non-null literals preserve the existing dispatcher /
allowIncompatiblebehavior. Non-literal and NULL patterns use the dispatcher; NULL is never considered a usable native pattern.What changes are included in this PR?
CometRegex, a recursive-descent parser implementing a conservative whitelist rather than relying on substring matching. Unrecognized constructs areIncompatible.*+?{n}{n,}{n,m}, capturing and non-capturing groups ((?:…)), alternation, and escaped metacharacters, provided counted and nested forms remain within conservative native compile-size and depth budgets.^$.\d\w\s, lookaround, backrefs, possessive/lazy quantifiers, inline flags,\p/\u/\0, nested classes, Rust-only class set operators (&&,~~,--), unescaped]class atoms or range endpoints, raw[range endpoints, counted or nested patterns exceeding the conservative compile budget, non-ASCII patterns, and non-default Spark 4 collation on either operand.{n,}, a lower bound of zero still retains the inner expression's compilation cost; exact-zero{0}and{0,0}repetitions remain distinct.CometRLikeconsults the analyzer. In-subset literals becomeCompatible()with nonativeOptInhint and convert to native without opt-in.Compatible(nativeOptIn = …)and convert to the dispatcher unlessallowIncompatible=true.literalPatternonly matches a non-nullUTF8Stringliteral).allowIncompatiblestill forces native execution for any non-null literal.compatibility/regex.md,expressions.md(rlike/regexp/regexp_like), and therlikeexpression-audit note.(?-u), propagate collation into the native kernel, or changeregexp_replace,split,regexp_extract, orregexp_extract_all.A conservative whitelist identifies a deliberately restricted subset for which Java and Rust
findsemantics are expected to agree and are covered by differential parity tests. It is not a formal proof of equivalence.How are these changes tested?
CometRegexSuite: admit / reject coverage, including:[(?=],\\d, and[.];\A,\Z,{2,1}, and an unclosed(;]and raw[class-range boundaries;{0,}regression(([^;]{256}){0,}){256};{0}and{0,0}controls.CometRegexParitySuite: every pattern in the admitted corpus × ASCII / non-ASCII / newline / NULL subjects, with native results compared againstjava.util.regex.Pattern.find. The suite also asserts that EXPLAIN output does not containJVM codegen dispatcher: rlike. The multi-batch corpus contains 5000 rows with batch size 64. NULL subjects match Spark; a NULL pattern is not part of this native corpus.CometRegExpJvmSuite: routing coverage for:allowIncompatible=true, including expected native compilation failures for unsupported Rust constructs;{0,}regression is verified against Spark using non-foldable input, including NULL, and remains on the JVM dispatcher.rlike_auto_native.sqlverifies default-configuration result equality.CometRegexSuiteandCometRegExpJvmSuitecompleted on Spark 4.1 / Scala 2.13:Other supported Spark and Scala profiles are covered by CI.
Benchmark
CometRegExpBenchmark, 1,048,576 rows, Apple M4, JDK 17.Native vs Spark for in-subset patterns
After this PR, these patterns use native execution by default.
[0-9]+abc|def|ghi[a-zA-Z][0-9]+(ab){2,}Default Comet execution before and after this PR
These measurements use identical in-subset queries. Both modes were measured on the PR base commit in the same run: the JVM dispatcher represents the pre-PR default, while
allowIncompatible=trueselects the same native kernel that this PR now chooses automatically.[0-9]+abc|def|ghi[a-zA-Z][0-9]+(ab){2,}On this ASCII
REPEATworkload, the pre-PR dispatcher is essentially Spark-cost. Switching the in-subset default from the dispatcher to native execution is the performance improvement that matters for this PR.Out-of-subset control
\d+remains outside the automatic-native subset because Java and Rust differ in their Unicode digit semantics. It uses the JVM dispatcher by default;allowIncompatible=trueexplicitly selects the native path.The benchmark SQL preserves the backslash so Spark receives
\d+rather than parsing it asd+.\d+The corrected JVM-dispatcher measurement is close to Spark cost, as expected. The explicitly opted-in native path is approximately 4.7X faster than the dispatcher on this ASCII workload.
The native implementation matches over Arrow buffers and is expected to avoid the dispatcher's per-row
toString()/Matcherallocations. This allocation difference was not measured with JFR or a GC log in this PR.