Skip to content

Commit 1ca37f1

Browse files
authored
perf(bench): downsample time series charts with LTTB (#2831)
ECharts becomes sluggish rendering 100k+ data points, common with multi-series 10-minute benchmarks at 10ms sampling. LTTB (Largest-Triangle-Three-Buckets) reduces each series to 3000 points at the chart boundary while preserving visual shape -- peaks, valleys, and trends. Full-resolution data remains in report.json; only the charming conversion path is downsampled. The latency distribution chart (50 pre-computed bins) is unaffected.
1 parent 3432714 commit 1ca37f1

8 files changed

Lines changed: 169 additions & 16 deletions

File tree

Cargo.lock

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

DEPENDENCIES.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ base64: 0.22.1, "Apache-2.0 OR MIT",
8484
base64ct: 1.8.3, "Apache-2.0 OR MIT",
8585
bdd: 0.0.1, "Apache-2.0",
8686
beef: 0.5.2, "Apache-2.0 OR MIT",
87-
bench-dashboard-frontend: 0.5.0, "Apache-2.0",
87+
bench-dashboard-frontend: 0.5.1-edge.1, "Apache-2.0",
8888
bench-dashboard-shared: 0.1.0, "Apache-2.0",
89-
bench-report: 0.2.2, "Apache-2.0",
89+
bench-report: 0.2.3-edge.1, "Apache-2.0",
9090
bench-runner: 0.1.0, "Apache-2.0",
9191
bigdecimal: 0.4.10, "Apache-2.0 OR MIT",
9292
bimap: 0.6.3, "Apache-2.0 OR MIT",
@@ -392,7 +392,7 @@ idna: 1.1.0, "Apache-2.0 OR MIT",
392392
idna_adapter: 1.2.1, "Apache-2.0 OR MIT",
393393
iggy: 0.9.0, "Apache-2.0",
394394
iggy-bench: 0.4.0, "Apache-2.0",
395-
iggy-bench-dashboard-server: 0.6.0, "Apache-2.0",
395+
iggy-bench-dashboard-server: 0.6.1-edge.1, "Apache-2.0",
396396
iggy-cli: 0.11.0, "Apache-2.0",
397397
iggy-connectors: 0.3.0, "Apache-2.0",
398398
iggy-mcp: 0.3.0, "Apache-2.0",

core/bench/dashboard/frontend/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
[package]
1919
name = "bench-dashboard-frontend"
2020
license = "Apache-2.0"
21-
version = "0.5.0"
21+
version = "0.5.1-edge.1"
2222
edition = "2024"
2323

2424
[package.metadata.cargo-machete]

core/bench/dashboard/server/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
[package]
1919
name = "iggy-bench-dashboard-server"
2020
license = "Apache-2.0"
21-
version = "0.6.0"
21+
version = "0.6.1-edge.1"
2222
edition = "2024"
2323

2424
[dependencies]

core/bench/report/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
[package]
1919
name = "bench-report"
20-
version = "0.2.2"
20+
version = "0.2.3-edge.1"
2121
edition = "2024"
2222
description = "Benchmark report and chart generation library for iggy-bench binary and iggy-benchmarks-dashboard web app"
2323
license = "Apache-2.0"

core/bench/report/src/lib.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@ pub fn create_throughput_chart(
5252

5353
chart = chart.add_dual_time_line_series(
5454
&format!("{} {} [MB/s]", actor_type, metrics.summary.actor_id),
55-
metrics.throughput_mb_ts.as_charming_points(),
55+
metrics.throughput_mb_ts.as_downsampled_charming_points(),
5656
None,
5757
0.4,
5858
0,
5959
1.0,
6060
);
6161
chart = chart.add_dual_time_line_series(
6262
&format!("{} {} [msg/s]", actor_type, metrics.summary.actor_id),
63-
metrics.throughput_msg_ts.as_charming_points(),
63+
metrics.throughput_msg_ts.as_downsampled_charming_points(),
6464
None,
6565
0.4,
6666
1,
@@ -77,15 +77,19 @@ pub fn create_throughput_chart(
7777

7878
chart = chart.add_dual_time_line_series(
7979
&format!("All {}s [MB/s]", metrics.summary.kind.actor()),
80-
metrics.avg_throughput_mb_ts.as_charming_points(),
80+
metrics
81+
.avg_throughput_mb_ts
82+
.as_downsampled_charming_points(),
8183
None,
8284
1.0,
8385
0,
8486
2.0,
8587
);
8688
chart = chart.add_dual_time_line_series(
8789
&format!("All {}s [msg/s]", metrics.summary.kind.actor()),
88-
metrics.avg_throughput_msg_ts.as_charming_points(),
90+
metrics
91+
.avg_throughput_msg_ts
92+
.as_downsampled_charming_points(),
8993
None,
9094
1.0,
9195
1,
@@ -117,7 +121,7 @@ pub fn create_latency_chart(
117121

118122
chart = chart.add_time_series(
119123
&format!("{} {} [ms]", actor_type, metrics.summary.actor_id),
120-
metrics.latency_ts.as_charming_points(),
124+
metrics.latency_ts.as_downsampled_charming_points(),
121125
None,
122126
0.3,
123127
);
@@ -131,7 +135,7 @@ pub fn create_latency_chart(
131135

132136
chart = chart.add_dual_time_line_series(
133137
&format!("Avg {}s [ms]", metrics.summary.kind.actor()),
134-
metrics.avg_latency_ts.as_charming_points(),
138+
metrics.avg_latency_ts.as_downsampled_charming_points(),
135139
None,
136140
1.0,
137141
0,

core/bench/report/src/types/time_series.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,22 @@ pub enum TimeSeriesKind {
4848
Latency,
4949
}
5050

51+
const MAX_CHART_POINTS: usize = 3000;
52+
5153
impl TimeSeries {
5254
pub fn as_charming_points(&self) -> Vec<Vec<f64>> {
5355
self.points
5456
.iter()
5557
.map(|p| vec![p.time_s, p.value])
5658
.collect()
5759
}
60+
61+
/// LTTB-downsampled points for chart rendering.
62+
/// Caps output at `MAX_CHART_POINTS` to keep ECharts responsive.
63+
pub fn as_downsampled_charming_points(&self) -> Vec<Vec<f64>> {
64+
crate::utils::lttb_downsample(&self.points, MAX_CHART_POINTS)
65+
.iter()
66+
.map(|p| vec![p.time_s, p.value])
67+
.collect()
68+
}
5869
}

core/bench/report/src/utils.rs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* under the License.
1717
*/
1818

19-
use crate::time_series::TimeSeries;
19+
use crate::time_series::{TimePoint, TimeSeries};
2020
use serde::Serializer;
2121

2222
pub(crate) fn round_float<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
@@ -48,6 +48,66 @@ pub fn max(series: &TimeSeries) -> Option<f64> {
4848
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
4949
}
5050

51+
/// LTTB (Largest-Triangle-Three-Buckets) downsampling.
52+
///
53+
/// Reduces `points` to at most `threshold` points while preserving visual shape
54+
/// (peaks, valleys, trends). Returns a clone if `points.len() <= threshold`.
55+
pub fn lttb_downsample(points: &[TimePoint], threshold: usize) -> Vec<TimePoint> {
56+
let len = points.len();
57+
if len <= threshold || threshold < 3 {
58+
return points.to_vec();
59+
}
60+
61+
let bucket_count = threshold - 2;
62+
let bucket_size = (len - 2) as f64 / bucket_count as f64;
63+
64+
let mut result = Vec::with_capacity(threshold);
65+
result.push(points[0].clone());
66+
67+
let mut prev_selected = 0usize;
68+
69+
for bucket_idx in 0..bucket_count {
70+
// Compute average of the *next* bucket (used as the third triangle vertex)
71+
let next_start = ((bucket_idx + 1) as f64 * bucket_size) as usize + 1;
72+
let next_end = (((bucket_idx + 2) as f64 * bucket_size) as usize + 1).min(len - 1);
73+
74+
let mut avg_time = 0.0;
75+
let mut avg_value = 0.0;
76+
let next_count = (next_end - next_start + 1) as f64;
77+
for p in &points[next_start..=next_end] {
78+
avg_time += p.time_s;
79+
avg_value += p.value;
80+
}
81+
avg_time /= next_count;
82+
avg_value /= next_count;
83+
84+
// Current bucket range
85+
let cur_start = (bucket_idx as f64 * bucket_size) as usize + 1;
86+
let cur_end = next_start;
87+
88+
// Pick the point in this bucket that forms the largest triangle with
89+
// the previously selected point and the next-bucket average.
90+
let prev = &points[prev_selected];
91+
let mut max_area = -1.0;
92+
let mut best = cur_start;
93+
for (i, p) in points[cur_start..cur_end].iter().enumerate() {
94+
let area = ((prev.time_s - avg_time) * (p.value - prev.value)
95+
- (prev.time_s - p.time_s) * (avg_value - prev.value))
96+
.abs();
97+
if area > max_area {
98+
max_area = area;
99+
best = cur_start + i;
100+
}
101+
}
102+
103+
result.push(points[best].clone());
104+
prev_selected = best;
105+
}
106+
107+
result.push(points[len - 1].clone());
108+
result
109+
}
110+
51111
/// Calculate the standard deviation of values from a TimeSeries
52112
///
53113
/// Returns None if the TimeSeries has fewer than 2 points
@@ -73,3 +133,81 @@ pub fn std_dev(series: &TimeSeries) -> Option<f64> {
73133

74134
Some(variance.sqrt())
75135
}
136+
137+
#[cfg(test)]
138+
mod tests {
139+
use super::*;
140+
141+
fn make_points(values: impl IntoIterator<Item = f64>) -> Vec<TimePoint> {
142+
values
143+
.into_iter()
144+
.enumerate()
145+
.map(|(i, v)| TimePoint::new(i as f64, v))
146+
.collect()
147+
}
148+
149+
#[test]
150+
fn lttb_passthrough_when_below_threshold() {
151+
let pts = make_points([1.0, 2.0, 3.0]);
152+
let result = lttb_downsample(&pts, 5);
153+
assert_eq!(result, pts);
154+
}
155+
156+
#[test]
157+
fn lttb_passthrough_when_equal_to_threshold() {
158+
let pts = make_points([1.0, 2.0, 3.0, 4.0, 5.0]);
159+
let result = lttb_downsample(&pts, 5);
160+
assert_eq!(result, pts);
161+
}
162+
163+
#[test]
164+
fn lttb_reduces_count() {
165+
let pts = make_points((0..10_000).map(|i| (i as f64).sin()));
166+
let result = lttb_downsample(&pts, 100);
167+
assert_eq!(result.len(), 100);
168+
}
169+
170+
#[test]
171+
fn lttb_preserves_endpoints() {
172+
let pts = make_points((0..1000).map(|i| i as f64 * 0.1));
173+
let result = lttb_downsample(&pts, 50);
174+
assert_eq!(result.first().unwrap().time_s, pts.first().unwrap().time_s);
175+
assert_eq!(result.last().unwrap().time_s, pts.last().unwrap().time_s);
176+
}
177+
178+
#[test]
179+
fn lttb_preserves_peaks() {
180+
// Triangle wave with clear peaks at indices 50, 150, 250, ...
181+
let pts: Vec<TimePoint> = (0..500)
182+
.map(|i| {
183+
let v = if (i / 50) % 2 == 0 {
184+
(i % 50) as f64
185+
} else {
186+
(50 - i % 50) as f64
187+
};
188+
TimePoint::new(i as f64, v)
189+
})
190+
.collect();
191+
192+
let result = lttb_downsample(&pts, 100);
193+
let result_values: Vec<f64> = result.iter().map(|p| p.value).collect();
194+
let max_val = result_values
195+
.iter()
196+
.cloned()
197+
.fold(f64::NEG_INFINITY, f64::max);
198+
// LTTB should retain the peaks (value = 50) in the downsampled output
199+
assert!(
200+
(max_val - 50.0).abs() < f64::EPSILON,
201+
"peak 50.0 not preserved, got max {max_val}"
202+
);
203+
}
204+
205+
#[test]
206+
fn lttb_edge_cases() {
207+
assert!(lttb_downsample(&[], 10).is_empty());
208+
let one = make_points([42.0]);
209+
assert_eq!(lttb_downsample(&one, 10), one);
210+
let two = make_points([1.0, 2.0]);
211+
assert_eq!(lttb_downsample(&two, 10), two);
212+
}
213+
}

0 commit comments

Comments
 (0)