Skip to content

Add aggregate-internal submetrics - #25051

Draft
kosiew wants to merge 37 commits into
apache:mainfrom
kosiew:aggmetrics-06-23570
Draft

kosiew wants to merge 37 commits into
apache:mainfrom
kosiew:aggmetrics-06-23570

Conversation

@kosiew

@kosiew kosiew commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

This is the last of a series of PRs to close the issue

Rationale for this change

Existing per-aggregate metrics measure complete update, merge, state, and evaluate calls, but they cannot show how much time an aggregate spends in meaningful internal work such as distinct-value handling.

This PR adds an optional aggregate-owned submetrics contract so aggregate implementations can expose those internal phases while preserving stable ownership by aggregate expression and partition. Aggregates that do not request internal submetrics continue to use the existing accumulator paths without registering additional metrics.

array_agg(DISTINCT ...) is used as the representative implementation and reports time spent in distinct-value handling.

What changes are included in this PR?

  • Adds AggregateMetric and AggregateMetrics interfaces and optional metric injection hooks for Accumulator and GroupsAccumulator.
  • Adds execution-side registration of aggregate-owned submetrics using the naming convention agg_expr_{index}_internal_{subphase}_time and the existing aggregate label.
  • Registers submetrics lazily and keeps their identity scoped to aggregate expression, subphase, and partition.
  • Propagates aggregate submetrics through streaming, grouped hash, ordered hash, and replacement accumulator creation paths.
  • Instruments array_agg(DISTINCT ...) with an internal_distinct timer for its distinct-value handling.
  • Updates GroupsAccumulatorAdapter so metric-enabled legacy accumulators can time grouped aggregate work once per bounded input chunk rather than once per group, while excluding adapter-owned filtering, slicing, size accounting, and state materialization from the internal timer.
  • Preserves the existing uninstrumented adapter path for accumulators that do not expose a grouped submetric.
  • Adds Time::add_duration_exact so repeated internal measurements do not round each zero-duration recording up to one nanosecond.
  • Documents the ownership, naming, partition aggregation, and relationship between internal submetrics and the existing aggregate call-boundary timers.
  • Adds a data-free high-cardinality array_agg(DISTINCT) SQL benchmark and focused GroupsAccumulatorAdapter, nth_value, and approx_percentile_cont benchmarks.

Are these changes tested?

Yes. The patch adds tests covering:

  • array_agg(DISTINCT) metric recording for small and large update batches and merge batches.
  • Metric caching, multiple subphases, exact zero-duration recording, and aggregation across partitions.
  • Stable metric names and aggregate labels for repeated array_agg(DISTINCT ...) expressions.
  • Multi-partition collection of internal submetrics and the absence of internal metrics for an aggregate that does not request them.
  • Grouped hash execution reporting the array_agg(DISTINCT) internal submetric.
  • Legacy grouped accumulator metric resolution and recording for conversion, update, and merge paths.
  • Exclusion of adapter-owned size accounting and state materialization from the aggregate-owned timer.
  • Correct cleanup of successfully processed group indices when a later group update fails.
  • Preservation of unwind auto traits and accumulator size accounting.
  • Time merge behavior for a zero-duration measurement.

The patch also adds benchmarks for the uninstrumented GroupsAccumulatorAdapter path, ordered nth_value, approx_percentile_cont, and a 1M-group / two-row-per-group array_agg(DISTINCT) SQL workload.

Are there any user-facing changes?

Yes. EXPLAIN ANALYZE Dev metrics can now include optional aggregate-owned internal timing metrics when an aggregate exposes them.

These metrics use names of the form agg_expr_{index}_internal_{subphase}_time and carry the owning aggregate's aggregate label. For array_agg(DISTINCT ...), the new metric is agg_expr_{index}_internal_distinct_time.

Internal submetrics supplement the existing update, merge, state, and evaluate timers; they are not a non-overlapping breakdown and should not be added to those phase timings.

The metrics documentation is updated accordingly.

LLM-generated code disclosure

This PR includes LLM-generated code and comments. All LLM-generated content has been manually reviewed.

@github-actions github-actions Bot added documentation Improvements or additions to documentation logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates functions Changes to functions implementation physical-plan Changes to the physical-plan crate auto detected api change Auto detected API change labels Sep 8, 2026
@codecov-commenter

