Skip to content

feat: Add experimental support for accelerated PyArrow UDFs#4234

Merged
andygrove merged 61 commits into
apache:mainfrom
andygrove:pyarrow-udf
Jul 8, 2026
Merged

feat: Add experimental support for accelerated PyArrow UDFs#4234
andygrove merged 61 commits into
apache:mainfrom
andygrove:pyarrow-udf

Conversation

@andygrove

@andygrove andygrove commented May 6, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #957
Closes #4240

Rationale for this change

Spark's mapInArrow / mapInPandas plans insert ColumnarToRow between a Comet scan and the Python operator, and MapInBatchExec then projects the Python output back to rows. Both projections copy data per row even though the upstream Comet plan and the Python runner both speak Arrow. Inside the Python runner, ArrowPythonRunner then re-encodes those rows into Arrow IPC, so the data round-trips Arrow → Row → Arrow on the JVM side before ever reaching Python.

This PR replaces the row-based plumbing with a custom CometArrowPythonRunner that consumes Iterator[ColumnarBatch] directly. The destination VectorSchemaRoot (in Spark's allocator, used for the IPC stream) is populated per batch by a single Unsafe.copyMemory per buffer per column, copying straight from Comet's vectors into the runner's root. No row materialization, no ArrowWriter.write(InternalRow) loop.

Not zero-copy in the strict sense: Comet's Parquet readers each construct their own RootAllocator, separate from ArrowUtils.rootAllocator, so Arrow's TransferPair cannot share buffers across the boundary. Bulk per-buffer memcpy is the next-best alternative and is materially faster than the per-row write loop, especially on wide rows where the row path is dominated by the InternalRow accessor overhead. A future PR that unifies the allocator parent would unlock true zero-copy via TransferPair.

I/O asymmetry: why memcpy on input but not on output

The input path memcpys; the output path does not, and that is structural rather than an oversight.

  • Input (JVM → Python). Two Arrow vector trees already exist in different allocators. The source is Comet's ColumnarBatch, allocated from each Parquet reader's private RootAllocator. The destination is Spark's persistent IPC root, allocated from ArrowUtils.rootAllocator. Because the two roots are unrelated, Arrow's TransferPair / VectorLoader.load cannot rebind buffers across the boundary. CometColumnarPythonInput.copyVector walks the trees in lockstep and setBytes-copies each buffer. This is the bridge between the two allocator trees.
  • Output (Python → JVM). There is no second tree to bridge to. Spark's BasicPythonArrowOutput constructs an ArrowStreamReader against a per-iterator BufferAllocator; loadNextBatch decodes the IPC frames coming back from the Python worker directly into a VectorSchemaRoot allocated from that allocator. Each FieldVector in the root is wrapped in an ArrowColumnVector and emitted as a ColumnarBatch. CometMapInBatchExec consumes those vectors as-is - it just unwraps the single struct child and forwards them. Nothing else in the JVM owns those buffers, so there is no boundary to cross.

In short, the input has two pre-existing trees in different allocators (memcpy required); the output has one tree, decoded straight into the right allocator (no copy possible or needed). The future allocator-unification work that would unlock TransferPair on input does not apply to output - the output is already as good as it can get.

Plan (Spark 4.0)

Baseline (spark.comet.exec.pyarrowUdf.enabled=false):

*(1) Filter (isnotnull(id#6L) AND (id#6L < 100))
+- MapInArrow transform(id#3L, value#4)#5, [id#6L, value#7], false
   +- CometNativeColumnarToRow
      +- CometNativeScan parquet [id#3L,value#4] Batched: true, DataFilters: [],
         Format: CometParquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/...],
         PartitionFilters: [], PushedFilters: [],
         ReadSchema: struct<id:bigint,value:double>

Optimized (spark.comet.exec.pyarrowUdf.enabled=true):

*(1) Filter (isnotnull(id#18L) AND (id#18L < 100))
+- CometMapInBatch transform(id#15L, value#16)#17, [id#18L, value#19], false, 207
   +- CometNativeScan parquet [id#15L,value#16] Batched: true, DataFilters: [],
      Format: CometParquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/...],
      PartitionFilters: [], PushedFilters: [],
      ReadSchema: struct<id:bigint,value:double>

What changes are included in this PR?

  • New input trait CometColumnarPythonInput under spark/src/main/spark-4.x/.../execution/python/. Extends Spark's private[python] PythonArrowInput[Iterator[ColumnarBatch]]; implements writeNextBatchToArrowStream by walking the destination struct's children, allocating each sized to match the corresponding Comet column, collecting buffer addresses, and issuing one bulk copy for the whole tree.
  • New CometArrowPythonRunner per Spark minor (4.0, 4.1, 4.2) under the same package. 4.0 extends BasePythonRunner directly because Spark 4.0's BaseArrowPythonRunner is bound to Iterator[InternalRow]; 4.1/4.2 extend the generic BaseArrowPythonRunner[IN, OUT]. All three mix in CometColumnarPythonInput plus Spark's BasicPythonArrowOutput.
  • New helper CometVectorIpcCopier in comet-common crosses the shading boundary using only long primitives. comet-common shades org.apache.arrow.* into org.apache.comet.shaded.arrow.*; comet-spark references the unshaded Spark Arrow classes. The helper does the byte-level walk over the source vector tree inside common, exposing bufferReadableBytes(): long[], valueCounts(): int[], and copyBuffersToAddresses(addresses: long[]): Unit. No shaded type crosses the API.
  • CometMapInBatchExec loses all row plumbing (rowIterator, BatchIterator, ContextAwareIterator, InternalRow(_) wrap). Feeds Iterator[ColumnarBatch] straight to the runner; on output, unwraps the single struct column into the user's output columns as before. isBarrier is propagated through RDD.barrier() so mapInArrow(..., barrier=True) keeps its gang-scheduling semantics.
  • EliminateRedundantTransitions rewrite unchanged in shape: matches ColumnarToRow + (PythonMapInArrowExec | MapInPandasExec | MapInArrowExec) over a columnar Comet child.
  • Per-Spark-version shim for the differing ArrowPythonRunner constructors and the 4.0+ MapInArrowExec rename. Shared 4.x bits live in spark-4.x/.../Spark4xMapInBatchSupport.scala; per-minor shims provide only the runner factory.
  • Spark 3.4 and 3.5 are no-ops. 3.5's PythonArrowInput trait has a different contract (writeIteratorToArrowStream one-shot vs 4.x's writeNextBatchToArrowStream batch-at-a-time) and a separate implementation has not been written. The matchers in the 3.4 / 3.5 shims return None; vanilla Spark handles the operation. 3.5 support can be added later if there is user demand.
  • New conf spark.comet.exec.pyarrowUdf.enabled, default false while experimental.
  • User guide page documenting usage, plan flow, barrier semantics, the buffer-copy boundary, and limitations.

Limitations

  • The optimization currently applies only to mapInArrow and mapInPandas. Scalar pandas UDFs (@pandas_udf) and grouped operations (applyInPandas) are not yet supported.
  • Spark 4.0 or newer is required. Spark 3.4 and 3.5 are documented no-ops.
  • The optimization fires across a shuffle only with Comet's columnar shuffle. A vanilla Spark Exchange outputs rows and breaks the precondition.
  • Not zero-copy; bulk per-buffer memcpy. Comet's Parquet readers each construct their own RootAllocator, so cross-root TransferPair cannot be used. A future PR that has the readers allocate from ArrowUtils.rootAllocator would unlock zero-copy.

How are these changes tested?

  • CometMapInBatchSuite under spark/src/test/spark-4.x/ covers the JVM-side rule and an end-to-end check: constructs a MapInArrowExec over a stub CometPlan leaf and asserts EliminateRedundantTransitions rewrites it to CometMapInBatchExec; runs a Parquet → mapInArrow query with primitives + nullable varchar and asserts row-equivalence with the un-rewritten output.
  • pytest module test_pyarrow_udf.py covers mapInArrow and mapInPandas end-to-end against a real Python worker: nulls, empty input, Python exception propagation, decimal / date / timestamp / array / struct types, post-shuffle correctness, and barrier=True gang scheduling. 24 / 24 cases pass locally on Spark 4.0.
  • Dedicated pyarrow_udf_test.yml workflow runs the pytest module on every PR against Spark 4.0 (covers the 4.x shim path; paths allowlist scoped to the feature files).

Wall-clock benchmark (benchmark_pyarrow_udf.py, 1M rows, 5 iters, local[2], Spark 4.0.2 / PySpark 4.0.1):

api workload vanilla median optimized median rows/s speedup
mapInArrow narrow primitives 0.268 s 0.140 s 7.5M 1.92x
mapInPandas narrow primitives 0.269 s 0.141 s 7.4M 1.91x
mapInArrow mixed with strings 0.489 s 0.273 s 3.8M 1.79x
mapInPandas mixed with strings 0.504 s 0.283 s 3.7M 1.78x
mapInArrow wide rows (50 cols) 2.542 s 1.795 s 584K 1.42x
mapInPandas wide rows (50 cols) 2.588 s 1.846 s 568K 1.40x

The benchmark UDF is a pure passthrough on local[2], so most of the wall time is Spark's Python fork / IPC overhead. Real UDFs (PyArrow compute, pandas ops, model inference) increase the per-row Python cost and shrink the speedup ratio. The bulk-copy path improves the wide-row case the most because that's where the row path's per-InternalRow overhead is most concentrated.

andygrove added 5 commits May 5, 2026 18:06
When Comet operators produce Arrow columnar data and the next operator
is a Python UDF (mapInArrow/mapInPandas), Spark currently inserts an
unnecessary ColumnarToRow transition. The Python runner then converts
those rows back to Arrow to send to Python, creating a wasteful
Arrow->Row->Arrow round-trip.

This adds CometPythonMapInArrowExec which:
- Accepts columnar input directly from Comet operators
- Uses lightweight batch.rowIterator() instead of UnsafeProjection
- Keeps the Python output as ColumnarBatch (no output row conversion)

The optimization is detected in EliminateRedundantTransitions and
controlled by spark.comet.exec.pythonMapInArrow.enabled (default: true).
Documents the CometPythonMapInArrowExec optimization, including
supported APIs, configuration, usage example, and how to verify
the optimization is active in query plans.
…ions

Fix three issues that prevented test_pyarrow_udf.py from running:

1. mapInArrow callbacks must accept Iterator[pa.RecordBatch] and yield
   batches. The previous single-batch signatures crashed with
   "'map' object has no attribute 'to_pandas'".
2. PySpark DataFrame has no `queryExecution` attribute. Use
   `_jdf.queryExecution().executedPlan().toString()` instead.
3. Replace soft plan-string heuristics with assertions that fail loudly
   if the optimization regresses. Match on `CometPythonMapInArrow` (no
   `Exec` suffix in the plan toString) and assert no `ColumnarToRow`
   transition is present.
- Rewrite test_pyarrow_udf.py as a pytest module. A session-scoped
  SparkSession fixture builds the Comet-enabled session once and a
  parametrized `accelerated` fixture toggles
  spark.comet.exec.pythonMapInArrow.enabled per test, so each case runs
  under both the optimized and fallback paths and asserts the expected
  plan operator (`CometPythonMapInArrow` vs vanilla `PythonMapInArrow`).
  The jar is auto-discovered from spark/target by matching the installed
  pyspark version, or taken from the COMET_JAR env var.
- Add a dedicated `PyArrow UDF Tests` workflow that builds Comet against
  Spark 3.5 / Scala 2.12, installs pyspark/pyarrow/pandas/pytest, and
  runs the new pytest module.
- Add CometPythonMapInArrowSuite to the `exec` suite list in both
  pr_build_linux.yml and pr_build_macos.yml so the JVM-side suite is
  exercised on every PR.
Comment thread .github/workflows/pyarrow_udf_test.yml Fixed
andygrove added 6 commits May 5, 2026 18:46
Replace the narrow paths allowlist with the same paths-ignore list used
by pr_build_linux.yml so the workflow runs on any source change that
could affect Comet's PyArrow UDF execution path, not just the few files
explicitly named.
The PR's `CometPythonMapInArrowExec` and `EliminateRedundantTransitions`
rule directly reference Spark 3.5 APIs that differ across supported
Spark versions: the `ArrowPythonRunner` constructor (4 distinct
signatures across 3.4/3.5/4.0/4.1+/4.2), `arrowUseLargeVarTypes`,
`JobArtifactSet`, `MapInBatchExec.isBarrier`, and the `PythonMapInArrowExec`
type itself (renamed to `MapInArrowExec` in 4.0+). This breaks compile
on every profile other than 3.5.

Introduce a per-version `ShimCometPythonMapInArrow` trait under
`org.apache.spark.sql.comet.shims` (placed in the spark namespace so
it can reach `private[spark]` members) that:

* matches the Spark-version-specific MapInArrow / MapInPandas exec types
  and exposes their `(func, output, child, isBarrier, evalType)` tuple,
* constructs the right `ArrowPythonRunner` for the version,
* hides `arrowUseLargeVarTypes` / `JobArtifactSet` / `getPythonRunnerConfMap`
  behind helper methods.

Spark 3.4 lacks the prerequisite APIs (no `isBarrier`, no `JobArtifactSet`,
no `arrowUseLargeVarTypes`), so its shim returns `None` from the matchers
and the optimization is a no-op there.
The default `amd64/rust` image is Debian 13 (trixie), where the system
`python3` is 3.13 and there is no `python3.11` apt package. The workflow
installed `python3.11` explicitly, which fails on trixie with `Unable to
locate package python3.11`.

Switching to `rust:bookworm` gives a Debian 12 base where `python3` is
3.11, matching the job name and pyspark 3.5.x's supported runtime.
Spark launches Python workers in fresh subprocesses that look up python3
on PATH. Without PYSPARK_PYTHON, workers use the system python (no pyarrow
installed) and UDF execution fails with ModuleNotFoundError. Point both
PYSPARK_PYTHON and PYSPARK_DRIVER_PYTHON at /tmp/venv/bin/python so workers
inherit the same interpreter that pytest uses.
@andygrove andygrove changed the title feat: Add support for accelerated PyArrow UDFs [experimental] feat: Add experimental support for accelerated PyArrow UDFs May 6, 2026
andygrove added 4 commits May 6, 2026 07:44
Flip spark.comet.exec.pythonMapInArrow.enabled default from true to false
and prefix the config doc with "Experimental:" so the default matches the
"[experimental]" label on the feature. Update the user guide to instruct
users to opt in explicitly.
Add coverage for cases that the original pytest module did not exercise:

- mapInPandas (claimed supported, previously zero coverage)
- Null preservation across long and string columns via Arrow passthrough
- Empty input from a CometScan via filter pushdown
- Python exception propagation (sentinel must surface in driver-side error)
- DecimalType(18,6), DateType, TimestampType round-trip with nulls
- ArrayType<Int> and nested StructType, including null arrays/structs and
  arrays containing null elements
- repartition between scan and UDF (correctness only; the optimization
  itself does not fire across a vanilla Exchange and is documented as
  such in the test)

Generalize _assert_plan_matches_mode to take the vanilla node name so the
fallback assertion can match either PythonMapInArrow or MapInPandas.
Expand the user guide with the limitations a user should know before
enabling the experimental optimization:

- The remaining row-to-Arrow round-trip inside the Python runner is
  documented more precisely (the input goes through ColumnarBatch.rowIterator
  to feed ArrowPythonRunner, which re-encodes to Arrow IPC).
- A vanilla Spark Exchange between the Comet scan and the UDF prevents
  the optimization from firing. Users must configure Comet's native
  shuffle manager at session startup to keep the data columnar.
- Spark 3.4 lacks the prerequisite APIs and the feature is a no-op there.
- isBarrier is captured by the operator constructor but not yet
  propagated to the Python runner.

Also explain the AQE display quirk: with AQE on and a shuffle present,
the pre-execution plan shows the unoptimized form because the rule
only sees the materialized subplan after stage execution. Running an
action and re-inspecting explain() reveals the optimized plan.
Standalone Python script that times df.mapInArrow(passthrough).count()
and the equivalent mapInPandas query with the optimization toggled on
and off. Numbers are wall-clock seconds, so they include the Python
worker, Arrow IPC, and downstream count() costs. That is the right
unit for a feature whose user surface is Python: it shows what
fraction of end-to-end time the optimization shaves off, not just the
JVM-side delta in isolation.

Three workloads exercise the dimension where the optimization helps
most:

- narrow primitives (long, int, double)
- mixed with strings (variable-length encoding)
- wide rows (50 columns, projection cost scales with column count)

Local smoke run with 200k rows shows 1.17x to 1.45x speedup across
mapInArrow and mapInPandas, narrow/wide schemas. The script is
configurable via BENCHMARK_ROWS / BENCHMARK_WARMUP / BENCHMARK_ITERS
env vars for users who want longer or shorter runs.
The operator captured isBarrier in its constructor but always called
inputRDD.mapPartitionsInternal, dropping the barrier execution mode
semantics that mapInArrow(..., barrier=True) requests. Stages running
under the optimization lost gang scheduling and the BarrierTaskContext
APIs the UDF expects.

Branch on isBarrier and route through inputRDD.barrier().mapPartitions
in the barrier case, matching what Spark's MapInBatchExec.doExecute
does. Add a pytest case that calls BarrierTaskContext.get() inside the
UDF, which raises if the task is not running in a barrier stage; runs
in both vanilla and optimized modes. Drop the isBarrier limitation
note from the user guide.
@comphead

comphead commented May 6, 2026

Copy link
Copy Markdown
Contributor

I assume not only speed is improved, would also be interesting to check memory metrics

@andygrove andygrove moved this from Todo to In progress in Comet Development May 6, 2026

@parthchandra parthchandra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor comments. Otherwise great PR!

* Optimized flow: CometNativeExec (Arrow) -> CometPythonMapInArrowExec (batch.rowIterator() ->
* Arrow -> Python -> Arrow columnar output)
*
* This eliminates:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 !

child: SparkPlan,
isBarrier: Boolean,
pythonEvalType: Int)
extends UnaryExecNode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other Comet operators extend CometPlan. The idea was that all Comet specific common behavior (say some Comet specific metrics, for example) can be in a single place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Comment thread .github/workflows/pyarrow_udf_test.yml Outdated

jobs:
pyarrow-udf:
name: PyArrow UDF (Spark 3.5, JDK 17, Python 3.11)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Spark 4 now?
I'm assuming that this is enabled for only one version of Spark because it is experimental?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the latest changes, this feature is now only supported for Spark 4.x. If there is user demand, we can create a separate PR to support for 3.x

.booleanConf
.createWithDefault(false)

val COMET_PYTHON_MAP_IN_ARROW_ENABLED: ConfigEntry[Boolean] =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAP is confusing IMO, so many things related to map in Spark

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe COMET_PYARROW_SUPPORT or something like that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, updated docs to clarify.

andygrove added 4 commits May 21, 2026 09:17
- Move the 3.4 / 3.5 ShimCometMapInBatch stubs into a single spark-3.x shim
  (they were byte-identical). The matchers still return None on both versions
  so the rule is a no-op on Spark 3.x.

- Replace the eligibleMapInBatchInfo guard + .get unpack with an
  EligibleMapInBatch extractor that runs the matchers and conf reads once
  per visited plan.

- Add arrowUseLargeVarTypes(conf) to ShimSQLConf so the rule no longer reads
  the conf stringly. 4.x and 3.5 forward to the typed accessor; 3.4 falls
  back to getConfString because that version has no accessor.

- Hoist the per-batch VectorUnloader in CometColumnarPythonInput to a lazy
  val. getRecordBatch reads root.getFieldVectors on every call so reuse is
  safe; this drops one allocation per batch.

- Clarify the comment on cometCodec: 4.0.x has no
  SQLConf.arrowCompressionCodec accessor (added after 4.0 branch was cut),
  so a typed ShimSQLConf forwarder would still need a stringly fallback for
  the 4.0 build. The 4.1+ codec instances live in the separate
  arrow-compression artifact, which Comet does not depend on; the
  CompressionCodec.Factory path keeps that dependency contained.

Addresses mbutrovich's items 5, 6, 8, 10 on apache#4234.
The PR description, CometColumnarPythonInput header, and pyarrow-udfs.md all
blamed the per-buffer copy on 'Comet's Parquet readers each constructing
their own RootAllocator'. The repo only has one process-wide RootAllocator
(CometArrowAllocator), and native scan does Parquet reading on the Rust
side: arrow buffers cross the boundary via Arrow C Data Interface, not a
JVM allocator.

The actual blocker on TransferPair is that imported buffers carry a
ReferenceManager whose release routes through FFI, while Spark's
destination IPC root is a child of ArrowUtils.rootAllocator. The two
reference managers cannot share buffers.

Reframe the per-batch work as 'two copies, one structural':
- copy 1 (Comet -> destination IPC root) is droppable, tracked in apache#4294
- copy 2 (root -> pipe via VectorUnloader / MessageSerializer) is the
  structural floor; Spark's transport to Python is fork + pipe + Arrow
  IPC, so the bytes must reach the pipe at least once

Addresses mbutrovich's items 1 and 3 (framing) on apache#4234. The PR
description update is a separate step.
Hand-written cases that pin the boundaries mbutrovich called out as gaps:

- decimal precision sweep (1, 9, 17, 18, 19, 28, 38; scale 0/half/max)
  covering the short-decimal (long-backed) and long-decimal (16-byte
  FixedSizeBinary) paths and the 18/19 boundary
- null density sweep (0, 0.01, 0.5, 0.99, 1.0) for validity-buffer memcpy
- multi-batch per partition (batch size 16, 4000 rows in 1 partition) so
  the persistent destination IPC root is exercised across many batches
- wide schema (50 cols, mixed primitives + strings + booleans) for the
  flattened-tree address arithmetic
- mid-stream zero-row batch so setValueCount(0) + validity sizing is
  hit while the iterator continues
- transforming array UDF (reverse each list) to catch symmetric
  encode/decode mistakes that a passthrough would invert

A randomised fuzz harness (analogous to CometCodegenFuzzSuite) is the
right next step for the recursive vector-tree walk; deferred to a
separate follow-on.
CometSparkSessionExtensions.isCometLoaded short-circuits the whole
extension (returning false; no rules registered) when
spark.comet.exec.shuffle.enabled is true but spark.shuffle.manager is
not Comet's manager. The pytest conftest only sets the basic Comet
configs, so this guard fired and CometScanRule never ran. The plan
stayed vanilla Parquet, the rewrite chain never had a Comet columnar
producer to match, and every [accelerated] assertion that checks for
CometMapInBatch failed.

These tests do not exercise shuffle, so disable Comet shuffle in the
session. Comet's scan and exec rules then run normally and the rewrite
fires.

Diagnoses the wholesale PyArrow UDF Spark 4.0 CI failure on apache#4234.
@andygrove

Copy link
Copy Markdown
Member Author

Item 7 (CometArrowPythonRunner dedupe across 4.1/4.2). Took a closer look. The shared surface ends up smaller than the diff suggests because Spark's BaseArrowPythonRunner itself changed shape between minors:

  • 4.1 constructor: (funcs, evalType, argOffsets, schema, timeZoneId, largeVarTypes, workerConf, pythonMetrics, jobArtifactUUID, sessionUUID)
  • 4.2 constructor: (funcs, evalType, argOffsets, schema, timeZoneId, largeVarTypes, pythonMetrics, jobArtifactUUID, sessionUUID)workerConf dropped, overridden via runnerConf instead

A shared CometArrowPythonRunnerBase would have to live in spark-4.x/ to be visible to both minors, but BaseArrowPythonRunner isn't on the classpath there at a single signature, so the base would still need per-minor constructors and super(...) calls. Net saving: the two trait mix-ins (CometColumnarPythonInput + BasicPythonArrowOutput) factor out, which is roughly 10 lines for one more abstraction layer. I'd rather keep the per-minor pattern that mirrors Spark4xMapInBatchSupport already in the repo. Open to revisiting if 4.3 lands and the two minors converge on a stable signature.

Item 11 (fuzz harness). Borrowing the CometCodegenFuzzSuite shape into test_pyarrow_udf.py is the right next step but I'd like to do it in a follow-up so this PR doesn't grow further. Filed as #4384.

For this round I've added the targeted gaps in commit a520321:

  • Decimal precision sweep {1, 9, 17, 18, 19, 28, 38} × scale {0, half, max} — covers the long-backed / FixedSizeBinary boundary at 18/19
  • Null density sweep {0, 0.01, 0.5, 0.99, 1.0} for validity-buffer memcpy
  • Multi-batch per partition (4000 rows, maxRecordsPerBatch=16) so the persistent IPC root is exercised across ~250 batches
  • 50-column mixed schema for the flattened-tree address walk
  • Mid-stream zero-row batch (validity sizing + setValueCount(0))
  • Transforming array UDF (reverse each list) — catches symmetric encode/decode mistakes a passthrough would invert

Also filed #4383 for item 3 (drop the per-batch Comet→Spark buffer copy via direct ArrowRecordBatch assembly). That one supersedes the bulk-copy framing #4294 was built on; the doc rewrite in b69da2e explains why.

andygrove added 10 commits June 22, 2026 16:31
# Conflicts:
#	docs/source/user-guide/latest/index.rst
Drop the removed useDecimal128 argument from the CometVector.getVector
call in CometMapInBatchExec, which no longer compiles after main removed
that parameter. Add braces to the EligibleMapInBatch if/else to satisfy
scalastyle, remove a redundant string interpolator flagged by scalafix,
and apply spotless formatting.
The workflow compiles Comet against the spark-4.0 profile (Spark 4.0.2)
but ran the pytest against pyspark==4.0.1. The PythonArrowInput trait's
private-field mixin is not binary-compatible across that gap, so
constructing CometArrowPythonRunner failed with AbstractMethodError on
the synthesized arrowSchema setter. Pin pyspark to 4.0.2 to match.
The runner extended Spark's PythonArrowInput / BasicPythonArrowOutput
traits, whose members expose Spark's (unshaded) Arrow types. The
packaged comet-spark jar relocates org.apache.arrow to
org.apache.comet.shaded.arrow, so the synthetic Arrow members on the
generated runner no longer matched Spark's unshaded trait contract,
raising AbstractMethodError at runtime (the output path had the same
latent break, wrapping Spark's unshaded ArrowColumnVector into a shaded
CometVector). It only surfaced in the packaged jar, not in tests run
from classes, which is why CI failed while local runs passed.

Shading must stay (Comet bundles a different Arrow version than Spark),
so instead extend only the Arrow-agnostic BasePythonRunner and perform
the Arrow IPC exchange directly with Comet's shaded Arrow. The Python
worker only ever sees a standard Arrow IPC byte stream, so nothing
crosses the shaded/unshaded boundary: input copies each Comet batch into
a shaded struct root written with a shaded ArrowStreamWriter; output
reads the worker's IPC with a shaded ArrowStreamReader straight into
CometVectors, which is what CometMapInBatchExec and downstream native
operators already consume.

BasePythonRunner has the same shape across Spark 4.0/4.1/4.2, so the IPC
logic lives in one shared CometArrowPythonRunnerBase and the per-version
runners are thin subclasses. Removes the now-unused
CometColumnarPythonInput.
The runner built the destination Arrow struct's child fields straight from
Comet's vectors (`getValueVector.getField`). Comet's FFI-imported vectors carry
Arrow `Field`s with null names (Comet uses positional schema), so shaded Arrow's
`AbstractStructVector.put` rejected them with `NullPointerException: field name
cannot be null`, failing every accelerated mapInArrow/mapInPandas query.

Wire the already-available input `schema` into the shared base trait and rename
each top-level field from it, recursively substituting a placeholder for any
null nested name. Field types and child structure are preserved so `copyVector`
still walks the source and destination trees in lockstep. This also fixes a
latent correctness gap: the Python worker reads columns by name, so the IPC
schema must carry the real names rather than anonymous fields.
The decimal precision sweep negated the maximum value with unary minus
(`-largest`). Python's `decimal` applies the default 28-digit context to that
operation, rounding the 38-digit maximum up to 1E38 and overflowing
Decimal(38, 0) when writing the source parquet, before any UDF runs. Use
`copy_negate()`, which flips the sign without applying the context.
reverse_arrays rebuilt the batch with pa.RecordBatch.from_pandas(pdf), which
infers list<int64> from the Python-int lists and mismatches the declared
array<int> (list<int32>) output. Spark's int32 projection over the result then
called getInt on a long-backed ArrowColumnVector accessor and threw
UnsupportedOperationException, failing the test in both modes (vanilla Spark's
own output handling rejects the type-mismatched UDF result). Pass
schema=batch.schema so the output keeps the int32 element type.
… in PyArrow UDFs

Fix the PyArrow UDF acceleration test failures:

- Read large_string / large_binary output from Python workers. PyArrow (with
  pandas 3) emits 64-bit offset variants and mapInArrow passes them through
  untouched. Map LargeUtf8/LargeBinary in Utils.fromArrowType and read 64-bit
  offsets in CometPlainVector.
- Emit a valid empty Arrow IPC stream when an upstream operator produces no
  input batches, instead of writing nothing and tripping the worker's
  ArrowStreamReader.
- Send input fields to the worker as nullable so columns containing nulls are
  accepted, while keeping Map entries non-nullable as Arrow requires, and fill
  an all-valid validity buffer when the source has no nulls.
- Rewrite chained mapInArrow operators: the outer operator now consumes the
  inner CometMapInBatchExec's columnar output directly.

Set spark.comet.scan.unsignedSmallIntSafetyCheck=false in the numeric scalar
test so the ShortType column does not force the scan to fall back, and update
the chained JVM test to expect both operators to be rewritten.
@mbutrovich
mbutrovich self-requested a review June 30, 2026 19:08

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for continuing to push on this, @andygrove. The latest revision resolves the correctness concern from last round. The output now wraps each vector via CometVector.getVector so the operator emits real CometVectors, extractColumnarChild handles a stacked CometMapInBatchExec directly, and the three downstream shapes I was worried about (chained UDFs, filter on UDF output, aggregate on UDF output) are all pinned by tests. The allocator framing rewrite and the removal of the per-batch VectorUnloader and the cometCodec reimplementation are real improvements, and the decimal, null-density, multi-batch, wide-schema, and nested-type coverage is strong. CI is green across every 4.x matrix plus the dedicated PyArrow workflow.

I left a set of inline comments I would like addressed before merge. None of them changes the approach.

* copied. Both source and destination are Comet's (shaded) Arrow vectors, so no shaded /
* unshaded type crosses.
*/
private def copyVector(src: FieldVector, dst: FieldVector): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyVector calls dst.allocateNew(...) on every child for every batch. In Arrow Java BaseFixedWidthVector.allocateNew(int) calls clear() first, which releases both buffers before reallocating (BaseFixedWidthVector.java:292 -> :296 -> releaseBuffer at :240-241), and BaseVariableWidthVector does the same. So the only thing actually persistent across batches is the StructVector container. Every leaf buffer is freed and re-malloc'd per batch. On a 50-column batch that is ~100 free/malloc pairs per batch.

Can we make the root genuinely persistent? Allocate once on the first batch, then for subsequent batches use reset() (BaseFixedWidthVector.java:225, "doesn't release any memory") plus a capacity check, reallocating only when the incoming batch does not fit. That is the setValueCount(0) + grow-on-demand path, and it removes the per-batch churn that the design is otherwise paying for. While you are in here, the test_map_in_arrow_multi_batch_per_partition docstring (test_pyarrow_udf.py, approx lines 437-442) says it exercises "the persistent destination IPC root over multiple batches," which is not true today since the child buffers reallocate. Once the reuse lands the docstring becomes accurate. If for some reason the reuse cannot work, the docstring needs correcting either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred the buffer reuse to #4383, which removes the copyVector bulk-copy path entirely (the per-batch allocateNew goes away with it), so a reset() + grow-on-demand rework here would be throwaway against code that is being deleted. I did correct the test_map_in_arrow_multi_batch_per_partition docstring in 5802e7c: it no longer claims leaf-buffer persistence (only the struct container is reused today) and now points at #4383.

}.toArray
val batch = new ColumnarBatch(vectors)
batch.setNumRows(root.getRowCount)
pythonMetrics("pythonNumRowsReceived") += root.getRowCount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read path increments pythonNumRowsReceived but nothing increments pythonDataReceived, while the writer does increment pythonDataSent. Stock BasicPythonArrowOutput populates pythonDataReceived as it reads frames. As written the accelerated path reports 0 bytes received, so the metric silently diverges from the fallback path depending on whether the rewrite fired. Please track the bytes read here so the metric matches the fallback. The metrics map in CometMapInBatchExec is where it surfaces to users.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5802e7c. The reader now meters reader.bytesRead() around loadNextBatch() into pythonDataReceived, matching BasicPythonArrowOutput.

(38, 38),
],
)
def test_map_in_arrow_decimal_precision_sweep(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says BaseFixedWidthVector handles precision <= 18 as "long-backed (8 bytes)" and precision >= 19 as "16-byte FixedSizeBinary." The Arrow DecimalVector that copyVector touches is always 16 bytes wide regardless of precision. The 8-byte long-backed form is Spark's UnsafeRow encoding, a layer this Arrow copy never sees. The sweep is good coverage. Please fix the rationale so it does not point at a buffer-width boundary that does not exist on the Arrow path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Rewrote the rationale: the Arrow DecimalVector on this path is always 16 bytes wide, and the 8-byte long-backed form is Spark's UnsafeRow encoding the Arrow copy never sees. The docstring now says the sweep guards the precision/scale extremes and the 18/19 point where Spark changes its own representation, rather than an Arrow buffer-width boundary.

childFields.asJava)
structVec = structField.createVector(allocator).asInstanceOf[StructVector]
writeRoot = new VectorSchemaRoot(Seq[FieldVector](structVec).asJava)
arrowWriter = new ArrowStreamWriter(writeRoot, null, Channels.newChannel(dataOut))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The writer constructs ArrowStreamWriter(writeRoot, null, channel) with no compression codec, so spark.sql.execution.arrow.compression.codec is ignored on this path. The fallback path honors it. The Python ArrowStreamReader auto-detects compression from the IPC message metadata, so an uncompressed stream still reads correctly, but a user who sets the codec gets it silently dropped when the rewrite fires. Please resolve the codec from SQLConf and pass it through the VectorUnloader the writer uses, matching the fallback behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to #4383. One correctness note on the "fallback honors it" framing: on Spark 4.0 the fallback does not compress input to Python either. PythonArrowInput there builds new ArrowStreamWriter(root, null, dataOut) and writes via arrowWriter.writeBatch(), with no VectorUnloader/codec. The codec-on-input path only exists on 4.1+, where it is applied through VectorUnloader.getRecordBatch() + MessageSerializer. #4383 moves this operator onto that same unloader assembly, which is exactly where the codec drops in and where per-version behavior can be matched cleanly. Applying it in the current copyVector/ArrowStreamWriter writer would be throwaway against that change.

if (!CometConf.COMET_PYARROW_UDF_ENABLED.get()) {
None
} else if (arrowUseLargeVarTypes(plan.conf)) {
None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule skips the rewrite entirely when useLargeVarTypes is on, because copyVector does a raw setBytes that cannot bridge Comet's 4-byte offsets to the destination's 8-byte offsets. This PR already taught CometPlainVector to read both offset widths, so the boundary is half-solved: the read side handles both, the write side gates the whole optimization off. Rather than gate, can the var-width arm of copyVector widen the offsets when the destination is a BaseLargeVariableWidthVector and the source is a BaseVariableWidthVector (read each 4-byte offset, write the 8-byte equivalent, copy the data buffer as-is)? That lets the rewrite always fire and removes a planner special case that would otherwise become dead code the moment the copy understands the wider offset.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to #4383. Agreed the useLargeVarTypes gate becomes dead code once the copy understands 8-byte offsets, but the offset widening belongs in the reworked copy path (#4383 assembles the ArrowRecordBatch directly and is where the offset handling gets re-answered) rather than in the copyVector version that is being removed. The current gate is a safe fallback to vanilla Spark, not a correctness gap, so I left it in place for this PR.

srcBufs.size == dstBufs.size,
s"buffer count mismatch for ${dst.getField}: src=${srcBufs.size}, dst=${dstBufs.size}")
var b = 0
while (b < srcBufs.size) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manual while (b < srcBufs.size) reads more cleanly as srcBufs.asScala.zip(dstBufs.asScala).foreach { case (s, d) => d.setBytes(0, s, 0, s.readableBytes) }, with the buffer-count require moved to a size check beforehand. Drops a nesting level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, switched to srcBufs.asScala.zip(dstBufs.asScala).foreach { case (s, d) => d.setBytes(0, s, 0, s.readableBytes) } with the buffer-count require kept ahead of it.

// on an absent stream ("Invalid IPC stream: negative continuation token"). There is
// no sample batch, so derive the schema from the Spark input schema. The timezone is
// irrelevant here because no rows are exchanged.
val inner = schema.head.dataType.asInstanceOf[StructType]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema.head.dataType.asInstanceOf[StructType] is cast in two mutually exclusive arms. Hoist it to a single lazy val on the Writer so the cast lives in one place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Hoisted to a single lazy val inputStructType on the Writer; both the empty-stream and first-batch arms use it.

children.asScala.zipWithIndex.map { case (child, idx) =>
renamed(
child,
if (child.getName == null) s"_$idx" else child.getName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A null-named FFI child gets the positional placeholder _$idx. A real sibling literally named _0 would collide. It only applies to positional FFI fields with null names so it is unlikely, but a one-line comment noting the placeholder assumes no real field uses the _N form would save the next reader the head-scratch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment noting the positional _$idx placeholder is only applied to null-named FFI children and assumes no real sibling uses the _N form.

Track pythonDataReceived bytes in the reader so the metric matches the
vanilla BasicPythonArrowOutput fallback. Hoist the repeated input struct
cast to a single lazy val, replace the manual buffer-copy loop with
zip/foreach, and document the positional field-name placeholder.

Correct two test docstrings: the Arrow DecimalVector copy is always
16 bytes wide (the 8-byte long-backed form is Spark's UnsafeRow encoding,
not the Arrow path), and the multi-batch test reuses only the struct
container, not the leaf buffers (see apache#4383).

The persistent-root reuse, input compression codec, and large-var-type
offset widening are deferred to apache#4383, which removes the copyVector
bulk-copy path entirely.
@andygrove
andygrove requested a review from mbutrovich July 1, 2026 22:47

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more pass, thanks for iterating on this, @andygrove!

Comment on lines +178 to +182
val childNames = inputStructType.fieldNames
val childFields = (0 until cometBatch.numCols()).map { i =>
val vecField =
cometBatch.column(i).asInstanceOf[CometDecodedVector].getValueVector.getField
renamed(vecField, childNames(i), forceNullable = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input Arrow schema is built from the source vector's Field (getValueVector.getField), and the empty-input branch hardcodes "UTC". Vanilla Spark builds the schema it sends to Python with ArrowUtils.toArrowSchema(schema, timeZoneId), so a TimestampType reaches the worker as Timestamp(MICROSECOND, sessionLocalTimeZone). Comet normalizes timestamps to UTC internally, so under a non-UTC spark.sql.session.timeZone the worker would see a UTC label where vanilla Spark presents the session zone.

A passthrough UDF round-trips fine, which is why the current tests pass, but a tz-sensitive mapInPandas UDF or a mapInArrow UDF that reads field.type.tz could diverge from vanilla. test_map_in_arrow_date_and_timestamp only runs under the default session timezone. Could we add a non-UTC spark.sql.session.timeZone case with a tz-aware UDF to confirm parity, or document it as a known limitation?

Comment on lines +39 to +40
timeZoneId: String,
largeVarTypes: Boolean,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three per-version runners take timeZoneId: String and largeVarTypes: Boolean, but neither is referenced. The base builds its schema from the source fields, and the rule already falls back to vanilla when useLargeVarTypes is on. Since these are brand-new classes, dropping the dead params (and the corresponding RunnerInputs fields) would keep the constructors honest. This is also the tell for comment #1: sessionLocalTimeZone is resolved on the driver and threaded all the way here, then ignored.

Comment thread .github/workflows/pyarrow_udf_test.yml Outdated

jobs:
pyarrow-udf:
name: PyArrow UDF (Spark 4.0, JDK 17, Python 3.11)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4.1 and 4.2 runners differ from 4.0 in the config param handling (workerConf as override val vs pythonRunnerConf + override def) and in writeUDF arity, and those files landed under [skip ci]. The pytest workflow only runs a real Python worker against 4.0, so a wiring divergence in the 4.1/4.2 runners would still pass CI. A single 4.1 or 4.2 smoke run would close that gap. Not a blocker for an experimental drop if out of scope.

Drop the unused timeZoneId and largeVarTypes parameters from the three
4.x CometArrowPythonRunner constructors and from RunnerInputs. The base
builds the Python-facing schema from Comet's own vectors and never reads
either value, and the useLargeVarTypes gate lives in the planner rule.

Document the timestamp time zone behavior as a known limitation: the
accelerated path labels timestamps UTC (Comet normalizes internally)
where vanilla Spark uses the session zone. Values round-trip identically;
only tz-aware UDFs can diverge.

Run the pytest smoke suite against Spark 4.1 as well as 4.0 so the 4.1
runner, which differs from 4.0 in constructor shape and writeUDF arity,
gets real Python-worker coverage.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI! Thanks for the initial PR and all the subsequent work on this, @andygrove !

@andygrove
andygrove merged commit 6ca6529 into apache:main Jul 8, 2026
71 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in Comet Development Jul 8, 2026
@andygrove

Copy link
Copy Markdown
Member Author

Merged. Thanks @mbutrovich!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Eliminate remaining row<->Arrow round-trip in CometPythonMapInArrowExec Is it possible to support PyArrow backed UDFs in Comet natively?

6 participants