Packages · Lesson 22.12

Benchmark

Source
Measure how long two searches take with the Test package's benchmarks: a BenchmarkCase per search, Consume to keep the work from being optimized away, and RunBenchmarksWith to repeat each one until it can be timed, after first checking that the searches agree.

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 fieldDefaultMeans
minimumTime200 mshow long the measured call must last
maximumIterationsone billionthe most a call is asked to do, for work too fast to time
filterempty: allrun 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.

From the command line.
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.

Src/Main.rux
// 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

  1. Run rux run --release and compare the three times with a Debug run.
  2. Set options.filter = "Linear" before running. How many benchmarks are measured, and how many filtered out?
  3. Make the data four times larger — int[4096] in all three benchmarks, and n % 8192. How much slower does each search get?
  4. Measure just the binary search with Measure(Binary, options) and print result.NanosecondsPerIteration() and result.iterations yourself.

Learn more

  • Test API — BenchmarkCase, BenchmarkOptions, Measure and Consume
  • Binary search — the search being measured
  • Stopwatch — timing one stretch of code with Instant
  • Unit tests — the Test package's checks
  • Function types in the Rux Reference — the func(uint64) a BenchmarkCase holds