This is written for someone who is not a statistician and does not want to become one. It explains what to actually do, in what order, and — this is the part most guides skip — what to do when something looks wrong, because something looking wrong is usually the measurement telling you something true.
If you just want the short version: call Compare
and read what it prints. This document exists for two reasons: to explain what
that one call is doing on your behalf, and to walk through doing it by hand for
the cases where you need more control than Compare gives you.
You have two ways of doing something in Go — two functions, two algorithms, two data structures — and you want to know: is one of them actually faster than the other, on this machine, right now?
That sounds simple. It is not, for two reasons that have nothing to do with your code:
- Your computer is noisy. Between runs, the operating system schedules other work, the CPU changes clock speed to manage heat, memory access patterns change, caches warm up and cool down. Run the exact same code twice and you will get two different numbers, even though nothing about the code changed.
- The clock itself is coarse. If one run of your code takes a few nanoseconds, and the clock you're using to time it only ticks every 40 or 100 nanoseconds (depending on your operating system), you cannot time a single run at all. You are trying to weigh a feather with a bathroom scale.
rtcompare exists to get a real answer despite both problems: it runs your code many times, times whole batches of repetitions instead of single ones (which defeats the clock problem), and then uses statistics to separate "genuinely different" from "just noise" (which defeats the scheduler-and-cache problem).
The price of doing this honestly is that the answer is never a bare number. It is always a number plus a statement of how much to trust it — and sometimes the honest answer is "I can't tell, and here is why."
For almost everyone, this is the whole document:
report, err := rtcompare.Compare(candidateA, candidateB, rtcompare.CompareOptions{
Thresholds: []float64{0.05, 0.10, 0.20}, // optional: "is A at least 5/10/20% faster?"
})
if err != nil {
panic(err)
}
fmt.Println(report)
if report.Resolved {
fmt.Printf("A is different from B by %s\n", report.Estimate)
}Compare runs the entire protocol described in the rest of this document —
sizing the measurement, checking the machine's own noise level, running the
comparison, checking for a trend across the run, choosing the right statistical
method, and computing a result — and hands you back a Report. Two fields
matter most:
report.Resolvedis a plain yes/no: is this difference big enough, and certain enough, to act on? It is deliberately conservative.falsedoes not mean "the two are equally fast" — it means "this run did not prove they are different." That is an important distinction; see What "not resolved" does and does not mean.report.Warningsis a list of plain-English sentences about anything that undermines the result — the machine seemed to slow down mid-run, the measurement was too coarse, the noise floor is unusually high. Read these even whenResolvedis true. A result can clear every bar and still have a warning worth knowing about.
Printing report gives you a short summary; the sections below explain what
each part of it means and, more usefully, what to do about it.
If Compare's defaults don't fit your case — you need a specific number of
repeats, you want to skip validation to save time, you're comparing something
other than raw speed — read on. Everything below is what Compare does for
you, spelled out so you can adjust any part of it.
Before any of the machinery below can help you, your code has to be wrapped
correctly. rtcompare doesn't call your function once per timing measurement —
it calls it once per batch of n repetitions, and you write the loop:
candidate := rtcompare.Candidate{
Name: "my approach",
Batch: func(n uint64) {
for range n {
result := myFunction(input)
sink += result // see below
}
},
}Two things matter here, and both exist to keep the measurement honest:
Why does the batch own the loop, instead of rtcompare calling your function
n times itself? Because if rtcompare called a function pointer once per
repetition, you would mostly be measuring the cost of calling a function
pointer, and the Go compiler could not inline, optimize, or otherwise treat
your code the way it would in a real program. Putting the loop inside Batch
lets the compiler compile it the same way it would compile your actual
application code. This is also why rtcompare can measure things far below
the length of a single clock tick at all: it isn't timing one repetition, it's
timing a few thousand of them and dividing, so the clock's coarseness gets
divided down along with everything else. CalibrateInnerLoops (below) is what
picks n for you.
Why the sink +=? Go does not delete a loop just because nothing reads
its result — unlike some C and C++ compilers, so this is mostly not a trap you
can fall into by accident in Go. But assigning every result to a
package-level variable is cheap insurance against the one case where it can
happen: if the compiler can prove at compile time that the loop always
produces the same value (for example, if the input never changes), it may
fold the whole thing to a constant and your "measurement" becomes the time it
takes to do nothing. Declare var sink SomeType at package level and add to
it; that alone is enough to keep the compiler honest.
If your code needs setup that shouldn't be timed — allocating a buffer,
building a test fixture — put it in Candidate.Setup, which runs before each
batch but outside the timed region. Candidate.Teardown is the same for
cleanup after. Anything your code needs per repetition inside the loop
(regenerating an input it mutates, say) cannot be moved to Setup, because
Setup runs once per batch, not once per repetition — and that per-repetition
cost gets measured along with your code. See
Attenuation
for what this costs you.
This is the sequence Compare runs automatically. Read it if you want to
understand what's happening, if you're calling the pieces yourself, or if
you're trying to figure out why a result looks strange.
Function: CalibrateInnerLoops
The clock on your machine can only tell time in fixed-size ticks — on a Mac that's about 40 nanoseconds, on Linux around 1 nanosecond, on Windows about 100 nanoseconds. If your code runs faster than one tick, timing a single execution is meaningless: you'd get "0 ticks" or "1 tick," which tells you nothing.
The fix is to time a batch of many repetitions at once and divide. If you run your code 50,000 times and the whole batch takes 50,000 nanoseconds, you know each repetition took about 1 nanosecond on average — even though no single repetition could be timed on its own. The more repetitions per batch, the finer the resolution you get, because whatever error the clock's coarseness introduces gets spread across all of them.
CalibrateInnerLoops figures out, automatically, how many repetitions (n)
one batch needs so that the clock's coarseness contributes only a tiny,
controlled amount of error — a tenth of a percent by default. You almost never
need to call this yourself; leaving CollectOptions.InnerLoops at zero (the
default) does it for you. Compare does this once, up front, for both
candidates, and reuses the same batch size for every later step — that
matters, because a noise measurement taken at one batch size doesn't tell you
anything trustworthy about a comparison run at a different one.
When to touch this yourself: almost never. The one exception is if calibration fails with an error saying it couldn't reach the target batch duration — see Troubleshooting.
Function: ValidateHarness
This is the step every other benchmarking approach skips, and it's the reason rtcompare exists.
Here is the experiment: take one candidate, and compare it against itself
— literally the same code, measured twice. Since it's the same code, any
"difference" the tool reports between the two runs is not real. It's pure
noise: scheduler jitter, cache effects, thermal throttling, whatever else
your machine happened to be doing. ValidateHarness runs this experiment
several times and tells you how big that noise typically gets. This number is
called the noise floor.
Why does this matter? Because a bootstrap confidence calculation (Step 6, later) can tell you "I am 99% confident A is faster than B" — and be completely right about that confidence — while the actual difference is still smaller than what your machine invents between two runs of the same code. High confidence in a small, meaningless number is not a contradiction; the confidence is about whether the result would repeat if you resampled the same data, not about whether the data was trustworthy in the first place. The noise floor is the only thing in this toolkit that can catch that.
Do this for both candidates, not just one. They don't have to be equally
well-behaved — one might allocate memory and get interrupted by garbage
collection more than the other — and your comparison is only as trustworthy as
the worse of the two. Compare does this automatically and uses the higher
(worse) of the two floors.
What you get back, and what each number tells you:
NoiseFloor— the practical floor. If your measured difference is smaller than this, you have measured nothing, however confident the bootstrap sounds. Note that this is a high percentile, not an absolute ceiling — roughly one run in ten of identical code will exceed it. That's intentional: a true ceiling (the worst case ever observed) gets worse the more carefully you check, which would be a strange thing for "more validation" to do to your confidence.TieRate— how often two measurements come out exactly equal. This usually means your measurement is too coarse — see Troubleshooting.DriftRate— how often the machine seemed to change behavior partway through a run (see Step 4).Autocorrelation— how much each measurement resembles the one right before it (see Step 5).
Function: Collect
Now run the real comparison: alternate between candidate A and candidate B, batch after batch, and record one timing per batch for each. By default the order alternates as A-B-B-A-B-A-A-B... (called ABBA), which is cheap insurance against a machine that's changing over time — if it's getting slower as the run progresses, both candidates are equally exposed to that instead of whichever one happens to run later.
This gives you two lists of numbers — SamplesA and SamplesB — one
measurement per batch. Everything from here on works from these two lists.
Function: DetectDrift
The statistics in the next steps (the bootstrap) treat your list of measurements as an unordered bag of numbers — as if you'd drawn them all at once, in no particular order. But you didn't: you drew them in sequence, over however long the run took. If your machine was getting steadily slower (say, from thermal throttling) or steadily faster (say, from the CPU ramping up after being idle), that shows up as a trend across the run — and the bootstrap is structurally blind to it, because it discards the order the numbers arrived in.
DetectDrift looks specifically for that trend, on each candidate's series
separately. If it finds one, it's worth knowing even though ABBA ordering
already protects the comparison from being biased by it — a real trend
means your measurements are less independent of each other than the
statistics assume, which affects how much you should trust any interval or
confidence number that follows.
Function: lag1Autocorrelation, feeding into a choice between
BootstrapConfidence and BlockBootstrapConfidence
A related but distinct problem: even without an overall trend, one measurement can be quietly correlated with the one right before it — a slow batch tends to be followed by another slow batch, say, because whatever disturbed the first one (another process waking up, a cache getting cold) is still going on. The ordinary bootstrap (Step 6) assumes each measurement is independent of the others; when they're not, it becomes overconfident — it reports a tighter, more certain answer than the data actually supports.
The fix, when this correlation is strong enough to matter (above roughly
0.2), is to resample in contiguous blocks of measurements instead of one at
a time, which keeps nearby measurements together and preserves the
dependence between them rather than pretending it isn't there. Compare (and
ValidateHarness, if you're doing this by hand) checks this and switches to
BlockBootstrapConfidence automatically when it's warranted; below that
threshold, blocks cost you nothing but don't help either, so the plain
version is used.
You never need to compute this by hand. It's here so that when a Report
says "resampled in blocks of 5," you know why, and so you understand why the
threshold exists if you're calling the bootstrap functions directly.
Functions: CompareSamples (confidence against thresholds you name) and
EstimateDifference (the size of the difference, with an interval)
This is the step that finally answers "how different are they, and how sure am I?" There are two slightly different questions you might be asking, and rtcompare has a function for each:
"Is A at least X% faster than B?" — use CompareSamples, or read
report.Confidence if you gave Compare a list of Thresholds. You give it
one or more thresholds (5%, 10%, 20% — whatever you actually care about,
such as a performance budget you need to hit) and it tells you, for each one,
how confident you can be that the true difference meets it. This is the right
tool when you already know the number you care about — a regression budget, a
release gate.
"How big is the difference, actually?" — use EstimateDifference, or read
report.Estimate. Instead of testing against a threshold you provide, this
gives you the difference itself, plus an interval around it: "A is 23% faster,
and we're 95% confident the true value is somewhere between 18% and 28%."
This is the right tool when you don't have a specific number in mind and just
want to know what's going on.
Both work by bootstrap resampling: computer-science-speak for "shuffle the measurements you have, with repeats allowed, recompute the answer thousands of times, and see how much the answer moves around." If the answer barely moves no matter how you reshuffle, you can trust it. If it swings wildly, you can't — and that swinginess is the confidence interval. You don't need to understand the mechanics to use it; you need to know that "5,000 resamples" (the default) is doing exactly what it sounds like, and more resamples cost more CPU time for a more precise (but not more correct) answer to the same question.
Compare folds all of the above into report.Resolved: a difference counts
as resolved only if both of these are true:
- The interval from Step 6 does not include zero (a real difference, not just noise scattering around zero), and
- The size of that difference is bigger than the noise floor from Step 2 (a difference big enough to matter on this machine, not an artifact).
Either condition alone is not enough — see What "not resolved" does and does not mean.
This trips people up, so it's worth stating plainly:
Resolved == false does not mean "A and B are the same speed." It means
this run, on this machine, did not produce enough evidence to say they're
different. Those are not the same claim. The true difference might be real
but too small for this setup to see (below the noise floor), or your sample
might genuinely have been too noisy this particular time (a one-off machine
hiccup that would look completely different on a re-run).
If you get Resolved == false and want a real answer, in rough order of what
to try first:
- Run it again. If it was a one-off noisy run, a second run often clears things up on its own.
- Make the batches longer. See The measurement seems too coarse below — a coarser measurement has a higher noise floor, so a real but small difference can hide inside it.
- Quiet the machine down. Close other applications, plug in a laptop (power-saving modes throttle the CPU), disable Turbo Boost / dynamic frequency scaling if your OS lets you, and avoid running anything else heavy at the same time.
- Accept it. Sometimes two implementations really are close enough that the difference doesn't matter for your purposes. "Not resolved" at a demanding threshold can be a perfectly good answer: it tells you the difference, if any, is too small to be worth choosing between them for.
One thing no amount of resampling can fix, so it's worth flagging on its own: you are always measuring the whole batch loop, not the isolated function. Whatever fixed cost sits in that loop besides your code under test — the loop counter, an accumulator, regenerating an input — is added to both candidates equally, so it never flips which one looks faster. But it does shrink the apparent size of the difference, because it's a constant cost that both share, diluting the relative size of whatever your code actually does differently.
Concretely: a test where the true difference between two pieces of code was exactly 50% measured as 35%, because a fixed 1.81 ns of loop overhead sat on top of 2.13 ns of the actual work being compared. Subtracting an empty loop's time as a baseline does not fix this — the compiler optimizes an empty loop differently from a real one, so that correction barely helps.
What this means for you: move everything you can out of the loop and into
Setup (which isn't timed at all). What's left inside the loop is measured
honestly — as the cost of that whole region, not as an isolated number for
your function alone. Read a result as "how much faster is this measured
region," not "how much faster is this one function in isolation."
This is the part of the "long protocol" that's normally invisible — the
judgment calls between steps. Here they are as an explicit table. Everything
below is available on a Report from Compare, or by calling the named
function yourself.
Symptom: HarnessValidation.TieRate (or a warning on the Report
mentioning "bootstrap replicates tied") is above a few percent.
What it means: two measurements are landing on exactly the same number often enough to matter. This happens when your batches are too short relative to the clock's granularity — the measurement is rounding several genuinely different durations onto the same value.
What to do: make the batches longer, by lowering
CollectOptions.MaxQuantizationError (it defaults to 0.001, i.e. 0.1%; try
0.0001). This asks the calibration step to run each batch about ten times
longer, which spreads the same clock-tick error over ten times as many
repetitions. Do not try to fix this by increasing Repeats — that draws
more samples from the same coarse set of possible values and doesn't change
the granularity at all. Only a longer batch does.
Symptom: HarnessValidation.NoiseFloor (or Report.NoiseFloor) is a
percent or more, when you expected it to be a small fraction of a percent.
What it means: your machine is disturbing the measurement more than usual. Common causes: running on battery power (power-saving throttles the CPU unpredictably), other applications competing for the CPU or memory bandwidth, thermal throttling on a machine that's been under load for a while, or a candidate that allocates memory and triggers garbage collection mid-batch.
What to do:
- Plug in, close other applications, and let the machine cool down or idle briefly before running.
- If one candidate allocates and the other doesn't, try
GCBetween: trueinCollectOptions— it forces a garbage collection between batches instead of letting one land unpredictably in the middle of a timed region. Pairing it withDisableGC: truegives fully deterministic collection points, though see that option's documentation for the trade-off (it under-counts collector overhead, which matters if you care about real-world behavior rather than a clean comparison). - If none of that helps, the noise floor is telling you the truth about this machine right now — treat any difference smaller than it as unresolved and move on, or find a quieter machine (a dedicated benchmark server, a CI runner with less contention) if this comparison matters enough.
Symptom: DriftReport.Drifted(...) returns true, or Report.Warnings
mentions a candidate that "drifted during the run."
What it means: the machine changed behavior over the course of the run — usually getting slower, most often from thermal throttling as sustained load heats up the CPU. Because measurement order is interleaved by default (ABBA), a drift usually doesn't bias which candidate looks faster — both are equally exposed to it — but it does mean your measurements are less independent than the statistics assume, which widens the real uncertainty beyond what the reported interval shows.
What to do:
- Let the machine idle and cool before running, especially on a laptop.
- Consider shorter runs (fewer
Repeats) if the drift is thermal — a shorter run gives the CPU less time to heat up in the first place. - If drift keeps appearing on a machine you use often for this, treat every result from it with a bit more skepticism than the headline confidence suggests.
Symptom: HarnessValidation.Autocorrelation (or Report.Autocorrelation)
is above about 0.2, or the report mentions resampling "in blocks."
What it means: consecutive measurements resemble each other more than pure chance would produce — see Step 5, above. This is usually a symptom of the same causes as a high noise floor (something intermittently loading the machine), just showing up as a pattern between neighbors rather than as pure scatter.
What to do: nothing, usually — Compare (and ValidateHarness) detects
this and switches to block resampling automatically, which repairs most of
the resulting overconfidence. If you're calling BootstrapConfidence
directly rather than through Compare, switch to
BlockBootstrapConfidence yourself once this crosses roughly 0.2. If the
value is very high (0.4 and up), block resampling only partly repairs the
problem — that's a sign to also address whatever is disturbing the
machine, using the same steps as for a high noise floor.
One thing worth knowing if you've set SkipValidation: true: this decision is
then made from a single, noisier read of the correlation, taken from the
comparison run itself rather than from the several separate A/A experiments
ValidateHarness would otherwise average over. With validation left on (the
default), the number Compare acts on is the median across those experiments
— a steadier estimate of a property of your machine and setup, not a one-off
reading of whatever happened to be going on during this particular run. That's
one more reason SkipValidation trades away more than just the noise floor.
Why doesn't interleaving the order (ABBA, see Step 3) already fix this? It's a fair question, and the answer is that ABBA and blocks solve two different problems. ABBA cancels a trend's effect on which candidate looks faster — if the machine is slowly getting hotter, both candidates are measured, on average, at the same points in that slowdown, so it washes out of the comparison between them. Autocorrelation is a property of one candidate's own sequence of measurements — whether its 51st batch resembles its 50th more than chance would suggest — and that says nothing about bias between A and B. It says something about how much independent information those 51 measurements actually contain. Resampling one at a time, as the plain bootstrap does, assumes each of them is a fresh, independent look; strong autocorrelation means they're not, and no amount of interleaving the order between A and B changes that fact about A's own numbers. This has been measured directly: on data manufactured to have no real difference at all, the plain bootstrap reported a "difference" in 21.7% of runs at a lag-1 correlation of 0.4, against a target of 10% — interleaving the order was already in effect and did not prevent it.
Symptom: Estimate.Low and Estimate.High are far apart — say, "A is
somewhere between 5% and 45% faster."
What it means: either you don't have enough measurements to pin the number down precisely, or the measurements themselves are highly variable (which usually traces back to one of the machine-noise issues above).
What to do:
- Increase
CollectOptions.Repeats(more measurements narrows the interval, up to a point — it helps with random scatter, not with a systematic problem like drift or a high noise floor). - Increase
Resamplesif the interval's edges seem to jump around between otherwise-identical runs — that's Monte Carlo noise in the resampling itself, and it settles down with more resamples (5,000 is the default; 10,000+ helps when you specifically care about the tails). - If the interval is wide because the underlying noise floor is high, fix that first — more repeats can't out-run a genuinely noisy machine.
Symptom: an error from CalibrateInnerLoops or Collect, saying it
couldn't get the batch long enough even at the maximum allowed number of
repetitions.
What it means: your Batch function almost certainly isn't doing n
times the work — most often because it ignores n entirely (a copy-paste
bug where the loop uses a fixed count instead of range n), or because the
compiler managed to fold the whole computation to a constant (see the
sink += advice above). This is not usually a sign that your code is "too
fast to measure" — Go does not delete a loop merely because nothing reads
its result, so a genuinely-too-fast operation is rare in practice.
What to do: check that Batch actually loops n times and that its
result depends on something the compiler can't precompute (real input data,
not a hardcoded constant), and check that the result is fed into a
package-level sink variable.
- Batch — one call to your
Batchfunction, running your codentimes in a row so the whole thing can be timed together. - Median — the middle value of a sorted list of numbers. rtcompare uses
this instead of the average because a single wildly slow batch (the machine
hiccuped) barely moves the median but can swing an average a lot — see
CompareSamples's documentation ("Why the median") for the evidence behind that choice, including where it stops being the right pick. - Bootstrap / resampling — repeatedly reshuffling your actual measurements (with repeats allowed) to see how much an answer computed from them would wobble if you'd happened to draw a slightly different sample. Wobbles a little → trust the answer. Wobbles a lot → don't, yet.
- Confidence — in this library, always "the fraction of resampled reshuffles in which the stated threshold held." Not a probability that a hypothesis is true in some absolute sense — a statement about how consistently your specific data supports a specific claim.
- Noise floor — what this exact setup, on this exact machine, reports as a "difference" between two runs of the same code. The yardstick every real result should be measured against.
- Tie rate — how often two resampled measurements come out exactly equal. High values mean the measurement is too coarse (see above).
- Drift — a trend across a run, usually the machine slowing down (or speeding up) as the run progresses.
- Autocorrelation — how much each measurement resembles the one right before it. High values mean measurements aren't fully independent of one another.
- Attenuation — the true difference between two pieces of code getting diluted in the measured number because of fixed overhead (a loop, an accumulator) that both candidates carry equally. See above.
- Quantization / quantization error — the rounding introduced by the system clock only being able to tell time in discrete ticks, rather than continuously.