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 now = Instant::now();
131    {
132        let mut guard = STOPWATCH.lock().map_err(|_| {
133            stopwatch_error_with_message(
134                builtin,
135                TIC_ERROR_STATE_LOCK.message,
136                &TIC_ERROR_STATE_LOCK,
137            )
138        })?;
139        guard.push(now);
140    }
141    Ok(encode_instant(now))
142}
143
144/// Remove and return the most recently recorded `tic`, if any.
145pub(crate) fn take_latest_start(builtin: &str) -> Result<Option<Instant>, crate::RuntimeError> {
146    let mut guard = STOPWATCH.lock().map_err(|_| {
147        stopwatch_error_with_message(builtin, TIC_ERROR_STATE_LOCK.message, &TIC_ERROR_STATE_LOCK)
148    })?;
149    Ok(guard.pop())
150}
151
152/// Encode an `Instant` into the scalar handle returned by `tic`.
153pub(crate) fn encode_instant(instant: Instant) -> f64 {
154    instant.duration_since(*MONOTONIC_ORIGIN).as_secs_f64()
155}
156
157/// Decode a scalar handle into an `Instant`.
158pub(crate) fn decode_handle(
159    handle: f64,
160    builtin: &str,
161    error: &BuiltinErrorDescriptor,
162) -> Result<Instant, crate::RuntimeError> {
163    if !handle.is_finite() || handle.is_sign_negative() {
164        return Err(stopwatch_error_with_message(builtin, error.message, error));
165    }
166    let duration = Duration::from_secs_f64(handle);
167    Ok((*MONOTONIC_ORIGIN) + duration)
168}
169
170#[cfg(test)]
171pub(crate) mod tests {
172    use super::*;
173    use futures::executor::block_on;
174    use std::thread;
175    use std::time::Duration;
176
177    const TEST_INVALID_HANDLE_ERROR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
178        code: "RM.TOC.INVALID_HANDLE",
179        identifier: Some("RunMat:toc:InvalidTimerHandle"),
180        when: "The timer handle is non-finite or negative.",
181        message: "toc: invalid timer handle",
182    };
183
184    fn reset_stopwatch() {
185        let mut guard = STOPWATCH.lock().unwrap();
186        guard.stack.clear();
187    }
188
189    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
190    #[test]
191    fn tic_returns_monotonic_handle() {
192        let _guard = TEST_GUARD.lock().unwrap();
193        reset_stopwatch();
194        let handle = block_on(tic_builtin()).expect("tic");
195        assert!(handle >= 0.0);
196        assert!(take_latest_start(BUILTIN_NAME).expect("take").is_some());
197    }
198
199    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
200    #[test]
201    fn tic_handles_increase_over_time() {
202        let _guard = TEST_GUARD.lock().unwrap();
203        reset_stopwatch();
204        let first = block_on(tic_builtin()).expect("tic");
205        thread::sleep(Duration::from_millis(5));
206        let second = block_on(tic_builtin()).expect("tic");
207        assert!(second > first);
208    }
209
210    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
211    #[test]
212    fn decode_roundtrip_matches_handle() {
213        let _guard = TEST_GUARD.lock().unwrap();
214        reset_stopwatch();
215        let handle = block_on(tic_builtin()).expect("tic");
216        let decoded = decode_handle(handle, "toc", &TEST_INVALID_HANDLE_ERROR).expect("decode");
217        let round_trip = encode_instant(decoded);
218        let delta = (round_trip - handle).abs();
219        assert!(delta < 1e-9, "delta {delta}");
220    }
221
222    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
223    #[test]
224    fn take_latest_start_pops_stack() {
225        let _guard = TEST_GUARD.lock().unwrap();
226        reset_stopwatch();
227        block_on(tic_builtin()).expect("tic");
228        assert!(take_latest_start(BUILTIN_NAME).expect("take").is_some());
229        assert!(take_latest_start(BUILTIN_NAME)
230            .expect("second take")
231            .is_none());
232    }
233
234    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
235    #[test]
236    fn decode_handle_rejects_invalid_values() {
237        let _guard = TEST_GUARD.lock().unwrap();
238        assert!(decode_handle(f64::NAN, "toc", &TEST_INVALID_HANDLE_ERROR).is_err());
239        assert!(decode_handle(-1.0, "toc", &TEST_INVALID_HANDLE_ERROR).is_err());
240    }
241}