codecov-commenter commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.15642% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.92%. Comparing base (c2cf289) to head (2171d62).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...gregate-common/src/aggregate/groups_accumulator.rs 80.82% 43 Missing and 13 partials ⚠️
...n/physical-plan/src/aggregates/aggregate_stream.rs 88.15% 6 Missing and 12 partials ⚠️
...hysical-plan/src/aggregates/grouped_hash_stream.rs 86.95% 7 Missing and 11 partials ⚠️
datafusion/functions-aggregate/src/array_agg.rs 91.91% 0 Missing and 8 partials ⚠️
...plan/src/aggregates/aggregate_hash_table/common.rs 80.00% 0 Missing and 4 partials ⚠️
datafusion/physical-expr/src/aggregate.rs 93.75% 0 Missing and 1 partial ⚠️
.../aggregates/aggregate_hash_table/common_ordered.rs 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25051      +/-   ##
==========================================
+ Coverage   81.90%   81.92%   +0.01%     
==========================================
  Files        1134     1134              
  Lines      425261   426487    +1226     
  Branches   425261   426487    +1226     
==========================================
+ Hits       348325   349399    +1074     
- Misses      56295    56370      +75     
- Partials    20641    20718      +77     

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

@kosiew
kosiew marked this pull request as ready for review September 8, 2026 07:33
@kosiew
kosiew requested a review from rluvaton September 8, 2026 07:34

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

Thanks @kosiew , here is a suggestion:

DistinctArrayAggAccumulator has no GroupsAccumulator, so it always runs through GroupsAccumulatorAdapter. That adapter calls the factory once per new group and update_batch once per group per batch, which means this PR adds, per group: a parking_lot::Mutex lock + HashMap lookup + Arc clone on construction (via set_metrics -> metric("distinct")), and two Instant::now() calls plus an atomic fetch_add on every update_batch. The existing update timer only fires once per batch around the whole adapter call, so this is a new cost class for high-cardinality GROUP BY k, array_agg(DISTINCT v) with small groups.

There is also a reporting artifact: Time::add_duration clamps each addition to at least 1 ns, so with millions of one-row groups the internal_distinct time is inflated by ~1 ns per group per batch.

Could you run a benchmark against main for something like

SELECT k, array_agg(DISTINCT v) FROM t GROUP BY k

with ~1M distinct k and 1-2 rows per group, and post the numbers? If it regresses, one mitigation is to resolve the metric once in the factory closure rather than per accumulator, so set_metrics does not take the lock per group:

let agg_expr_captured = Arc::clone(agg_expr);
let factory = move || {
    agg_expr_captured.create_accumulator_with_metrics(Arc::clone(&metrics))
};

becomes something where AggregateSubMetrics::metric is called once up front and the accumulator receives the resolved Arc<dyn AggregateMetric> (or a small pre-resolved struct). The per-batch Instant::now() pair is harder to avoid without restructuring; if the numbers show it matters, skipping the timer for batches below a small row threshold would keep the metric meaningful for the cases where it is actually informative.

@kosiew
kosiew marked this pull request as draft September 9, 2026 04:06
@kosiew kosiew mentioned this pull request Sep 9, 2026
- Added harness implementation:
- `benchmarks/sql_benchmarks/array_agg_distinct/array_agg_distinct.suite` – defines the benchmark suite, test parameters, and execution configuration for the data‑free SQL harness.
- `benchmarks/sql_benchmarks/array_agg_distinct/benchmarks/q01.benchmark` – contains the specific query benchmark (`q01`) that exercises the `array_agg(DISTINCT …)` workload without requiring any input data.

- Workload characteristics:
- Simulates **2 M range rows** → **1 M groups**.
- Each group contains **2 rows** with **2 distinct values**, providing a realistic yet data‑free test scenario for aggregation performance.
- add bench.sh wrapper for array_agg_distinct
Reduce the data-free grouped array_agg(DISTINCT) workload while preserving its two-rows-per-group and two-distinct-values-per-group shape.
@kosiew
kosiew force-pushed the aggmetrics-06-23570 branch from 87e724f to 8550e2a Compare September 10, 2026 03:16
@github-actions github-actions Bot removed the auto detected api change Auto detected API change label Sep 10, 2026
@kosiew

