andygrove opened a new issue, #5212:
URL: https://github.com/apache/datafusion-comet/issues/5212

   ## What / Why
   
   Comet's memory accounting is spread across four largely independent layers — 
the native DataFusion `MemoryPool` implementations, the JNI bridge to Spark's 
`TaskMemoryManager`, the JVM shuffle allocators, and the native shuffle 
writer's own `MemoryReservation`. Each has been modified in isolation over 
time, and there are **no unit tests for any of the pools**. This EPIC collects 
the findings from a sweep across all four layers.
   
   The headline item is that the **default off-heap pool silently caps a task 
at `pool_size / num_consumers`** (position 1 below), and the second is an 
**unbounded leak of per-task pool entries that pin JVM objects for the 
executor's lifetime** (position 2). The rest range from cross-task interference 
in JVM shuffle spilling down to metrics that always report zero.
   
   Verified against `datafusion-execution` 54.1.0, `arrow-buffer` 59.1.0, and 
`spark-core_2.12` 3.5.7 sources.
   
   ## Scope of the sweep
   
   - `native/core/src/execution/memory_pools/` — `fair_pool.rs`, 
`unified_pool.rs`, `task_shared.rs`, `config.rs`, `logging_pool.rs`, `mod.rs`
   - `native/core/src/execution/jni_api.rs` — pool creation/release, tracing 
pool registry
   - `native/jni-bridge/src/comet_task_memory_manager.rs` and 
`spark/src/main/java/org/apache/spark/CometTaskMemoryManager.java`
   - `spark/src/main/java/org/apache/spark/shuffle/comet/` — the JVM shuffle 
allocators
   - `SpillWriter` / `SpillSorter` / `CometShuffleExternalSorter` / 
`CometDiskBlockWriter`
   - `native/shuffle/src/partitioners/multi_partition.rs` — the native shuffle 
writer's reservation
   
   ## Findings — correctness, high
   
   ### 1. `CometFairMemoryPool` compares pool-wide usage against a per-consumer 
limit
   
   `native/core/src/execution/memory_pools/fair_pool.rs:151-162`
   
   ```rust
   let limit = self.pool_size.checked_div(num).expect("overflow in 
checked_div");
   let used = state.used;              // pool-wide total
   if limit < used + additional {
       return resources_err!(...);
   }
   ```
   
   `limit` is the *per-consumer* fair share but `used` is the *whole pool's* 
usage, so a task can never use more than `pool_size / num_consumers` in total. 
Effective capacity shrinks linearly with every registered consumer. This is the 
**default off-heap pool type** (`fair_unified`, `CometConf.scala:686`), so it 
affects the default configuration.
   
   The comment justifying the switch away from `reservation.size()` 
(`fair_pool.rs:155-157`) is incorrect:
   
   > We use `state.used` instead of `reservation.size()` because DataFusion 53+ 
calls `pool.try_grow()` before incrementing the reservation's atomic size, so 
`reservation.size()` would not include prior grows.
   
   `MemoryReservation::try_grow` (datafusion-execution 54.1.0, 
`memory_pool/mod.rs:471-475`) calls `pool.try_grow(self, capacity)` and *then* 
`self.size.fetch_add(capacity)`. So `reservation.size()` does include every 
prior grow — it excludes only the current `additional`, which is exactly why 
upstream `FairSpillPool` writes `reservation.size() + additional > available` 
(`memory_pool/pool.rs:249`). The pre-existing code was correct. The 
corresponding comment on `shrink` (`fair_pool.rs:128-130`) *is* correct, since 
`MemoryReservation::shrink` decrements before calling the pool; the two cases 
appear to have been conflated during the DataFusion 53 upgrade (`90633dcc4`).
   
   Secondary divergence: `num` counts every registered consumer, whereas 
upstream divides by `num_spill` (spillable consumers only), making Comet's 
limit tighter still.
   
   ### 2. `TASK_SHARED_MEMORY_POOLS` entries leak, pinning JVM objects for the 
executor's lifetime
   
   `memory_pools/task_shared.rs:25`, `memory_pools/mod.rs:57`/`:73`/`:122`, 
`jni_api.rs:952`
   
   `num_plans` is incremented inside `create_memory_pool` (`jni_api.rs:435`), 
