andygrove opened a new issue, #5710:
URL: https://github.com/apache/datafusion-comet/issues/5710
Part of #5572.
`ds.map(f)` drops a three-operator island into the middle of an
otherwise-native plan, and every operator in it falls back. Reproduced on
`81d637b9b` against a Parquet table with a `case class ScratchRec(a: Int, b:
String)`:
```
*(1) SerializeFromObject [invoke(knownnotnull(assertnotnull(input[0,
ScratchRec, true])).a()) AS a#21, \
static_invoke(UTF8String.fromString(invoke(...).b())) AS b#22]
+- *(1) MapElements <lambda>, obj#18: ScratchRec
+- *(1) DeserializeToObject newInstance(class ScratchRec), obj#15:
ScratchRec
+- *(1) CometColumnarToRow
+- CometProject [a#4, b#5], [_1#2 AS a#4, _2#3 AS b#5]
+- CometNativeScan parquet [_1#2,_2#3]
```
Only `DeserializeToObject` carries a fallback reason, because it is the
lowest node whose children are all native; the two above it just inherit the
island. Neither operator has an entry in `CometExecRule.nativeExecs`
(`spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:75`), so both
take the generic "`X` is not supported" path at `:507`.
It cascades past the island. `ds.map(f).groupBy("b").count()` loses the
aggregate and the exchange too:
```
HashAggregate [COMET: Comet aggregate that merges intermediate buffers
requires a Comet child aggregate ...]
+- AQEShuffleRead
+- Exchange
+- HashAggregate
+- SerializeFromObject
+- MapElements
+- DeserializeToObject [COMET: DeserializeToObject is not
supported]
+- CometColumnarToRow
+- CometProject
+- CometNativeScan parquet
Comet accelerated 2 out of 8 eligible operators (25%).
```
## Why the mixin is not the answer here
`DeserializeToObjectExec.output` is exactly one attribute of
`ObjectType(cls)` (Spark's `ObjectProducerExec.output`), and
`SerializeFromObjectExec`'s input is the same. `ObjectType` is absent from
`QueryPlanSerde.supportedDataType` (`QueryPlanSerde.scala:555`) and
`serializeDataType` returns `None` for it (`:612`), and it is outside
`CometBatchKernelCodegen.isSupportedDataType`
(`CometBatchKernelCodegen.scala:85`) as the EPIC already notes. A JVM object
reference cannot live in an Arrow `FieldVector`. Each of these operators has an
object on one side of it, so neither can be a kernel in isolation — the kernel
contract is Arrow-in, Arrow-out.
`CodegenDispatchFallback`'s self-type is also `CometExpressionSerde[_]`
(`CometExpressionSerde.scala:125`), so there is no operator-level dispatch hook
to mix it into. This is the same structural wall the EPIC records for the
aggregate serdes.
## Why the fused sandwich is
Two things make the fused form legal, and neither needs new native code.
`canHandle` only inspects the **root** `dataType`
(`CometBatchKernelCodegen.scala:120`) and every **`BoundReference`** `dataType`
(`:171`). Intermediate nodes are never type-checked. So an `ObjectType` that
exists strictly *inside* the tree passes the gate — it is only the boundary
that has to be Arrow-representable, and for this sandwich the outer boundary is
ordinary SQL data on both sides.
Spark also already builds the fused expression itself.
`MapElementsExec.doConsume` constructs
```scala
val funcObj = Literal.create(func, ObjectType(funcClass))
val callFunc = Invoke(funcObj, funcName, outputObjectType, child.output,
propagateNull = false)
```
so the user closure has a first-class Catalyst representation and we do not
have to invent one. `DeserializeToObjectExec.doConsume` is
`bindReference(deserializer, child.output).genCode(ctx)` and
`SerializeFromObjectExec.doConsume` is the same over each serializer. The fused
tree is therefore just: substitute `Invoke(func, deserializer)` into each
serializer's `BoundReference(0)` — exactly what whole-stage codegen produces by
chaining the three `doConsume`s today.
That means the shape of the change is a **plan rewrite into `ProjectExec`**,
not a new operator serde. Recognize the sandwich, emit a `ProjectExec` whose
expressions are the fused trees, and let the existing `CometProjectExec` plus
`CometScalaUDF.emitJvmCodegenDispatch` path take it from there. No proto
change, no native change.
#5692 landed `Invoke` as a `CometCodegenDispatch[Invoke]`, which is the
piece that makes the middle of the sandwich dispatchable. Note that #5575
explicitly scoped *out* the encoder and deserializer trees on the grounds that
their `ObjectType` boundary makes `canHandle` reject them — correct for a bare
`Invoke`, and this issue is the follow-up that removes the boundary by fusing.
## The one design problem worth deciding up front
`emitJvmCodegenDispatch` emits one `JvmScalarUdf` and gets one output vector
back. `SerializeFromObject` has N serializers that all share the *same*
`Invoke`. Dispatching them as N separate expressions means N closure
invocations per row: N times the cost, and observably wrong for a closure with
side effects, since Spark's fused loop calls it once.
The fix is to make the kernel's single root a
`CreateNamedStruct(serializer)` and project the N fields off it natively with
`GetStructField`. Struct output is already supported —
`CometBatchKernelCodegenOutput.outputVectorClass` maps `StructType` to
`StructVector` (`CometBatchKernelCodegenOutput.scala:180`) — and both
`CometCreateNamedStruct` and `CometGetStructField` already exist
(`serde/structs.scala:34`, `:73`). `Invoke` is deterministic in Catalyst
(`InvokeLike` does not override `deterministic`, and both children are
deterministic), so the `doSubexpressionElimination = true` path in
`generateSource` (`CometBatchKernelCodegen.scala:261`) hoists the shared
`Invoke` into a single per-row call.
## Scope
| Shape | Middle operator | Fusable |
| --- | --- | --- |
| `ds.map(f)`, `ds.map(MapFunction)` | `MapElementsExec` | yes, per-row |
| `ds.mapPartitions`, `ds.flatMap` | `MapPartitionsExec(Iterator[Any] =>
Iterator[Any])` | no — arbitrary row count, no per-row expression exists |
| `groupByKey(...).mapGroups` / `cogroup` | `FlatMapGroupsExec`,
`CoGroupExec` | no — group-at-a-time |
| `groupByKey` key extraction | `AppendColumnsExec` | per-row but widens the
schema to `child.output ++ serializer`; a Project with passthrough, worth a
follow-up rather than this issue |
| `ds.filter(func)` | none — `EliminateSerialization` and `TypedFilter`
collapse to a plain `FilterExec` with an `Invoke` condition | already covered
by #5692 |
So the target is `MapElementsExec`, i.e. `ds.map`. `ds.mapPartitions` was
confirmed in the same run to produce the identical sandwich with
`MapPartitions` in the middle, and it is not reachable this way.
## Payoff, and where it could regress
The closure does not get faster. It is the same `Invoke` on the same JVM
lambda, once per row, in a Janino-compiled kernel loop instead of a
whole-stage-codegen loop. The win is deleting the island, and it is worth
having only when the typed operation sits *between* native operators — the
`groupBy` case above, or a typed map feeding a join.
When the typed operation is at the top of the plan, which
`ds.map(f).collect()` is, the gain is roughly nil: we would trade a
`CometColumnarToRow` before the map for one after it, and it could come out
slightly behind, since the kernel writes into Arrow only for something to
immediately read rows back out. Worth checking whether
`RevertNativeForTransitionHeavyStages` already covers that shape before
assuming a new heuristic is needed.
## Risk to check before writing code
The lesson from #5575 applies directly: the mixin's contract covers whether
`doGenCode` *compiles*, not limits that live at the **Arrow output boundary**.
That is where the Iceberg `truncate(w, decimal)` regression came from.
Encoder serializers for decimals are the case I would check first. A
`BigDecimal` encoder declares `DecimalType(38, 18)`, and the
`StaticInvoke(Decimal.fromDecimal)` in the serializer can produce a value wider
than the declared precision — Spark nulls it at row materialization, but the
Arrow `Decimal128(p, s)` writer the kernel targets may not. Same failure shape
as the Iceberg bug, so it needs a fixture before this lands.
Two smaller ones: `NewInstance` for a nested (non-top-level) case class
carries an `outerPointer` closure over the enclosing instance, which
closure-serialization would drag along — `ScalaUDF` has the same hazard, but it
is worth a test. And any encoder that touches a type outside
`isSupportedDataType` (`NullType`, UDTs, Variant) has to fall back cleanly at
plan time rather than fail the Janino compile at execute time, which is what
the `canHandle` gate is for.
## Verification note
The plan shapes and fallback reasons above were reproduced on `81d637b9b`
with a throwaway suite; the fused-kernel design has not been prototyped. The
`DecimalType` concern in particular is a hypothesis drawn from the #5575
postmortem, not a reproduced bug.
--
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]