How do i know if the compiler will optimize a variable?

Viewed 306

I am new to Microcontrollers. I have read a lot of Articles and documentations about volatile variables in c. What i understood, is that while using volatile we are telling the compiler not to cache either to optimize the variable. However i still didnt get when this should really be used.
For example let's say i have a simple counter and for loop like this.

for(int i=0; i < blabla.length; i++) {
    //code here
}

or maybe when i write a simple piece of code like this

int i=1; 
int j=1;
printf("the sum is: %d\n" i+j);

I have never cared about compiler optimization for such examples. But in many scopes if the variable is not declared as volatile the ouptut wont be as expected. How would i know that i have to care about compiler optimization in other examples?

3 Answers

Simple example:

int flag = 1;

while (flag)
{
   do something that doesn't involve flag
}

This can be optimized to:

while (true)
{
   do something
}

because the compiler knows that flag never changes.

with this code:

volatile int flag = 1;

while (flag)
{
   do something that doesn't involve flag
}

nothing will be optimized, because now the compiler knows: "although the program doesn't change flag inside the while loop, it might changed anyway".

According to cppreference:

volatile object - an object whose type is volatile-qualified, or a subobject of a volatile object, or a mutable subobject of a const-volatile object. Every access (read or write operation, member function call, etc.) made through a glvalue expression of volatile-qualified type is treated as a visible side-effect for the purposes of optimization (that is, within a single thread of execution, volatile accesses cannot be optimized out or reordered with another visible side effect that is sequenced-before or sequenced-after the volatile access. This makes volatile objects suitable for communication with a signal handler, but not with another thread of execution, see std::memory_order). Any attempt to refer to a volatile object through a non-volatile glvalue (e.g. through a reference or pointer to non-volatile type) results in undefined behavior.

This explains why some optimizations can’t be made by the compiler since it can’t entirely predict when its value will be modified at compile-time. This qualifier is useful to indicate to the compiler that it shouldn’t do these optimizations because its value can be changed in a way unknown by the compiler.

I have not worked recently with microcontrollers but I think that the states of different electrical input and output pins have to be marked as volatile since the compiler doesn’t know that they can be changed externally. (In this case by means other than code like when you plug-in a component).

Just try it. First off there is the language and what is possible to be optimized and then there is what the compiler actual figures out and optimizes, if it can be optimized does not mean the compiler will figure it out nor will it always produce the code you think.

Volatile has nothing to do with caching of any kind, did not we just get this question recently using that term? Volatile indicates to the compiler that the variable should not be optimized into a register or optimized away. Let us say "all" accesses to that variable must go back to memory, although different compilers have a different understanding of how to use volatile, I have seen clang (llvm) and gcc (gnu) disagree, when the variable was used twice in a row or something like that clang didnt do two reads it only did one.

It was a Stack Overflow question you are welcome to search for it, the clang code was slightly faster than gcc, simply because of one less instruction because of differences of opinion of how to implement volatile. So even there the main compiler folks can't agree on what it really means. Its the nature of the C language, lots of implementation defined features and pro tip, avoid them volatile, bitfields, unions, etc, certainly across compile domains.

void fun0 ( void )
{
    unsigned int i;
    unsigned int len;
    len = 5;
    for(i=0; i < len; i++)
    {
    }
}

00000000 <fun0>:
   0:   4770        bx  lr

This is completely dead code, it does noting it touches nothing, all the items are local, so it can all go away, simply return.

unsigned int fun1 ( void )
{
    unsigned int i;
    unsigned int len;
    len = 5;
    for(i=0; i < len; i++)
    {
    }
    return i;
}
00000004 <fun1>:
   4:   2005        movs    r0, #5
   6:   4770        bx  lr

This one returns something, the compiler can figure out it is counting and the last value after the loop is what gets returned....so just return that value, no need for variables or any other code generation, the rest is dead code.

unsigned int fun2 ( unsigned int len )
{
    unsigned int i;
    for(i=0; i < len; i++)
    {
    }
    return i;
}
00000008 <fun2>:
   8:   4770        bx  lr