but `createPlan` can still fail afterwards — `local_dirs` decoding 
(`jni_api.rs:444-451`), `prepare_datafusion_session_context` (`:456`) and the 
key-unwrapper global ref (`:476`) all use `?`. On the JVM side `plan` is a 
field initializer (`CometExecIterator.scala:87`) evaluated *before* the 
task-completion listener is registered (`:139`), so a failed `createPlan` means 
`releasePlan` is never called and `num_plans` never returns to zero.
   
   Symmetrically, `releasePlan` runs `update_metrics(env, execution_context)?` 
(`jni_api.rs:950`) *before* `handle_task_shared_pool_release` (`:952`), so a 
metrics failure also strands the entry — and skips the `Box::from_raw` at 
`:967`.
   
   Each stranded entry holds an `Arc<Global<JObject>>` for that task's 
`CometTaskMemoryManager`, which transitively pins `TaskMemoryManager` and 
`TaskContext`. Keys are unique task-attempt ids and nothing else ever prunes 
the map, so it grows without bound.
   
   Fix: move the release ahead of `update_metrics`, and make 
`create_memory_pool` the last fallible step in `createPlan` (or register the 
count via a guard).
   
   ### 3. `num_plans -= 1` underflow poisons a `std::sync::Mutex` and bricks 
the executor
   
   `memory_pools/task_shared.rs:51-53`
   
   `per_task_memory_pool.num_plans -= 1` on a `usize` panics on underflow while 
holding `TASK_SHARED_MEMORY_POOLS.lock().unwrap()`. Because that is a 
`std::sync::Mutex`, the panic poisons it and every subsequent 
`create_memory_pool` / release in the executor fails at `.unwrap()`. Use 
`saturating_sub`, and either `parking_lot::Mutex` (as `fair_pool.rs` already 
does) or `lock().unwrap_or_else(|e| e.into_inner())`.
   
   ### 4. `NativeMemoryConsumer.toString()` throws 
`UnknownFormatConversionException`
   
   `spark/src/main/java/org/apache/spark/CometTaskMemoryManager.java:117`
   
   ```java
   return String.format("NativeMemoryConsumer(id=%)", id);
   ```
   
   `%)` is an invalid conversion specifier — this throws rather than formatting 
(verified). Reachable whenever Spark stringifies the consumer: 
`TaskMemoryManager.acquireExecutionMemory` logs `logger.debug("Task {} acquired 
{} for {}", ..., requestingConsumer)` (Spark 3.5.7 
`TaskMemoryManager.java:200`), and `logger.error("error while calling spill() 
on " + consumerToSpill, e)` at `:248`. Under DEBUG logging on that class the 
exception propagates out of `acquireMemory`, back through JNI, into 
`CometUnifiedMemoryPool::try_grow`.
   
   Not reachable via the `showMemoryUsage()` call at 
`CometTaskMemoryManager.java:74` — but only because of the next finding.
   
   ### 5. Comet native memory is invisible to Spark's own accounting
   
   `spark/src/main/java/org/apache/spark/CometTaskMemoryManager.java:62`
   
   ```java
   long acquired = internal.acquireExecutionMemory(size, nativeMemoryConsumer);
   ```
   
   Calling `TaskMemoryManager.acquireExecutionMemory` directly bypasses 
`MemoryConsumer.acquireMemory`, so `nativeMemoryConsumer.used` stays 0 forever. 
Consequences:
   
   - `showMemoryUsage()` skips the consumer entirely (its `totalMemUsage > 0` 
guard) and attributes all Comet native memory to the "bytes … not associated 
with specific consumers" bucket — precisely the diagnostic we call at 
`CometTaskMemoryManager.java:74` when an acquisition comes back short.
   - Spark's spill-victim ordering (`TaskMemoryManager.java:175`, `key = 
c.getUsed()`) always sorts Comet's consumer as zero.
   
   The class already tracks the correct number in its own `AtomicLong` (`:47`); 
it just never publishes it where Spark looks.
   
   ### 6. `CometDiskBlockWriter.currentWriters` is executor-wide, so spilling 
steals across tasks
   
   
`spark/src/main/java/org/apache/spark/sql/comet/execution/shuffle/CometDiskBlockWriter.java:66`,
 `:377-406`
   
   `currentWriters` is `static final`, holding writers from *all* concurrent 
tasks. `ArrowIPCWriter.spill(required)` sorts that global list and spills other 
tasks' writers, accumulating their freed bytes into the `totalFreed >= 
required` check. With `CometUnifiedShuffleMemoryAllocator` each task has its 
own `TaskMemoryManager`, so freeing another task's pages does nothing for 
*this* task's acquisition — the loop breaks early believing it succeeded, 
having forced unrelated tasks to spill for no benefit. Should be scoped per 
task attempt.
   
   ### 7. `getActiveMemoryUsage()` reads a `LinkedList` cross-thread without 
synchronization
   
   `CometDiskBlockWriter.java:273`, `SpillWriter.java:249-258`
   
   `SpillWriter.getMemoryUsage()` documents "Assume this method won't be called 
on a spilling writer, so we don't need to synchronize it" — but the comparator 
in position 6 above (`CometDiskBlockWriter.java:387-388`) calls it on writers 
owned by other threads that may be concurrently inserting or freeing pages, 
risking `ConcurrentModificationException` or torn sums. `SpillSorter` overrides 
it with `synchronized` (`SpillSorter.java:154-166`); `ArrowIPCWriter` does not.
   
   ### 8. Double `releasePlan` on a freed pointer
   
   `spark/src/main/scala/org/apache/comet/CometExecIterator.scala:225-245`
   
   `closed = true` is set *last*. If `nativeLib.releasePlan(plan)` (`:233`) 
succeeds but `traceMemoryUsage()` (`:236`) throws, `closed` stays false and the 
task-completion listener (`:139`) calls `close()` again → a second 
`releasePlan` on a pointer already freed by `Box::from_raw` (`jni_api.rs:967`). 
Set `closed = true` before the teardown calls, or use `try`/`finally`.
   
   ## Findings — correctness, lower
   
   ### 9. Non-volatile cross-thread state in `CometDiskBlockWriter`
   
   `spilling` (`:105`) and `totalWritten` (`:91`) are mutated from other tasks' 
threads via `doSpill()`. `totalWritten` is written under `synchronized (this)` 
at `:185` but read and written unguarded at `:245` and `:265`.
   
   ### 10. Dead spill-tracking list
   
   `CometDiskBlockWriter.java:69` — `spillingWriters` is never added to, so the 
loop at `:278` never executes. Either populate it or delete both.
   
   ### 11. `allocator.getUsed()` always reports 0 in on-heap mode
   
   `CometBoundedShuffleMemoryAllocator` tracks its own `allocatedMemory` field 
(`:58`) and never touches `MemoryConsumer.used`, so the tracing calls at 
`CometUnsafeShuffleWriter.java:228`/`:251` and 
`CometBypassMergeSortShuffleWriter.java:222`/`:241` log zero. 
`CometUnifiedShuffleMemoryAllocator` reports correctly (via 
`allocatePage`/`freePage`), so the same metric means different things in the 
two modes.
   
   ### 12. Peak memory lost across spills
   
   `CometShuffleExternalSorter.java:199` calls `activeSpillSorter.freeMemory()` 
without `updatePeakMemoryUsed()` first, so `getPeakMemoryUsedBytes()` only ever 
reflects the high-water mark after the last spill.
   
   ### 13. Reservation stranded on the native shuffle spill error path
   
   `native/shuffle/src/partitioners/multi_partition.rs:504-533` — if 
`partition_writer.write` fails (`:521`), `reservation.free()` (`:528`) and 
`pinned_buffers.clear()` (`:529`) are skipped even though 
`partitioned_batches()` already took ownership of the batches and indices. 
Additionally the comment at `:460-462` ("`try_grow` is evaluated first so the 
reservation accounts for this batch either way") is wrong — a failed `try_grow` 
does not grow the reservation.
   
   ### 14. Acquired bytes lost on overflow in the unified pool
   
   `memory_pools/unified_pool.rs:150-160` — if `fetch_update` fails, the 
function returns `Err` without releasing the already-acquired bytes back to 
Spark. Separately, `used` is incremented by `acquired` while the caller's 
reservation grows by `additional`. Practically unreachable (requires `usize` 
overflow), but the asymmetry is trivial to remove.
   
   ### 15. On-heap shuffle allocator singleton freezes config and shares the 
page table
   
   `CometShuffleMemoryAllocator.getInstance` (`:47-53`) returns a process-wide 
instance whose `totalMemory` is fixed from the first task's `SparkConf` and 
whose 8192-entry page table 
(`CometBoundedShuffleMemoryAllocator.PAGE_TABLE_SIZE`) is shared by all 
concurrent tasks, so "Have already allocated a maximum of 8192 pages" is an 
executor-wide rather than per-task limit.
   
   ### 16. `memory_limit()` not overridden on the Comet pools
   
   Both Comet pools inherit the default `MemoryLimit::Unknown`, even though 
`CometFairMemoryPool` knows its `pool_size`. Low impact in DataFusion 54 (only 
`runtime_env.rs:261` display and the arrow-pool wrapper consume it), but 
trivial to fix.
   
   ## Findings — performance
   
   ### 17. `CometFairMemoryPool` holds its mutex across the JNI round-trip
   
   `memory_pools/fair_pool.rs:149-183` — `state.lock()` is taken at `:149` and 
held through `self.acquire(additional)` at `:165`, a JVM call that can block on 
Spark's `synchronized` `MemoryManager` and may itself trigger spills. Because 
the pool is *task-shared* across Comet's tokio workers, this serializes the 
whole task's memory traffic behind a lock held across a JVM call. It is also a 
non-reentrant `parking_lot::Mutex`, so this becomes a deadlock if any Comet 
`MemoryConsumer` ever spills back through the pool. `CometUnifiedMemoryPool` 
does the same job with a lock-free `AtomicUsize`; the fair pool could do the 
limit check under the lock, drop it, then acquire.
   
   ### 18. One JNI round-trip per grow/shrink, with no batching or hysteresis
   
   `memory_pools/unified_pool.rs:64-78` — DataFusion calls `try_grow`/`shrink` 
per batch in aggregates, joins and sorts; each becomes a JNI call plus 
contention on Spark's executor-wide `MemoryManager` lock, and every `shrink` 
releases immediately however small. Acquiring in chunks with slack and 
releasing with hysteresis is likely the highest-leverage optimization in this 
area.
   
   ### 19. Tracing double-counts task-shared pools and takes a global lock per 
batch
   
   `jni_api.rs:437-441` wraps each context's pool in its own 
`LoggingMemoryPool` when `COMET_DEBUG_MEMORY` is set, defeating the 
`Arc::as_ptr` dedup in `total_reserved_for_thread` (`:182`) and 
`unregister_and_total` (`:164`), so a task-shared pool is counted once per 
plan. Separately, `total_reserved_for_thread` takes a global mutex and walks 
the map on every `executePlan` (`:896`). Both are debug/tracing-only paths.
   
   ## Test coverage gap
   
   There are **no unit tests for any memory pool** — no `#[cfg(test)]` module 
