Use when reviewing a DataFusion Comet pull request that touches memory pools, memory reservations, spilling, the Spark TaskMemoryManager bridge, allocators, or memory-related configuration. Load alongside review-comet-pr.
<!--
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.
-->
Memory-specific review for Comet PR #$ARGUMENTS.
REQUIRED BACKGROUND: Use review-comet-pr for PR metadata, existing comments, CI, the review
bar, and the output format. This skill only covers memory.
The three budgets, the allocator inventory, the pool stack, the accounting gap
docs/source/contributor-guide/ffi.md
Where buffers physically live when a batch crosses the boundary
docs/source/contributor-guide/debugging.md
"Debugging Memory Reservations", for what evidence to ask the author for
docs/source/contributor-guide/tracing.md
jemalloc_allocated against summed reservations, the only measurement of the gap
Read memory_management.md in full before the diff. Most bad memory PRs are not wrong about code,
they are wrong about which of the three budgets they are affecting.
1. Name the Budget
There are three budgets, enforced by three different parties, and a PR that improves one can make
another worse:
Budget
Enforced by
Failure mode
JVM heap
The JVM
OutOfMemoryError, executor exits with code 52
Spark memory pools
Spark bookkeeping
A consumer spills, or the task fails with SparkOutOfMemoryError
Container RSS
OS or cgroup
SIGKILL of the whole executor, exit 137, OOMKilled
The first two are accounting, a running total of voluntarily declared bytes. The third is physical.
Ask which one the PR claims to fix and check that the mechanism actually reaches it. Lowering a
declared reservation does not reduce resident pages. Reducing resident pages does not by itself
prevent a SparkOutOfMemoryError.
Comet's declared reservations are a lower bound on real usage, for structural reasons the guide
enumerates: undeclared allocations in builders and kernels, 64-byte padding and doubling growth,
allocator fragmentation and retained pages, non-Rust allocations, and batches in flight across FFI.
A PR that treats reserved() as the real footprint is built on a false premise.
2. Reservations
Native operators reserve through DataFusion's MemoryConsumer and MemoryReservation API.
try_grow failure turns into spilling, not an error. For a spillable operator,
ResourcesExhausted is the signal to spill and retry. A new operator that propagates it as a
query failure has converted a recoverable condition into a lost task.
Treat a new grow call site as potential unbacked memory.grow cannot fail. When
Spark grants less than asked, the Comet pools record the full amount anyway and carry the
shortfall as overcommit, which Spark does not know about and can hand to another consumer
or task. Nothing caps how far successive grow calls overcommit short of the container
limit. That is acceptable only for memory that already exists and cannot be spilled, such
as a spilled batch read back from disk, so a new call site needs that justification.
Memory the caller is about to allocate belongs behind try_grow.
Every try_grow has a matching shrink, including on the error path. A reservation
leaked on an early return is charged for the life of the task.
In try_grow, a partial grant is released, not kept.acquired < additional must
release and report ResourcesExhausted. Only grow keeps a partial grant, as overcommit.
An operator that buffers without reserving is invisible to the pool. If the PR adds
buffering, ask where the reservation is. "It is only a few batches" is how the accounting gap
grows.
Reservation size matches what is actually held. Check the PR uses buffer sizes read off
the ArrayData, as get_reserved_bytes_for_record_batch and get_record_batch_memory_size
do, rather than a row-count estimate.
3. The Pool Stack
create_memory_pool builds a base pool and wraps it in decorators. In the default off-heap
configuration, from the inside out:
[LoggingMemoryPool] <- only when spark.comet.debug.memory=true
[TaskSharedMemoryPool] <- RAII handle for the per-task registry
[TrackConsumersPool] <- DataFusion, names the top 10 consumers in error messages
[CometFairMemoryPool] <- delegates acquire/release to Spark over JNI
A new decorator forwards everyMemoryPool method to its inner pool. A partial
implementation makes reserved() disagree between levels.
fair_unified checks the requesting consumer against its share, and the total against
the pool. It divides pool_size by the number of registered consumers and rejects if what
the consumer holds plus the request exceeds that quotient, or if the pool's total reserved
plus the request exceeds pool_size. With two consumers and an 8 GiB pool, once one holds
3 GiB the other can still reserve up to 4 GiB. The pool keeps a running total for each
consumer id, so the sibling reservations that new_empty(), split() and take() create
count against one share. A PR that checks reservation.size() instead lets an operator with
several reservations, such as a sort's streaming merge, take other consumers' shares. It
also depends on when DataFusion updates the size, which is after it calls try_grow but
before it calls shrink. If the PR changes either comparison it changes when every query
spills, so it needs benchmark evidence, not reasoning.
num_consumers counts every consumer in the task, across every native plan, because the
pool is task-shared.
Task-shared pool lifetime.acquire_task_shared_pool keeps a process-wide
HashMap<task_attempt_id, Weak<TaskSharedMemoryPool>>. The returned Arc is the only
lifetime handle, so there is no explicit release to forget and a createPlan that fails
partway cleans up on unwind. A PR that adds an explicit release call, or stores the Arc
somewhere longer-lived, breaks that property. TaskSharedMemoryPool::drop compares pointers
so it only removes an entry that is still its own, which handles the race where an acquire
observes an expired Weak and inserts a replacement first. Do not let that check be
simplified away.
fair_unified and greedy_unified are the only pool types. On-heap mode ignores the
pool-type string and always gets UnboundedMemoryPool: it exists so the Spark SQL test suite
can run against Comet, it accounts for nothing, and it must not be used in production. A PR
re-adding a sized on-heap pool is reintroducing a budget that bounds nothing real (see
issue #6063).
4. The Spark Bridge
CometUnifiedMemoryPool and CometFairMemoryPool call CometTaskMemoryManager.acquireMemory over
JNI, which goes through Spark's ordinary TaskMemoryManager. Two asymmetries matter:
Spark can force its own consumers in the same task to spill to satisfy a Comet request.
Comet cannot be forced to spill by Spark. NativeMemoryConsumer.spill() returns 0, so a
JVM consumer blocked behind native reservations has no way to reclaim them. Native operators
spill only when their own try_grow fails.
If the PR claims Spark-side reclamation works, check spill(). If the PR implements native
reclamation, that is a significant change and needs to say what it does to in-flight operators.
5. Budget and Configuration
CometExecIterator.getMemoryConfig computes the budget once per executor and passes it across JNI
as memory_limit:
A new memory config goes through getMemoryConfig rather than being read separately in
native code.
Changing memoryPool.fraction semantics affects every deployment that tuned it as a haircut
for the accounting gap.
On Kubernetes, spark.memory.offHeap.size is part of the pod limit, not headroom on top
of it. A PR whose fix is "raise the off-heap size" is asking for fewer executors per node.
spark.executor.memoryOverhead is the only real slack in the container, and JVM non-heap
usage already consumes much of it.
6. Unaccounted Allocators
Two allocators sit outside Comet's pool entirely, and they are easy to confuse:
CometArrowAllocator (spark/src/main/scala/org/apache/comet/package.scala) is a
process-wide RootAllocator(Long.MaxValue). It is unbounded and no budget sees it. Child
allocators are cut from it for FFI stream export, broadcast coalescing, and
CometSparkToColumnarExec. A PR that adds a child allocator, or makes an existing one hold more,
is adding uncounted container RSS. Say so even if the volume is small.
JVM shuffle pages go through CometShuffleMemoryAllocator.getInstance, which returns
CometUnifiedShuffleMemoryAllocator, an ordinary Spark MemoryConsumer drawing from
spark.memory.offHeap.size. These are arbitrated by Spark against its other consumers. Do
not review them as if they were native allocations.
7. Evidence
Memory changes are hard to unit test, so the review question is what evidence exists rather than
what test was added. Ask for at least one of:
spark.comet.debug.memory=true output from LoggingMemoryPool, showing register, grow, and
shrink with consumer names
A trace comparing jemalloc_allocated against the summed
thread_NNN_comet_memory_reserved values, which is the only way to see the accounting gap
Spill counts from spark.comet.explain.native.enabled=true, before and after
For a DataFusion upgrade, or a change to a spilling operator, a run where the operator spills
under a tight pool and then reads its spill back. The DataFusion 55.1 final aggregate lost the
ability to spill again during that replay, and only a run at a small off-heap size showed it
(#6254).
For a fix to an OOM report, the exit code that identifies which budget was exceeded. 137 or
OOMKilled is the cgroup. 52 with java.lang.OutOfMemoryError is JVM heap. A failed task with
SparkOutOfMemoryError and a surviving executor is Spark's pool, the only one of the three that
is recoverable at task level.
spark/src/test/scala/org/apache/spark/CometTaskMemoryManagerSuite.scala and
CometUnboundedShuffleMemoryAllocatorSuite.scala are the existing JVM-side tests. A change to the
bridge or an allocator should extend one of them.
8. Does the PR Make memory_management.md Stale?
This doc states invariants and enumerates known gaps, so it goes stale in ways a file-path check
will not catch. Check:
The Who allocates what table, if the PR adds an allocator or changes what bounds one
The pool stack diagram, if a decorator is added, removed, or reordered
The pool type table, if a pool type is added, removed, or changes how it is sized
The memory_limit formula, if the budget calculation changes
The fair pool description, which spells out the share and pool-total checks and their
consequences
The Crossing the FFI boundary section, which names the operators that reserve for imported
batches
The Open problems list. If the PR closes one of these gaps, the entry must be removed or
rewritten in the same PR. Leaving a fixed problem listed as open is worse than no doc, because
the next contributor will re-fix it.
The Debugging memory issues table and checklist, if a tool or signal is added or removed
Audit an existing Comet expression for correctness and test coverage. Studies the Spark implementation across versions 3.4.3, 3.5.8, 4.0.1, and 4.1.1, reviews the Comet and DataFusion implementations, identifies missing test coverage, and offers to implement additional tests.
Triage open Comet issues marked `requires-triage` per the project bug triage guide. Classifies each issue as a bug or an enhancement, applies the recommended type (`bug`/`enhancement`), priority, and area labels, removes `requires-triage`, and files a dated summary issue listing what was done. A human reviews the summary issue and closes it when satisfied.
Use when implementing a new Spark expression in DataFusion Comet. Walks through cloning latest Spark master to study the canonical implementation, checking the upstream datafusion-spark crate before writing native code, building the Comet serde and Rust wire-up from the contributor guide, then running audit-comet-expression to drive a test-coverage iteration loop.
Use when optimizing the performance of an existing native scalar expression in the datafusion-comet-spark-expr crate (native/spark-expr/) — casts, string/JSON/array/math kernels that run per-row or per-batch. Covers benchmarking, keeping output bit-identical, and the no-regression gate. Not for adding new expressions (use implement-comet-expression) or wiring upstream functions (use wire-datafusion-function).
Use when reviewing a DataFusion Comet pull request that adds or changes a Spark expression, its Scala serde, its protobuf message, or its native Rust implementation. Load alongside review-comet-pr, which covers the parts of the review that apply to every PR.
Use when reviewing a DataFusion Comet pull request that crosses the JVM/native boundary, touching Arrow C Data or C Stream interface code, batch export and import, CometExecIterator, ScanExec, NativeUtil, CometVector subclasses, or jni_api. Load alongside review-comet-pr.