Mehdi Akiki
Published on

When the Compiler Computes the Answer Before the Benchmark Runs

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Investigation · Part 3 of 20 · When the benchmark lies

On my laptop, summing all the numbers below one thousand takes 0.81 nanoseconds. Summing all the numbers below one billion takes 0.81 nanoseconds too.

A million times more work, and the same time. I wanted to see this with my own eyes, so I made the smallest program that shows it. The short answer: the loop is not there. The compiler found the formula that every school student learns, and the benchmark measures one multiplication.

In the first part of this series, black_box on the input and on the output was enough to keep the work alive. Here black_box is in the right place, and it is still not enough.

First, with a constant

The function is a plain loop:

fn sum_below(n: u64) -> u64 {
    let mut s = 0;
    for i in 0..n {
        s += i;
    }
    s
}

#[no_mangle]
#[inline(never)]
fn sum_below_1000() -> u64 {
    sum_below(1000)
}

With rustc +1.98.0 -O, the whole function sum_below_1000 becomes this:

sum_below_1000:
	movl	$499500, %eax
	retq

Two instructions: put the number 499,500 in the return register, and return. The sum of 0 to 999 is 999 times 1000 divided by 2, that is 499,500. The compiler computed it during the build. This is constant folding, and it is not a surprise: everything about this call is known at compile time.

The usual fix is to hide n, so that the compiler cannot know it. This is what black_box is for.

Then, with n hidden

The second function takes n as an argument, and the benchmark gives it through black_box, so it can be any value:

#[no_mangle]
#[inline(never)]
fn sum_below_n(n: u64) -> u64 {
    sum_below(n)
}

Now the compiler cannot compute the answer during the build. So I expected a loop in the assembly. Here is what I got:

sum_below_n:
	testq	%rdi, %rdi
	je	.LBB21_1
	leaq	-1(%rdi), %rax
	leaq	-2(%rdi), %rcx
	mulq	%rcx
	shldq	$63, %rax, %rdx
	leaq	(%rdi,%rdx), %rax
	decq	%rax
	retq
.LBB21_1:
	xorl	%eax, %eax
	retq

There is no loop. n arrives in rdi. If it is zero, the function returns zero. Otherwise it computes n - 1 in rax and n - 2 in rcx, and mulq multiplies them. The result of mulq has 128 bits, the high half in rdx and the low half in rax. Then shldq $63 shifts this 128-bit number one bit to the right and keeps 64 bits, that is a division by two. At the end it adds n and subtracts one.

So the function returns (n - 1)(n - 2) / 2 + (n - 1). If you simplify it, this is n(n - 1) / 2, the formula for the sum of 0 to n - 1. The compiler did not need to know n. It needed only to know what the loop computes as a function of n.

For me this case is more interesting than the deleted loop of the first part. That loop had no effect, so deleting it was easy to accept. This loop has a real result, and the result is correct for every n. The compiler did not remove the work, it replaced the work with a better algorithm.

The same thing in the LLVM IR

The intermediate code of LLVM shows the idea more clearly than the assembly. This is the core of sum_below_n from rustc -O --emit llvm-ir:

  %0 = add i64 %n, -1
  %1 = zext i64 %0 to i65
  %2 = add i64 %n, -2
  %3 = zext i64 %2 to i65
  %4 = mul i65 %1, %3
  %5 = lshr i65 %4, 1
  %6 = trunc nuw i65 %5 to i64
  %7 = add i64 %n, %6
  %8 = add i64 %7, -1

i65 is an integer of 65 bits. I liked this detail a lot. In a release build, the += on u64 wraps around on overflow, so for a very big n the loop gives the true sum modulo 2 to the power 64. The formula must give exactly the same answer, also in this case. The division by two would lose the top bit of a 64-bit product, so LLVM makes the product with one bit more. After the shift, the low 64 bits are exactly the bits the loop would produce. So the optimizer keeps even the overflow behavior of the loop.

Who does this: scalar evolution

The part of LLVM that does this is called scalar evolution, or SCEV. I only explain it as far as the LLVM documentation goes. Its page about passes describes the analysis like this: it "specializes in recognizing general induction variables", and "given this analysis, trip counts of loops and other important properties can be obtained".

An induction variable is a value that changes in a regular way at every turn of a loop. i starts at 0 and gets plus one at each turn. s starts at 0 and gets plus i at each turn. So s grows by a value that grows by one, and this is a polynomial of the number of turns. The number of turns is n, and it is known when the loop starts. So the final value of s can be computed without running the loop.

