feat: add native allocation accounting for memory observability by andygrove · Pull Request #5934 · apache/datafusion-comet · GitHub
Skip to content

feat: add native allocation accounting for memory observability - #5934

Open
andygrove wants to merge 5 commits into
apache:mainfrom
andygrove:feat-native-alloc-accounting
Open

andygrove wants to merge 5 commits into
apache:mainfrom
andygrove:feat-native-alloc-accounting

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Relates to #4576. This is the first of the pieces extracted from the closed
prototype in #4582, and it deliberately stops short of enforcement, so it does
not close that issue.

Rationale for this change

Comet's memory pool counts declared reservations: bytes an operator explicitly
asked for. A lot of real allocation never goes through it — Arrow builders,
expression kernels producing intermediates, decompression buffers, Parquet
metadata, object_store buffers, tokio's own machinery. Pool reservations are
therefore a lower bound on Comet's footprint, and the size of the gap is
workload-dependent.

Today that gap is invisible at runtime. Diagnosing an out-of-memory report means
reasoning about it indirectly, and spark.comet.exec.memoryPool.fraction asks
operators to hand-tune a haircut for a quantity nobody can measure.

#4582 tried to both measure the gap and enforce on it. Reviewing it convinced me
the enforcement half needs a question answered first — how well does a tracked
byte balance actually track RSS on real workloads? — and that question is much
easier to answer if the measurement lands on its own. So this PR is the
measurement only.

What changes are included in this PR?

Behind a new, off-by-default alloc-accounting cargo feature:

  • native/core/src/alloc_accounting.rs: AccountingAllocator<A> wraps whichever
    global allocator the build selected and maintains one signed process-wide byte
    balance, exposed by current_balance().
  • native/core/src/lib.rs: installs the wrapper over jemalloc / mimalloc /
    system. The cfgs are mutually exclusive, so a build without the feature is
    exactly the previous arrangement — no wrapper, no per-allocation work.
  • native/core/src/execution/jni_api.rs: reports the balance as the
    native_allocated tracing metric, logged in the same place as
    jemalloc_allocated and alongside the per-thread pool reservations it is meant
    to be compared against.
  • tracing.md: documents the feature and the new metric.

It is observability only. It never rejects an allocation, never panics, and does
not touch the memory pool. Two things follow from that which are worth calling
out:

Because it cannot fail an allocation, realloc accounts after delegating and
only on success. The prototype had to account before delegating, because
panicking after inner.realloc would leave a caller unwinding with a stale
pointer — a soundness constraint that simply does not exist here, and dropping it
also removes the over-count on a failed realloc.

Per-thread deltas are batched and flushed at 64 KiB, so the common path is a
thread-local add-and-compare rather than an atomic read-modify-write. The
prototype leaked up to 64 KiB of accounting every time a thread died, which
matters because the blocking pool churns on tokio's idle timeout. ThreadDrift's
destructor settles the remainder on exit. That is slightly more delicate than it
looks: touching a destructor-bearing thread-local can itself allocate on first
use, so track() keeps a destructor-free re-entrancy flag and settles re-entrant
calls straight into the shared balance, and uses try_with so an allocation
during thread teardown cannot panic inside the allocator.

What this does not do

The balance counts Layout bytes, not resident pages, so it excludes allocator
fragmentation, jemalloc's retained pages, mmaped regions, and anything a C
dependency allocates through libc malloc. It is a lower bound on RSS — just a
much tighter one than pool reservations. It is also process-wide, not per-task.
Whether it is a good enough proxy to enforce on is exactly what I would like to
learn from it before proposing that.

How are these changes tested?

Unit tests in alloc_accounting.rs cover the settle/flush helper and the
negative-balance clamp, and two tests drive real allocations through the
installed allocator: one asserts an 8 MiB allocation moves the reported balance,
and one asserts that drift from exited threads reaches the shared balance.

The thread-exit test is mutation-checked. Each worker allocates a sub-threshold
buffer and hands ownership back before exiting, so the matching free happens on
the main thread after the worker is gone and the destructor is the only path by
which those bytes can be counted. Neutering the destructor fails it with
"balance moved 0 bytes, expected at least 1048576". An earlier version of the
test, where each thread freed what it allocated, passed under the same mutation
and was replaced.

cargo clippy --all-targets -- -D warnings and the full native test suite pass
under default, alloc-accounting, jemalloc,alloc-accounting, and
mimalloc,alloc-accounting.

