feat: Support Spark levenshtein expression in native execution#4105
feat: Support Spark levenshtein expression in native execution#4105Myx778 wants to merge 16 commits into
Conversation
|
Thank you for the PR . @Myx778 . Could you please also add SLT tests to match sparks behavior? |
|
Thanks for the review! I've added SLT tests in The test file covers:
|
|
|
||
| -- column arguments | ||
| query | ||
| SELECT levenshtein(s1, s2) FROM test_levenshtein |
There was a problem hiding this comment.
Could you add tests for the three argument version where the third argument is threshold.
@ExpressionDescription(
usage = """
_FUNC_(str1, str2[, threshold]) - Returns the Levenshtein distance between the two given strings. If threshold is set and distance more than it, return -1.""",
examples = """
Examples:
> SELECT _FUNC_('kitten', 'sitting');
3
> SELECT _FUNC_('kitten', 'sitting', 2);
-1
""",
since = "1.5.0",
group = "string_funcs")
There was a problem hiding this comment.
Done! I've implemented full three-argument threshold support:
Rust (levenshtein.rs):
spark_levenshteinnow accepts 2 or 3 args- 3rd arg is extracted as
ScalarValue::Int32threshold - Returns -1 when distance > threshold, matching Spark semantics
- NULL threshold → NULL result
Tests added:
- Rust unit tests:
test_spark_levenshtein_with_thresholdandtest_spark_levenshtein_null_threshold - SLT: threshold with literals, columns, edge cases (threshold=0), and NULL threshold
- Scala:
"levenshtein with threshold"test withcheckSparkAnswerAndOperator - Benchmark:
levenshtein_thresholdentry
Commit: 7f37fd8
| classOf[GetJsonObject] -> CometGetJsonObject, | ||
| classOf[InitCap] -> CometInitCap, | ||
| classOf[Length] -> CometLength, | ||
| classOf[Levenshtein] -> CometScalarFunction("levenshtein"), |
There was a problem hiding this comment.
Spark 4 lets levenshtein accept StringTypeWithCollation. With a non-default collation like utf8_lcase, Spark expects collation-aware comparison. The PR does no collation check. Could you implement getSupportLevel for this expression so it falls back for unsupported collations in Spark 4.
There was a problem hiding this comment.
Done! Added CometLevenshtein serde object in strings.scala with getSupportLevel that checks for non-default collation:
object CometLevenshtein extends CometScalarFunction[Levenshtein]("levenshtein") {
override def getSupportLevel(expr: Levenshtein): SupportLevel = {
expr.children.headOption match {
case Some(child) if QueryPlanSerde.isStringCollationType(child.dataType) =>
Unsupported(Some("Levenshtein with non-default collation is not supported"))
case _ => Compatible()
}
}
}This uses the existing isStringCollationType from CometTypeShim (which checks collationId != StringType.collationId on Spark 4, no-op on Spark 3.x). Non-default collations will fall back to Spark's implementation.
Updated QueryPlanSerde.scala mapping from generic CometScalarFunction("levenshtein") to CometLevenshtein.
Commit: 7f37fd8
|
Could you add a microbenchmark in |
|
Could you update |
|
Thanks @andygrove for the review! Done:
Commit: 8bdc34a |
Benchmark ResultsRan levenshtein (2-arg)levenshtein_threshold (3-arg)AnalysisPerformance is roughly on par with Spark for both variants (~1.0X). This is expected because:
The key benefit is correctness — ensuring the native path produces identical results to Spark, which is validated by the unit tests and SLT tests. |
|
Thanks @Myx778 - could you run "make format" to fix CI failures: |
|
There are some test failures: |
| } | ||
| } | ||
|
|
||
| object CometLevenshtein extends CometScalarFunction[Levenshtein]("levenshtein") { |
There was a problem hiding this comment.
The current CometLevenshtein inherits CometScalarFunction.convert, which does not set return_type on the proto. When the native planner sees no return type it falls back to session_ctx.udf("levenshtein"), which finds DataFusion's built-in 2-arg LevenshteinFunc and fails signature validation as soon as a third arg is present. That is what CometNativeException: Error
from DataFusion: Function 'levenshtein' expects 2 arguments but received 3. is telling us in the 3.5 / 4.0 logs. Could you override convert and use scalarFunctionExprToProtoWithReturnType("levenshtein", IntegerType, false, ...) so the planner skips the registry lookup, the same way CometStringSplit and CometGetJsonObject do?
There was a problem hiding this comment.
Fixed! Changed CometLevenshtein from extending CometScalarFunction to CometExpressionSerde, and overrode convert() to use scalarFunctionExprToProtoWithReturnType:
override def convert(
expr: Levenshtein,
inputs: Seq[Attribute],
binding: Boolean): Option[Expr] = {
val childExprs = expr.children.map(exprToProtoInternal(_, inputs, binding))
val optExpr = scalarFunctionExprToProtoWithReturnType(
"levenshtein",
IntegerType,
false,
childExprs: _*)
optExprWithInfo(optExpr, expr, expr.children: _*)
}This ensures the proto includes return_type = IntegerType, so the native planner uses our registered Comet UDF directly instead of looking up DataFusion's built-in 2-arg LevenshteinFunc.
Also fixed the Spotless issue (removed unnecessary s prefix).
Commit: f099ffe
|
Thanks @andygrove for catching both issues! Fixed in commit f099ffe:
|
40535ff to
1555d9f
Compare
|
Thanks for addressing review comments @Myx778. I kicked off CI. |
|
|
||
| -- three argument version with threshold | ||
| query | ||
| SELECT levenshtein('kitten', 'sitting', 2), levenshtein('kitten', 'sitting', 3), levenshtein('kitten', 'sitting', 4) |
There was a problem hiding this comment.
Could you add a test where the threshold argument is a column rather than a literal? I am not sure that the implementation handles this currently
There was a problem hiding this comment.
Done! The implementation now supports threshold as both scalar and column.
Changes:
- Refactored
spark_levenshteinto useinto_array()which handles bothColumnarValue::ScalarandColumnarValue::Arrayuniformly - Per-row NULL threshold → NULL result for that row
- Added Scala integration tests (
levenshtein with threshold as column+ null/negative cases) - Added SQL file tests with threshold column
Rust unit test results (all 8 passing):
running 8 tests
test string_funcs::levenshtein::tests::test_levenshtein_basic ... ok
test string_funcs::levenshtein::tests::test_levenshtein_unicode ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_nulls ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_null_threshold ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_with_threshold ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_threshold_as_array ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_threshold_array_with_nulls ... ok
test string_funcs::levenshtein::tests::test_spark_levenshtein_threshold_negative ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 341 filtered out
ef5e379 to
3a683a4
Compare
|
Hi @andygrove, just a gentle ping — I've pushed the changes you requested (threshold as column support + tests). The CI is waiting for approval to run. Whenever you have a moment, could you kick it off? No rush at all, just wanted to make sure it didn't slip through. Thanks for all your guidance on this PR! |
|
Hi @andygrove, the 2 failing checks are Could you re-run the CI or approve when you get a chance? All other 69 checks pass. Thanks! |
26766c3 to
aaa1d04
Compare
|
Hi @andygrove, I've rebased onto the latest main to resolve the merge conflicts (the branch was 163 commits behind). The conflicts were straightforward — mostly the |
Implements the Levenshtein edit distance function as a native Comet
scalar UDF, enabling Spark's `levenshtein(str1, str2)` to run via
DataFusion instead of falling back to JVM.
Implementation:
- Rust kernel: O(min(m,n)) space DP algorithm with Unicode char-level
distance computation and proper NULL propagation
- Scala serde: Register via CometScalarFunction("levenshtein") in
QueryPlanSerde stringExpressions map
- Tests: Basic, NULL handling, and Unicode test cases
Closes apache#3084
- Reformat Seq literal to match scalafmt maxColumn=98 constraint - Break long sql() call into multi-line format - Reformat unicode test data as multi-line Seq
- Fix Clippy needless_range_loop: use iter_mut().enumerate() instead of indexing loop for initializing prev array - Fix Scalafix unused import: remove Levenshtein from strings.scala imports (it's used via wildcard import in QueryPlanSerde.scala)
Add SQL logic test file covering: - Column arguments with NULL propagation - Column + literal combinations - Literal + literal edge cases - Identical string comparison - Unicode character-level distance
- Add levenshtein benchmark entry in CometStringExpressionBenchmark - Update expressions.md to list Levenshtein as supported
…heck - Implement threshold semantics: levenshtein(str1, str2, threshold) returns -1 when distance exceeds threshold, matching Spark behavior - Add CometLevenshtein serde with getSupportLevel that falls back for non-default collations (Spark 4 StringTypeWithCollation) - Add Rust tests for threshold and NULL threshold cases - Add SLT tests for threshold variants - Add Scala integration test for threshold - Add levenshtein_threshold to benchmark
Override convert() to use scalarFunctionExprToProtoWithReturnType with IntegerType, so the native planner skips the DataFusion registry lookup and does not conflict with DataFusion's built-in 2-arg levenshtein function when 3 args (threshold) are passed. Also fix Spotless: remove unnecessary string interpolation prefix.
…k < 3.5 The three-argument levenshtein(str1, str2, threshold) is only available in Spark 3.5+. This moves threshold-related SQL tests to a separate file with MinSparkVersion: 3.5 and adds assume(isSpark35Plus) to the unit test.
- Refactored spark_levenshtein to handle threshold as either ColumnarValue::Scalar or ColumnarValue::Array using into_array() - NULL threshold in a column produces NULL result for that row - Added Rust unit tests for array threshold, nulls, and negative values - Added Scala integration tests with threshold as column reference - Added SQL file tests with threshold column and NULL scenarios
aaa1d04 to
ed37d4a
Compare
|
Hi @andygrove and @mbutrovich, Hope you both are doing well! I just rebased onto the latest main again to resolve fresh merge conflicts (the branch had fallen 82 commits behind since the previous rebase). A note on one substantive merge decision worth flagging:
The PR has been through ~5 review rounds since April and I've tried to address all substantive feedback. The branch is mergeable now and I've kept the commit history clean. Whenever either of you has a moment, would you be able to take another look, or kick off CI so we can confirm everything is green? No urgency at all — I really appreciate the time you've already put into reviewing this. Thanks so much for your guidance throughout! Best regards, |
|
Thanks for the persistence on this one @Myx778, and thanks especially for calling out the codegen-dispatch vs native decision explicitly. A few thoughts after going through the diff and checking the Rust against Spark's Correctness looks good. I traced the threshold logic against Spark's banded The main thing to weigh is whether the native path is worth it. A couple of smaller code points:
Also the PR is showing merge conflicts again, so it will need another rebase onto |
Resolve the stale-branch conflict and make the native path safe and efficient before CI validation. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thank you very much for the detailed review and for validating the correctness against Spark's implementation, @andygrove. Your concern about the maintenance trade-off is entirely fair. I agree that keeping the pipeline native only when I have pushed commit
For the native-path decision, I plan to optimize and benchmark the implementation more thoroughly, including ASCII and Unicode data, short and long strings, literal and column operands, several threshold values, and dispatcher-enabled/disabled execution. The main optimization candidates are an ASCII fast path, common-prefix/suffix trimming, reusable batch-level work buffers, and a bit-parallel path for short strings. If those changes do not produce a clear, repeatable advantage over The new CI workflows have been triggered, but they are currently waiting for an Apache repository maintainer to approve the fork workflow runs. Thank you again for the thoughtful feedback. |
Which issue does this PR close?
Closes #3084
What changes are included in this PR?
Implements Spark's
levenshtein(str1, str2)function as a native Comet scalar UDF, enabling it to run via DataFusion instead of falling back to the JVM.Implementation Details
Rust (native/spark-expr/src/string_funcs/levenshtein.rs)
Scala Serde (strings.scala + QueryPlanSerde.scala)
CometScalarFunction("levenshtein")— leverages existing ScalarFunc proto pathwayUDF Registration (comet_scalar_funcs.rs)
spark_levenshteinin thecreate_comet_physical_fun_with_eval_modematchTests (CometStringExpressionSuite.scala)
test("levenshtein")— basic edit distance computationtest("levenshtein with nulls")— NULL propagationtest("levenshtein with unicode")— character-level distance for CJK and emojiHow are these changes tested?
cargo test -p datafusion-comet-spark-exprCometStringExpressionSuite(3 new test cases)checkSparkAnswerAndOperatorto verify result matches Spark AND runs natively