Skip to main content

runmat_runtime/builtins/timing/
timeit.rs

1//! MATLAB-compatible `timeit` builtin for RunMat.
2//!
3//! Measures the execution time of zero-input function handles by running them
4//! repeatedly and returning the median per-invocation runtime in seconds.
5
6use runmat_time::Instant;
7use std::cmp::Ordering;
8
9use runmat_builtins::{
10    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor, Value,
12};
13use runmat_macros::runtime_builtin;
14
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::timing::type_resolvers::timeit_type;
20
21const TARGET_BATCH_SECONDS: f64 = 0.005;
22const MAX_BATCH_SECONDS: f64 = 0.25;
23const LOOP_COUNT_LIMIT: usize = 1 << 20;
24const MIN_SAMPLE_COUNT: usize = 7;
25const MAX_SAMPLE_COUNT: usize = 21;
26const BUILTIN_NAME: &str = "timeit";
27
28const TIMEIT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
29    name: "t",
30    ty: BuiltinParamType::NumericScalar,
31    arity: BuiltinParamArity::Required,
32    default: None,
33    description: "Median execution time per invocation in seconds.",
34}];
35
36const TIMEIT_INPUTS_ONE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
37    name: "f",
38    ty: BuiltinParamType::Any,
39    arity: BuiltinParamArity::Required,
40    default: None,
41    description: "Zero-input function handle to benchmark.",
42}];
43
44const TIMEIT_INPUTS_TWO: [BuiltinParamDescriptor; 2] = [
45    BuiltinParamDescriptor {
46        name: "f",
47        ty: BuiltinParamType::Any,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "Zero-input function handle to benchmark.",
51    },
52    BuiltinParamDescriptor {
53        name: "numOutputs",
54        ty: BuiltinParamType::IntegerScalar,
55        arity: BuiltinParamArity::Optional,
56        default: Some("1"),
57        description: "Requested output count for invoking the benchmarked handle.",
58    },
59];
60
61const TIMEIT_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
62    BuiltinSignatureDescriptor {
63        label: "t = timeit(f)",
64        inputs: &TIMEIT_INPUTS_ONE,
65        outputs: &TIMEIT_OUTPUT,
66    },
67    BuiltinSignatureDescriptor {
68        label: "t = timeit(f, numOutputs)",
69        inputs: &TIMEIT_INPUTS_TWO,
70        outputs: &TIMEIT_OUTPUT,
71    },
72];
73
74const TIMEIT_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
75    code: "RM.TIMEIT.TOO_MANY_INPUTS",
76    identifier: Some("RunMat:timeit:TooManyInputs"),
77    when: "More than two input arguments are supplied.",
78    message: "timeit: too many input arguments",
79};
80
81const TIMEIT_ERROR_NUM_OUTPUTS_SCALAR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
82    code: "RM.TIMEIT.NUM_OUTPUTS_SCALAR",
83    identifier: Some("RunMat:timeit:NumOutputsScalar"),
84    when: "numOutputs is not a scalar numeric/integer value.",
85    message: "timeit: numOutputs must be a scalar numeric value",
86};
87
88const TIMEIT_ERROR_NUM_OUTPUTS_FINITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
89    code: "RM.TIMEIT.NUM_OUTPUTS_FINITE",
90    identifier: Some("RunMat:timeit:NumOutputsFinite"),
91    when: "numOutputs is NaN or infinite.",
92    message: "timeit: numOutputs must be finite",
93};
94
95const TIMEIT_ERROR_NUM_OUTPUTS_NONNEG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
96    code: "RM.TIMEIT.NUM_OUTPUTS_NONNEGATIVE",
97    identifier: Some("RunMat:timeit:NumOutputsNonnegative"),
98    when: "numOutputs is negative.",
99    message: "timeit: numOutputs must be a nonnegative integer",
100};
101
102const TIMEIT_ERROR_NUM_OUTPUTS_INTEGER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
103    code: "RM.TIMEIT.NUM_OUTPUTS_INTEGER",
104    identifier: Some("RunMat:timeit:NumOutputsInteger"),
105    when: "numOutputs has a non-integer numeric value.",
106    message: "timeit: numOutputs must be an integer value",
107};
108
109const TIMEIT_ERROR_EMPTY_HANDLE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
110    code: "RM.TIMEIT.EMPTY_HANDLE",
111    identifier: Some("RunMat:timeit:EmptyFunctionHandle"),
112    when: "A function-handle string or payload is empty after trimming.",
113    message: "timeit: empty function handle string",
114};
115
116const TIMEIT_ERROR_EXPECTS_AT_HANDLE_STRING: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
117    code: "RM.TIMEIT.EXPECTS_AT_HANDLE_STRING",
118    identifier: Some("RunMat:timeit:ExpectedAtHandleString"),
119    when: "A string/char function handle does not begin with '@'.",
120    message: "timeit: expected a function handle string beginning with '@'",
121};
122
123const TIMEIT_ERROR_HANDLE_KIND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
124    code: "RM.TIMEIT.HANDLE_KIND",
125    identifier: Some("RunMat:timeit:HandleKind"),
126    when: "Function handle argument is not a scalar string/char or callable handle value.",
127    message: "timeit: function handle must be a string scalar or function handle",
128};
129
130const TIMEIT_ERROR_FIRST_ARG_KIND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
131    code: "RM.TIMEIT.FIRST_ARG_KIND",
132    identifier: Some("RunMat:timeit:FirstArgKind"),
133    when: "First argument is not a function handle value.",
134    message: "timeit: first argument must be a function handle",
135};
136
137const TIMEIT_ERRORS: [BuiltinErrorDescriptor; 9] = [
138    TIMEIT_ERROR_TOO_MANY_INPUTS,
139    TIMEIT_ERROR_NUM_OUTPUTS_SCALAR,
140    TIMEIT_ERROR_NUM_OUTPUTS_FINITE,
141    TIMEIT_ERROR_NUM_OUTPUTS_NONNEG,
142    TIMEIT_ERROR_NUM_OUTPUTS_INTEGER,
143    TIMEIT_ERROR_EMPTY_HANDLE,
144    TIMEIT_ERROR_EXPECTS_AT_HANDLE_STRING,
145    TIMEIT_ERROR_HANDLE_KIND,
146    TIMEIT_ERROR_FIRST_ARG_KIND,
147];
148
149pub const TIMEIT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
150    signatures: &TIMEIT_SIGNATURES,
151    output_mode: BuiltinOutputMode::Fixed,
152    completion_policy: BuiltinCompletionPolicy::Public,
153    errors: &TIMEIT_ERRORS,
154};
155
156fn timeit_error_with_message(
157    message: impl Into<String>,
158    error: &'static BuiltinErrorDescriptor,
159) -> crate::RuntimeError {
160    let mut builder = crate::build_runtime_error(message).with_builtin(BUILTIN_NAME);
161    if let Some(identifier) = error.identifier {
162        builder = builder.with_identifier(identifier);
163    }
164    builder.build()
165}
166
167#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::timing::timeit")]
168pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
169    name: "timeit",
170    op_kind: GpuOpKind::Custom("timer"),
171    supported_precisions: &[],
172    broadcast: BroadcastSemantics::None,
173    provider_hooks: &[],
174    constant_strategy: ConstantStrategy::InlineLiteral,
175    residency: ResidencyPolicy::GatherImmediately,
176    nan_mode: ReductionNaN::Include,
177    two_pass_threshold: None,
178    workgroup_size: None,
179    accepts_nan_mode: false,
180    notes: "Host-side helper; GPU kernels execute only if invoked by the timed function.",
181};
182
183#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::timing::timeit")]
184pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
185    name: "timeit",
186    shape: ShapeRequirements::Any,
187    constant_strategy: ConstantStrategy::InlineLiteral,
188    elementwise: None,
189    reduction: None,
190    emits_nan: false,
191    notes: "Timing helper; excluded from fusion planning.",
192};
193
194#[runtime_builtin(
195    name = "timeit",
196    category = "timing",
197    summary = "Measure runtime of zero-argument function handles using repeated execution.",
198    keywords = "timeit,benchmark,timing,performance,gpu",
199    accel = "helper",
200    type_resolver(timeit_type),
201    descriptor(crate::builtins::timing::timeit::TIMEIT_DESCRIPTOR),
202    builtin_path = "crate::builtins::timing::timeit"
203)]
204async fn timeit_builtin(func: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
205    let requested_outputs = parse_num_outputs(&rest)?;
206    let callable = prepare_callable(func, requested_outputs)?;
207
208    // Warm-up once to catch early errors and pay one-time JIT costs.
209    callable.invoke().await?;
210
211    let loop_count = determine_loop_count(&callable).await?;
212    let samples = collect_samples(&callable, loop_count).await?;
213    if samples.is_empty() {
214        return Ok(Value::Num(0.0));
215    }
216
217    Ok(Value::Num(compute_median(samples)))
218}
219
220fn parse_num_outputs(rest: &[Value]) -> Result<Option<usize>, crate::RuntimeError> {
221    match rest.len() {
222        0 => Ok(None),
223        1 => parse_non_negative_integer(&rest[0]).map(Some),
224        _ => Err(timeit_error_with_message(
225            TIMEIT_ERROR_TOO_MANY_INPUTS.message,
226            &TIMEIT_ERROR_TOO_MANY_INPUTS,
227        )),
228    }
229}
230
231fn parse_non_negative_integer(value: &Value) -> Result<usize, crate::RuntimeError> {
232    match value {
233        Value::Int(iv) => {
234            let raw = iv.to_i64();
235            if raw < 0 {
236                Err(timeit_error_with_message(
237                    TIMEIT_ERROR_NUM_OUTPUTS_NONNEG.message,
238                    &TIMEIT_ERROR_NUM_OUTPUTS_NONNEG,
239                ))
240            } else {
241                Ok(raw as usize)
242            }
243        }
244        Value::Num(n) => {
245            if !n.is_finite() {
246                return Err(timeit_error_with_message(
247                    TIMEIT_ERROR_NUM_OUTPUTS_FINITE.message,
248                    &TIMEIT_ERROR_NUM_OUTPUTS_FINITE,
249                ));
250            }
251            if *n < 0.0 {
252                return Err(timeit_error_with_message(
253                    TIMEIT_ERROR_NUM_OUTPUTS_NONNEG.message,
254                    &TIMEIT_ERROR_NUM_OUTPUTS_NONNEG,
255                ));
256            }
257            let rounded = n.round();
258            if (rounded - n).abs() > f64::EPSILON {
259                return Err(timeit_error_with_message(
260                    TIMEIT_ERROR_NUM_OUTPUTS_INTEGER.message,
261                    &TIMEIT_ERROR_NUM_OUTPUTS_INTEGER,
262                ));
263            }
264            Ok(rounded as usize)
265        }
266        _ => Err(timeit_error_with_message(
267            TIMEIT_ERROR_NUM_OUTPUTS_SCALAR.message,
268            &TIMEIT_ERROR_NUM_OUTPUTS_SCALAR,
269        )),
270    }
271}
272
273async fn determine_loop_count(callable: &TimeitCallable) -> Result<usize, crate::RuntimeError> {
274    let mut loops = 1usize;
275    loop {
276        let elapsed = run_batch(callable, loops).await?;
277        if elapsed >= TARGET_BATCH_SECONDS
278            || elapsed >= MAX_BATCH_SECONDS
279            || loops >= LOOP_COUNT_LIMIT
280        {
281            return Ok(loops);
282        }
283        loops = loops.saturating_mul(2);
284        if loops == 0 {
285            return Ok(LOOP_COUNT_LIMIT);
286        }
287    }
288}
289
290async fn collect_samples(
291    callable: &TimeitCallable,
292    loop_count: usize,
293) -> Result<Vec<f64>, crate::RuntimeError> {
294    let mut samples = Vec::with_capacity(MIN_SAMPLE_COUNT);
295    while samples.len() < MIN_SAMPLE_COUNT {
296        let elapsed = run_batch(callable, loop_count).await?;
297        let per_iter = elapsed / loop_count as f64;
298        samples.push(per_iter);
299        if samples.len() >= MAX_SAMPLE_COUNT || elapsed >= MAX_BATCH_SECONDS {
300            break;
301        }
302    }
303    Ok(samples)
304}
305
306async fn run_batch(
307    callable: &TimeitCallable,
308    loop_count: usize,
309) -> Result<f64, crate::RuntimeError> {
310    let start = Instant::now();
311    for _ in 0..loop_count {
312        let value = callable.invoke().await?;
313        drop(value);
314    }
315    Ok(start.elapsed().as_secs_f64())
316}
317
318fn compute_median(mut samples: Vec<f64>) -> f64 {
319    if samples.is_empty() {
320        return 0.0;
321    }
322    samples.sort_by(|a, b| match (a.is_nan(), b.is_nan()) {
323        (true, true) => Ordering::Equal,
324        (true, false) => Ordering::Greater,
325        (false, true) => Ordering::Less,
326        (false, false) => a.partial_cmp(b).unwrap_or_else(|| {
327            if a < b {
328                Ordering::Less
329            } else {
330                Ordering::Greater
331            }
332        }),
333    });
334    let mid = samples.len() / 2;
335    if samples.len() % 2 == 1 {
336        samples[mid]
337    } else {
338        (samples[mid - 1] + samples[mid]) * 0.5
339    }
340}
341
342#[derive(Clone, Debug)]
343struct TimeitCallable {
344    handle: Value,
345    num_outputs: Option<usize>,
346}
347
348impl TimeitCallable {
349    async fn invoke(&self) -> Result<Value, crate::RuntimeError> {
350        let requested_outputs = self.num_outputs.unwrap_or(1);
351        let value =
352            crate::call_feval_async_with_outputs(self.handle.clone(), &[], requested_outputs)
353                .await?;
354        drop(value);
355        Ok(Value::Num(0.0))
356    }
357}
358
359fn prepare_callable(
360    func: Value,
361    num_outputs: Option<usize>,
362) -> Result<TimeitCallable, crate::RuntimeError> {
363    fn normalize_name(name: &str) -> Result<String, crate::RuntimeError> {
364        let trimmed = name.trim();
365        if trimmed.is_empty() {
366            Err(timeit_error_with_message(
367                TIMEIT_ERROR_EMPTY_HANDLE.message,
368                &TIMEIT_ERROR_EMPTY_HANDLE,
369            ))
370        } else {
371            Ok(trimmed.to_string())
372        }
373    }
374
375    fn canonicalize_text_handle(handle: String) -> Value {
376        let name = handle.strip_prefix('@').unwrap_or(handle.as_str());
377        handle_for_name(name).unwrap_or(Value::String(handle))
378    }
379
380    match func {
381        Value::String(text) => parse_handle_string(&text).map(|handle| TimeitCallable {
382            handle: canonicalize_text_handle(handle),
383            num_outputs,
384        }),
385        Value::CharArray(arr) => {
386            if arr.rows != 1 {
387                Err(timeit_error_with_message(
388                    TIMEIT_ERROR_HANDLE_KIND.message,
389                    &TIMEIT_ERROR_HANDLE_KIND,
390                ))
391            } else {
392                let text: String = arr.data.iter().collect();
393                parse_handle_string(&text).map(|handle| TimeitCallable {
394                    handle: canonicalize_text_handle(handle),
395                    num_outputs,
396                })
397            }
398        }
399        Value::StringArray(sa) => {
400            if sa.data.len() == 1 {
401                parse_handle_string(&sa.data[0]).map(|handle| TimeitCallable {
402                    handle: canonicalize_text_handle(handle),
403                    num_outputs,
404                })
405            } else {
406                Err(timeit_error_with_message(
407                    TIMEIT_ERROR_HANDLE_KIND.message,
408                    &TIMEIT_ERROR_HANDLE_KIND,
409                ))
410            }
411        }
412        Value::FunctionHandle(name) => {
413            let normalized = normalize_name(&name)?;
414            Ok(TimeitCallable {
415                handle: handle_for_name(&normalized)
416                    .unwrap_or_else(|| Value::String(format!("@{normalized}"))),
417                num_outputs,
418            })
419        }
420        Value::ExternalFunctionHandle(name) => {
421            let normalized = normalize_name(&name)?;
422            Ok(TimeitCallable {
423                handle: if crate::is_well_formed_qualified_name(&normalized) {
424                    handle_for_name(&normalized)
425                        .unwrap_or_else(|| Value::ExternalFunctionHandle(normalized))
426                } else {
427                    Value::ExternalFunctionHandle(normalized)
428                },
429                num_outputs,
430            })
431        }
432        Value::BoundFunctionHandle { name, function } => {
433            let normalized = normalize_name(&name)?;
434            Ok(TimeitCallable {
435                handle: Value::BoundFunctionHandle {
436                    name: normalized,
437                    function,
438                },
439                num_outputs,
440            })
441        }
442        Value::Closure(mut closure) => Ok(TimeitCallable {
443            handle: {
444                if closure.bound_function.is_none() {
445                    if let Some(function) = crate::user_functions::resolve_semantic_function_by_name(
446                        &closure.function_name,
447                    ) {
448                        closure.bound_function = Some(function);
449                    }
450                }
451                Value::Closure(closure)
452            },
453            num_outputs,
454        }),
455        other => Err(timeit_error_with_message(
456            format!("timeit: first argument must be a function handle, got {other:?}"),
457            &TIMEIT_ERROR_FIRST_ARG_KIND,
458        )),
459    }
460}
461
462fn handle_for_name(name: &str) -> Option<Value> {
463    let function = crate::user_functions::resolve_semantic_function_by_name(name)?;
464    Some(Value::BoundFunctionHandle {
465        name: name.to_string(),
466        function,
467    })
468}
469
470fn parse_handle_string(text: &str) -> Result<String, crate::RuntimeError> {
471    let trimmed = text.trim();
472    if let Some(rest) = trimmed.strip_prefix('@') {
473        if rest.trim().is_empty() {
474            Err(timeit_error_with_message(
475                TIMEIT_ERROR_EMPTY_HANDLE.message,
476                &TIMEIT_ERROR_EMPTY_HANDLE,
477            ))
478        } else {
479            Ok(format!("@{}", rest.trim()))
480        }
481    } else {
482        Err(timeit_error_with_message(
483            TIMEIT_ERROR_EXPECTS_AT_HANDLE_STRING.message,
484            &TIMEIT_ERROR_EXPECTS_AT_HANDLE_STRING,
485        ))
486    }
487}
488
489#[cfg(test)]
490pub(crate) mod tests {
491    use super::*;
492    use futures::executor::block_on;
493    use runmat_builtins::{Closure, IntValue};
494    use std::sync::atomic::{AtomicUsize, Ordering};
495    use std::sync::Arc;
496
497    const TIMEIT_HELPER_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
498        name: "y",
499        ty: BuiltinParamType::NumericScalar,
500        arity: BuiltinParamArity::Required,
501        default: None,
502        description: "Helper scalar return value.",
503    }];
504
505    const TIMEIT_HELPER_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
506        [BuiltinSignatureDescriptor {
507            label: "y = __timeit_helper()",
508            inputs: &[],
509            outputs: &TIMEIT_HELPER_OUTPUT,
510        }];
511
512    const TIMEIT_HELPER_ERRORS: [BuiltinErrorDescriptor; 0] = [];
513
514    pub const TIMEIT_TEST_HELPER_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
515        signatures: &TIMEIT_HELPER_SIGNATURES,
516        output_mode: BuiltinOutputMode::Fixed,
517        completion_policy: BuiltinCompletionPolicy::HiddenInternal,
518        errors: &TIMEIT_HELPER_ERRORS,
519    };
520
521    static COUNTER_DEFAULT: AtomicUsize = AtomicUsize::new(0);
522    static COUNTER_NUM_OUTPUTS: AtomicUsize = AtomicUsize::new(0);
523    static COUNTER_INVALID: AtomicUsize = AtomicUsize::new(0);
524    static COUNTER_ZERO_OUTPUTS: AtomicUsize = AtomicUsize::new(0);
525
526    #[runtime_builtin(
527        name = "__timeit_helper_counter_default",
528        type_resolver(crate::builtins::timing::type_resolvers::timeit_type),
529        descriptor(crate::builtins::timing::timeit::tests::TIMEIT_TEST_HELPER_DESCRIPTOR),
530        builtin_path = "crate::builtins::timing::timeit::tests"
531    )]
532    async fn helper_counter_default() -> crate::BuiltinResult<Value> {
533        COUNTER_DEFAULT.fetch_add(1, Ordering::SeqCst);
534        Ok(Value::Num(1.0))
535    }
536
537    #[runtime_builtin(
538        name = "__timeit_helper_counter_outputs",
539        type_resolver(crate::builtins::timing::type_resolvers::timeit_type),
540        descriptor(crate::builtins::timing::timeit::tests::TIMEIT_TEST_HELPER_DESCRIPTOR),
541        builtin_path = "crate::builtins::timing::timeit::tests"
542    )]
543    async fn helper_counter_outputs() -> crate::BuiltinResult<Value> {
544        COUNTER_NUM_OUTPUTS.fetch_add(1, Ordering::SeqCst);
545        Ok(Value::Num(1.0))
546    }
547
548    #[runtime_builtin(
549        name = "__timeit_helper_counter_invalid",
550        type_resolver(crate::builtins::timing::type_resolvers::timeit_type),
551        descriptor(crate::builtins::timing::timeit::tests::TIMEIT_TEST_HELPER_DESCRIPTOR),
552        builtin_path = "crate::builtins::timing::timeit::tests"
553    )]
554    async fn helper_counter_invalid() -> crate::BuiltinResult<Value> {
555        COUNTER_INVALID.fetch_add(1, Ordering::SeqCst);
556        Ok(Value::Num(1.0))
557    }
558
559    #[runtime_builtin(
560        name = "__timeit_helper_zero_outputs",
561        type_resolver(crate::builtins::timing::type_resolvers::timeit_type),
562        descriptor(crate::builtins::timing::timeit::tests::TIMEIT_TEST_HELPER_DESCRIPTOR),
563        builtin_path = "crate::builtins::timing::timeit::tests"
564    )]
565    async fn helper_counter_zero_outputs() -> crate::BuiltinResult<Value> {
566        COUNTER_ZERO_OUTPUTS.fetch_add(1, Ordering::SeqCst);
567        Ok(Value::Num(0.0))
568    }
569
570    fn default_handle() -> Value {
571        Value::String("@__timeit_helper_counter_default".to_string())
572    }
573
574    fn assert_timeit_error_contains(err: &crate::RuntimeError, needle: &str) {
575        let message = err.message().to_ascii_lowercase();
576        assert!(
577            message.contains(&needle.to_ascii_lowercase()),
578            "unexpected error text: {}",
579            err.message()
580        );
581    }
582
583    fn assert_timeit_error_identifier(err: &crate::RuntimeError, identifier: &'static str) {
584        assert_eq!(err.identifier(), Some(identifier), "{}", err.message());
585    }
586
587    fn outputs_handle() -> Value {
588        Value::String("@__timeit_helper_counter_outputs".to_string())
589    }
590
591    fn invalid_handle() -> Value {
592        Value::String("@__timeit_helper_counter_invalid".to_string())
593    }
594
595    fn zero_outputs_handle() -> Value {
596        Value::String("@__timeit_helper_zero_outputs".to_string())
597    }
598
599    #[test]
600    fn timeit_test_helper_descriptor_is_attached_shape() {
601        assert_eq!(
602            TIMEIT_TEST_HELPER_DESCRIPTOR.signatures[0].label,
603            "y = __timeit_helper()"
604        );
605    }
606
607    #[test]
608    fn timeit_accepts_external_function_handle() {
609        let callable = prepare_callable(
610            Value::ExternalFunctionHandle("pkg.callback".to_string()),
611            Some(2),
612        )
613        .expect("timeit should accept external function handle");
614        assert_eq!(
615            callable.handle,
616            Value::ExternalFunctionHandle("pkg.callback".to_string())
617        );
618        assert_eq!(callable.num_outputs, Some(2));
619    }
620
621    #[test]
622    fn timeit_rejects_empty_function_handle_name_value() {
623        let err = prepare_callable(Value::FunctionHandle("   ".to_string()), None)
624            .expect_err("timeit should reject empty function-handle payload name");
625        assert_timeit_error_contains(&err, "empty function handle");
626        assert_timeit_error_identifier(&err, TIMEIT_ERROR_EMPTY_HANDLE.identifier.unwrap());
627    }
628
629    #[test]
630    fn timeit_rejects_empty_external_function_handle_name_value() {
631        let err = prepare_callable(Value::ExternalFunctionHandle("   ".to_string()), None)
632            .expect_err("timeit should reject empty external function-handle payload name");
633        assert_timeit_error_contains(&err, "empty function handle");
634        assert_timeit_error_identifier(&err, TIMEIT_ERROR_EMPTY_HANDLE.identifier.unwrap());
635    }
636
637    #[test]
638    fn timeit_trims_function_handle_name_for_semantic_resolution() {
639        let _resolver_guard =
640            crate::user_functions::install_semantic_function_resolver(Some(Arc::new(|name| {
641                (name == "__timeit_helper_counter_default").then_some(188)
642            })));
643        let callable = prepare_callable(
644            Value::FunctionHandle("  __timeit_helper_counter_default  ".to_string()),
645            None,
646        )
647        .expect("timeit should normalize function-handle payload name");
648        assert_eq!(
649            callable.handle,
650            Value::BoundFunctionHandle {
651                name: "__timeit_helper_counter_default".to_string(),
652                function: 188,
653            }
654        );
655    }
656
657    #[test]
658    fn timeit_callable_invoke_honors_multi_requested_outputs() {
659        let _invoker_guard = crate::user_functions::install_semantic_function_invoker(Some(
660            Arc::new(|function, args, requested_outputs| {
661                assert_eq!(function, 612);
662                assert!(args.is_empty());
663                assert_eq!(requested_outputs, 3);
664                Box::pin(async {
665                    Ok(Value::OutputList(vec![
666                        Value::Num(1.0),
667                        Value::Num(2.0),
668                        Value::Num(3.0),
669                    ]))
670                })
671            }),
672        ));
673
674        let callable = prepare_callable(
675            Value::BoundFunctionHandle {
676                name: "function_target".to_string(),
677                function: 612,
678            },
679            Some(3),
680        )
681        .expect("timeit should accept semantic callback handles");
682
683        let invoked = block_on(callable.invoke()).expect("timeit callable invoke should succeed");
684        assert_eq!(invoked, Value::Num(0.0));
685    }
686
687    #[test]
688    fn timeit_string_handle_prefers_semantic_resolver_identity() {
689        let _resolver_guard =
690            crate::user_functions::install_semantic_function_resolver(Some(Arc::new(|name| {
691                (name == "__timeit_helper_counter_default").then_some(87)
692            })));
693        let callable = prepare_callable(
694            Value::String("@__timeit_helper_counter_default".to_string()),
695            None,
696        )
697        .expect("timeit should accept string function handle");
698        assert_eq!(
699            callable.handle,
700            Value::BoundFunctionHandle {
701                name: "__timeit_helper_counter_default".to_string(),
702                function: 87,
703            }
704        );
705    }
706
707    #[test]
708    fn timeit_char_handle_prefers_semantic_resolver_identity() {
709        let _resolver_guard =
710            crate::user_functions::install_semantic_function_resolver(Some(Arc::new(|name| {
711                (name == "__timeit_helper_counter_default").then_some(88)
712            })));
713        let callable = prepare_callable(
714            Value::CharArray(runmat_builtins::CharArray::new_row(
715                "@__timeit_helper_counter_default",
716            )),
717            None,
718        )
719        .expect("timeit should accept char function handle");
720        assert_eq!(
721            callable.handle,
722            Value::BoundFunctionHandle {
723                name: "__timeit_helper_counter_default".to_string(),
724                function: 88,
725            }
726        );
727    }
728
729    #[test]
730    fn timeit_external_function_handle_prefers_semantic_resolver_identity() {
731        let _resolver_guard =
732            crate::user_functions::install_semantic_function_resolver(Some(Arc::new(|name| {
733                (name == "pkg.callback").then_some(86)
734            })));
735        let callable = prepare_callable(
736            Value::ExternalFunctionHandle("pkg.callback".to_string()),
737            Some(2),
738        )
739        .expect("timeit should accept external function handle");
740        assert_eq!(
741            callable.handle,
742            Value::BoundFunctionHandle {
743                name: "pkg.callback".to_string(),
744                function: 86,
745            }
746        );
747        assert_eq!(callable.num_outputs, Some(2));
748    }
749
750    #[test]
751    fn timeit_accepts_semantic_function_handle() {
752        let callable = prepare_callable(
753            Value::BoundFunctionHandle {
754                name: "function_target".to_string(),
755                function: 41,
756            },
757            Some(1),
758        )
759        .expect("timeit should accept semantic function handle");
760        assert_eq!(
761            callable.handle,
762            Value::BoundFunctionHandle {
763                name: "function_target".to_string(),
764                function: 41,
765            }
766        );
767        assert_eq!(callable.num_outputs, Some(1));
768    }
769
770    #[test]
771    fn timeit_name_only_closure_prefers_semantic_resolver_identity() {
772        let _resolver_guard =
773            crate::user_functions::install_semantic_function_resolver(Some(Arc::new(|name| {
774                (name == "__timeit_helper_counter_default").then_some(89)
775            })));
776        let callable = prepare_callable(
777            Value::Closure(Closure {
778                function_name: "__timeit_helper_counter_default".to_string(),
779                bound_function: None,
780                captures: vec![Value::Num(9.0)],
781            }),
782            None,
783        )
784        .expect("timeit should accept closure callback");
785        assert_eq!(
786            callable.handle,
787            Value::Closure(Closure {
788                function_name: "__timeit_helper_counter_default".to_string(),
789                bound_function: Some(89),
790                captures: vec![Value::Num(9.0)],
791            })
792        );
793    }
794
795    #[test]
796    fn timeit_name_only_closure_without_resolver_keeps_name_shaped_identity() {
797        let callable = prepare_callable(
798            Value::Closure(Closure {
799                function_name: "__timeit_helper_counter_default".to_string(),
800                bound_function: None,
801                captures: vec![Value::Num(9.0)],
802            }),
803            None,
804        )
805        .expect("timeit should accept closure callback");
806        assert_eq!(
807            callable.handle,
808            Value::Closure(Closure {
809                function_name: "__timeit_helper_counter_default".to_string(),
810                bound_function: None,
811                captures: vec![Value::Num(9.0)],
812            })
813        );
814    }
815
816    #[test]
817    fn timeit_external_function_handle_surfaces_undefined_function() {
818        let err = block_on(timeit_builtin(
819            Value::ExternalFunctionHandle("pkg.missing_callback".to_string()),
820            Vec::new(),
821        ))
822        .expect_err("unresolved external callback should fail");
823        assert_eq!(err.identifier(), Some("RunMat:UndefinedFunction"));
824    }
825
826    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
827    #[test]
828    fn timeit_measures_time() {
829        COUNTER_DEFAULT.store(0, Ordering::SeqCst);
830        let result = block_on(timeit_builtin(default_handle(), Vec::new())).expect("timeit");
831        match result {
832            Value::Num(v) => assert!(v >= 0.0),
833            other => panic!("expected numeric result, got {other:?}"),
834        }
835        assert!(
836            COUNTER_DEFAULT.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
837            "expected at least {} invocations",
838            MIN_SAMPLE_COUNT
839        );
840    }
841
842    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
843    #[test]
844    fn timeit_accepts_num_outputs_argument() {
845        COUNTER_NUM_OUTPUTS.store(0, Ordering::SeqCst);
846        let args = vec![Value::Int(IntValue::I32(3))];
847        let _ = block_on(timeit_builtin(outputs_handle(), args)).expect("timeit numOutputs");
848        assert!(
849            COUNTER_NUM_OUTPUTS.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
850            "expected at least {} invocations",
851            MIN_SAMPLE_COUNT
852        );
853    }
854
855    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
856    #[test]
857    fn timeit_supports_zero_outputs() {
858        COUNTER_ZERO_OUTPUTS.store(0, Ordering::SeqCst);
859        let args = vec![Value::Int(IntValue::I32(0))];
860        let _ = block_on(timeit_builtin(zero_outputs_handle(), args)).expect("timeit zero outputs");
861        assert!(
862            COUNTER_ZERO_OUTPUTS.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
863            "expected at least {} invocations",
864            MIN_SAMPLE_COUNT
865        );
866    }
867
868    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
869    #[test]
870    #[cfg(feature = "wgpu")]
871    fn timeit_runs_with_wgpu_provider_registered() {
872        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
873            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
874        );
875        let result =
876            block_on(timeit_builtin(default_handle(), Vec::new())).expect("timeit with wgpu");
877        match result {
878            Value::Num(v) => assert!(v >= 0.0),
879            other => panic!("expected numeric result, got {other:?}"),
880        }
881    }
882
883    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
884    #[test]
885    fn timeit_rejects_non_function_input() {
886        let err = block_on(timeit_builtin(Value::Num(1.0), Vec::new())).unwrap_err();
887        assert_timeit_error_contains(&err, "function");
888        assert_timeit_error_identifier(&err, TIMEIT_ERROR_FIRST_ARG_KIND.identifier.unwrap());
889    }
890
891    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
892    #[test]
893    fn timeit_rejects_invalid_num_outputs() {
894        COUNTER_INVALID.store(0, Ordering::SeqCst);
895        let err = block_on(timeit_builtin(invalid_handle(), vec![Value::Num(-1.0)])).unwrap_err();
896        assert_timeit_error_contains(&err, "nonnegative");
897        assert_timeit_error_identifier(&err, TIMEIT_ERROR_NUM_OUTPUTS_NONNEG.identifier.unwrap());
898        assert_eq!(COUNTER_INVALID.load(Ordering::SeqCst), 0);
899    }
900
901    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
902    #[test]
903    fn timeit_rejects_extra_arguments() {
904        let err = block_on(timeit_builtin(
905            default_handle(),
906            vec![Value::from(1.0), Value::from(2.0)],
907        ))
908        .unwrap_err();
909        assert_timeit_error_contains(&err, "too many");
910        assert_timeit_error_identifier(&err, TIMEIT_ERROR_TOO_MANY_INPUTS.identifier.unwrap());
911    }
912}