Conversation
CometArrowAllocator is a single process-wide RootAllocator with no limit, so the off-heap bytes it hands out were counted by neither Spark's TaskMemoryManager nor Comet's native memory pool, despite being resident in the container and contributing to container OOM kills. Attach an AllocationListener that charges each allocation to a Spark MemoryConsumer belonging to the task that made it. Reservations are grown and shrunk in whole blocks, 1 MiB by default, because Arrow allocates per buffer and acquireExecutionMemory takes locks. Arrow passes a parent's listener down to child allocators, so a single attachment point covers every call site including those that cut child allocators. Report without enforcing. A short grant from Spark is logged and the allocation proceeds, because Arrow allocation on these paths cannot fail today and making it fail is a behavioural change that belongs in its own commit. Do nothing in three cases: when there is no active task, since broadcast coalescing and the cached batch serializer can allocate off a task thread; in on-heap mode, which exists so the Spark SQL suite can run without off-heap memory configured; and for buffers released after their allocating task has finished, since the allocator is process-wide precisely because buffers can outlive their task. Part of apache#5997.
Release reserved blocks in a single call instead of one per block. releaseExecutionMemory synchronizes on the executor-wide pool, so freeing a 64 MiB buffer at the 1 MiB block size took 64 acquisitions of that lock rather than one, which defeated the point of batching reservations into blocks. Round the grow request up to a block multiple. Requesting the bare deficit left reserved exactly equal to usedBytes for any buffer at or above the block size, so the next allocation of any size went straight back into Spark's lock. Reserved is now always a block multiple and growth always leaves headroom. Check for an active task before resolving config. Resolving first meant re-reading SparkEnv on every allocation in any process that never has one, and the task check is both cheaper and eliminates more callers. Declare the accounting toggle in CometConf so it reaches the generated configuration docs and follows the conventions every other spark.comet.* key follows, and demote the block size to a private constant rather than an undocumented key that would become a permanent compatibility surface. Collapse the loop in allocated() that could never iterate twice, move warn-once to the companion so TaskReservation no longer holds a back-reference to its parent, and route the on-heap test through the shared fixture.
Arrow's BaseAllocator.wrapForeignAllocation reports an imported buffer to the allocator's listener at full capacity even though no JVM-side allocation happened. Comet imports every batch from native execution through that path, so charging the root allocator's listener counted memory the native side owns and frees as though it were JVM Arrow memory. That double counted whatever an operator had already reserved in Comet's native pool, and the error grew in proportion to batch throughput, which is the opposite of what this accounting is for. Import through CometImportedArrowAllocator, a child allocator with no listener. Arrow notifies only the allocating allocator's own listener and never its ancestors, so the imported bytes stay out of Spark's accounting while reference counting and lifetime are unchanged. The two import sites are NativeUtil and CometUdfBridge; export, IPC and materialisation paths keep the charging root. Add a test pinning both halves of the mechanism: a child with no listener is not charged, and the same root still charges for allocations made directly against it.
The inventory table and the observation below it both stated that CometArrowAllocator was accounted by nobody, which the listener has changed. Update the row, rewrite the observation to say that allocations are reported to Spark but the allocator is still unbounded, and soften the Open problems entry to match: no longer invisible, still uncapped. Add the imported-buffer distinction as its own observation and its own inventory row. A reader who learns the allocator is accounted would otherwise reasonably assume FFI imports are too, and they are deliberately excluded because charging them would double count memory Comet's native pool already reserved.
The doc string for spark.comet.arrowAllocator.accounting.enabled left a 103 character line, which failed scalastyle and took every downstream CI job with it. scalafmt cannot split a string literal, so the segments themselves are shortened rather than reindented.
sunchao
left a comment
There was a problem hiding this comment.
Correctness
This closes an accounting gap in the process-wide JVM Arrow allocator. Previously its buffers were outside Spark's execution-memory budget. The PR adds a per-task MemoryConsumer, batches reservations in 1 MiB blocks, and introduces a default-enabled switch. It also routes native FFI imports through a no-listener child allocator so wrapping native-owned buffers does not create another JVM reservation. Arrow 18.3.0's listener dispatch supports that separation.
I reviewed 4d7657d4 against 58ab5f61, including the existing stream, cache, broadcast, Python and JVM-UDF ownership paths. Maintained Spark 3.5 and 4.0 confirm that memory acquisition can spill, throw and call consumers while holding the task-memory-manager lock. Three findings need addressing: [P1] reversed lock ordering can deadlock concurrent Arrow/native requests, [P2] releases use the releasing thread's task instead of the allocation owner, and [P2] a spill exception can escape after Arrow creates a buffer and leak that buffer. The inline comments include the triggers and regression cases.
The local component probe compiled a listener byte-for-byte identical to this head and ran against locally cached Spark 4.0.0 JVM artifacts and real Arrow 18.3.0 buffers on JDK 17. It used Spark's TestMemoryManager, an injected failing spill consumer, deterministic thread barriers and a stub for the Comet config key/default accessor. It reproduced all three boundaries. This is component evidence, not an end-to-end Comet/native-JNI run, and those runtime jars are not claimed to contain the maintained Spark commits. Maintained Spark 3.4/4.1 sources were unavailable.
At September 17, 16:15 UTC, CI was still running, with 17 successful checks, three failed lint checks, 13 skips and three checks in progress. The Spark 4.1/JDK 17 build passed, resolving the earlier line-length gate. The Spark 3.4, 3.5 and 4.0 JDK 17 lint logs fail Scalafix because CometImportedArrowAllocator needs the explicit BufferAllocator result type. These logs check out merge 579794d6, whose parents are the exact base/head and whose tree equals the head. Spark execution/expression checks and TPC-DS verification were still in progress, so this is not a complete green product-validation result.
Performance
Block-sized growth and returning excess in one call avoid repeated pool operations while allocations fit in an existing reservation. The null-task fast path and once-per-JVM config lookup also keep unnecessary work out of driver-side allocation. Every accounted buffer still adds a task-context lookup, concurrent-map lookup and reservation monitor acquisition. A short grant causes later allocations to retry Spark, and freeing back to zero means the next small allocation requests another block.
Because this is enabled by default and consumes real off-heap budget, please include an enabled/disabled microbenchmark covering small FFI buffers, representative wide batches and concurrent native reservations under pressure. Report allocation/release throughput, Spark reservation-call counts and spill behavior. The PR contains no timing evidence, and I have not measured a speedup or regression. The ownership and lock-order findings must be fixed before such a comparison is meaningful.
Design
Keeping FFI imports separate from JVM-owned allocation is the right boundary. The behavior change is more than visibility: reservations reduce the headroom available to other Spark consumers and can make them spill earlier, even though a short grant does not reject the Arrow allocation. The updated guide and PR body make this budget impact explicit, and the opt-out makes that behavior reversible. It does not establish a hard native-memory or RSS limit, and native spill support remains outside this PR.
The remaining design requirement is stable ownership across callbacks. A task-bound listener/allocator or equivalent ownership token should survive cross-thread release and become inert after task cleanup. Reading the current TaskContext independently in each callback cannot provide that contract. Please extend the tests to exercise actual task completion, late releases, failed acquisitions and concurrent native/Arrow reservations. The current fixture calls cleanUpAllAllocatedMemory directly and does not execute the registered completion listener.
Abstraction & complexity
A small listener plus one reservation object per task is a reasonable scope, and a private block-size constant avoids adding another tuning knob without evidence. Registering completion callbacks outside computeIfAbsent correctly avoids recursive map updates when Spark invokes a late listener immediately.
The single shared listener currently mixes allocation-owner discovery, task registration, reservation tracking and release attribution. Separating stable owner binding from byte accounting would remove the need to rediscover a task on release and make cleanup behavior easier to test. The usage snapshot should also avoid introducing a second lock order into Spark's existing memory-consumer protocol.
| * consumer never calls `acquireMemory` or `allocatePage`; Arrow has already obtained the | ||
| * memory and we are only accounting for it. | ||
| */ | ||
| override def getUsed: Long = synchronized(usedBytes) |
There was a problem hiding this comment.
Correctness
[P1] Could getUsed expose a lock-free snapshot instead of taking the reservation monitor? allocated holds this monitor while entering TaskMemoryManager.acquireExecutionMemory, while Spark 3.5/4.0 hold the task-memory-manager monitor when calling consumers' getUsed during a short grant. A concurrent native reservation through CometTaskMemoryManager can therefore hold the Spark monitor and wait here while the Arrow allocation holds this monitor and waits for Spark. Comet's shared Tokio execution permits those requests to overlap. A two-thread component probe using the unchanged listener and the real Spark task memory manager reproduces the lock cycle. Please establish a consistent lock order and add a bounded concurrent-reservation regression test.
There was a problem hiding this comment.
Yes, and it does now. getUsed reads an AtomicLong and a volatile flag, and spill returns 0 without touching anything, so neither waits on the reservation monitor. That matches what CometTaskMemoryManager.NativeMemoryConsumer already does, and for the same reason. The lock order is now stated on the class: the reservation monitor may be held across acquireExecutionMemory, so nothing Spark can call while holding the TaskMemoryManager monitor is allowed to need it.
The regression test is deterministic rather than a stress loop. It holds the listener's monitor on the test thread and calls getUsed and spill from another one with a timeout, which fails outright if either becomes synchronized again. There is a bounded two-thread Arrow/native reservation test alongside it, but the monitor probe is the one that actually pins the property.
| } | ||
|
|
||
| override def onRelease(size: Long): Unit = { | ||
| val reservation = reservationForCurrentTask() |
There was a problem hiding this comment.
Correctness
[P2] Can releases be associated with the allocating task rather than the task currently on the releasing thread? The existing JVM UDF path installs a task context, allocates/exports JVM Arrow buffers, then restores or unsets the context before Rust retains the exported result through from_ffi. Its later release callback can run on a Tokio thread without a task context, so this lookup ignores the release while the allocating task is still running. Repeated batches leave reservations charged for memory already freed and can starve the native pool. In a component probe, allocating 1 MiB under task A and closing it without a context leaves A charged 1 MiB with zero Arrow bytes alive. Closing A's buffer under task B instead subtracts B's accounting. The size-only callback cannot recover ownership from the current task. Please bind the listener/accounting to the allocation owner and cover cross-thread release plus release after task completion.
There was a problem hiding this comment.
They can, but not by looking harder at the callback, since Arrow gives it nothing but a size. The fix is to stop asking the thread and make the allocator the owner: CometTaskArrowAllocator.forCurrentTask() now hands each task a child of the root carrying its own listener instance, so onAllocation and onRelease both land on the allocating task no matter which thread drops the last reference. That is the same shape Gluten uses. BaseAllocator.newChildAllocator passes the listener down, so the Python runner and CometNativeArrowSource, which cut their own children, are covered too.
Falling out of that: the root no longer carries a listener at all, so FFI imports are unaccounted simply by using it, and CometImportedArrowAllocator is gone. Off a task and in on-heap mode the accessor returns the root, so those paths behave exactly as they do on main.
The awkward part is lifetime. The process-wide allocator exists because buffers can outlive their task, and Arrow treats closing a non-empty allocator as a leak, so at task completion the reservation is dropped and the allocator is closed only if it is drained; otherwise it is parked and reaped by a later task. It cannot just be left open, because BaseAllocator keeps every child in a map until it closes.
Tests cover a release with no task context, a release under a different task, completion driven through markTaskCompleted rather than cleanUpAllAllocatedMemory, and a buffer outliving its task and being reaped afterwards.
| // Requesting the bare deficit would land exactly on `usedBytes` for any buffer at or above | ||
| // the block size, sending the very next allocation straight back into Spark's lock. | ||
| val request = roundUpToBlock(usedBytes - reserved) | ||
| val granted = taskMemoryManager.acquireExecutionMemory(request, this) |
There was a problem hiding this comment.
Correctness
[P2] Please keep potentially throwing Spark acquisition out of Arrow's onAllocation callback. This call can invoke another consumer's spill method, and maintained Spark 3.5/4.0 propagate spill interruption or I/O failure as exceptions. Arrow 18.3.0 sets success = true and creates the buffer before invoking onAllocation, so an exception here escapes without returning or closing that buffer. With the unchanged listener, a 1 MiB pool occupied by a spill consumer that throws InterruptedIOException makes root.buffer(1 MiB) throw RuntimeException while the root still owns 1 MiB that the caller never received. Closing the allocator then reports that leak. Please move fallible work to a safe allocation boundary with rollback, and add a failing-spill/cancellation test that checks both Arrow live bytes and Spark reservations.
There was a problem hiding this comment.
Agreed, and this is worse than "should not throw": Arrow's AllocationListener javadoc says an exception cannot be thrown from onAllocation or onRelease, and BaseAllocator.buffer sets success = true before the call, so a throw skips the finally { releaseBytes } and the buffer is neither returned nor freed.
Rather than move the acquisition to a different boundary, the listener now simply never lets anything out. Both callbacks wrap the memory-manager call and log once. SparkOutOfMemoryError is caught explicitly because it extends OutOfMemoryError and so slips past NonFatal, which is exactly the IOException-from-spill path you describe.
Two tests, one for each way trySpillAndAcquire fails: a consumer holding the only block that throws IOException, and one that throws InterruptedIOException. Both assert the Arrow allocation succeeds, that the allocator reports the buffer's bytes while it is open and zero after closing it, and that nothing was reserved, which is what proves the throw really happened rather than the acquisition quietly succeeding and making the test vacuous.
There was a problem hiding this comment.
You are right, and it is worse than the NonFatal gap alone. ExecutionMemoryPool.acquireMemory parks in lock.wait() when a task is below its fair share, so a kill raises a plain InterruptedException straight out of acquireExecutionMemory, and scala.util.control.NonFatal excludes InterruptedException by name along with the Error subclasses.
It is caught now, but not simply swallowed: the handler re-arms the thread's interrupt flag before returning. Arrow has already created the buffer by the time onAllocation runs, so returning normally is the only option that does not lose it, and re-arming leaves the cancellation for the task to observe at its next interruptible point, which is the only place it can act on it anyway.
The regression test injects the InterruptedException through a failing spill rather than through the pool, since TestMemoryManager never parks, and asserts all three things: the allocation succeeds, the allocator is back to zero after closing the buffer, and Thread.interrupted() is true afterwards.
scalafix's ExplicitResultTypes rule requires a declared type on new public members, and CometImportedArrowAllocator was inferred. The rule's own suggested patch was to annotate it as BufferAllocator and import that type, which is what this does; the import is merged into the existing Arrow import rather than added separately, which scalafix accepts since the rule only requires the annotation to exist. This escaped local checks because scalafix runs only in the Lint Java jobs, under -Psemanticdb for Spark 3.4, 3.5 and 4.0 on JDK 17, and is not part of the default build or of spotless. Verified by running the CI invocation locally: ./mvnw package -DskipTests scalafix:scalafix -Dscalafix.mode=CHECK -Psemanticdb -Pspark-3.5 -Pscala-2.12
sunchao
left a comment
There was a problem hiding this comment.
Rechecked a2518d97 against 58ab5f61. Since the previous review, the only change is the explicit BufferAllocator result type and import. This fixes the Scalafix failures without changing allocation or release behavior.
All three findings remain: [P1] lock inversion, [P2] release attribution to the wrong task, and [P2] leaked buffers when Spark acquisition throws inside onAllocation. The listener, relevant callers and tests are unchanged. I rechecked the JVM-UDF release path, native reservation concurrency and task completion against maintained Spark 3.5/4.0. The prior component reproductions remain applicable by verified source/dependency identity. No new inline findings.
At September 17, 17:20 UTC, CI is green. The three previously failing lint jobs pass, and the execution log confirms all six listener tests passed within 922 successful tests. The verified checkout is merge d00e8133, with the exact base/head as parents and the same tree as the head. The listener tests still do not exercise concurrent reservation, cross-thread release or failed acquisition. The earlier enabled/disabled benchmark request also remains unanswered. No new local runtime or timing measurements were needed for this type-only change.
|
Triage note: #5027 overlaps with this on the UDF output path. It cuts a per-task Arrow child allocator backed by a non-spillable @peterxcli, worth deciding which layer owns those buffers. My instinct is that the root listener here covers everything by default and the per-task consumer in #5027 should replace it on the UDF path rather than stack on top, but I have not tried writing that. |
Arrow hands `AllocationListener` nothing but a size, and it reports a release on whichever thread drops the last reference. Reading `TaskContext` inside the callbacks therefore lost every release that happens off the allocating task's thread, the JVM UDF export path being the clear case: it exports a JVM-owned vector and Rust drops it later from a Tokio worker with no task context installed. Batch after batch, that left the task charged for memory it had already freed, and a release arriving under a different task subtracted from that task instead. Bind one listener to one task's allocator instead, the way Gluten does. `CometTaskArrowAllocator.forCurrentTask()` cuts a child of the root per task; Arrow passes the listener down to children, so the paths that make their own children are covered too. The root keeps no listener, which is what FFI imports want, so the separate imported-buffer allocator is no longer needed. Off a task, and in Comet's on-heap mode, callers get the root and nothing is reported, exactly as before. Two further fixes to the listener itself: - `getUsed` and `spill` are now lock-free. Spark calls both while holding the `TaskMemoryManager` monitor, and the listener holds its own monitor across `acquireExecutionMemory`, so a native reservation arriving through `CometTaskMemoryManager` could hold Spark's monitor and wait for ours while an Arrow allocation on the same task did the reverse. - Neither callback propagates an exception. Arrow documents that they may not, and `BaseAllocator.buffer` marks the allocation successful before calling `onAllocation`, so a throw loses the buffer Arrow has already created. Spark's acquisition is fallible: it runs other consumers' `spill`, which turns a task interrupt into a `RuntimeException` and an I/O failure into a `SparkOutOfMemoryError`. A task allocator cannot simply be closed at task end, because the process-wide allocator exists precisely so buffers can outlive their task and Arrow treats closing a non-empty allocator as a leak. At completion the reservation is dropped and the allocator is closed if it is drained; otherwise it is parked and reaped by a later task.
The old fixture called `cleanUpAllAllocatedMemory` directly and never ran the registered completion listener, so nothing exercised what happens at the end of a task. It now goes through `markTaskCompleted`, and the suite adds the cases the per-task binding exists for: - a release on a thread with no task context, and a release arriving under a different task, are both charged to the task that allocated - completion drops the whole reservation and closes the allocator - a buffer outliving its task parks the allocator until it is released, then a later task reaps it - a spill that fails with an I/O error or an interrupt neither fails the Arrow allocation nor leaks the buffer Arrow already created - `getUsed` and `spill` do not wait on the reservation monitor, probed by holding that monitor and calling both from another thread - concurrent Arrow allocations and native-style reservations both finish Also covers what the root allocator is for now: allocating from it inside a task is not reported, which is what keeps FFI-imported buffers out of Spark's accounting, and a child of a task allocator is reported to that task, which is what covers the Python runner and the native Arrow source.
Accounting is on by default, so the overhead needs a number rather than an assurance. Each case runs the same allocate/release loop against a plain `RootAllocator` and against a task allocator, over the three buffer shapes Comet actually produces, and once more with a second thread reserving from the same constrained pool the way the native side does. Reservation call counts are printed per iteration, because how often a buffer crosses a block boundary matters more than the per-buffer bookkeeping.
|
Benchmark numbers for the accounting overhead, as asked for. Committed as Each case runs the same allocate-then-release loop against a plain
Reading it:
On spill behaviour: the accounted consumer never spills, |
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed d591b8f2 against 58ab5f61. The per-task allocator fixes the P1 lock inversion and P2 release attribution. Rerunning the original failure probes and current-source controls confirms both fixes. The added benchmark addresses the request for accounting-on/off and contended-pool measurements. Its timings remain author-reported.
The P2 callback-exception finding is partially fixed but remains for plain InterruptedException. Interrupting a thread blocked in Spark's real execution-memory pool reaches lock.wait(). That exception is excluded by NonFatal and escapes onAllocation. With the current listener and real Arrow, the caller receives no buffer while the allocator retains 1 MiB. Releasing the competing reservation instead is a passing control. Could this cancellation boundary preserve Arrow's allocation lifecycle as well as cancellation semantics?
Two additional P2 findings are inline: a partial Spark grant is lost when a later spill throws, and exported JVM-owned UDF buffers retain their JVM charge while a native hash join reserves the same buffers. The former is reproduced through real Spark TaskMemoryManager. The latter combines a real Arrow C Data/Java bridge component check with the pinned DataFusion/Arrow ownership trace. I independently repeated these component checks, but did not run a full Comet JNI query.
At 2026-09-17 20:42 UTC, CI has 22 successful checks, 13 skipped and the Spark 4.1 expressions job still running, with no failures reported. Its checkout is merge 7c0cf107 over 3b942e59, rather than the assigned base. All 17 PR-authored files are identical to the reviewed head, but this is not exact-base CI. Maintained Spark 3.5/4.0 sources were inspected. Required 3.4/4.1 branches are unavailable locally.
| val granted = taskMemoryManager.acquireExecutionMemory(request, this) | ||
| reserved += granted |
There was a problem hiding this comment.
Correctness
[P2] Account for a partial grant when Spark's later spill attempt throws
TaskMemoryManager.acquireExecutionMemory can acquire some bytes from the execution pool and then throw while asking another consumer to spill. In that case it never returns granted, so reserved stays unchanged, while adjustQuietly swallows the exception and lets the Arrow allocation succeed. With a 2 MiB pool, another consumer holding 1 MiB, and a 2 MiB Arrow request, I reproduced this with both IOException and InterruptedIOException: after closing the Arrow buffer and freeing the other consumer, Arrow and reservedBytes are zero but Spark still charges the task 1 MiB. taskCompleted() cannot return that unrecorded grant. It lasts until Spark's final task cleanup, reducing headroom for the rest of the task. Could acquisition failure reconcile or roll back partial grants? A regression with partially available capacity would cover this. The current full-pool failure tests only exercise a zero-byte initial grant.
There was a problem hiding this comment.
Confirmed, and thank you for separating this from the throw itself — I had only fixed the escape, not the accounting behind it. acquireExecutionMemory takes its first grant from the pool before it asks anyone to spill, so on the throw path it has charged the task for bytes it never returns, and since we swallow the exception the task carries on without them for the rest of its life.
Rolling back is not possible from outside, because Spark never tells us how much it took. So acquire adopts them instead, measured as the change in getMemoryConsumptionForThisTask across the call. That is an estimate, but a safe one in the direction that matters: acquireExecutionMemory holds the TaskMemoryManager monitor for its whole duration, so no other consumer in the task can acquire concurrently and inflate the figure, and a concurrent release can only deflate it, which degrades to today's behaviour. The request bounds it from above either way.
The new test is the partially-available case you describe — a two-block pool, one block held by a consumer that throws, and a two-block request. It asserts a block is reserved immediately after the allocation, and that after closing the buffer the task is charged only for the other consumer's block rather than for two.
| */ | ||
| def allocateOutput(field: Field, numRows: Int, estimatedBytes: Int): FieldVector = { | ||
| // JVM-owned codegen output, accounted to the task that is running the kernel. | ||
| val allocator = CometTaskArrowAllocator.forCurrentTask() |
There was a problem hiding this comment.
Correctness
[P2] Give exported JVM output a single Spark reservation owner
This charges codegen output to the task allocator, but CometUdfBridge exports that vector through C Data and closes only its original reference. Export retains the same buffers and their JVM charge until native code releases them. With a unified native memory pool, the existing DataFusion 55.1.0 hash-join build path then counts and reserves the incoming batch's buffers through Comet's unified pool, charging the same Spark task again. Arrow's foreign-buffer capacity is nonzero. The custom allocation retains its size. I verified the export lifetime with real Arrow C Data export/import and the current Java memory bridge: an unchanged data address retained a 2 MiB JVM reservation while the corresponding 1,064,960-byte native-style reservation also succeeded. This is a component reproduction plus a source trace, not an end-to-end join run. Under a bounded pool, this reduces available headroom twice for one allocation and can reject the join build. Please transfer or otherwise coordinate reservation ownership at export and cover JVM-UDF output feeding a native hash join. This path already exists without unmerged #5027.
There was a problem hiding this comment.
Agreed, and the fix is to stop charging it here rather than to coordinate a handoff, which is not something this PR can do from one side of the boundary.
The rule is now that native's pool is the authority for bytes native holds, in both directions. Imports already used the listener-less root; exports now join them, so NativeUtil, the JVM UDF result and CometNativeArrowSource.stream all allocate from the root, while IPC reads, codegen output, the cached batch serializer and CometNativeArrowSource.readerBatchIter keep the task allocator. Those three sites exist only to hand bytes to native, so the JVM-side charge was pure duplication with no information in it.
The part I want to be explicit about rather than have you find it: this is a split by allocation site, so it is not exact. A buffer used in the JVM and only later handed to native is still charged on both sides, and a shuffle-read batch feeding a native operator through exportBatch is the common shape, not a corner. The allocation site cannot know where the batch ends up. That residual is written into the guide's open problems next to the existing entry about buffer and reservation lifetimes being independent across the boundary, and it is the same problem: which side owns a reservation is decided by where the bytes were allocated rather than by who is holding them. Closing it properly is a change on both sides, so it belongs with the rest of #5997 rather than here.
There was a problem hiding this comment.
Confirmed, and you are right that the previous revision only moved the exporter. CometUdfBridge switched to the root for its import and its Data.exportVector, but the output buffers were allocated before either of those, and CometBatchKernelCodegenOutput.allocateOutput still selected the task allocator. So the JVM charge stood while native reserved the same buffers.
It allocates from the listener-less root now. The only production caller is CometScalaUDFCodegen.evaluate, which is the only CometUDF implementation, and its result is consumed solely by CometUdfBridge, which exports it over the C Data Interface and then closes its own reference. Every buffer this method hands out is therefore allocated to be handed to native, which puts it under the same rule as NativeUtil, the imported inputs and CometNativeArrowSource.stream: native's pool is the authority for bytes native holds, so charging them here reserved the same memory twice.
That should close the 2 MiB JVM reservation you measured alongside the 1,064,960-byte native one, since nothing on the JVM side reserves those buffers any more. The contributor guide listed codegen output under the task allocator and is corrected.
One side effect worth recording: this also unstacks the overlap with #5027. With codegen output no longer charged here, the per-task consumer in that PR can own the UDF output path outright rather than adding a second charge on top of this one.
Latest main is merged in, which clears the conflict that was holding CI to the label check.
Two more findings from review. **Spark's acquisition can fail in ways `NonFatal` does not catch, and can lose memory when it does.** `ExecutionMemoryPool.acquireMemory` parks in `lock.wait()` when a task is below its fair share, so killing a task raises a plain `InterruptedException` out of `acquireExecutionMemory` — and `NonFatal` deliberately excludes it, so it escaped `onAllocation` and Arrow lost the buffer it had already created. It is now caught, with the thread's interrupt flag re-armed rather than swallowed, so the cancellation is still there for the task to observe. Separately, `acquireExecutionMemory` takes its first grant from the pool and only then asks other consumers to spill, so when a spill throws it has already charged the task for bytes it never reports back. Nothing would have released them before the final task cleanup. `acquire` now adopts them, measured as the change in what the pool says the task holds — an estimate, but one that can only come out too small, because `acquireExecutionMemory` holds the `TaskMemoryManager` monitor for its whole duration so no concurrent acquire can inflate it. **Buffers crossing the FFI boundary are no longer charged on the JVM side.** Native's pool is the authority for bytes native holds: whichever DataFusion operator retains a batch reserves those same buffers through Comet's unified pool, which charges the same Spark task. Charging them here as well reserved the same memory twice and could reject an allocation that fits — a hard failure this PR has no business introducing. So `NativeUtil`, the JVM UDF result and `CometNativeArrowSource.stream` join the imports on the unaccounted root, while IPC reads, codegen output, the cached batch serializer and `CometNativeArrowSource.readerBatchIter` keep the task allocator. The split is by allocation site, so it is not exact: a buffer used in the JVM and only later handed to native, a shuffle-read batch feeding a native operator being the common shape, is still charged on both sides. Closing that needs reservation ownership handed over at the boundary, which is a change on both sides of it. Recorded in the guide's open problems.
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed 054fe9a2 against 58ab5f61. The P2 interruption leak is fixed: rerunning the real Spark pool-wait case returns the Arrow buffer, preserves the interrupt flag and releases the buffer cleanly. The original P2 partial-grant leak also passes with both I/O-failure variants. The new recovery method introduces a different P2 race, described inline: it can adopt and release another consumer's reservation because the before/after snapshots are outside Spark's acquisition lock.
The P2 UDF export ownership finding remains on the original codegen path. CometUdfBridge now uses the root for imports and Data.exportVector, but udf.evaluate(inputs, numRows) still reaches CometScalaUDFCodegen → CometBatchKernelCodegenOutput.allocateOutput, which selects the task allocator at line 91. The export allocator does not transfer the result buffers' owner. I independently reran the C Data/Java bridge check, which still retains the 2 MiB JVM charge alongside the 1,064,960-byte native-style reservation for the same buffers. Could the actual output allocation or ownership handoff be changed? This is the originally reported UDF path, separate from the acknowledged shuffle-read limitation.
At 2026-09-17 23:11:48 UTC, CI is green with 24 successful checks and 14 skips. The execution log confirms all 18 listener tests within 944 successful tests. CI checked out f4c45949 over d0c10f2d, not the assigned base. The listener and its tests match the reviewed head. I independently reran the original failure cases, the new race and its control. These local checks used cached Spark 4.0.0/Arrow 18.3.0 components and real Arrow C Data, without a full Comet JNI query. Maintained Spark 3.5/4.0 sources were checked. Required 3.4/4.1 branches remain unavailable.
| val heldBefore = taskMemoryManager.getMemoryConsumptionForThisTask | ||
| try { | ||
| taskMemoryManager.acquireExecutionMemory(request, this) |
There was a problem hiding this comment.
Correctness
[P2] Keep both usage snapshots inside the acquisition transaction
heldBefore is read before acquireExecutionMemory takes the task-manager monitor, and adoptOrphanedGrant reads again after that monitor has been released on the exception path. Another consumer can therefore acquire between either snapshot and the protected call. This lets the listener adopt and later release someone else's reservation, rather than only underestimate its own grant. With the exact current listener and real Spark UnifiedMemoryManager/TaskMemoryManager, I paused after the real before-snapshot, let another OFF_HEAP consumer take the 1 MiB pool, and then let Arrow's zero-grant acquisition trigger that consumer's failing spill. The listener adopted 1 MiB despite receiving no grant. Closing the Arrow buffer reduced Spark's charge to zero while the other consumer still owned 1 MiB. A control that acquired before the snapshot retained the correct 1 MiB charge. Could failure recovery use an atomic, consumer-specific accounting boundary and add this interleaving as a regression? Bounding the result by request does not establish that those bytes belong to this listener.
There was a problem hiding this comment.
Confirmed, and the argument I gave for the old code being safe was wrong in exactly the place you point at: the monitor is held for the whole of acquireExecutionMemory, but heldBefore was read before entering it, so the window was open regardless of what happens inside.
There is no consumer-specific figure to read instead. getMemoryConsumptionForThisTask is the whole task, and this consumer's inherited used stays at zero by design, because it never calls acquireMemory — Arrow has already taken the memory and we are only accounting for it. So what makes the task-wide delta attributable has to be the boundary rather than the quantity: acquire now takes the TaskMemoryManager monitor itself, and both snapshots and the acquisition run inside it. That is the same monitor acquireExecutionMemory takes and holds for its whole duration, spills included, and it is reentrant, so this only widens the window to cover the two reads. Every acquisition in the task funnels through that method, so nothing else in the task can take memory in between and have it adopted here.
What the monitor does not cover is a release, which reaches the pool without it. Another consumer returning memory, or a spill that frees some bytes before throwing, still makes the figure too small. That is the safe direction and degrades to what would have happened anyway, and request still bounds it from above.
The regression is your interleaving, made deterministic rather than timed. A TaskMemoryManager subclass runs a second consumer immediately after the real before-snapshot, which is the pause you described, and then waits until that consumer is either blocked or finished, so the two orderings are told apart by thread state rather than by sleeping. The assertion is that nothing is claimed twice: what the listener adopted, plus what the other two consumers hold, equals what the task holds. I ran it against the unfixed listener first, where it fails with 2099200 adopted against a task total of 2098176 — the interloper's 1024 charged to the listener on top of its own grant — and green afterwards.
The lock order note on the class is updated too, since the listener now takes Spark's monitor explicitly: ours before Spark's, never the reverse, which is why getUsed and spill have to stay lock-free.
The orphaned grant adopted after a failed acquisition was measured with a snapshot taken before acquireExecutionMemory took the TaskMemoryManager monitor, and a second one read after that monitor had been released on the exception path. Another consumer in the same task could acquire in either window, so the listener could adopt bytes it never received and later hand them back, leaving two consumers holding the same bytes between them. Both snapshots and the acquisition now run as one transaction under that monitor. It is the same monitor acquireExecutionMemory itself takes and holds for its whole duration, and it is reentrant, so taking it here only widens that window to cover the two reads. Every acquisition in the task funnels through that method, so no other consumer can take memory in between and have it adopted here. A release does not take the monitor, so a concurrent release, or a spill that frees some bytes before throwing, can still deflate the figure, which is the safe direction. The regression test drives that interleaving deterministically. A hooked TaskMemoryManager runs another consumer immediately after the real snapshot, and the test asserts that what the listener adopted plus what the other two consumers hold equals what the task actually holds. Without the transaction it fails, adopting 2099200 bytes against a task total of 2098176.
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed c2a542d6 against 58ab5f61. The P2 orphan-grant race is fixed: both snapshots now share Spark’s acquisition monitor. I reran the old-head failure and current-head control with real Spark memory managers. The competing consumer now blocks, and closing Arrow releases only its own reservation. The earlier interruption, partial-grant, cross-thread release and task-completion checks still pass.
The P2 UDF export ownership finding remains. The output allocation still selects the task allocator, and the C Data/Java bridge probe still retains the 2 MiB JVM reservation alongside the 1,064,960-byte native-style reservation for the same buffers. The exporter’s root allocator does not transfer ownership. No new findings or duplicate inline comments.
Current-head CI has only the label check; GitHub reports merge conflicts and no merge commit. Local validation used cached Spark 4.0.0/Arrow 18.3.0 components with exact Comet sources, not a full Comet JNI query. Maintained Spark 3.5/4.0 sources were checked. Required 3.4/4.1 branches remain unavailable.
…nting # Conflicts: # spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala
CometBatchKernelCodegenOutput.allocateOutput charged its vector to the task allocator, but every buffer it hands out is exported to native. Its only production caller is CometScalaUDFCodegen.evaluate, whose result CometUdfBridge exports over the C Data Interface before closing its own reference, so the buffers stay alive on the native side. Whichever DataFusion operator retains that batch reserves the same buffers through Comet's unified pool, which charges the same Spark task, so the JVM-side charge was reserving the same memory twice and could reject an allocation that fits. This is the rule the rest of the PR already follows: native's pool is the authority for bytes native holds, so anything allocated to be handed straight to native uses the listener-less root. Bring the contributor guide in line.
Which issue does this PR close?
Part of #5997. This is the first of the four items listed there, and the only one that needs no native changes.
Rationale for this change
CometArrowAllocatoris a single process-wideRootAllocator(Long.MaxValue). Child allocators are cut from it for FFI stream export, broadcast coalescing,CometSparkToColumnarExec, the cached batch serializer, codegen output, and the Python runner. Every byte it hands out is real off-heap memory resident in the container, and none of it is visible to Spark'sTaskMemoryManageror to Comet's native memory pool. The memory management guide already lists this as an open problem: "unbounded and accounted by nobody".That makes these bytes a blind spot exactly when it matters, which is when an executor is killed for exceeding its container limit. Gluten solves the same problem by giving each task its own Arrow allocator, whose listener reserves from Spark in fixed-size blocks.
This PR closes the reporting half of the gap. It deliberately does not close the enforcing half: Arrow allocation on these paths cannot fail today, and making it fail is a behavioural change that deserves its own PR once we have numbers on what the real volumes are.
What changes are included in this PR?
The owner of a buffer is the allocator, not the calling thread. Arrow hands
AllocationListenernothing but a size, and it reports a release on whichever thread drops the last reference. For anything exported over the C Data Interface that is a Tokio worker with no task context installed, so attributing byTaskContextwould silently drop those releases and leave a task charged, batch after batch, for memory it had already freed. Everything below follows from that.CometTaskArrowAllocator.forCurrentTask()hands each task a child ofCometArrowAllocatorcarrying its ownCometArrowAllocationListener.BaseAllocator.newChildAllocatorpasses the listener down, so the call sites that cut their own children — the Python runner's stdin/stdout allocators andCometNativeArrowSource— are accounted without knowing anything about this. Every production allocation of a JVM-owned buffer now goes through the accessor.CometArrowAllocationListeneris one instance per task and never looks atTaskContext. It charges to aMemoryConsumerbelonging to its task, so the bytes appear inTaskMemoryManager.showMemoryUsageand are arbitrated against Spark's other off-heap consumers, and both callbacks land on the allocating task whichever thread they arrive on.BaseAllocator.wrapForeignAllocationreports an imported buffer to the listener at full capacity even though no JVM-side allocation happened. Going out, whichever DataFusion operator retains the batch reserves those buffers itself, through a unified pool that charges the same Spark task, so charging them here as well would reserve the same memory twice and could reject an allocation that fits.NativeUtil, the JVM UDF result inCometUdfBridgetogether with the codegen output vector it exports, andCometNativeArrowSource.streamtherefore all use the root; IPC reads, the cached batch serializer andCometNativeArrowSource.readerBatchIteruse the task allocator. The same fallback covers the driver, broadcast coalescing and the cached batch serializer, which have no task to charge, and Comet's on-heap mode, where charging an off-heap consumer would be wrong. This replaces the separateCometImportedArrowAllocatorfrom the first revision, which is no longer needed.exportBatchis the common shape rather than a corner case. The allocation site cannot know where the batch ends up. Fixing that means handing reservation ownership over at the boundary, which is a change on both sides of it, so it is recorded in the guide's open problems and left to Improve memory accounting: bound the JVM Arrow allocator and make native reclaim work #5997.acquireExecutionMemorytakes an executor-wide lock. The grow request is rounded up to a block multiple, and excess is returned in a single call rather than one per block.getUsedandspillare lock-free. Spark calls both while holding theTaskMemoryManagermonitor, and the listener holds its own monitor acrossacquireExecutionMemory, which takes that monitor. A native reservation arriving throughCometTaskMemoryManageron a Tokio thread would otherwise deadlock against an Arrow allocation on the same task.CometTaskMemoryManager.NativeMemoryConsumeralready reads anAtomicLongfor the same reason.AllocationListenerdocuments that they may not, andBaseAllocator.buffersetssuccess = truebefore callingonAllocation, so a throw loses the buffer Arrow has already created and never hands back. Spark's acquisition is fallible in three ways, and only the first is caught byNonFatal: it runs other consumers'spill, andTaskMemoryManagerturns a task interrupt into aRuntimeExceptionand anIOExceptioninto aSparkOutOfMemoryError; andExecutionMemoryPool.acquireMemoryparks inlock.wait()when a task is below its fair share, so killing a task raises a plainInterruptedExceptionhere. An interrupt is caught with the thread's flag re-armed, so the cancellation is left for the task to observe rather than swallowed.acquireExecutionMemorytakes its first grant from the pool and only then asks other consumers to spill, so when a spill throws it has already charged the task for bytes it never reports back, and nothing would release them before the final task cleanup. They are adopted, measured as the change ingetMemoryConsumptionForThisTaskacross the call. Spark reports that figure per task rather than per consumer, so both snapshots and the call run as one transaction under theTaskMemoryManagermonitor, which is the same reentrant monitoracquireExecutionMemoryitself takes and holds for its whole duration. Every acquisition in the task funnels through that method, so no other consumer can take memory between the snapshot and the call and have it adopted here. A release does not take that monitor, so a concurrent release, or a spill that frees some bytes before throwing, can still make the figure too small, which is the safe direction: too small is what would have happened anyway.BaseAllocatorkeeps every child in a map until it closes.spark.comet.arrowAllocator.accounting.enabled(default true) turns the reporting off, in which caseforCurrentTask()returns the root and behaviour is exactly what it is onmain. It is declared inCometConfso it reaches the generated configuration docs, and read from theSparkConfbecause the decision is made on executors, whereSQLConfdoes not carry Comet's settings.Effect on other memory consumers
Worth calling out explicitly for review, because "reports without enforcing" undersells it. The listener cannot fail an allocation of its own, but it does consume budget:
acquireExecutionMemorygenuinely takes the memory, so once Arrow bytes are reserved, Spark's other off-heap consumers in the same task see correspondingly less headroom and may spill earlier than they do today. This is therefore not a purely observational change.That is the intended semantics. These bytes were always resident in the container, and the bug being fixed is that nothing counted them. Accounting is enabled by default, because hiding real bytes is the problem this is meant to solve, and
spark.comet.arrowAllocator.accounting.enabled=falserestores the previous behaviour if a workload regresses.This also sharpens the case for the second item in #5997. With Arrow bytes now reserved, the one consumer in a Comet task that still cannot return anything when Spark asks for memory is the native one, whose
NativeMemoryConsumer.spill()returns0.How are these changes tested?
CometArrowAllocationListenerSuite(18 tests) covers the reservation arithmetic, and then the properties the per-task binding exists for:markTaskCompletedrather than by callingcleanUpAllAllocatedMemorydirectlyIOExceptionor anInterruptedIOExceptionneither fails the Arrow allocation nor leaks the buffer Arrow already createdInterruptedExceptionis caught and the thread's interrupt flag is left setgetUsedandspilldo not wait on the reservation monitor, probed by holding that monitor and calling both from another threadCometArrowAllocationListenerBenchmarkmeasures the overhead with accounting on and off across the three buffer shapes Comet produces, and once more with a second thread reserving from the same constrained pool. Numbers are in a comment below.Locally,
CometExecSuite,CometShuffleSuite,CometNativeShuffleSuite,CometCodegenSuite,CometInMemoryCacheSuite,CometNativeColumnarToRowSuite,CometArrowPythonRunnerSuite,UtilsSuite,NativeUtilSuite,CometExecIteratorLifecycleSuiteandCometTaskMemoryManagerSuiteall pass, and all four Spark profiles compile.