andygrove opened a new pull request, #5744:
URL: https://github.com/apache/datafusion-comet/pull/5744

   ## Which issue does this PR close?
   
   Closes #5386.
   
   ## Rationale for this change
   
   Comet's opt-in Arrow UDF path (`spark.comet.exec.pyarrowUDF.enabled`) 
covered only `mapInArrow` and `mapInPandas`. Scalar Python UDFs fell back 
entirely, so a Comet scan feeding one paid a `ColumnarToRow` transition and 
Spark then re-encoded those rows back into Arrow to reach the Python worker.
   
   One operator, `ArrowEvalPythonExec`, covers three user-facing UDF families, 
because `ExtractPythonUDFs` routes all of them to it:
   
   - a plain `udf()` when `spark.sql.execution.pythonUDF.arrow.enabled=true`
   - `@pandas_udf` (scalar)
   - Spark 4.1's `@arrow_udf`
   
   The first is what makes ordinary PySpark UDF code benefit from Comet, so 
this is the operator that turns "Comet accelerates PyArrow UDFs" into "Comet 
accelerates Python UDFs".
   
   The win is larger than it was for `mapInArrow`, because 
`EvalPythonEvaluatorFactory` is considerably more row-bound than 
`MapInBatchExec` was. Per row the vanilla path does a 
`HybridRowQueue.add(UnsafeRow)` (a full row copy that can spill to disk), a 
`MutableProjection` to materialize the UDF arguments, then on the way back a 
`queue.remove()`, a `JoinedRow`, and an `UnsafeProjection` over the full output 
schema.
   
   ## What changes are included in this PR?
   
   `CometArrowEvalPythonExec` sends the UDF argument columns straight from the 
input batch to a `CometArrowEvalPythonRunner`, and appends the columns the 
worker returns to the input batch's own columns to form the output batch. No 
row is materialized, and the row queue and its spill path are gone.
   
   One design point worth flagging, since it differs from what the issue 
proposed. The issue suggested retaining the input `ColumnarBatch` and appending 
to it. That is not safe: `CometExecIterator.hasNext` closes the previous batch 
before fetching the next one, precisely because "the buffer memory [is] shared 
across batches in the native side", and the Python runner writes from its own 
thread and pipelines ahead. So the input batch is deep-copied on the way in 
(`CometBatchDeepCopy`) — a bulk `memcpy` per Arrow buffer, with dictionary 
columns decoded first. That copy replaces Spark's per-row `UnsafeRow` copy into 
a spillable queue, so it stays well ahead of the unoptimized path, but it is a 
copy rather than the zero-copy the issue envisaged.
   
   Scope of this first version. Each restriction is enforced in the shim's 
matcher, so an unsupported shape falls back to vanilla Spark rather than 
failing:
   
   - **Non-iterator eval types only** (`SQL_ARROW_BATCHED_UDF`, 
`SQL_SCALAR_PANDAS_UDF`, and `SQL_SCALAR_ARROW_UDF` on 4.1+). The iterator 
variants guarantee only equal total row counts, not equal batching, which the 
batch pairing relies on.
   - **Every UDF argument must be a plain attribute of the child.** Spark does 
not project UDF arguments below the operator, so `udf(col("a"))` qualifies but 
`udf(col("a") + 1)` does not. This restriction also excludes chained UDFs 
(`f(g(x))`, folded into one operator whose argument is a `PythonUDF`) and 
keyword arguments (`NamedArgumentExpression`), neither of which is an 
`Attribute`.
   - **Pickled `BatchEvalPythonExec` stays out of scope**, as the issue 
specified: per-row boxing, pickle, and interpreter cost dominate, and the 
unpickled Java-object results would have to be converted into Arrow vectors to 
keep the batch all-Comet.
   
   Comet does not flip `spark.sql.execution.pythonUDF.arrow.enabled` on the 
user's behalf, since that conf changes Spark's own type coercion and error 
semantics rather than just the transport. The user guide now explains what it 
does and why enabling it is a prerequisite for accelerating a plain `udf()`.
   
   Supporting changes:
   
   - `CometArrowPythonRunnerBase` gains a `wrapInputInStruct` hook. Scalar eval 
types exchange one top-level column per argument, matching the flat `_0`, `_1`, 
... schema Spark builds, rather than the single struct `mapInArrow` uses. The 
runner writes the input schema JSON for `SQL_ARROW_BATCHED_UDF` and uses the 
`ArgumentMetadata` form of `writeUDFs`, both of which the worker's protocol 
requires for these eval types.
   - The shared driver-side runner-input resolution moves out of 
`Spark4xMapInBatchSupport` into `ShimPythonRunnerInputs`, so a class can mix in 
both Python operator shims without a conflicting `RunnerInputs`.
   - `EliminateRedundantTransitions` rewrites the operator behind the existing 
feature flag, annotates it with the same `[COMET-INFO]` opt-in hint when the 
flag is off, and honours the same `useLargeVarTypes` fallback. 
`extractColumnarChild` also accepts a `CometArrowEvalPythonExec` child, so 
stacked operators stay columnar end to end.
   - The shared `spark` / `accelerated` pytest fixtures move into `conftest.py` 
so both UDF test modules run against one Spark session.
   
   ## How are these changes tested?
   
   `CometArrowEvalPythonSuite` (11 tests, new) covers the plan rule without 
spinning up Python: the rewrite and its output attributes, transition 
stripping, each supported eval type, and a negative case for every fallback 
condition (iterator eval type, non-attribute argument, chained UDF, 
`useLargeVarTypes`, feature flag off, and the opt-in hint), plus stacked 
operators.
   
   `test_scalar_python_udf.py` (29 tests, new) runs a real Python worker end to 
end, each test in both accelerated and fallback modes: all three UDF families, 
pass-through of a wide mixed-type child, nulls, multiple arguments, a repeated 
argument that must deduplicate to one exchanged column, several UDFs in one 
operator, stacked operators of different eval types (where the outer UDF's 
argument is the column the inner worker produced), empty input, and a 
multi-batch run with `maxRecordsPerBatch=100`. The fallback cases assert the 
operator is left to Spark and still produces correct results.
   
   `CometArrowPythonRunnerSuite` gains a test that the flat path emits the 
columns as top-level fields with no struct field node or validity buffer 
prepended, and allocates nothing of its own.
   
   Locally: all 162 pytest cases across both modules pass against Spark 4.1.3; 
the 28 JVM tests in the three suites pass; `CometExecSuite` + 
`CometSparkSessionExtensionsSuite` (155 tests) pass as a regression check on 
the modified plan rule; and `test-compile` is clean on all five Spark profiles 
(3.4, 3.5, 4.0, 4.1, 4.2). CI runs the pytest modules against a real worker on 
4.0, 4.1, and 4.2, whose runner constructor and command framing differ.
   


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