Benchmark
Binary search claimed that halving beats looking at every element: for 1,024 sorted numbers, about ten steps instead of up to 1,024. A benchmark checks a claim like that with a clock. This lesson times a linear and a binary search over the same 1,024 sorted numbers, with the benchmarks of the Test package — the same package whose checks drive unit tests:
import Test::{ BenchmarkCase, BenchmarkOptions, Consume, RunBenchmarksWith };
Why repeat the work
One binary search takes tens of nanoseconds — less than the clock can resolve reliably, and less than the noise from everything else the machine is doing. So a benchmark repeats the work many times, times the lot, and divides. The Test package decides how many times, so you do not have to guess.
A benchmark is a function that takes an iteration count and does the work that many times, in its own loop:
func Binary(iterations: uint64) {
var values: int[1024];
Fill(values[..]);
var found: uint64 = 0;
for n in 0..iterations {
if BinarySearch(values[..], (n % 2048) as int) != none { found = found + 1; }
}
Consume(@found);
}
Filling the array happens once per call, before the loop. The runner calls the function with counts in the hundreds of thousands, so that one-off cost all but vanishes from the time per iteration. Asking for n % 2048 searches for every value from 0 to 2047 in turn, so half of the searches find their number and half do not.
Keeping the work
An optimizing compiler removes work whose result nobody uses. Here nothing ever reads found, so in principle a Release build could drop the searches and report the time for an empty loop. Consume(@found) prevents that: it takes the result's address and stores it where the compiler cannot see it read, which costs one store. Every benchmark should end by consuming what its loop produced.
Running them
A BenchmarkCase gives a benchmark its name, and a table of them goes to the runner with a BenchmarkOptions:
let benchmarks: BenchmarkCase[3] = [
BenchmarkCase { name: "Search::Linear", run: Linear },
BenchmarkCase { name: "Search::LinearMissing", run: LinearMissing },
BenchmarkCase { name: "Search::Binary", run: Binary }
];
var options = BenchmarkOptions();
options.minimumTime = Duration::FromMilliseconds(100);
RunBenchmarksWith(benchmarks[..], options);
For each benchmark, the runner calls the function with a count that grows from 1 until one call lasts at least minimumTime, then reports that last call:
flowchart LR
s(["count = 1"]) --> run["`run(count)`,<br/>timed"]
run --> q{"at least<br/>`minimumTime`?"}
q -- "no" --> g["grow the count,<br/>2× to 100×"] --> run
q -- "yes" --> out["print ns per iteration"]BenchmarkOptions field | Default | Means |
|---|---|---|
minimumTime | 200 ms | how long the measured call must last |
maximumIterations | one billion | the most a call is asked to do, for work too fast to time |
filter | empty: all | run only the benchmarks whose names contain this text |
Measure(Binary, options) does the same for one function and hands back a BenchmarkResult instead of printing, for a program that wants to compare the numbers itself.
RunBenchmarks, without With, reads its options from the program's arguments: each plain argument is a filter, and a benchmark runs when its name holds any of them, while --quick measures each for 10 milliseconds. This lesson sets its options in code instead, so its output does not depend on how it was started.Reading the numbers
Linear search averages about a thousand nanoseconds, and over a third more when the value is missing and every element must be looked at. Binary search takes well under a hundred. Your numbers will differ — the program's output says which lines vary — but the ratio between them is the point, and it is the reason binary search exists.
Measure an optimized build when the numbers matter: rux run --release. A Debug build optimizes nothing, so it times code that you would never ship. On the machine that produced the output below, Release ran the binary search about three times faster, and the linear one about five.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// A benchmark measures how long a piece of code takes. One run of a fast function is too short
// for a clock to time well, so a benchmark repeats the work many times and divides: the answer is
// the time per iteration, in nanoseconds.
//
// The `Test` package does the repeating for you. A `BenchmarkCase` is a name and a function that
// takes an iteration count and does the work that many times, in its own loop. `RunBenchmarksWith`
// calls each one with a count that grows from 1 until a run lasts at least the minimum time in
// its `BenchmarkOptions`, then prints the time per iteration of that last run.
//
// This program asks a question the Binary search lesson answered on paper: how much faster is
// a binary search than looking at every element? Before timing anything, it checks that the two
// searches agree, since a fast answer is worth nothing if it is wrong.
//
// Times depend on the machine, on what else it is doing, and on the build. Measure with
// `rux run --release`: a Debug build does not optimize, so it times code nobody ships.
import Algorithms::{ BinarySearch, Contains };
import Io::PrintLine;
import Test::{ BenchmarkCase, BenchmarkOptions, Consume, RunBenchmarksWith };
import Time::Duration;
// The sorted data every benchmark searches: 0, 2, 4, ... 2046, so odd numbers are missing.
func Fill(values: var int[..]) {
for i in 0..values.length {
values[i] = i as int * 2;
}
}
// Each benchmark sets up its data before its loop. That costs the same once whatever the count,
// so over thousands of iterations its share of the time per iteration all but vanishes.
//
// The loop counts what it found and hands the count to `Consume`. Without that, a Release build
// could see that the result is never used, remove the searches as having no effect, and report
// a time for doing nothing. `Consume` takes the count's address:
//
// Consume(found);
// error: argument 1 to 'Consume' has type 'uint64', but parameter 'value' requires '*T'
//
// The iteration count is a `uint64`, and a benchmark taking anything else does not fit:
//
// BenchmarkCase { name: "Search::Linear", run: Linear } with func Linear(iterations: int)
// error: field 'run' in initializer for 'BenchmarkCase' has type 'func(int) -> opaque',
// but its declaration requires 'func(uint64) -> opaque'
func Linear(iterations: uint64) {
var values: int[1024];
Fill(values[..]);
var found: uint64 = 0;
for n in 0..iterations {
if Contains(values[..], (n % 2048) as int) { found = found + 1; }
}
Consume(@found);
}
// A value that is not there is the worst case for a linear search: it looks at every element.
func LinearMissing(iterations: uint64) {
var values: int[1024];
Fill(values[..]);
var found: uint64 = 0;
for n in 0..iterations {
if Contains(values[..], 2049) { found = found + 1; }
}
Consume(@found);
}
func Binary(iterations: uint64) {
var values: int[1024];
Fill(values[..]);
var found: uint64 = 0;
for n in 0..iterations {
if BinarySearch(values[..], (n % 2048) as int) != none { found = found + 1; }
}
Consume(@found);
}
func Main() -> int {
// First, are both searches right? They must agree on every value, present or missing.
var values: int[1024];
Fill(values[..]);
var agree = true;
for target in 0..2048 {
let linear = Contains(values[..], target);
let binary = BinarySearch(values[..], target) != none;
if linear != binary { agree = false; }
}
PrintLine("linear and binary search agree on 2048 values: {}", agree);
PrintLine();
// Then, how fast? `BenchmarkOptions()` measures each benchmark for at least 200 milliseconds;
// a shorter minimum gives a quicker but rougher answer. Setting `options.filter = "Linear"`
// would run only the benchmarks whose names contain `Linear`.
let benchmarks: BenchmarkCase[3] = [
BenchmarkCase { name: "Search::Linear", run: Linear },
BenchmarkCase { name: "Search::LinearMissing", run: LinearMissing },
BenchmarkCase { name: "Search::Binary", run: Binary }
];
var options = BenchmarkOptions();
options.minimumTime = Duration::FromMilliseconds(100);
RunBenchmarksWith(benchmarks[..], options);
return 0;
}
Besides Io, its Rux.toml lists Algorithms, Test and Time under [Dependencies].
Run it
cd Examples/Packages/Benchmark
rux run
linear and binary search agree on 2048 values: true
bench Search::Linear ... 970.3 ns/iter (119084 iterations)
bench Search::LinearMissing ... 1298.7 ns/iter (78212 iterations)
bench Search::Binary ... 79.3 ns/iter (2000000 iterations)
result: 3 measured, 0 filtered out
The three bench lines are samples: the times and iteration counts depend on the machine and
change on every run. The other lines are the same each time. For times worth comparing, measure an
optimized build with rux run --release.
Common mistakes
Try it yourself
- Run
rux run --releaseand compare the three times with a Debug run. - Set
options.filter = "Linear"before running. How many benchmarks are measured, and how many filtered out? - Make the data four times larger —
int[4096]in all three benchmarks, andn % 8192. How much slower does each search get? - Measure just the binary search with
Measure(Binary, options)and printresult.NanosecondsPerIteration()andresult.iterationsyourself.
Learn more
- Test API —
BenchmarkCase,BenchmarkOptions,MeasureandConsume - Binary search — the search being measured
- Stopwatch — timing one stretch of code with
Instant - Unit tests — the
Testpackage's checks - Function types in the Rux Reference — the
func(uint64)aBenchmarkCaseholds
22.11 Unit tests
Write #Test() functions with the Test package's Expect checks beside the code they test, and run them with rux test, all of them or only those whose names match a filter.
Overview
Decide what a program contains before it ever runs. Seven lessons on when, the target and build mode, source locations, #Error and #Warn, your own defines, and the intrinsic declarations behind Core.