| <!-- | |
| Licensed to the Apache Software Foundation (ASF) under one | |
| or more contributor license agreements. See the NOTICE file | |
| distributed with this work for additional information | |
| regarding copyright ownership. The ASF licenses this file | |
| to you under the Apache License, Version 2.0 (the | |
| "License"); you may not use this file except in compliance | |
| with the License. You may obtain a copy of the License at | |
| http://www.apache.org/licenses/LICENSE-2.0 | |
| Unless required by applicable law or agreed to in writing, | |
| software distributed under the License is distributed on an | |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| KIND, either express or implied. See the License for the | |
| specific language governing permissions and limitations | |
| under the License. | |
| --> | |
| # Arrow FFI Usage in Comet | |
| ## Overview | |
| Comet transfers Arrow data across the JVM/native boundary in two directions: | |
| 1. **JVM → Native**: Native code pulls batches from the JVM over the | |
| [Arrow C Stream Interface](https://arrow.apache.org/docs/format/CStreamInterface.html). The JVM exports each | |
| per-partition iterator once as an `ArrowArrayStream`, and native pulls every batch through a single C callback. | |
| 2. **Native → JVM**: JVM pulls batches from native code using `CometExecIterator`, via the | |
| [Arrow C Data Interface](https://arrow.apache.org/docs/format/CDataInterface.html) (one `ArrowArray`/`ArrowSchema` | |
| pair per batch). | |
| The following diagram shows an example of the end-to-end flow for a query stage. | |
|  | |
| Both scenarios use the same FFI mechanism but have different ownership semantics and memory management implications. | |
| ## Arrow FFI Basics | |
| The Arrow C Data Interface defines two C structures: | |
| - `ArrowArray`: Contains pointers to data buffers and metadata | |
| - `ArrowSchema`: Contains type information | |
| The Arrow C Stream Interface builds on these with a third structure: | |
| - `ArrowArrayStream`: A stream of `ArrowArray`s sharing one `ArrowSchema`, pulled one at a time through a | |
| `get_next` C callback. This is how Comet transfers JVM-sourced input (see below). | |
| ### Key Characteristics | |
| - **Zero-copy**: Data buffers can be shared across language boundaries without copying | |
| - **Ownership transfer**: Clear semantics for who owns and must free the data | |
| - **Release callbacks**: Custom cleanup functions for proper resource management | |
| ## JVM → Native Data Flow (ScanExec) | |
| ### Architecture | |
| When native code needs data from the JVM, it uses `ScanExec`, which is backed by an Arrow C Stream that the JVM | |
| exports once per partition: | |
| ``` | |
| ┌─────────────────┐ | |
| │ Spark/Scala │ | |
| │ Iterator of │ | |
| │ batches or rows │ | |
| └────────┬────────┘ | |
| │ wrapped in an ArrowReader, exported once | |
| │ via Data.exportArrayStream | |
| ▼ | |
| ┌─────────────────┐ | |
| │ ArrowArrayStream│ ── JVM side: one C stream struct per partition | |
| │ (C struct) │ | |
| └────────┬────────┘ | |
| │ Arrow C Stream Interface | |
| │ (native pulls each batch via the get_next callback) | |
| ▼ | |
| ┌─────────────────┐ | |
| │ ScanExec │ ── owns an AlignedArrowStreamReader | |
| │ (Rust/native) │ | |
| └────────┬────────┘ | |
| │ | |
| ▼ | |
| ┌─────────────────┐ | |
| │ DataFusion │ | |
| │ operators │ | |
| └─────────────────┘ | |
| ``` | |
| ### Stream Export and Import | |
| On the JVM side, `CometArrowStream` (in `execution/arrow/CometNativeArrowSource.scala`) wraps each per-partition | |
| input in an `org.apache.arrow.vector.ipc.ArrowReader` and exports it once with `Data.exportArrayStream`. The reader | |
| implementation depends on the source of the data: | |
| - `RowArrowReader`: a Spark `Iterator[InternalRow]` (row input) | |
| - `SparkColumnarArrowReader`: a non-Arrow Spark `ColumnarBatch` | |
| - `ColumnarBatchArrowReader`: an Arrow-backed `ColumnarBatch` (transfers `VectorSchemaRoot` ownership) | |
| The exported `ArrowArrayStream`s are boxed into the `Array[Object]` that `CometExecIterator` / `CometExecRDD` pass | |
| to native `createPlan` (one slot per scan input; shuffle inputs pass a `CometShuffleBlockIterator` instead). | |
| On the native side, `planner.rs` reads each stream's `memoryAddress` and takes ownership through | |
| `AlignedArrowStreamReader::from_raw`, importing the schema once. `ScanExec::get_next_batch` then pulls each batch | |
| through the stream's `get_next` callback. There is no per-batch JNI call and no per-column FFI export. | |
| ### Buffer Alignment (AlignedArrowStreamReader) | |
| `AlignedArrowStreamReader` (in `execution/operators/aligned_stream_reader.rs`) wraps the imported stream and calls | |
| `align_buffers` on every batch before constructing typed arrays. This works around the fact that Java's allocator | |
| hands back `Decimal128` buffers at 8-byte (not 16-byte) alignment, which the stock `ArrowArrayStreamReader` rejects | |
| ([apache/arrow-rs#10028](https://github.com/apache/arrow-rs/issues/10028)). The fix | |
| ([apache/arrow-rs#10030](https://github.com/apache/arrow-rs/pull/10030)) makes import align internally and ships in | |
| arrow 59.0.0; once Comet is on arrow >= 59 this reader can be dropped for the stock `ArrowArrayStreamReader`. | |
| ### Schema Reconciliation | |
| `CometArrowStream.reconcileStreamSchema` advertises the stream's schema from the actual `CometVector` types in the | |
| first batch rather than the consumer's Spark-declared types. Native `ScanExec` already casts its input to the | |
| declared scan-input schema in `build_record_batch`, so the truthful first-batch schema lets that cast fire; if the | |
| two differ, it logs one deduplicated warning naming the operator, column, and type drift. | |
| ### Memory Layout | |
| When a batch is transferred from JVM to native: | |
| ``` | |
| JVM Heap: Native Memory: | |
| ┌──────────────────┐ ┌──────────────────┐ | |
| │ ColumnarBatch │ │ FFI_ArrowArray │ | |
| │ ┌──────────────┐ │ │ ┌──────────────┐ │ | |
| │ │ ArrowBuf │─┼──────────────>│ │ buffers[0] │ │ | |
| │ │ (off-heap) │ │ │ │ (pointer) │ │ | |
| │ └──────────────┘ │ │ └──────────────┘ │ | |
| └──────────────────┘ └──────────────────┘ | |
| │ │ | |
| │ │ | |
| Off-heap Memory: │ | |
| ┌──────────────────┐ <──────────────────────┘ | |
| │ Actual Data │ | |
| │ (e.g., int32[]) │ | |
| └──────────────────┘ | |
| ``` | |
| **Key Point**: The actual data buffers are shared zero-copy; native only takes pointers to the off-heap buffers. | |
| ### Ownership and Lifecycle | |
| The Arrow C Stream Interface transfers ownership by reference count: native takes ownership of each imported batch, | |
| so it is safe to buffer batches in operators such as `SortExec` or `ShuffleWriterExec` without a deep copy. | |
| The whole per-partition stream is exported once, so the JVM allocates one `ArrowArrayStream` per partition rather | |
| than a per-batch, per-column `ArrowArray`/`ArrowSchema` wrapper object pair. Lifecycle is anchored at the stream: when | |
| `ScanExec` drops its `AlignedArrowStreamReader`, the stream's release callback fires synchronously back into the JVM | |
| and closes the `ArrowReader` and its `VectorSchemaRoot`, releasing the off-heap buffers. Because native holds those | |
| buffers until the reader drops, an operator that buffers many batches keeps the corresponding JVM-side data alive | |
| until then. | |
| ## Native → JVM Data Flow (CometExecIterator) | |
| ### Architecture | |
| When JVM needs results from native execution: | |
| ``` | |
| ┌─────────────────┐ | |
| │ DataFusion Plan │ | |
| │ (native) │ | |
| └────────┬────────┘ | |
| │ produces RecordBatch | |
| ▼ | |
| ┌─────────────────┐ | |
| │ CometExecIter │ | |
| │ (Rust/native) │ | |
| └────────┬────────┘ | |
| │ Arrow FFI | |
| │ (transfers ArrowArray/ArrowSchema pointers) | |
| ▼ | |
| ┌─────────────────┐ | |
| │ CometExecIter │ ◄─── JNI call from Spark | |
| │ (Scala side) │ | |
| └────────┬────────┘ | |
| │ | |
| ▼ | |
| ┌─────────────────┐ | |
| │ Spark Actions │ | |
| │ (collect, etc) │ | |
| └─────────────────┘ | |
| ``` | |
| ### FFI Transfer Process | |
| The transfer happens in `CometExecIterator::getNextBatch()`: | |
| ```scala | |
| // Scala side | |
| def getNextBatch(): ColumnarBatch = { | |
| val batchHandle = Native.getNextBatch(nativeHandle) | |
| // Import from FFI structures | |
| val vectors = (0 until schema.length).map { i => | |
| val array = Array.empty[Long](1) | |
| val schemaPtr = Array.empty[Long](1) | |
| // Get FFI pointers from native | |
| Native.exportVector(batchHandle, i, array, schemaPtr) | |
| // Import into Arrow Java | |
| Data.importVector(allocator, array(0), schemaPtr(0)) | |
| } | |
| new ColumnarBatch(vectors.toArray, numRows) | |
| } | |
| ``` | |
| ```rust | |
| // Native side (simplified) | |
| #[no_mangle] | |
| pub extern "system" fn Java_..._getNextBatch( | |
| env: JNIEnv, | |
| handle: jlong, | |
| ) -> jlong { | |
| let context = get_exec_context(handle)?; | |
| let batch = context.stream.next().await?; | |
| // Store batch and return handle | |
| let batch_handle = Box::into_raw(Box::new(batch)) as i64; | |
| batch_handle | |
| } | |
| #[no_mangle] | |
| pub extern "system" fn Java_..._exportVector( | |
| env: JNIEnv, | |
| batch_handle: jlong, | |
| col_idx: jint, | |
| array_ptr: jlongArray, | |
| schema_ptr: jlongArray, | |
| ) { | |
| let batch = get_batch(batch_handle)?; | |
| let array = batch.column(col_idx); | |
| // Export to FFI structures | |
| let (array_ffi, schema_ffi) = to_ffi(array.to_data())?; | |
| // Write pointers back to JVM | |
| env.set_long_array_region(array_ptr, 0, &[array_ffi as i64])?; | |
| env.set_long_array_region(schema_ptr, 0, &[schema_ffi as i64])?; | |
| } | |
| ``` | |
| ### Wrapper Object Lifecycle (Native → JVM) | |
| ``` | |
| Time Native Memory JVM Heap Off-heap/Native | |
| ──────────────────────────────────────────────────────────────────────── | |
| t0 RecordBatch produced - Data in native | |
| in DataFusion | |
| t1 FFI_ArrowArray created - Data in native | |
| FFI_ArrowSchema created | |
| (native heap) | |
| t2 Pointers exported to JVM ArrowBuf created Data in native | |
| (wraps native ptr) | |
| t3 FFI structures kept alive Spark processes Data in native | |
| via batch handle ColumnarBatch ✓ Valid | |
| t4 Batch handle released ArrowBuf freed Data freed | |
| Release callback runs (triggers native (via release | |
| release callback) callback) | |
| ``` | |
| **Key Difference from JVM → Native**: | |
| - Native code controls lifecycle through batch handle | |
| - JVM creates `ArrowBuf` wrappers that point to native memory | |
| - Release callback ensures proper cleanup when JVM is done | |
| - No GC pressure issue because native allocator manages the data | |
| ### Release Callbacks | |
| Critical for proper cleanup: | |
| ```rust | |
| // Native release callback (simplified) | |
| extern "C" fn release_batch(array: *mut FFI_ArrowArray) { | |
| if !array.is_null() { | |
| unsafe { | |
| // Free the data buffers | |
| for buffer in (*array).buffers { | |
| drop(Box::from_raw(buffer)); | |
| } | |
| // Free the array structure itself | |
| drop(Box::from_raw(array)); | |
| } | |
| } | |
| } | |
| ``` | |
| When JVM is done with the data: | |
| ```java | |
| // ArrowBuf.close() triggers the release callback | |
| arrowBuf.close(); // → calls native release_batch() | |
| ``` | |
| ## Memory Ownership Rules | |
| ### JVM → Native | |
| | Scenario | Ownership | Action Required | | |
| | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | |
| | All cases | Native owns | None; the C Stream transfers ownership by reference count (copy only to unpack dictionaries). Dropping the reader releases the JVM-side data | | |
| ### Native → JVM | |
| | Scenario | Ownership | Action Required | | |
| | --------- | -------------------------------- | ---------------------------------------------------------- | | |
| | All cases | Native allocates, JVM references | JVM must call `close()` to trigger native release callback | | |
| ## Further Reading | |
| - [Arrow C Data Interface Specification](https://arrow.apache.org/docs/format/CDataInterface.html) | |
| - [Arrow C Stream Interface Specification](https://arrow.apache.org/docs/format/CStreamInterface.html) | |
| - [Arrow Java FFI Implementation](https://github.com/apache/arrow/tree/main/java/c) | |
| - [Arrow Rust FFI Implementation](https://docs.rs/arrow/latest/arrow/ffi/) |