This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 32fae06d24 feat: trace Arrow memory held on the JVM side (#6048)
32fae06d24 is described below

commit 32fae06d24a1489a1b1d729372585211b4f64185
Author: Andy Grove <[email protected]>
AuthorDate: Sun Sep 20 15:24:34 2026 +0000

    feat: trace Arrow memory held on the JVM side (#6048)
    
    * feat: trace Arrow memory held on the JVM side
    
    Comet's tracing counters cover native allocations, memory pool
    reservations and the JVM heap, but Arrow buffers allocated on the JVM
    are off-heap, so none of the existing counters see them.
    
    A single gauge over the root allocator would not answer the question
    either. Comet imports batches from native over the Arrow C Data
    Interface, and Arrow charges an imported buffer to whichever allocator
    wraps it, so the root's total mixes memory the JVM allocated with
    native memory that native_allocated already counts.
    
    Hold imported buffers in a dedicated child allocator and report two
    counters. The child reserves nothing, so every byte still escalates to
    the parent and the root keeps reporting the total, which makes
    jvm_arrow_allocated minus jvm_arrow_imported the Arrow memory the JVM
    allocated itself.
    
    * refactor: move the Arrow memory counters into Tracing
    
    Tracing already owns the JVM tracing seam and its own Native handle, so
    it is a better home for the counters than the package object, which
    holds the allocators themselves.
    
    Emitting from there also drops the Seq of tuples that the single caller
    built only to destructure one line later, along with the boxing and the
    capturing lambda it cost on every traced batch.
    
    Fold the two new NativeUtilSuite tests into one. They shared their whole
    fixture and asserted two views of a single invariant: that imports are
    charged to the import allocator and still roll up into the root. Assert
    allocator identity in the UDF probe rather than a byte-count delta,
    which is the property actually under test and removes the sentinel, the
    manual reset and the ran-check.
    
    * fix: narrow the imported-memory claim and keep UDF output off the import 
allocator
    
    The import allocator does not hold foreign memory exclusively. Arrow's
    importer allocates the owning ArrowArray struct from it, and
    loadValidityBuffer allocates a validity bitmap there when an imported
    vector is all-valid or all-null and carries no validity buffer. Both are
    bytes the JVM allocated, so the difference between the two counters is a
    close lower bound on the JVM's own Arrow memory rather than an exact
    split. Say so in the tracing guide, the allocator scaladoc and the
    emission scaladoc, and note that the counters are separate reads of
    process-wide state, so they are neither an atomic per-query balance nor
    a measure of RSS.
    
    A UDF may also allocate its result from the allocator it finds on its
    inputs, which is the import allocator, and Data.exportVector does not
    re-own the buffers. That output would then be reported as imported
    native memory for as long as the export holds it. Transfer the result to
    the root before exporting when the UDF allocated it elsewhere;
    TransferPair moves ownership without copying the payload.
    
    Add a characterization test pinning the import path's own overhead, and
    a regression test for a UDF output allocated from the input allocator.
    Record the new child in the memory management guide's allocator
    inventory.
    
    * fix: drop a redundant string interpolator
    
    scalafix's RedundantSyntax rule rejects an `s` prefix on a string part
    that interpolates nothing. This failed the syntactic lint job and, since
    the full rule set also runs there, all four Java lint jobs.
    
    It went unnoticed locally because `make format` cannot run here: it dies
    resolving semanticdb-scalac, so scalafix never ran over these changes.
    The syntactic rules need no semanticdb, so they can be checked directly
    with the same invocation CI uses.
    
    * fix: report the JVM Arrow counters as allocator charges, and keep a 
returned UDF input on the import allocator
    
    Allocator accounting tracks which allocator is accountable for a buffer, 
not where its bytes were
    allocated, and the two come apart in both directions. The import allocator 
is charged for the
    importer's own `ArrowArray` struct and for a validity bitmap synthesized 
when an imported vector
    carries none, while an ownership transfer re-parents a charge without 
moving the payload. So
    `jvm_arrow_allocated - jvm_arrow_imported` is not a bound on the Arrow 
memory the JVM allocated
    itself, and `jvm_arrow_imported` is not guaranteed to be included in 
`native_allocated`: a buffer
    Comet exported to native and that native passed back by reference is 
imported without the Rust
    allocator ever having handed it out.
    
    Tracking true origin instead is not reachable without vendoring. Arrow 
routes
    `wrapForeignAllocation` through the same `allocateBytes` / `onAllocation` / 
`releaseBytes` path as
    ordinary allocation, so an `AllocationListener` cannot tell the two apart, 
and both
    `ForeignAllocation.release0()` and `memoryAddress()` are protected, so a 
delegating wrapper that
    could hook the release would have to live in `org.apache.arrow.memory`. 
Both counters are
    therefore described as allocator charges, in the tracing guide, the memory 
management guide and
    the two scaladocs.
    
    One case is worth fixing rather than documenting. `CometUdfBridge` 
transfers a result off the
    import allocator when the UDF allocated it there, but a UDF that returns 
one of its inputs took
    that same branch, moving the whole batch's imported charge onto the root: 
with a 4096-row int
    input the import allocator was left holding 128 bytes of the 16 KiB. The 
transfer now skips a
    result that is reference-identical to an input, and the input loop's close 
covers it, so the
    result branch skips it too. Reference identity does not cover a result that 
merely shares buffers
    with an input, such as a slice, which is why the contract above is 
charge-based rather than a
    corrected list of exceptions.
    
    `CometUdfBridgeSuite` gains the regression: an identity UDF, asserting the 
charge stays on the
    import allocator, that importing the export back reads the payload, and 
that the import allocator
    returns to its prior charge. It fails at 128 of 16384 bytes without the 
skip.
---
 .github/workflows/pr_build_linux.yml               |   1 +
 .github/workflows/pr_build_macos.yml               |   1 +
 docs/source/contributor-guide/memory_management.md |   8 +
 docs/source/contributor-guide/tracing.md           |  40 +++-
 .../java/org/apache/comet/udf/CometUdfBridge.java  |  51 ++++-
 .../scala/org/apache/comet/CometExecIterator.scala |   1 +
 .../src/main/scala/org/apache/comet/Tracing.scala  |  12 ++
 .../src/main/scala/org/apache/comet/package.scala  |  26 ++-
 .../scala/org/apache/comet/vector/NativeUtil.scala |   4 +-
 .../org/apache/comet/udf/CometUdfBridgeSuite.scala | 207 +++++++++++++++++++++
 .../org/apache/comet/vector/NativeUtilSuite.scala  |  82 +++++++-
 11 files changed, 419 insertions(+), 14 deletions(-)

diff --git a/.github/workflows/pr_build_linux.yml 
b/.github/workflows/pr_build_linux.yml
index 17c9da123b..a9c184a20b 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -564,6 +564,7 @@ jobs:
               org.apache.spark.sql.comet.util.UtilsSuite
               org.apache.comet.vector.NativeUtilSuite
               org.apache.comet.vector.CometVectorUtilsSuite
+              org.apache.comet.udf.CometUdfBridgeSuite
               org.apache.comet.objectstore.NativeConfigSuite
               org.apache.comet.serde.operator.CometIcebergNativeScanSuite
               org.apache.comet.serde.operator.CometNativeScanSuite
diff --git a/.github/workflows/pr_build_macos.yml 
b/.github/workflows/pr_build_macos.yml
index 5a1abca8cf..b47ed5a46f 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -212,6 +212,7 @@ jobs:
               org.apache.spark.sql.comet.util.UtilsSuite
               org.apache.comet.vector.NativeUtilSuite
               org.apache.comet.vector.CometVectorUtilsSuite
+              org.apache.comet.udf.CometUdfBridgeSuite
               org.apache.comet.objectstore.NativeConfigSuite
               org.apache.comet.serde.operator.CometIcebergNativeScanSuite
               org.apache.comet.serde.operator.CometNativeScanSuite
diff --git a/docs/source/contributor-guide/memory_management.md 
b/docs/source/contributor-guide/memory_management.md
index 91199dec35..e7916f9c78 100644
--- a/docs/source/contributor-guide/memory_management.md
+++ b/docs/source/contributor-guide/memory_management.md
@@ -103,6 +103,14 @@ off-heap bytes in container RSS that neither Spark's 
`TaskMemoryManager` nor Com
 pool sees. In practice the volume is modest, a batch at a time per stream, but 
there is no
 ceiling and no backpressure.
 
+One further child, `CometArrowImportAllocator` (`comet-ffi-imports`), is what 
the Arrow C Data
+Interface import path allocates from, so that tracing can report those charges 
apart from the rest
+of Comet's Arrow memory. Unlike the others it is process-wide and never 
closed, because imported
+buffers are reference counted and routinely outlive the task that imported 
them. Its reservation
+is zero, so every byte still escalates to the root and the inventory above is 
unchanged by it.
+Being charged there is not the same as having been allocated there; see the 
scaladoc on the
+allocator before reading anything into the split.
+
 **The JVM shuffle allocator is an ordinary Spark consumer.** 
`CometShuffleMemoryAllocator.getInstance`
 returns `CometUnifiedShuffleMemoryAllocator`, a Spark `MemoryConsumer` drawing 
from
 `spark.memory.offHeap.size`, so shuffle pages are arbitrated against Spark's 
other consumers in the
diff --git a/docs/source/contributor-guide/tracing.md 
b/docs/source/contributor-guide/tracing.md
index 9757bd8a57..bc5690bc59 100644
--- a/docs/source/contributor-guide/tracing.md
+++ b/docs/source/contributor-guide/tracing.md
@@ -119,12 +119,38 @@ Some excess is expected (allocator metadata and 
fragmentation for `jemalloc_allo
 allocations like Arrow IPC buffers for either counter). Large or growing 
excess may indicate memory that is
 not being tracked by the pool.
 
+Arrow memory on the JVM side is reported separately, because it is off-heap 
and so invisible to
+`jvm_heap_used`. Comet imports batches from native over the Arrow C Data 
Interface, and Arrow
+charges a buffer to whichever allocator owns it, so those imports are taken 
against a dedicated
+child allocator and reported as `jvm_arrow_imported`, within the 
`jvm_arrow_allocated` total.
+
+Both are allocator charges. They report what each allocator is accountable 
for, not where the bytes
+were allocated, and their difference is not a bound on the Arrow memory the 
JVM allocated itself.
+Ownership and allocation come apart in both directions:
+
+- Bytes the JVM allocated get charged to the import allocator. Arrow's 
importer allocates the
+  owning `ArrowArray` struct there, and `BitVectorHelper.loadValidityBuffer` 
allocates a validity
+  bitmap there when an imported vector is all-valid or all-null and carries no 
validity buffer
+  (512 bytes per 4096 rows).
+- Imported bytes get charged to the root. An ownership transfer re-parents a 
charge without moving
+  the payload, so a vector that shares buffers with an import, such as a slice 
of a UDF input, can
+  leave the root accountable for memory the producer allocated.
+
+For the same reason neither counter is a count of unique physical bytes, and 
`jvm_arrow_imported`
+is not guaranteed to be included in `native_allocated`. Usually the producer 
is Rust and the bytes
+are counted in both, but a buffer that Comet exported to native and that 
native passed back by
+reference is imported without the Rust allocator ever having handed it out. 
Finally, the two
+counters are separate reads of process-wide state, so concurrent tasks can 
change them between
+samples: they are not an atomic per-query balance, and neither is a measure of 
RSS.
+
 ## Definition of Labels
 
-| Label                            | Meaning                                   
                                                                                
                                           |
-| -------------------------------- | 
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
 |
-| jvm_heap_used                    | JVM heap memory usage of live objects for 
the executor process                                                            
                                           |
-| jemalloc_allocated               | Native memory usage for the executor 
process (requires `jemalloc` feature)                                           
                                                |
-| native_allocated                 | Bytes handed out by the Rust global 
allocator, process-wide (requires `alloc-accounting` feature). Approximate to 
within 64 KiB of un-flushed delta per live thread. |
-| thread_NNN_comet_memory_reserved | Memory reserved by Comet's DataFusion 
memory pool (summed across all contexts on the thread). NNN is the Rust thread 
ID.                                             |
-| thread_NNN_comet_jvm_shuffle     | Off-heap memory allocated by Comet for 
columnar shuffle. NNN is the Rust thread ID.                                    
                                              |
+| Label                            | Meaning                                   
                                                                                
                                                        |
+| -------------------------------- | 
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 |
+| jvm_heap_used                    | JVM heap memory usage of live objects for 
the executor process                                                            
                                                        |
+| jemalloc_allocated               | Native memory usage for the executor 
process (requires `jemalloc` feature)                                           
                                                             |
+| jvm_arrow_allocated              | Bytes charged to Comet's Arrow allocator 
tree on the JVM, including buffers imported from native over the Arrow C Data 
Interface                                                  |
+| jvm_arrow_imported               | Bytes charged to the Arrow C Data 
Interface import allocator, a subset of `jvm_arrow_allocated`. An allocator 
charge, not a measure of where the bytes were allocated; see above. |
+| native_allocated                 | Bytes handed out by the Rust global 
allocator, process-wide (requires `alloc-accounting` feature). Approximate to 
within 64 KiB of un-flushed delta per live thread.              |
+| thread_NNN_comet_memory_reserved | Memory reserved by Comet's DataFusion 
memory pool (summed across all contexts on the thread). NNN is the Rust thread 
ID.                                                          |
+| thread_NNN_comet_jvm_shuffle     | Off-heap memory allocated by Comet for 
columnar shuffle. NNN is the Rust thread ID.                                    
                                                           |
diff --git a/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java 
b/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
index d8dea73135..d2881bfe44 100644
--- a/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
+++ b/spark/src/main/java/org/apache/comet/udf/CometUdfBridge.java
@@ -27,6 +27,7 @@ import org.apache.arrow.c.Data;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.vector.FieldVector;
 import org.apache.arrow.vector.ValueVector;
+import org.apache.arrow.vector.util.TransferPair;
 import org.apache.spark.TaskContext;
 import org.apache.spark.comet.CometTaskContextShim;
 import org.apache.spark.util.TaskCompletionListener;
@@ -210,17 +211,26 @@ public class CometUdfBridge {
     assert udf != null : "reflective instantiation returned null for " + 
udfClassName;
 
     BufferAllocator allocator = 
org.apache.comet.package$.MODULE$.CometArrowAllocator();
+    // See CometArrowImportAllocator: inputs are imported against that 
allocator, so that tracing
+    // can report the import path's charges apart from the rest of Comet's 
Arrow memory.
+    BufferAllocator importAllocator = 
org.apache.comet.package$.MODULE$.CometArrowImportAllocator();
 
     ValueVector[] inputs = new ValueVector[inputArrayPtrs.length];
     ValueVector result = null;
+    // Whether the UDF handed back one of the vectors it was given. Such a 
result is closed by the
+    // input loop below, so the result branch there must leave it alone.
+    boolean resultIsInput = false;
+    ValueVector transferred = null;
     try {
       for (int i = 0; i < inputArrayPtrs.length; i++) {
         ArrowArray inArr = ArrowArray.wrap(inputArrayPtrs[i]);
         ArrowSchema inSch = ArrowSchema.wrap(inputSchemaPtrs[i]);
-        inputs[i] = Data.importVector(allocator, inArr, inSch, null);
+        inputs[i] = Data.importVector(importAllocator, inArr, inSch, null);
       }
 
       result = udf.evaluate(inputs, numRows);
+      // Recorded before the checks below, so the invariant holds however this 
exits.
+      resultIsInput = isOneOf(result, inputs);
       if (!(result instanceof FieldVector)) {
         throw new RuntimeException(
             "CometUDF.evaluate() must return a FieldVector, got: " + 
result.getClass().getName());
@@ -232,9 +242,27 @@ public class CometUdfBridge {
                 + " rows, expected "
                 + numRows);
       }
+      // The UDF may allocate its result from the allocator it found on its 
inputs, which is
+      // the import allocator. Data.exportVector does not re-own the buffers, 
so the result
+      // would stay charged there for as long as the export holds it and be 
reported as
+      // imported memory. TransferPair moves ownership without copying the 
payload.
+      //
+      // A result that *is* one of the inputs is left alone: those buffers 
were imported, so the
+      // import allocator is the right place for them, and transferring would 
move a foreign
+      // charge onto the root. The check is reference identity, so a result 
that merely shares
+      // buffers with an input (a slice, say) is still transferred. That is a 
limit of allocator
+      // accounting rather than something this can close; see 
CometArrowImportAllocator.
+      FieldVector toExport = (FieldVector) result;
+      if (!resultIsInput && result.getAllocator() != allocator) {
+        TransferPair transferPair = result.getTransferPair(allocator);
+        transferPair.transfer();
+        transferred = transferPair.getTo();
+        toExport = (FieldVector) transferred;
+      }
+
       ArrowArray outArr = ArrowArray.wrap(outArrayPtr);
       ArrowSchema outSch = ArrowSchema.wrap(outSchemaPtr);
-      Data.exportVector(allocator, (FieldVector) result, null, outArr, outSch);
+      Data.exportVector(allocator, toExport, null, outArr, outSch);
     } finally {
       for (ValueVector v : inputs) {
         if (v != null) {
@@ -245,13 +273,30 @@ public class CometUdfBridge {
           }
         }
       }
-      if (result != null) {
+      if (result != null && !resultIsInput) {
         try {
           result.close();
         } catch (RuntimeException ignored) {
           // do not mask the original throwable
         }
       }
+      if (transferred != null) {
+        try {
+          transferred.close();
+        } catch (RuntimeException ignored) {
+          // do not mask the original throwable
+        }
+      }
+    }
+  }
+
+  /** Whether the UDF handed back one of the vectors it was given, rather than 
a new one. */
+  private static boolean isOneOf(ValueVector result, ValueVector[] inputs) {
+    for (ValueVector input : inputs) {
+      if (result == input) {
+        return true;
+      }
     }
+    return false;
   }
 }
diff --git a/spark/src/main/scala/org/apache/comet/CometExecIterator.scala 
b/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
index 4da95af18d..4de949b9de 100644
--- a/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
+++ b/spark/src/main/scala/org/apache/comet/CometExecIterator.scala
@@ -357,6 +357,7 @@ class CometExecIterator(
 
   private def traceMemoryUsage(): Unit = {
     nativeLib.logMemoryUsage("jvm_heap_used", 
memoryMXBean.getHeapMemoryUsage.getUsed)
+    Tracing.logArrowMemory()
   }
 }
 
diff --git a/spark/src/main/scala/org/apache/comet/Tracing.scala 
b/spark/src/main/scala/org/apache/comet/Tracing.scala
index 13f44ce270..a3cb6fd8b5 100644
--- a/spark/src/main/scala/org/apache/comet/Tracing.scala
+++ b/spark/src/main/scala/org/apache/comet/Tracing.scala
@@ -23,6 +23,18 @@ object Tracing {
 
   private val nativeLib = new Native
 
+  /**
+   * Emits the Arrow memory counters for the JVM side: what Comet's Arrow 
allocator tree is
+   * charged for in total, and how much of that the C Data Interface import 
path is charged for.
+   *
+   * Both are allocator charges rather than allocation origin, so their 
difference is not a bound
+   * on the Arrow memory the JVM allocated itself. See 
[[CometArrowImportAllocator]].
+   */
+  def logArrowMemory(): Unit = {
+    nativeLib.logMemoryUsage("jvm_arrow_allocated", 
CometArrowAllocator.getAllocatedMemory)
+    nativeLib.logMemoryUsage("jvm_arrow_imported", 
CometArrowImportAllocator.getAllocatedMemory)
+  }
+
   def withTrace[T](label: String, tracingEnabled: Boolean, fun: => T): T = {
     try {
       if (tracingEnabled) {
diff --git a/spark/src/main/scala/org/apache/comet/package.scala 
b/spark/src/main/scala/org/apache/comet/package.scala
index 0eb65c9ba6..702359e335 100644
--- a/spark/src/main/scala/org/apache/comet/package.scala
+++ b/spark/src/main/scala/org/apache/comet/package.scala
@@ -21,7 +21,7 @@ package org.apache
 
 import java.util.Properties
 
-import org.apache.arrow.memory.RootAllocator
+import org.apache.arrow.memory.{BufferAllocator, RootAllocator}
 import org.apache.spark.internal.Logging
 
 package object comet {
@@ -35,6 +35,30 @@ package object comet {
    */
   val CometArrowAllocator = new RootAllocator(Long.MaxValue)
 
+  /**
+   * The allocator that the Arrow C Data Interface import path allocates from.
+   *
+   * Arrow charges a buffer to whichever allocator owns it, so imports taken 
directly against
+   * [[CometArrowAllocator]] are indistinguishable from buffers the JVM 
allocated itself. Giving
+   * the import path its own child keeps the two separable for tracing. The 
child reserves
+   * nothing, so every byte still escalates to the parent and the root keeps 
reporting the total.
+   * Like the root, it is never closed: imported buffers are reference counted 
and routinely
+   * outlive the task that imported them.
+   *
+   * What this counts is what the import path is charged for, not where the 
bytes were allocated.
+   * Ownership and allocation come apart in both directions. Bytes the JVM 
allocated land here:
+   * Arrow's importer allocates the owning `ArrowArray` struct from this 
allocator, and
+   * `BitVectorHelper.loadValidityBuffer` allocates a validity bitmap here 
when an imported vector
+   * is all-valid or all-null and carries no validity buffer. Imported bytes 
land elsewhere: an
+   * ownership transfer re-parents a charge without moving the payload, so a 
vector that shares
+   * buffers with an import can leave the root accountable for memory the 
producer allocated.
+   *
+   * So read this and the root's total as allocator charges. Their difference 
is not a bound on
+   * the Arrow memory the JVM allocated itself, and neither is a count of 
unique physical bytes.
+   */
+  val CometArrowImportAllocator: BufferAllocator =
+    CometArrowAllocator.newChildAllocator("comet-ffi-imports", 0, 
Long.MaxValue)
+
   /**
    * Provides access to build information about the Comet libraries. This will 
be used by the
    * benchmarking software to provide the source revision and repository. In 
addition, the build
diff --git a/spark/src/main/scala/org/apache/comet/vector/NativeUtil.scala 
b/spark/src/main/scala/org/apache/comet/vector/NativeUtil.scala
index 173086d2fd..5ef2432173 100644
--- a/spark/src/main/scala/org/apache/comet/vector/NativeUtil.scala
+++ b/spark/src/main/scala/org/apache/comet/vector/NativeUtil.scala
@@ -31,7 +31,7 @@ import org.apache.spark.sql.comet.util.Utils
 import org.apache.spark.sql.execution.vectorized.ConstantColumnVector
 import org.apache.spark.sql.vectorized.ColumnarBatch
 
-import org.apache.comet.CometArrowAllocator
+import org.apache.comet.{CometArrowAllocator, CometArrowImportAllocator}
 
 /**
  * Provides functionality for importing Arrow vectors from native code and 
wrapping them as
@@ -51,7 +51,7 @@ class NativeUtil extends AutoCloseable {
   private val allocator = CometArrowAllocator
 
   /** ArrowImporter does not hold any state and does not need to be closed */
-  private val importer = new ArrowImporter(allocator)
+  private val importer = new ArrowImporter(CometArrowImportAllocator)
 
   /**
    * Dictionary provider to use for the lifetime of this instance of 
NativeUtil. The dictionary
diff --git 
a/spark/src/test/scala/org/apache/comet/udf/CometUdfBridgeSuite.scala 
b/spark/src/test/scala/org/apache/comet/udf/CometUdfBridgeSuite.scala
new file mode 100644
index 0000000000..7efc5f12cb
--- /dev/null
+++ b/spark/src/test/scala/org/apache/comet/udf/CometUdfBridgeSuite.scala
@@ -0,0 +1,207 @@
+/*
+ * 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.
+ */
+
+package org.apache.comet.udf
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.arrow.memory.BufferAllocator
+import org.apache.arrow.vector.{IntVector, ValueVector}
+import org.apache.spark.sql.execution.vectorized.ConstantColumnVector
+import org.apache.spark.sql.types.IntegerType
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+
+import org.apache.comet.{CometArrowAllocator, CometArrowImportAllocator}
+import org.apache.comet.vector.NativeUtil
+
+/**
+ * A UDF that reports which allocator owns its inputs.
+ *
+ * The bridge closes the imported inputs before `evaluate` returns, so this 
can only be observed
+ * from inside the call.
+ */
+class ImportAllocatorProbeUdf extends CometUDF {
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
= {
+    ImportAllocatorProbeUdf.inputAllocator = Some(inputs.head.getAllocator)
+
+    // Allocated from the root: a UDF result is memory the JVM allocated.
+    val out = new IntVector("out", CometArrowAllocator)
+    out.allocateNew(numRows)
+    (0 until numRows).foreach(row => out.setSafe(row, 7))
+    out.setValueCount(numRows)
+    out
+  }
+}
+
+object ImportAllocatorProbeUdf {
+  @volatile var inputAllocator: Option[BufferAllocator] = None
+}
+
+/**
+ * A UDF that allocates its output from the allocator that owns its inputs.
+ *
+ * The `CometUDF` interface permits this, and nothing about it is wrong from 
the UDF's side. It
+ * matters here because that allocator is the FFI import allocator, so without 
intervention the
+ * output would be charged to it and counted as imported memory.
+ */
+class InputAllocatorOutputUdf extends CometUDF {
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
= {
+    val out = new IntVector("out", inputs.head.getAllocator)
+    out.allocateNew(numRows)
+    (0 until numRows).foreach(row => out.setSafe(row, 7))
+    out.setValueCount(numRows)
+    out
+  }
+}
+
+/**
+ * A UDF that hands back the vector it was given.
+ *
+ * The `CometUDF` interface permits this. It matters here because the result's 
buffers were
+ * imported, so re-parenting them to the root before export would charge the 
root for memory the
+ * producer allocated.
+ */
+class IdentityUdf extends CometUDF {
+  override def evaluate(inputs: Array[ValueVector], numRows: Int): ValueVector 
= inputs.head
+}
+
+class CometUdfBridgeSuite extends AnyFunSuite {
+
+  /** Exports a single-column batch and invokes the bridge against it. */
+  private def withBridgeCall(udfClassName: String, numRows: Int)(check: () => 
Unit): Unit = {
+    val col = new ConstantColumnVector(numRows, IntegerType)
+    col.setInt(42)
+    val batch = new ColumnarBatch(Array[ColumnVector](col), numRows)
+
+    val nativeUtil = new NativeUtil
+    try {
+      val (inputArrayAddrs, inputSchemaAddrs, _) = 
nativeUtil.exportBatchToAddresses(batch)
+      val (outArrays, outSchemas) = nativeUtil.allocateArrowStructs(1)
+
+      try {
+        CometUdfBridge.evaluate(
+          udfClassName,
+          inputArrayAddrs,
+          inputSchemaAddrs,
+          outArrays(0).memoryAddress(),
+          outSchemas(0).memoryAddress(),
+          numRows,
+          null,
+          null)
+
+        // Checked before releasing the exported structs: the export keeps the 
result's buffers
+        // alive, so whichever allocator owns them is still charged at this 
point.
+        check()
+      } finally {
+        outArrays(0).release()
+        outArrays(0).close()
+        outSchemas(0).release()
+        outSchemas(0).close()
+      }
+    } finally {
+      nativeUtil.close()
+    }
+  }
+
+  test("evaluate imports its input vectors against the FFI import allocator") {
+    withBridgeCall(classOf[ImportAllocatorProbeUdf].getName, 4) { () =>
+      assert(
+        
ImportAllocatorProbeUdf.inputAllocator.contains(CometArrowImportAllocator),
+        "the UDF's inputs were imported against " +
+          s"${ImportAllocatorProbeUdf.inputAllocator.map(_.getName)}, so their 
bytes are not " +
+          "reported as imported memory")
+    }
+  }
+
+  test("a UDF output allocated from the input allocator is not left charged as 
imported") {
+    // The CometUDF interface lets a UDF allocate its result from 
inputs.head.getAllocator, which
+    // is the import allocator. Data.exportVector does not re-own the buffers, 
so without a
+    // transfer the output stays charged to the import allocator for as long 
as the export holds
+    // it, and jvm_arrow_imported counts JVM-created bytes as imported native 
memory.
+    val numRows = 4096
+    val before = CometArrowImportAllocator.getAllocatedMemory
+
+    withBridgeCall(classOf[InputAllocatorOutputUdf].getName, numRows) { () =>
+      val during = CometArrowImportAllocator.getAllocatedMemory
+      assert(
+        during - before < numRows.toLong * 4,
+        s"the UDF's output is still charged to the import allocator ($before 
-> $during bytes, " +
+          s"output is ${numRows * 4} bytes), so it would be reported as 
imported native memory")
+    }
+  }
+
+  test("a UDF that returns one of its inputs leaves the charge on the import 
allocator") {
+    // The transfer above exists for a UDF that allocates its output from the 
import allocator. A
+    // UDF that returns an input is the opposite case: those buffers really 
were imported, so
+    // transferring them would move a charge for the producer's memory onto 
the root and inflate
+    // the JVM-side reading. Reference identity does not cover a result that 
merely shares buffers
+    // with an input, which is why the counters are documented as allocator 
charges rather than as
+    // a measure of where the bytes were allocated.
+    val numRows = 4096
+    val importBefore = CometArrowImportAllocator.getAllocatedMemory
+
+    val col = new ConstantColumnVector(numRows, IntegerType)
+    col.setInt(42)
+    val batch = new ColumnarBatch(Array[ColumnVector](col), numRows)
+
+    val nativeUtil = new NativeUtil
+    try {
+      val (inputArrayAddrs, inputSchemaAddrs, _) = 
nativeUtil.exportBatchToAddresses(batch)
+      val (outArrays, outSchemas) = nativeUtil.allocateArrowStructs(1)
+
+      CometUdfBridge.evaluate(
+        classOf[IdentityUdf].getName,
+        inputArrayAddrs,
+        inputSchemaAddrs,
+        outArrays(0).memoryAddress(),
+        outSchemas(0).memoryAddress(),
+        numRows,
+        null,
+        null)
+
+      // Read before the export is consumed. The bridge has dropped its own 
reference to the
+      // inputs, so what remains charged is the export's, and it must still be 
on the import
+      // allocator.
+      val during = CometArrowImportAllocator.getAllocatedMemory - importBefore
+      assert(
+        during >= numRows.toLong * 4,
+        s"the returned input was re-parented off the import allocator ($during 
bytes charged, " +
+          s"its data buffer alone is ${numRows * 4}), so the root is now 
charged for memory the " +
+          "producer allocated")
+
+      // Importing the export back shows the bridge's cleanup left the payload 
alive and readable.
+      val result = nativeUtil.importVector(outArrays, outSchemas)
+      try {
+        val vector = result.head
+        assert(vector.getValueVector.getValueCount == numRows)
+        assert(
+          (0 until numRows).forall(i => vector.getInt(i) == 42),
+          "the exported payload did not survive the bridge's cleanup")
+      } finally {
+        result.foreach(_.close())
+      }
+    } finally {
+      nativeUtil.close()
+    }
+
+    assert(
+      CometArrowImportAllocator.getAllocatedMemory == importBefore,
+      "the import allocator did not return to its prior charge once the export 
was released")
+  }
+}
diff --git a/spark/src/test/scala/org/apache/comet/vector/NativeUtilSuite.scala 
b/spark/src/test/scala/org/apache/comet/vector/NativeUtilSuite.scala
index ec9dde945c..fb22dd0abc 100644
--- a/spark/src/test/scala/org/apache/comet/vector/NativeUtilSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/vector/NativeUtilSuite.scala
@@ -22,6 +22,7 @@ package org.apache.comet.vector
 import java.io.IOException
 import java.nio.charset.StandardCharsets
 
+import scala.jdk.CollectionConverters._
 import scala.util.Using
 
 import org.apache.arrow.c.{ArrowArray, ArrowSchema, Data}
@@ -38,7 +39,7 @@ import 
org.apache.spark.sql.execution.vectorized.ConstantColumnVector
 import org.apache.spark.sql.types.{IntegerType, StringType, StructField, 
StructType}
 import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
 
-import org.apache.comet.CometConf
+import org.apache.comet.{CometArrowAllocator, CometArrowImportAllocator, 
CometConf}
 import org.apache.comet.serde.{OperatorOuterClass, QueryPlanSerde}
 
 class NativeUtilSuite extends CometTestBase {
@@ -371,6 +372,85 @@ class NativeUtilSuite extends CometTestBase {
     }
   }
 
+  test("imports are charged to the import allocator and roll up into the 
root") {
+    // Arrow charges a buffer to whichever allocator owns it, so the tracing 
counters can only
+    // report the import path apart from the rest of Comet's Arrow memory if 
imports go to their
+    // own allocator. The child must still roll up into the root, because 
jvm_arrow_imported is
+    // reported as a subset of jvm_arrow_allocated.
+    val numRows = 4
+    val col = new ConstantColumnVector(numRows, IntegerType)
+    col.setInt(42)
+    val batch = new ColumnarBatch(Array[ColumnVector](col), numRows)
+
+    val nativeUtil = new NativeUtil
+    var imported: ColumnarBatch = null
+    try {
+      val (arrayAddrs, schemaAddrs, _) = 
nativeUtil.exportBatchToAddresses(batch)
+      val vectors =
+        nativeUtil.importVector(
+          arrayAddrs.map(ArrowArray.wrap),
+          schemaAddrs.map(ArrowSchema.wrap))
+      imported = new ColumnarBatch(vectors.toArray, numRows)
+
+      assert(
+        CometArrowAllocator.getChildAllocators.asScala.exists(_ eq 
CometArrowImportAllocator),
+        "the FFI import allocator must be a child of the root, so the root 
keeps reporting the " +
+          "total across both")
+      val importedBytes = CometArrowImportAllocator.getAllocatedMemory
+      assert(
+        importedBytes > 0,
+        "imported buffers were charged somewhere other than the FFI import 
allocator")
+      assert(
+        CometArrowAllocator.getAllocatedMemory >= importedBytes,
+        "the root's total must include imported bytes, otherwise the 
subtraction is meaningless")
+    } finally {
+      if (imported != null) {
+        imported.close()
+      }
+      nativeUtil.close()
+    }
+  }
+
+  test("the import allocator is also charged for the JVM-side cost of 
importing") {
+    // Characterization, not an aspiration: Arrow's importer allocates the 
owning ArrowArray
+    // struct from the import allocator (ArrayImporter calls 
ArrowArray.allocateNew(allocator)),
+    // and loadValidityBuffer allocates a validity bitmap there when an 
imported vector is
+    // all-valid and carries no validity buffer. So the import allocator is 
charged for more than
+    // the imported buffers, which is one half of why jvm_arrow_imported is an 
allocator charge
+    // rather than a measure of where the bytes were allocated. Compared 
against every imported
+    // buffer, not just the data one, so the excess measured here is 
JVM-allocated rather than the
+    // foreign validity buffer.
+    val numRows = 4096
+    val col = new ConstantColumnVector(numRows, IntegerType)
+    col.setInt(42)
+    val batch = new ColumnarBatch(Array[ColumnVector](col), numRows)
+
+    val nativeUtil = new NativeUtil
+    var imported: ColumnarBatch = null
+    val before = CometArrowImportAllocator.getAllocatedMemory
+    try {
+      val (arrayAddrs, schemaAddrs, _) = 
nativeUtil.exportBatchToAddresses(batch)
+      val vectors =
+        nativeUtil.importVector(
+          arrayAddrs.map(ArrowArray.wrap),
+          schemaAddrs.map(ArrowSchema.wrap))
+      imported = new ColumnarBatch(vectors.toArray, numRows)
+
+      val charged = CometArrowImportAllocator.getAllocatedMemory - before
+      val foreign = 
vectors.head.getValueVector.getBuffers(false).map(_.capacity()).sum
+      assert(
+        charged > foreign,
+        s"expected the import allocator to hold more than the $foreign bytes 
of imported " +
+          "buffers, since the importer allocates its own ArrowArray struct 
there, but it held " +
+          s"$charged bytes")
+    } finally {
+      if (imported != null) {
+        imported.close()
+      }
+      nativeUtil.close()
+    }
+  }
+
   test("Variant schema identity round-trips through native Arrow FFI") {
     val variantType = Utils.variantType.getOrElse {
       cancel("VariantType requires Spark 4.0+")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to