Conversation
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.
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.
|
I ran TPC-H at SF100 against this PR to check the feature end to end and to get a first read on the per-allocation overhead that the description lists as not yet measured. Setup
Correctness Result hashes and row counts are identical between the two builds for all 22 queries, in both the traced and untraced runs. No errors, spills, or task retries in either. Overhead (untraced, median of 3 iterations per query)
The per-iteration ranges overlap. Only Q21 is slower in all three iterations (29.9 s vs 30.9 s, about +3%). Q10 is bimodal (7.5 s or 13 to 14 s) on both builds, so that is unrelated to this change. What
Traces and result JSON are available if useful. |
`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.
|
Follow-up on the Cause
Evidence
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 A new test, Re-run of the traced TPC-H SF100 suite with the fix
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 |
sunchao
left a comment
There was a problem hiding this comment.
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.
| #[cfg(all( | ||
| feature = "alloc-accounting", | ||
| not(feature = "mimalloc"), | ||
| any(target_env = "msvc", not(feature = "jemalloc")) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Here is the Before the numbers, one correction to the benchmark itself, fixed in ca2f8c8. In edition 2021 an Setup: woody, Ryzen 9 7950X3D (16 cores / 32 threads, one socket), Linux,
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:
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. |
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed ca2f8c875318 against d1dd302d3fde. The previous P2 is fixed: the mixed-feature accounting case now installs the wrapper. The exact selector passed all eight feature combinations in a local harness using system-allocator aliases for the optional backend types. The allocator module passed six default and seven accounting-enabled tests, and the failure/realloc probe passed. Removing the exit flush makes the newly ungated thread-exit test fail as intended.
There is one new P2 in the benchmark liveness guard: enabling both allocator features selects System in the library but still requires jemalloc statistics in the benchmark. The inline comment identifies the condition to align. An extracted selector/guard harness reproduced that mismatch using a system-backed statistics test double. This was not a run with the actual jemalloc or mimalloc libraries.
The explicit crate linkage and added parallel threshold cases address the earlier benchmark requests. The revised report measures an 8% increase for the parallel 64 KiB case on the author's host. I treat that as a reported workload-specific result, not a general overhead bound, and did not reproduce the timings.
The current checks show 53 successes and 10 skips. The detailed CI log read stalled, so I am not claiming a verified CI checkout, accounting-enabled JNI execution, or a full local Comet build. No new issue was found in allocation ownership, failure accounting, or the observability-only design.
| /// Guards against measuring the wrong allocator. jemalloc keeps its own count of bytes it has | ||
| /// served; if it is not the global allocator of this binary that count stays at zero, and a | ||
| /// "jemalloc" baseline would in fact be the system allocator. | ||
| #[cfg(feature = "jemalloc")] |
There was a problem hiding this comment.
[P2] Match the liveness guard to the selected allocator
Correctness
Could this guard use the same condition as the jemalloc backend in lib.rs? On non-MSVC targets, --features jemalloc,mimalloc deliberately selects the system allocator, but this condition still enables the jemalloc assertion. The held 8 MiB allocation therefore goes through System, while the guard checks jemalloc's separate allocation statistics. The benchmark aborts before collecting measurements for that accepted combination, both with and without alloc-accounting. Restricting this guard to all(not(target_env = "msvc"), feature = "jemalloc", not(feature = "mimalloc")), with the no-op using its complement, keeps the linkage check for actual jemalloc builds and permits the mixed-feature system baseline.
There was a problem hiding this comment.
Confirmed and fixed in ac51c5c. With jemalloc,mimalloc the library selects the system allocator but the bench still asserted on jemalloc's counters, so that combination aborted before measuring anything.
Rather than mirror the three-term predicate in the bench, lib.rs now exports the selection it made: each backend module declares a NAME and the crate re-exports it as comet::ALLOCATOR_BACKEND ("jemalloc", "mimalloc" or "system"). The bench announces that name at startup and runs the jemalloc liveness assertion only when the library reports jemalloc. The selection is still written once, so the guard cannot drift from it the way a copied cfg could, which is how the previous P2 crept in.
Verified with clippy on the bench target for all eight feature combinations, and a criterion --test run of alloc_overhead under jemalloc,mimalloc and jemalloc,mimalloc,alloc-accounting, both of which now report measuring the system allocator backend and complete; jemalloc alone still runs the assertion and passes.
The alloc_overhead benchmark asserted on jemalloc's counters whenever the jemalloc feature was on, but lib.rs selects jemalloc only when mimalloc was not also requested and the target is not MSVC. With jemalloc,mimalloc the library falls back to the system allocator and the bench aborted before measuring anything. Export the selection lib.rs made as comet::ALLOCATOR_BACKEND, one NAME per backend module, and have the bench announce it and run the jemalloc assertion only when the library reports jemalloc. The predicate stays written once, so the guard cannot drift from the selection it verifies.
|
checking this today |
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed ac51c5c0 against d1dd302d, since review 5211754611. The benchmark P2 is fixed: the guard now reads the library’s selected backend, so mixed allocator features correctly use the system baseline. No new or remaining P1/P2 findings.
I reran the earlier extracted selector/guard reproduction. Both mixed-feature cases fail on the prior commit and pass on this head. All eight current feature combinations pass. Two negative controls still fail when jemalloc is selected but its statistics report zero. These use System-backed backend/statistics test doubles, not the actual jemalloc/mimalloc libraries or Criterion timings. The accounting implementation and timed benchmark loops are byte-identical to the prior review.
At September 15, 18:04 UTC, checks show 7 successes, 11 skips, 20 cancellations and 1 failure. Required Checks failed on upstream cancellations. Rust formatting passed on merge 95ccd751, whose six authored files match this head. The Rust job was cancelled during clippy. Full runtime and accounting-enabled JNI validation remain unverified.
comphead
left a comment
There was a problem hiding this comment.
Reviewed as an observability-only change, so the usual Spark-semantics questions do not apply here. Findings are inline. A few that do not anchor to a single line:
Comment volume. The module doc, the lib.rs backend header, the bench module doc, and several test doc comments restate the PR description at paragraph length. The parts genuinely worth keeping are the non-obvious invariants: why dealloc settles before delegating, why IN_TRACK has to be const-initialized and destructor-free, and why post-hoc realloc accounting is safe here but would not be under enforcement. The surrounding narrative could come out.
Scope. Appropriately scoped, and splitting measurement from enforcement is the right call. The benchmark is the one piece that could reasonably ship separately.
Tests. The allocator contract is well covered (settle/flush, clamp, dealloc ordering, thread-exit drift, real-allocation liveness), and mutation-checking the thread-exit test is a nice touch. Two gaps inline: nothing exercises realloc, and the thread-exit test is not mutation-proof in the feature-on build. Nothing here has a SQL surface, so no test belongs in an .slt file.
Performance. Default builds are unaffected. With the feature on, every allocation pays a thread-local read plus a compare, and current_balance() in executePlan is one relaxed load per JNI call. The missing piece is the overhead number itself.
No blockers. The analyze_trace gap is the one I would want closed before merge, since without it the feature's headline use case does not work end to end.
| /// Logged alongside the per-thread pool reservations so the two can be compared directly: a large | ||
| /// and growing excess is native memory the pool is not accounting for. | ||
| #[cfg(feature = "alloc-accounting")] | ||
| fn log_native_allocated() { |
There was a problem hiding this comment.
analyze_trace never sees this metric. native/common/src/bin/analyze_trace.rs:113 matches jemalloc_allocated by exact name and drops everything else in the trailing else { continue; }, so native_allocated is discarded.
That tool is what computes excess = allocated - pool_total, which is the comparison this PR exists to enable. In the combination the PR specifically motivates (alloc-accounting without jemalloc) it will report zero allocated and no excess.
Suggest making that arm accept either name, deciding which wins when both are logged, and updating the tracing.md prose at line 69 that currently names only jemalloc_allocated as the process-wide counter.
There was a problem hiding this comment.
Fixed in 4b71ad9. analyze_trace now recognises both counters. When a trace carries native_allocated it is analyzed against that alone, since it counts only what Rust code holds from the allocator; otherwise it falls back to jemalloc_allocated. The rule is order-independent: the first native_allocated event resets the peaks and violations so the report never mixes the two sources, and the output names the counter it used in every label. A trace with neither counter is rejected with a message naming the two features. tracing.md describes the selection and the sample output matches the new labels.
Checked with synthetic traces: 32 MiB native vs 8 MiB pool reports 24 MiB excess with or without jemalloc events present, in either event order, and a trace with only jvm_heap_used exits 1.
There was a problem hiding this comment.
The zero-reservation boundary is fixed in 5a4334a. The comparison now waits only until the trace has produced at least one pool sample, so an observed zero reservation is compared against like any other value: a zero pool sample with 32 MiB native_allocated reports 32 MiB excess, and reservations falling from 8 MiB to zero under a steady 32 MiB report a 32 MiB peak. When the trace never carries a pool sample the tool says so instead of reporting that allocation never exceeded reservations. The same commit reflows the tracing.md table that failed the Preflight prettier check.
| # hands out, and reports the total as the `native_allocated` tracing metric so it can be compared | ||
| # against the memory pool's reservations. Never rejects an allocation. Off by default; a build | ||
| # without it has no wrapper and no per-allocation work. | ||
| alloc-accounting = [] |
There was a problem hiding this comment.
Nothing in CI builds this feature. pr_benchmark_check.yml:55 runs cargo clippy --all-targets --workspace with default features, and no workflow passes --features. So the three backend arms, the wrapper, and the bench's jemalloc liveness check are compiled only on developer machines.
The mod backend partition is nicely self-checking (zero matches gives an unresolved backend, two gives a duplicate module), but only for combinations someone actually compiles. Adding cargo check --features alloc-accounting plus one jemalloc,alloc-accounting check to an existing job would keep that property honest.
There was a problem hiding this comment.
Fixed in 4b71ad9. The rust-test composite action now lints datafusion-comet with --all-targets --features jemalloc,alloc-accounting (which covers the bench guards for the jemalloc case), runs the alloc_accounting tests with the wrapper installed over jemalloc, and cargo checks the alloc-accounting-only build for the system-allocator arm. That is the Linux Rust test job; the extra steps reuse its cache.
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| //! Measures the cost the `alloc-accounting` global-allocator wrapper adds per allocation. |
There was a problem hiding this comment.
The PR description still says "Not yet measured: the per-allocation overhead of the wrapper when the feature is on", but this benchmark is in the diff. 225 lines whose whole purpose is bounding that overhead, landing without the number, leaves a reviewer unable to judge the cost of turning the feature on.
Either paste the off versus alloc-accounting comparison this header describes, or land the bench separately once you have it.
There was a problem hiding this comment.
The numbers were posted in #5934 (comment) after the description was written. The description now summarises them and links there.
| /// which makes the check immune to whatever the rest of the crate is allocating meanwhile. | ||
| /// The injected amount is taken back out afterwards so later tests see an unchanged balance. | ||
| #[test] | ||
| fn thread_exit_settles_remaining_drift() { |
There was a problem hiding this comment.
This stops being mutation-proof once the feature is on. The worker leaves LOCAL_DRIFT at 1 << 40, and thread teardown itself allocates, so the next track call sees drift.unsigned_abs() >= SETTLE_THRESHOLD and flushes immediately. BALANCE then moves by roughly INJECTED even with ThreadDrift::drop neutered.
The doc comment already says the default build is the one that catches it. Worth gating the test #[cfg(not(feature = "alloc-accounting"))] so it cannot quietly become a tautology in the build that actually ships the wrapper.
There was a problem hiding this comment.
Confirmed: with the wrapper installed, teardown's own allocations call track, see the oversized drift, and flush it before the destructor runs. Gated on not(feature = "alloc-accounting") in 4b71ad9, with the doc comment saying why.
|
|
||
| // Pulls `comet`, and with it the `#[global_allocator]` selected by its feature set, into this | ||
| // binary even when the feature set leaves nothing here that names the crate. | ||
| extern crate comet; |
There was a problem hiding this comment.
This and the comment above it look unnecessary. assert_backend_is_live() names comet::ALLOCATOR_BACKEND unconditionally (no cfg), which in edition 2021 already resolves through the extern prelude and pulls the rlib, and with it the #[global_allocator], into the crate graph. The comment's premise, that nothing here names the crate, does not hold.
There was a problem hiding this comment.
Confirmed, it became redundant once the liveness check named comet::ALLOCATOR_BACKEND unconditionally. Removed in 4b71ad9; the note that naming the crate is what links the allocator moved onto that reference, since that is now the thing holding the link. Verified by running the bench under --features jemalloc with --test, where the jemalloc liveness assertion still passes.
| } | ||
|
|
||
| /// Clamps a signed balance to the unsigned value reported to callers. | ||
| fn clamp_balance(balance: isize) -> usize { |
There was a problem hiding this comment.
clamp_balance exists only so a_transiently_negative_balance_reports_as_zero can assert on isize::max(0) as usize. Inlining it into current_balance and dropping that test loses no coverage. The clamp's real behavior is already implied by current_balance's documented contract.
There was a problem hiding this comment.
Inlined into current_balance and the test dropped in 4b71ad9.
| /// | ||
| /// Returns 0 when the [`AccountingAllocator`] is not installed. Never reported negative: the | ||
| /// balance can dip below zero transiently while per-thread deltas settle out of order. | ||
| pub fn current_balance() -> usize { |
There was a problem hiding this comment.
Worth stating the accuracy bound here and in the tracing.md row: the returned value can lag the true balance by up to SETTLE_THRESHOLD times the number of live threads, since each thread holds un-flushed drift. Immaterial against GiB-scale footprints, but a reader comparing native_allocated against pool reservations byte-for-byte should know the number is approximate.
There was a problem hiding this comment.
Added to the current_balance doc and the tracing.md row in 4b71ad9: up to SETTLE_THRESHOLD per live thread.
| self.inner.dealloc(ptr, layout); | ||
| } | ||
|
|
||
| unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { |
There was a problem hiding this comment.
realloc is the one method whose accounting order this PR deliberately changed from the prototype (after delegating rather than before), and nothing tests it. dealloc_settles_before_delegating already has the Recording inner-allocator harness. A grow and a shrink through it, asserting the balance moves by the delta rather than by the full new size, would lock the new ordering in.
There was a problem hiding this comment.
Added realloc_accounts_the_size_difference_after_delegating in 4b71ad9. It grows a 64 MiB block to 96 MiB and shrinks it to 32 MiB through the Recording harness, asserting the balance moves by +32 MiB and then -64 MiB (within a 16 MiB margin, so accounting the full new size would fail), and that the inner realloc sees the balance still carrying the old size, which pins the after-delegating order. The harness now records the balance at realloc as well as at dealloc.
|
|
||
| use errors::{try_unwrap_or_throw, CometError, CometResult}; | ||
|
|
||
| pub mod alloc_accounting; |
There was a problem hiding this comment.
Question: is it intentional that this is ungated? It makes current_balance() public API that always returns 0 in default builds, and a locally constructed AccountingAllocator mutates the process-wide BALANCE even when the wrapper is not installed, which dealloc_settles_before_delegating relies on. I assume the point is keeping the unit tests running in the default build, just want to confirm that is the reason rather than an oversight.
There was a problem hiding this comment.
Intentional. The module is ungated so the unit tests, including the dealloc-ordering test that drives a local AccountingAllocator against the shared balance, run in the default build that CI actually executes. current_balance documents that it returns 0 when the wrapper is not installed. Now that CI also runs the tests with the feature on, gating would be possible, but it would take the ordering tests out of the default build for no gain.
| /// `"system"`. This is the one place the selection is decided, so anything that needs to know | ||
| /// which allocator is in effect (the `alloc_overhead` benchmark's liveness check, for instance) | ||
| /// reads it from here rather than re-deriving it from the feature set. | ||
| pub use backend::NAME as ALLOCATOR_BACKEND; |
There was a problem hiding this comment.
Question: this adds public crate API whose only consumer is a benchmark assertion. The reasoning in the doc comment is sound, since lib.rs owns the selection and re-deriving it from the feature set in the bench could disagree with it. Not arguing against it, just wondering whether #[doc(hidden)] is worth it to keep this out of the crate's documented surface.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked unchanged head ac51c5c0 against d1dd302d after the new review. One newly verified P2 remains, already covered by the analyzer thread.
Correctness
[P2] The analyzer ignores native_allocated. With accounting enabled and jemalloc disabled, its allocation side stays zero. In an isolated run of the exact post-parsing analyzer logic, native_allocated=32 MiB and pool reservations of 8 MiB produced zero excess and the “OK” report, in either event order. The equivalent jemalloc fixture reported 24 MiB excess. The raw trace remains usable, but the documented CLI misses this gap. Could the analyzer support the new counter with a defined source selection, or explicitly reject unsupported traces, and align its labels and documentation?
Other new review points
Two other claims need qualification:
- Off/on measurements are already posted, although the PR description is stale. They remain author-reported, workload-specific results.
- Filtering to fill/growth benchmarks still runs the guards: Criterion 0.7 calls every group target before filtering individual benchmark IDs.
The exact allocator module passed 6 default and 7 System-backed accounting tests. Independent grow/shrink and failed-realloc probes also passed. Removing the thread-exit settlement fails the default test but passes the accounting-enabled test, confirming that the latter alone does not prove destructor coverage. Feature-enabled CI coverage is still absent from the workflow.
At 2026-09-15 19:40 UTC, checks remain 7 successful, 11 skipped, 20 cancelled and 1 failed. Rust clippy was cancelled, and Required Checks failed on cancellations. No full Comet/JNI, actual jemalloc/mimalloc, or timing run was completed locally. This is a COMMENT follow-up. The existing approval is left in place.
…ing feature in CI Address the third review round on the alloc-accounting feature. - analyze_trace only matched jemalloc_allocated, so a trace from a build with alloc-accounting and no jemalloc reported zero allocated and no excess. It now analyzes native_allocated when present, falls back to jemalloc_allocated otherwise, names the counter in its output, and rejects a trace with neither. tracing.md describes the selection. - Nothing in CI compiled the feature. The rust-test action now lints the jemalloc,alloc-accounting build with --all-targets, runs the accounting tests with the wrapper installed, and checks the system-allocator arm. - lib.rs: the three backend modules only name the type; the two global_allocator statics sit together, so the default build now installs System explicitly. ALLOCATOR_BACKEND is doc(hidden). - alloc_accounting.rs: inline the clamp, table-drive the settle tests, add a realloc test that pins the size-difference accounting and the after-delegating order, and confine the thread-exit test to the default build, where a missing destructor is actually caught. Trim the prose. - alloc_overhead bench: drop the redundant extern crate, run both liveness guards from a single Once that every bench function calls, merge the two alloc/free loops, and use iter instead of iter_batched. Benchmark IDs are unchanged.
|
Third-round feedback addressed in 4b71ad9; each inline thread has a reply. On the comment volume: trimmed in 4b71ad9. The module doc, the |
sunchao
left a comment
There was a problem hiding this comment.
Rechecked 4b71ad9c against d1dd302d. The counter-selection portion of the analyzer finding is fixed: the original 32 MiB native / 8 MiB pool case now reports 24 MiB excess. Native wins over jemalloc in either order, including a zero native value, labels identify the chosen source, and traces without an allocation counter are rejected.
Correctness
[P2] One analyzer boundary remains. The carried-over pool_total > 0 guard treats an explicitly observed zero reservation like missing data. This exclusion already exists in the base jemalloc analyzer, so this is not a new jemalloc regression. The newly supported native-counter path inherits it. With a zero pool sample and 32 MiB native_allocated, the exact post-parsing analyzer logic reports zero excess and “OK”. With reservations falling from 8 MiB to zero, it reports a 24 MiB peak instead of 32 MiB. Zero reservations are emitted by the tracing path. Could we compare against an observed zero and handle missing pool samples separately, rather than claim that allocation never exceeded reservations? This extends the existing analyzer thread, without another inline.
The updated allocator module passed 4 default and 4 System-backed feature tests. The default thread-exit test still detects a removed destructor. All 8 backend combinations passed isolated guard checks, and both negative controls failed as expected. These use test doubles and do not establish full Comet/JNI or real jemalloc/mimalloc execution.
At 2026-09-15 20:38 UTC, checks show 4 successful, 15 skipped and 2 failed. Preflight failed Markdown formatting in tracing.md, and Required Checks propagated that failure. The new feature-enabled CI commands have not run.
…format tracing.md A zero pool reservation is a real sample that allocation can exceed, so the analyzer now defers the comparison only until it has seen at least one pool sample, and says so when it never does instead of reporting that allocation never exceeded reservations. Reflow the tracing.md label table with prettier, which the Preflight check enforces.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked 5a4334a1 against d1dd302d. The remaining analyzer finding is fixed. Both an observed zero pool and reservations falling from 8 MiB to zero now report 32 MiB excess for 32 MiB allocated. Missing pool samples are reported separately, while missing allocation counters still fail and native/legacy counter precedence is preserved.
All 23 current-head typed-event fixtures passed, and the previous revision reproduces both failures. These execute the exact post-parsing logic, not the JSON parser or full Comet/JNI. This revision adds no analyzer regression tests. The allocator implementation and runtime hot path are unchanged.
No new or remaining P1/P2 findings. Preflight, including Markdown formatting, passed. The completed Rust CI log now confirms 1,455 default tests and four jemalloc,alloc-accounting tests passed, plus feature-enabled Clippy and the --features alloc-accounting compile. These ran on merge 96bfc50d, whose eight authored files match this head but whose base and full tree differ from the assigned pair.
At September 15, 21:42 UTC, CI shows 16 successful, 13 skipped and six running checks. Spark integration and TPC checks are still pending.
comphead
left a comment
There was a problem hiding this comment.
Thanks @andygrove lets try native alloc
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_storebuffers, tokio's own machinery. Pool reservations aretherefore 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.fractionasksoperators 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-accountingcargo feature:native/core/src/alloc_accounting.rs:AccountingAllocator<A>wraps whicheverglobal allocator the build selected and maintains one signed process-wide byte
balance, exposed by
current_balance().native/core/src/lib.rs: selects the backend (jemalloc, mimalloc or system)in one place and installs either it or the wrapper over it as the global
allocator. The three backend cfgs partition every feature combination, so a
combination matching none fails to compile rather than installing nothing.
native/core/src/execution/jni_api.rs: reports the balance as thenative_allocatedtracing metric, logged in the same place asjemalloc_allocatedand alongside the per-thread pool reservations it is meantto be compared against.
native/common/src/bin/analyze_trace.rs: analyzesnative_allocatedwhen thetrace has it, otherwise
jemalloc_allocated, and names the counter it used.native/core/benches/alloc_overhead.rs: measures the wrapper's per-allocationcost with the feature off and on.
tracing.md: documents the feature, the new metric, and the analyzer'scounter selection.
.github/actions/rust-test: lints, tests and checks the feature-on builds,which nothing else in CI compiles.
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,
reallocaccounts after delegating andonly on success. The prototype had to account before delegating, because
panicking after
inner.reallocwould leave a caller unwinding with a stalepointer, a soundness constraint that simply does not exist here, and dropping it
also removes the over-count on a failed realloc.
deallocgoes the other way andsettles before delegating, because jemalloc drops its own count at the start of
a large free and then spends milliseconds unmapping the pages; accounting
afterwards made
native_allocatedread abovejemalloc_allocatedduring taskteardown.
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'sdestructor 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-entrantcalls straight into the shared balance, and uses
try_withso an allocationduring thread teardown cannot panic inside the allocator.
What this does not do
The balance counts
Layoutbytes, not resident pages, so it excludes allocatorfragmentation, jemalloc's retained pages,
mmaped regions, and anything a Cdependency allocates through libc
malloc. It is a lower bound on RSS, just amuch tighter one than pool reservations. It is also process-wide, not per-task,
and approximate to within 64 KiB per live thread. 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.rscover the settle/flush threshold, thedeallocordering (the inner free must see the balance already reduced), thereallocaccounting (the balance moves by the size difference, afterdelegating), and thread-exit settlement of remaining drift. The thread-exit test
injects a drift directly into the exiting thread's cell so the destructor is the
only path to the shared balance; it is confined to the default build because with
the wrapper installed, teardown's own allocations would flush the drift anyway.
With the feature on, a further test allocates 256 MiB through the real global
allocator and checks the balance moved, which is what catches a feature
combination that installs nothing.
CI runs the default build as before, and the Rust test job now also lints the
jemalloc,alloc-accountingbuild with--all-targets, runs the accounting testswith the wrapper installed over jemalloc, and checks the
alloc-accounting-onlybuild. Locally, clippy passes on all eight combinations of the three allocator
features.
The
alloc_overheadbenchmark, run with the feature off and on against jemalloc(full numbers and setup in
#5934 (comment)):
the thread-local path costs about 2 ns per alloc/free pair, an uncontended flush
about 1.5 ns more, and the worst case for the shared counter (32 threads each
allocating and freeing exactly 64 KiB blocks with nothing in between) 25 ns per
pair, or 8% over jemalloc's own contended path. Filling a 64 KiB buffer and
growing a builder show no measurable difference. TPC-H SF100 with the feature on
ran 1.4% slower on the sum of per-query medians, with identical results
(#5934 (comment)).