Skip to content

Add aggregate-internal submetrics - #25051

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

Add aggregate-internal submetrics#25051
kosiew wants to merge 32 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 update, merge, state, and evaluate timers attribute complete accumulator operations to an aggregate expression, but they cannot show how much time an aggregate spends in meaningful internal subphases.

This makes it difficult to diagnose cases such as array_agg(DISTINCT ...), where distinct-value handling can be a significant part of aggregate execution.

This PR adds an optional aggregate-owned submetrics contract so aggregate implementations can expose internal timing while preserving stable ownership by aggregate expression and keeping the existing call-boundary metrics unchanged.

What changes are included in this PR?

This PR:

  • Adds AggregateMetric and AggregateMetrics interfaces and optional set_metrics hooks for Accumulator and GroupsAccumulator.
  • Adds metric-aware accumulator construction in the physical aggregate execution paths.
  • Lazily registers internal metrics using the naming convention agg_expr_{index}_internal_{subphase}_time with the owning aggregate expression attached through the aggregate label.
  • Shares submetric identity across replacement accumulators within a partition and preserves distinct metric instances across aggregate expressions and partitions.
  • Instruments array_agg(DISTINCT ...) with an internal_distinct submetric covering its distinct-value processing.
  • Updates GroupsAccumulatorAdapter so grouped legacy accumulators can record aggregate-owned work once per bounded input chunk rather than once per group, while excluding adapter-owned filtering, slicing, size accounting, and state materialization from the submetric.
  • Adds Time::add_duration_exact so aggregate submetrics can accumulate measured durations without rounding each recording up to one nanosecond.
  • Documents aggregate-internal submetric naming, ownership, partition behavior, lazy registration, and the relationship between internal submetrics and the existing accumulator operation timers.
  • Adds an array_agg_distinct SQL benchmark covering a high-cardinality grouped array_agg(DISTINCT) workload.

Are these changes tested?

Yes. The patch adds tests covering:

  • grouped adapter metric recording after filtering;
  • convert_to_state metric recording;
  • exclusion of state materialization from the internal metric;
  • exclusion of grouped-adapter size accounting from the internal metric;
  • cleanup of successfully processed group indices when a later group update fails;
  • DistinctArrayAggAccumulator metric recording for small and large batches;
  • single metric recording for distinct-state merges;
  • unwind auto-trait preservation;
  • accumulator size accounting after adding the metric handle;
  • first-subphase metric caching;
  • exact zero-duration submetric recording;
  • multiple internal subphases;
  • aggregation of submetrics across partitions;
  • repeated array_agg(DISTINCT ...) expressions retaining separate names and aggregate labels;
  • multi-partition aggregate execution and metric collection;
  • grouped hash execution exposing the array_agg(DISTINCT ...) internal metric;
  • legacy grouped distinct conversion, update, and merge metric behavior; and
  • preservation of zero-duration behavior when merging existing Time metrics.

The patch also adds a data-free SQL benchmark for 1M groups with two rows and two distinct values per group:

SELECT value / 2 AS k, array_agg(DISTINCT value % 2) AS distinct_values
FROM range(2000000)
GROUP BY value / 2;

Are there any user-facing changes?

Yes. EXPLAIN ANALYZE can now expose optional aggregate-owned internal timing metrics when an aggregate requests them.

For array_agg(DISTINCT ...), the distinct-value processing time is reported as:

agg_expr_{index}_internal_distinct_time

with an aggregate label identifying the owning aggregate expression.

These metrics complement the existing update, merge, state, and evaluate timers. They may overlap those enclosing timers and should not be added to them as a timing breakdown.

Aggregates that do not request internal submetrics continue without additional internal metrics.

The accumulator traits also gain default metric hooks, so existing implementations are not required to implement them.

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.39590% with 102 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.94%. Comparing base (b239d04) to head (2b8c4cf).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...gregate-common/src/aggregate/groups_accumulator.rs 81.15% 42 Missing and 10 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.92%   81.94%   +0.01%     
==========================================
  Files        1133     1133              
  Lines      423288   424363    +1075     
  Branches   423288   424363    +1075     
==========================================
+ Hits       346797   347761     +964     
- Misses      55902    55977      +75     
- Partials    20589    20625      +36     

☔ 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.
- Restored shared Time::add min‑1ns behavior.
- Removed exact‑duration API/use.
- Added regression test merge‑zero → recorded 1ns.
- Updated submetric zero test.
…per‑batch 1 ns inflation

- Time::add: legacy min‑1 ns merge unchanged.
- Time::add_duration_exact: restored, scoped API.
- Aggregate submetrics use exact adds → no per‑batch 1 ns inflation.
- Tests cover both contracts.
…ile‑time test for DistinctArrayAggAccumulator

- Updated `AggregateMetric` to implement the `RefUnwindSafe` trait, ensuring safe reference semantics during unwind operations.
- Added a compile‑time test (`distinct_array_agg_accumulator_unwindsafe`) that verifies `DistinctArrayAggAccumulator` maintains both `UnwindSafe` and `RefUnwindSafe` guarantees, preventing panics in error‑recovery scenarios.
- Direct `GroupedHashAggregateStream` usage.
- Multiple groups → adapter per‑group accumulators.
- Asserts positive `agg_expr_0_internal_distinct_time`.
…mprove per-group metrics

- Skip internal DISTINCT timing for batches <16 to reduce overhead.
- Avoid per-group `Instant::now()`, metric `Arc` clone, and atomic updates for small batches.
- Metrics are still recorded for batches >=16 to retain visibility where needed.
- Added threshold tests to verify the new behavior.
…for accumulators

This change introduces two new methods to the Accumulator trait:
1. `grouped_update_batch_metric`: Returns an optional metric that can be used to time grouped updates once per batch instead of per group.
2. `update_batch_grouped`: Updates state when called by a grouped accumulator adapter, with support for using the grouped update metric.

The GroupsAccumulatorAdapter now uses these new methods to avoid timing every per-group call, recording one interval for the full batch instead. This reduces the overhead of metric collection when there are many groups.

DistinctArrayAggAccumulator is updated to take advantage of the new grouped update functionality, skipping per-group timing for deduplication operations.

This refactor improves performance for grouped aggregations by making metric collection proportional to batch count rather than group cardinality.
…tion clone

- Replace direct `Option` clone with proper handling to avoid unnecessary allocations.
- Switch to `datafusion_common::instant::Instant` for more accurate timing measurements.
- Update related logic to ensure correctness and performance improvements.
…etric-count regressions

- array_agg(DISTINCT) merge: one metric timer/state batch.
- convert_to_state: untimed row updates + one conversion timer.
- Added metric-count regressions.
…ng, bound prep chunks, time only accumulator calls, and emit one metric update per logical batch

- Preps slice/filter outside submetric timing.
- Times only accumulator calls.
- Bounded 64‑group prep chunks; avoids unbounded retained arrays.
- One metric update per logical batch.
…pped batch updates and adds regression test

**Details:**
- Bounded 64‑row prep chunks for `convert_to_state`.
- Timer only wraps `update_batch_grouped`.
- Excludes factory, slice/filter, `state/evaluate`, and result materialization steps.
- Single metric update / logical conversion batch per operation.
- Added RED→GREEN filtered multi‑row state‑materialization regression test.
…s, import AggregateMetrics

- **Clear conditional metric lookup** – Refactored the conditional logic that retrieves metrics, removing redundant checks and making the code path easier to follow.
- **Merged adjacent metrics impl blocks** – Consolidated neighboring implementation blocks that handled similar metric types, reducing duplication and improving maintainability.
- **Imported AggregateMetrics; removed qualified repeats** – Added the `AggregateMetrics` import and eliminated unnecessary fully‑qualified references throughout the codebase.
@kosiew
kosiew force-pushed the aggmetrics-06-23570 branch from 7c3ebca to 68945a9 Compare September 11, 2026 08:24
… propagate to accumulators

- Updated `common.rs` to introduce a helper that constructs an `AggregateMetrics` owned by the execution context.
- The helper returns an `Arc<dyn AggregateMetrics>` which is now passed to both the initial and replacement accumulator instances.
- Ensures consistent metric aggregation ownership across accumulator updates, resolving the blocker.
@kosiew
kosiew marked this pull request as draft September 11, 2026 09:50
@kosiew

kosiew commented Sep 11, 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: "93f2dae"
changed:
  ref: "eab374e"

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