Skip to main content

runmat_runtime/builtins/timing/
tic.rs

1//! MATLAB-compatible `tic` builtin with precise stopwatch semantics for RunMat.
2
3use once_cell::sync::Lazy;
4use runmat_builtins::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7};
8use runmat_macros::runtime_builtin;
9use runmat_time::Instant;
10use std::sync::Mutex;
11use std::time::Duration;
12
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::builtins::timing::type_resolvers::tic_type;
18
19#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::timing::tic")]
20pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
21    name: "tic",
22    op_kind: GpuOpKind::Custom("timer"),
23    supported_precisions: &[],
24    broadcast: BroadcastSemantics::None,
25    provider_hooks: &[],
26    constant_strategy: ConstantStrategy::InlineLiteral,
27    residency: ResidencyPolicy::GatherImmediately,
28    nan_mode: ReductionNaN::Include,
29    two_pass_threshold: None,
30    workgroup_size: None,
31    accepts_nan_mode: false,
32    notes: "Stopwatch state lives on the host. Providers are never consulted for tic/toc.",
33};
34
35#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::timing::tic")]
36pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
37    name: "tic",
38    shape: ShapeRequirements::Any,
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    elementwise: None,
41    reduction: None,
42    emits_nan: false,
43    notes: "Timing builtins are executed eagerly on the host and do not participate in fusion.",
44};
45
46static MONOTONIC_ORIGIN: Lazy<Instant> = Lazy::new(Instant::now);
47static STOPWATCH: Lazy<Mutex<StopwatchState>> = Lazy::new(|| Mutex::new(StopwatchState::default()));
48
49#[cfg(test)]
50pub(crate) static TEST_GUARD: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
51
52#[derive(Default)]
53struct StopwatchState {
54    stack: Vec<Instant>,
55}
56
57impl StopwatchState {
58    fn push(&mut self, instant: Instant) {
59        self.stack.push(instant);
60    }
61
62    fn pop(&mut self) -> Option<Instant> {
63        self.stack.pop()
64    }
65}
66
67const BUILTIN_NAME: &str = "tic";
68
69const TIC_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
70    name: "timerVal",
71    ty: BuiltinParamType::NumericScalar,
72    arity: BuiltinParamArity::Required,
73    default: None,
74    description: "Timer handle used by toc.",
75}];
76
77const TIC_INPUTS: [BuiltinParamDescriptor; 0] = [];
78
79const TIC_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
80    label: "timerVal = tic()",
81    inputs: &TIC_INPUTS,
82    outputs: &TIC_OUTPUT,
83}];
84
85const TIC_ERROR_STATE_LOCK: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
86    code: "RM.TIC.STATE_LOCK",
87    identifier: Some("RunMat:tic:StateLockFailed"),
88    when: "Internal stopwatch state cannot be acquired.",
89    message: "tic: failed to acquire stopwatch state",
90};
91
92const TIC_ERRORS: [BuiltinErrorDescriptor; 1] = [TIC_ERROR_STATE_LOCK];
93
94pub const TIC_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
95    signatures: &TIC_SIGNATURES,
96    output_mode: BuiltinOutputMode::Fixed,
97    completion_policy: BuiltinCompletionPolicy::Public,
98    errors: &TIC_ERRORS,
99};
100
101fn stopwatch_error_with_message(
102    builtin: &str,
103    message: impl Into<String>,
104    error: &BuiltinErrorDescriptor,
105) -> crate::RuntimeError {
106    let mut builder = crate::build_runtime_error(message).with_builtin(builtin);
107    if let Some(identifier) = error.identifier {
108        builder = builder.with_identifier(identifier);
109    }
110    builder.build()
111}
112
113/// Start a stopwatch timer and return a handle suitable for `toc`.
114#[runtime_builtin(
115    name = "tic",
116    category = "timing",
117    summary = "Start a high-resolution stopwatch and optionally return a toc handle.",
118    keywords = "tic,timing,profiling,benchmark",
119    sink = true,
120    type_resolver(tic_type),
121    descriptor(crate::builtins::timing::tic::TIC_DESCRIPTOR),
122    builtin_path = "crate::builtins::timing::tic"
123)]
124pub async fn tic_builtin() -> crate::BuiltinResult<f64> {
125    record_tic(BUILTIN_NAME)
126}
127
128/// Record a `tic` start time and return the encoded handle.
129pub(crate) fn record_tic(builtin: &str) -> Result<f64, crate::RuntimeError> {
130    let _origin = *MONOTONIC_ORIGIN;
131    let now = Instant::now();
132    {
133        let mut guard = STOPWATCH.lock().map_err(|_| {
134            stopwatch_error_with_message(
135                builtin,
136                TIC_ERROR_STATE_LOCK.message,
137                &TIC_ERROR_STATE_LOCK,
138            )
139        })?;
140        guard.push(now);
141    }
142    Ok(encode_instant(now))
143}
144
145/// Remove and return the most recently recorded `tic`, if any.
146pub(crate) fn take_latest_start(builtin: &str) -> Result<Option<Instant>, crate::RuntimeError> {
147    let mut guard = STOPWATCH.lock().map_err(|_| {
148        stopwatch_error_with_message(builtin, TIC_ERROR_STATE_LOCK.message, &TIC_ERROR_STATE_LOCK)
149    })?;
150    Ok(guard.pop())
151}
152
153/// Encode an `Instant` into the scalar handle returned by `tic`.
154pub(crate) fn encode_instant(instant: Instant) -> f64 {
155    instant
156        .checked_duration_since(*MONOTONIC_ORIGIN)
157        .unwrap_or(Duration::ZERO)
158        .as_secs_f64()
159}
160
161/// Decode a scalar handle into an `Instant`.
162pub(crate) fn decode_handle(
163    handle: f64,
164    builtin: &str,
165    error: &BuiltinErrorDescriptor,
166) -> Result<Instant, crate::RuntimeError> {
167    if !handle.is_finite() || handle.is_sign_negative() {
168        return Err(stopwatch_error_with_message(builtin, error.message, error));
169    }
170    let duration = Duration::try_from_secs_f64(handle)
171        .map_err(|_| stopwatch_error_with_message(builtin, error.message, error))?;
172    (*MONOTONIC_ORIGIN)
173        .checked_add(duration)
174        .ok_or_else(|| stopwatch_error_with_message(builtin, error.message, error))
175}
176
177/// Return elapsed time since `start`, saturating to zero if the host clock reports
178/// a slightly earlier current instant on wasm.
179pub(crate) fn elapsed_since(start: Instant) -> Duration {
180    Instant::now()
181        .checked_duration_since(start)
182        .unwrap_or(Duration::ZERO)
183}
184
185#[cfg(test)]
186pub(crate) mod tests {
187    use super::*;
188    use futures::executor::block_on;
189    use std::thread;
190    use std::time::Duration;
191
192    const TEST_INVALID_HANDLE_ERROR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
193        code: "RM.TOC.INVALID_HANDLE",
194        identifier: Some("RunMat:toc:InvalidTimerHandle"),
195        when: "The timer handle is non-finite or negative.",
196        message: "toc: invalid timer handle",
197    };
198
199    fn reset_stopwatch() {
200        let mut guard = STOPWATCH.lock().unwrap();
201        guard.stack.clear();
202    }
203
204    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
205    #[test]
206    fn tic_returns_monotonic_handle() {
207        let _guard = TEST_GUARD.lock().unwrap();
208        reset_stopwatch();
209        let handle = block_on(tic_builtin()).expect("tic");
210        assert!(handle >= 0.0);
211        assert!(take_latest_start(BUILTIN_NAME).expect("take").is_some());
212    }
213
214    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
215    #[test]
216    fn tic_handles_increase_over_time() {
217        let _guard = TEST_GUARD.lock().unwrap();
218        reset_stopwatch();
219        let first = block_on(tic_builtin()).expect("tic");
220        thread::sleep(Duration::from_millis(5));
221        let second = block_on(tic_builtin()).expect("tic");
222        assert!(second > first);
223    }
224
225    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
226    #[test]
227    fn decode_roundtrip_matches_handle() {
228        let _guard = TEST_GUARD.lock().unwrap();
229        reset_stopwatch();
230        let handle = block_on(tic_builtin()).expect("tic");
231        let decoded = decode_handle(handle, "toc", &TEST_INVALID_HANDLE_ERROR).expect("decode");
232        let round_trip = encode_instant(decoded);
233        let delta = (round_trip - handle).abs();
234        assert!(delta < 1e-9, "delta {delta}");
235    }
236
237    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
238    #[test]
239    fn take_latest_start_pops_stack() {
240        let _guard = TEST_GUARD.lock().unwrap();
241        reset_stopwatch();
242        block_on(tic_builtin()).expect("tic");
243        assert!(take_latest_start(BUILTIN_NAME).expect("take").is_some());
244        assert!(take_latest_start(BUILTIN_NAME)
245            .expect("second take")
246            .is_none());
247    }
248
249    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
250    #[test]
251    fn decode_handle_rejects_invalid_values() {
252        let _guard = TEST_GUARD.lock().unwrap();
253        assert!(decode_handle(f64::NAN, "toc", &TEST_INVALID_HANDLE_ERROR).is_err());
254        assert!(decode_handle(-1.0, "toc", &TEST_INVALID_HANDLE_ERROR).is_err());
255        assert!(decode_handle(f64::MAX, "toc", &TEST_INVALID_HANDLE_ERROR).is_err());
256    }
257}