I was playing with loop unrolling, and decided to investigate it a
little. The basis is this very simple program which looks like it could
do with some loop unrolling.
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#define SIZE (1500 * 1024 * 1024)
int fn(int *buf) {
int i;
for(i=0; i < (SIZE/sizeof(int)); i+=1)
buf[i] = i;
return 0;
}
int main(void)
{
int *buf = malloc(SIZE);
assert(buf);
return fn(buf);
}
Firstly, lets have a look at what happens to the function when
-funroll-loops is applied. Below is the de-compilation with some
comments on what each line is doing.
Non-optimised
| nop.m 0x0
r14=0 | mov r14=r0
save the "loop counter" | mov.i r2=ar.lc
| nop.m 0x0
one less than iterations | movl r15=0x176fffff;;
| nop.m 0x0
initalise loop counter | mov.i ar.lc=r15
| nop.i 0x0;;
| loop:
*buf = i, buf++ | st4 [r32]=r14,4
i++ | adds r14=1,r14
dec lc, branch if > 0 | br.cloop.sptk.few .loop
| nop.m 0x0
restore loop counter | mov.i ar.lc=r2
Loop unrolled
| nop.m 0x0
r26 = 0 | mov r26=r0
save lc | mov.i r2=ar.lc
| nop.m 0x0
iter / 32 | movl r14=0x2edffff;;
| nop.m 0x0
setup loop counter | mov.i ar.lc=r14
| nop.i 0x0
| loop:
r3 = buf | mov r3=r32
r8 = 1 | adds r8=1,r26
r25 = 3 | adds r25=3,r26
r23 = buf[3] | adds r23=12,r32
r24 = 4 | adds r24=4,r26
r22 = buf[4] | adds r22=16,r32;;
buf[0] = 0; r3=buf+1 | st4 [r3]=r26,4
r21 = 5 | adds r21=5,r26
r19 = buf[5] | adds r19=20,r32
r20 = 6 | adds r20=6,r26
r18 = buf[6] | adds r18=24,r32
r16 = 7 | adds r16=7,r26;;
| nop.m 0x0
buf[1] = 1, r3=buf+2 | st4 [r3]=r8,4
r15 = buf[7] | adds r15=28,r32
r17 = 2 | adds r17=1,r8
r26 += 8 | adds r26=8,r26
buf += 8 (for next iter) | adds r32=32,r32;;
buf[2] = 2 | st4 [r3]=r17
buf[3] = 3 | st4 [r23]=r25
| nop.i 0x0
buf[4] = 4 | st4 [r22]=r24
buf[5] = 5 | st4 [r19]=r21
| nop.i 0x0
buf[6] = 6 | st4 [r18]=r20
buf[7] = 7 | st4 [r15]=r16
loop, dec branch | br.cloop.sptk.few .loop
| nop.m 0x0
restore lc | mov.i ar.lc=r2
| br.ret.sptk.many b0;;
We can see that in the first case the loop counter is setup to run
393216000 times (e.g. 1500*1024*1024/sizeof(int)) and a very simple
loop of storing the value and incrementing is undertaken. In the second
version, the code becomes more complex. We can see the loop now executes
49152000 times (e.g. 1500*1024*1024/sizeof(int)/8) so we can infer
that for each loop iteration, 8 values have been unrolled to be written
into our buf array. Indeed, when pulled apart we can see the 8
stores (numbers just reflect the first time through).
So, is it faster?
$ time ./loop
real 0m1.209s
user 0m0.388s
sys 0m0.820s
$ timme ./loop-unroll
real 0m1.056s
user 0m0.244s
sys 0m0.812s
Yes! Great. We can suggest a number of reasons why, but it would be
interesting to get some hard statistics on why this is so. Thanks to the
Itanium performance counters, we can. Firstly, lets start by seeing how
cycles are being spent.
$ pfmon -e cpu_cycles,ia64_inst_retired_this,nops_retired,back_end_bubble_all ./loop
547923609 CPU_CYCLES
1179995250 IA64_INST_RETIRED_THIS
104431 NOPS_RETIRED
154285534 BACK_END_BUBBLE_ALL
$ pfmon -e cpu_cycles,ia64_inst_retired_this,nops_retired,back_end_bubble_all ./loop-unroll
311570590 CPU_CYCLES
1327451286 IA64_INST_RETIRED_THIS
147560435 NOPS_RETIRED
16088889 BACK_END_BUBBLE_ALL
Now, the Itanium can issue up to six instructions
(IA64_INST_RETIRED_THIS) in two bundles of three instructions per
cycle (CPU_CYCLES). However, you can't have dependencies between
registers within a 3-instruction bundle (and branches and interruptions
play havoc) so sometimes you need to pad with no-ops (this is why you
need a good compiler). We can work out some figures from this, namely
the useful instructions per cycle (e.g. instructions retired - nop
instructions / cycles).
Test |
Useful instructions/cycle |
loop |
1179995250 - 104431 / 547923609 = 2.15 |
unroll |
1327451286 - 147560435 / 311570590 = 3.78 |
This tells us that for each given cycle, the unrolled-loop version is
doing more work (ideally we'd like that to be a the theoretical maximum
of 6). So what is the unrolled version doing for all this time? Bubbles
are where the CPU is waiting for something; a prime candidate is moving
data from caches to registers. Bubbles make up (154285534/547823609) 28%
of the cycles on the loop version, but only (16088889/311570590) 5% of
the time for the unrolled version. This means the processor is spending
a lot less time waiting for things to happen with the unrolled loop
version.
We can drill down further to see what bubbles are occurring.
$ pfmon -e be_l1d_fpu_bubble_all,be_exe_bubble_all,be_RSE_bubble_all,Back_end_bubble_fe ./loop
14229581 BE_L1D_FPU_BUBBLE_ALL
97669 BE_EXE_BUBBLE_ALL
1260 BE_RSE_BUBBLE_ALL
131354866 BACK_END_BUBBLE_FE
$ pfmon -e be_l1d_fpu_bubble_all,be_exe_bubble_all,be_RSE_bubble_all,Back_end_bubble_fe ./loop-unroll
22676169 BE_L1D_FPU_BUBBLE_ALL
83064 BE_EXE_BUBBLE_ALL
1263 BE_RSE_BUBBLE_ALL
718850 BACK_END_BUBBLE_FE
We are now breaking the stalls down to see where they are occuring.
L1D_FPU_BUBBLES are essentially related to micro-architectural issues
with the caches; things like the L1D cache being overwhelmend and
interactions with the hardware page-table walker. BE_EXE bubbles are
more simply described a waiting for data to come from caches (or even
main memory) to registers. BE_RSE bubbles are related to the register
stack engine, and "front end" bubbles (BACK_END_BUBBLE_FE) are where
there are not enough instructions coming from the "front-end" for the
"back-end" to issue out to functional units in the processor.
In this case, the major cause of problems for the simple loop seems to
be not being able to get enough instructions through to keep the
processor busy. We can try to pin down what is happening in the
front-end:
$ pfmon -e fe_bubble_imiss,fe_bubble_tlbmiss,fe_bubble_branch,fe_bubble_bubble ./loop
27727 FE_BUBBLE_IMISS
4910 FE_BUBBLE_TLBMISS
98094189 FE_BUBBLE_BRANCH
52615907 FE_BUBBLE_BUBBLE
$ pfmon -e fe_bubble_imiss,fe_bubble_tlbmiss,fe_bubble_branch,fe_bubble_bubble ./loop-unroll
780729 FE_BUBBLE_IMISS
4858 FE_BUBBLE_TLBMISS
291620 FE_BUBBLE_BRANCH
111089 FE_BUBBLE_BUBBLE
What we can see here is that the increased code size of the unrolled
version causes us a lot more i-cache misses, as expected. However, the
problem for the unrolled version seems to be the time spent by the
front-end dealing with the branch. This is not surprising since there is
a rumor that there is a front-end buffer which fills up after 3 repeated
cycles with branch instructions; apparently this is a problem addressed
in the new Montecito chips (if anyone has one, I'll update this if you
send me the numbers). Since we are doing 393216000 branches all within a
single bundle its not surprising we've hit it!
Just to confirm, we can break-down the BE_EXE bubbles. Remember,
BE_EXE bubbles are caused by stalls in the execution phase primarily
due to cache latencies or inter-register dependencies. Looking at our
code, we don't re-use the same register over-and-over so we would not
expect to spend a long time waiting for registers, and indeed this is
what we see.
$ pfmon -e be_exe_bubble_grall,be_exe_bubble_grgr ./loop
79601 BE_EXE_BUBBLE_GRALL
919 BE_EXE_BUBBLE_GRGR
$ pfmon -e be_exe_bubble_grall,be_exe_bubble_grgr ./loop-unroll
76627 BE_EXE_BUBBLE_GRALL
918 BE_EXE_BUBBLE_GRGR
Therefore in this case, the speed increase isn't coming from better
cache behaviour as we might have expected, but greater ability to keep
the processor busy with instructions between branches.
We can also examine the major events happening with the unrolled case.
Here we see BE_L1D_FPU bubbles being the major overhead. On Itanium
floating-point avoids the L1 cache (it is too big, and not re-used
enough to make it useful) hence the concatenation of the event acronym.
These bubbles can be broken down into the following events (measured in
two runs, since you can only do 4 at a time).
$ pfmon -e be_l1d_fpu_bubble_l1d,be_l1d_fpu_bubble_l1d_dcurecir,\
be_l1d_fpu_bubble_l1d_tlb,be_l1d_fpu_bubble_l1d_stbufrecir ./loop-unroll
9623193 BE_L1D_FPU_BUBBLE_L1D_DCURECIR
414 BE_L1D_FPU_BUBBLE_L1D_TLB
282 BE_L1D_FPU_BUBBLE_L1D_STBUFRECIR
$ pfmon -e be_l1d_fpu_bubble_l1d_fullstbuf,\
be_l1d_fpu_bubble_l1d_l2bpress,be_l1d_fpu_bubble_l1d_tlb ./loop-unroll
23 BE_L1D_FPU_BUBBLE_L1D_FULLSTBUF
17220177 BE_L1D_FPU_BUBBLE_L1D_L2BPRESS
425 BE_L1D_FPU_BUBBLE_L1D_TLB
These events expose micro-architectural details, the full extent of
which is only gleaned by reading the processor manuals (a PhD in
computer architecture probably helps too!). The store buffers are not an
issue here -- the caches are more than able to keep up with our few
writes. The TLB is also not involved; we have pretty good TLB coverage
thanks to the virtual-linear page table and hardware page-table walker.
The two clues are DCURECIR and L2BPRESS. Essentially DCURECIR happens
when references have to "re-circulate"; this is a side-effect of the
data not being available. The L2BPRESS figure suggests why this is so;
this event happens when the L2 cache can not handle any more requests.
The L2 cache keeps a queue of outstanding requests; when this queue is
full (for example, full of long latency requests to get data from main
memory) the L2 cache causes "back-pressure" which stops the L1 cache
from issuing new requests. This suggests the program is chewing through
a lot of data and not using the cache very effectively, which is exactly
what we are doing. Pre-fetching hints can help (but as described in a
previous entry on hacking
Enigma may not
always help), but in this case there probably isn't any spare bandwidth
to pre-fetch in because essentially no processing is happening.
What does all this mean? I don't know, but playing with performance
monitors is fun!