Mehdi Akiki
Published on

My Laptop Changed Its CPU Speed During the Benchmark

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Investigation · Part 6 of 20 · When the benchmark lies

At the end of the part about JIT warmup, a compiled Java function still jumped between levels, 3.24, 3.81, 4.01 nanoseconds per call, long after the JIT had finished its work. The compile log was silent, so the JVM was not the reason. My guess was the clock of the CPU.

A guess is not a measurement. So for this part I made a program that measures two things at the same time: how much work a loop does, and at which frequency its core runs.

Work and clock in the same trace

The program has two threads. The worker is pinned to CPU 2, a performance core, and runs this loop, in chunks of 100,000 turns:

.LBB23_18:
	imulq	%rax, %rbx
	addq	%rcx, %rbx
	incq	%rdx
	cmpq	$100000, %rdx
	jne	.LBB23_18

One multiplication and one addition per turn, and each turn needs the result of the previous one. So the loop can never run faster than the latency of this chain, and it does not touch memory at all. After each chunk it increments a shared counter.

The sampler is pinned to CPU 10, an efficiency core, so it does not steal time from the worker. Every 100 ms (every 5 ms during the first half second) it reads the counter and scaling_cur_freq of CPU 2. The kernel documentation of intel_pstate describes this file as the "frequency of the average P-state of the CPU", so it is an average over a short recent time, not one instant. The sampler prints the work done since the last sample and the work per cycle, that is the work divided by the frequency. It prints also the temperature of the core of CPU 2 and of the whole package.

If the speed of the loop depends only on the clock, the work per cycle must stay constant.

One minute alone

After three seconds of sleep, the worker ran for 60 seconds. These are the medians of each phase:

phase      load  freq_MHz  core_C  pkg_C  iter_per_us  iter_per_cycle  (medians)
0-0.5 s    off       3894      62     88        966.6           0.248
0.5-10 s   off       3844      68     85        959.4           0.247
10-20 s    off       3400      69     79        849.0           0.249
20-30 s    off       3400      70     79        845.0           0.248
30-40 s    off       4102      70     87       1013.9           0.247
40-50 s    off       4140      69     88       1026.5           0.248
50-60 s    off       4123      69     88       1027.1           0.247

The full trace shows three different periods. Until about 6 seconds the clock was between 3.8 and 4.0 GHz. From 7 to 29 seconds it was between 3.3 and 3.6 GHz. After 30 seconds it went back to 3.9 to 4.2 GHz. My program did exactly the same thing during the whole minute.

And the last column is the important one. The work per cycle stayed between 0.247 and 0.249 in every phase, while the work per microsecond moved by 20 percent. 0.25 iterations per cycle means four cycles per turn, the latency of the chain. OK, this is clear: the loop always does the same work per cycle, and only the number of cycles per second changes.

So, if I had measured version A of some code in the first 6 seconds and version B between 10 and 30 seconds, B would look 11.5 percent slower (849 against 959 iterations per microsecond), with no difference in the code at all. Honestly, this number scares me a little: it is bigger than most of the differences I would want a benchmark to detect.

Why not 4.7 GHz?

The maximum frequency of this CPU is 4.7 GHz, and the result file records it next to the settings:

cpu2: driver intel_pstate, energy_performance_preference power, cpuinfo_max_freq 4700000 kHz, no_turbo 0, AC online 1

Turbo is allowed (no_turbo 0) and the laptop is on AC power. But the energy performance preference is power. With intel_pstate, the hardware chooses the frequency itself, and this preference is a hint that tells it to save energy. The highest median of any phase in the saved run was 4,399 MHz, during the first half second of the second run.

There is a second reason in the kernel documentation: the maximum turbo frequency "generally depends on the number of cores running concurrently". My laptop is never idle. When this run started, the snapshot of busy CPUs shows CPU 3 at 100 percent, another program on the other hardware thread of my own core, and at the end other CPUs were busy too. (The work per cycle stayed at 0.25 anyway: this loop waits for its own chain all the time.) I can not change the preference without root, I can not stop programs that are not mine, and I did not want to: this is the laptop as I use it.

Other cores slow down mine

To test the second reason directly, the second run starts the same chain on five efficiency cores (CPUs 11 to 15) during every second phase of 10 seconds:

phase      load  freq_MHz  core_C  pkg_C  iter_per_us  iter_per_cycle  (medians)
0-0.5 s    off       4399      64     66       1090.8           0.247
0.5-10 s   off       4147      69     90       1025.8           0.246
10-20 s    on        4056      68     78        991.0           0.246
20-30 s    off       4117      70     89       1021.9           0.248
30-40 s    on        3727      72     84        932.0           0.249
40-50 s    off       4128      78     92       1026.6           0.248
50-60 s    on        3800      69     78        957.0           0.248

Every phase with the extra load is slower than the two phases around it: 4,056, 3,727 and 3,800 MHz, against 4,117 to 4,147 MHz without the load. The size changes, from 2 to 10 percent, but the direction is always the same. In the first test of the program, also in the fixture, the direction was the same too, but the difference was only about 1 percent (medians of 3,758 MHz with the load and 3,800 without). The busy efficiency cores do not share anything with my loop, no memory and no code. They only share the chip.