kosiew commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@jayzhan211

adapter calls the factory once per new group and update_batch once per group per batch

I amended the GroupsAccumulatorAdapter to time the entire grouped dispatch once per input batch, while each DistinctArrayAggAccumulator executes the same update logic without individually recording another timer.

Could you run a benchmark against main for something like ...with ~1M distinct k and 1-2 rows per group, and post the numbers?

I added a benchmark and rebased the benchmark to before adding internal submetrics so I can compare benchmark before vs after.

empty commit is the mark right before the commits adding internal submetrics.

I duplicated this branch to another PR and ran benchmark there.

1M distinct

-> did not finish before repo killed it

100k

run 1

group                     HEAD                                   test-aggmetrics
-----                     ----                                   ---------------
array_agg_distinct/Q01    1.00      5.1±0.03ms        ? ?/sec    1.04      5.3±0.06ms        ? ?/sec

run 2

group                     HEAD                                   test-aggmetrics
-----                     ----                                   ---------------
array_agg_distinct/Q01    1.01      4.8±0.06ms        ? ?/sec    1.00      4.8±0.02ms        ? ?/sec

In this PR's benchmark run

group                     HEAD                                   aggmetrics-06-23570
-----                     ----                                   -------------------
array_agg_distinct/Q01    1.06      5.2±0.12ms        ? ?/sec    1.00      4.9±0.06ms        ? ?/sec

@kosiew

kosiew commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

run benchmark sql

env:
  CARGO_BUILD_JOBS: 1
  BENCH_NAME: array_agg_distinct
  BENCH_QUERY: 1
baseline:
  ref: "a997d8313e4c85c5a6ff14ada6876809592df42b"
changed:
  ref: "7c3ebca0bebd388356d6c63103f1163b479f9766"

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5613903840-2297-bpjgk 6.12.94+ #1 SMP Fri Jul 17 09:42:57 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing 7c3ebca (7c3ebca) to a997d83 diff

Run configuration
run benchmark sql
env:
  BENCH_NAME: "array_agg_distinct"
  BENCH_QUERY: "1"
  CARGO_BUILD_JOBS: "1"
baseline:
  ref: "a997d8313e4c85c5a6ff14ada6876809592df42b"
changed:
  ref: "7c3ebca0bebd388356d6c63103f1163b479f9766"

Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing 7c3ebca (7c3ebca) to a997d83 diff

Run configuration
run benchmark sql
env:
  BENCH_NAME: "array_agg_distinct"
  BENCH_QUERY: "1"
  CARGO_BUILD_JOBS: "1"
baseline:
  ref: "a997d8313e4c85c5a6ff14ada6876809592df42b"
changed:
  ref: "7c3ebca0bebd388356d6c63103f1163b479f9766"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                     HEAD                                   aggmetrics-06-23570
-----                     ----                                   -------------------
array_agg_distinct/Q01    1.06      5.2±0.12ms        ? ?/sec    1.00      4.9±0.06ms        ? ?/sec

Resource Usage

sql — base (merge-base)

Metric Value
Wall time 3100.7s
Peak memory 354.1 MiB
Avg memory 1.1 MiB
CPU user 61.5s
CPU sys 2.3s
Peak spill 0 B

sql — branch

Metric Value
Wall time 3465.7s
Peak memory 387.8 MiB
Avg memory 1.0 MiB
CPU user 59.9s
CPU sys 2.2s
Peak spill 0 B

File an issue against this benchmark runner

@kosiew
kosiew marked this pull request as ready for review September 10, 2026 08:03
@kosiew
kosiew requested a review from jayzhan211 September 10, 2026 09:08
@jayzhan211

jayzhan211 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

@kosiew , here is another suggestion:

update_batch's timer is paid per row on the merge and convert-to-state paths

merge_batch fans out to self.update_batch(&[val]) once per state row (array_agg.rs:1044), and update_batchupdate_batch_impl(values, true) does Instant::now() + elapsed() + an atomic add on each call. I confirmed it with a probe: a 5-row List<Int32> state batch produces 5 metric recordings inside a single merge_batch.