Then a transform pass uses it. The same page says that the pass indvars changes the uses of such values after the loop, so that the value is computed outside of the loop, and: "If the only purpose of the loop is to compute the exit value of some derived expression, this transformation will make the loop dead." That is exactly our case. The only purpose of the loop is the final value of s. Once the formula computes it, the loop is dead, and it is deleted.

I wanted to be sure that it is really this pass, so I asked LLVM to print the function after every pass that changed it (the option -print-changed, through -C llvm-args). The 65-bit multiplication appears for the first time here:

*** IR Dump After LCSSAPass on sum_below_n ***
*** IR Dump After IndVarSimplifyPass on loop %bb2 in function sum_below_n ***
  %4 = mul i65 %1, %3

IndVarSimplifyPass is the class behind the pass called indvars. OK, so the documentation and the experiment agree.

The time does not depend on n

Now the benchmark. The harness gives n through black_box, and each sample calls the function 100,000 times. This is the second of three runs:

sum_below_n(1000) = 499500
below_n_1000       min     0.759  median     0.810  p90     0.810  max     0.811  ns/call  (n=21)
sum_below_n(1000000) = 499999500000
below_n_1000000    min     0.760  median     0.810  p90     0.810  max     0.923  ns/call  (n=21)
sum_below_n(1000000000) = 499999999500000000
below_n_1000000000 min     0.760  median     0.810  p90     0.819  max     0.839  ns/call  (n=21)

The answers are correct for the three values, and the median is 0.81 ns for all three. The third run gives 0.79 ns for all three. The first run was about six times slower, between 4.6 and 5.0 ns. When it started, the load average was 11.1 and my CPU was already 24 percent busy, so my first idea was the load. But when I ran this part a second time, on a quieter laptop, the first run was again slower: 1.8 to 2.1 ns, against 0.53 to 0.67 for the two others (p03-closed-form.txt). So the load is not the whole story, and I do not know the rest. But even in the slow runs, n = 1,000 and n = 1,000,000,000 take the same time.

A real loop of one billion additions cannot do this. Even a vector loop with four additions per cycle needs 250 million cycles, that is more than 50 milliseconds at 4.7 GHz. The benchmark says 0.81 nanoseconds. If I would divide, I would get more than one billion additions per nanosecond. The number is not wrong as a measurement. It measures a multiplication, a shift and two additions. It just does not measure a loop.

A benchmark that really grows with n

The difference is that sum_below only computes with the loop counter. No formula can replace a loop over data that the compiler does not know. So the third function sums the content of a slice:

fn sum_array(data: &[u64]) -> u64 {
    let mut s = 0;
    for &x in data {
        s += x;
    }
    s
}

The assembly has the same vector loop as in the first part, four numbers per turn, and the times grow with the length:

array_1000         min    86.816  median    91.546  p90    95.598  max    97.116  ns/call  (n=21)
                   median per element 0.092 ns
array_10000        min   877.704  median   907.720  p90   933.049  max   981.644  ns/call  (n=21)
                   median per element 0.091 ns
array_100000       min  8771.733  median  9041.989  p90  9255.420  max 11372.089  ns/call  (n=21)
                   median per element 0.090 ns
array_1000000      min 137569.810  median 155274.060  p90 191915.700  max 229885.680  ns/call  (n=21)
                   median per element 0.155 ns
array_10000000     min 4333094.900  median 4738471.600  p90 4996801.600  max 5362493.800  ns/call  (n=21)
                   median per element 0.474 ns

Good, that is what a real loop looks like. Ten times more elements, ten times more time, at least in the first steps: 0.09 ns per element up to 100,000 elements. Then the time per element grows, to 0.155 ns and 0.474 ns. At one million elements the array is 8 MB, more than the L2 cache of one core (1.25 MiB), and at ten million it is 80 MB, more than the L3 cache that all cores share (18 MiB). So the elements come from further away. A later part of the series measures exactly this. Here it is enough to see that the time follows n. That is the proof that the work happens.

How I check this now

The first part gave the rule "hide the input, use the output". This part adds one more:

black_box hides a value. It does not hide the structure of the computation. If the loop computes something that has a closed form in terms of the input, the compiler can find the closed form without knowing the input.

So when I write a microbenchmark, I check two things now. First, I run it with two or three sizes that are ten times apart, and I look if the time follows. If the time stays flat when the work should grow, I do not trust the number before I read the assembly. Second, I make sure that the work depends on data, not only on a counter: an array, a string, the bytes of a file. Real programs almost always work on data, so this also makes the benchmark more similar to the real thing.

And sometimes the closed form is the real thing. If your production code has a loop like sum_below, it can be optimized in the same way, and 0.81 ns is its true cost. The benchmark is only wrong when you believe it measures the loop.

The program and the output are in the fixture, in p03-closed-form.txt.

References