feat: Add experimental support for accelerated PyArrow UDFs#4234
Conversation
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.
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.
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.
|
I assume not only speed is improved, would also be interesting to check memory metrics |
parthchandra
left a comment
There was a problem hiding this comment.
Some minor comments. Otherwise great PR!
| * Optimized flow: CometNativeExec (Arrow) -> CometPythonMapInArrowExec (batch.rowIterator() -> | ||
| * Arrow -> Python -> Arrow columnar output) | ||
| * | ||
| * This eliminates: |
| child: SparkPlan, | ||
| isBarrier: Boolean, | ||
| pythonEvalType: Int) | ||
| extends UnaryExecNode |
There was a problem hiding this comment.
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.
|
|
||
| jobs: | ||
| pyarrow-udf: | ||
| name: PyArrow UDF (Spark 3.5, JDK 17, Python 3.11) |
There was a problem hiding this comment.
Should this be Spark 4 now?
I'm assuming that this is enabled for only one version of Spark because it is experimental?
There was a problem hiding this comment.
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] = |
There was a problem hiding this comment.
MAP is confusing IMO, so many things related to map in Spark
There was a problem hiding this comment.
maybe COMET_PYARROW_SUPPORT or something like that?
There was a problem hiding this comment.
Thanks, updated docs to clarify.
- 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.
|
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
A shared Item 11 (fuzz harness). Borrowing the For this round I've added the targeted gaps in commit a520321:
Also filed #4383 for item 3 (drop the per-batch Comet→Spark buffer copy via direct |
# 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
left a comment
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
mbutrovich
left a comment
There was a problem hiding this comment.
One more pass, thanks for iterating on this, @andygrove!
| 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) |
There was a problem hiding this comment.
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?
| timeZoneId: String, | ||
| largeVarTypes: Boolean, |
There was a problem hiding this comment.
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.
|
|
||
| jobs: | ||
| pyarrow-udf: | ||
| name: PyArrow UDF (Spark 4.0, JDK 17, Python 3.11) |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Approved pending CI! Thanks for the initial PR and all the subsequent work on this, @andygrove !
|
Merged. Thanks @mbutrovich! |
Which issue does this PR close?
Closes #957
Closes #4240
Rationale for this change
Spark's
mapInArrow/mapInPandasplans insertColumnarToRowbetween a Comet scan and the Python operator, andMapInBatchExecthen 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,ArrowPythonRunnerthen 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
CometArrowPythonRunnerthat consumesIterator[ColumnarBatch]directly. The destinationVectorSchemaRoot(in Spark's allocator, used for the IPC stream) is populated per batch by a singleUnsafe.copyMemoryper buffer per column, copying straight from Comet's vectors into the runner's root. No row materialization, noArrowWriter.write(InternalRow)loop.Not zero-copy in the strict sense: Comet's Parquet readers each construct their own
RootAllocator, separate fromArrowUtils.rootAllocator, so Arrow'sTransferPaircannot 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 theInternalRowaccessor overhead. A future PR that unifies the allocator parent would unlock true zero-copy viaTransferPair.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.
ColumnarBatch, allocated from each Parquet reader's privateRootAllocator. The destination is Spark's persistent IPC root, allocated fromArrowUtils.rootAllocator. Because the two roots are unrelated, Arrow'sTransferPair/VectorLoader.loadcannot rebind buffers across the boundary.CometColumnarPythonInput.copyVectorwalks the trees in lockstep andsetBytes-copies each buffer. This is the bridge between the two allocator trees.BasicPythonArrowOutputconstructs anArrowStreamReaderagainst a per-iteratorBufferAllocator;loadNextBatchdecodes the IPC frames coming back from the Python worker directly into aVectorSchemaRootallocated from that allocator. EachFieldVectorin the root is wrapped in anArrowColumnVectorand emitted as aColumnarBatch.CometMapInBatchExecconsumes 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
TransferPairon 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):Optimized (
spark.comet.exec.pyarrowUdf.enabled=true):What changes are included in this PR?
CometColumnarPythonInputunderspark/src/main/spark-4.x/.../execution/python/. Extends Spark'sprivate[python] PythonArrowInput[Iterator[ColumnarBatch]]; implementswriteNextBatchToArrowStreamby 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.CometArrowPythonRunnerper Spark minor (4.0, 4.1, 4.2) under the same package. 4.0 extendsBasePythonRunnerdirectly because Spark 4.0'sBaseArrowPythonRunneris bound toIterator[InternalRow]; 4.1/4.2 extend the genericBaseArrowPythonRunner[IN, OUT]. All three mix inCometColumnarPythonInputplus Spark'sBasicPythonArrowOutput.CometVectorIpcCopierin comet-common crosses the shading boundary using onlylongprimitives. comet-common shadesorg.apache.arrow.*intoorg.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, exposingbufferReadableBytes(): long[],valueCounts(): int[], andcopyBuffersToAddresses(addresses: long[]): Unit. No shaded type crosses the API.CometMapInBatchExecloses all row plumbing (rowIterator,BatchIterator,ContextAwareIterator,InternalRow(_)wrap). FeedsIterator[ColumnarBatch]straight to the runner; on output, unwraps the single struct column into the user's output columns as before.isBarrieris propagated throughRDD.barrier()somapInArrow(..., barrier=True)keeps its gang-scheduling semantics.EliminateRedundantTransitionsrewrite unchanged in shape: matchesColumnarToRow + (PythonMapInArrowExec | MapInPandasExec | MapInArrowExec)over a columnar Comet child.ArrowPythonRunnerconstructors and the 4.0+MapInArrowExecrename. Shared 4.x bits live inspark-4.x/.../Spark4xMapInBatchSupport.scala; per-minor shims provide only the runner factory.PythonArrowInputtrait has a different contract (writeIteratorToArrowStreamone-shot vs 4.x'swriteNextBatchToArrowStreambatch-at-a-time) and a separate implementation has not been written. The matchers in the 3.4 / 3.5 shims returnNone; vanilla Spark handles the operation. 3.5 support can be added later if there is user demand.spark.comet.exec.pyarrowUdf.enabled, defaultfalsewhile experimental.Limitations
mapInArrowandmapInPandas. Scalar pandas UDFs (@pandas_udf) and grouped operations (applyInPandas) are not yet supported.Exchangeoutputs rows and breaks the precondition.RootAllocator, so cross-rootTransferPaircannot be used. A future PR that has the readers allocate fromArrowUtils.rootAllocatorwould unlock zero-copy.How are these changes tested?
CometMapInBatchSuiteunderspark/src/test/spark-4.x/covers the JVM-side rule and an end-to-end check: constructs aMapInArrowExecover a stubCometPlanleaf and assertsEliminateRedundantTransitionsrewrites it toCometMapInBatchExec; runs a Parquet →mapInArrowquery with primitives + nullable varchar and asserts row-equivalence with the un-rewritten output.test_pyarrow_udf.pycoversmapInArrowandmapInPandasend-to-end against a real Python worker: nulls, empty input, Python exception propagation, decimal / date / timestamp / array / struct types, post-shuffle correctness, andbarrier=Truegang scheduling. 24 / 24 cases pass locally on Spark 4.0.pyarrow_udf_test.ymlworkflow runs the pytest module on every PR against Spark 4.0 (covers the 4.x shim path;pathsallowlist 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):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-InternalRowoverhead is most concentrated.