Skip to main content

canic_core/
perf.rs

1//! Cross-cutting performance instrumentation.
2//!
3//! This module provides instruction-count measurement primitives used
4//! across endpoints, ops, timers, and background tasks.
5//!
6//! It is intentionally crate-level infrastructure, not part of the
7//! domain layering (endpoints → ops → model).
8//! Instrumentation modules are layer-neutral and may be used anywhere.
9
10use crate::ids::{EndpointCall, EndpointCallKind};
11use std::{cell::RefCell, collections::HashMap};
12
13thread_local! {
14    /// Last snapshot used by the `perf!` macro.
15    #[cfg(not(test))]
16    pub static PERF_LAST: RefCell<u64> = RefCell::new(perf_counter());
17
18    // Unit tests run outside a canister context, so `perf_counter()` would trap.
19    #[cfg(test)]
20    pub static PERF_LAST: RefCell<u64> = const { RefCell::new(0) };
21
22    /// Aggregated perf counters keyed by kind (endpoint vs timer) and label.
23    static PERF_TABLE: RefCell<HashMap<PerfKey, PerfSlot>> = RefCell::new(HashMap::new());
24
25    /// Stack of active endpoint scopes for exclusive instruction accounting.
26    /// This is independent of `PERF_LAST`, which is only used by `perf!` checkpoints.
27    static PERF_STACK: RefCell<Vec<PerfFrame>> = const { RefCell::new(Vec::new()) };
28}
29
30/// Returns the **call-context instruction counter** for the current execution.
31///
32/// This value is obtained from `ic0.performance_counter(1)` and represents the
33/// total number of WebAssembly instructions executed by *this canister* within
34/// the **current call context**.
35///
36/// Key properties:
37/// - Monotonically increasing for the duration of the call context
38/// - Accumulates across `await` points and resumptions
39/// - Resets only when a new call context begins
40/// - Counts *only* instructions executed by this canister (not other canisters)
41///
42/// This counter is suitable for:
43/// - Endpoint-level performance accounting
44/// - Async workflows and timers
45/// - Regression detection and coarse-grained profiling
46///
47/// It is **not** a measure of cycle cost. Expensive inter-canister operations
48/// (e.g., canister creation) may have low instruction counts here but high cycle
49/// charges elsewhere.
50///
51/// For fine-grained, single-slice profiling (e.g., hot loops), use
52/// `ic0.performance_counter(0)` instead.
53#[must_use]
54#[cfg_attr(not(target_arch = "wasm32"), expect(clippy::missing_const_for_fn))]
55pub fn perf_counter() -> u64 {
56    #[cfg(target_arch = "wasm32")]
57    {
58        crate::cdk::api::performance_counter(1)
59    }
60
61    #[cfg(not(target_arch = "wasm32"))]
62    {
63        0
64    }
65}
66
67///
68/// PerfKey
69/// Splits perf counters by transport surface so metrics rows remain explicit.
70///
71
72#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub enum PerfKey {
74    Endpoint {
75        kind: EndpointCallKind,
76        name: String,
77    },
78    Timer(String),
79    Checkpoint {
80        scope: String,
81        label: String,
82    },
83}
84
85///
86/// PerfFrame
87/// Tracks an active endpoint scope and accumulated child instructions.
88///
89
90struct PerfFrame {
91    start: u64,
92    child_instructions: u64,
93}
94
95///
96/// PerfSlot
97///
98
99#[derive(Default)]
100struct PerfSlot {
101    count: u64,
102    total_instructions: u64,
103}
104
105impl PerfSlot {
106    const fn increment(&mut self, delta: u64) {
107        self.count = self.count.saturating_add(1);
108        self.total_instructions = self.total_instructions.saturating_add(delta);
109    }
110}
111
112///
113/// PerfEntry
114/// Aggregated perf counters keyed by kind (endpoint vs timer) and label.
115///
116
117#[derive(Clone)]
118pub struct PerfEntry {
119    pub key: PerfKey,
120    pub count: u64,
121    pub total_instructions: u64,
122}
123
124/// Record a counter under the provided key.
125pub fn record(key: PerfKey, delta: u64) {
126    PERF_TABLE.with(|table| {
127        let mut table = table.borrow_mut();
128        table.entry(key).or_default().increment(delta);
129    });
130}
131
132pub fn record_endpoint_call(call: EndpointCall, delta_instructions: u64) {
133    record(
134        PerfKey::Endpoint {
135            kind: call.kind,
136            name: call.endpoint.name.to_string(),
137        },
138        delta_instructions,
139    );
140}
141
142pub fn record_timer(label: &str, delta_instructions: u64) {
143    record(PerfKey::Timer(label.to_string()), delta_instructions);
144}
145
146pub fn record_checkpoint(scope: &str, label: &str, delta_instructions: u64) {
147    record(
148        PerfKey::Checkpoint {
149            scope: scope.to_string(),
150            label: label.to_string(),
151        },
152        delta_instructions,
153    );
154}
155
156/// Begin an endpoint scope and push it on the stack.
157pub(crate) fn enter_endpoint() {
158    enter_endpoint_at(perf_counter());
159}
160
161/// End the most recent endpoint scope and record exclusive instructions.
162pub(crate) fn exit_endpoint(call: EndpointCall) {
163    exit_endpoint_at(call, perf_counter());
164}
165
166fn enter_endpoint_at(start: u64) {
167    PERF_STACK.with(|stack| {
168        let mut stack = stack.borrow_mut();
169
170        // If a previous call trapped, clear any stale frames.
171        if let Some(last) = stack.last()
172            && start < last.start
173        {
174            stack.clear();
175        }
176
177        stack.push(PerfFrame {
178            start,
179            child_instructions: 0,
180        });
181    });
182}
183
184fn exit_endpoint_at(call: EndpointCall, end: u64) {
185    PERF_STACK.with(|stack| {
186        let mut stack = stack.borrow_mut();
187        let Some(frame) = stack.pop() else {
188            record_endpoint_call(call, end);
189            return;
190        };
191
192        let total = end.saturating_sub(frame.start);
193        let exclusive = total.saturating_sub(frame.child_instructions);
194
195        if let Some(parent) = stack.last_mut() {
196            parent.child_instructions = parent.child_instructions.saturating_add(total);
197        }
198
199        record_endpoint_call(call, exclusive);
200    });
201}
202
203/// Snapshot all recorded perf counters, sorted by key.
204/// Entries are sorted by (kind, label).
205#[must_use]
206pub fn entries() -> Vec<PerfEntry> {
207    PERF_TABLE.with(|table| {
208        let table = table.borrow();
209
210        let mut out: Vec<PerfEntry> = table
211            .iter()
212            .map(|(key, slot)| PerfEntry {
213                key: key.clone(),
214                count: slot.count,
215                total_instructions: slot.total_instructions,
216            })
217            .collect();
218
219        out.sort_by(|a, b| a.key.cmp(&b.key));
220        out
221    })
222}
223
224// -----------------------------------------------------------------------------
225// Tests
226// -----------------------------------------------------------------------------
227
228#[cfg(test)]
229pub fn reset() {
230    PERF_TABLE.with(|t| t.borrow_mut().clear());
231    PERF_LAST.with(|last| *last.borrow_mut() = 0);
232    PERF_STACK.with(|stack| stack.borrow_mut().clear());
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn checkpoint_at(now: u64) {
240        PERF_LAST.with(|last| *last.borrow_mut() = now);
241    }
242
243    fn call(name: &'static str, kind: EndpointCallKind) -> EndpointCall {
244        EndpointCall {
245            endpoint: crate::ids::EndpointId::new(name),
246            kind,
247        }
248    }
249
250    fn entry_for(kind: EndpointCallKind, label: &str) -> PerfEntry {
251        entries()
252            .into_iter()
253            .find(|entry| {
254                matches!(
255                    &entry.key,
256                    PerfKey::Endpoint {
257                        kind: entry_kind,
258                        name
259                    } if *entry_kind == kind && name == label
260                )
261            })
262            .expect("expected perf entry to exist")
263    }
264
265    fn checkpoint_entry_for(scope: &str, label: &str) -> PerfEntry {
266        entries()
267            .into_iter()
268            .find(|entry| {
269                matches!(
270                    &entry.key,
271                    PerfKey::Checkpoint {
272                        scope: entry_scope,
273                        label: entry_label,
274                    } if entry_scope == scope && entry_label == label
275                )
276            })
277            .expect("expected checkpoint perf entry to exist")
278    }
279
280    #[test]
281    fn nested_endpoints_record_exclusive_totals() {
282        reset();
283
284        enter_endpoint_at(100);
285        checkpoint_at(140);
286
287        enter_endpoint_at(200);
288        checkpoint_at(230);
289        exit_endpoint_at(call("child", EndpointCallKind::Query), 260);
290
291        exit_endpoint_at(call("parent", EndpointCallKind::Update), 300);
292
293        let parent = entry_for(EndpointCallKind::Update, "parent");
294        let child = entry_for(EndpointCallKind::Query, "child");
295
296        assert_eq!(child.count, 1);
297        assert_eq!(child.total_instructions, 60);
298        assert_eq!(parent.count, 1);
299        assert_eq!(parent.total_instructions, 140);
300    }
301
302    #[test]
303    fn endpoint_perf_keys_preserve_call_kind() {
304        reset();
305
306        record_endpoint_call(call("same_name", EndpointCallKind::Query), 10);
307        record_endpoint_call(call("same_name", EndpointCallKind::QueryComposite), 20);
308        record_endpoint_call(call("same_name", EndpointCallKind::Update), 30);
309
310        assert_eq!(
311            entry_for(EndpointCallKind::Query, "same_name").total_instructions,
312            10
313        );
314        assert_eq!(
315            entry_for(EndpointCallKind::QueryComposite, "same_name").total_instructions,
316            20
317        );
318        assert_eq!(
319            entry_for(EndpointCallKind::Update, "same_name").total_instructions,
320            30
321        );
322    }
323
324    #[test]
325    fn checkpoints_record_scope_and_label() {
326        reset();
327
328        record_checkpoint("workflow::bootstrap", "load_cfg", 120);
329        record_checkpoint("workflow::bootstrap", "load_cfg", 80);
330
331        let checkpoint = checkpoint_entry_for("workflow::bootstrap", "load_cfg");
332
333        assert_eq!(checkpoint.count, 2);
334        assert_eq!(checkpoint.total_instructions, 200);
335    }
336}