Two problems:

  1. Metrics are always collected (analyze_level only gates display), so distinct_metric is always Some and every Final/FinalPartitioned array_agg(DISTINCT) merge now pays two clock reads plus an atomic per row. That's the same per-row overhead the grouped_update_batch_metric / update_batch_grouped split was added to remove on the update path — the merge path just didn't get the treatment.
  2. Merge time is recorded into agg_expr_N_internal_distinct_time while also being counted by the existing merge timer. That contradicts metrics.md: "Grouped accumulation records this once per input batch, rather than once per group", and "complement the update, merge, state, and evaluate timers rather than subdividing".

Suggest timing once at the batch boundary and using the untimed impl inside:

     fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> {
         if states.is_empty() {
             return Ok(());
         }

         assert_eq_or_internal_err!(states.len(), 1, "expects single state");

+        // Time the whole merge once: the per-element calls below must not each
+        // take a timestamp.
+        let distinct_metric = self.distinct_metric.clone();
+        let distinct_start = distinct_metric.as_ref().map(|_| Instant::now());
+
         // The DISTINCT state is `List<value>`.
-        states[0]
+        let result = states[0]
             .as_list::<i32>()
             .iter()
             .flatten()
-            .try_for_each(|val| self.update_batch(&[val]))
+            .try_for_each(|val| self.update_batch_impl(&[val], false));
+
+        if let (Some(metric), Some(start)) = (distinct_metric, distinct_start) {
+            metric.add_duration(start.elapsed());
+        }
+        result
     }

The same shape exists in GroupsAccumulatorAdapter::convert_to_state
(functions-aggregate-common/src/aggregate/groups_accumulator.rs:478): it builds a
fresh accumulator per row through the factory — so set_metricsmetric() also
runs per row — and then calls update_batch, timing each row separately on the
skip-partial-aggregation path.

-            converted_accumulator.update_batch(&values_to_accumulate)?;
+            // Row-at-a-time conversion: use the untimed variant so this path
+            // does not take a timestamp per row.
+            converted_accumulator.update_batch_grouped(&values_to_accumulate)?;

If you want convert-to-state time attributed to the subphase, hoist a single
Instant around the for row_idx in 0..num_rows loop using the metric from the
first converted accumulator, the way invoke_per_accumulator does.

…comprehensive tests

- Introduce optional `AggregateMetric(s)` API with default no‑op setters for backward compatibility.
- Add lazy‑stable internal metrics: `agg_expr_{i}_internal_{subphase}_time` for fine‑grained phase tracking.
- Wire the metrics across all execution paths: stream, grouped, hash, ordered, and replay.
- Implement `array_agg(DISTINCT)` distinct‑timer to measure distinct‑aggregation latency.
- Extend test coverage:
- Partition merge scenarios.
- Repeated DISTINCT expression handling.
- Update documentation to reflect new API, metric naming, and wiring details.
…ove performance

Cache one adapter per subphase; no repeated wrapper allocation
- Introduce a single reusable adapter instance per subphase, eliminating the need to allocate multiple wrapper objects. This reduces memory churn and improves performance during metric collection.

Skip `Arc` clone/clock read when no metric
- Detect when there is no active metric to record and skip the unnecessary `Arc` clone and system clock reads. This lowers CPU overhead for subphases that don't emit metrics.

Make submetric implementation details private
- Move internal helpers and type-specific logic for submetrics behind `pub(super)` or module‑level privacy boundaries. This hides implementation details from external users, enhancing encapsulation and reducing the risk of misuse.
- Updated the test to verify that both `internal_distinct` timers are positive (`>0`)
- This resolves the blocker where timers could be zero, causing test failures
- Ensures correct initialization and behavior of the timer logic
- Improves the reliability and confidence of timer‑related functionality
… partition display, and empty input behavior

- Clarify identity/cardinality format as **(expr index, subphase, partition)**
- Note that replacement accumulators share a single timer
- Explain how partitions are combined in the normal display
- Document that construction‑time requests can cause metrics to appear on empty input
- Counts the retained metric‑handle field in the accumulator’s size.
- Adds a regression test to verify the size calculation under various inputs.
- Updates the exact distinct‑size expectation to match the corrected behavior.
@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5633496578-2317-lqx8h 6.12.94+ #1 SMP Tue Aug 4 08:44:15 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing eab374e (eab374e) to 93f2dae diff