anywhere under `native/core/src/execution/memory_pools/` — and no JVM tests for 
the shuffle allocators or `CometTaskMemoryManager` (only 
`SpillSorterSuite.scala` exists in this area).
   
   Position 1 is exactly the kind of regression a handful of pool tests would 
have caught. A minimal suite for each pool: register N consumers, assert a 
single consumer can grow to `pool_size / N`, and assert N consumers can 
collectively reach `pool_size`.
   
   ## Suggested sequencing
   
   1. Positions 1, 2, 3, 4, 5 — small, independent, all on the default off-heap 
path — plus the pool unit tests above.
   2. Positions 6, 7, 9, 10 — the `CometDiskBlockWriter` cross-task spilling 
and its associated data races, likely one change.
   3. Positions 8, 11, 12, 13, 14, 15, 16 — independent small fixes, good first 
issues.
   4. Positions 17, 18, 19 — performance work, needs a benchmark to demonstrate 
the win.
   
   ## Not investigated
   
   - Whether native pool sizing and the JVM shuffle allocator's budget should 
be coordinated. In on-heap mode `memoryLimit` (`CometExecIterator.scala:306`) 
is the full `COMET_ONHEAP_MEMORY_OVERHEAD` while 
`CometBoundedShuffleMemoryAllocator.totalMemory` is 
`COMET_SHUFFLE_JVM_MEMORY_FACTOR` times the same overhead, so the two can 
jointly exceed the nominal budget.
   - Whether `memoryLimitPerTask = memoryLimit * coresPerTask / numCores` 
(`CometExecIterator.scala:309`) can round to 0 in practice, which would make 
`GreedyMemoryPool::new(0)` fail every allocation.
   - Behaviour when a task runs multiple native plans: the task-shared pool 
holds a global ref to the *first* plan's `CometTaskMemoryManager`, so later 
plans' memory is accounted through the first manager. 
`cometTaskMemoryManager.getUsed` at `CometExecIterator.scala:239` will then 
over-report for the first iterator and report 0 for the others, which may 
explain spurious "closed with non-zero memory usage" warnings.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to