I did not expect this: during the load phases, the package temperature goes down, from about 90 °C to 78 to 84 °C. My own core stays between 64 and 78 °C in all phases, so it is not my core that cools. Something else in the package gets cooler when five more cores are busy. Maybe the other busy performance cores get a lower clock too, but I did not measure them, so I can not say. And the slow period of the first run, between 7 and 29 seconds, has the same signature: lower clock and a cooler package. So my guess is that some other program started to work on other cores during those twenty seconds. I do not know which one.

The ramp from idle

In the part about the first run I wanted to see the clock ramp up after idle, and I could not separate it from cold caches. Here the saved run makes five short starts after three seconds of sleep:

start 1: cpu 2 at 4059 MHz, its core at 92 C, package at 92 C | 5 ms 4372 MHz  10 ms 4376 MHz  20 ms 4357 MHz  40 ms 4108 MHz  60 ms 4375 MHz  80 ms 4358 MHz  100 ms 4364 MHz  
start 2: cpu 2 at 991 MHz, its core at 62 C, package at 96 C | 5 ms 4422 MHz  10 ms 4055 MHz  20 ms 4473 MHz  40 ms 4424 MHz  60 ms 4428 MHz  80 ms 4119 MHz  100 ms 4528 MHz  
...

All five were above 4 GHz at 5 ms. The minute with load in the same run did not start like this: the package was at 66 °C, and the clock went from 1.47 GHz at 5 ms to 4.40 GHz only at 73 ms. The very first test of this program, which I kept in the fixture, looked the same:

worker on cpu 2, sampler on cpu 10, 40 s, extra load on [11, 12, 13, 14, 15] in every second phase of 10 s
before the start: cpu 2 at 1275 MHz, package at 67 C
 time_ms  load  freq_MHz  temp_C  iter_per_us  iter_per_cycle
       5   off      1207      67        271.7           0.225
      10   off      1300      66        324.7           0.250
      15   off      1372      68        338.1           0.246
      22   off      1555      66        374.4           0.241
      25   off      1555      66        399.5           0.257
      30   off      1690      67        420.8           0.249
      35   off      1802      68        439.8           0.244
      40   off      2022      68        500.8           0.248
      46   off      2291      68        551.2           0.241
      50   off      2463      71        611.8           0.248
      55   off      2661      72        672.9           0.253
      60   off      3102      75        756.7           0.244
      65   off      3390      80        887.2           0.262
      70   off      3838      80        919.0           0.239
...

Here the core started at 1.2 GHz and needed 70 ms to reach 3.8 GHz. During the first 35 ms it did less than half of the work per microsecond of the later samples, and still the same work per cycle. The difference between the cases: in the first test and before the minute with load the package was at 66 to 67 °C, so the rest of the laptop was quiet, and before the five starts it was at 92 to 96 °C because other programs were busy. I saw the slow start only when the package was cool, that is when the rest of the laptop was quiet. I do not know the mechanism, but the practical point is clear: a benchmark that starts on a quiet machine can spend its first tens of milliseconds at a third of the speed.

When I ran this part a second time, on a quiet laptop with the package at 50 to 51 °C, the slow start was everywhere: all five starts stayed between 1.0 and 1.9 GHz for the first 80 ms, only one reached 2.4 GHz at 100 ms, and the minute alone needed about 150 ms to pass 3 GHz. After that, its phase medians stayed between 4.47 and 4.61 GHz, so the slow period of the first run probably came from other programs, as I guessed. The five busy E-cores lowered my clock again, but only by about 1 percent, and this time the package did not get cooler (p06-cpu-speed.txt).

What a benchmark machine does, and what I can do

On a machine reserved for benchmarks, people remove these effects at the source. The LLVM documentation has a page of benchmarking tips that lists the usual steps: "Disable frequency scaling, Turbo Boost and address space randomization", set the governor to performance, disable Turbo Boost with intel_pstate/no_turbo, "Disable the SMT pair of the cpus you will use for the benchmark", and reserve cores with cset shield. All of these need root, and they make the machine less similar to the one where the program will really run. They are still the right thing for a benchmark machine.

On my laptop I can not do any of this, so I do what I can:

  • Pin the benchmark to one known core, so at least the core type and its sibling are known.
  • Warm up for much longer than the ramp. The slow start above took 70 ms, so I prefer about a second.
  • Record the frequency next to every result, as the result files of this series do since the first part.
  • For a loop that only computes, look at the work per cycle. Its phase medians did not move in this experiment. It fell only in the first 40 ms of the minute with load, to 0.011 to 0.149, and right after that minute the snapshot shows CPU 2 itself 59 percent busy, so probably another program shared my CPU at that moment. So if the work per cycle changes between two versions, look first at what else ran on that CPU.
  • Never run version A for one minute and then version B for the next minute. Alternate them in short blocks, so that a slow period of 20 seconds hits both. A later part of the series measures how much this matters.

The programs, the two traces and the first test are in the fixture, with the summary in p06-cpu-speed.txt.

Sources