Run configuration
run benchmark sql
env:
  BENCH_NAME: "array_agg_distinct"
  BENCH_QUERY: "1"
  CARGO_BUILD_JOBS: "1"
baseline:
  ref: "93f2dae"
changed:
  ref: "eab374e"

Results will be posted here when complete


File an issue against this benchmark runner

- Internal submetrics may overlap enclosing phase timers.
- They are supplementary diagnostics.
- Do not add them as phase‑time breakdowns.
@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing eab374e (eab374e) to 93f2dae diff

Run configuration
run benchmark sql
env:
  BENCH_NAME: "array_agg_distinct"
  BENCH_QUERY: "1"
  CARGO_BUILD_JOBS: "1"
baseline:
  ref: "93f2dae"
changed:
  ref: "eab374e"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                     HEAD                                   aggmetrics-06-23570
-----                     ----                                   -------------------
array_agg_distinct/Q01    1.00     27.6±0.45ms        ? ?/sec    1.01     27.8±0.41ms        ? ?/sec

Resource Usage

sql — base (merge-base)

Metric Value
Wall time 2945.6s
Peak memory 570.0 MiB
Avg memory 1.5 MiB
CPU user 71.7s
CPU sys 2.3s
Peak spill 0 B

sql — branch

Metric Value
Wall time 2975.7s
Peak memory 618.4 MiB
Avg memory 1.8 MiB
CPU user 71.5s
CPU sys 2.4s
Peak spill 0 B

File an issue against this benchmark runner

@kosiew

kosiew commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

@jayzhan211

c79745312f^..68945a9a20 removes the per-row metric work on both paths.

  • DistinctArrayAggAccumulator::merge_batch_impl now calls the untimed update_batch_impl(..., false) for each list-state row and records internal_distinct around the complete direct merge once. The existing merge timer remains the call-boundary timer; internal_distinct remains the aggregate-owned deduplication submetric.
  • Added Accumulator::merge_batch_grouped, matching update_batch_grouped. The grouped adapter invokes this untimed entry point and owns the grouped submetric timing, so grouped merge no longer starts a timer or atomically records a duration per group/state row.
  • GroupsAccumulatorAdapter::convert_to_state now calls update_batch_grouped, not update_batch. It prepares factory/slice/filter work outside the submetric, times only aggregate-owned deduplication, and emits one accumulated metric update for the logical conversion batch. Preparation is bounded in 64-row chunks, so timer reads scale with chunks rather than rows while avoiding retention of all prepared arrays.
  • The legacy grouped factory shares an OnceLock metric-handle cache. Only its first accumulator receives set_metrics and resolves metric("distinct"); conversion, update, and merge do not resolve it per row/group.

Regression coverage added:

  • distinct_accumulator_records_merge_metric_once: a three-row List<Int32> merge records one internal duration.
  • legacy_grouped_distinct_merge_records_metric_once: grouped merge resolves the metric once and records one duration for three state rows.
  • adapter_convert_to_state_records_metric_once plus the legacy grouped conversion/update test: conversion records once and metric lookup is not repeated per conversion row.
  • adapter_convert_to_state_excludes_state_materialization_from_metric: verifies factory/input preparation/state materialization remain outside the aggregate-owned submetric.

Why the suggested shape was not adopted wholesale

  • We intentionally do not use one uninterrupted Instant around all of convert_to_state. That would either retain prepared slice/filter arrays for the entire input batch, making memory proportional to row count, or include factory, slice/filter preparation, state, and result materialization in an aggregate-owned deduplication metric. The 64-row preparation chunks retain bounded memory and time only accumulator invocation. The remaining timestamp cost is once per chunk, rather than once per row, and the accumulated duration is committed with one metric update per logical batch.
  • We retain agg_expr_N_internal_distinct_time during merge. merge is the aggregate call-boundary timer, while internal_distinct is the aggregate-owned deduplication diagnostic. They intentionally overlap; the internal metric complements the phase timer and is not an additive subdivision of it. Removing merge-side internal_distinct would make that diagnostic depend on execution phase rather than report all distinct-deduplication work. metrics.md now explicitly says internal submetrics may overlap phase timers and must not be added to phase timings as a breakdown.

