test(perf): model bounded mixed integer and fractional metric series

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
discord9
2026-09-08 16:39:59 +08:00
parent 932be19b06
commit 149b49de5f
5 changed files with 196 additions and 18 deletions
@@ -204,6 +204,7 @@ pub(super) enum ValuePattern {
QuantizedSignal,
SignalWithSporadicStalls,
MixedSignalRepeated,
BoundedMixed,
}
impl std::fmt::Display for ValuePattern {
@@ -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::<Vec<_>>();
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::<Vec<_>>();
monolithic.value_seed = 43;
assert_ne!(
original,
(0..600)
.map(|sample_idx| deterministic_prom_value(&monolithic, 19, sample_idx))
.collect::<Vec<_>>()
);
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);
+40 -13
View File
@@ -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
@@ -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
@@ -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"