Intel i7 loop performance anomaly
eli.thegreenplace.net
eli.thegreenplace.net
For example, in Intel processors with a u-op cache, a 32-byte fetch group that generates more than 18 u-ops has to go through the x86 decoder and cannot stream from cache.[1] The call instruction, being a pretty big instruction with that 32-bit immediate displacement, might force the loop onto two 32-byte lines, reducing the u-op count in each line below 18 and allowing the loop to stream from the u-op cache instead of going through the decoders.
In general, if adding instructions makes a loop go faster, it's some sort of alignment (maybe code layout is a better term?) issue. Intel CPU's have limits on things like the number of branch predictions per fetch group, number of decoded instructions per fetch group, etc. Some x86 CPUs can only track 3 branches in a 16-byte group, while it might be possible to encode 4 in unusually dense code. In such cases, the extra branch just won't be tracked by the predictor. Seemingly superfluous code can force some of the branches into a different 16-byte group, improving overall performance. (This can't be the case here, because there's just one branch, but it can pop up in branch-heavy code).
EDIT: Someone makes in the comments makes a good point that in any case, both loops should fit in the loop buffer, which is after the u-op cache, and so wouldn't suffer from the 18-uop limit mentioned above.
Not necessarily. In the early days of the P6 core, I found a simple loop which would take either 4.0 or 5.33 cycles depending on the internal CPU state -- exactly the same code, but running with an iteration count of 10^6 would take either 4000000 cycles or 5333333 cycles.
I had the good fortune to talk to a member of the team which designed the P6 core, and we concluded that this was due to a quirk of the out-of-order scheduler: In order to reduce circuit depths, the mechanism for finding and dispatching runnable uops wouldn't always dispatch the earliest available uops, and so -- depending on how the incoming uops were aligned within an internal buffer -- the P6 core would sometimes take a stream of instructions which could be executed in-order without pipeline stalls and run them out-of-order with less ILP.
In short, OOO cores are weird and horribly complicated and completely untrustworthy where performance is concerned.
In order comes back during strange times. Sun switched back to in-order for Niagara, for instance.
Bottom line is you just have to think about GPUs in a slightly different way - they do have disadvantages (limited register/cache/memory per thread since you need so many of them), but things like branching aren't that big of a deal.
Yep. There's a great talk about this by Cliff Click, called A Crash Course in Modern Hardware[1] that I would recommend to everyone. I am no hardware expert, so that talk really enlightened me.
Regarding the issue at hand, I remember Doug Lea saying[2] that some new Intel processors may recognize a loop as the OSs idle loop and power down the core. That's why he computes random numbers in busy-wait loops.
[1] http://www.infoq.com/presentations/click-crash-course-modern...
[2] http://emergingtech.chariotsolutions.com/2013/04/phillyete-s...
Core2 had an 18 x86 instruction "loop stream detector".
Nehalem went to a 28 u-op (not instruction) LSD.
Sandy Bridge has a 1500 u-op "decoded instruction cache" plus a 28 u-op LSD.
Haswell is the same as SB, but doubles the u-op LSD to 56 if hyperthreading is disabled.
The confusing part is why there is still a tiny loop stream detector in addition to the much larger decoded iCache. Perhaps there is a small additional power savings for code that fits the smaller LSD?
But in general, you no longer have to worry about instruction decoding speed if your loops are less than 1500 u-ops. With some caveats: https://www-ssl.intel.com/content/dam/www/public/us/en/docum...
One interesting experiment to try would be to realign the loop to 0..31 mod 32, and see how/if the behavior changes.
Both loops should fit, but I think the same limits for micro-op density apply. The Optimization Manual says the Loop Stream Detector requires that "All micro-ops are also resident in the Decoded ICache". I took this to mean if something can't be cached by the Decoded ICache, it can't be streamed by the LSD.
Here is a version of the code that runs even faster than the asm("call foo") one by inserting nops between the load and the use of the loaded value. However it became even faster by inserting a few nops at the beginning of the loop too.
The fastest version that I could find is to do a prefetch of counter at the end of the loop .... speaking of which isn't that nopw that GCC uses for alignment a prefetch instruction too? Is the CPU tricked by the fake address used there?
void prefetch() {
unsigned j;
for (j = 0; j < N; ++j) {
counter += j;
__builtin_prefetch(&counter, 0, 3);
}
}
void nop_wait() {
unsigned j;
for (j = 0; j < N; ++j) {
unsigned x = counter;
/* 4 or more nops seem right, 3 nops are slower */
__asm__("nop");
__asm__("nop");
__asm__("nop");
__asm__("nop");
counter = x + j;
}
}
tightloop:
3,000,314,154 cycles # 0.000 GHz ( +- 0.01% )
2,400,971,094 instructions # 0.80 insns per cycle ( +- 0.00% )
0.788256283 seconds time elapsed ( +- 0.02% )
loop_with_call:
3,000,314,154 cycles # 0.000 GHz ( +- 0.01% )
2,400,971,094 instructions # 0.80 insns per cycle ( +- 0.00% )
0.788256283 seconds time elapsed ( +- 0.02% )
nopwait:
2,679,341,970 cycles # 0.000 GHz ( +- 0.37% )
4,000,906,874 instructions # 1.49 insns per cycle ( +- 0.00% )
0.704209471 seconds time elapsed
vs prefetch:
2,586,821,497 cycles # 0.000 GHz ( +- 0.54% )
2,800,888,975 instructions # 1.08 insns per cycle ( +- 0.00% )
0.679998564 seconds time elapsed
This is on a Intel(R) Core(TM) i7-2600 CPU @ 3.40GHzIn fact replacing the nopw that is used for alignment by a prefetch instruction gives me something slightly even faster:
00000000004004e0 <prefetchit>:
4004e0: 31 c0 xor %eax,%eax
4004e2: 0f 18 0d 87 04 20 00 prefetcht0 0x200487(%rip) # 600970 <counter>
4004e9: 48 8b 15 80 04 20 00 mov 0x200480(%rip),%rdx # 600970 <counter>
4004f0: 48 01 c2 add %rax,%rdx
4004f3: 48 83 c0 01 add $0x1,%rax
4004f7: 48 3d 00 84 d7 17 cmp $0x17d78400,%rax
4004fd: 48 89 15 6c 04 20 00 mov %rdx,0x20046c(%rip) # 600970 <counter>
400504: 75 dc jne 4004e2 <prefetchit+0x2>
400506: f3 c3 repz retq
400508: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
40050f: 00
2,445,505,116 cycles # 0.000 GHz ( +- 0.52% )
2,800,857,971 instructions # 1.15 insns per cycle ( +- 0.00% )
0.643010038 seconds time elapsed volatile unsigned dummy = 0;
void loop_dummy_read() {
IACA_START;
unsigned j;
unsigned dummy_read;
for (j = 0; j < N; ++j) {
dummy_read = dummy;
counter += j;
}
IACA_END;
}There are remarkably few memory accesses actually being performed in that loop. The CPU is using store forwarding to cache the 'memory' accesses in the store buffer, which means most accesses are not even accessing L1 cache (if this were the case, we would not have such a low count of cycles per iteration).
The best result I've got is by inserting a 6-byte nopw right before the store:
.L3:
movq counter(%rip), %rdx
addq %rax, %rdx
addq $1, %rax
cmpq $400000000, %rax
nopw 0x1(%rax,%rax,1)
movq %rdx, counter(%rip)
jne .L3That's a P4 thing, not a Core thing http://en.wikipedia.org/wiki/CPU_cache#Trace_cache
Edit: ok, Core has a uop cache http://en.wikipedia.org/wiki/Micro-operation_cache#Micro-ope...
You don't need a trace cache to do what you suggest, it just predicts the branch and goes from there
gcc -O2 -o main main.c
perf stat -r 10 -e cycles,instructions ./main t
Performance counter stats for './main t' (10 runs):
2,408,573,034 cycles # 0.000 GHz ( +- 0.07% )
2,401,351,221 instructions # 1.00 insns per cycle ( +- 0.00% )
0.723734145 seconds time elapsed ( +- 0.12% )
perf stat -r 10 -e cycles,instructions ./main c
Performance counter stats for './main c' (10 runs):
2,802,522,431 cycles # 0.000 GHz ( +- 0.00% )
3,201,523,974 instructions # 1.14 insns per cycle ( +- 0.00% )
0.842082646 seconds time elapsed ( +- 0.07% )I'm not familiar with the caching mechanism, but here's an educated guess. There are potential optimizations in the CISC -> RISC translation, according to the x86 opcode sequence (reorder operations in order to run some of them in parallel, for example), and it is possible to cache them, sparing cycles in the process, since the processor wouldn't have to analyze the code each time to perform the optimizations.
Edit: apparently, my guess was correct :-) Thanks to Symmetry for the confirmation.
--
[0] By modern, I mean "not ancient". The first processor to do that was the Pentium Pro.
Translating them takes energy, and can potentially be the limiting factor in how faster certain programs can be run. That means that recent Intel designs keep a small buffer of uOps that correspond to instructions it's likely to see again quickly. This can help a lot in loops.
clang acts CISCy -- use a read-modify-write instruction to add directly in memory.
By the way, read-modify-write is not fused altogether: only the write part is fused, so it generates 2 unfused uops (read, add), plus the fused uop for the write + address generation.
EDIT: Intel's compiler also generates read-modify-write code, but it ends up being slower than both gcc and clang.
$ perf stat -r 10 -e cycles,instructions ./loop t
Performance counter stats for './loop t' (10 runs):
2,631,020,892 cycles # 0.000 GHz ( +- 0.34% )
2,403,261,796 instructions # 0.91 insns per cycle ( +- 0.00% )
0.713682287 seconds time elapsed ( +- 0.39% )
$ perf stat -r 10 -e cycles,instructions ./loop c
Performance counter stats for './loop c' (10 runs):
2,237,200,035 cycles # 0.000 GHz ( +- 0.31% )
3,202,823,909 instructions # 1.43 insns per cycle ( +- 0.00% )
0.606741377 seconds time elapsed ( +- 0.29% )- tightloop() does nothing useful and is not realistic assembly code, so modern CPU optimisations aren't targeted for it
- loop_with_extra_call() does nothing useful either, but might look like a more realistic instruction sequence - the call could modify 'counter', making it appear to be a realistic sequence which the CPU optimisations are targeted for.
End result: the CPU is better designed for handling cases like loop_with_extra_call(). In the real world this makes realistic code faster, and this benchmark is just a weird quirk.
Disclaimer: I am not qualified enough to say I know what I'm talking about.
https://code.google.com/p/mao/wiki/NOPIN
The real question is whether any of these compiler heroics are actually any good or serve any purpose other than killing time and curiosity. No one knows how "optimized" NOP'ped code will fare on post-i7.
A modern Intel CPU is the most complex proprietary trade secret in the world. That's all Intel wants us to know.
My assembly is a little rusty but I would just assume that the processor is blocking on memory and the waits just add up.
My intuition is that this is more likely to be behavior related to the trace cache and rayiner's comment points out several good suggestions on what might be happening here.
On the other hand, given the likely difference in how these instructions may be represented in the trace cache, there may also be interactions between the order and groups of instructions dispatched from the trace cache and that may change how things occupy different slots in reservation stations. Which may change the amount of instruction level parallelism in the loop. It doesn't seem likely, but it may be the case.
The culprit is probably store-to-load forwarding. Memory ops cannot wait until the coast is clear like data ops do because the addresses are not known early enough in the pipeline, so they are dispatched well before it's even known that there will be a store-to-load hit. The system to maintain the memory model is very complex, not well understood outside Intel, and might conceivably include optimistic optimizations. In such cases, it's completely feasible that issuing another store in the pipeline might reduce the amount of work that is rolled back and needs to be redone.
Why? In Haswell, a store needs an AGU (ports 2, 3, or 7) and the store data unit (port 4). Even with perfect store-load forwarding, you can only write one value per cycle into the store buffer.