Differences Between Compiler Optimization Levels -O0 and -O1 in ARMv8

This article explores the differences between the compiler optimization levels <span><span>-O0</span></span> and <span><span>-O1</span></span> under the ARMv8 architecture. While the core concepts are consistent with architectures like x86, there are ARM-specific behaviors in terms of register usage, instruction set features, and more.

Overview of ARMv8 Architecture

  • Rich Register Set: ARMv8 has 31 general-purpose registers <span><span>X0-X30</span></span> (64-bit) or <span><span>W0-W30</span></span> (32-bit), providing a solid foundation for register optimization.

  • Load/Store Architecture: Data operations must be performed in registers and cannot be done directly in memory. Therefore, memory access optimization is crucial.

  • Calling Conventions: For example, <span><span>X0-X7</span></span> are used for passing parameters and return values, while <span><span>X19-X29</span></span> are callee-saved registers, etc. Optimization will affect the usage strategy of these registers.

Core Differences Comparison (ARMv8 Perspective)

Feature <span><span>-O0</span></span> (Debug Mode) <span><span>-O1</span></span> (Optimization Mode) Specific Performance in ARMv8
Register Usage Conservative, frequent memory exchanges Aggressive, maximizing register utilization <span><span>-O0</span></span> frequently uses <span><span>str</span></span>/<span><span>ldr</span></span>; <span><span>-O1</span></span> allows variables to reside longer in <span><span>X0-X15</span></span> and other temporary registers.
Stack Frame Management Complete, each function has a clear stack frame Simplified or Omitted <span><span>-O0</span></span> strictly executes <span><span>stp x29, x30, [sp, #-48]!</span></span> and other instructions to set up the stack frame; <span><span>-O1</span></span> may omit the stack frame for leaf functions.
Instruction Selection Direct, Redundant Efficient, Streamlined <span><span>-O0</span></span> may use multiple simple instructions; <span><span>-O1</span></span> will use more powerful composite instructions (e.g., <span><span>stp</span></span>/<span><span>ldp</span></span> to access two registers simultaneously).
Function Calls Strictly follows calling conventions Tail call optimization, limited inlining <span><span>-O0</span></span> uses <span><span>bl</span></span> for all calls; <span><span>-O1</span></span> may optimize tail calls to <span><span>b</span></span> instructions or inline small functions.
Debug Support Perfect Fundamentally Unavailable <span><span>-O0</span></span> ensures every C/C++ variable has a memory location; <span><span>-O1</span></span> variables are only in registers, with disordered sequences.

Technical Analysis and ARM Assembly Example

Let’s compare with a simple C code example.

C Code (test.c):

int calculate(int a, int b) {
    int c = a + b;
    int d = c * 2;
    return d - 1;
}

Compiled with <span><span>-O0</span></span> (GCC)

Using the command <span><span>aarch64-linux-gnu-gcc -S -O0 test.c</span></span> to generate assembly.

Characteristics of the Generated Assembly Code:

calculate:
    // 1. Complete stack frame setup
    stp     x29, x30, [sp, -32]! // Push frame pointer (x29) and return address (x30) onto the stack, allocating 32 bytes of stack space
    mov     x29, sp              // Set new frame pointer

    // 2. Save all parameters from registers to stack memory (for debugger inspection)
    str     w0, [x29, 28]        // Store parameter 'a' (w0) to stack [sp+28]
    str     w1, [x29, 24]        // Store parameter 'b' (w1) to stack [sp+24]

    // 3. Perform calculations: frequently exchanging data between registers and memory
    ldr     w0, [x29, 28]        // Load 'a' from stack to w0
    ldr     w1, [x29, 24]        // Load 'b' from stack to w1
    add     w0, w0, w1           // w0 = a + b
    str     w0, [x29, 20]        // Store result 'c' to stack [sp+20]

    // 4. Similarly redundant loading to calculate d
    ldr     w0, [x29, 20]        // Load 'c' from stack to w0
    lsl     w0, w0, 1            // w0 = c * 2 (logical left shift by 1)
    str     w0, [x29, 16]        // Store result 'd' to stack [sp+16]

    // 5. Prepare return value
    ldr     w0, [x29, 16]        // Load 'd' from stack to w0
    sub     w0, w0, 1            // w0 = d - 1

    // 6. Complete stack frame recovery
    ldp     x29, x30, [sp], 32   // Restore frame pointer and return address, reclaim stack space
    ret                          // Return

<span><span>-O0</span></span> Summary:

  • Memory Operation Intensive: Even the simplest calculations involve a lot of <span><span>str</span></span>/<span><span>ldr</span></span> instructions.

  • Complete Stack Frame: Facilitates stack backtracking at any point in the function.

  • Logical Clarity: The sequence of assembly instructions corresponds closely to the lines of C code.

Compiled with <span><span>-O1</span></span>

Using the command <span><span>aarch64-linux-gnu-gcc -S -O1 test.c</span></span> to generate assembly.

Characteristics of the Generated Assembly Code:

calculate:
    // 1. No stack frame operations! Because this is a simple leaf function, no extra stack space is needed, nor does it call other functions.

    // 2. All calculations are done in registers in one go
    add     w0, w0, w1      // w0 = a + b (parameters are already in w0, w1)
    add     w0, w0, w0      // w0 = c * 2 (using addition w0 + w0 instead of shifting)
    sub     w0, w0, 1       // w0 = d - 1

    // 3. Directly return. The return value is already in w0.
    ret

<span><span>-O1</span></span> Summary:

  • Registers Rule: The entire function has no memory accesses. Parameters <span><span>a</span></span> and <span><span>b</span></span> remain in <span><span>w0</span></span> and <span><span>w1</span></span> throughout, with all intermediate results also circulating in <span><span>w0</span></span>.

  • Stack Frame Omitted: As this is a leaf function and does not need to save state, stack frame operations are directly omitted, saving instructions and execution time.

  • Instruction Fusion and Simplification: The compiler recognizes that <span><span>c * 2</span></span> can be efficiently completed using <span><span>add w0, w0, w0</span></span>.

  • Code Extremely Streamlined: From 12 instructions (<span><span>-O0</span></span>) optimized to only 3 instructions (<span><span>-O1</span></span>), resulting in significant improvements in performance and code size.

Other Common Optimizations in ARMv8 <span><span>-O1</span></span>

  1. Dead Code Elimination: Similar to other architectures, code that will never be executed is removed.

  2. Constant Propagation and Folding:

    // C code
    int x = 5;
    int y = x * 10;

    <span><span>-O1</span></span> will directly compute to <span><span>int y = 50;</span></span>, and in assembly, it may be implemented with a single <span><span>mov w0, 50</span></span> instruction.

  3. Tail Call Optimization:

    int tail_function(int n) {
        if (n == 0) return 1;
        return tail_function(n - 1); // Tail call
    }

    <span><span>-O1</span></span> may optimize this into a loop, avoiding the stack overhead of recursive calls. In assembly, the recursive call <span><span>bl tail_function</span></span> will be replaced with a branch instruction after adjusting parameters <span><span>b tail_function</span></span>.

  4. Limited Function Inlining: Very small functions (like <span><span>getter</span></span>) will be inlined at the call site, eliminating the overhead of <span><span>bl</span></span> instructions.

Conclusion

In the ARMv8 architecture, the transition from <span><span>-O0</span></span> to <span><span>-O1</span></span> is a Qualitative Change:

  • <span><span>-O0</span></span> produces code for programmers and debuggers to view, ensuring absolute logical transparency at the cost of poor performance. Its ARM assembly is filled with structured but redundant stack operations and memory accesses.

  • <span><span>-O1</span></span> produces code for the CPU to execute, fully utilizing the rich register resources and streamlined instruction set of ARMv8. By eliminating redundant memory accesses, simplifying stack frames, and optimizing instruction selection, it greatly enhances execution efficiency while maintaining compilation speed.

Therefore, in ARM embedded or server development, the development phase must use <span><span>-O0</span></span> to ensure debuggability, while at release, at least <span><span>-O1</span></span> must be used to achieve basic performance guarantees. For performance-sensitive applications, <span><span>-O2</span></span> or <span><span>-Os</span></span> (size optimization) is often adopted.

Finally, I recommend an online C language to assembly code conversion website: https://godbolt.org/. You can compare any C code you think of online and see the differences between C and assembly.

Here is a comparison of the differences between O0 and O1:

O0 C Language and Assembly Comparison

Differences Between Compiler Optimization Levels -O0 and -O1 in ARMv8O1 C Language and Assembly ComparisonDifferences Between Compiler Optimization Levels -O0 and -O1 in ARMv8

Leave a Comment