Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,16 @@ struct AggregateFunctionTimeseriesLinearRegressionTraits
/// in a window reaches this value; below it, recomputing the window each grid point is cheaper. The
/// `timeseries_to_grid_two_stack_vs_recompute` example measures the crossover by driving the real finalize over
/// a larger-than-cache dataset (so recompute pays the same per-point cache misses as the real query) and puts
/// it around 8-10 populated buckets per window, matching an end-to-end A/B. Sparse data needs no margin here:
/// the density factor in `getStackSizeForTwoStacks` already converts `buckets_per_window` to the populated average.
static constexpr size_t AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS = 10;
/// it at 4 populated buckets per window. Sparse data needs no margin here: the density factor in
/// `getStackSizeForTwoStacks` already converts `buckets_per_window` to the populated average.
static constexpr size_t AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS = 4;

/// Hard cap: regardless of average density, use two-stacks once a window can hold this many buckets. The
/// density estimate in `getStackSizeForTwoStacks` is an average, but density is not uniform - a low average
/// can still hide a locally dense window whose recompute folds far more buckets than the average. Beyond this
/// capacity we stop trusting the average and bound the worst case: at this size a fully dense window already
/// makes recompute ~2x slower than two-stacks (measured by the `timeseries_to_grid_two_stack_vs_recompute` example).
static constexpr size_t BPW_TO_FORCE_TWO_STACKS = 20;
static constexpr size_t BPW_TO_FORCE_TWO_STACKS = 12;

@vitlibar vitlibar Sep 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Recent measurements after our optimizations showed different values than what were measured originally:

AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS (two-stacks first wins) = 4
BPW_TO_FORCE_TWO_STACKS (two-stacks > 2x faster) = 12

So these thresholds in the code should be updated

};


Expand Down
20 changes: 10 additions & 10 deletions tests/performance/timeseries_to_grid_aggregation_functions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@

<!--
Benchmarks the `timeSeries*ToGrid` aggregate functions, focusing on the grid-filling stage
(`doInsertResultInto`). Only the linear-regression functions (`timeSeriesDerivToGrid` /
`timeSeriesPredictLinearToGrid`) choose there between recomputing each window and the sliding two-stack
queue, governed by `AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS` / `BPW_TO_FORCE_TWO_STACKS`; the other
functions always recompute.
(`doInsertResultInto`). Of the functions benchmarked here, only the linear-regression functions
(`timeSeriesDerivToGrid` / `timeSeriesPredictLinearToGrid`) choose there between recomputing each window
and the sliding two-stack queue, governed by `AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS` /
`BPW_TO_FORCE_TWO_STACKS`; the other functions here always recompute.

The grid is long (10001 points) and there is roughly one sample per grid step, so the number of populated
buckets in a window is about `window / step` and the same buckets are re-visited at every grid point. This
makes the per-grid-point work in `doInsertResultInto` dominate over the one-off bucketing/aggregation of the
samples (which would instead dominate if many samples fell into each bucket).

`window` is swept across the two-stack thresholds (with ~1 populated bucket per step, the average populated
buckets per window ~= window/step): 50/step=5 (below AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS=10 -> recompute),
160/step=16 (between the thresholds -> two-stacks via the average path), and 1000/step=100
(>= BPW_TO_FORCE_TWO_STACKS=20 -> two-stacks via the hard cap). The data is dense (~1 sample per bucket), so
buckets per window ~= window/step): 30/step=3 (below AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS=4 -> recompute),
80/step=8 (between the thresholds -> two-stacks via the average path), and 1000/step=100
(>= BPW_TO_FORCE_TWO_STACKS=12 -> two-stacks via the hard cap). The data is dense (~1 sample per bucket), so
every query uses the range-scan bucket iteration; the sparse scenario below covers the collect-and-sort path.
Functions are ordered from the cheapest per-bucket merge (resample, instant, changes) to the most expensive
(linear regression).
Expand All @@ -28,8 +28,8 @@
<substitution>
<name>window</name>
<values>
<value>50</value>
<value>160</value>
<value>30</value>
<value>80</value>

@vitlibar vitlibar Sep 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Also the performance and stateless tests were adjusted to follow their claimed scenarios.

<value>1000</value>
</values>
</substitution>
Expand Down Expand Up @@ -91,7 +91,7 @@
<query>SELECT id, timeSeriesPredictLinearToGrid(0, 100000, 10, {window}, 60)(timestamp, value) FROM ts_grid GROUP BY id FORMAT Null</query>

<!-- Sort-path coverage on the sparse table, at a single fixed window=1000 (buckets_per_window=100 >=
BPW_TO_FORCE_TWO_STACKS=20, so the regression functions force two-stacks): the cheapest function (resample)
BPW_TO_FORCE_TWO_STACKS=12, so the regression functions force two-stacks): the cheapest function (resample)
plus the two regression functions. The literal window (no {window}) means each runs once, not per sweep. -->
<query>SELECT id, timeSeriesResampleToGridWithStaleness(0, 100000, 10, 1000)(timestamp, value) FROM ts_grid_sparse GROUP BY id FORMAT Null</query>
<query>SELECT id, timeSeriesDerivToGrid(0, 100000, 10, 1000)(timestamp, value) FROM ts_grid_sparse GROUP BY id FORMAT Null</query>
Expand Down
25 changes: 13 additions & 12 deletions tests/queries/0_stateless/04319_timeseries_two_stacks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ SELECT timeSeriesMinToGrid(100, 120, 2, 51)(timestamp, value)

DROP TABLE ts_two_stacks;

-- Two-stacks selected via the AVERAGE-density path, not the hard cap: step=1, window=15 -> buckets_per_window=15
-- (below BPW_TO_FORCE_TWO_STACKS=20), but a sample in every bucket makes the average populated buckets per window
-- (~15) >= AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS=10, so the regression functions pick two-stacks through the
-- average condition. The full density (populated / bucket_count = 1.0 >= BUCKET_DENSITY_TO_ENABLE_RANGE_SCAN=0.35)
-- Two-stacks selected via the AVERAGE-density path, not the hard cap: step=1, window=10 -> buckets_per_window=10
-- (below every BPW_TO_FORCE_TWO_STACKS: 12 for the regression functions, 18 for the extremum functions), but a
-- sample in every bucket makes the average populated buckets per window (10) reach
-- AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS (4 for both), so these functions pick two-stacks through the average
-- condition. The full density (populated / bucket_count = 1.0 >= BUCKET_DENSITY_TO_ENABLE_RANGE_SCAN=0.35)
-- also drives the range-scan bucket iteration. Quadratic values make the per-window slope vary, so a faulty moment
-- merge would diverge from the fresh recompute.
DROP TABLE IF EXISTS ts_dense;
Expand All @@ -97,13 +98,13 @@ INSERT INTO ts_dense SELECT 186 + number, number * number FROM numbers(35); --

-- Eviction on the average-path two-stacks must still match a fresh single-grid-point aggregate over the same window.
SELECT 'dense average-path two-stack values match a fresh single-grid-point aggregate (all 1):';
SELECT abs(timeSeriesDerivToGrid(200, 220, 1, 15)(timestamp, value)[21]
- timeSeriesDerivToGrid(220, 220, 1, 15)(timestamp, value)[1]) < 1e-9 FROM ts_dense;
SELECT abs(timeSeriesPredictLinearToGrid(200, 220, 1, 15, 10)(timestamp, value)[21]
- timeSeriesPredictLinearToGrid(220, 220, 1, 15, 10)(timestamp, value)[1]) < 1e-9 FROM ts_dense;
SELECT timeSeriesMaxToGrid(200, 220, 1, 15)(timestamp, value)[21]
= timeSeriesMaxToGrid(220, 220, 1, 15)(timestamp, value)[1] FROM ts_dense;
SELECT timeSeriesMinToGrid(200, 220, 1, 15)(timestamp, value)[21]
= timeSeriesMinToGrid(220, 220, 1, 15)(timestamp, value)[1] FROM ts_dense;
SELECT abs(timeSeriesDerivToGrid(200, 220, 1, 10)(timestamp, value)[21]
- timeSeriesDerivToGrid(220, 220, 1, 10)(timestamp, value)[1]) < 1e-9 FROM ts_dense;
SELECT abs(timeSeriesPredictLinearToGrid(200, 220, 1, 10, 10)(timestamp, value)[21]
- timeSeriesPredictLinearToGrid(220, 220, 1, 10, 10)(timestamp, value)[1]) < 1e-9 FROM ts_dense;
SELECT timeSeriesMaxToGrid(200, 220, 1, 10)(timestamp, value)[21]
= timeSeriesMaxToGrid(220, 220, 1, 10)(timestamp, value)[1] FROM ts_dense;
SELECT timeSeriesMinToGrid(200, 220, 1, 10)(timestamp, value)[21]
= timeSeriesMinToGrid(220, 220, 1, 10)(timestamp, value)[1] FROM ts_dense;

DROP TABLE ts_dense;
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
-- Tests the memoization in the recompute path (`AggregateFunctionTimeseriesSlidingSum::getCurrentSum`, driven
-- from `AggregateFunctionTimeseriesBase::doInsertResultInto`): the merged window aggregate is reused across
-- consecutive grid points that cover the same set of populated buckets. Here `buckets_per_window` = 10, below
-- `BPW_TO_FORCE_TWO_STACKS` (20), so even `timeSeriesDerivToGrid` (the only function here that could use
-- two-stacks) stays on recompute; the other functions always recompute. A window (1000) much wider than the data
-- extent (300..700) makes a run of grid points share the identical full bucket set (memo hits), while the edges
-- (buckets entering/leaving) force recomputes.
-- `BPW_TO_FORCE_TWO_STACKS` (12), and the sparse data (5 populated of 21 buckets) keeps the average populated
-- buckets per window at 2, below `AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS` (4), so even `timeSeriesDerivToGrid`
-- (the only function here that could use two-stacks) stays on recompute; the other functions always recompute.
-- A window (1000) much wider than the data extent (300..700) makes a run of grid points share the identical full
-- bucket set (memo hits), while the edges (buckets entering/leaving) force recomputes.
SET allow_experimental_time_series_aggregate_functions = 1;
SET allow_experimental_ts_to_grid_aggregate_function = 1;

Expand Down