Assembly and Low-Level Language Fundamentals Questions
Programming and debugging at the instruction level. Covers reading and writing assembly for x86-64 and ARM (A32, Thumb, AArch64), including small hand-written routines, vector (SIMD) code and exclusive load/store atomics, registers, the stack and frame layout, prologues and epilogues, calling conventions and ABIs (System V, Microsoft x64, AAPCS), variadic calls, inline assembly and its constraints and clobbers, Cortex-M exception entry and context-switch code written in assembly, and how compilers translate and optimize source into machine code (optimization flags, inlining, tail calls, LTO, aliasing, strength reduction, virtual dispatch, memcpy lowering, stack spills). Also covers object files and linking as they affect generated code (ELF, PE/COFF and Mach-O, relocations, GOT and PLT, position-independent code, static linking, stack unwinding), the compiler backend ideas behind it (SSA, register allocation, instruction selection, peephole passes) and emitting machine code from a minimal JIT. On the debugging side: reading disassembly, using gdb and lldb for registers, frames, breakpoints and watchpoints, analyzing core dumps and stripped binaries with addr2line and build IDs, and diagnosing crashes from instruction-level state such as corrupted returns, stack smashing, ABI mismatches and optimizer-induced bugs. Debugging method in general, hardware probe tooling, malware analysis and exploit-mitigation design are covered elsewhere.
How does a compiler decide whether to inline a call, and what are the costs and benefits of doing so? How would LTO or profile-guided optimization change those decisions?
Sample Answer
Direct answer
Inlining replaces a call with a copy of the callee's body at the call site. The compiler decides with a cost model: it estimates how much code size and compile time the copy adds against how much it saves (the call and return, argument shuffling, and the chance to optimize the body with the caller's known values), and it compares the result with size limits, adjusted for how hot the call is. Benefits: no call overhead, and constants and dead branches can be folded across the old boundary (folded means evaluated at compile time once the argument values are visible). Costs: larger code (more instruction-cache pressure, meaning the small fast memory next to the CPU that holds recently used instructions has to hold more, so more fetches miss it and go to slower memory; and larger binaries), longer compiles, and less faithful debugging and profiling. Link-time optimization (LTO) removes the "other file" barrier, so calls across source files become candidates. Profile-guided optimization (PGO) replaces guessed call frequencies with measured ones, so hot call sites inline more readily and cold ones stay as calls.
How the decision is made
For each call site the compiler checks, roughly in this order:
- Can it? The body must be visible (same file, or LTO), the callee not marked
noinline, and no property such as an address use or unusual calling pattern that prevents it. A function markedalways_inline(a GCC attribute that demands inlining) skips the cost model. - How much does it grow the code? Small bodies, a function called from only one place, or a body that shrinks once the arguments are known are cheap. A
staticfunction with a single caller can be absorbed and its out-of-line copy dropped. - How hot is the site? (A hot site is one that runs very often, a cold site one that rarely or never runs.) A call inside a loop is worth more than one on an error path. Without a profile this is a guess from loop depth and branch heuristics.
- Budget. GCC reports the limits it applied. In its units (an internal estimate of instructions, not exact machine instructions), the defaults on the GCC 14.4 used here, as printed by
gcc --help=params -Q, are--param early-inlining-insns= 6 at-O2(14 at-O3) for the first, early inlining pass, and--param max-inline-insns-auto= 15 at-O2(30 at-O3) for functions not markedinline. A call is refused when the growth it would cause is above the limit.
A worked case uses the PGO source below, where mix is deliberately a long chain of arithmetic so that it is not tiny. -fopt-info-inline-all on it printed will not early inline: main/23->mix/22, growth 26 exceeds --param early-inlining-insns and not inlinable: main/23 -> mix/22, --param max-inline-insns-auto limit reached: inlining mix would grow main by 26, which is above both -O2 limits (6 and 15). So at -O2 without a profile all three calls to mix stay calls. At -O3 the same source, with limits of 14 and 30 (26 is below the 30 for the main pass), inlined the call inside the loop and left two calls to mix (the two under the if tests). Building -O2 with only those two parameters raised to -O3's values also left two calls. The only change between these builds is the size budget, which shows the size limit is the deciding quantity for a function this size.
The inline keyword is only a hint in C and C++; static inline mostly affects linkage rules (static gives each source file its own private copy of the function, so a definition in a header does not cause duplicate-symbol errors at link time), not the decision.
LTO: inlining across files
Two files, mathlib.c and app.c:
/* mathlib.c */
int clamp_add(int a, int b)
{
int s = a + b;
return s > 255 ? 255 : s;
}
/* app.c */
#include <stdio.h>
int clamp_add(int a, int b);
int main(void)
{
int acc = 0;
for (int i = 0; i < 1000; i++)
acc = clamp_add(acc, i & 3);
printf("%d\n", acc);
return 0;
}
Built as gcc -O2 -c mathlib.c app.c and linked, objdump -d on main (GCC 14.4, AArch64 container) shows the call kept: bl 4006e0 <clamp_add> followed by bl 400500 <printf@plt> (the addresses depend on the link layout) (printf@plt is a small stub in the executable that jumps to the C library's printf). Built with gcc -O2 -flto -c ... and linked with -flto, main contains only the printf call: clamp_add was inlined into the loop. Both binaries print 255. Without LTO the compiler compiled app.c without ever seeing the body of clamp_add, so it could not copy it.
PGO: measured hotness
Profile-guided optimization is a two-build process: first build an instrumented program and run it on a representative input, and the run writes a profile (a .gcda file of counts: how many times each function and branch executed); then build again with the profile as input. mix below is a deliberately large function (ten rounds of shifts, xors and multiplies) so that it is above the default size limit, which makes the effect of the profile visible. The loop call is the hot site, and the two calls under if (argc > ...) are cold sites that never run in the training run.
#include <stdio.h>
#include <stdlib.h>
static unsigned mix(unsigned x)
{
x ^= x >> 15; x *= 0x2c1b3c6dU;
x ^= x >> 12; x *= 0x297a2d39U;
x ^= x >> 15; x += 0x9e3779b9U;
x ^= x << 7; x *= 0x85ebca6bU;
x ^= x >> 13; x *= 0xc2b2ae35U;
x ^= x >> 11; x *= 0x7feb352dU;
x ^= x >> 15; x *= 0x846ca68bU;
x ^= x >> 15; x *= 0x2c1b3c6dU;
x ^= x >> 12; x *= 0x297a2d39U;
return x ^ (x >> 16);
}
int main(int argc, char **argv)
{
unsigned h = 1;
for (unsigned i = 0; i < 1000000; i++) /* hot call site */
h += mix(h + i);
if (argc > 1) /* cold call sites */
h += mix((unsigned)atoi(argv[1]));
if (argc > 2)
h += mix((unsigned)atoi(argv[2]));
printf("%u\n", h);
return 0;
}
The sequence. The profile file is named after the output file and source (here app-mix_hot_cold.gcda), so the final build uses the same -o app and source name and GCC finds it. -fprofile-correction tells GCC to repair counts that do not add up, which happens when a threaded program updates the counters without locking:
gcc -O2 -o app mix_hot_cold.c -> 3 calls to mix in main
gcc -O2 -fprofile-generate -o app mix_hot_cold.c && ./app -> prints 1578539220, writes the profile
gcc -O2 -fprofile-use -fprofile-correction -o app mix_hot_cold.c -> 2 calls to mix in main
With the profile, -fopt-info-inline-optimized printed mix_hot_cold.c:22:14: optimized: Inlined mix/27 into main/23 which now has time 34000018.000000 and size 64, net change of +26. Line 22 is the loop call. Read the message as: mix/27 and main/23 are GCC's internal numbers for the two functions; after inlining, main has an estimated size of 64 units (so it was 64 - 26 = 38 before), net change of +26 is the growth, the same 26 that the cost model refused at -O2, and time 34000018.000000 is GCC's estimate of main's running time in its own units. It is dominated by the million loop iterations, which is why it is millions here, where the same build without a profile reports a time of only a few thousand (3402.12 at -O3); GCC does not document how it splits the figure per iteration, so read it as a relative estimate and not as cycles. The profile is what tells GCC that the loop really runs that many times; the same growth that was too much for an unknown call site is acceptable for a site the profile shows to be hot. The two cold sites on lines 24 and 26 remain bl instructions to mix. The profile run executed the loop a million times and the cold branches never, so the compiler spent code size where the time is. The run was on AArch64; the call counts come from objdump -d app.
Trade-offs and what flips the choice
- Optimizing for size (
-Os) keeps the same parameter limits as-O2(15 and 6 here) but treats every call site as cold, so an inline that grows the code is refused: on the PGO source above-Osprintedcall is cold and code would grow by 26for each of the three sites, and all three stayed calls. A firmware build with a flash limit should check the final size after raising optimization, because inlining a function into many call sites multiplies its size. - Hot loop, small function: inline. This is the case that justifies it, and it is where
always_inlineis reasonable. - Large function called from many places: keep the call. Duplicated bodies grow the instruction footprint, which can slow the program through instruction-cache misses even though each call is cheaper.
- Debugging and profiling: inlined code has no stack frame of its own, so a profiler attributes its time to the caller unless it reads the inline debug information.
- PGO only helps if the training run resembles production. A profile from an unrepresentative input marks the wrong sites hot, and the build then optimizes the wrong paths.
- LTO increases link time and memory use; the benefit is largest for small helper functions in other files, as
clamp_addshows.
Look at the assembly generated for a C++ virtual method call. What in the instructions gives away the virtual dispatch, and what does it cost compared with a direct call?
Sample Answer
Direct answer
A virtual call gives itself away by two loads before the call, and an indirect call through a register or memory operand instead of a call to a named symbol. The first load reads the object's hidden vptr (virtual table pointer, stored at offset 0 of any object with virtual methods). The second reads a function pointer out of the vtable (the per-class array of function pointers) at a fixed offset for that method. The call then goes through that loaded address. A direct call has a symbol name in the instruction (call foo, bl foo, or a jmp foo tail call, a jump used instead of a call when the callee is the last thing the function does) and needs no loads.
The listing
struct Shape {
virtual ~Shape();
virtual double area() const;
virtual int sides() const;
double plain_area() const;
};
double call_virtual(const Shape *p) { return p->area(); }
double call_direct(const Shape *p) { return p->plain_area(); }
double call_second_slot(const Shape *p) { return p->sides(); }
Compiled with g++ -O2 -S -masm=intel (GCC 14.4, x86-64, run in an emulated linux/amd64 container; assembler directives and the .LFB/.LFE function-boundary labels removed, otherwise as emitted):
_Z12call_virtualPK5Shape:
mov rax, QWORD PTR [rdi]
jmp [QWORD PTR [rax+16]]
_Z11call_directPK5Shape:
jmp _ZNK5Shape10plain_areaEv
_Z16call_second_slotPK5Shape:
sub rsp, 8
mov rax, QWORD PTR [rdi]
call [QWORD PTR [rax+24]]
pxor xmm0, xmm0
add rsp, 8
cvtsi2sd xmm0, eax
ret
The same source with the same flags minus -masm=intel (GCC 14.4 on AArch64; that option is x86-only) gives the following for the first two functions (call_second_slot is left out here; directives and the .LFB/.LFE labels are removed as before):
_Z12call_virtualPK5Shape:
ldr x1, [x0]
ldr x1, [x1, 16]
mov x16, x1
br x16
_Z11call_directPK5Shape:
b _ZNK5Shape10plain_areaEv
How to read it. The first listing is x86-64 in Intel syntax (destination operand first, QWORD PTR [x] means the 8-byte value at address x); the second is AArch64 (destination first too; ldr x1, [x0] loads 8 bytes from the address in x0). On x86-64 the first argument, this, arrives in rdi (on AArch64, x0). mov rax, [rdi] loads the vptr. jmp [QWORD PTR [rax+16]] jumps to the address stored in memory at rax+16: an indirect branch, one whose target is data rather than a name in the instruction. [rax+16] is the vtable slot for area; the slot for sides is [rax+24]. On AArch64 the same two steps are the two ldr lines, then mov x16, x1; br x16 is the register-indirect jump. In call_second_slot, pxor xmm0, xmm0 zeroes the floating-point result register and cvtsi2sd xmm0, eax converts the integer returned by sides to double.
Those offsets are not arbitrary. The vtable that GCC emits (_ZTV5Shape, with the three functions defined in the same file) is six 8-byte entries: 0, the typeinfo pointer, the two destructors, area, sides. The block below is a hand-drawn diagram of those entries and of the object, not tool output:
vtable for Shape object of type Shape
+0 0 (offset-to-top) [ vptr ] -----> points at vtable +16
+8 typeinfo for Shape [ members ... ]
+16 ~Shape (complete) <-- vptr points here
+24 ~Shape (deleting)
+32 Shape::area
+40 Shape::sides
The vptr does not point at the start of the table: it points 16 bytes in, past the offset-to-top entry (used for multiple inheritance) and the typeinfo pointer (used by RTTI and dynamic_cast), at the first virtual function. Slots are therefore counted from the vptr: ~Shape complete destructor at +0, deleting destructor at +8, then area at +16 and sides at +24. The two destructor slots come first because the destructor is declared first. Both call_virtual and call_direct are tail calls (jmp / b, not call), so the only visible differences are the two loads and the indirect operand. call_second_slot shows the full-size call, with the return converted to double.
What it costs compared with a direct call
Compared with a direct call the virtual call adds:
- Two dependent loads (object to vptr, vptr to slot) before the target address is known. Both are usually L1 cache hits (the L1 cache is the smallest, fastest cache next to the core) for a hot class, but an object that was not recently touched makes the first load a likely cache miss, and the vtable itself is shared per class so it is normally hot.
- An indirect branch. The CPU's branch predictor (hardware that guesses a branch's target before it is known so the pipeline can keep fetching) guesses the target; if one call site sees one or two concrete types it predicts well, and if it sees many types in a pseudo-random order (a mixed array of 20 entity kinds) it mispredicts and pays a pipeline flush (the work started on the wrong path is thrown away and fetching restarts).
- Lost inlining, which is usually the largest cost. A direct call to a small function can be inlined and then optimized with its caller (constants folded, loads hoisted, loops vectorized). A call through the vtable usually cannot be, because the callee is unknown at compile time.
No cycle count is given here, because it depends on the core, the cache state and how predictable the call site is; measure on the target (for example the CPU of the device you ship on) with a benchmark that mixes types the way the real workload does.
When the instructions look different
Devirtualization means turning a virtual call into a direct one. The compiler can do it when it can see the dynamic type. Using final (a C++ keyword that forbids further overriding or deriving) on the derived class, or having the object's real type visible, produces a direct call or none at all. In a separate file, with Shape::area declared pure virtual and one derived class Square final : Shape visible in the same file:
struct Shape {
virtual ~Shape();
virtual double area() const = 0;
virtual int sides() const;
double plain_area() const;
};
struct Square final : Shape {
double s;
double area() const override { return s * s; }
};
double via_base(const Shape *p) { return p->area(); }
double via_final(const Square *p) { return p->area(); }
GCC 14.4 at -O2 (AArch64, directives and .LFB/.LFE labels removed, otherwise as emitted; only via_base is shown, and the full output also contains an out-of-line copy of Square::area before it and via_final after it) compiled via_base(const Shape*) as a guarded speculative devirtualization (the compiler guesses the likely target, checks the guess at run time, and falls back to the indirect call if it is wrong); g++ -O2 -S -masm=intel on x86-64 (GCC 14.4) gives the same shape, with mov rax, QWORD PTR [rdi], mov rax, QWORD PTR [rax+16], cmp rax, OFFSET FLAT:_ZNK6Square4areaEv and jne in place of the two ldr lines, cmp x3, x2 and bne, and a final jmp rax for the fall-back path:
_Z8via_basePK5Shape:
ldr x3, [x0]
adrp x2, _ZNK6Square4areaEv
add x2, x2, :lo12:_ZNK6Square4areaEv
ldr x3, [x3, 16]
cmp x3, x2
bne .L8
ldr d0, [x0, 8]
fmul d0, d0, d0
ret
.L8:
mov x16, x3
br x16
adrp plus add ... :lo12: builds the address of Square::area (the page address, then the low 12 bits of the symbol). Here the loads are still there, but the loaded slot is compared with the address of Square::area; if equal, the body is inlined (fmul), otherwise it falls back to the indirect br. A cmp of a loaded pointer against a function address is the signature of this guarded form. The guard appeared because, with area pure in Shape, the only implementation of area the compiler could see was Square::area. GCC's -fdevirtualize-speculatively (the GCC manual lists the flag among those -O2 turns on, so -O3 and -Os include it, and -O1 does not; on AArch64 this same file compiled to the guarded form at -O2 and -O3 but to the plain unguarded two-load indirect call at -O1 and at -Os, also with the flag named explicitly, and the manual says a speculative call that looks useless after further optimization is converted back into the original form) looks at the type inheritance graph, determines the set of likely targets, and, if the set is small, preferably of size 1, turns the call into a conditional between a direct and an indirect call. With the non-pure Shape::area of the first listing (defined in another file) the same Square test kept the plain two-load indirect call with no guard. For a Square * or Square & with final, via_final became just movsd xmm0, QWORD PTR [rdi+8] then mulsd xmm0, xmm0 on x86-64 (the same Intel-syntax build), with no vtable access.
Practical ways to reduce the cost
- Mark leaf classes and methods
finalso calls through them devirtualize. - Sort or batch objects by concrete type so one call site sees one type at a time (predictor friendly) and loops can inline.
- Use link-time optimization (
-flto) so the compiler sees all derived classes across files and can devirtualize. - For hot inner loops, replace per-object virtual calls with a data-oriented layout (arrays per type processed in a tight loop).
RTTI (run-time type information, the typeinfo pointer at +8 in the vtable table above) and exception metadata add binary size but not per-call dispatch cost; they matter if you build with -fno-rtti / -fno-exceptions for size.
Explain the meaning and practical effects of common compiler optimization flags: -O0, -O1, -O2, -O3, -Os, and -Ofast. For each, describe typical trade-offs in compilation time, code size, debuggability, floating-point semantics, and opportunities for transformations such as inlining and vectorization.
Sample Answer
Direct answer
The -O flags choose how many optimization passes the compiler runs and what it is willing to trade. -O0 does almost none (fastest compile, best debugging). -O1 and -O2 add safe transformations, with -O2 the usual release setting. -O3 adds more aggressive loop and cloning work that can grow code. -Os is -O2 tuned for size. Vectorization, mentioned throughout, means using SIMD (single instruction, multiple data) instructions that work on several values at once; on x86-64 these are the SSE instructions on 128-bit xmm registers, each holding four 32-bit floats or ints. IEEE here means IEEE 754, the standard that fixes how floating-point numbers are rounded, which is why strict builds give predictable sums. -Ofast is -O3 plus options that break strict IEEE and ISO C floating-point rules (fast math), so it can change numeric results. For a release build, use -O2 (or -Os when flash or cache is the constraint) and reach for -O3 or -Ofast only after measuring and, for -Ofast, after checking your numerics.
The levels side by side
Facts in this table come from the GCC manual's Optimize Options page and from running GCC 14.4 (x86-64, emulated under linux/amd64; -Q --help=optimizers lists which passes a level enables).
| Flag | Compile time and memory | Code size | Debuggability | Floating-point semantics | Inlining and vectorization |
|---|---|---|---|---|---|
| -O0 | lowest (the default) | largest per line of source | variables live in memory, one source line maps to a contiguous block; you can change a variable or the program counter and get what the source says | strict IEEE | none: even a one-line static function is a real call |
| -O1 | somewhat more, "a lot more memory for a large function" per the manual | smaller than -O0 (1223 against 1379 text bytes in the example below) | good, but variables may be held only in registers | strict | functions called once are inlined, and so are tiny functions whose body is no bigger than a call (a static helper called twice still disappeared at -O1 in a test); the wider small-function inlining pass (-finline-small-functions) is off until -O2 |
| -O2 | more again | close to -O1 (1252 text bytes in the example below, slightly larger) | stepping jumps between lines, values may show as optimized out | strict | small functions inlined, interprocedural register allocation (the caller keeps values in registers a called function is known not to touch), vectorizer with its "very-cheap" cost model (the vectorizer's most cautious setting) |
| -O3 | most of the -O levels | larger (loop unrolling, peeling, cloning) | worst of the -O levels | strict | most aggressive loop vectorization |
| -Os | like -O2 | smallest | like -O2 | strict | -O2 minus the options that often increase code size, which the manual lists as -falign-* (these insert padding bytes so functions, loops and jumps start at aligned addresses), -fprefetch-loop-arrays and -freorder-blocks-algorithm=stc; GCC 14.4's -Q --help=optimizers also shows -ftree-loop-vectorize, -ftree-slp-vectorize and -funroll-loops off at -Os (on at -O2), while -finline-small-functions stays on, so there is no auto-vectorization here; functions called once still inline |
| -Ofast | like -O3 | largest here | like -O3 | relaxed: -ffast-math and friends | like -O3, plus it may reorder floating-point sums to vectorize them |
These flag lists are for reading, not for setting by hand: you normally choose the level and leave the individual passes alone. Passes enabled at -O3 and not at -O2 in GCC 14.4 include -floop-interchange (swap nested loops to walk memory in a cache-friendly order), -funswitch-loops (move a loop-invariant if out of the loop), -fpeel-loops (copy the first few iterations out of a loop), -fipa-cp-clone (clone a function so a constant argument can be propagated into the copy), -ftree-loop-distribution (split one big loop into several) and -fsplit-loops. -floop-unroll-and-jam (unroll an outer loop and fuse the copies of the inner loop) is also on at -O3 but not -O2. The unrolling, peeling and cloning in these lists are why -O3 code is larger. Passes -Ofast switches on beyond -O3 (the full -Q difference also includes -funsafe-math-optimizations, -fcx-limited-range and -fexcess-precision=fast) include the fast-math group: -fassociative-math (re-associate operands, so sums may be added in another order), -ffinite-math-only (assume no NaN or infinity), -freciprocal-math (use a reciprocal instead of dividing), -fno-signed-zeros (ignore the sign of zero), -fno-trapping-math (assume floating-point operations raise no user-visible traps), -fno-math-errno (do not set errno after single-instruction math functions such as sqrt) and -fallow-store-data-races (allow optimizations that may introduce new data races on stores). Of these, -fassociative-math is the one that changes the sum in the example below.
Worked example
The test program, compiled once per flag (add -fno-asynchronous-unwind-tables -fcf-protection=none for tidy listings):
#include <stdio.h>
static int square(int x) { return x * x; }
__attribute__((noinline)) int sum_sq(const int *a, int n)
{
int s = 0;
for (int i = 0; i < n; i++)
s += square(a[i]);
return s;
}
__attribute__((noinline)) float sum_f(const float *a, int n)
{
float s = 0.0f;
for (int i = 0; i < n; i++)
s += a[i];
return s;
}
int main(int argc, char **argv)
{
(void)argv;
int n = 4 * argc + 4; /* 8 when run with no arguments */
float f[8] = {1e8f, 1, 1, 1, -1e8f, 1, 1, 1};
int v[8] = {1, 2, 3, 4, 5, 6, 7, 8};
printf("sum_sq = %d\n", sum_sq(v, n));
printf("sum_f = %g\n", sum_f(f, n));
return 0;
}
Sizes come from size (the text column covers the whole program including the C startup code common to every build), and objdump -d on the result:
| Flag | text bytes | call square remains | SSE vector instructions in sum_sq | printed sum_f |
|---|---|---|---|---|
| -O0 | 1379 | yes | no | 3 |
| -O1 | 1223 | no | no | 3 |
| -O2 | 1252 | no | no | 3 |
| -O3 | 1519 | no | yes (17 xmm references) | 3 |
| -Os | 1142 | no | no | 3 |
| -Ofast | 1607 | no | yes | 6 |
The xmm count is the number of times objdump -d shows an xmm register inside sum_sq; any nonzero count means the compiler emitted SIMD code, and it says nothing about speed. A sum_sq loop at -O3 starts like this (Intel syntax, abridged): movdqu xmm0, XMMWORD PTR [rax] loads four ints, a few pmuludq/pshufd instructions square them four at a time, and paddd xmm2, xmm1 adds the four squares into four running totals, while add rax, 16 moves 16 bytes (four ints) per iteration.
sum_sq prints 204 at every level, as it must for integers. sum_f shows what -Ofast really changes. The exact sum is 6 (1e8 minus 1e8 plus six ones). Strict left-to-right float addition gives 3, because 1e8 plus 1 rounds back to 1e8 in single precision, losing three of the ones. With -ffast-math (so also -Ofast) the compiler reorders the additions into four vector lanes. The -Ofast loop of sum_f is movups xmm2, XMMWORD PTR [rax] (load four floats), add rax, 16, addps xmm0, xmm2 (add them lane by lane into four running totals). With the eight inputs 1e8, 1, 1, 1, -1e8, 1, 1, 1 the first pass puts 1e8, 1, 1, 1 in the lanes and the second adds -1e8, 1, 1, 1, so the lanes end as 1e8 - 1e8 = 0, 1 + 1 = 2, 2 and 2. Adding the lanes together gives 0 + 2 + 2 + 2 = 6: the big values cancel before any one is added to a small one, so no ones are lost. Neither answer is "wrong" under its flag; the second simply is not the sum the language rules define, and the difference depends on the order the compiler picks.
Debugging the same function in gdb on AArch64 (GDB 16.3), successive next commands at -O0 went through lines 7, 8 and 9 of the source, one line per step. At -O2 the breakpoint landed on line 8, the first next went to line 9, and the second went to line 3, the body of the inlined square, because there is no separate function any more. That is the practical debuggability cost of inlining: step and breakpoint locations stop matching what you wrote.
How to choose
- Daily debugging:
-Og -g(-Og optimizes while keeping the debugging experience in mind, and the manual calls it the level of choice for the edit-compile-debug cycle;-gadds debug information) or -O0 when you need every variable. - Release for servers and desktop: -O2. It adds nearly every optimization that does not trade space for speed, per the manual.
- Flash or instruction-cache limited firmware: -Os, then check that timing-critical loops still meet their deadlines.
- Numeric kernels: try -O3 and measure; do not assume it is faster, because the larger code can lose to -O2 on cache.
- -Ofast only for code where reassociation and the loss of NaN and infinity checks (the ISO C standard's floating-point rules, which -Ofast relaxes) are acceptable, with a test that compares results against a strict build.
Pitfalls
Code with undefined behaviour (signed overflow, out-of-bounds reads, uninitialized variables) often works at -O0 and fails at -O2 or -O3, because the optimizer is allowed to assume the behaviour never occurs; a bug that "disappears" at -O0 is a clue, not a fix. -ffast-math also assumes values are finite, so isnan style checks can be removed. And compile time is a property of the whole build: the manual's statement is that -O1 costs somewhat more time than -O0 and -O2 more again, not a fixed multiplier.
A hot loop is slow because the compiler cannot prove two pointers do not overlap, so it reloads memory every iteration. How would you confirm that in the disassembly, what are your options, and what is the risk of promising the compiler there is no overlap?
Sample Answer
Direct answer
Look at the loop body in the disassembly: if a value that never changes (the loop bound, a scale factor, a base pointer) is loaded from memory again on every iteration, between the stores, the compiler is treating each store as a possible write to that value. That situation is called aliasing: two pointers that might refer to the same memory. The cheapest fix is to read the value once into a local variable. The stronger fix is restrict (a promise that two pointers never refer to overlapping memory), which also unlocks loop transformations such as vectorization (using SIMD instructions that process several array elements per instruction). The risk is that restrict is a promise to the compiler and not a check: if a caller breaks it, behaviour is undefined, and the failure looks like a wrong result that only appears at -O2.
Reproducing the symptom
The loop bound is read through a pointer, and the loop stores through another int *:
/* Fill a[0..*n) with v. The bound is read through a pointer. */
void fill(int *a, const int *n, int v) {
for (int i = 0; i < *n; i++)
a[i] = v;
}
/* Same loop, with the bound read once into a local. */
void fill_local(int *a, const int *n, int v) {
int count = *n;
for (int i = 0; i < count; i++)
a[i] = v;
}
/* Same loop, promising that a and n do not overlap. */
void fill_restrict(int *restrict a, const int *restrict n, int v) {
for (int i = 0; i < *n; i++)
a[i] = v;
}
gcc -O2 -S fill.c (GCC 14.4, aarch64, native; assembler directives and the .LFB/.LFE function-boundary labels removed, everything else as emitted). The listing is AArch64 assembly with the destination operand first: x0, x1 and w2 hold the arguments a, n and v (x registers are 64-bit, w registers their lower 32 bits), ldr w3, [x1] loads the 4-byte *n, str w2, [x0, x3, lsl 2] stores v at a + i*4 (lsl 2 multiplies the index by 4), cmp compares, and ble/bgt branch on signed less-or-equal or greater-than:
fill:
ldr w3, [x1]
cmp w3, 0
ble .L1
mov x3, 0
.L3:
str w2, [x0, x3, lsl 2]
add x3, x3, 1
ldr w4, [x1]
cmp w4, w3
bgt .L3
.L1:
ret
The loop .L3 (a loop-invariant load, that is a load of a value that does not change across iterations, would normally be hoisted out) contains ldr w4, [x1]: x1 is the n pointer, and the bound is re-read after every str. The reason is that a[i] = v stores an int through a, and nothing tells the compiler a and n are different objects, so the store could have changed *n. Note that const int *n does not help: const only forbids writing through that one pointer, it says nothing about other pointers to the same memory. fill_local and fill_restrict from the same file compile to a tight loop with no reload (add x1, x0, w1, uxtw 2 computes the end address a + count*4, with uxtw zero-extending the 32-bit count, and str w2, [x0], 4 stores and then advances x0 by 4):
fill_local:
ldr w1, [x1]
cmp w1, 0
ble .L6
add x1, x0, w1, uxtw 2
.L8:
str w2, [x0], 4
cmp x0, x1
bne .L8
.L6:
ret
(fill_restrict compiles to the identical instructions with different label numbers.) How to confirm in your own build: disassemble the function (objdump -d, or gcc -S, or gdb disassemble /s), find the loop's back-edge branch (the branch at the bottom that jumps back to the top), and check whether an ldr from a loop-invariant pointer register sits inside it; then rebuild the experiment with the value copied to a local and see whether the load leaves the loop.
Options, with the cost of each
- Copy the value into a local before the loop (
int count = *n;). It is portable, needs no promise, and defines the behaviour as "read once". Use this first. It changes the meaning only for callers that deliberately modify*nduring the loop. - Add
restrict(C99;__restrictin C++ with GCC, Clang and MSVC) on the pointers that never overlap. It also helps loops where the compiler would otherwise have to guess, but it is a contract written into the function's interface, so document it and test callers. - Do nothing and let the compiler version the loop. For two float pointers GCC 14.4 at
-O3emits a runtime overlap check and two loops, as in thisscale.c:
void scale(float *dst, const float *src, float k, int n) {
for (int i = 0; i < n; i++)
dst[i] = src[i] * k;
}
gcc -O3 -c -fopt-info-vec-all scale.c reports loop vectorized using 16 byte vectors and loop versioned for vectorization because of possible aliasing. In the gcc -O3 -S scale.c output (not reproduced in full here) the check is sub x3, x0, #4, sub x3, x3, x1, cmp x3, 8, bls .L3, which branches to a scalar (one element at a time) loop when the buffers might overlap; the cost is extra code size and a few instructions per call. Loop versioning means emitting two copies of the loop, a fast one and a safe one, and choosing at run time. The check computes dst - 4 - src in bytes and compares it, as an unsigned number, with 8, so it is true when dst - src is anywhere from 4 to 12 bytes; for 4-byte-aligned float pointers that means exactly 4, 8 or 12 bytes, that is dst is 1, 2 or 3 floats ahead of src. In that case a vector step that loads four floats before storing four would read values that the one-at-a-time loop would already have overwritten. The 4 is one float, and the 8 is the width of the unsafe range (12 - 4). If dst is behind src, equal to it, or 16 or more bytes ahead, the subtraction wraps to a huge unsigned value or exceeds 8, and the vector loop is safe. (Separately, the instructions sub w3, w2, #1; cmp w3, 4; bls .L3 at the top of the same listing send arrays of 5 or fewer elements straight to the scalar loop, because w3 is n - 1 compared as an unsigned number.)
4. Make the call site visible: with inlining or LTO (link-time optimization) the compiler can see that two arguments are distinct objects and drop the reload itself.
5. Restructure the interface: separate input and output buffers, or pass the bound by value (int n instead of const int *n) so there is nothing to alias.
Ordered by what they ask of callers: options 1, 5 and 4 need no promise from anyone and are the first to try; option 3 needs nothing from the source but costs code size; option 2 is the only one that makes a promise callers must keep, so it comes last.
The risk of promising "no overlap"
If the promise is false, the compiler is entitled to assume it anyway. This program calls the three functions with n pointing inside the array being filled (n == &a[3], and a[3] starts at 10, so filling a[3] changes the bound). The aliasing is chosen so the bug shows: the bound 10 is large enough that the loop reaches index 3, and the store at i = 3 writes v = 0 over *n, so a loop that re-reads *n stops after four stores while one that read it once continues to ten:
#include <stdio.h>
void fill(int *a, const int *n, int v);
void fill_local(int *a, const int *n, int v);
void fill_restrict(int *restrict a, const int *restrict n, int v);
static void show(const char *name, const int *a) {
printf("%-14s", name);
for (int i = 0; i < 12; i++) printf(" %d", a[i]);
printf("\n");
}
int main(void) {
int a[12];
/* n deliberately points INTO the array being filled: n == &a[3], and a[3] starts at 10. */
for (int i = 0; i < 12; i++) a[i] = 7;
a[3] = 10; fill(a, &a[3], 0); show("fill", a);
for (int i = 0; i < 12; i++) a[i] = 7;
a[3] = 10; fill_local(a, &a[3], 0); show("fill_local", a);
for (int i = 0; i < 12; i++) a[i] = 7;
a[3] = 10; fill_restrict(a, &a[3], 0); show("fill_restrict", a);
return 0;
}
Built with gcc -O2 -Wall fill.c fill_main.c -o fill_demo, it printed:
fill 0 0 0 0 7 7 7 7 7 7 7 7
fill_local 0 0 0 0 0 0 0 0 0 0 7 7
fill_restrict 0 0 0 0 0 0 0 0 0 0 7 7
fill obeys the source: it overwrites a[3], which is *n, with 0, so the loop stops after four stores. fill_local reads the bound once (10) and so fills ten elements, which is what its source says. fill_restrict fills ten elements because the compiler read the bound once, as the promise allowed; the call violates restrict, so the behaviour is undefined, and this output is only what GCC 14.4 at -O2 produced. A different compiler or flag could print something else. The same code gives a different answer than the unannotated version, with no warning, and may differ between -O0 and -O2.
Ways to keep it safe: put restrict only where the contract is obvious and documented (an input and an output buffer), assert non-overlap in debug builds, run the test suite at -O2 and -O3 as well as -O0, and prefer the local-copy fix when the gain is only the removed load.
Where this matters
In game and signal-processing inner loops the reload and the lost vectorization can be costly; measure with a profiler on the target, since the number of cycles depends on the core. In embedded code with memory-mapped registers the opposite rule holds: a register read must happen on every iteration, so those pointers are volatile, which obliges the compiler to perform every access (a volatile read stays inside the loop even when the pointer is also restrict; GCC 14.4 at -O2 keeps the ldr in the loop for such a function). restrict is the wrong tool there because it is a promise about aliasing, not about whether an access must happen: on a register pointer that is not volatile it would help the compiler prove nothing else writes the register and read it once outside the loop.
That is every published Assembly and Low-Level Language Fundamentals question for Game Developer so far. Browse the other topics in this category, or practice this one interactively.