Not yet measured: the per-allocation overhead of the wrapper when the feature is
on. That is worth a benchmark before anyone considers enabling it by default,
and I have not done it.

Comet's memory pool counts declared reservations. Plenty of real allocation
never goes through it -- Arrow builders, expression kernels, decompression
buffers, Parquet metadata, object_store buffers, tokio itself -- so pool
reservations are a lower bound on Comet's footprint and the size of the gap is
currently unmeasurable at runtime. Diagnosing an OOM means guessing at it, and
spark.comet.exec.memoryPool.fraction asks operators to hand-tune a haircut for
a quantity nobody can see.

AccountingAllocator wraps the selected global allocator (jemalloc, mimalloc, or
system) and maintains one signed process-wide byte balance. executePlan reports
it as the native_allocated tracing metric, next to the per-thread pool
reservations it should be compared against.

This is observability only: it never rejects an allocation, never panics, and
does not touch the memory pool. Because it cannot fail an allocation, realloc
can account after delegating rather than before, which avoids over-counting a
failed realloc.

Per-thread deltas are batched and flushed into the shared balance at 64 KiB, so
the common path is a thread-local add-and-compare rather than an atomic RMW.
ThreadDrift's destructor settles the remainder when a thread exits, which
matters because the blocking pool churns on tokio's idle timeout and would
otherwise bias the balance on a long-lived executor. Touching that
destructor-bearing thread-local can itself allocate on first use, so track()
keeps a destructor-free re-entrancy flag and settles re-entrant calls straight
into the shared balance.

Off by default; a build without the feature has no wrapper and no
per-allocation work.

Verified clippy -D warnings and the native test suite across default,
alloc-accounting, jemalloc+alloc-accounting, and mimalloc+alloc-accounting.
The thread-exit test was mutation-checked: neutering the destructor fails it.
@github-actions github-actions Bot added the enhancement New feature or request label Sep 14, 2026
Answers the question the feature has to answer before anyone proposes enabling
it by default. The liveness assertion is the point of the harness as much as the
timings are: without it a 'with the feature' run can silently be a second
baseline, which is exactly what happened on the first attempt here.
@andygrove

Copy link
Copy Markdown
Member Author

@andygrove
andygrove marked this pull request as ready for review September 14, 2026 20:31
`dealloc` accounted after calling the inner allocator, mirroring `alloc`
and `realloc`. A free cannot fail, so that ordering bought nothing, and it
opened a window: jemalloc decrements `stats.allocated` at the start of a
large free and then, for blocks above its 8 MiB oversize threshold,
unmaps the pages eagerly, which takes milliseconds for a block of a few
hundred megabytes. For that whole window the balance still carried a
block the allocator had already given back, so `native_allocated` read
above `jemalloc_allocated` by the size of the block in flight. On TPC-H
SF100 about 2% of trace samples showed the excess, up to 160 MB, during
task teardown in Q10, Q17 and Q18.

Subtract before delegating. A new test wraps a recording inner allocator
and asserts the balance has already dropped by the time the inner free is
called; it fails with "inner dealloc saw balance 67182807, expected at
most 33628375" under the previous ordering.
@andygrove

Copy link
Copy Markdown
Member Author

Follow-up on the native_allocated > jemalloc_allocated samples from the comment above: root cause found, fixed in ce9b13a.

Cause

AccountingAllocator::dealloc called inner.dealloc first and subtracted afterwards, mirroring alloc and realloc. jemalloc 5.3 decrements stats.allocated at the very start of a large free (large_dalloc_prep_impl runs arena_large_dalloc_stats_update before large_dalloc_finish_impl releases the extent), and for blocks above its 8 MiB oversize threshold the release is eager: the pages go straight back to the kernel. Unmapping 100+ MB of resident memory takes milliseconds. For that whole window jemalloc had already forgotten the block but the balance still carried it, so native_allocated read high by exactly the size of the block being freed. The affected samples were all during task teardown, where many large buffers are dropped back to back.

Evidence

  • It is not a sampling race. The two counters are logged 1 us apart on the same thread, and the anomalous samples have the same read gap as normal ones. The excess persists at a fixed value across consecutive samples from different threads, then the balance drops by that amount while jemalloc stays flat.
  • A standalone repro (one thread allocating, touching and freeing a block in a loop, a sampler reading jemalloc then the balance) reproduces it deterministically and the episode length tracks the duration of the free:
ordering block samples with native > jemalloc longest episode slowest free
subtract after (previous) 128 MiB 16.5% 9.3 ms 9.3 ms
subtract after (previous) 4 MiB 4.4% 413 us 413 us
subtract before (fixed) 128 MiB 0.01% 15 us 11 ms
subtract before (fixed) 4 MiB 3.2% 25 us 355 us

The residual in the fixed rows is the sampler's own 1 us gap catching an allocation on the other thread, not a lag.

Fix

Subtract before delegating. A free cannot fail, so the reason alloc and realloc account after the fact does not apply to dealloc. With this the balance never includes memory the allocator has already handed back, so the "lower bound" relationship to jemalloc holds at every instant rather than modulo in-flight frees.

A new test, dealloc_settles_before_delegating, wraps a recording inner allocator and asserts the balance has already dropped by the time the inner free is called. Under the previous ordering it fails with inner dealloc saw balance 67182807, expected at most 33628375.

Re-run of the traced TPC-H SF100 suite with the fix

before (303875f) after (ce9b13a)
samples with native > jemalloc 156 of 9269 4 of 9272
worst excess 160 MB 9.4 MB
median native/jemalloc ratio 0.90 0.90

The four remaining samples have a 1 to 3 us read gap, consistent with the residual above. Result hashes are unchanged and the traced total is 208.2 s against 207.7 s for the jemalloc-only baseline.

@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.

Correctness

This adds visibility into Rust allocations that are absent from the memory pool's declared reservations. AccountingAllocator wraps the selected backend, accumulates signed thread-local deltas, and flushes them to a process-wide balance at 64 KiB or thread exit. native_allocated exposes that balance in tracing. The feature measures allocation sizes without enforcing a limit or changing the memory pool.

I found one P2 issue at ce9b13a351e8 against d1dd302d3fde: on non-MSVC targets, enabling both jemalloc and mimalloc together with alloc-accounting selects none of the allocator definitions. The metric remains enabled but reports zero. The inline comment covers the exact branch and reproduction.

For the individual backend selections, allocation and zeroed allocation update the balance only after success. A failed realloc leaves both the old pointer and its accounting intact. Successful realloc records the size difference. Freeing subtracts before calling the inner allocator, addressing the lag described in the author's follow-up. This matches the ownership and failure rules in Rust's GlobalAlloc contract.

The destructor-free re-entry flag protects initialization of the drift state. try_with falls back to the atomic balance after that state has been destroyed, and ThreadDrift::drop settles its remainder. The balance is an approximate allocation-size sample, with unflushed deltas on live threads. It should not be read as exact RSS, per-task usage, or a memory limit.

No Spark expression, operator result, error mode, or fallback decision changes. The wrapper delegates allocation requests and adds bookkeeping. No new equivalence claim is made for Spark's types, nulls, overflow, or ANSI behavior. Maintained Spark 3.4 and 4.1 sources were unavailable and are not claimed as reviewed.

Validation

The exact allocator module passed five tests without the feature and seven with an installed system-allocator wrapper in an isolated local harness. A separate probe passed allocation failure, zeroed allocation failure, failed realloc ownership, successful zeroed allocation, grow, shrink, and free cases. These are component checks, not a full Comet/JNI build. The configuration probe used the exact allocator-selection block with system-allocator aliases for the two optional backend types.

The public checks show 53 successes and 10 skips. Rust CI ran the five ungated accounting tests, but its default feature set excludes alloc-accounting. The two tests requiring the installed wrapper therefore have no CI result here. The Spark 4.1 execution suite passed 867 tests and consumed the native artifact whose ID and digest match the producer.

Those jobs checked out d97e84153938, with this head and base 5cff668396e7, one commit beyond the assigned base. All six changed files match that tested merge, but the complete trees differ. I credit that as source-matched CI coverage, not execution of the assigned pair or of the accounting-enabled JNI path.

Performance

Without the feature, allocator selection retains the previous behavior and does not add per-allocation bookkeeping. With it enabled, small changes use thread-local state. Large allocations and frees flush to the shared atomic counter, so contention is still relevant for allocation-heavy parallel workloads.

The new benchmark covers small allocation/free cycles, a filled 64 KiB buffer, and vector growth. I checked the optimized code for the extracted filled-buffer body and found that allocation, fill, and free were retained, so I am not raising an allocation-elision finding. This was an assembly check, not a timing measurement or a run of the Criterion target.

