blob: c252afedc45497818d93f02cbf502701c018162c [file] [view]
<!--
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 Management
This page describes how memory is budgeted, accounted, and enforced across the JVM/native
boundary. It is aimed at contributors working on memory pools, operators that reserve memory, or
anyone debugging an out-of-memory report. For user-facing tuning advice, see the
[Tuning Guide](../user-guide/latest/tuning.md).
This page covers off-heap mode (`spark.memory.offHeap.enabled=true`) only. Comet also has an
on-heap mode, but it exists so that the Spark SQL test suite can run against Comet without changing
Spark's memory configuration. It must not be used in production, and it is not described here. The
pool types that only on-heap mode exposes belong to the `CATEGORY_TESTING` config group for the
same reason.
## Overview
A Comet executor has to satisfy three separate memory budgets at once, and they are enforced by
three different parties:
| Budget | Enforced by | What happens when it is exceeded |
| -------------------- | ----------------- | ----------------------------------------------------------------------------- |
| JVM heap | The JVM | `java.lang.OutOfMemoryError`; Spark treats it as fatal and exits the executor |
| Spark's memory pools | Spark bookkeeping | A consumer is asked to spill, or the task fails with `SparkOutOfMemoryError` |
| Container RSS | The OS / cgroup | `SIGKILL` of the whole executor process (exit 137, `OOMKilled`) |
The first two are _accounting_: a running total of bytes that consumers have voluntarily declared.
The third is _physical_: the kernel measures resident pages and does not care what any accounting
layer believes.
The first row is easy to get wrong. Spark's `Executor.isFatalError` exempts `SparkOutOfMemoryError`,
which is the error Spark's own memory manager throws when a consumer cannot get the bytes it asked
for, so that failure is confined to the task and the executor keeps running. An ordinary JVM
`OutOfMemoryError` is not exempt: the executor hands it to `SparkUncaughtExceptionHandler`, which
calls `System.exit(SparkExitCode.OOM)` (exit code 52). Real heap exhaustion therefore usually costs
the whole executor, not one task. An executor loss on its own does not tell you which budget was
exceeded; the exit code does.
Comet's difficulty is that its allocations are made by Rust code, so they are invisible to the JVM
heap and to Spark's own off-heap accounting, yet they land squarely in container RSS. Comet
therefore maintains its own budget that is meant to shadow the physical one, and the accuracy of
that shadow is the central problem this page is about.
## Who allocates what
Enabling Comet does not add one new memory consumer, it adds several, and they are not all
accounted by the same party. This inventory is worth internalizing before reading the rest of the
page:
| Allocator | Lives in | Bounded by | Visible to Spark? |
| --------------------------------------- | ----------- | ------------------------------------------------------------- | ----------------- |
| Spark execution + storage (on-heap) | JVM heap | `spark.executor.memory` and the unified memory manager | Yes |
| Spark Tungsten (off-heap) | Off-heap | `spark.memory.offHeap.size` via `TaskMemoryManager` | Yes |
| Comet native (Rust global allocator) | Native heap | `memory_limit` (see below), enforced only via the memory pool | No |
| Comet JVM Arrow (`CometArrowAllocator`) | Off-heap | **Nothing**: a `RootAllocator(Long.MaxValue)` | No |
| Comet JVM shuffle pages | Off-heap | `spark.memory.offHeap.size` via `TaskMemoryManager` | Yes |
Two observations follow.
**Comet's JVM-side Arrow allocator is unbounded and accounted by nobody.** `CometArrowAllocator`
(`spark/src/main/scala/org/apache/comet/package.scala`) is a single process-wide
`new RootAllocator(Long.MaxValue)`. Child allocators are cut from it for FFI stream export
(`CometNativeArrowSource`), broadcast coalescing, and `CometSparkToColumnarExec`. These are real
off-heap bytes in container RSS that neither Spark's `TaskMemoryManager` nor Comet's native memory
pool sees. In practice the volume is modest, a batch at a time per stream, but there is no
ceiling and no backpressure.
**The JVM shuffle allocator is an ordinary Spark consumer.** `CometShuffleMemoryAllocator.getInstance`
returns `CometUnifiedShuffleMemoryAllocator`, a Spark `MemoryConsumer` drawing from
`spark.memory.offHeap.size`, so shuffle pages are arbitrated against Spark's other consumers in the
same task like any other allocation.
## Where Comet's budget comes from
`CometExecIterator.getMemoryConfig` computes the budget once per executor and passes it across JNI
to `Java_org_apache_comet_Native_createPlan` as `memory_limit`. Comet shares Spark's off-heap pool
rather than asking for a separate allocation:
```text
memory_limit = spark.memory.offHeap.size * spark.comet.exec.memoryPool.fraction
```
`spark.comet.exec.memoryPool.fraction` defaults to `1.0`. Lowering it is the current workaround for
Comet's under-accounting (see [The accounting gap](#the-accounting-gap)). It holds back a slice of
the off-heap pool that Comet is not allowed to reserve, on the assumption that Comet's real usage
overshoots its reservations by roughly that slice.
A second value, `memory_limit_per_task`, is computed and passed alongside it, but only the on-heap
pool types read it.
### Resolving the pool type
`parse_memory_pool_config` (`native/core/src/execution/memory_pools/config.rs`) turns the pool-type
string and the limit into a `MemoryPoolConfig`. Two pool types are valid in off-heap mode:
| Pool type | Sized from | Notes |
| ------------------------ | ------------------- | ----------------------------------------------------- |
| `fair_unified` (default) | `memory_limit` | Delegates to Spark's `TaskMemoryManager`; task-shared |
| `greedy_unified` | n/a (pool size `0`) | Spark owns the limit entirely; task-shared |
Any other pool type is rejected with a configuration error.
## The pool stack
`create_memory_pool` builds a base pool and `createPlan` then wraps it in decorators. Reading from
the inside out, a Comet plan in the default configuration sees:
```text
[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
```
Each decorator forwards every `MemoryPool` method to its inner pool, so `reserved()` at any level
reports the base pool's number.
### The unified pools
`CometUnifiedMemoryPool` and `CometFairMemoryPool` (`unified_pool.rs`, `fair_pool.rs`) are the
bridge to Spark. Their `try_grow` calls `CometTaskMemoryManager.acquireMemory` over JNI, which goes
through Spark's ordinary `TaskMemoryManager`. That means:
- Comet competes with Spark's own off-heap consumers (Tungsten sorters, `BytesToBytesMap`, and so
on) for the same `spark.memory.offHeap.size`, and Spark's unified memory manager arbitrates.
- Spark can force _Spark's_ consumers in the same task to spill to satisfy Comet's request.
- The reverse does not hold. Comet registers a `NativeMemoryConsumer` with the `TaskMemoryManager`
so that Spark has something to charge, but its `spill()` always returns `0`
(`CometTaskMemoryManager.java`). A Spark allocation can therefore never make a native sorter or
aggregate release its reservations. Native operators spill only when their _own_ `try_grow`
fails, so a JVM consumer that is blocked behind native reservations has no way to reclaim them.
- A partial grant (`acquired < additional`) is released immediately and reported as
`ResourcesExhausted`, which is the signal DataFusion uses to spill.
`CometFairMemoryPool` additionally applies a local check before it asks Spark. It divides
`pool_size` by the number of consumers currently registered with the pool and rejects the request if
the pool's _total_ reserved bytes plus the request would exceed that quotient. Two details matter
for tuning:
- The comparison is against the shared total (`state.used`), not against the requesting consumer's
own reservation. With an 8 GiB pool and two registered consumers, once one of them holds 3 GiB a
2 GiB request from the other is rejected, even though neither would exceed 4 GiB. In effect the
usable pool shrinks to `pool_size / num_consumers` in aggregate as soon as more than one consumer
is registered.
- The pool is task-shared (see below), so `num_consumers` counts every registered consumer across
every native plan in the task, not just the plan making the request.
This is why `fair_unified` spills earlier than `greedy_unified`. It is not a per-consumer quota, and
reading it as one overstates the memory a multi-operator task can use.
### Task-shared pools and their lifetime
A single Spark task can run more than one native plan concurrently: a shuffle runs the pre-shuffle
operators and the shuffle writer as separate native execution contexts. If each got its own pool,
the per-task limit would be enforced once per plan rather than once per task.
`acquire_task_shared_pool` (`task_shared.rs`) keeps a process-wide
`HashMap<task_attempt_id, Weak<TaskSharedMemoryPool>>`. Plans in the same task upgrade the existing
`Weak` and share one pool; the returned `Arc` is the only lifetime handle, so the registry entry
disappears when the last plan (and its last reservation) drops. There is no explicit release call to
forget, and a `createPlan` that fails partway through cleans up on unwind.
`TaskSharedMemoryPool::drop` has to handle one race: an `acquire` can observe an expired `Weak` and
insert a replacement before the dying pool reaches the registry lock. The drop therefore compares
pointers and only removes an entry that is still its own.
## How DataFusion consumes the pool
Native operators reserve through DataFusion's `MemoryConsumer` / `MemoryReservation` API:
- `try_grow(n)` may fail. Spillable operators (`ExternalSorter`, the grouped hash aggregate,
sort-merge join) respond to a `ResourcesExhausted` error by spilling to disk and retrying. This is
the only mechanism that turns memory pressure into progress rather than failure.
- `grow(n)` is infallible and panics if the pool refuses. It is used where the caller cannot spill.
- `shrink(n)` returns bytes to the pool.
An operator that never calls `try_grow` is invisible to the pool no matter how much memory it uses.
## Crossing the FFI boundary
Batches move between the JVM and native over the Arrow C Data and C Stream interfaces, which are
zero-copy. Nothing is copied, so the _allocator_ that produced a batch and the _runtime_ that
decides when it dies can be on opposite sides of the boundary. See [Arrow FFI](ffi.md) for the
mechanics; what matters here is who is charged and who controls the lifetime.
Two things are easy to conflate here. The _allocator_ of a buffer determines which process-level
accounting (if any) saw the bytes appear. Whether the buffer is _reserved_ in Comet's pool is a
separate decision, made by whichever operator holds it, and that operator neither knows nor cares
which side of the boundary the bytes came from.
**JVM → native (`ScanExec`).** The JVM allocates the Arrow buffers from a child of
`CometArrowAllocator` and exports the whole per-partition iterator once as an `ArrowArrayStream`.
`ScanExec` imports each batch through `AlignedArrowStreamReader` with `CopyMode::UnpackOrClone`:
dictionary columns are unpacked into new native arrays, everything else is an `Arc` clone of the
imported buffers. Those bytes stay where Java Arrow put them and are pinned for as long as any native
reference survives. They are invisible to Spark's `TaskMemoryManager`, and `CometArrowAllocator` is
unbounded, so nobody charged for them at allocation time. Whether they are charged _later_ depends
on who holds them. DataFusion's `ExternalSorter` reserves `get_reserved_bytes_for_record_batch` for
every batch it retains, and the hash join build side reserves `get_record_batch_memory_size` for
each incoming batch; both read buffer sizes off the `ArrayData` and apply equally to imported
buffers. So an imported batch that a sort or join has buffered _is_ reserved in Comet's pool, and
through a unified pool is charged to Spark. The same batch held by an operator that does not reserve
(a projection or filter, or `ScanExec` itself between polls) is charged to nothing.
**Native → JVM (`CometExecIterator`).** DataFusion produces the batch in Rust, and whether it is
still reserved when it reaches the boundary depends on the producing operator. The sort's output is
wrapped in a `ReservationStream` that shrinks the reservation by the batch's size as each batch is
emitted, so by the time `prepare_output` sees a sorted batch its bytes are no longer reserved.
`prepare_output` then calls `move_to_spark`, which writes an `FFI_ArrowArray` whose release callback
drops the Rust `ArrayData`; no reservation travels with it. The JVM wraps the pointers in
`ArrowBuf`s and the memory is freed when the JVM calls `close()`. An exported batch is therefore
usually _not_ pool-charged while the JVM holds it, but it is resident until the JVM releases it. A
slow or backed-up JVM consumer keeps native memory alive that no accounting layer is counting.
The point is not that one direction is charged and the other is not. **Buffer lifetime and
reservation lifetime are independent.** A reservation is a number an operator chose to declare and
later withdraw; a buffer lives until its last reference drops, on whichever side of the boundary
that happens. The two are kept in step only inside operators written to do so, and only while that
operator holds the batch. Once the batch leaves the operator, in either direction, the reservation
stops and the bytes keep going. That is the shape of the gap to hold in mind when reading the next
section, and it is what any fix at the allocator level has to close.
## The accounting gap
The pool tracks _declared reservations_. Container RSS counts _pages the process touched_. The two
diverge for several structural reasons:
- **Undeclared allocations.** Arrow array builders, expression kernels producing intermediate
arrays, decompression buffers, Parquet metadata structures, `object_store` request buffers, and
tokio's own machinery all allocate without reserving. Only operators that were explicitly written
to reserve show up in the pool.
- **Rounding and padding.** Arrow buffers are padded to 64-byte boundaries and builders grow by
doubling, so a reservation of exactly `n` bytes routinely corresponds to more than `n` bytes of
heap.
- **Allocator behavior.** `malloc`-level fragmentation, size-class rounding, and jemalloc's
retained/dirty page cache all add resident bytes that no layer above the allocator can see.
Freeing memory does not necessarily return pages to the OS.
- **Non-Rust allocations.** Memory allocated by C dependencies through libc `malloc`, and anything
`mmap`ed, never passes through Rust's `GlobalAlloc`.
- **Batches in flight across the FFI boundary.** Reservations stop at the operator that made them.
Imported JVM batches are reserved only while a reserving operator holds them, and exported native
batches have usually been released by the time the JVM receives them yet stay resident until the
JVM closes them (see [Crossing the FFI boundary](#crossing-the-ffi-boundary)).
The practical consequence is that `reserved()` is a lower bound on Comet's real footprint, and the
gap is workload-dependent. `spark.comet.exec.memoryPool.fraction` exists purely so operators can
hand-tune a haircut that covers the gap for their workload.
To measure the gap on a real query, enable tracing with the `jemalloc` feature and compare
`jemalloc_allocated` against the summed `thread_NNN_comet_memory_reserved` values; see
[Tracing](tracing.md#analyzing-memory-usage).
## What the container sees
On Kubernetes, Spark sizes the executor pod from `ResourceProfile`:
```text
pod memory request = pod memory limit
= spark.executor.memory
+ spark.executor.memoryOverhead (default max(0.1 * executor.memory, 384 MiB))
+ spark.memory.offHeap.size
+ pyspark memory (Python applications only)
```
Both the request and the limit are set to this same value, so the pod's cgroup `memory.max` is a
hard ceiling on the sum of everything in the container. That cgroup counts, among other things:
- the JVM heap (`spark.executor.memory`),
- JVM non-heap: metaspace, code cache, thread stacks, GC structures, Netty direct buffers,
- Spark's own off-heap allocations,
- **all of Comet's native allocations**,
- Comet's JVM-side Arrow buffers (`CometArrowAllocator`),
- page cache charged to the cgroup by the container's file I/O, including spill files.
Only the first and a portion of the third are visible to Spark's accounting. When the total crosses
`memory.max`, the kernel OOM killer kills the process. The failure mode is significantly worse than
a task-level OOM: every task running on that executor dies, every cached block it held is lost and
must be recomputed, and the shuffle files it produced become unavailable to downstream fetches.
Spark's driver sees only `ExecutorLostFailure` with exit code 137.
Two facts follow that are easy to get wrong:
1. **`spark.memory.offHeap.size` is part of the pod limit, not extra headroom on top of it.**
Raising Comet's off-heap budget on Kubernetes raises the pod's memory request by the same
amount, so the scheduler will place fewer executors per node rather than silently giving Comet
more room.
2. **`spark.executor.memoryOverhead` is the only slack in the container**, and the JVM's own
non-heap usage already consumes a large part of it. Comet's overshoot beyond its declared
reservations eats into the same allowance.
YARN behaves analogously. The container size is the same sum, and the NodeManager kills containers
that exceed it, but the kill is done by the NodeManager's monitor rather than the kernel, so it is
somewhat less abrupt.
## Open problems
Nothing described above prevents an executor from being OOM-killed. Every enforcement point Comet
has bounds _declared reservations_, and the sections above describe several structural reasons why
declared reservations are a lower bound on physical usage. The known gaps, roughly in order of how
much they matter:
- **No signal for real native usage.** The only way to observe the gap today is to enable tracing
with the `jemalloc` feature and compare `jemalloc_allocated` against summed reservations after
the fact. There is no runtime value that an operator, a metric, or a policy could read.
- **`spark.comet.exec.memoryPool.fraction` is a manual proxy for the gap.** It asks operators to
guess a per-workload haircut rather than measuring anything.
- **`CometArrowAllocator` is unbounded** and participates in no budget.
- **Buffer and reservation lifetimes are independent across the FFI boundary.** A batch can be
resident on either side with no reservation covering it, because reservations are made and
withdrawn by individual operators while the bytes outlive them.
- **Spark cannot trigger native spilling.** `NativeMemoryConsumer.spill()` returns `0`, so native
reservations are released only when a native operator decides to spill on its own failed
`try_grow`. JVM consumers in the same task can be starved behind them.
[Issue #4576](https://github.com/apache/datafusion-comet/issues/4576) tracks work on the first two.
## Debugging memory issues
| Tool | What it gives you |
| ------------------------------------------------ | -------------------------------------------------------------------------- |
| `spark.comet.debug.memory=true` | `LoggingMemoryPool` logs every register/grow/shrink with the consumer name |
| `spark.comet.explain.native.enabled=true` | Native plan with per-operator metrics, including spill counts |
| [Tracing](tracing.md#analyzing-memory-usage) | `jemalloc_allocated` vs summed pool reservations; the accounting gap |
| `TrackConsumersPool` | Names the top 10 consumers in `ResourcesExhausted` messages (always on) |
| [`thresher`](https://github.com/cetra3/thresher) | Third-party crate that dumps a jemalloc heap profile at a threshold |
A checklist for triaging an executor OOM kill:
1. Confirm which budget was exceeded. Exit code 137 / `OOMKilled` on the pod is the cgroup. Exit
code 52 with `java.lang.OutOfMemoryError` in the executor log is JVM heap exhaustion; Spark
treats it as fatal, so the executor is lost either way and the exit code is what distinguishes
them. A failed task with `SparkOutOfMemoryError` and a surviving executor is Spark's managed
memory pool, which is the only one of the three that is recoverable at task level.
2. Compare `jemalloc_allocated` against the summed pool reservations from a trace. A large excess
points at undeclared native allocations; a small excess points at the budget simply being too
small, or at the JVM side.
3. Check `spark.comet.batchSize` against the schema width. Peak memory scales with
`batch_size * columns`, and wide or deeply nested schemas amplify it.
4. Check whether the operators involved can spill at all. `ShuffledHashJoin` cannot, so
`spark.comet.exec.forceShuffledHashJoin=true` converts a spillable sort-merge join into one that
is not.