Skip to content

Commit f398301

Browse files
adriangbclaude
andauthored
Report peak MemoryPool reservation per query in benchmarks (#23985)
## 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 833e501 commit f398301

22 files changed

Lines changed: 631 additions & 21 deletions

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

benchmarks/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ tokio = { workspace = true, features = ["rt-multi-thread", "parking_lot"] }
6464
tokio-util = { version = "0.7.17" }
6565

6666
[dev-dependencies]
67+
# `pool`/`arrow_buffer_pool` are enabled only for tests, so the benchmark
68+
# binaries are built exactly as before. They let `memory_pool`'s tests cover
69+
# Arrow-side reservations reaching the pool via `ArrowMemoryPool`.
70+
arrow-buffer = { workspace = true, features = ["pool"] }
71+
datafusion-execution = { workspace = true, features = ["arrow_buffer_pool"] }
6772
datafusion-proto = { workspace = true, features = ["parquet"] }
6873
tempfile = { workspace = true }
6974

benchmarks/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,14 @@ Your benchmark should create and use an instance of `BenchmarkRun` defined in `b
483483
- Call its `start_new_case` method with a string that will appear in the "Query" column of the
484484
compare output.
485485
- Use `write_iter` to record elapsed times for the behavior you're benchmarking.
486+
- Call `set_memory_pool` with the `RuntimeEnv`'s memory pool (`ctx.runtime_env().memory_pool`),
487+
and again for each new runtime if your benchmark builds one per query. Each case then reports a
488+
`pool_peak_bytes` field: the peak `MemoryPool` reservation reached while running it, which is the
489+
largest value across that case's iterations. The field is omitted when the benchmark runs without
490+
`--memory-limit`, since no pool is installed to record. Comparing it against the peak RSS printed
491+
by `print_memory_stats` shows how much of the run's memory the pool actually accounted for; the
492+
pool only tracks the "large" allocations that scale with input size, so the two are expected to
493+
differ.
486494
- When all cases are done, call the `BenchmarkRun`'s `maybe_write_json` method, giving it the value
487495
of the `--output` structopt field on `RunOpt`.
488496

benchmarks/src/bin/benchmark_runner.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ async fn run_simple_benchmark(
299299

300300
let case_name = benchmark_case_name(benchmark);
301301

302+
// Each case gets its own `SessionContext`, so hand over its pool before the
303+
// case starts.
304+
run.set_memory_pool(&ctx.runtime_env().memory_pool);
302305
run.start_new_case(&case_name);
303306

304307
for iteration in 0..config.common.iterations {
@@ -312,7 +315,7 @@ async fn run_simple_benchmark(
312315
run.write_iter(elapsed, row_count);
313316
}
314317

315-
print_memory_stats();
318+
print_memory_stats(&*ctx.runtime_env().memory_pool);
316319

317320
Ok(())
318321
}

benchmarks/src/bin/external_aggr.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ use datafusion::execution::runtime_env::RuntimeEnvBuilder;
3939
use datafusion::physical_plan::display::DisplayableExecutionPlan;
4040
use datafusion::physical_plan::{collect, displayable};
4141
use datafusion::prelude::*;
42-
use datafusion_benchmarks::util::{BenchmarkRun, CommonOpt, QueryResult};
42+
use datafusion_benchmarks::util::{
43+
BenchmarkRun, CommonOpt, PeakRecordingPool, QueryResult,
44+
};
4345
use datafusion_common::instant::Instant;
4446
use datafusion_common::utils::get_available_parallelism;
4547
use datafusion_common::{DEFAULT_PARQUET_EXTENSION, exec_err};
@@ -169,7 +171,7 @@ impl ExternalAggrConfig {
169171
));
170172

171173
let query_results = self
172-
.benchmark_query(query_id, mem_limit, mem_pool_type)
174+
.benchmark_query(query_id, mem_limit, mem_pool_type, &mut benchmark_run)
173175
.await?;
174176
for iter in query_results {
175177
benchmark_run.write_iter(iter.elapsed, iter.row_count);
@@ -182,11 +184,15 @@ impl ExternalAggrConfig {
182184
}
183185

184186
/// Benchmark query `query_id` in `AGGR_QUERIES`
187+
///
188+
/// `benchmark_run` is handed this query's runtime, which is built here
189+
/// because each query runs under its own memory limit.
185190
async fn benchmark_query(
186191
&self,
187192
query_id: usize,
188193
mem_limit: u64,
189194
mem_pool_type: &str,
195+
benchmark_run: &mut BenchmarkRun,
190196
) -> Result<Vec<QueryResult>> {
191197
let query_name =
192198
format!("Q{query_id}({})", human_readable_size(mem_limit as usize));
@@ -198,6 +204,12 @@ impl ExternalAggrConfig {
198204
return exec_err!("Invalid memory pool type: {}", mem_pool_type);
199205
}
200206
};
207+
// This benchmark builds its pool directly rather than going through
208+
// `CommonOpt::runtime_env_builder`, so it has to install the recorder
209+
// itself to report a peak.
210+
let memory_pool: Arc<dyn MemoryPool> =
211+
Arc::new(PeakRecordingPool::new(memory_pool));
212+
benchmark_run.set_memory_pool(&memory_pool);
201213
let runtime_env = RuntimeEnvBuilder::new()
202214
.with_memory_pool(memory_pool)
203215
.build_arc()?;

benchmarks/src/clickbench.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ impl RunOpt {
213213
self.register_hits(&ctx).await?;
214214

215215
let mut benchmark_run = BenchmarkRun::new();
216+
benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);
216217
for query_id in query_range {
217218
let query_path = get_query_path(&self.queries_path, query_id);
218219
let Some(sql) = get_query_sql(&query_path)? else {
@@ -278,7 +279,7 @@ impl RunOpt {
278279
println!("Query {query_id} avg time: {avg:.2} ms");
279280

280281
// Print memory usage stats using mimalloc (only when compiled with --features mimalloc_extended)
281-
print_memory_stats();
282+
print_memory_stats(&*ctx.runtime_env().memory_pool);
282283

283284
Ok(query_results)
284285
}

benchmarks/src/dict.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ impl RunOpt {
333333
let rt = self.common.build_runtime()?;
334334
let ctx = SessionContext::new_with_config_rt(config, rt);
335335
let mut benchmark_run = BenchmarkRun::new();
336+
benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);
336337

337338
for query_id in query_range {
338339
let query = &DICTIONARY_QUERIES[query_id - 1];

benchmarks/src/h2o.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ impl RunOpt {
109109

110110
let iterations = self.common.iterations;
111111
let mut benchmark_run = BenchmarkRun::new();
112+
benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);
112113
for query_id in query_range {
113114
benchmark_run.start_new_case(&format!("Query {query_id}"));
114115
let sql = queries.get_query(query_id)?;
@@ -131,7 +132,7 @@ impl RunOpt {
131132
println!("Query {query_id} avg time: {avg:.2} ms");
132133

133134
// Print memory usage stats using mimalloc (only when compiled with --features mimalloc_extended)
134-
print_memory_stats();
135+
print_memory_stats(&*ctx.runtime_env().memory_pool);
135136

136137
if self.common.debug {
137138
ctx.sql(sql)

benchmarks/src/hj.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ impl RunOpt {
564564
}
565565

566566
let mut benchmark_run = BenchmarkRun::new();
567+
benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);
567568

568569
for query_id in query_range {
569570
let query_index = query_id - 1;

benchmarks/src/imdb/run.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ impl RunOpt {
295295
let mut benchmark_run = BenchmarkRun::new();
296296
for query_id in query_range {
297297
benchmark_run.start_new_case(&format!("Query {query_id}"));
298-
let query_run = self.benchmark_query(query_id).await?;
298+
let query_run = self.benchmark_query(query_id, &mut benchmark_run).await?;
299299
for iter in query_run {
300300
benchmark_run.write_iter(iter.elapsed, iter.row_count);
301301
}
@@ -304,7 +304,13 @@ impl RunOpt {
304304
Ok(())
305305
}
306306

307-
async fn benchmark_query(&self, query_id: usize) -> Result<Vec<QueryResult>> {
307+
/// `benchmark_run` is handed this query's runtime, which is built here so
308+
/// each query gets a pool of its own.
309+
async fn benchmark_query(
310+
&self,
311+
query_id: usize,
312+
benchmark_run: &mut BenchmarkRun,
313+
) -> Result<Vec<QueryResult>> {
308314
let mut config = self
309315
.common
310316
.config()?
@@ -314,6 +320,7 @@ impl RunOpt {
314320
self.hash_join_buffering_capacity;
315321
let rt = self.common.build_runtime()?;
316322
let ctx = SessionContext::new_with_config_rt(config, rt);
323+
benchmark_run.set_memory_pool(&ctx.runtime_env().memory_pool);
317324

318325
// register tables
319326
self.register_tables(&ctx).await?;
@@ -348,7 +355,7 @@ impl RunOpt {
348355
println!("Query {query_id} avg time: {avg:.2} ms");
349356

350357
// Print memory usage stats using mimalloc (only when compiled with --features mimalloc_extended)
351-
print_memory_stats();
358+
print_memory_stats(&*ctx.runtime_env().memory_pool);
352359

353360
Ok(query_results)
354361
}

0 commit comments

Comments
 (0)