From 149b49de5f31899cdad58ac2f2b8387aa439cb1b Mon Sep 17 00:00:00 2001 From: discord9 <55937128+discord9@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:39:59 +0800 Subject: [PATCH] test(perf): model bounded mixed integer and fractional metric series Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/cmd/src/bin/query_perf_fixture/case.rs | 1 + .../query_perf_fixture/prom_remote_write.rs | 141 +++++++++++++++++- tests/perf/README.md | 53 +++++-- .../perf/query_cases/sst_float_bss/RESULTS.md | 7 +- .../perf/query_cases/sst_float_bss/case.toml | 12 +- 5 files changed, 196 insertions(+), 18 deletions(-) diff --git a/src/cmd/src/bin/query_perf_fixture/case.rs b/src/cmd/src/bin/query_perf_fixture/case.rs index 693152e6a11..0f5239a6b51 100644 --- a/src/cmd/src/bin/query_perf_fixture/case.rs +++ b/src/cmd/src/bin/query_perf_fixture/case.rs @@ -204,6 +204,7 @@ pub(super) enum ValuePattern { QuantizedSignal, SignalWithSporadicStalls, MixedSignalRepeated, + BoundedMixed, } impl std::fmt::Display for ValuePattern { diff --git a/src/cmd/src/bin/query_perf_fixture/prom_remote_write.rs b/src/cmd/src/bin/query_perf_fixture/prom_remote_write.rs index 1f36b0c2d23..a59178f4a79 100644 --- a/src/cmd/src/bin/query_perf_fixture/prom_remote_write.rs +++ b/src/cmd/src/bin/query_perf_fixture/prom_remote_write.rs @@ -197,8 +197,35 @@ fn deterministic_prom_value(args: &PromRemoteWriteArgs, series_idx: u64, sample_ args.value_base + (series_idx % 97) as f64 + local as f64 * args.value_step } } + ValuePattern::BoundedMixed => bounded_mixed_value(args, series_idx, local), } } + +fn bounded_mixed_value(args: &PromRemoteWriteArgs, series_idx: u64, local: u64) -> f64 { + const SPANS: [i64; 4] = [10, 1_000, 100_000, 10_000_000]; + let series = splitmix64(series_idx ^ args.value_seed); + let span = SPANS[(series >> 62) as usize] * (1 + ((series >> 32) % 10) as i64); + let baseline = (series % args.value_cardinality.max(1)) as f64 * span as f64 / 10.0; + let run_length = args.value_run_length.max(1); + let group = local / run_length; + let phase = local % run_length; + let anchor = bounded_mixed_anchor(series, group, span); + let next_anchor = bounded_mixed_anchor(series, group + 1, span); + let signal = baseline + anchor + (next_anchor - anchor) * phase as f64 / run_length as f64; + let value = (args.value_base + args.value_step * signal).round(); + if series_idx % args.value_mixed_every.max(1) == args.value_mixed_every.max(1) - 1 { + let numerator = splitmix64(series ^ local) % 999 + 1; + value + numerator as f64 / 1_000.0 + } else { + value + } +} + +fn bounded_mixed_anchor(series: u64, group: u64, span: i64) -> f64 { + let limit = span - 1; + (splitmix64(series ^ group) % (2 * limit as u64 + 1)) as i64 as f64 - limit as f64 +} + fn splitmix64(mut value: u64) -> u64 { value = value.wrapping_add(0x9e3779b97f4a7c15); let mut mixed = value; @@ -307,7 +334,7 @@ mod tests { for chunk_idx in 0..10 { let mut chunk = args(ValuePattern::SeededRandom); chunk.samples_per_series = 1_440; - chunk.value_sample_offset = chunk_idx * 1_440; + chunk.value_sample_offset = chunk_idx * 1_439; chunk.value_total_samples_per_series = Some(14_400); for sample_idx in 0..chunk.samples_per_series { assert_eq!( @@ -319,6 +346,118 @@ mod tests { } } + #[test] + fn bounded_mixed_has_bounded_95_5_per_series_values() { + let mut args = args(ValuePattern::BoundedMixed); + args.series_count = 1_000; + args.samples_per_series = 4_320; + args.value_cardinality = 1_000; + args.value_seed = 42; + args.value_run_length = 60; + args.value_mixed_every = 20; + + let mut integral_series = 0; + let mut fractional_series = 0; + let mut spans = HashSet::new(); + let mut ranges = HashSet::new(); + for series_idx in 0..args.series_count { + let series = splitmix64(series_idx ^ args.value_seed); + let span = [10_i64, 1_000, 100_000, 10_000_000][(series >> 62) as usize] + * (1 + ((series >> 32) % 10) as i64); + let baseline = (series % args.value_cardinality.max(1)) as f64 * span as f64 / 10.0; + let values = (0..args.samples_per_series) + .map(|sample_idx| deterministic_prom_value(&args, series_idx, sample_idx)) + .collect::>(); + + assert!(values.iter().all(|value| value.is_finite())); + assert!( + values + .iter() + .all(|value| *value >= baseline - span as f64 - 1.0 + && *value <= baseline + span as f64 + 1.0) + ); + assert!( + values.windows(2).any(|pair| pair[1] > pair[0]), + "series {series_idx} never rises" + ); + assert!( + values.windows(2).any(|pair| pair[1] < pair[0]), + "series {series_idx} never falls" + ); + ranges.insert(( + values + .iter() + .copied() + .fold(f64::INFINITY, f64::min) + .to_bits(), + values + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max) + .to_bits(), + )); + spans.insert(span); + if series_idx % args.value_mixed_every == args.value_mixed_every - 1 { + fractional_series += 1; + assert!(values.iter().all(|value| value.fract() != 0.0)); + } else { + integral_series += 1; + assert!(values.iter().all(|value| value.fract() == 0.0)); + } + } + + assert_eq!((integral_series, fractional_series), (950, 50)); + assert!(spans.len() > 4); + assert!(ranges.len() > 1); + } + + #[test] + fn bounded_mixed_is_seeded_and_chunk_bit_identical() { + let mut monolithic = args(ValuePattern::BoundedMixed); + monolithic.samples_per_series = 14_400; + monolithic.value_total_samples_per_series = Some(14_400); + monolithic.value_cardinality = 1_000; + monolithic.value_seed = 42; + monolithic.value_run_length = 60; + monolithic.value_mixed_every = 20; + + let original = (0..600) + .map(|sample_idx| deterministic_prom_value(&monolithic, 19, sample_idx)) + .collect::>(); + monolithic.value_seed = 43; + assert_ne!( + original, + (0..600) + .map(|sample_idx| deterministic_prom_value(&monolithic, 19, sample_idx)) + .collect::>() + ); + + monolithic.value_seed = 42; + for series_idx in [0, 19] { + for chunk_idx in 0..10 { + let mut chunk = args(ValuePattern::BoundedMixed); + chunk.samples_per_series = 1_440; + chunk.value_sample_offset = chunk_idx * 1_439; + chunk.value_total_samples_per_series = Some(14_400); + chunk.value_cardinality = 1_000; + chunk.value_seed = 42; + chunk.value_run_length = 60; + chunk.value_mixed_every = 20; + for sample_idx in 0..chunk.samples_per_series { + assert_eq!( + deterministic_prom_value(&chunk, series_idx, sample_idx).to_bits(), + deterministic_prom_value( + &monolithic, + series_idx, + chunk.value_sample_offset + sample_idx + ) + .to_bits() + ); + } + } + } + } + #[test] fn values_continue_across_series_boundaries() { let mut args = args(ValuePattern::Unique); diff --git a/tests/perf/README.md b/tests/perf/README.md index f483d8e2ec5..dea0354580a 100644 --- a/tests/perf/README.md +++ b/tests/perf/README.md @@ -86,15 +86,19 @@ sample values without changing label cardinality or the runner lifecycle: [scenario.remote_write.value] pattern = "quantized_signal" # linear, constant, modulo, unique, seeded_random, # run_length, quantized_signal, - # signal_with_sporadic_stalls, mixed_signal_repeated + # signal_with_sporadic_stalls, mixed_signal_repeated, + # bounded_mixed base = 0.0 step = 0.125 -cardinality = 4096 # buckets for modulo/seeded_random/quantized_signal/run_length -seed = 12345 # deterministic seeded_random input -run_length = 8 # adjacent samples per bucket for run_length/quantized_signal +cardinality = 4096 # buckets for modulo/seeded_random/quantized_signal/run_length; + # baseline buckets for bounded_mixed +seed = 12345 # deterministic seeded_random input and bounded_mixed series hash +run_length = 8 # adjacent samples per bucket for run_length/quantized_signal; + # interpolation interval for bounded_mixed stall_every = 100 # interval for signal_with_sporadic_stalls stall_length = 16 # held samples inside each stall interval -mixed_every = 5 # every Nth sample becomes the repeated base value +mixed_every = 5 # every Nth sample becomes the repeated base value; + # one fractional bounded_mixed series per N series ``` The default `linear` pattern preserves the helper's historical formula. Use @@ -103,12 +107,29 @@ data shapes, `run_length` for run-heavy low-cardinality series, `quantized_signa for signal-like values collapsed into a finite bucket set, `signal_with_sporadic_stalls` for mostly continuous signals with periodic flat spots, and `mixed_signal_repeated` for signal-plus-periodic-default mixtures. `unique` or high-cardinality buckets -still work for broad sample-value distributions. This is a generic sample-value -control for query/ingestion cases; it does not inspect or assert storage -encoding, Parquet footers, or storage policy choices. For chunked remote-write -ingestion, the runner passes the sample offset and total sample count to the -helper so non-linear value patterns use a stable global/per-series ordinal across -chunks. +still work for broad sample-value distributions. + +`bounded_mixed` is synthetic rather than an empirical workload. For series `s`, +`h = splitmix64(s ^ seed)` selects a span from `[10, 1_000, 100_000, 10_000_000]` +using its top two bits, multiplied by `1 + ((h >> 32) % 10)`. Its baseline is +`(h % max(cardinality, 1)) * span / 10`. Ranges can overlap. At local sample +`l = sample_offset + sample_idx`, it linearly interpolates hash-derived anchors +in `[-span + 1, span - 1]`, changing anchors every `max(run_length, 1)` samples. +It applies `base + step * signal`, then rounds to an integer. Every +`max(mixed_every, 1)`-th series adds a hash-derived fraction from `1/1000` to +`999/1000`. The 95/5 finite, nonintegral guarantee applies to the case's bounded +parameters, not arbitrary huge or nonfinite base/step inputs. With `base = 0` +and `step = 1`, output lies within `baseline ± span`, allowing one unit for +rounding and the fractional addition. The explicit case uses 1,000 equal-length +series and `mixed_every = 20`, giving exactly 95% integer-valued and 5% +fractional Float64 samples. Its ranges and temporal shape are synthetic design +parameters, not measured properties from the survey. + +This is a generic sample-value control for query/ingestion cases; it does not +inspect or assert storage encoding, Parquet footers, or storage policy choices. +For chunked remote-write ingestion, the runner passes the sample offset and total +sample count to the helper so non-linear value patterns use a stable global/per-series +ordinal across chunks. Case schema, value distribution defaults, storage defaults, and read-bench defaults are owned by Rust. The outer CI driver calls @@ -373,8 +394,14 @@ uv run --no-project python .github/scripts/query-regression-run.py \ `tests/perf/query_cases/sst_float_bss/case.toml` is an explicit-only case for comparing a default empty physical metric table with a candidate -byte-stream-split (BSS) physical table. Current local-case findings are recorded -in [`query_cases/sst_float_bss/RESULTS.md`](query_cases/sst_float_bss/RESULTS.md). +byte-stream-split (BSS) physical table. It writes 1,000 series × 4,320 samples +(4,320,000 rows) using synthetic `bounded_mixed` values: 95% integral series and +5% nonintegral series, with bounded per-series fluctuation rather than globally +unique values. This deliberately matches a 95/5 design; it is not an empirical +claim about any production population. A separate survey sample found 94.79% +integral values, but that observation does not make its values globally unique. +Historical unique-value findings are preserved, but superseded pending a mixed +workload rerun, in [`query_cases/sst_float_bss/RESULTS.md`](query_cases/sst_float_bss/RESULTS.md). Both targets must use the exact same release `greptime` binary; only the per-target table setup SQL differs. Run the existing driver from a checkout containing that binary, with absolute paths and fresh data directories for every diff --git a/tests/perf/query_cases/sst_float_bss/RESULTS.md b/tests/perf/query_cases/sst_float_bss/RESULTS.md index 45554059b28..a44034e3286 100644 --- a/tests/perf/query_cases/sst_float_bss/RESULTS.md +++ b/tests/perf/query_cases/sst_float_bss/RESULTS.md @@ -1,4 +1,9 @@ -# SST float BSS: current-case results +# SST float BSS: historical unique-integer results + +> Superseded workload: the current case now generates bounded, fluctuating +> 95% integer-valued / 5% fractional series. The numbers below belong only to +> the earlier global-unique-integer experiment, not the current case. Mixed +> workload measurements are pending. ## Decision context diff --git a/tests/perf/query_cases/sst_float_bss/case.toml b/tests/perf/query_cases/sst_float_bss/case.toml index 63819158d24..7d5a33e2de7 100644 --- a/tests/perf/query_cases/sst_float_bss/case.toml +++ b/tests/perf/query_cases/sst_float_bss/case.toml @@ -3,7 +3,7 @@ [case] name = "sst_float_bss" -description = "Compare default and byte-stream-split floating-point SST bytes and warmed value-scanning query latency" +description = "Compare default and byte-stream-split floating-point SST bytes and warmed value-scanning query latency with bounded 95/5 per-series DOUBLE values" [scenario] kind = "prom_remote_write_then_query" @@ -12,7 +12,7 @@ kind = "prom_remote_write_then_query" database = "public" metric = "sst_float_bss" physical_table = "sst_float_bss_physical" -series_count = 1024 +series_count = 1000 samples_per_series = 4320 sample_chunk_size = 1440 flush_every_sample_chunks = 1 @@ -32,9 +32,15 @@ candidate_setup_sql = [ ] [scenario.remote_write.value] -pattern = "unique" +# Synthetic, not empirical: 95% integral and 5% nonintegral series vary +# smoothly within deterministic per-series ranges; series are not a global monotonic counter. +pattern = "bounded_mixed" base = 0 step = 1 +cardinality = 1000 +seed = 42 +mixed_every = 20 +run_length = 60 [scenario.remote_write.prom_store] pending_rows_flush_interval = "1s"