runmat_runtime/builtins/timing/
timeit.rs1use std::cmp::Ordering;
7use std::time::Instant;
8
9use runmat_builtins::Value;
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::spec::{
13 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14 ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16#[cfg(feature = "doc_export")]
17use crate::register_builtin_doc_text;
18use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
19
20const TARGET_BATCH_SECONDS: f64 = 0.005;
21const MAX_BATCH_SECONDS: f64 = 0.25;
22const LOOP_COUNT_LIMIT: usize = 1 << 20;
23const MIN_SAMPLE_COUNT: usize = 7;
24const MAX_SAMPLE_COUNT: usize = 21;
25
26#[cfg(feature = "doc_export")]
27pub const DOC_MD: &str = r#"---
28title: "timeit"
29category: "timing"
30keywords: ["timeit", "benchmark", "timing", "performance", "gpu"]
31summary: "Measure the execution time of a zero-argument function handle."
32references: []
33gpu_support:
34 elementwise: false
35 reduction: false
36 precisions: []
37 broadcasting: "none"
38 notes: "Runs purely on the host CPU; GPU work inside the timed function executes through the active provider."
39fusion:
40 elementwise: false
41 reduction: false
42 max_inputs: 0
43 constants: "inline"
44requires_feature: null
45tested:
46 unit: "builtins::timing::timeit::tests"
47 integration: "builtins::timing::timeit::tests::timeit_measures_time"
48---
49
50# Measure execution time of a function handle
51`t = timeit(f)` evaluates the zero-argument function handle `f` repeatedly and returns the median runtime (in seconds).
52RunMat accepts the optional `numOutputs` argument for MATLAB compatibility; today the handle executes with its default output arity (or none when you pass `0`) and all returned values are discarded.
53
54## Syntax
55```matlab
56t = timeit(f)
57t = timeit(f, numOutputs)
58```
59
60- `f` is a zero-argument function handle (for example, `@() myOp(A)`).
61- `numOutputs` is an optional nonnegative integer kept for MATLAB compatibility. Passing `0` suppresses outputs entirely; any other value currently executes the handle with its default output arity while discarding the result.
62
63## How `timeit` works
64- Executes `f` repeatedly, adjusting the inner loop count until a single batch takes at least a few milliseconds or the function is slow enough.
65- Collects multiple samples (at least seven batches) and returns the median per-invocation time, which is robust against outliers.
66- Drops the outputs produced by `f`; you should perform any validation that depends on those outputs inside the handle. Multi-output dispatch will route through this helper once the runtime exposes multi-return `feval`.
67- Leaves GPU residency untouched—if `f` launches GPU kernels, they execute on the active provider. Insert `wait(gpuDevice)` inside the handle if you need explicit synchronisation.
68
69## Examples
70
71### Timing a simple anonymous function
72```matlab
73f = @() sin(rand(1000, 1));
74t = timeit(f);
75```
76
77### Comparing two implementations
78```matlab
79A = rand(1e5, 1);
80slow = @() sum(A .* A);
81fast = @() sumsq(A);
82
83slowTime = timeit(slow);
84fastTime = timeit(fast);
85```
86
87### Timing a function that returns no outputs
88```matlab
89logMessage = @() fprintf("Iteration complete\n");
90t = timeit(logMessage, 0);
91```
92
93### Timing a multiple-output function
94```matlab
95svdTime = timeit(@() svd(rand(256)), 3);
96```
97This records the runtime while discarding any outputs produced by `svd`.
98
99### Measuring GPU-bound work
100```matlab
101gfun = @() gather(sin(gpuArray.rand(4096, 1)));
102tgpu = timeit(gfun);
103```
104
105### Timing a preallocation helper
106```matlab
107makeMatrix = @() zeros(2048, 2048);
108t = timeit(makeMatrix);
109```
110
111## FAQ
112
1131. **What does `timeit` return?** — A scalar double containing the median runtime per invocation in seconds.
1142. **How many runs does `timeit` perform?** — It automatically selects a loop count so each batch lasts a few milliseconds, collecting at least seven batches.
1153. **Does `timeit` synchronise GPU kernels?** — No. Insert `wait(gpuDevice)` inside the handle when you need explicit synchronisation.
1164. **Can I time functions that require inputs?** — Yes. Capture them in the handle, for example `timeit(@() myfun(A, B))`.
1175. **How do I time a function with multiple outputs?** — Pass `timeit(@() svd(A), 3)` to mirror MATLAB’s call signature. RunMat currently ignores values greater than zero until multi-output dispatch lands, but the handle still executes.
1186. **Why do successive runs differ slightly?** — Normal system jitter, cache effects, and GPU scheduling can change runtimes slightly; the median mitigates outliers.
1197. **Can `timeit` time scripts?** — Wrap the script body in a function handle so it becomes zero-argument, then call `timeit` on that handle.
1208. **Does `timeit` participate in fusion or JIT tiers?** — It simply executes the provided handle; any tiering or fusion happens inside the timed function.
1219. **What happens if the function errors?** — The error is propagated immediately and timing stops, matching MATLAB behaviour.
12210. **Is there a limit on runs?** — Yes. `timeit` caps the inner loop at about one million iterations to avoid runaway measurements.
123
124## See Also
125[tic](./tic), [toc](./toc), [feval](../introspection/feval)
126
127## Source & Feedback
128- Implementation: [`crates/runmat-runtime/src/builtins/timing/timeit.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/timing/timeit.rs)
129- Found a behavioural difference? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal repro.
130"#;
131
132pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
133 name: "timeit",
134 op_kind: GpuOpKind::Custom("timer"),
135 supported_precisions: &[],
136 broadcast: BroadcastSemantics::None,
137 provider_hooks: &[],
138 constant_strategy: ConstantStrategy::InlineLiteral,
139 residency: ResidencyPolicy::GatherImmediately,
140 nan_mode: ReductionNaN::Include,
141 two_pass_threshold: None,
142 workgroup_size: None,
143 accepts_nan_mode: false,
144 notes: "Host-side helper; GPU kernels execute only if invoked by the timed function.",
145};
146
147register_builtin_gpu_spec!(GPU_SPEC);
148
149pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
150 name: "timeit",
151 shape: ShapeRequirements::Any,
152 constant_strategy: ConstantStrategy::InlineLiteral,
153 elementwise: None,
154 reduction: None,
155 emits_nan: false,
156 notes: "Timing helper; excluded from fusion planning.",
157};
158
159register_builtin_fusion_spec!(FUSION_SPEC);
160
161#[cfg(feature = "doc_export")]
162register_builtin_doc_text!("timeit", DOC_MD);
163
164#[runtime_builtin(
165 name = "timeit",
166 category = "timing",
167 summary = "Measure the execution time of a zero-argument function handle.",
168 keywords = "timeit,benchmark,timing,performance,gpu",
169 accel = "helper"
170)]
171fn timeit_builtin(func: Value, rest: Vec<Value>) -> Result<Value, String> {
172 let requested_outputs = parse_num_outputs(&rest)?;
173 let callable = prepare_callable(func, requested_outputs)?;
174
175 callable.invoke()?;
177
178 let loop_count = determine_loop_count(&callable)?;
179 let samples = collect_samples(&callable, loop_count)?;
180 if samples.is_empty() {
181 return Ok(Value::Num(0.0));
182 }
183
184 Ok(Value::Num(compute_median(samples)))
185}
186
187fn parse_num_outputs(rest: &[Value]) -> Result<Option<usize>, String> {
188 match rest.len() {
189 0 => Ok(None),
190 1 => parse_non_negative_integer(&rest[0]).map(Some),
191 _ => Err("timeit: too many input arguments".to_string()),
192 }
193}
194
195fn parse_non_negative_integer(value: &Value) -> Result<usize, String> {
196 match value {
197 Value::Int(iv) => {
198 let raw = iv.to_i64();
199 if raw < 0 {
200 Err("timeit: numOutputs must be a nonnegative integer".to_string())
201 } else {
202 Ok(raw as usize)
203 }
204 }
205 Value::Num(n) => {
206 if !n.is_finite() {
207 return Err("timeit: numOutputs must be finite".to_string());
208 }
209 if *n < 0.0 {
210 return Err("timeit: numOutputs must be a nonnegative integer".to_string());
211 }
212 let rounded = n.round();
213 if (rounded - n).abs() > f64::EPSILON {
214 return Err("timeit: numOutputs must be an integer value".to_string());
215 }
216 Ok(rounded as usize)
217 }
218 _ => Err("timeit: numOutputs must be a scalar numeric value".to_string()),
219 }
220}
221
222fn determine_loop_count(callable: &TimeitCallable) -> Result<usize, String> {
223 let mut loops = 1usize;
224 loop {
225 let elapsed = run_batch(callable, loops)?;
226 if elapsed >= TARGET_BATCH_SECONDS
227 || elapsed >= MAX_BATCH_SECONDS
228 || loops >= LOOP_COUNT_LIMIT
229 {
230 return Ok(loops);
231 }
232 loops = loops.saturating_mul(2);
233 if loops == 0 {
234 return Ok(LOOP_COUNT_LIMIT);
235 }
236 }
237}
238
239fn collect_samples(callable: &TimeitCallable, loop_count: usize) -> Result<Vec<f64>, String> {
240 let mut samples = Vec::with_capacity(MIN_SAMPLE_COUNT);
241 while samples.len() < MIN_SAMPLE_COUNT {
242 let elapsed = run_batch(callable, loop_count)?;
243 let per_iter = elapsed / loop_count as f64;
244 samples.push(per_iter);
245 if samples.len() >= MAX_SAMPLE_COUNT || elapsed >= MAX_BATCH_SECONDS {
246 break;
247 }
248 }
249 Ok(samples)
250}
251
252fn run_batch(callable: &TimeitCallable, loop_count: usize) -> Result<f64, String> {
253 let start = Instant::now();
254 for _ in 0..loop_count {
255 let value = callable.invoke()?;
256 drop(value);
257 }
258 Ok(start.elapsed().as_secs_f64())
259}
260
261fn compute_median(mut samples: Vec<f64>) -> f64 {
262 if samples.is_empty() {
263 return 0.0;
264 }
265 samples.sort_by(|a, b| match (a.is_nan(), b.is_nan()) {
266 (true, true) => Ordering::Equal,
267 (true, false) => Ordering::Greater,
268 (false, true) => Ordering::Less,
269 (false, false) => a.partial_cmp(b).unwrap_or_else(|| {
270 if a < b {
271 Ordering::Less
272 } else {
273 Ordering::Greater
274 }
275 }),
276 });
277 let mid = samples.len() / 2;
278 if samples.len() % 2 == 1 {
279 samples[mid]
280 } else {
281 (samples[mid - 1] + samples[mid]) * 0.5
282 }
283}
284
285#[derive(Clone)]
286struct TimeitCallable {
287 handle: Value,
288 num_outputs: Option<usize>,
289}
290
291impl TimeitCallable {
292 fn invoke(&self) -> Result<Value, String> {
293 if let Some(0) = self.num_outputs {
298 let value = crate::call_builtin("feval", std::slice::from_ref(&self.handle))?;
299 drop(value);
300 Ok(Value::Num(0.0))
301 } else {
302 crate::call_builtin("feval", std::slice::from_ref(&self.handle))
303 }
304 }
305}
306
307fn prepare_callable(func: Value, num_outputs: Option<usize>) -> Result<TimeitCallable, String> {
308 match func {
309 Value::String(text) => parse_handle_string(&text).map(|handle| TimeitCallable {
310 handle: Value::String(handle),
311 num_outputs,
312 }),
313 Value::CharArray(arr) => {
314 if arr.rows != 1 {
315 Err(
316 "timeit: function handle must be a string scalar or function handle"
317 .to_string(),
318 )
319 } else {
320 let text: String = arr.data.iter().collect();
321 parse_handle_string(&text).map(|handle| TimeitCallable {
322 handle: Value::String(handle),
323 num_outputs,
324 })
325 }
326 }
327 Value::StringArray(sa) => {
328 if sa.data.len() == 1 {
329 parse_handle_string(&sa.data[0]).map(|handle| TimeitCallable {
330 handle: Value::String(handle),
331 num_outputs,
332 })
333 } else {
334 Err(
335 "timeit: function handle must be a string scalar or function handle"
336 .to_string(),
337 )
338 }
339 }
340 Value::FunctionHandle(name) => Ok(TimeitCallable {
341 handle: Value::String(format!("@{name}")),
342 num_outputs,
343 }),
344 Value::Closure(closure) => Ok(TimeitCallable {
345 handle: Value::Closure(closure),
346 num_outputs,
347 }),
348 other => Err(format!(
349 "timeit: first argument must be a function handle, got {other:?}"
350 )),
351 }
352}
353
354fn parse_handle_string(text: &str) -> Result<String, String> {
355 let trimmed = text.trim();
356 if let Some(rest) = trimmed.strip_prefix('@') {
357 if rest.trim().is_empty() {
358 Err("timeit: empty function handle string".to_string())
359 } else {
360 Ok(format!("@{}", rest.trim()))
361 }
362 } else {
363 Err("timeit: expected a function handle string beginning with '@'".to_string())
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use runmat_builtins::IntValue;
371 use std::sync::atomic::{AtomicUsize, Ordering};
372
373 #[cfg(feature = "doc_export")]
374 use crate::builtins::common::test_support;
375
376 static COUNTER_DEFAULT: AtomicUsize = AtomicUsize::new(0);
377 static COUNTER_NUM_OUTPUTS: AtomicUsize = AtomicUsize::new(0);
378 static COUNTER_INVALID: AtomicUsize = AtomicUsize::new(0);
379 static COUNTER_ZERO_OUTPUTS: AtomicUsize = AtomicUsize::new(0);
380
381 #[runtime_builtin(name = "__timeit_helper_counter_default")]
382 fn helper_counter_default() -> Result<Value, String> {
383 COUNTER_DEFAULT.fetch_add(1, Ordering::SeqCst);
384 Ok(Value::Num(1.0))
385 }
386
387 #[runtime_builtin(name = "__timeit_helper_counter_outputs")]
388 fn helper_counter_outputs() -> Result<Value, String> {
389 COUNTER_NUM_OUTPUTS.fetch_add(1, Ordering::SeqCst);
390 Ok(Value::Num(1.0))
391 }
392
393 #[runtime_builtin(name = "__timeit_helper_counter_invalid")]
394 fn helper_counter_invalid() -> Result<Value, String> {
395 COUNTER_INVALID.fetch_add(1, Ordering::SeqCst);
396 Ok(Value::Num(1.0))
397 }
398
399 #[runtime_builtin(name = "__timeit_helper_zero_outputs")]
400 fn helper_counter_zero_outputs() -> Result<Value, String> {
401 COUNTER_ZERO_OUTPUTS.fetch_add(1, Ordering::SeqCst);
402 Ok(Value::Num(0.0))
403 }
404
405 fn default_handle() -> Value {
406 Value::String("@__timeit_helper_counter_default".to_string())
407 }
408
409 fn outputs_handle() -> Value {
410 Value::String("@__timeit_helper_counter_outputs".to_string())
411 }
412
413 fn invalid_handle() -> Value {
414 Value::String("@__timeit_helper_counter_invalid".to_string())
415 }
416
417 fn zero_outputs_handle() -> Value {
418 Value::String("@__timeit_helper_zero_outputs".to_string())
419 }
420
421 #[test]
422 fn timeit_measures_time() {
423 COUNTER_DEFAULT.store(0, Ordering::SeqCst);
424 let result = timeit_builtin(default_handle(), Vec::new()).expect("timeit");
425 match result {
426 Value::Num(v) => assert!(v >= 0.0),
427 other => panic!("expected numeric result, got {other:?}"),
428 }
429 assert!(
430 COUNTER_DEFAULT.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
431 "expected at least {} invocations",
432 MIN_SAMPLE_COUNT
433 );
434 }
435
436 #[test]
437 fn timeit_accepts_num_outputs_argument() {
438 COUNTER_NUM_OUTPUTS.store(0, Ordering::SeqCst);
439 let args = vec![Value::Int(IntValue::I32(3))];
440 let _ = timeit_builtin(outputs_handle(), args).expect("timeit numOutputs");
441 assert!(
442 COUNTER_NUM_OUTPUTS.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
443 "expected at least {} invocations",
444 MIN_SAMPLE_COUNT
445 );
446 }
447
448 #[test]
449 fn timeit_supports_zero_outputs() {
450 COUNTER_ZERO_OUTPUTS.store(0, Ordering::SeqCst);
451 let args = vec![Value::Int(IntValue::I32(0))];
452 let _ = timeit_builtin(zero_outputs_handle(), args).expect("timeit zero outputs");
453 assert!(
454 COUNTER_ZERO_OUTPUTS.load(Ordering::SeqCst) >= MIN_SAMPLE_COUNT,
455 "expected at least {} invocations",
456 MIN_SAMPLE_COUNT
457 );
458 }
459
460 #[test]
461 #[cfg(feature = "wgpu")]
462 fn timeit_runs_with_wgpu_provider_registered() {
463 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
464 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
465 );
466 let result = timeit_builtin(default_handle(), Vec::new()).expect("timeit with wgpu");
467 match result {
468 Value::Num(v) => assert!(v >= 0.0),
469 other => panic!("expected numeric result, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn timeit_rejects_non_function_input() {
475 let err = timeit_builtin(Value::Num(1.0), Vec::new()).unwrap_err();
476 assert!(
477 err.to_ascii_lowercase().contains("function"),
478 "unexpected error text: {err}"
479 );
480 }
481
482 #[test]
483 fn timeit_rejects_invalid_num_outputs() {
484 COUNTER_INVALID.store(0, Ordering::SeqCst);
485 let err = timeit_builtin(invalid_handle(), vec![Value::Num(-1.0)]).unwrap_err();
486 assert!(err.to_ascii_lowercase().contains("nonnegative"));
487 assert_eq!(COUNTER_INVALID.load(Ordering::SeqCst), 0);
488 }
489
490 #[test]
491 fn timeit_rejects_extra_arguments() {
492 let err =
493 timeit_builtin(default_handle(), vec![Value::from(1.0), Value::from(2.0)]).unwrap_err();
494 assert!(err.to_ascii_lowercase().contains("too many"));
495 }
496
497 #[test]
498 #[cfg(feature = "doc_export")]
499 fn doc_examples_present() {
500 let blocks = test_support::doc_examples(DOC_MD);
501 assert!(!blocks.is_empty());
502 }
503}