andygrove opened a new issue, #5354:
URL: https://github.com/apache/datafusion-comet/issues/5354

   ## Summary
   
   `replace` has a native kernel already (DataFusion `replace`), gated off 
behind
   `spark.comet.expression.StringReplace.allowIncompatible`. Its entire 
incompatibility is **one input
   condition**: Spark returns the input unchanged when the search string is 
empty, DataFusion inserts the
   replacement between every character.
   
   ```
   SELECT replace('hello', '', 'x')   -- Spark: hello      DataFusion: 
xhxexlxlxox
   ```
   
   That condition is a plain emptiness check on an argument, so when the search 
argument is a `Literal`
   (the overwhelmingly common shape) it is decidable at plan time. This is the 
narrowest compatibility gap
   of any candidate assessed so far, and closing it needs no native code.
   
   Assessed with the `suggest-native-expression` skill: **compatibility 
confidence High**, **native upside
   Medium to High, measured**. Verdict: Recommended.
   
   Prior art: [#3344](https://github.com/apache/datafusion-comet/issues/3344) 
and
   [#4497](https://github.com/apache/datafusion-comet/issues/4497) are both 
closed. They reported the
   divergence as a bug and it was fixed by routing `replace` through the 
dispatcher, which is the current
   state. Neither covers making the native path the default for the safe 
subset. (Note for the record:
   #3344 states the divergence backwards, claiming Spark produces 
`xhxexlxlxox`. #4497 has it right.)
   
   ## How it runs today
   
   `object CometStringReplace extends 
CometScalarFunction[StringReplace]("replace") with NativeOptInAvailable`
   (`spark/src/main/scala/org/apache/comet/serde/strings.scala:179`).
   
   `getSupportLevel` returns `Compatible(nativeOptIn = ...)` unconditionally 
when the user has not opted
   in, so **every** `replace` runs on the dispatcher, including the vast 
majority whose search string is a
   non-empty literal and for which the native kernel is already exactly correct.
   
   `getIncompatibleReasons()` is "Produces different results from Spark when 
the search string is empty",
   which already names the whole gap.
   
   ## Native upside: Medium to High (measured)
   
   Two measurements, both on this branch.
   
   **1. The emitted kernel.** Dumped via 
`CometBatchKernelCodegen.generateSource` for
   `StringReplace(BoundReference(0, StringType), Literal("XYZ"), 
Literal("QQ"))`:
   
   ```java
   value_0 = CollationSupport.StringReplace.execBinary(value_1, references[0], 
references[1]);
   ...
   Object utfBase_0 = value_0.getBaseObject();
   if (utfBase_0 instanceof byte[]) {
     output.setSafe(i, (byte[]) utfBase_0, ..., utfLen_0);
   } else {
     byte[] utfArr_0 = value_0.getBytes();     // allocation + copy
     output.setSafe(i, utfArr_0, 0, utfArr_0.length);
   }
   ```
   
   The important subtlety is in that output branch. `UTF8String.replace` 
returns `this` when there is no
   match, and in the dispatcher the input is off-heap backed 
(`UTF8String.fromAddress`, so `base` is
   `null`, not a `byte[]`). Non-matching rows therefore take the **`getBytes()` 
branch and allocate a copy
   anyway**. Reading `UTF8String.replace` in isolation suggests non-matching 
rows are free. They are not.
   
   **2. Allocated bytes per row**, measured with
   `com.sun.management.ThreadMXBean.getThreadAllocatedBytes` over 8192 
off-heap-backed 64-byte values,
   100 iterations after 200 warmup passes, including the kernel's output-write 
branch:
   
   | match density | bytes/row | ns/row |
   | --- | --- | --- |
   | 0% | 80.0 | 171.0 |
   | 10% | 85.6 | 195.8 |
   | 50% | 108.0 | 170.5 |
   | 100% | 136.0 | 182.4 |
   
   For contrast, the same measurement with heap-backed inputs (which is *not* 
what the dispatcher
   provides) reports 0.0 bytes/row at 0% density. That difference is exactly 
the output-side copy above,
   and it is why the off-heap variant is the one to trust.
   
   So the allocation saving is **unconditional**, 80 to 136 bytes per row, 
roughly 650 KB to 1.1 MB of JVM
   heap churn per 8192-row batch, none of it visible to Comet's native memory 
pool. A native kernel writes
   straight into the Arrow output buffer, and for the pass-through case can 
copy buffer to buffer.
   
   **What is not established:** the wall-clock win. Time per row is flat at 
roughly 170 to 195 ns and
   barely moves with density, so this path is **scan-bound, not 
allocation-bound**. Whether native is
   faster depends on whether Rust's substring search beats `UTF8String.find`'s 
byte-at-a-time loop, which
   is plausible (memchr-accelerated) but unmeasured here because it needs a 
release build of the native
   library. Both paths already exist, so this is cheap for the implementer: run 
`CometStringExpressionBenchmark`
   with and without 
`spark.comet.expression.StringReplace.allowIncompatible=true` at several match
   densities. It is in the acceptance criteria below rather than asserted here.
   
   ## Compatibility: High
   
   ### Spark versions
   
   Identical across 3.4.3, 3.5.9, 4.0.4, 4.1.3, and 4.2.0 for the default 
collation. 3.4.3 and 3.5.9 are
   byte-identical; 4.0.4 adds `NullIntolerant` becoming an override, a 
`collationId` field, and routes
   through `CollationSupport.StringReplace.exec`, whose `isUtf8BinaryType` 
branch is
   `execBinary(src, search, replace) = src.replace(search, replace)`, i.e. the 
same call as 3.x. 4.0.4,
   4.1.3, and 4.2.0 are byte-identical to each other. No shims needed.
   
   ### Hazard checklist
   
   | Hazard | Applies? |
   | --- | --- |
   | Empty search string | **Yes, and this is the whole gap.** Spark 
short-circuits on `search.numBytes == 0` (`UTF8String.java`) and returns the 
input. DataFusion inserts between every character. Decidable at plan time for a 
literal search argument. |
   | Collation on Spark 4.0+ | **Yes.** Non-UTF8_BINARY collations route to 
`execLowercase` / `execICU`, which the native path does not implement 
([#4496](https://github.com/apache/datafusion-comet/issues/4496)). Out of 
subset, and the collation is on the expression's type, so this is plan-time 
detectable. |
   | Empty **source** string | No. Spark returns the input; DataFusion's 
replace over an empty haystack also produces empty. Same result. |
   | JVM formatting / locale / ICU | No. Byte-level search and splice, no 
locale involvement. |
   | Invalid UTF-8 | No more than any other string expression; the search and 
replace are both byte sequences and Comet's ingress policy already applies. |
   | Regex features, decimal, timezone, ANSI errors, lambdas, nondeterminism, 
host state | No |
   
   Unlike `upper`/`lower` 
([#5353](https://github.com/apache/datafusion-comet/issues/5353)), the guard 
here
   is an **expression** property rather than a **data** property, so it needs 
no per-batch fallback
   mechanism and is expressible in today's architecture.
   
   ## Proposed approach
   
   No native code. This is a `getSupportLevel` change plus tests.
   
   1. In `CometStringReplace.getSupportLevel`, return `Compatible(None)` when 
the search argument is a
      non-empty `Literal` **and** the collation is UTF8_BINARY. That case takes 
the native path by default.
   2. Everything else keeps today's behaviour exactly: an empty literal search, 
a non-literal search, or a
      non-default collation returns `Compatible(nativeOptIn = ...)` and rides 
the dispatcher.
   3. Keep `getIncompatibleReasons()` as is, since it still describes the 
opt-in path for the cases that
      stay on the dispatcher, and add a compatible note documenting the new 
default.
   4. Consider a follow-up for the non-literal search case: a native kernel 
could implement Spark's
      short-circuit directly (`if search.is_empty() { return src }`), which 
would make the whole expression
      compatible rather than just the literal subset. That is a small kernel 
change in
      `datafusion-comet-spark-expr` (or upstream), and it would be the cleaner 
end state. Filing this as
      step 4 rather than step 1 because the plan-time guard delivers most of 
the value with no native
      change at all.
   
   ### Non-goals
   
   - Collation support. Non-default collations stay on the dispatcher.
   - Changing the `allowIncompatible` config surface.
   
   ## Acceptance criteria
   
   - `replace(col, 'nonempty', r)` runs natively by default, asserted via the 
absence of the expression
     from the `[COMET-INFO: JVM codegen dispatcher: ...]` EXPLAIN segment.
   - `replace(col, '', r)`, a non-literal search, and non-default collations 
demonstrably still route to
     the dispatcher, each with a test.
   - A Comet SQL Test covering empty search, empty source, no-match, single 
match, multiple matches,
     overlapping candidates, and multi-byte UTF-8 values.
   - `CometStringExpressionBenchmark` numbers, dispatcher versus native, at 0%, 
10%, and 100% match
     density, in the PR description. If native turns out **not** to be faster 
despite the allocation
     saving, say so in the PR and let reviewers decide whether the reduced heap 
churn alone justifies the
     change.
   - The `replace` entry in 
`docs/source/contributor-guide/expression-audits/string_funcs.md` updated.
   
   ---
   
   Filed by the `suggest-native-expression` skill. Motivation:
   [Native Coverage for Codegen-Dispatched 
Expressions](https://github.com/apache/datafusion-comet/blob/main/docs/source/contributor-guide/roadmap.md#native-coverage-for-codegen-dispatched-expressions).
   Assessment recorded in 
`docs/source/contributor-guide/expression-audits/string_funcs.md` under
   `## replace`. Earlier runs of the same skill produced #5347, #5349, #5351, 
and #5353.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to