The author's TPC-H report gives a 1.4% increase in the sum of per-query medians over three untraced iterations at the earlier 303875f revision. The current-head follow-up is traced. These are useful initial observations, but they do not establish a general overhead bound or isolate atomic contention. I did not reproduce those timings. Could you report the new alloc_overhead microbenchmark with accounting off/on and add a parallel allocation/free case around the 64 KiB flush threshold to quantify shared-counter contention?

Design

Keeping measurement separate from enforcement is a clear boundary. It avoids introducing new allocation failures, pool limits, or retry behavior while collecting evidence about the gap between reservations and allocated bytes. Process-wide scope and exclusions such as C-library allocations and memory mappings are explicit.

The backend selection needs to cover every accepted feature combination. Defining the system fallback as the complement of the selected backend cases, or explicitly rejecting the conflicting combination, would resolve the reported zero-metric failure. The probe should accompany that adjustment.

Abstraction & complexity

The generic wrapper is a useful small abstraction because all three backends share the same bookkeeping. ThreadDrift gives the exit flush a clear owner, and the signed balance handles cross-thread allocation/free ordering without underflow. The new code does not introduce task attribution or enforcement machinery. The duplicated cfg predicates are the one place where complexity has produced an observable gap.

Comment thread native/core/src/lib.rs Outdated
Comment on lines +119 to +122
#[cfg(all(
feature = "alloc-accounting",
not(feature = "mimalloc"),
any(target_env = "msvc", not(feature = "jemalloc"))

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.

Correctness

[P2] Could the system fallback also cover the case where both allocator features are enabled, or reject that combination explicitly? On non-MSVC targets, jemalloc,mimalloc,alloc-accounting makes every GLOBAL definition false: each backend excludes the other, and this fallback excludes mimalloc. The process therefore uses an unwrapped default allocator while log_native_allocated is still enabled, so the new metric silently stays zero. I reproduced the selection with this exact cfg block and the exact accounting module: holding an 8 MiB buffer moved the balance with either individual backend configuration, but left it at zero with both features. The probe substitutes System for the backend types, so it tests allocator selection rather than jemalloc/mimalloc behavior. A combination check would prevent publishing a plausible zero metric when accounting was requested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2d78f87. On main, jemalloc,mimalloc on a non-MSVC target already selects neither arm and falls through to the system allocator; the accounting fallback here excluded mimalloc, so that combination with alloc-accounting matched nothing.

The selection is now a backend module chosen by three cfgs that partition every feature combination: jemalloc where it builds and mimalloc was not requested, mimalloc otherwise, and the system allocator as the exact complement of those two (which is where jemalloc,mimalloc still lands, as on main). The unwrapped #[global_allocator] lives inside the jemalloc and mimalloc modules, so a build without the feature is unchanged and still installs nothing for the system case. The single accounting #[global_allocator] refers to backend::Backend, so a combination with no backend is a compile error rather than a silent zero.

Verified with cargo check on all eight combinations of the three features, plus cargo test --features jemalloc,mimalloc,alloc-accounting alloc_accounting, where a_real_allocation_raises_the_balance (the test that checks the wrapper is really installed for the current feature set) passes.

While doing that I found the two feature-gated tests were flaky under the feature (2 of 20 runs): BALANCE is process-wide and the rest of the crate's tests, plus the 64 MiB block in dealloc_settles_before_delegating, move it concurrently. The same commit makes them noise-proof, and the thread-exit test no longer needs the feature, so it now runs in the default CI build.

…nation

Select the allocator backend once, in a `backend` module whose three cfgs
partition every feature combination, and let the single accounting
`#[global_allocator]` refer to whatever that resolved to. Previously each
backend predicate was repeated per arm and the accounting fallback to the
system allocator excluded `mimalloc`, so `jemalloc,mimalloc,alloc-accounting`
on a non-MSVC target matched no arm at all: the process ran on the unwrapped
default allocator while `native_allocated` stayed enabled and read zero.
A build without the feature is unchanged: the unwrapped allocator lives in
the backend module that owns it, and no explicit allocator is installed
when the selection is the system allocator.

Make the accounting tests immune to parallel test noise. `BALANCE` is
process-wide, so with the wrapper installed the crate's other tests move it
concurrently and the margin-based assertions failed intermittently. The
tests that observe the balance now serialize against each other, the
real-allocation probe uses an untouched 256 MiB block that nothing else in
the crate can mask, and the thread-exit test injects its drift directly so
it no longer needs the feature and runs in the default CI build.

