Skip to content

Report peak MemoryPool reservation per query in benchmarks - #23985

Merged
gabotechs merged 7 commits into
apache:mainfrom
pydantic:claude/peak-pool-memory-accounting-3b4c26
Jul 30, 2026
Merged

Report peak MemoryPool reservation per query in benchmarks#23985
gabotechs merged 7 commits into
apache:mainfrom
pydantic:claude/peak-pool-memory-accounting-3b4c26

Conversation

@adriangb

@adriangb adriangb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of #22758. Doesn't close an issue.

Rationale for this change

DataFusion's MemoryPool deliberately accounts for only the "large" allocations that scale with input size; intermediate batches flowing between operators are assumed to be small and left untracked. The MemoryPool docs therefore advise reserving "some overhead (e.g. 10%)" on top of the configured limit.

Nothing measures what that overhead actually is. MemoryPool::reserved() is a live value that has usually fallen back to zero by the time a query finishes, so the peak is never observed, and benchmarks report peak RSS with nothing to compare it against.

This adds the missing number so the two can be compared. It is measurement only — nothing enforces a relationship between the pool's accounting and actual allocation, and no threshold or CI check is added.

This is complementary to the accounting work discussed in #22898. That issue proposes changing what gets accounted (Arrow claim() / builder with_pool()); because ArrowMemoryPool grows a DataFusion MemoryReservation against the pool it wraps, reserved() is where both models converge. This PR just makes the peak of that value observable per query, so the effect of any such change — or of a regression — is visible as a number rather than inferred.

Concretely, that means the peak follows the accounting instead of being pinned to the current set of manually tracked consumers: nothing claims buffers today, but if #22898 lands in either form, those bytes show up here without further changes. There is a test covering that path.

What changes are included in this PR?

  • PeakRecordingPool, a delegating MemoryPool wrapper recording the high-water mark of reserved(). Every method delegates; wrapping does not change how memory is granted, limited, or reported.
  • CommonOpt::runtime_env_builder installs it around the pool it already builds, so every benchmark run with a memory limit reports the peak without further changes.
  • BenchQuery gains pool_peak_bytes, reset per case by start_new_case, so each query gets its own reading rather than inheriting the high-water mark of the queries before it.
  • print_memory_stats prints the run-wide peak after the existing mimalloc line, leaving mem_profile's output parser untouched.

Everything is confined to the benchmarks crate. No trait changes.

arrow-buffer/pool and datafusion-execution/arrow_buffer_pool are enabled as dev-dependencies only, so the benchmark binaries build with exactly the features they had before — confirmed absent from cargo tree --no-dev-dependencies.

Are these changes tested?

Yes — 5 unit tests plus a doctest, and verified end-to-end with dfbench nlj --query 1 -i 2 --memory-limit 512M, which emits "pool_peak_bytes": 262528 per query.

One of those tests builds an ArrowMemoryPool over the recording pool and asserts an Arrow-side reservation both raises the peak and releases on drop, pinning the interaction described above.

The pre-existing test_runtime_env_builder_reads_env_var still asserts MemoryLimit::Finite(2G) through the wrapper, which is direct evidence the delegation is transparent.

The wrapper is installed only when a memory limit is configured, since without one there is no pool to wrap. pool_peak_bytes is omitted from the results JSON in that case, leaving output byte-identical to before for runs without a limit — confirmed compare.py parses both old and new files.

Are there any user-facing changes?

No. Confined to the benchmarks crate; adds one optional field to the benchmark results JSON.

claude and others added 2 commits July 29, 2026 14:32
DataFusion's `MemoryPool` deliberately accounts for only the "large"
allocations that scale with input size; intermediate batches flowing
between operators are assumed to be small and left untracked. The
`MemoryPool` docs therefore advise reserving "some overhead (e.g. 10%)"
on top of the configured limit.

Nothing measures what that overhead actually is. `MemoryPool::reserved`
is a live value that has usually fallen back to zero by the time a query
finishes, so the peak is never observed, and benchmarks report peak RSS
with nothing to compare it against.

Add `PeakRecordingPool`, a delegating `MemoryPool` wrapper that records
the high-water mark of `reserved()`. `CommonOpt::runtime_env_builder`
installs it around the pool it already builds, so every benchmark that
runs with a memory limit reports the peak without further changes.

- `BenchQuery` gains `pool_peak_bytes`, reset per case by
  `start_new_case`, so each query gets its own reading rather than
  inheriting the high-water mark of the queries before it.
- `print_memory_stats` prints the run-wide peak after the existing
  mimalloc line, leaving `mem_profile`'s output parser untouched.

This is measurement only. Nothing enforces a relationship between the
pool's accounting and actual allocation, and no threshold or CI check is
added.

The wrapper is installed only when a memory limit is configured, since
without one there is no pool to wrap. `pool_peak_bytes` is omitted from
the results JSON in that case, leaving the output byte-identical to
before for runs without a limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ArrowMemoryPool` implements Arrow's `MemoryPool` by growing a DataFusion
`MemoryReservation` against the pool it wraps, so a buffer claimed through
it reaches `PeakRecordingPool::grow` and is recorded like any other
reservation. Nothing in DataFusion claims buffers today, but that makes
the peak follow the accounting as it changes rather than fixing it to the
current set of manually tracked consumers.

That property was assumed rather than tested. Add a test that builds an
`ArrowMemoryPool` over the recording pool and asserts an Arrow-side
reservation both raises the peak and releases on drop.

`arrow-buffer/pool` and `datafusion-execution/arrow_buffer_pool` are
enabled as dev-dependencies only, so the benchmark binaries are built with
exactly the features they were before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adriangb
adriangb force-pushed the claude/peak-pool-memory-accounting-3b4c26 branch from 920656e to 5bb2c36 Compare July 29, 2026 21:30
@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.91873% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.85%. Comparing base (5de7f1d) to head (885d16d).
⚠️ Report is 120 commits behind head on main.

Files with missing lines Patch % Lines
benchmarks/src/imdb/run.rs 0.00% 8 Missing ⚠️
benchmarks/src/sort_pushdown.rs 0.00% 8 Missing ⚠️
benchmarks/src/sort_tpch.rs 0.00% 8 Missing ⚠️
benchmarks/src/util/memory_pool.rs 94.80% 7 Missing and 1 partial ⚠️
benchmarks/src/util/memory.rs 53.84% 4 Missing and 2 partials ⚠️
benchmarks/src/bin/external_aggr.rs 0.00% 5 Missing ⚠️
benchmarks/src/clickbench.rs 0.00% 2 Missing ⚠️
benchmarks/src/h2o.rs 0.00% 2 Missing ⚠️
benchmarks/src/tpcds/run.rs 0.00% 2 Missing ⚠️
benchmarks/src/tpch/run.rs 0.00% 2 Missing ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23985      +/-   ##
==========================================
+ Coverage   80.71%   80.85%   +0.14%     
==========================================
  Files        1089     1098       +9     
  Lines      368760   374236    +5476     
  Branches   368760   374236    +5476     
==========================================
+ Hits       297647   302607    +4960     
- Misses      53372    53586     +214     
- Partials    17741    18043     +302     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@adriangb
adriangb marked this pull request as ready for review July 29, 2026 21:57
@adriangb

Copy link
Copy Markdown
Contributor Author

@avantgardnerio @LiaCastaneda any interest in this change?

Comment thread benchmarks/src/util/memory_pool.rs
adriangb added 5 commits July 30, 2026 09:23
The high-water marks lived in process-wide statics, which meant the
recorded peak could not be attributed to a particular pool, the `Debug`
impl reported a number that was not the instance's, and the unit tests
needed a mutex to keep from clobbering each other.

Move both marks onto `PeakRecordingPool` and reach them through
`RuntimeEnv::memory_pool`, which `dyn MemoryPool::downcast_ref` already
supports. `BenchmarkRun::set_memory_pool` takes the pool the queries run
against, so benchmarks that build a runtime per query now report each
query against the pool it actually ran on.

No change to what is measured or emitted.
Recording the peak by calling `inner.reserved()` after every grow put the
wrapped pool's bookkeeping on the allocation path. `--mem-pool-type`
defaults to `fair`, and `FairSpillPool::reserved()` takes the same state
lock `try_grow` just released, so every accounted allocation acquired it
twice — in a harness whose job is measuring time, on exactly the
spilling benchmarks where the number matters.

Keep a running total in the wrapper instead. It stays exact because the
trait grants exactly what is asked for: `grow` is infallible and
`try_grow` either grants `additional` or leaves the reservation alone.

Resetting now sets the mark to what is currently reserved rather than to
zero, so a query that starts with data already held reports that as its
floor instead of under-reporting until the next grow.
`pool_peak_bytes` was only assigned in `write_iter`, which a query that
failed never reaches. Its absence was documented as meaning "no memory
limit was configured", so an out-of-memory query — the case where the
peak is most worth seeing — serialized identically to a run with no pool
at all. Assign it in `mark_failed` as well.
`external_aggr` builds its pool directly instead of going through
`CommonOpt::runtime_env_builder`, so it was the one benchmark that never
got a recorder installed — despite being the benchmark that exists to
run queries under a memory limit. Wrap the pool it builds and hand it to
the run, so each (query, limit) case reports its peak like the rest.
The README's "Collecting data" section is the contract benchmarks follow
to produce results JSON, and it did not mention the new field or the
`set_memory_pool` call that populates it.
@adriangb
adriangb requested a review from gabotechs July 30, 2026 14:25

@avantgardnerio avantgardnerio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great idea! Thanks @adriangb

@gabotechs gabotechs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good 🚀

@adriangb
adriangb added this pull request to the merge queue Jul 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 30, 2026
@gabotechs
gabotechs added this pull request to the merge queue Jul 30, 2026
Merged via the queue into apache:main with commit f398301 Jul 30, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants