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
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 doConsumes 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.
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 on81d637b9bagainst a Parquet table with acase class ScratchRec(a: Int, b: String):Only
DeserializeToObjectcarries 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 inCometExecRule.nativeExecs(spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:75), so both take the generic "Xis not supported" path at:507.It cascades past the island.
ds.map(f).groupBy("b").count()loses the aggregate and the exchange too:Why the mixin is not the answer here
DeserializeToObjectExec.outputis exactly one attribute ofObjectType(cls)(Spark'sObjectProducerExec.output), andSerializeFromObjectExec's input is the same.ObjectTypeis absent fromQueryPlanSerde.supportedDataType(QueryPlanSerde.scala:555) andserializeDataTypereturnsNonefor it (:612), and it is outsideCometBatchKernelCodegen.isSupportedDataType(CometBatchKernelCodegen.scala:85) as the EPIC already notes. A JVM object reference cannot live in an ArrowFieldVector. 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 alsoCometExpressionSerde[_](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.
canHandleonly inspects the rootdataType(CometBatchKernelCodegen.scala:120) and everyBoundReferencedataType(:171). Intermediate nodes are never type-checked. So anObjectTypethat 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.doConsumeconstructsso the user closure has a first-class Catalyst representation and we do not have to invent one.
DeserializeToObjectExec.doConsumeisbindReference(deserializer, child.output).genCode(ctx)andSerializeFromObjectExec.doConsumeis the same over each serializer. The fused tree is therefore just: substituteInvoke(func, deserializer)into each serializer'sBoundReference(0)— exactly what whole-stage codegen produces by chaining the threedoConsumes today.That means the shape of the change is a plan rewrite into
ProjectExec, not a new operator serde. Recognize the sandwich, emit aProjectExecwhose expressions are the fused trees, and let the existingCometProjectExecplusCometScalaUDF.emitJvmCodegenDispatchpath take it from there. No proto change, no native change.#5692 landed
Invokeas aCometCodegenDispatch[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 theirObjectTypeboundary makescanHandlereject them — correct for a bareInvoke, and this issue is the follow-up that removes the boundary by fusing.The one design problem worth deciding up front
emitJvmCodegenDispatchemits oneJvmScalarUdfand gets one output vector back.SerializeFromObjecthas N serializers that all share the sameInvoke. 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 withGetStructField. Struct output is already supported —CometBatchKernelCodegenOutput.outputVectorClassmapsStructTypetoStructVector(CometBatchKernelCodegenOutput.scala:180) — and bothCometCreateNamedStructandCometGetStructFieldalready exist (serde/structs.scala:34,:73).Invokeis deterministic in Catalyst (InvokeLikedoes not overridedeterministic, and both children are deterministic), so thedoSubexpressionElimination = truepath ingenerateSource(CometBatchKernelCodegen.scala:261) hoists the sharedInvokeinto a single per-row call.Scope
ds.map(f),ds.map(MapFunction)MapElementsExecds.mapPartitions,ds.flatMapMapPartitionsExec(Iterator[Any] => Iterator[Any])groupByKey(...).mapGroups/cogroupFlatMapGroupsExec,CoGroupExecgroupByKeykey extractionAppendColumnsExecchild.output ++ serializer; a Project with passthrough, worth a follow-up rather than this issueds.filter(func)EliminateSerializationandTypedFiltercollapse to a plainFilterExecwith anInvokeconditionSo the target is
MapElementsExec, i.e.ds.map.ds.mapPartitionswas confirmed in the same run to produce the identical sandwich withMapPartitionsin 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
Invokeon 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 — thegroupBycase 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 aCometColumnarToRowbefore 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 whetherRevertNativeForTransitionHeavyStagesalready 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
doGenCodecompiles, not limits that live at the Arrow output boundary. That is where the Icebergtruncate(w, decimal)regression came from.Encoder serializers for decimals are the case I would check first. A
BigDecimalencoder declaresDecimalType(38, 18), and theStaticInvoke(Decimal.fromDecimal)in the serializer can produce a value wider than the declared precision — Spark nulls it at row materialization, but the ArrowDecimal128(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:
NewInstancefor a nested (non-top-level) case class carries anouterPointerclosure over the enclosing instance, which closure-serialization would drag along —ScalaUDFhas the same hazard, but it is worth a test. And any encoder that touches a type outsideisSupportedDataType(NullType, UDTs, Variant) has to fall back cleanly at plan time rather than fail the Janino compile at execute time, which is what thecanHandlegate is for.Verification note
The plan shapes and fallback reasons above were reproduced on
81d637b9bwith a throwaway suite; the fused-kernel design has not been prototyped. TheDecimalTypeconcern in particular is a hypothesis drawn from the #5575 postmortem, not a reproduced bug.