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