Skip to main content

keyhog_profile/
comparison.rs

1use crate::{RunProfile, Stage};
2use serde::{Deserialize, Serialize};
3use std::fmt::Debug;
4
5pub const PROFILE_COMPARISON_VERSION: u16 = 1;
6pub const COMPARISON_DIFFERENCE_VERSION: u16 = 1;
7pub const STAGE_COMPARISON_VERSION: u16 = 1;
8
9/// One identity or workload field that prevents a valid performance comparison.
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11pub struct ComparisonDifference {
12    pub version: u16,
13    pub field: String,
14    pub baseline: String,
15    pub candidate: String,
16}
17
18/// Exact aggregate difference for one stage.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20pub struct StageComparison {
21    pub version: u16,
22    pub stage: Stage,
23    pub baseline_elapsed_ns: u64,
24    pub candidate_elapsed_ns: u64,
25    pub elapsed_delta_ns: i128,
26    pub elapsed_change_percent: Option<f64>,
27    pub baseline_calls: u64,
28    pub candidate_calls: u64,
29    pub calls_delta: i128,
30}
31
32/// Deterministic comparison of two profile records.
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct ProfileComparison {
35    pub version: u16,
36    pub baseline_run_id: String,
37    pub candidate_run_id: String,
38    pub comparable: bool,
39    pub incompatibilities: Vec<ComparisonDifference>,
40    pub baseline_wall_time_ns: u64,
41    pub candidate_wall_time_ns: u64,
42    pub wall_time_delta_ns: i128,
43    pub wall_time_change_percent: Option<f64>,
44    pub stages: Vec<StageComparison>,
45}
46
47fn signed_delta(candidate: u64, baseline: u64) -> i128 {
48    i128::from(candidate) - i128::from(baseline)
49}
50
51fn percent_change(candidate: u64, baseline: u64) -> Option<f64> {
52    if baseline == 0 {
53        return (candidate == 0).then_some(0.0);
54    }
55    Some((candidate as f64 - baseline as f64) * 100.0 / baseline as f64)
56}
57
58fn compare_field<T: Debug + PartialEq>(
59    differences: &mut Vec<ComparisonDifference>,
60    field: &str,
61    baseline: &T,
62    candidate: &T,
63) {
64    if baseline != candidate {
65        differences.push(ComparisonDifference {
66            version: COMPARISON_DIFFERENCE_VERSION,
67            field: field.to_owned(),
68            baseline: format!("{baseline:?}"),
69            candidate: format!("{candidate:?}"),
70        });
71    }
72}
73
74/// Compare two runs only after checking every identity field that changes timing.
75pub fn compare_profiles(baseline: &RunProfile, candidate: &RunProfile) -> ProfileComparison {
76    let mut incompatibilities = Vec::new();
77    compare_field(
78        &mut incompatibilities,
79        "profile.schema",
80        &baseline.schema,
81        &candidate.schema,
82    );
83    compare_field(
84        &mut incompatibilities,
85        "profile.version",
86        &baseline.version,
87        &candidate.version,
88    );
89    compare_field(
90        &mut incompatibilities,
91        "identity.version",
92        &baseline.identity.version,
93        &candidate.identity.version,
94    );
95    compare_field(
96        &mut incompatibilities,
97        "identity.binary_version",
98        &baseline.identity.binary_version,
99        &candidate.identity.binary_version,
100    );
101    compare_field(
102        &mut incompatibilities,
103        "identity.detector_digest",
104        &baseline.identity.detector_digest,
105        &candidate.identity.detector_digest,
106    );
107    compare_field(
108        &mut incompatibilities,
109        "identity.config_digest",
110        &baseline.identity.config_digest,
111        &candidate.identity.config_digest,
112    );
113    compare_field(
114        &mut incompatibilities,
115        "identity.source_kind",
116        &baseline.identity.source_kind,
117        &candidate.identity.source_kind,
118    );
119    compare_field(
120        &mut incompatibilities,
121        "identity.workload_class",
122        &baseline.identity.workload_class,
123        &candidate.identity.workload_class,
124    );
125    compare_field(
126        &mut incompatibilities,
127        "identity.backend_requested",
128        &baseline.identity.backend_requested,
129        &candidate.identity.backend_requested,
130    );
131    compare_field(
132        &mut incompatibilities,
133        "identity.backend_selected",
134        &baseline.identity.backend_selected,
135        &candidate.identity.backend_selected,
136    );
137    compare_field(
138        &mut incompatibilities,
139        "identity.cache_state",
140        &baseline.identity.cache_state,
141        &candidate.identity.cache_state,
142    );
143    compare_field(
144        &mut incompatibilities,
145        "identity.daemon_state",
146        &baseline.identity.daemon_state,
147        &candidate.identity.daemon_state,
148    );
149    compare_field(
150        &mut incompatibilities,
151        "identity.scanner_threads",
152        &baseline.identity.scanner_threads,
153        &candidate.identity.scanner_threads,
154    );
155    compare_field(
156        &mut incompatibilities,
157        "identity.reader_threads",
158        &baseline.identity.reader_threads,
159        &candidate.identity.reader_threads,
160    );
161    compare_field(
162        &mut incompatibilities,
163        "identity.logical_cpus",
164        &baseline.identity.logical_cpus,
165        &candidate.identity.logical_cpus,
166    );
167    compare_field(
168        &mut incompatibilities,
169        "input_bytes",
170        &baseline.input_bytes,
171        &candidate.input_bytes,
172    );
173    compare_field(
174        &mut incompatibilities,
175        "input_units",
176        &baseline.input_units,
177        &candidate.input_units,
178    );
179    compare_field(
180        &mut incompatibilities,
181        "collectors",
182        &baseline.collectors,
183        &candidate.collectors,
184    );
185
186    let stages = Stage::ALL
187        .into_iter()
188        .filter_map(|stage| {
189            let baseline_stage = baseline.stages.iter().find(|item| item.stage == stage);
190            let candidate_stage = candidate.stages.iter().find(|item| item.stage == stage);
191            if baseline_stage.is_none() && candidate_stage.is_none() {
192                return None;
193            }
194            let baseline_elapsed_ns = baseline_stage.map_or(0, |item| item.elapsed_ns);
195            let candidate_elapsed_ns = candidate_stage.map_or(0, |item| item.elapsed_ns);
196            let baseline_calls = baseline_stage.map_or(0, |item| item.calls);
197            let candidate_calls = candidate_stage.map_or(0, |item| item.calls);
198            Some(StageComparison {
199                version: STAGE_COMPARISON_VERSION,
200                stage,
201                baseline_elapsed_ns,
202                candidate_elapsed_ns,
203                elapsed_delta_ns: signed_delta(candidate_elapsed_ns, baseline_elapsed_ns),
204                elapsed_change_percent: percent_change(candidate_elapsed_ns, baseline_elapsed_ns),
205                baseline_calls,
206                candidate_calls,
207                calls_delta: signed_delta(candidate_calls, baseline_calls),
208            })
209        })
210        .collect();
211
212    ProfileComparison {
213        version: PROFILE_COMPARISON_VERSION,
214        baseline_run_id: baseline.identity.run_id.clone(),
215        candidate_run_id: candidate.identity.run_id.clone(),
216        comparable: incompatibilities.is_empty(),
217        incompatibilities,
218        baseline_wall_time_ns: baseline.wall_time_ns,
219        candidate_wall_time_ns: candidate.wall_time_ns,
220        wall_time_delta_ns: signed_delta(candidate.wall_time_ns, baseline.wall_time_ns),
221        wall_time_change_percent: percent_change(candidate.wall_time_ns, baseline.wall_time_ns),
222        stages,
223    }
224}
225
226impl ProfileComparison {
227    /// Render a stable text comparison. Incompatible inputs remain visible and are never called a speedup.
228    pub fn render_text(&self) -> String {
229        let mut output = format!(
230            "KeyHog profile comparison comparable={} baseline_run={:?} candidate_run={:?}\n",
231            self.comparable, self.baseline_run_id, self.candidate_run_id,
232        );
233        for difference in &self.incompatibilities {
234            output.push_str(&format!(
235                "incompatible field={} baseline={} candidate={}\n",
236                difference.field, difference.baseline, difference.candidate,
237            ));
238        }
239        output.push_str(&format!(
240            "wall baseline_ns={} candidate_ns={} delta_ns={}",
241            self.baseline_wall_time_ns, self.candidate_wall_time_ns, self.wall_time_delta_ns,
242        ));
243        if let Some(percent) = self.wall_time_change_percent {
244            output.push_str(&format!(" change_percent={percent:.3}"));
245        } else {
246            output.push_str(" change_percent=undefined");
247        }
248        output.push('\n');
249        for stage in &self.stages {
250            output.push_str(&format!(
251                "stage {} baseline_ns={} candidate_ns={} delta_ns={} calls={}->{}",
252                stage.stage.as_str(),
253                stage.baseline_elapsed_ns,
254                stage.candidate_elapsed_ns,
255                stage.elapsed_delta_ns,
256                stage.baseline_calls,
257                stage.candidate_calls,
258            ));
259            if let Some(percent) = stage.elapsed_change_percent {
260                output.push_str(&format!(" change_percent={percent:.3}"));
261            } else {
262                output.push_str(" change_percent=undefined");
263            }
264            output.push('\n');
265        }
266        output
267    }
268}