Like fun1 except the value is passed in in a register, just happens to be the same register as the return value for the ABI for this target. So you do not even have to copy the length to the return value in this case, for other architectures or ABIs we would hope that this optimizes to return = len and that gets sent back. A simple mov instruction.

unsigned int fun3 ( unsigned int len )
{
    volatile unsigned int i;
    for(i=0; i < len; i++)
    {
    }
    return i;
}
0000000c <fun3>:
   c:   2300        movs    r3, #0
   e:   b082        sub sp, #8
  10:   9301        str r3, [sp, #4]
  12:   9b01        ldr r3, [sp, #4]
  14:   4298        cmp r0, r3
  16:   d905        bls.n   24 <fun3+0x18>
  18:   9b01        ldr r3, [sp, #4]
  1a:   3301        adds    r3, #1
  1c:   9301        str r3, [sp, #4]
  1e:   9b01        ldr r3, [sp, #4]
  20:   4283        cmp r3, r0
  22:   d3f9        bcc.n   18 <fun3+0xc>
  24:   9801        ldr r0, [sp, #4]
  26:   b002        add sp, #8
  28:   4770        bx  lr
  2a:   46c0        nop         ; (mov r8, r8)

it gets significantly different here, that is a lot of code compared to the ones thus far. We would like to think that volatile indicates all uses of that variable touch the memory for that variable.

  12:   9b01        ldr r3, [sp, #4]
  14:   4298        cmp r0, r3
  16:   d905        bls.n   24 <fun3+0x18>

get i and compare it to len is it less than? we are done exit loop

  18:   9b01        ldr r3, [sp, #4]
  1a:   3301        adds    r3, #1
  1c:   9301        str r3, [sp, #4]

i was less than len so we need to increment it, read it, change it, write it back.

  1e:   9b01        ldr r3, [sp, #4]
  20:   4283        cmp r3, r0
  22:   d3f9        bcc.n   18 <fun3+0xc>

do the i < len test again, see if it is less than or greater than and loop again or do not.

24: 9801        ldr r0, [sp, #4]

get i from ram so it can be returned.

All reads and writes of i involved the memory that holds i. Because we asked for that now the loop is not dead code each iteration has to be implemented in order to handle all the touches of that variable on memory.

void fun4 ( void )
{
    unsigned int a;
    unsigned int b;
    a = 1;
    b = 1;
    fun3(a+b);
}
0000002c <fun4>:
  2c:   2300        movs    r3, #0
  2e:   b082        sub sp, #8
  30:   9301        str r3, [sp, #4]
  32:   9b01        ldr r3, [sp, #4]
  34:   2b01        cmp r3, #1
  36:   d805        bhi.n   44 <fun4+0x18>
  38:   9b01        ldr r3, [sp, #4]
  3a:   3301        adds    r3, #1
  3c:   9301        str r3, [sp, #4]
  3e:   9b01        ldr r3, [sp, #4]
  40:   2b01        cmp r3, #1
  42:   d9f9        bls.n   38 <fun4+0xc>
  44:   9b01        ldr r3, [sp, #4]
  46:   b002        add sp, #8
  48:   4770        bx  lr
  4a:   46c0        nop         ; (mov r8, r8)

this both optimized out the addition and the a and b variables but also optimized by inlining the fun3 function.

void fun5 ( void )
{
    volatile unsigned int a;
    unsigned int b;
    a = 1;
    b = 1;
    fun3(a+b);
}
0000004c <fun5>:
  4c:   2301        movs    r3, #1
  4e:   b082        sub sp, #8
  50:   9300        str r3, [sp, #0]
  52:   2300        movs    r3, #0
  54:   9a00        ldr r2, [sp, #0]
  56:   9301        str r3, [sp, #4]
  58:   9b01        ldr r3, [sp, #4]
  5a:   3201        adds    r2, #1
  5c:   429a        cmp r2, r3
  5e:   d905        bls.n   6c <fun5+0x20>
  60:   9b01        ldr r3, [sp, #4]
  62:   3301        adds    r3, #1
  64:   9301        str r3, [sp, #4]
  66:   9b01        ldr r3, [sp, #4]
  68:   429a        cmp r2, r3
  6a:   d8f9        bhi.n   60 <fun5+0x14>
  6c:   9b01        ldr r3, [sp, #4]
  6e:   b002        add sp, #8
  70:   4770        bx  lr

Also fun3 is inlined, but the a variable is read from memory every time instead of being optimized out

  58:   9b01        ldr r3, [sp, #4]
  5a:   3201        adds    r2, #1


void fun6 ( void )
{
    unsigned int i;
    unsigned int len;
    len = 5;
    for(i=0; i < len; i++)
    {
        fun3(i);
    }
}
00000074 <fun6>:
  74:   2300        movs    r3, #0
  76:   2200        movs    r2, #0
  78:   2100        movs    r1, #0
  7a:   b082        sub sp, #8
  7c:   9301        str r3, [sp, #4]
  7e:   9b01        ldr r3, [sp, #4]
  80:   3201        adds    r2, #1
  82:   9b01        ldr r3, [sp, #4]
  84:   2a05        cmp r2, #5
  86:   d00d        beq.n   a4 <fun6+0x30>
  88:   9101        str r1, [sp, #4]
  8a:   9b01        ldr r3, [sp, #4]
  8c:   4293        cmp r3, r2
  8e:   d2f7        bcs.n   80 <fun6+0xc>
  90:   9b01        ldr r3, [sp, #4]
  92:   3301        adds    r3, #1
  94:   9301        str r3, [sp, #4]
  96:   9b01        ldr r3, [sp, #4]
  98:   429a        cmp r2, r3
  9a:   d8f9        bhi.n   90 <fun6+0x1c>
  9c:   3201        adds    r2, #1
  9e:   9b01        ldr r3, [sp, #4]
  a0:   2a05        cmp r2, #5
  a2:   d1f1        bne.n   88 <fun6+0x14>
  a4:   b002        add sp, #8
  a6:   4770        bx  lr

This one I found interesting, could have been optimized better, based on my gnu experience kind of confused, but as pointed out, this is how it is, you can expect one thing but the compiler does what it does.

  9c:   3201        adds    r2, #1
  9e:   9b01        ldr r3, [sp, #4]
  a0:   2a05        cmp r2, #5

The i variable in the fun6 function is put on the stack for some reason, it is not volatile it does not desire that kind of access every time. But that is how they implemented it.

If I build with an older version of gcc I see this

9c: 3201 adds r2, #1 9e: 9b01 ldr r3, [sp, #4] a0: 2a05 cmp r2, #5

Another thing to note is that gnu at least is not getting better every version, it has been at times getting worse, this is a simple case.

void fun7 ( void )
{
    unsigned int i;
    unsigned int len;
    len = 5;
    for(i=0; i < len; i++)
    {
        fun2(i);
    }
}
0000013c <fun7>:
 13c:   e12fff1e    bx  lr

Okay too extreme (no surprise in the result), let us try this

void more_fun ( unsigned int );
void fun8 ( void )
{
    unsigned int i;
    unsigned int len;
    len = 5;
    for(i=0; i < len; i++)
    {
        more_fun(i);
    }
}

000000ac <fun8>:
  ac:   b510        push    {r4, lr}
  ae:   2000        movs    r0, #0
  b0:   f7ff fffe   bl  0 <more_fun>
  b4:   2001        movs    r0, #1
  b6:   f7ff fffe   bl  0 <more_fun>
  ba:   2002        movs    r0, #2
  bc:   f7ff fffe   bl  0 <more_fun>
  c0:   2003        movs    r0, #3
  c2:   f7ff fffe   bl  0 <more_fun>
  c6:   2004        movs    r0, #4
  c8:   f7ff fffe   bl  0 <more_fun>
  cc:   bd10        pop {r4, pc}
  ce:   46c0        nop         ; (mov r8, r8)

No surprise there it chose to unroll it because 5 is below some threshold.

void fun9 ( unsigned int len )
{
    unsigned int i;
    for(i=0; i < len; i++)
    {
        more_fun(i);
    }
}
000000d0 <fun9>:
  d0:   b570        push    {r4, r5, r6, lr}
  d2:   1e05        subs    r5, r0, #0
  d4:   d006        beq.n   e4 <fun9+0x14>
  d6:   2400        movs    r4, #0
  d8:   0020        movs    r0, r4
  da:   3401        adds    r4, #1
  dc:   f7ff fffe   bl  0 <more_fun>
  e0:   42a5        cmp r5, r4
  e2:   d1f9        bne.n   d8 <fun9+0x8>
  e4:   bd70        pop {r4, r5, r6, pc}

That is what I was looking for. So in this case the i variable is in a register (r4) not on the stack as shown above. The calling convention for this says r4 and some number of others after it (r5,r6,...) must be preserved. This is calling an external function which the optimizer can't see, so it has to implement the loop so that the function is called that many times with each of the values in order. Not dead code.

Textbook/classroom implies that local variables are on the stack, but they do not have to be. i is not declared volatile so instead take a non-volatile register, r4 save that on the stack so the caller does not lose its state, use r4 as i and the callee function more_fun either will not touch it or will return it as it found it. You add a push, but save a bunch of loads and stores in the loop, yet another optimization based on the target and the ABI.

Volatile is a suggestion/recommendation/desire to the compiler that it have an address for the variable and perform actual load and store accesses to that variable when used. Ideally for use cases like when you have a control/status register in a peripheral in hardware that you need all of the accesses described in the code to happen in the order coded, no optimization. As to a cache that is independent of the language you have to setup the cache and the mmu or other solution so that control and status registers do not get cached and the peripheral is not touched when we wanted it to be touched. Takes both layers you need to tell the compiler to do all the accesses and need to not block those accesses in the memory system.

Without volatile and based on the command line options you use and the list of optimizations the compiler has been programmed to attempt to perform the compiler will try to perform those optimizations as they are programmed in the compilers code. If the compiler can't see into a calling function like more_fun above because it is not in this optimization domain then the compiler must functionally represent all the calls in order, if it can see and inlining is allowed then the compiler can if programmed to do so essentially pull the function inline with the caller THEN optimize that whole blob as if it were one function based on other available options. Not uncommon to have the callee function be bulky because of its nature, but when specific values are passed by a caller and the compiler can see all of it the caller plus callee code can be smaller than the callee implementation.

You will often see folks wanting to for example learn assembly language by examining the output of a compiler do something like this:

void fun10 ( void )
{
    int a;
    int b;
    int c;
    a = 5;
    b = 6;
    c = a + b;
}

not realizing that that is dead code and should be optimized out if an optimizer is used, they ask a Stack Overflow question and someone says you need to turn the optimizer off, now you get a lot of loads and stores have to understand and keep track of stack offsets and while it is valid asm code you can study it is not what you were hoping for, instead something like this is more valuable to that effort

unsigned int fun11 ( unsigned int a, unsigned int b )
{
    return(a+b);
}

The inputs are unknown to the compiler and a return value is required so it can't dead code this it has to implement it.

And this is a simple case of demonstrating the caller plus callee is smaller than the callee

000000ec <fun11>:
  ec:   1840        adds    r0, r0, r1
  ee:   4770        bx  lr

000000f0 <fun12>:
  f0:   2007        movs    r0, #7
  f2:   4770        bx  lr

While that may not look simpler it has inlined the code, it has optimized out the a = 3, b = 4 assignments, optimized out the addition operation and simply pre-computed the result and returned it.

Certainly with gcc you can cherry pick the optimizations you want to add or block there is a laundry list of them that you can go research.

With very little practice you can see what is optimizable at least within the view of the function but then hope the compiler figures it out. Certainly visualizing inline takes more work but really it is the same you just visually inline it.

Now there are ways with gnu and llvm to optimize across files, basically whole project so more_fun would be visible now and the functions that call it might get further optimized than what you see in the object of the one file with the caller. Takes certain command lines on the compile and/or link for this to work and I have not memorized them. With llvm there is a way to merge bytecode and then optimize that, but it does not always do what you hoped it would do as far as a whole project optimization.

Related