Benchmark results for 1M distinct groups

@kosiew
kosiew marked this pull request as ready for review September 11, 2026 13:18
@jayzhan211

jayzhan211 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

@kosiew , here is a suggestion:

invoke_per_accumulator: state.size() is inside the timed region

groups_accumulator.rs:322-335 starts the timer, then calls state.size() twice per group inside it:

let start = grouped_update_metric.as_ref().map(|_| Instant::now());
let chunk_result: Result<()> = (|| {
    for (group_idx, values) in values_to_accumulate {
        let state = &mut self.states[group_idx];
        sizes_pre += state.size();          // <-- adapter accounting, timed
        f(state.accumulator.as_mut(), &values)?;
        state.indices.clear();
        sizes_post += state.size();         // <-- adapter accounting, timed
    }
    Ok(())
})();

AccumulatorState::size() calls Accumulator::size(), and DistinctArrayAggAccumulator::size() (array_agg.rs:1227) walks state.group_rows summing r.row().data().len(), plus converter.size() and rows_buffer.size(). That's two O(D) passes per group inside the timer, where D is the group's distinct-set size, against O(rows-in-group) of actual distinct work. As D grows across batches — precisely the array_agg(DISTINCT) case this metric exists to diagnose — the accounting dominates the number being reported, and it grows with result cardinality rather than with distinct work.

This also contradicts the comment three lines above ("slicing and filtering are adapter work, not aggregate-owned subphase work") and is inconsistent with convert_to_state, which deliberately keeps state() outside the timer and has adapter_convert_to_state_excludes_state_materialization_from_metric to prove it.

Splitting into three passes keeps the one-Instant-pair-per-chunk property you were after:

-                let start = grouped_update_metric.as_ref().map(|_| Instant::now());
-                let chunk_result: Result<()> = (|| {
-                    for (group_idx, values) in values_to_accumulate {
-                        let state = &mut self.states[group_idx];
-                        sizes_pre += state.size();
-                        f(state.accumulator.as_mut(), &values)?;
-
-                        // clear out the state so they are empty for next
-                        // iteration
-                        state.indices.clear();
-                        sizes_post += state.size();
-                    }
-                    Ok(())
-                })();
+                // Size accounting is adapter work: keep it out of the timer.
+                for (group_idx, _) in &values_to_accumulate {
+                    sizes_pre += self.states[*group_idx].size();
+                }
+
+                let start = grouped_update_metric.as_ref().map(|_| Instant::now());
+                let mut chunk_result = Ok(());
+                for (group_idx, values) in &values_to_accumulate {
+                    chunk_result =
+                        f(self.states[*group_idx].accumulator.as_mut(), values);
+                    if chunk_result.is_err() {
+                        break;
+                    }
+                }
                 if let Some(start) = start {
                     aggregate_duration += start.elapsed();
                 }
                 chunk_result?;
+
+                for (group_idx, _) in &values_to_accumulate {
+                    let state = &mut self.states[*group_idx];
+                    // clear out the state so they are empty for next iteration
+                    state.indices.clear();
+                    sizes_post += state.size();
+                }

A regression test in the shape of adapter_convert_to_state_excludes_state_materialization_from_metric — an accumulator whose size() sleeps, asserting the recorded duration stays small — would lock this in.

…handling in GroupsAccumulatorAdapter

The `sizes_pre` accumulation is now performed before the timer starts,
preventing adapter work from being measured as part of the aggregate
duration. The inner accumulation loop now breaks early on error and uses
borrowed references, improving error propagation and reducing unnecessary copies.
- Added `groups_accumulator.rs` regression test file.
- Introduced a sleeping `SlowSizeAccumulator` that delays `size()` by 50 ms.
- Asserts that the grouped metric excludes the 50 ms `size()` delay, confirming correct grouping behavior.
@kosiew
kosiew marked this pull request as draft September 11, 2026 16:24
… add retry regression

- Clear successful groups before propagating later-group error to prevent contaminating subsequent error handling.
- Added retry regression test to catch duplicate update scenarios.
@kosiew

kosiew commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

@jayzhan211

invoke_per_accumulator: state.size() is inside the timed region

e6e4a85 moved sizes_pre collection before the timer and sizes_post collection after it. Only f(accumulator, values) is timed, retaining one timer pair per prepared chunk.

A regression test ... an accumulator whose size() sleeps, asserting the recorded duration stays small — would lock this in.

e7d2d7e added adapter_grouped_update_excludes_size_accounting_from_metric regression test

@kosiew
kosiew marked this pull request as ready for review September 12, 2026 09:43
@jayzhan211

Copy link
Copy Markdown
Contributor

invoke_per_accumulator regresses the adapter path ~20% for aggregates with no submetric

The chunked rewrite in datafusion/functions-aggregate-common/src/aggregate/groups_accumulator.rs:300-350 replaces one pass over groups_with_rows with a per-64-group Vec<(usize, Vec<ArrayRef>)> plus three passes over it. It exists only so the timer can wrap a bounded batch of f calls, but it runs unconditionally — and grouped_update_metric is Some for exactly one aggregate today. Everything else on the adapter path (string_agg, ordered array_agg, nth_value, approx_percentile_cont, every third-party UDAF) pays the cost and gets no metric.

Measured on this branch vs. b239d041b5: GroupsAccumulatorAdapter::update_batch, 8192-row batch, 8192 groups (1 row/group), MaxAccumulator, release, 300 iterations:

µs/iter
main (b239d041b5) 383.1 / 386.7 / 387.4
this PR 462.3 / 472.1 / 478.5
this PR + fast path below 399.1 / 411.7 / 412.4

convert_to_state is unaffected (~665 µs/iter both ways), so only invoke_per_accumulator needs the guard:

         let result: Result<()> = (|| {
+            if grouped_update_metric.is_none() {
+                // Untimed fast path: identical to the pre-metrics loop.
+                for (&group_idx, offsets) in
+                    groups_with_rows.iter().zip(offsets.windows(2))
+                {
+                    let state = &mut self.states[group_idx];
+                    sizes_pre += state.size();
+                    let values_to_accumulate = slice_and_maybe_filter(
+                        &values,
+                        opt_filter.as_ref().map(|f| f.as_boolean()),
+                        offsets,
+                    )?;
+                    f(state.accumulator.as_mut(), &values_to_accumulate)?;
+                    let state = &mut self.states[group_idx];
+                    state.indices.clear();
+                    sizes_post += state.size();
+                }
+                return Ok(());
+            }
             // Keep preparation bounded to avoid retaining one filtered array per
             // group. Time only accumulator invocation: slicing and filtering
             // are adapter work, not aggregate-owned subphase work.

That's the exact patch I benchmarked. Note it also makes time_grouped_update (currently true at both call sites) genuinely dead — worth removing in the same pass.

- GroupsAccumulatorAdapter: uninstrumented aggregates skip chunk/Vec path.
- Removed dead `time_grouped_update`.
- Timed submetric path unchanged.
- `convert_to_state` unchanged.
Adds a new benchmark `groups_accumulator_adapter` to measure the performance of the uninstrumented update path of the `GroupsAccumulatorAdapter`. This benchmark is integrated into the existing benchmarks for `nth_value` and `approx_percentile_cont` to verify performance characteristics with grouped data (8192 groups).
@kosiew kosiew mentioned this pull request Sep 12, 2026
@kosiew
kosiew force-pushed the aggmetrics-06-23570 branch from 80fd18b to 6a5c245 Compare September 14, 2026 08:14
@kosiew

kosiew commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

run benchmark groups_accumulator_adapter nth_value percentile_cont

baseline:
  ref: "93f2dae"
changed:
  ref: "6a5c245"

@kosiew

kosiew commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@jayzhan211

invoke_per_accumulator regresses the adapter path ~20% for aggregates with no submetric

Implemented fast path

@kosiew
kosiew marked this pull request as draft September 14, 2026 12:51
@apache apache deleted a comment from adriangbot Sep 14, 2026
@apache apache deleted a comment from adriangbot Sep 14, 2026
@apache apache deleted a comment from adriangbot Sep 14, 2026
@apache apache deleted a comment from adriangbot Sep 14, 2026
@apache apache deleted a comment from adriangbot Sep 14, 2026
@apache apache deleted a comment from adriangbot Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation functions Changes to functions implementation logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add aggregate specific metrics

4 participants