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