andygrove opened a new issue, #5347:
URL: https://github.com/apache/datafusion-comet/issues/5347
## Summary
`unbase64` is the last remaining codegen-dispatched member of Comet's base64
family: `base64` has a
native Rust kernel (`spark_base64`,
`native/spark-expr/src/string_funcs/base64.rs`) with a criterion
benchmark, while the decode direction still runs Spark's generated JVM code
inside the Comet
pipeline. Closing this asymmetry removes 5 JVM heap allocations per non-null
row and a double scan of
every input value.
Assessed with the `suggest-native-expression` skill (survey mode over the 58
`Codegen dispatch` rows
in the expression reference). `unbase64` topped the ranking: **compatibility
confidence High**,
**native upside High**.
Note on prior art: #423 ("Support spark unbase64 function") is closed, but
it was about *support*,
which was delivered via codegen dispatch. This issue is about the native
implementation, which does
not exist.
## How it runs today
`object CometUnBase64 extends CometCodegenDispatch[UnBase64]`
(`spark/src/main/scala/org/apache/comet/serde/strings.scala`). A plain
`CometCodegenDispatch` means
the codegen dispatcher is the only path: no native path exists and there is
no `NativeOptInAvailable`
opt-in. `getSupportLevel` is `Compatible()`, so behavior matches Spark
exactly, at the cost of one
JNI round trip per batch plus per-row JVM evaluation.
`StringType` input and `BinaryType` output are both inside
`CometBatchKernelCodegen.isSupportedDataType`,
so there are **no** input shapes that fall back to Spark entirely. The only
fallback trigger is
`spark.comet.exec.scalaUDF.codegen.enabled=false`. The upside here is purely
the dispatch cost, not a
recovered whole-operator fallback.
## Native upside: High
Spark's `doGenCode` for `UnBase64` (unchanged across 3.4.3, 3.5.8, 4.0.1,
4.1.1) emits:
```java
${ev.value} = java.util.Base64.getMimeDecoder().decode($child.toString());
```
`getMimeDecoder()` returns a cached singleton, so that part allocates
nothing. Everything else does.
Per non-null row, counted from the Spark and JDK 17 sources rather than
estimated:
1. `UTF8String.toString()` calls `getBytes()`. In the dispatcher, strings
are read zero-copy through
`UTF8String.fromAddress(null, addr, len)`
(`CometBatchKernelCodegenInput.scala:275`), so `base` is off-heap and
`getBytes()` takes the
copying branch (`UTF8String.java:252-262`). → **1 `byte[]`**
2. `new String(bytes, UTF_8)` (`UTF8String.java:1378-1380`). → **1 `String`
+ 1 internal `byte[]`**
3. `Base64.Decoder.decode(String)` is `decode(src.getBytes(ISO_8859_1))`
(`Base64.java:588-590`).
→ **1 `byte[]`**
4. `decode(byte[])` allocates `new byte[decodedOutLength(...)]`
(`Base64.java:564-571`).
→ **1 `byte[]`**, plus an `Arrays.copyOf` when the estimate overshoots.
That is **5 heap allocations per non-null row, about 41,000 per 8192-row
batch**, none of which are
visible to Comet's native memory pool. On top of that, the MIME decoder
scans each value twice:
`decodedOutLength` pre-scans the whole input to discount non-alphabet bytes
(`Base64.java:712-725`,
comment: "a performance trade-off of pre-scan or Arrays.copyOf") before
`decode0` scans it again.
A native kernel removes all of it with the pattern already documented in
`docs/source/contributor-guide/optimizing_expressions.md` and already used
by `spark_unhex`: a
compile-time 256-entry decode table plus a `BinaryBuilder` preallocated to
`len / 4 * 3`, writing
straight into the output buffer in a single pass with zero per-row heap
allocation. The encode-side
sibling `spark_base64` is the working precedent, including its
`benches/base64.rs`.
Honest limits on this rating:
- The allocation counts above are **derived from source, not measured**. No
JVM benchmark in the repo
covers `base64` or `unbase64`, so there is no baseline to quote.
Establishing one is part of the
work.
- Workload presence is **unmeasured**. `grep` over `benchmarks/tpc/queries/`
finds zero uses of
`unbase64` in TPC-H or TPC-DS. The claim that base64 decoding is common in
ETL pipelines (decoding
embedded payloads, Kafka values, log fields) is a judgment call, not a
measurement.
## Compatibility assessment: High
### Spark versions
Semantics are **identical** across 3.4.3, 3.5.8, 4.0.1, and 4.1.1. The only
differences are
non-behavioral:
- 4.0.1: the `NullIntolerant` trait becomes `override def nullIntolerant:
Boolean = true`, and
`inputTypes` widens from `Seq(StringType)` to
`Seq(StringTypeWithCollation(supportsTrimCollation = true))`.
- 4.1.1: adds `contextIndependentFoldable`.
No shims are needed for the native kernel.
### Hazard checklist
| Hazard | Applies? |
| --- | --- |
| JVM formatting APIs (`DecimalFormat`, `String.format`, date formatters) |
No |
| `java.util.regex` features Rust's `regex` lacks | No |
| Collation on Spark 4.0+ | **No, cleared with reasoning.** `inputTypes`
accepts collated strings, but the implementation never routes through
`CollationSupport`. Base64 decoding is byte-level and collation-independent, so
there is no divergence to guard. |
| `BigDecimal` / `MathContext` | No |
| Timezone and calendar | No |
| ANSI error class and message parity | **Yes, bounded.** See below. |
| Float/double text conversion, `StrictMath` | No |
| Raw byte semantics of `UTF8String` (invalid UTF-8, #4764) | **No, cleared
with reasoning.** Spark itself decodes via `toString()`, which replaces
ill-formed sequences with `U+FFFD`; `U+FFFD` is not in the base64 alphabet, so
the MIME decoder skips it. Comet's ingress replacement produces the same
character stream. |
| Lambda bodies / arbitrary Catalyst trees | No |
| JVM session state beyond a scalar config | No |
| Nondeterminism or ordering sensitivity | No |
| Cross-version behavioral differences | No (see above) |
### The one real hazard: the decoder is the JDK MIME decoder, not a standard
base64 engine
This is the crux, and it is where a naive implementation would ship a
correctness bug.
`java.util.Base64.getMimeDecoder()` **silently skips every byte outside the
base64 alphabet** and
has four enumerable error conditions (JDK 17 `Base64.java`):
1. `"Input byte array has wrong 4-byte ending unit"` — malformed padding
(`=`, `xx=y`, `xx=` with a
missing second `=`).
2. `"Last unit does not have enough valid bits"` — a dangling single
character.
3. `"Input byte array has incorrect ending byte at N"` — an alphabet
character appearing after
padding.
4. `"Input byte[] should at least have 2 bytes for base64 bytes"` — from
`decodedOutLength`.
Every one of these is mechanically reproducible from the JDK source, which
is why the rating is High
rather than Medium. Comet has already accepted a port of the mirror-image
MIME **encoder** semantics
in `spark_base64` (76-char lines, CRLF, no trailing separator), so this is
consistent with existing
precedent. Error parity mechanics are covered by
`docs/source/contributor-guide/sql_error_propagation.md`.
### Upstream is NOT usable as-is
`datafusion-spark` 54.1.0 (the version Comet depends on) has
`SparkUnBase64`, but it is a `simplify`
wrapper that lowers to `decode(bin, 'base64pad')`, and `decode`'s base64
path uses
`datafusion-functions`' `BASE64_ENGINE`: `base64::alphabet::STANDARD` with
`DecodePaddingMode::Indifferent`
(`datafusion-functions-54.1.0/src/encoding/inner.rs:45-51`). That engine
**errors** on any
non-alphabet byte instead of skipping it, which diverges from Spark:
| Input | Spark (`getMimeDecoder`) | `datafusion-spark::SparkUnBase64` |
| --- | --- | --- |
| `unbase64('YW Jj')` | `abc` (space skipped) | error: `Failed to decode
from base64: Invalid byte 32` |
| `unbase64('YWJj?')` | `abc` (`?` skipped) | error |
| `unbase64(base64(x))` where `x` is longer than 57 bytes | round-trips |
error: Spark's `base64` CRLF-chunks at 76 chars by default
(`spark.sql.chunkBase64String.enabled=true`), and Comet's own `spark_base64`
does the same, so the encoded value contains `\r\n` |
The third row is the important one: wiring the upstream function would break
round-tripping Comet's
own `base64` output, which is the most common way `unbase64` is used. Worth
reporting upstream
separately.
## Proposed approach
Implement a native kernel rather than wiring upstream. See the
`implement-comet-expression` skill.
1. Add `spark_unbase64` under `native/spark-expr/src/string_funcs/`, next to
`base64.rs`. Port the
JDK MIME decoder rules: skip non-alphabet bytes, handle the four error
conditions above, single
pass into a `BinaryBuilder` preallocated to `len / 4 * 3`, 256-entry
decode table.
2. Wire it in `native/spark-expr/src/comet_scalar_funcs.rs` alongside the
existing `"base64"` arm.
3. Change `CometUnBase64` from `CometCodegenDispatch[UnBase64]` to a
`CometExpressionSerde[UnBase64]`
mixing in `CodegenDispatchFallback`, with `getSupportLevel` returning
`Unsupported(Some(...))` for `failOnError = true` so those cases keep
running on the dispatcher
(see scope below).
4. Add a criterion benchmark `native/spark-expr/benches/unbase64.rs`
covering the shapes required by
`optimizing_expressions.md`: no nulls / sparse nulls / dense nulls, short
/ long values, padded /
unpadded, clean / with skipped characters (CRLF-chunked input), and the
error path.
5. Add Comet SQL Tests at
`spark/src/test/resources/sql-tests/expressions/string_funcs/unbase64.sql`.
### Scope
`failOnError = true` is reachable only from `to_binary(x, 'base64')` and
`try_to_binary`, and it
needs Spark's separate, stricter `UnBase64.isValidBase64` validator (RFC
4648-ish: whitespace
skipped, padding must conclude the string, last group rules). `failOnError`
is a plain boolean field
on the expression node, so it is fully detectable at plan time. Keep those
cases on
`CodegenDispatchFallback` in the first PR and treat native validation as a
follow-up.
### Non-goals
- Native `failOnError = true` (`to_binary` / `try_to_binary`), per above.
- Any collation work. `unbase64` has no collation-dependent behavior.
- Changing `base64` (encode), which is already native.
## Acceptance criteria
- Output is bit-identical to the codegen-dispatch path for every input,
including null placement and
the four `IllegalArgumentException` conditions with matching messages.
- Round-tripping Comet's own `base64` output works for values long enough to
be CRLF-chunked.
- Criterion benchmark committed, covering the shapes listed above, with no
regression on any shape.
- Comet SQL Tests cover column and literal arguments, `NULL`, empty string,
padded and unpadded
input, skipped characters, embedded CRLF, and each error condition.
- The `unbase64` entry in
`docs/source/contributor-guide/expression-audits/string_funcs.md` is
updated when the work lands.
---
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).
The assessment is recorded in
`docs/source/contributor-guide/expression-audits/string_funcs.md` under `##
unbase64`.
--
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]