Skip to content

Commit dfcd9ac

Browse files
authored
Capture clickhouse profile events in query summaries. (#10172)
ClickHouse includes a collection of profile events by default when using the native tcp client. This patch captures those events, aggregating them by type and including aggregated profile events in the optional query profile section. We also make use of these profile summaries in the oxql benchmark, adding a new benchmark type that measures query cpu usage rather than latency. Context: I wanted to evaluate #10110 more rigorously, and Claude noticed that we had access to clickhouse profiling events already. Looking at cpu profiles for that patch actually showed that latency improvements came at the cost of higher cpu use, which is annoying but useful to know.
1 parent ddd9f12 commit dfcd9ac

5 files changed

Lines changed: 136 additions & 12 deletions

File tree

nexus/types/versions/src/initial/oxql.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ pub struct OxqlQuerySummary {
6969
pub elapsed_ms: usize,
7070
/// Summary of the data read and written.
7171
pub io_summary: oxql_types::IoSummary,
72+
/// Aggregated ClickHouse profile events.
73+
pub profile_summary: std::collections::BTreeMap<String, i64>,
7274
}
7375

7476
impl From<oxql_types::QuerySummary> for OxqlQuerySummary {
@@ -78,6 +80,7 @@ impl From<oxql_types::QuerySummary> for OxqlQuerySummary {
7880
query: query_summary.query,
7981
elapsed_ms: query_summary.elapsed.as_millis() as usize,
8082
io_summary: query_summary.io_summary,
83+
profile_summary: query_summary.profile_summary,
8184
}
8285
}
8386
}

oximeter/db/benches/oxql.rs

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,29 @@ use rand::seq::SliceRandom;
1818
use std::net::IpAddr;
1919
use std::net::SocketAddr;
2020
use std::sync::Arc;
21+
use std::time::Duration;
2122
use uuid::Uuid;
2223

2324
const DEFAULT_CLICKHOUSE_PORT: u16 = 9000;
2425

26+
/// The metric to benchmark.
27+
///
28+
/// Set via BENCH_METRIC env var.
29+
enum BenchMetric {
30+
/// Wall clock latency.
31+
Latency,
32+
/// Total cpu time (user and system).
33+
CpuTime,
34+
}
35+
36+
fn bench_metric() -> BenchMetric {
37+
match std::env::var("BENCH_METRIC").as_deref() {
38+
Ok("cpu_time") => BenchMetric::CpuTime,
39+
Ok("latency") => BenchMetric::Latency,
40+
_ => panic!("BENCH_METRIC must be 'cpu_time' or 'latency'"),
41+
}
42+
}
43+
2544
/// Timeseries to benchmark, spanning a range of field table counts.
2645
const TIMESERIES_NAMES: &[&str] = &[
2746
"crucible_upstairs:flush",
@@ -125,6 +144,8 @@ fn get_timeseries_info(rt: &tokio::runtime::Runtime) -> Vec<TimeseriesInfo> {
125144
// field lookup, and ignore measurements. Note that the user is responsible for
126145
// populating ClickHouse with test data.
127146
fn oxql_field_lookup(c: &mut Criterion) {
147+
let metric = bench_metric();
148+
128149
let rt = tokio::runtime::Builder::new_multi_thread()
129150
.enable_all()
130151
.build()
@@ -136,8 +157,11 @@ fn oxql_field_lookup(c: &mut Criterion) {
136157
let mut timeseries_info = get_timeseries_info(&rt);
137158
timeseries_info.shuffle(&mut rand::rng());
138159

139-
let max_cardinality =
140-
timeseries_info.iter().map(|i| i.cardinality).max().unwrap_or(0);
160+
let max_cardinality = timeseries_info
161+
.iter()
162+
.map(|ti| ti.cardinality)
163+
.max()
164+
.expect("No timeseries found");
141165
let cardinality_width = max_cardinality.to_string().len();
142166

143167
for info in &timeseries_info {
@@ -158,16 +182,54 @@ fn oxql_field_lookup(c: &mut Criterion) {
158182

159183
group.bench_function(
160184
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-
})
185+
|bench| match metric {
186+
BenchMetric::CpuTime => {
187+
bench.to_async(&rt).iter_custom(|iters| {
188+
let client = client.clone();
189+
let query = query.clone();
190+
async move {
191+
let mut total = Duration::ZERO;
192+
for _ in 0..iters {
193+
let result = client
194+
.oxql_query(&query, QueryAuthzScope::Fleet)
195+
.await
196+
.unwrap();
197+
let cpu_us: i64 = result
198+
.query_summaries
199+
.iter()
200+
.map(|s| {
201+
// Profile events are occasionally and
202+
// inexplicably empty; default to 0
203+
// for rare missing events.
204+
s.profile_summary
205+
.get("UserTimeMicroseconds")
206+
.copied()
207+
.unwrap_or(0)
208+
+ s.profile_summary
209+
.get("SystemTimeMicroseconds")
210+
.copied()
211+
.unwrap_or(0)
212+
})
213+
.sum();
214+
total +=
215+
Duration::from_micros(cpu_us.max(0) as u64);
216+
}
217+
total
218+
}
219+
});
220+
}
221+
BenchMetric::Latency => {
222+
bench.to_async(&rt).iter(|| {
223+
let client = client.clone();
224+
let query = query.clone();
225+
async move {
226+
client
227+
.oxql_query(&query, QueryAuthzScope::Fleet)
228+
.await
229+
.unwrap()
230+
}
231+
});
232+
}
171233
},
172234
);
173235
}

oximeter/db/src/native/packets/client.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,47 @@ impl QueryResult {
154154
query: self.query.clone(),
155155
elapsed: self.progress.query_time,
156156
io_summary: self.progress.into(),
157+
profile_summary: self.profile_summary(),
157158
}
158159
}
160+
161+
/// Aggregate ClickHouse profile events by name.
162+
///
163+
/// By default, ClickHouse includes a block of ProfileEvents with each
164+
/// query when using the native protocol:
165+
///
166+
/// ```text
167+
/// host_name current_time thread_id type name value
168+
/// ────────────────────────────── ──────────────────── ────────── ───────── ──────────────────────────────── ──────────
169+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 increment ReadCompressedBytes 124134764
170+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 increment DiskReadElapsedMicroseconds 211350
171+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 increment SelectedRows 16941579
172+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 increment UserTimeMicroseconds 339959
173+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 increment SystemTimeMicroseconds 2169535
174+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 gauge MemoryTrackerUsage 40823063
175+
/// oxz_clickhouse_aa646c82.local 2026-03-25 21:00:00 0 gauge MemoryTrackerPeakUsage 40823063
176+
/// ...
177+
/// ```
178+
///
179+
/// If profile events are available, sum their values by event name to
180+
/// attach to the query summary.
181+
fn profile_summary(&self) -> BTreeMap<String, i64> {
182+
let mut summary = BTreeMap::new();
183+
if let Some(block) = self.profile_events.as_ref() {
184+
let names = block
185+
.column_values("name")
186+
.ok()
187+
.and_then(|v| v.as_string().ok());
188+
let values =
189+
block.column_values("value").ok().and_then(|v| v.as_i64().ok());
190+
if let (Some(names), Some(values)) = (names, values) {
191+
for (name, &value) in names.iter().zip(values.iter()) {
192+
*summary.entry(name.clone()).or_insert(0) += value;
193+
}
194+
}
195+
}
196+
summary
197+
}
159198
}
160199

161200
/// The stage through which we run a query.

oximeter/db/tests/integration_test.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,23 @@ async fn test_cluster() -> anyhow::Result<()> {
263263
// Ensure the samples are correct on this replica
264264
assert_input_and_output(&input, &samples, &oxql_res1);
265265

266+
// Assert that at least one query summary and at least one known profile
267+
// type are present.
268+
assert!(
269+
!oxql_res1.query_summaries.is_empty(),
270+
"expected at least one query summary"
271+
);
272+
let total_user_cpu: i64 = oxql_res1
273+
.query_summaries
274+
.iter()
275+
.filter_map(|s| s.profile_summary.get("UserTimeMicroseconds"))
276+
.sum();
277+
assert!(
278+
total_user_cpu > 0,
279+
"expected non-zero UserTimeMicroseconds in profile summaries, got {}",
280+
total_user_cpu,
281+
);
282+
266283
let start = tokio::time::Instant::now();
267284
wait_for_num_points(&log, &client2, samples.len())
268285
.await

oximeter/oxql-types/src/query_summary.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
use schemars::JsonSchema;
1010
use serde::{Deserialize, Serialize};
11+
use std::collections::BTreeMap;
1112
use std::time::Duration;
1213
use uuid::Uuid;
1314

@@ -46,4 +47,6 @@ pub struct QuerySummary {
4647
pub elapsed: Duration,
4748
/// Summary of the data read and written.
4849
pub io_summary: IoSummary,
50+
/// Aggregated profile events from the query.
51+
pub profile_summary: BTreeMap<String, i64>,
4952
}

0 commit comments

Comments
 (0)