Skip to content

perf: stop holding the fair pool lock across blocking memory calls - #5613

Open
dwsmith1983 wants to merge 3 commits into
apache:mainfrom
dwsmith1983:perf/fair-pool-lock-free
Open

perf: stop holding the fair pool lock across blocking memory calls#5613
dwsmith1983 wants to merge 3 commits into
apache:mainfrom
dwsmith1983:perf/fair-pool-lock-free

Conversation

@dwsmith1983

Copy link
Copy Markdown

Which issue does this PR close?

No dedicated issue. Adjacent to #5494, which made task-shared pools the norm and so widened the blast radius of this lock.

Rationale for this change

CometFairMemoryPool held its internal mutex across the JNI acquire and release calls into Spark's TaskMemoryManager. That call can block for a long time while Spark spills other consumers, and while it blocked, every other native thread sharing the task pool sat behind the lock, including plain releases that needed nothing from the JVM. The sibling unified pool already avoids this.

What changes are included in this PR?

The admission check and the reservation now happen as one short locked step (the fair limit couples used bytes and the consumer count, so this part genuinely needs mutual exclusion), then the blocking JVM call runs with no lock held, and the reservation rolls back if the JVM declines, grants partially, or the call panics. Fairness semantics are unchanged: concurrent grows still cannot jointly exceed pool_size divided by the consumer count, and registering a new consumer still only blocks further growth rather than clawing back existing reservations. Going fully lock-free like the unified pool was considered and rejected, since separate atomics would let a register or unregister slip between reading the count and committing the reservation.

Two side effects worth naming. The JNI boundary moved behind a small internal trait so the pool can be tested without a live JVM (neither pool had any tests before). And the old code could deadlock if a blocked acquire ever re-entered the pool on the same thread via a spill callback, since the lock was held across the call; that hazard is gone by construction.

How are these changes tested?

Ten tests, all new: fairness rejection without reaching Spark, limit tightening on register, partial-grant rollback with the excess returned to the JVM, acquire-failure accounting, zero-size no-op, over-shrink panic, a panic inside the bridge rolling back the reservation, a blocking test where a parked acquire must not stall a concurrent release (it hung for its full ten-second timeout on the old code and completes in 30ms now), and an eight-thread by 500-iteration stress test asserting the accounting never exceeds the fair limit and nets to zero after quiesce. The stress and blocking tests were looped 50 times in both debug and release with no failures. Full core crate suite passes in both profiles, clippy with warnings denied and fmt are clean.

CometFairMemoryPool held its mutex across the JNI calls into Spark
task memory manager, which can block for seconds while Spark spills,
so every native thread sharing a task pool serialized behind whichever
thread was acquiring. The fairness check and the reservation are now
one short locked step, the blocking call runs unlocked, and the
reservation rolls back if the JVM fails to back it or the call panics.
Fairness semantics are unchanged: concurrent grows still cannot
jointly exceed pool_size divided by the consumer count.

The JNI boundary moved behind a small trait so the pool finally has
tests: fairness rejection, limit tightening on register, partial-grant
rollback, panic rollback, a blocking test that took ten seconds on the
old code and 30ms now, and an eight-thread stress test.
@dwsmith1983
dwsmith1983 force-pushed the perf/fair-pool-lock-free branch from 196d709 to 626bc39 Compare September 2, 2026 02:13

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 626bc395091f313ae93b8867c1cb1c846dc7fc53 against 8729f6e6adf7091e18a48670e790d4ba8fd41e51. I found one P2 in the new concurrent release path, detailed inline.

The focused Spark memory-pool component probe reproduced the missing-task exception. No full Comet native/JNI query suite ran. CI, CodeQL, and Delta Contrib Build Gate currently report action_required, with no test checks recorded.

For this performance change, please include matched BASE/HEAD microbenchmarks with one and multiple native threads, full and partial grants, and consumer registration changes. Report completed operations, grow/release latency, error counts, and final Rust/Spark balances. The parked stub test establishes lock behavior but does not measure production JNI throughput.

Comment on lines 176 to 177
state.used -= subtractive;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep waiting Spark acquisitions registered during a full release

Could you handle Spark's same-task wait/release contract before allowing this release to overlap an acquire? With fair_unified, two native consumers can now enter the same CometTaskMemoryManager concurrently. In a 100-unit executor pool, let another task hold 90 and this task hold 10. This task's next 10-unit grow waits below Spark's 1/(2N) minimum. Freeing its last 10 units on another native thread removes its entry from ExecutionMemoryPool.memoryForTask and wakes the grower. The grower then indexes the removed entry and throws NoSuchElementException: key not found. Spark's release bypasses the task monitor held by the waiting acquire, so that monitor does not prevent this interleaving. The Rust provisional reservation does not keep Spark's entry alive.

I reproduced the failure using unchanged Spark 3.5.9 pool source with only logging/annotation/memory-mode scaffolding. A scheduling control modeling the previous serialization completed after the other task freed its memory. The relevant map lifecycle is also present in 4.0.4 source. This was a component probe plus JNI source tracing, not a full Comet query reproduction. Please make full releases safe while grows are pending and add a regression that exercises Spark's memory manager.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against the pool source, thanks for the repro. Blocking the release until the in-flight acquires drain turned out to deadlock in testing, since the parked acquire can be waiting on exactly the memory that release frees. So the fix defers instead: a release that would zero the task's balance while acquires are in flight frees n-1 bytes right away (that is what wakes the waiter) and holds the last byte, which the final completing acquire pays off. At most one byte is ever deferred and it always settles once the acquires finish. The test stub now models the entry lifecycle (created on acquire, removed at zero, a woken waiter fails if the entry is gone) and reproduced this crash before the fix. It also turned out one of our existing tests was exercising the same broken pattern.

A release that would zero the JVM-side balance while other acquires are
still in flight frees all but one byte immediately and holds the last
byte until the in-flight acquires complete. Spark drops the task's
accounting entry when its balance hits zero, so a parked acquire waking
after that point indexes a missing entry and fails. Blocking the release
instead can deadlock because the parked acquire may be waiting for the
very memory the release frees. The stub task memory now models the
entry lifecycle so the regression is covered.
@dwsmith1983
dwsmith1983 requested a review from sunchao September 2, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants