From 285c3d7799331ba4fbfc21d6504e5152e026acf2 Mon Sep 17 00:00:00 2001 From: discord9 <55937128+discord9@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:59:12 +0800 Subject: [PATCH] feat(perf): add nullable_wave distribution and nullable range-fn case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a nullable_wave value distribution to the direct-SST fixture generator: every null_every-th sample of each series is NULL, counted per series so every series carries the same null ratio on every layout. New case tests/perf/query_cases/promql_nullable_range_fn: 128 series x 780 timestamps at 15s cadence, nullable_table (25% NULL, null_every=4) and dense_table (identical, 0 NULL) as control. Seven TQL queries cover rate/increase/delta and count/last_over_time at [5m], rate at [1m], and a dense control query. Every window of every series takes the nullable branch (no window is entirely NULL or NULL-free). Regression evidence for #9234. The case is selectable by explicit path; not added to DEFAULT_CASES. Review: .e-agent/greptimedb-query-optimization-review-2026-09-17.md ยง4.4. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit 5a73f6f21f05cf969c2425ffc5cfbed8e6878c17) Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/cmd/src/bin/query_perf_fixture/case.rs | 13 + .../src/bin/query_perf_fixture/direct_sst.rs | 289 +++++++++++++++++- tests/perf/fixture-format.md | 28 ++ .../promql_nullable_range_fn/case.toml | 183 +++++++++++ 4 files changed, 502 insertions(+), 11 deletions(-) create mode 100644 tests/perf/query_cases/promql_nullable_range_fn/case.toml diff --git a/src/cmd/src/bin/query_perf_fixture/case.rs b/src/cmd/src/bin/query_perf_fixture/case.rs index 0f5239a6b51..199ac9f8119 100644 --- a/src/cmd/src/bin/query_perf_fixture/case.rs +++ b/src/cmd/src/bin/query_perf_fixture/case.rs @@ -548,6 +548,19 @@ pub(super) enum Distribution { }, #[serde(rename = "deterministic_wave")] DeterministicWave { min: f64, max: f64 }, + /// Wave values with periodic NULLs: every `null_every`-th sample of each + /// series is NULL, and `0` writes no NULLs. + /// + /// The cadence is counted in samples of the series rather than in generated + /// rows, so every series carries the same null ratio on every layout; a row + /// cadence stays constant per series whenever `series_count` is a multiple of + /// `null_every`. + #[serde(rename = "nullable_wave")] + NullableWave { + min: f64, + max: f64, + null_every: usize, + }, } #[derive(Debug, Deserialize, Serialize)] diff --git a/src/cmd/src/bin/query_perf_fixture/direct_sst.rs b/src/cmd/src/bin/query_perf_fixture/direct_sst.rs index 27a6b4109cd..c601331656d 100644 --- a/src/cmd/src/bin/query_perf_fixture/direct_sst.rs +++ b/src/cmd/src/bin/query_perf_fixture/direct_sst.rs @@ -214,6 +214,27 @@ fn wave_value(min: f64, max: f64, row: usize) -> f64 { min + span * (0.5 - 0.5 * (std::f64::consts::TAU * phase).cos()) } +/// Position of the generated row inside its own series sample sequence. +/// +/// [Distribution::NullableWave] counts its null cadence in these positions so that +/// the null pattern is deterministic per series instead of per generated row. +fn series_sample_index( + layout: &LayoutConfig, + sst_idx: usize, + base_row: usize, + row: usize, +) -> usize { + let series_count = layout.series_count.get(); + match layout.series_layout.as_str() { + // Rows of one series are `series_count` generated rows apart. + "round_robin" | "timestamp_major" => (base_row + row) / series_count, + // One series per SST, repeated across SSTs when there are more SSTs than + // series. + "per_sst" => (sst_idx / series_count) * layout.rows_per_sst + row, + other => panic!("unsupported series_layout {other}"), + } +} + fn generate_record_batch( table: &TableConfig, metadata: &RegionMetadataRef, @@ -237,17 +258,33 @@ fn generate_record_batch( } columns.push(Arc::new(b.finish()) as ArrayRef); } - ("field", "DOUBLE") => { - let (min, max) = match col.distribution.as_ref() { - Some(Distribution::DeterministicWave { min, max }) => (*min, *max), - _ => (0.0, 1.0), - }; - columns.push(Arc::new(Float64Array::from( - (0..rows) - .map(|r| wave_value(min, max, base_row + r)) - .collect::>(), - )) as ArrayRef); - } + ("field", "DOUBLE") => match col.distribution.as_ref() { + Some(Distribution::NullableWave { + min, + max, + null_every, + }) => { + columns.push(Arc::new(Float64Array::from_iter((0..rows).map(|r| { + let sample_index = series_sample_index(layout, sst_idx, base_row, r); + if *null_every > 0 && sample_index.is_multiple_of(*null_every) { + None + } else { + Some(wave_value(*min, *max, base_row + r)) + } + }))) as ArrayRef); + } + distribution => { + let (min, max) = match distribution { + Some(Distribution::DeterministicWave { min, max }) => (*min, *max), + _ => (0.0, 1.0), + }; + columns.push(Arc::new(Float64Array::from( + (0..rows) + .map(|r| wave_value(min, max, base_row + r)) + .collect::>(), + )) as ArrayRef); + } + }, ("field", "UINT64") => columns.push(Arc::new(UInt64Array::from( (0..rows).map(|r| (base_row + r) as u64).collect::>(), )) as ArrayRef), @@ -736,4 +773,234 @@ mod tests { } } } + + /// The nullable range-function case must write NULL on every fourth sample of + /// every series and wave values elsewhere, while its dense control table stays + /// NULL-free. + #[test] + fn nullable_wave_nulls_one_sample_in_every_four_per_series() { + let case: CaseFile = toml::from_str(include_str!( + "../../../../../tests/perf/query_cases/promql_nullable_range_fn/case.toml" + )) + .expect("nullable range-function case should parse"); + let scenario = match &case.scenario { + Scenario::DirectReadableSst(scenario) => scenario, + _ => unreachable!("nullable range-function case is a direct SST case"), + }; + assert_eq!(2, scenario.tables.len()); + let sst_idx = 3; + let sequence = 1003; + let base_row = sst_idx * scenario.layout.rows_per_sst; + let series_count = scenario.layout.series_count.get(); + let samples_per_series = scenario.layout.rows_per_sst / series_count; + for table in &scenario.tables { + let metadata = Arc::new(build_region_metadata(table, RegionId::from(43))); + let batch = + generate_record_batch(table, &metadata, &scenario.layout, sst_idx, sequence); + let value = table + .columns + .iter() + .find(|column| column.name == "value") + .expect("fixture case has a value field"); + let (min, max, null_every) = match value.distribution.as_ref() { + Some(Distribution::NullableWave { + min, + max, + null_every, + }) => (*min, *max, *null_every), + Some(Distribution::DeterministicWave { min, max }) => (*min, *max, 0), + other => panic!("unexpected value distribution in {}: {other:?}", table.name), + }; + let host = table + .columns + .iter() + .find(|column| column.name == "host") + .expect("fixture case has a host tag"); + let instance = table + .columns + .iter() + .find(|column| column.name == "instance") + .expect("fixture case has an instance tag"); + + let mut expected = (0..scenario.layout.rows_per_sst) + .map(|row| { + let series = series_for_row(&scenario.layout, sst_idx, base_row, row); + let mut tags = HashMap::new(); + tags.insert(host.name.clone(), tag_value(host, series)); + tags.insert(instance.name.clone(), tag_value(instance, series)); + let is_null = null_every > 0 + && series_sample_index(&scenario.layout, sst_idx, base_row, row) + .is_multiple_of(null_every); + ( + encode_dense_primary_key(table, &tags), + timestamp_for_row(&scenario.layout, base_row, row), + sequence, + tag_value(host, series), + tag_value(instance, series), + (!is_null).then(|| wave_value(min, max, base_row + row)), + ) + }) + .collect::>(); + expected.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.cmp(&right.1)) + .then_with(|| right.2.cmp(&left.2)) + }); + + let timestamps = batch + .column(batch.num_columns() - 4) + .as_any() + .downcast_ref::() + .expect("nanosecond fixture timestamp") + .values() + .to_vec(); + let sequences = batch + .column(batch.num_columns() - 2) + .as_any() + .downcast_ref::() + .expect("fixture sequence column") + .values() + .to_vec(); + let hosts = string_values(&batch, 0); + let instances = string_values(&batch, 1); + let values = batch + .column(2) + .as_any() + .downcast_ref::() + .expect("fixture value column") + .iter() + .collect::>>(); + let actual = encoded_primary_keys(&batch) + .into_iter() + .zip(timestamps) + .zip(sequences) + .zip(hosts) + .zip(instances) + .zip(values.iter().cloned()) + .map( + |(((((primary_key, timestamp), sequence), host), instance), value)| { + (primary_key, timestamp, sequence, host, instance, value) + }, + ) + .collect::>(); + assert_eq!(expected, actual, "{}", table.name); + + // Sorted by primary key, every series owns `samples_per_series` + // consecutive rows ordered by timestamp, so its NULLs must sit on a + // single residue class of its own sample positions. + assert_eq!(series_count, values.len() / samples_per_series); + for (chunk_index, chunk) in values.chunks(samples_per_series).enumerate() { + if null_every == 0 { + assert!( + chunk.iter().all(Option::is_some), + "{} series chunk {chunk_index} must stay dense", + table.name + ); + continue; + } + let residue = chunk.iter().position(Option::is_none).unwrap_or_else(|| { + panic!("{} series chunk {chunk_index} has no NULL", table.name) + }); + assert_eq!( + samples_per_series / null_every, + chunk.iter().filter(|value| value.is_none()).count(), + "{} series chunk {chunk_index} NULL count", + table.name + ); + for (position, value) in chunk.iter().enumerate() { + assert_eq!( + value.is_none(), + position % null_every == residue, + "{} series chunk {chunk_index} sample {position}", + table.name + ); + } + } + } + } + + /// The case's window arithmetic counts the NULL slots of every window, so the + /// cadence must also stay on one residue class of a series across SSTs, not + /// only inside a single SST. + #[test] + fn nullable_wave_keeps_one_null_residue_per_series_across_ssts() { + let case: CaseFile = toml::from_str(include_str!( + "../../../../../tests/perf/query_cases/promql_nullable_range_fn/case.toml" + )) + .expect("nullable range-function case should parse"); + let scenario = match &case.scenario { + Scenario::DirectReadableSst(scenario) => scenario, + _ => unreachable!("nullable range-function case is a direct SST case"), + }; + let table = scenario + .tables + .iter() + .find(|table| table.name == "nullable_table") + .expect("nullable range-function case has a nullable_table"); + let value = table + .columns + .iter() + .find(|column| column.name == "value") + .expect("fixture case has a value field"); + let null_every = match value.distribution.as_ref() { + Some(Distribution::NullableWave { null_every, .. }) => *null_every, + other => panic!("nullable_table must use nullable_wave, found {other:?}"), + }; + // Without aligned SST boundaries the per-series cadence would shift by one + // slot per SST, and the expected window counts documented in the case would + // stop holding. + let samples_per_series = scenario.layout.rows_per_sst / scenario.layout.series_count.get(); + assert_eq!(0, samples_per_series % null_every); + + let metadata = Arc::new(build_region_metadata(table, RegionId::from(43))); + let mut null_positions: HashMap<(String, String), Vec> = HashMap::new(); + for sst_idx in 0..scenario.layout.sst_count { + let batch = generate_record_batch( + table, + &metadata, + &scenario.layout, + sst_idx, + 1003 + sst_idx as u64, + ); + let values = batch + .column(2) + .as_any() + .downcast_ref::() + .expect("fixture value column") + .iter() + .collect::>>(); + let hosts = string_values(&batch, 0); + let instances = string_values(&batch, 1); + for (chunk_index, chunk) in values.chunks(samples_per_series).enumerate() { + let series = ( + hosts[chunk_index * samples_per_series].clone(), + instances[chunk_index * samples_per_series].clone(), + ); + let positions = null_positions.entry(series).or_default(); + for (position, value) in chunk.iter().enumerate() { + if value.is_none() { + positions.push(sst_idx * samples_per_series + position); + } + } + } + } + + assert_eq!( + scenario.layout.series_count.get(), + null_positions.len(), + "every series must be represented" + ); + let expected_nulls = scenario.layout.sst_count * samples_per_series / null_every; + for (series, positions) in &null_positions { + assert_eq!(expected_nulls, positions.len(), "{series:?} null count"); + assert!( + positions + .iter() + .all(|position| position % null_every == positions[0] % null_every), + "{series:?} nulls leave their residue class: {:?}", + &positions[..positions.len().min(6)] + ); + } + } } diff --git a/tests/perf/fixture-format.md b/tests/perf/fixture-format.md index c7d306d852a..d722065c86d 100644 --- a/tests/perf/fixture-format.md +++ b/tests/perf/fixture-format.md @@ -58,6 +58,34 @@ warmup = 0 iterations = 1 ``` +### Value distributions + +Tag columns use `cardinality` and produce one label per series. Double field +columns accept either a NULL-free wave: + +```toml +distribution = { kind = "deterministic_wave", min = 0.0, max = 100.0 } +``` + +or a wave with periodic NULLs: + +```toml +distribution = { kind = "nullable_wave", min = 0.0, max = 100.0, null_every = 4 } +``` + +`nullable_wave` also accepts `null_every = 0` to disable NULLs. It writes NULL on +every `null_every`-th sample of each series and wave values everywhere else, so a +NULL is one missing sample of one series rather than a missing row. The cadence +is counted in samples of the series, not in generated rows, so every series +carries the same null ratio under any `series_layout`; when one SST holds a +multiple of `null_every` samples per series (`rows_per_sst / series_count`), the +pattern also repeats with the same period in every SST. Counting the cadence in +generated rows instead would make it constant per series whenever `series_count` +is a multiple of `null_every` (128 and 4, for example), leaving series either +entirely NULL or entirely NULL-free. Counting per series keeps NULL and non-NULL +samples mixed inside range-function windows, which is what the nullable branches +of the PromQL range functions observe. + ### Query kinds `kind = "prom_http"` runs a Prometheus range query by POSTing form fields to diff --git a/tests/perf/query_cases/promql_nullable_range_fn/case.toml b/tests/perf/query_cases/promql_nullable_range_fn/case.toml new file mode 100644 index 00000000000..9ef693a129b --- /dev/null +++ b/tests/perf/query_cases/promql_nullable_range_fn/case.toml @@ -0,0 +1,183 @@ +# PromQL nullable range-function benchmark: regression evidence for the indexed +# valid-sample lookup in nullable range functions (#9234). +# +# `rate`/`increase`/`delta` and the presence evaluator behind +# `count_over_time`/`last_over_time` fall back to a per-window validity scan once +# any sample of a series is NULL. `nullable_table` writes NULL on every fourth +# sample of every series, so 25% of its samples are missing (one missing sample +# per minute at the 15s cadence) and every window of every series contains both +# NULL and non-NULL samples instead of being entirely NULL or entirely dense. +# `dense_table` is the same fixture without NULLs; it keeps the no-NULL fast path +# measured next to the nullable path. +# +# 128 series x 780 timestamps at 15s cadence = 99,840 rows per table, written as +# 13 SSTs x 7,680 rows. Data starts one hour before evaluation; 481 evaluations +# span two hours. Range windows are left-open and every series is dense on the +# 15s grid, so a fully covered 5m window holds 20 slots and a 1m window holds 4. +# Five of the 20 slots and one of the 4 slots are NULL, which makes +# `count_over_time(nullable_table[5m])` return exactly 15 and +# `count_over_time(nullable_table[1m])` return exactly 3 at every one of the 481 +# evaluations: no window is entirely NULL and none is NULL-free, so every window +# of every series takes the nullable branch. + +[case] +name = "promql_nullable_range_fn" +description = "PromQL range functions over partially NULL series at 15-second scrape and evaluation cadence" + +[scenario] +kind = "direct_readable_sst" +seed = 9234 + +[[scenario.tables]] +database = "public" +name = "nullable_table" +engine = "mito" +append_mode = true +sst_format = "flat" +primary_key = ["host", "instance"] +time_index = "ts" + +[[scenario.tables.columns]] +name = "host" +type = "STRING" +semantic = "tag" +distribution = { kind = "cardinality", values = 16, prefix = "host" } + +[[scenario.tables.columns]] +name = "instance" +type = "STRING" +semantic = "tag" +distribution = { kind = "cardinality", values = 128, prefix = "instance" } + +[[scenario.tables.columns]] +name = "value" +type = "DOUBLE" +semantic = "field" +distribution = { kind = "nullable_wave", min = 0.0, max = 1000.0, null_every = 4 } + +[[scenario.tables.columns]] +name = "ts" +type = "TIMESTAMP(9)" +semantic = "timestamp" + +[[scenario.tables]] +database = "public" +name = "dense_table" +engine = "mito" +append_mode = true +sst_format = "flat" +primary_key = ["host", "instance"] +time_index = "ts" + +[[scenario.tables.columns]] +name = "host" +type = "STRING" +semantic = "tag" +distribution = { kind = "cardinality", values = 16, prefix = "host" } + +[[scenario.tables.columns]] +name = "instance" +type = "STRING" +semantic = "tag" +distribution = { kind = "cardinality", values = 128, prefix = "instance" } + +[[scenario.tables.columns]] +name = "value" +type = "DOUBLE" +semantic = "field" +distribution = { kind = "deterministic_wave", min = 0.0, max = 1000.0 } + +[[scenario.tables.columns]] +name = "ts" +type = "TIMESTAMP(9)" +semantic = "timestamp" + +[scenario.layout] +regions = 1 +sst_count = 13 +rows_per_sst = 7680 +row_group_size = 1920 +series_count = 128 +start_unix_nanos = 1704067200000000000 +step_nanos = 15000000000 +time_range_layout = "non_overlapping_per_sst" +series_layout = "timestamp_major" + +# Twenty slots per window, five of them NULL: the counter correction and the +# valid-sample lookup both walk a mixed window on every series. +[[scenario.queries]] +name = "rate_nullable_5m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(nullable_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +[[scenario.queries]] +name = "increase_nullable_5m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') increase(nullable_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +# `delta` shares the nullable window-bounds path but skips the counter correction. +[[scenario.queries]] +name = "delta_nullable_5m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') delta(nullable_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +# The presence evaluator counts valid slots per window instead of scanning bounds. +[[scenario.queries]] +name = "count_over_time_nullable_5m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') count_over_time(nullable_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +# `last_over_time` searches backwards for the last valid slot of every window, +# which the NULL at the window end forces for one evaluation in every four. +[[scenario.queries]] +name = "last_over_time_nullable_5m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') last_over_time(nullable_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +# Four slots per window, one of them NULL: fixed overhead and short-window control +# for the same nullable path. +[[scenario.queries]] +name = "rate_nullable_1m" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(nullable_table{host=~'host.*'}[1m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20 + +# Identical shape and values without NULLs: the fast path must not move. +[[scenario.queries]] +name = "rate_dense_5m_control" +kind = "tql" +query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(dense_table{host=~'host.*'}[5m])" +warmup = 3 +iterations = 9 + +[scenario.queries.thresholds] +max_candidate_latency_regression_pct = 20