Add `threshold_churn` to the `alloc_overhead` benchmark: alloc/free loops
at 32 KiB (never flushes) and 64 KiB (flushes on every call), single
threaded and from every core at once, so shared-counter contention can be
quantified rather than inferred.
…ed allocator

An `--extern` crate that nothing names is dropped from the crate graph, and
the accounting-off run of this benchmark named nothing in `comet`, so the
`#[global_allocator]` in `lib.rs` never reached the binary: the "jemalloc"
baseline was measuring glibc malloc. The accounting-on run names
`comet::alloc_accounting`, so it did link the crate, and every off/on
comparison so far was glibc against jemalloc plus the wrapper.

Add `extern crate comet` so the crate is always linked, and a jemalloc
liveness assertion next to the existing accounting one, so a run against
the wrong allocator fails instead of producing a plausible number.
@andygrove

Copy link
Copy Markdown
Member Author

Here is the alloc_overhead microbenchmark with accounting off and on, plus the parallel case around the flush threshold that was asked for (added in 2d78f87 as threshold_churn).

Before the numbers, one correction to the benchmark itself, fixed in ca2f8c8. In edition 2021 an --extern crate that nothing names is dropped from the crate graph. The accounting-off run of this benchmark named nothing in comet, so the rlib and its #[global_allocator] were never linked and the "jemalloc" baseline was in fact glibc malloc (the binary was 4 MB and contained no jemalloc symbols; with the feature on it names comet::alloc_accounting and is 28 MB with jemalloc linked). The bench now has an extern crate comet and a jemalloc liveness assertion next to the existing accounting one, so a run against the wrong allocator fails instead of producing a plausible number. The comparison below is jemalloc on both sides.

Setup: woody, Ryzen 9 7950X3D (16 cores / 32 threads, one socket), Linux, --features jemalloc vs --features jemalloc,alloc-accounting, Criterion with 2 s warm-up and 4 s measurement. Single-thread cases are pinned to one core with taskset because this CPU has two chiplets with different cache and unpinned single-thread numbers swing by 2x between runs; a pinned off-vs-off rerun agrees within 2%. Parallel cases use all 32 threads.

case off (jemalloc) on (jemalloc + accounting) delta
alloc_free_16b 4.11 ns 6.05 ns +1.9 ns
alloc_free_256b 4.26 ns 6.20 ns +1.9 ns
alloc_free_4096b 6.10 ns 7.89 ns +1.8 ns
alloc_free_32kb (never flushes) 23.0 ns 25.1 ns +2.1 ns
alloc_free_64kb (flushes on every alloc and free) 200.9 ns 204.5 ns +3.6 ns
alloc_fill_free_64kb 616 ns 611 ns none (p = 0.56)
grow_vec_to_64kb 41.56 µs 41.38 µs none
parallel_alloc_free_32kb_x32 (never flushes) 47.6 ns 49.0 ns +1.4 ns (p = 0.24)
parallel_alloc_free_64kb_x32 (every thread flushes on every call) 295 ns 320 ns +25 ns (+8%)

Times are per alloc/free pair, and for the parallel rows per pair per thread, so a parallel number equal to its single-thread counterpart would mean no interference at all.

Reading it:

  • The thread-local path costs about 2 ns per alloc/free pair, or 1 ns per call, independent of size. That is the whole cost when a thread's drift stays under 64 KiB, which is the 32 KiB rows: the alloc and the free cancel in the thread-local cell and the shared counter is never touched, single-threaded or on 32 threads.
  • A flush is two uncontended atomic read-modify-writes and adds another 1.5 ns on top of that single-threaded (64 KiB row). 64 KiB blocks are above jemalloc's thread-cache limit, so the allocator's own cost dominates at 200 ns.
  • Contention is the last row: 32 threads each doing two atomic adds on the same cacheline per iteration, with nothing else in between, cost 25 ns per pair, or 8% over jemalloc's own contended large-allocation path. That is the upper bound for the shared counter, and it needs every core to do nothing but allocate and free exactly-threshold blocks. Anything below the threshold never reaches the counter, and anything doing real work between allocations amortizes it.
  • The two rows closest to what Comet actually does, filling a batch-sized buffer and growing a builder, show no measurable difference.

This is consistent with the 1.4% on the TPC-H SF100 sum of medians reported above, where the executors spend a small fraction of their time in the allocator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants