fix: CometFairMemoryPool compared pool-wide usage against a per-consumer limit - #5214
fix: CometFairMemoryPool compared pool-wide usage against a per-consumer limit#5214andygrove wants to merge 1 commit into
Conversation
…mer limit
`try_grow` computed the per-consumer fair share `pool_size / num` but compared
it against the pool-wide `state.used`, so a task could never use more than one
consumer's share in total. Usable memory shrank linearly as consumers
registered. This is the default off-heap pool type (`fair_unified`).
Compare against `reservation.size()` instead, matching upstream
`FairSpillPool`. The comment justifying the previous behaviour was incorrect:
`MemoryReservation::try_grow` calls `pool.try_grow` before adding `additional`
to the reservation's atomic size, so `reservation.size()` does include prior
grows -- it excludes only the current request, which is why upstream compares
`reservation.size() + additional`. The equivalent comment on `shrink` is
correct, since `MemoryReservation::shrink` decrements before calling the pool.
Extract the check into `check_fair_share` so the policy is unit testable, and
drop the `expect("overflow in checked_div")` in favour of falling back to the
whole pool when no consumers are registered.
andygrove
left a comment
There was a problem hiding this comment.
I dug into this against the values the JVM actually passes in. The core defect is real, but I think two parts of the description need correcting, and there is one consequence of the fix that isn't called out.
The defect and the reasoning behind it check out. limit = pool_size / num is a per-consumer share and state.used is the sum over every consumer in the pool, so two consumers each staying inside their own share can still trip the check. And the comment being removed is indeed wrong: MemoryReservation::try_grow calls pool.try_grow() before size.fetch_add in both datafusion-execution 53.1.0 (memory_pool/mod.rs:454-458) and 54.1.0 (:471-475), so reservation.size() does include prior grows. The shrink comment above it is correct (:404-414 decrements before calling the pool), which supports the "conflated during the DF 53 upgrade" reading — blame agrees, #1369 originally passed reservation.size(). docs/source/user-guide/latest/tuning.md:69 also documents the intended semantics as per-operator ("prevents operators from using more than an even fraction of the available memory (i.e. pool_size / num_reservations)"), which is what this restores.
Correction 1: pool_size is the executor-wide off-heap pool, not the task's share. For fair_unified, pool_size = memory_limit = spark.memory.offHeap.size × spark.comet.exec.memoryPool.fraction (CometExecIterator.scala:293, fraction defaults to 1.0 at CometConf.scala:707), passed straight through at config.rs:65,68. memoryLimitPerTask (CometExecIterator.scala:294) is computed, logged, and shipped over JNI, then ignored by both off-heap pool types.
Since the pool instance is per task attempt (TASK_SHARED_MEMORY_POOLS keyed by task_attempt_id), the old effective cap on a task was offHeap.size / num_consumers_in_this_task. On an 8-core executor with ~8 consumers that lands close to the task's fair 1/N share, so the old code accidentally approximated per-task fairness; it only bites when a task has many consumers, and it is looser than fair when a task has few. So "a plan with 10 memory consumers could use 10% of its off-heap share" reads as 10% of the task's share, when it is 10% of the executor-wide pool. The symptom is real; I'd soften the stated severity.
Correction 2: the fix removes the only aggregate per-task cap in the native pool. After this change, num consumers × pool_size/num = pool_size, so as far as this check goes a single task may reserve the entire executor off-heap pool. That is defensible — the real per-task bound is Spark's ExecutionMemoryPool.acquireMemory via CometTaskMemoryManager.acquireMemory, which grants each active task between 1/2N and 1/N and returns a partial grant, hitting the acquired < additional path and triggering a spill; it's the same enforcement greedy_unified relies on with pool_size = 0. But it's worth saying explicitly, because it reads against tuning.md:66-68 ("the shared pool ensures that the combined memory usage stays within the per-task limit") — that guarantee comes from TaskMemoryManager, not from this check, and the buggy code was supplying a second, accidental aggregate bound that this deletes. Please check whether that doc sentence needs rewording.
It also makes the num vs upstream's num_spill divergence noted under follow-ups more load-bearing: with the aggregate cap gone, dividing by all consumers rather than spillable ones is the only fairness knob left.
Smaller notes
reservation.consumer().name()is public in 54.1.0 (mod.rs:333) andTrackConsumersPool::try_growforwards the caller's reservation unchanged (pool.rs:570-572), soreservation.size()at the call site is the right consumer's usage. Note thatTrackConsumersPoolappends the top-consumers report on top of the now-longer message, so the combined error text gets fairly long.checked_div(num).unwrap_or(pool_size)silently admits everything atnum == 0. Unreachable in practice, so either behaviour is fine, but the fallback is the permissive one rather than the safe one.- The flagged test gap is the right one to flag: the tests cover
check_fair_share, not thatreservation.size()is what gets passed to it — which is precisely the line the DF 53 upgrade got wrong.
|
Follow-up to my review above: rather than reason about how tight the old cap was, I measured it. I temporarily instrumented Consumers registering per task pool:
So That lets us say where the old cap actually binds. The old code caps a task's total native usage at
The conclusion I'd draw: the defect is real, but on a busy executor Spark's own per-task fair share is tighter than the buggy cap, so it is masked. It bites when consumers-per-task exceeds active-tasks-per-executor - stage tails, AQE-coalesced partitions, low partition counts, small executors - where Spark would hand a lone task most of the pool and this check clamps it to Caveat on my own numbers: the two probe queries were small enough that the check never actually fired (zero If you want a reproducer that demonstrates the fix rather than arguing for it, the low-concurrency case is the one to target: |
Which issue does this PR close?
Part of #5212 (position 1).
Rationale for this change
CometFairMemoryPool::try_growcomputed the per-consumer fair sharepool_size / numbut compared it against the pool-widestate.used:So a task could never use more than a single consumer's share in total, and usable memory shrank linearly as consumers registered — a plan with 10 memory consumers could use 10% of its off-heap share before erroring or spilling. This is the default off-heap pool type (
fair_unified,CometConf.scala:686), so it affects the default configuration wheneverspark.memory.offHeap.enabled=true.The comment justifying the comparison was incorrect:
MemoryReservation::try_grow(datafusion-execution 54.1.0,memory_pool/mod.rs:471-475) is:reservation.size()therefore does include every prior grow. It excludes only the currentadditional, which is exactly why upstreamFairSpillPoolcomparesreservation.size() + additional > available(memory_pool/pool.rs:249). The corresponding comment onshrinkis correct —MemoryReservation::shrinkdecrements the atomic before calling the pool — and the two cases look to have been conflated during the DataFusion 53 upgrade in #3629.What changes are included in this PR?
reservation.size()rather than the pool-widestate.used, matching upstreamFairSpillPoolsemantics.check_fair_sharefunction so the policy is unit testable without a JVM, and document thatreservedmust be the requesting consumer's own usage.expect("overflow in checked_div")with a fallback to the whole pool.numcannot be zero while a reservation exists (a consumer registers before it can grow), but the previous code would have panicked with a message describing the wrong failure.Behaviour is otherwise unchanged: the pool still tracks
state.usedforreserved()and theshrinkguard, and the hard bound is still whatever Spark'sTaskMemoryManageractually grants (theacquired < additionalpath below the check).How are these changes tested?
Six new unit tests in
fair_pool.rscovering the fair-share policy: a sole consumer may use the whole pool, the share divides evenly, each consumer may independently reach its own share (the regression guard), a consumer may not exceed its share, zero registered consumers does not divide by zero, and an oversized request does not overflow.Full crate suite passes (141 passed, 4 ignored), and
cargo clippy --all-targetsis clean.Testing gap worth flagging: these tests cover the policy function, not the
try_growcall site — thatreservation.size()rather thanstate.usedis passed in is verified by inspection only. Testing the pool end to end needs a JNI harness, sinceCometFairMemoryPool::newrequires a liveCometTaskMemoryManagerglobal ref. That is tracked as the test-coverage item in #5212; I did not want to grow this PR into building that harness.I have not measured the performance effect on a real workload. The change strictly widens what the pool admits, so I would expect fewer spurious spills and
ResourcesExhaustederrors on off-heap plans with several memory consumers, but that is reasoning rather than a measurement.Are there any user-facing changes?
Yes, on the default off-heap configuration: plans that previously spilled or failed with "Failed to acquire N bytes … the fair limit is M bytes" will now be able to use their intended fair share of the pool. No configuration changes.
Follow-ups not in this PR
register/unregistercount every consumer, whereas upstreamFairSpillPooldivides bynum_spill(spillable consumers only) and gives unspillable consumers the remainder. Comet's limit is therefore still tighter than upstream's. Aligning that means tracking spillable and unspillable usage separately, which is a larger behavioural change than this fix, so I left it out — noted as a secondary divergence under position 1 in #5212.