Skip to content

Commit 210bdf9

Browse files
authored
Check in oxql benchmark. (#10124)
Add oxql field lookup benchmark. To avoid the complication of generating realistic synthetic data, we provide scripts for fetching and loading fields from a running ClickHouse instance. Checking in to benchmark changes in #10110.
1 parent 6a3bdd6 commit 210bdf9

6 files changed

Lines changed: 315 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

oximeter/db/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ expectorate.workspace = true
112112
itertools.workspace = true
113113
omicron-test-utils.workspace = true
114114
oximeter-test-utils.workspace = true
115+
rand.workspace = true
115116
slog-dtrace.workspace = true
116117
sqlformat.workspace = true
117118
sqlparser.workspace = true
@@ -150,3 +151,7 @@ doc = false
150151
[[bench]]
151152
name = "protocol"
152153
harness = false
154+
155+
[[bench]]
156+
name = "oxql"
157+
harness = false

oximeter/db/benches/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Oximeter benchmarks
2+
3+
## Field lookup
4+
5+
Filtering and pivoting OxQL field labels can take a significant fraction of overall query time, so we include a benchmark focusing on field lookup. This benchmark queries all timeseries for a given table, filtering on a far-future timestamp so that we don't exercise measurement lookup. Because field lookup latency varies with the number of field tables to be combined, we include metrics that use varying numbers of field types. In the interest of benchmarking realistic queries, this benchmark doesn't generate synthetic data, but instead provides scripts for the operator to back up real field data from a running rack and restore them into a test database.
6+
7+
To fetch field data:
8+
9+
```bash
10+
$ mkdir -p /tmp/oximeter-field-bench
11+
$ oximeter/db/benches/backup_field_tables.sh /tmp/oximeter-field-bench [port]
12+
```
13+
14+
To restore into a test database. Note: take care not to restore into a real Oxide rack. For safety, the load script will fail if the destination database has nonzero rows.
15+
16+
```bash
17+
$ oximeter/db/benches/load_field_tables.sh /tmp/oximeter-field-bench [port]
18+
```
19+
20+
Then run the benchmark:
21+
22+
```bash
23+
$ cargo bench --package oximeter-db --bench oxql -- --save-baseline main
24+
```
25+
26+
To evaluate performance changes, run the benchmark using a new baseline:
27+
28+
```bash
29+
$ cargo bench --package oximeter-db --bench oxql -- --save-baseline my-branch
30+
```
31+
32+
Then compare with `critcmp`:
33+
34+
```bash
35+
$ critcmp main my-branch
36+
```
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/bin/bash
2+
#
3+
# Dump ClickHouse field and schema tables to disk in native format. Run against
4+
# a test rack with realistic oximeter data. Used to capture test data for
5+
# benchmarking.
6+
#
7+
# Usage: ./backup_field_tables.sh <output_dir> [port]
8+
9+
set -euo pipefail
10+
11+
if [[ $# -lt 1 ]]; then
12+
echo "Usage: $0 <output_dir> [port]" >&2
13+
exit 1
14+
fi
15+
16+
OUTPUT_DIR="$1"
17+
PORT="${2:-9000}"
18+
DATABASE="oximeter"
19+
20+
mkdir -p "$OUTPUT_DIR"
21+
22+
# Back up field tables.
23+
#
24+
# Note: Use SELECT rather than RESTORE because we may not have access to the
25+
# remote ClickHouse's local disk, or have backups enabled at all.
26+
for table in timeseries_schema fields_{bool,i8,i16,i32,i64,ipaddr,string,u8,u16,u32,u64,uuid}; do
27+
count=$(clickhouse client --port "$PORT" \
28+
--query "SELECT count() FROM $DATABASE.$table")
29+
if [[ "$count" -eq 0 ]]; then
30+
echo "No rows in table $DATABASE.$table; skipping"
31+
continue
32+
fi
33+
output="$OUTPUT_DIR/${table}.native.gz"
34+
echo "Backing up $DATABASE.$table ($count rows) to $output"
35+
clickhouse client --port "$PORT" \
36+
--query "SELECT * FROM $DATABASE.$table FORMAT Native" \
37+
| gzip > "$output"
38+
done
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/bash
2+
#
3+
# Load field table backups into a fresh ClickHouse for benchmarking.
4+
# Crashes if the destination database already contains data.
5+
#
6+
# Usage: ./load_field_tables.sh <input_dir> [port]
7+
8+
set -euo pipefail
9+
10+
if [[ $# -lt 1 ]]; then
11+
echo "Usage: $0 <input_dir> [port]" >&2
12+
exit 1
13+
fi
14+
15+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16+
SCHEMA_DIR="$SCRIPT_DIR/../schema/single-node"
17+
18+
INPUT_DIR="$1"
19+
PORT="${2:-9000}"
20+
21+
DATABASE="oximeter"
22+
23+
# Error if database isn't empty.
24+
echo "Checking for existing data..."
25+
count=$(clickhouse client --port "$PORT" \
26+
--query "SELECT ifNull(sum(total_rows), 0) FROM system.tables WHERE database = '$DATABASE'")
27+
28+
if [[ "$count" -gt 0 ]]; then
29+
echo "Error: $DATABASE database already contains data ($count rows)"
30+
echo "Refusing to initialize a non-empty database."
31+
exit 1
32+
fi
33+
34+
# Initialize schema.
35+
echo "Initializing database schema..."
36+
clickhouse client --port "$PORT" --multiquery < "$SCHEMA_DIR/db-init.sql"
37+
38+
# Load backups.
39+
#
40+
# Note: Use INSERT rather than RESTORE because we may not have access to the
41+
# remote ClickHouse's local disk, or have backups enabled at all.
42+
for table in timeseries_schema fields_{bool,i8,i16,i32,i64,ipaddr,string,u8,u16,u32,u64,uuid}; do
43+
input="$INPUT_DIR/${table}.native.gz"
44+
if [[ ! -f "$input" ]]; then
45+
echo "No backup for table $table; skipping"
46+
continue
47+
fi
48+
echo "Loading $table"
49+
gunzip -c "$input" | clickhouse client --port "$PORT" \
50+
--query "INSERT INTO $DATABASE.$table FORMAT Native"
51+
done

oximeter/db/benches/oxql.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! Benchmark for OxQL query performance.
6+
//!
7+
//! Tests multiple timeseries with varying numbers of field types.
8+
9+
// Copyright 2026 Oxide Computer Company
10+
11+
use criterion::BenchmarkId;
12+
use criterion::Criterion;
13+
use criterion::{criterion_group, criterion_main};
14+
use oximeter_db::Client;
15+
use oximeter_db::native::Connection;
16+
use oximeter_db::oxql::query::QueryAuthzScope;
17+
use rand::seq::SliceRandom;
18+
use std::net::IpAddr;
19+
use std::net::SocketAddr;
20+
use std::sync::Arc;
21+
use uuid::Uuid;
22+
23+
const DEFAULT_CLICKHOUSE_PORT: u16 = 9000;
24+
25+
/// Timeseries to benchmark, spanning a range of field table counts.
26+
const TIMESERIES_NAMES: &[&str] = &[
27+
"crucible_upstairs:flush",
28+
"ddm_session:advertisements_received",
29+
"virtual_machine:vcpu_usage",
30+
"bgp_session:active_connections_accepted",
31+
"switch_data_link:bytes_sent",
32+
];
33+
34+
/// Metadata about a timeseries, fetched from the database.
35+
struct TimeseriesInfo {
36+
name: String,
37+
field_tables: u64,
38+
cardinality: u64,
39+
}
40+
41+
fn get_clickhouse_addr() -> IpAddr {
42+
std::env::var("CLICKHOUSE_ADDRESS")
43+
.ok()
44+
.and_then(|s| s.parse().ok())
45+
.unwrap_or_else(|| IpAddr::from([127, 0, 0, 1]))
46+
}
47+
48+
fn get_clickhouse_port() -> u16 {
49+
std::env::var("CLICKHOUSE_PORT")
50+
.ok()
51+
.and_then(|s| s.parse().ok())
52+
.unwrap_or(DEFAULT_CLICKHOUSE_PORT)
53+
}
54+
55+
fn get_socket_addr() -> SocketAddr {
56+
SocketAddr::new(get_clickhouse_addr(), get_clickhouse_port())
57+
}
58+
59+
fn get_client(rt: &tokio::runtime::Runtime) -> Arc<Client> {
60+
let addr = get_socket_addr();
61+
let log = slog::Logger::root(slog::Discard, slog::o!());
62+
63+
rt.block_on(async {
64+
let client = Arc::new(Client::new(addr, &log));
65+
client.ping().await.unwrap();
66+
client
67+
})
68+
}
69+
70+
/// Fetch field table count and cardinality for each timeseries.
71+
fn get_timeseries_info(rt: &tokio::runtime::Runtime) -> Vec<TimeseriesInfo> {
72+
let names_list = TIMESERIES_NAMES
73+
.iter()
74+
.map(|name| format!("'{}'", name))
75+
.collect::<Vec<_>>()
76+
.join(", ");
77+
78+
let query = format!(
79+
"SELECT
80+
series.timeseries_name,
81+
length(arrayDistinct(any(series.fields.type))) AS field_tables,
82+
count(DISTINCT fields.timeseries_key) AS cardinality
83+
FROM oximeter.timeseries_schema series
84+
JOIN merge('oximeter', '^fields_') fields
85+
ON series.timeseries_name = fields.timeseries_name
86+
WHERE series.timeseries_name IN ({})
87+
GROUP BY series.timeseries_name
88+
ORDER BY field_tables, cardinality",
89+
names_list
90+
);
91+
92+
rt.block_on(async {
93+
let mut conn = Connection::new(get_socket_addr()).await.unwrap();
94+
let result = conn.query(Uuid::new_v4(), &query).await.unwrap();
95+
let block = result.data.as_ref().expect("query returned no data");
96+
97+
let names = block
98+
.column_values("timeseries_name")
99+
.unwrap()
100+
.as_string()
101+
.unwrap();
102+
let field_tables =
103+
block.column_values("field_tables").unwrap().as_u64().unwrap();
104+
let cardinalities =
105+
block.column_values("cardinality").unwrap().as_u64().unwrap();
106+
107+
names
108+
.iter()
109+
.zip(field_tables.iter())
110+
.zip(cardinalities.iter())
111+
.map(|((name, &field_tables), &cardinality)| TimeseriesInfo {
112+
name: name.clone(),
113+
field_tables,
114+
cardinality,
115+
})
116+
.collect()
117+
})
118+
}
119+
120+
// Benchmark field lookup. As of this writing, filtering and collating fields
121+
// can make up a significant proportion of overall query time, and its latency
122+
// varies with both the cardinality and the number of field tables that need to
123+
// be combined for the relevant series. Query each timeseries in TIMESERIES_NAMES,
124+
// filtering to a future timestamp so that we only benchmark the performance of
125+
// field lookup, and ignore measurements. Note that the user is responsible for
126+
// populating ClickHouse with test data.
127+
fn oxql_field_lookup(c: &mut Criterion) {
128+
let rt = tokio::runtime::Builder::new_multi_thread()
129+
.enable_all()
130+
.build()
131+
.unwrap();
132+
133+
let client = get_client(&rt);
134+
let mut group = c.benchmark_group("oxql");
135+
136+
let mut timeseries_info = get_timeseries_info(&rt);
137+
timeseries_info.shuffle(&mut rand::rng());
138+
139+
let max_cardinality =
140+
timeseries_info.iter().map(|i| i.cardinality).max().unwrap_or(0);
141+
let cardinality_width = max_cardinality.to_string().len();
142+
143+
for info in &timeseries_info {
144+
// Use a far-future timestamp to benchmark field lookup only, with no
145+
// measurements.
146+
let query =
147+
format!("get {} | filter timestamp > @2200-01-01", info.name);
148+
149+
rt.block_on(client.oxql_query(&query, QueryAuthzScope::Fleet)).unwrap();
150+
151+
let bench_id = format!(
152+
"{} tables/{:0width$} keys: {}",
153+
info.field_tables,
154+
info.cardinality,
155+
info.name,
156+
width = cardinality_width
157+
);
158+
159+
group.bench_function(
160+
BenchmarkId::new("field_lookup", &bench_id),
161+
|bench| {
162+
let client = client.clone();
163+
let query = query.clone();
164+
bench.to_async(&rt).iter(|| {
165+
let client = client.clone();
166+
let query = query.clone();
167+
async move {
168+
client.oxql_query(&query, QueryAuthzScope::Fleet).await
169+
}
170+
})
171+
},
172+
);
173+
}
174+
175+
group.finish();
176+
}
177+
178+
criterion_group!(
179+
name = benches;
180+
config = Criterion::default().sample_size(50).noise_threshold(0.05);
181+
targets = oxql_field_lookup
182+
);
183+
184+
criterion_main!(benches);

0 commit comments

Comments
 (0)