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

   ### What is the problem the feature request solves?
   
   Comet produces Arrow. Arrow-based clients consume Arrow. Between the two, 
Spark inserts a full round-trip through `UnsafeRow`:
   
   ```
   Comet Arrow batch -> C2R -> UnsafeRow -> ArrowConverters -> Arrow IPC -> 
client
   ```
   
   Two conversions where the ideal number is zero.
   
   The cause is that the Arrow collect paths take the row RDD unconditionally, 
with no `supportsColumnar` check. In `Dataset.toArrowBatchRdd`:
   
   ```scala
   private[sql] def toArrowBatchRdd(plan: SparkPlan): RDD[Array[Byte]] = {
     ...
     plan.execute().mapPartitionsInternal { iter =>
       val context = TaskContext.get()
       ArrowConverters.toBatchIterator(iter, schemaCaptured, 
maxRecordsPerBatch, timeZoneId, ...)
     }
   }
   ```
   
   (`sql/core/.../classic/Dataset.scala:2343` on 4.1.3, same shape at 
`Dataset.scala:4256` on 3.4.3.) `collectAsArrowToPython` and `toPythonIterator` 
both go through it, so this covers `toPandas()`, `toArrow()`, and `collect()` 
when `spark.sql.execution.arrow.pyspark.enabled` is set.
   
   Spark Connect has the same problem via a *different* code path — it does not 
use `toArrowBatchRdd` at all:
   
   ```scala
   val rows = dataframe.queryExecution.executedPlan.execute()
   ...
   val batches = rows.mapPartitionsInternal(
     SparkConnectStreamHandler.rowToArrowConverter(schema, maxRecordsPerBatch, 
maxBatchSize, timeZoneId))
   ```
   
   (`SparkConnectStreamHandler.scala:113`, verified on 3.4.3 — the handler was 
reorganised in 4.x and I have not checked whether the shape survived, which is 
worth confirming.) Connect always transfers Arrow, so unlike the PySpark case 
there is no conf to turn this off.
   
   I grepped the Comet tree and we intercept none of this today: no handling of 
`toArrowBatchRdd`, `ArrowConverters`, or `collectAsArrowToPython` anywhere. So 
a Comet user on PySpark-with-Arrow or on Connect pays for the C2R and then pays 
again to rebuild what Comet already had in the right format.
   
   I think this is worth separating from the existing C2R work. #5119, #5112, 
and #4440 are all about making the conversion itself cheaper. This is a case 
where the conversion's output is discarded and re-encoded immediately, so the 
available win is deleting both halves rather than shaving one.
   
   We already know the trick — `EliminateRedundantTransitions` strips exactly 
this round-trip for the UDF path, rewriting `MapInArrow`/`MapInPandas` over a 
Comet C2R into `CometMapInBatchExec` 
(`EliminateRedundantTransitions.scala:124`). The collect path is the same 
shape, minus a seam to hook.
   
   ### Describe the potential solution
   
   The clean fix is upstream: make the Arrow collect paths columnar-aware.
   
   ```scala
   if (plan.supportsColumnar) {
     plan.executeColumnar().mapPartitionsInternal { /* serialize batches 
straight to Arrow IPC */ }
   } else {
     plan.execute().mapPartitionsInternal { 
ArrowConverters.toBatchIterator(...) }
   }
   ```
   
   This needs no Comet-specific code in Spark. Any plugin whose 
`executeColumnar` output matches `ArrowUtils.toArrowSchema` can take the fast 
path, so Gluten and the RAPIDS plugin benefit identically — which should help 
the case upstream.
   
   Comet cannot do this on its own. `toArrowBatchRdd` is `private[sql]` on 
`Dataset`, the Connect handler is internal to the Connect module, and 
`SparkSessionExtensions` reaches planner and columnar rules but not either of 
these.
   
   Since Connect and `toArrowBatchRdd` are separate paths, there is a design 
question about whether to patch both call sites or introduce something more 
general, e.g. an optional `executeArrow` on `SparkPlan` that any Arrow consumer 
can ask for and that falls back to the row path when unimplemented. The second 
is more work to land but stops this from recurring at the next Arrow consumer.
   
   Interim options, if we want anything before an upstream change:
   
   1. A Comet-side entry point (`CometDataset.toArrowBatches(df)` or similar) 
that users call explicitly. Cheap, but only helps people who rewrite their 
code, and does nothing for Connect.
   2. Nothing, and treat the upstream change as the deliverable.
   
   I lean towards (2), with the measurement below done first, but I would like 
other opinions.
   
   ### Additional context
   
   Things that need resolving before this is more than an idea.
   
   **Schema equivalence.** Comet's `Utils.toArrowType` 
(`spark/src/main/scala/org/apache/spark/sql/comet/util/Utils.scala:139`) is 
field-for-field identical to Spark's `ArrowUtils.toArrowType` for every type it 
supports, with one exception: Comet has no `largeVarTypes` variant and always 
emits `Utf8`/`Binary` with 4-byte offsets. Under 
`spark.sql.execution.arrow.useLargeVarTypes=true` a client would get 
`string`/`binary` where it asked for `large_string`/`large_binary`. That is the 
same conflict we already resolve by falling back for mapInArrow 
(`EliminateRedundantTransitions.scala:113-118`), so the same fallback applies.
   
   **Batch framing and compression.** `Utils.serializeBatches` 
(`util/Utils.scala:242`) writes one complete Arrow stream per batch, compressed 
with Spark's codec. That is what the broadcast path wants, but clients expect 
`ArrowBatchStreamWriter` framing: schema once, then N uncompressed record 
batches. This needs a separate serializer rather than a reuse of 
`serializeBatches`.
   
   **Dictionary-encoded vectors.** Comet can emit `CometDictionaryVector`. 
pyarrow handles dictionary arrays, but they surface in pandas as categoricals, 
which is a visible behavior change from today. Either decode before 
serializing, or decide the change is acceptable and document it.
   
   **`maxRecordsPerBatch`.** Comet batches are sized by `spark.comet.batchSize` 
(8192 by default) while `spark.sql.execution.arrow.maxRecordsPerBatch` defaults 
to 10000. Do we honor the Arrow conf by re-batching, or document that Comet's 
batch size wins on this path? Connect additionally caps by estimated byte size 
(`CONNECT_GRPC_ARROW_MAX_BATCH_SIZE`), which a columnar path would have to 
respect.
   
   **`errorOnDuplicatedFieldNames`.** Spark threads this into schema 
construction for the pandas struct handling mode. Comet's `toArrowField` has no 
equivalent.
   
   Open questions:
   
   1. Does anyone have a measurement of what this actually costs on a realistic 
`toPandas()` or Connect workload? I have not benchmarked it — the argument so 
far is structural, from reading the call paths. Worth confirming the win is 
real, and how large, before proposing anything upstream.
   2. Is there a seam I have missed that would let Comet intercept either path 
without a Spark change?
   3. If we go upstream, narrow patches at the two call sites, or a general 
`executeArrow` on `SparkPlan`?
   4. `spark.sql.execution.arrow.pyspark.enabled` defaults to false, so the 
PySpark half only affects users who opted in. Does anyone have a sense of how 
common that is among Comet users, relative to Connect?
   


-- 
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