Assembly (x86-64, AArch64, RISC-V) Module 11 – Reading compiler output
Optimisation and undefined behaviour in assembly
What -O2 does to C, read in AArch64, x86-64 and RISC-V listings: inlining, tail calls, unrolled and vectorised loops, and checks undefined behaviour deletes.
What you will learn
- Identify inlining, a tail call, an unrolled loop and a vectorised loop in an -O2 listing
- Explain why an overflow test written as x + n < x compiles to a single shift at -O2
- Read the main loop, the reduction and the leftover loops of a vectorised loop on three ISAs
- Detect and fix checks that undefined behaviour lets an optimising compiler delete
- Choose between restrict, a loop pragma, intrinsics and hand-written assembly for a hot loop
Before you start
What you’ll learn · Before you start
What you will learn
- Identify inlining, a tail call, an unrolled loop and a vectorised loop in an -O2 listing
- Explain why an overflow test written as x + n < x compiles to a single shift at -O2
- Read the main loop, the reduction and the leftover loops of a vectorised loop on three ISAs
- Detect and fix checks that undefined behaviour lets an optimising compiler delete
- Choose between restrict, a loop pragma, intrinsics and hand-written assembly for a hot loop
Before you start
On this page
At -O0, Clang compiles sum_squares in opt.c with two calls of a helper; at -O2 the calls are gone, and a
multiply and a multiply-add remain. The same switch turns x + n < x, a test for overflow, into one shift that never reads x, and
for x = INT_MAX - 50 and n = 100 the -O2 build answers 0 where the -O0 build answered 1. Reading optimisation and
undefined behaviour in assembly means asking what the compiler was allowed to assume.
Optimisation shows as code that is missing, moved or repeated; undefined behaviour, as a test that is not there.
An optimised listing shows four fingerprints
At -O0 each function of opt.c keeps the shape of its source; at -O2 each one changed in a way you can learn to
spot:
// opt.c: three small functions whose -O2 listings show what the optimiser did to them
// square is static, so no other file can call it: -O2 is free to copy its body into the caller.
static int square(int x) { return x * x; }
int sum_squares(int a, int b) { return square(a) + square(b); }
// scale is defined elsewhere; calling it is the last thing forward does.
int scale(int x);
int forward(int x) { return scale(x + 1); }
// A loop whose trip count, 3, is known while compiling.
int dot3(const int *a, const int *b) {
int s = 0;
for (int i = 0; i < 3; i++)
s += a[i] * b[i];
return s;
} import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
# opt.c at -O0 for AArch64 Linux: the labels and every call (bl) in the listing
clang --target=aarch64-linux-gnu -O0 -S -fno-asynchronous-unwind-tables -o opt_O0.s opt.c
grep -E '^[a-z_0-9]+:|[[:space:]]bl[[:space:]]' opt_O0.s
# The same file at -O2, with directives and comments filtered out
clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o opt_O2.s opt.c
grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*//' opt_O2.s | sed -E 's/[[:space:]]+\\/\\/.*$//'
# sum.c at -O2: the lines of its listing that use vector registers (q0 to q31, v0 to v31)
clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o sum_O2.s sum.c
grep -E '[[:space:]](q|v)[0-9]+' sum_O2.s
""") Output
# opt.c at -O0 for AArch64 Linux: the labels and every call (bl) in the listing $ clang --target=aarch64-linux-gnu -O0 -S -fno-asynchronous-unwind-tables -o opt_O0.s opt.c $ grep -E '^[a-z_0-9]+:|[[:space:]]bl[[:space:]]' opt_O0.s sum_squares: // @sum_squares bl square bl square square: // @square forward: // @forward bl scale dot3: // @dot3 # The same file at -O2, with directives and comments filtered out $ clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o opt_O2.s opt.c $ grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*//' opt_O2.s | sed -E 's/[[:space:]]+\/\/.*$//' sum_squares: mul w8, w0, w0 madd w0, w1, w1, w8 ret forward: add w0, w0, #1 b scale dot3: ldp w8, w11, [x1] ldp w9, w10, [x0] ldr w12, [x1, #8] mul w8, w8, w9 ldr w9, [x0, #8] madd w8, w11, w10, w8 madd w0, w12, w9, w8 ret # sum.c at -O2: the lines of its listing that use vector registers (q0 to q31, v0 to v31) $ clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o sum_O2.s sum.c $ grep -E '[[:space:]](q|v)[0-9]+' sum_O2.s movi v0.2d, #0000000000000000 movi v1.2d, #0000000000000000 movi v2.2d, #0000000000000000 movi v3.2d, #0000000000000000 ldp q4, q5, [x8, #-32] ldp q6, q7, [x8], #64 add v0.4s, v4.4s, v0.4s add v1.4s, v5.4s, v1.4s add v2.4s, v6.4s, v2.4s add v3.4s, v7.4s, v3.4s add v0.4s, v1.4s, v0.4s add v0.4s, v2.4s, v0.4s add v0.4s, v3.4s, v0.4s addv s0, v0.4s movi v0.2d, #0000000000000000 mov v0.s[0], w8 ldr q1, [x8], #16 add v0.4s, v1.4s, v0.4s addv s0, v0.4s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fingerprints_session.py
| Fingerprint | Function | What the -O2 listing shows |
|---|---|---|
| Inlining | sum_squares |
no bl: the callee’s multiply in its place |
| Tail call | forward |
b scale instead of bl then ret |
| Full unrolling | dot3 |
the body three times, no branch back |
| Vectorisation | sum |
q and v registers in a loop over int |
inlining: Replacing a call with a copy of the called function's body, so that the call, the return and the passing of arguments disappear and the copy is optimised together with its caller. copied square into sum_squares: mul w8, w0, w0 squares a and
madd w0, w1, w1, w8 squares b and adds, so no call is left, and square, being static, keeps no copy of its
own. forward ends in b scale because scale’s result is its result: a tail call: A call that is the last thing a function does, compiled as a plain jump (b, jmp or RISC-V's tail) instead of a call and a return. The caller's return address stays in place, so the callee returns straight to the caller's caller. More in “Calls, returns and the link register”, after
which scale returns straight to forward’s caller. dot3’s trip count, 3, is known while compiling, so
loop unrolling: Writing a loop's body out several times so that each pass does the work of several iterations. A loop whose trip count is known while compiling can be unrolled completely, leaving no counter and no branch back. wrote the body out three times, with no counter and no branch back.
In sum.c at plain -O2, a loop that adds 32-bit integers loads 128-bit q registers and adds with
add v0.4s, v4.4s, v0.4s, four additions in one instruction: auto-vectorisation: A compiler rewriting a loop written one element at a time so that each pass uses vector instructions on several elements, when it can prove the result is the same and judges that it pays..
A vectorised loop has a wide main body, a step that adds the lanes of a register together, and loops for the leftover
elements. Its x86-64 and RISC-V forms differ in width and in how they finish. Whether a vector loop pays is a
compiler’s cost-model decision, and two compilers can decide differently.
Four tools change what a hot loop compiles to. restrict promises the compiler that two
pointers never reach the same elements. A loop pragma asks the vectoriser for a width or an interleave count.
Intrinsics let C name vector instructions directly. Hand-written assembly replaces the compiler’s code for one
function.
Checkpoint
Check: What -O2 leaves in a listing
Undefined behaviour lets the compiler delete a check
add_would_overflow tests for overflow the way the maths reads: adding a non-negative n can only make the sum
smaller than x by wrapping round. But C makes signed overflow undefined behaviour: What the C standard calls an operation for which it sets no requirements at all, such as dividing by zero; a compiler may assume a correct program never performs one. More in “Division: traps, defined results, remainders”: a program whose x + n does not fit in an int has no defined meaning, so the compiler may assume it
never happens. Here is the function at -O2 on three ISAs, then run on Apple silicon at -O0 and -O2:
// overflow.c: an overflow test written the way it reads in maths, for a non-negative n: adding such an n can only
// make the sum smaller than x by wrapping round. But signed overflow is undefined behaviour in C.
int add_would_overflow(int x, int n) {
return x + n < x;
} import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
# add_would_overflow at -O2 on three ISAs, directives and comments filtered out
clang --target=x86_64-linux-gnu -O2 -masm=intel -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*#' | sed -E 's/[[:space:]]+#.*$//'
clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*//' | sed -E 's/[[:space:]]+\\/\\/.*$//'
clang --target=riscv64-linux-gnu -march=rv64gc -O2 -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*#' | sed -E 's/[[:space:]]+#.*$//'
# Built and run on Apple silicon at -O0, then at -O2
clang -O0 -o overflow0 overflow_main.c overflow.c && ./overflow0; echo "exit=$?"
clang -O2 -o overflow2 overflow_main.c overflow.c && ./overflow2; echo "exit=$?"
""") Output
# add_would_overflow at -O2 on three ISAs, directives and comments filtered out $ clang --target=x86_64-linux-gnu -O2 -masm=intel -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*#' | sed -E 's/[[:space:]]+#.*$//' add_would_overflow: mov eax, esi shr eax, 31 ret $ clang --target=aarch64-linux-gnu -O2 -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*//' | sed -E 's/[[:space:]]+\/\/.*$//' add_would_overflow: lsr w0, w1, #31 ret $ clang --target=riscv64-linux-gnu -march=rv64gc -O2 -S -fno-asynchronous-unwind-tables -o - overflow.c | grep -v -E '^[[:space:]]+[.]|^[.]L|^[[:space:]]*#' | sed -E 's/[[:space:]]+#.*$//' add_would_overflow: srliw a0, a1, 31 ret # Built and run on Apple silicon at -O0, then at -O2 $ clang -O0 -o overflow0 overflow_main.c overflow.c && ./overflow0; echo "exit=$?" add_would_overflow(INT_MAX - 50, 100) = 1 exit=0 $ clang -O2 -o overflow2 overflow_main.c overflow.c && ./overflow2; echo "exit=$?" add_would_overflow(INT_MAX - 50, 100) = 0 exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 overflow_session.py
All three listings return the sign bit of n and never read x: shr eax, 31, lsr w0, w1, #31 and
srliw a0, a1, 31. With overflow ruled out, x + n < x is ordinary algebra, and subtracting x from both sides
leaves n < 0.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The test as written. add_would_overflow is meant for a non-negative n: adding such an n can only make the sum smaller than x by wrapping round past the largest int.
- An assumption C allows. C defines x + n only when the exact sum fits in an int. So the compiler may assume it always fits, and then x + n < x is ordinary algebra.
- Subtract x from both sides. In exact arithmetic, x + n < x holds exactly when n < 0. The test no longer mentions x at all.
- One instruction is enough. n < 0 is the sign bit of n, bit 31. lsr w0, w1, #31 shifts n right by 31 and returns that bit; x, in w0 on entry, is never read.
- The run that disagrees. For x = INT_MAX - 50 and n = 100 the exact sum does not fit. The -O0 build compared a wrapped sum and printed 1; the -O2 build tested only n and printed 0.
For x = INT_MAX - 50 and n = 100 the true sum does not fit. The -O0 build compared a wrapped, negative sum and
printed 1; the -O2 build tested only n and printed 0. Neither is a compiler bug: C leaves the answer undefined. That
is what undefined behaviour in assembly usually looks like, not a strange instruction but a missing one.
Key idea
A compiler may assume that undefined behaviour never happens, so a test that can only be true after an overflow is always false, and the listing simply leaves it out.
-fwrapv defines signed overflow to wrap. A sanitizer reports the overflow while the program runs. C23’s ckd_add
asks the question directly and always gets a defined answer. An over-wide shift, and a test of a pointer made after
reading through it, go wrong for the same reason.
Checkpoint
Check: Undefined behaviour and a deleted check
Practise
Copy opt.c, overflow.c and sum.c first and change only the copies. To see what a change did, paste the old
and the new listing into the text diff tool.
- Remove
staticfromsquareand compileopt.cat -O2. Issum_squaresstill inlined, and what is new in the listing? - Make
dot3loop to a parameterninstead of 3, and predict what replaces themuland themadds before you compile. - Rewrite the test as
n > 0 && x > __INT_MAX__ - n, which never overflows. Which instructions now readx, and what do the -O0 and -O2 builds print? - Compile
sum.cwith-fno-vectorizeand compare its loop with the plain -O2 one.
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Vectorised loops read lane by lane on three ISAs Pro: opens with a Pro, Premium or Ultimate pass
A vectorised loop is usually three loops in one function: a wide main loop, a narrower one for most of what is left,
and a scalar tail. This part reads all three in the AArch64 listing of sum, compares the x86-64 and RISC-V forms,
and weighs what the vector loop is worth in two ways.
In this part:
- Sixteen elements an iteration on AArch64, then the leftovers
- The same loop with SSE2, AVX2 and the RISC-V vector extension
- Why GCC declined at -O2, and what a cost model weighs
- What the vector loop buys, in a model of the core
1 diagram · 3 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Undefined behaviour that removes checks, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
Three tests that look correct in C depend on undefined behaviour, and each can be made to fail, explained from its listing and fixed. Every fix here is shown the same way: the old code gives the wrong answer and the new code the right one, at every optimisation level.
In this part:
- The overflow test with -fwrapv, a sanitizer and checked arithmetic
- A shift by the full width, caught and fixed
- A null test after the read, and the order that keeps it
6 runnable examples (C, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Restrict, pragmas, intrinsics or assembly: choosing for a hot loop Pro: opens with a Pro, Premium or Ultimate pass
Each tool that changes a hot loop’s code asks something different of you. This part reads what restrict removes
from a listing, measures hand-written intrinsics against the compiler’s own loop in one model, and ends with a choice
made from your own case.
In this part:
- The overlap test that restrict removes
- Intrinsics against the vectoriser’s own loop
- Choosing a tool for a hot loop
4 runnable examples (C, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
A warm-up for freshers: what does -O2 turn on that -O0 leaves off?
-O0 compiles each statement on its own and keeps every variable in memory, so a debugger can follow the source line by line. -O2 turns on the optimisations that almost always pay: values stay in registers, small functions are inlined, calls in tail position become jumps, constants are folded, and loops are unrolled and vectorised when a cost model says it pays, by Clang and by GCC since version 12. It also uses the language’s rules to the full, assuming no signed overflow and no read through a null pointer, so checks that rely on either can vanish. GCC keeps costlier transformations for -O3.
These three go further: an overflow check that fails only at -O2, proof that a loop was vectorised, and intrinsics against the compiler.
-
Mid to senior
A C function rejects bad input with if (a + b < a) return -1; for int a and a non-negative int b. Its unit test of an overflowing pair passes in the debug build and fails in the -O2 release build. Explain what happened, how you would show it, and how you would fix it for every build.
-
Mid to senior
A library's README says its hot summing loop is vectorised at -O2. Your product builds it with Clang on one platform and GCC on another. How do you confirm the claim for both builds, and what do you check if one of them is not vectorised?
-
Senior to staff
A teammate rewrote a summing loop with NEON intrinsics and expects it to beat the compiler's code. How do you decide whether the rewrite helps, and why could it be slower than what the compiler produced from plain C?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What four fingerprints of optimisation can you spot in a listing?
A call that vanished is inlining; a final call turned into a branch is a tail call; a loop body written out several times is unrolling; vector registers working on a loop written for one element at a time are vectorisation.
-
Why can -O2 compile the test x + n < x into a check of the sign of n?
Signed overflow is undefined behaviour, so the compiler may assume it never happens. Without overflow, x + n < x holds exactly when n is negative, and the listing tests only that.
-
What does -O2 turn on that -O0 leaves off?
Values kept in registers, inlining of small functions, tail calls as jumps, constant folding, and unrolling and vectorising of loops when a cost model says it pays. It also relies on the language's rules, such as no signed overflow, so checks that depend on breaking them can vanish.
-
What does inlining do to a call?
It replaces the call with a copy of the called function's body, so the call, the return and the argument passing disappear and the copy is optimised together with its caller.
-
What is loop unrolling, and what happens when the trip count is known while compiling?
Writing the body out several times so each iteration does the work of several. With a known trip count the loop can be unrolled completely, leaving no counter and no branch back.
Key takeaways
- An optimised listing is read by what is missing or repeated: a vanished call is inlining, a final call turned into a branch is a tail call, a loop written out is unrolling, and vector registers in a scalar loop are vectorisation.
- Signed overflow is undefined behaviour, so -O2 may compile
x + n < xto a test of the sign bit ofn. - A test that relies on undefined behaviour can change its answer with the optimisation level, and the listing shows it as a missing check.
Terms in this lesson
- auto-vectorisation
- A compiler rewriting a loop written one element at a time so that each pass uses vector instructions on several elements, when it can prove the result is the same and judges that it pays.
- inlining
- Replacing a call with a copy of the called function's body, so that the call, the return and the passing of arguments disappear and the copy is optimised together with its caller.
- loop unrolling
- Writing a loop's body out several times so that each pass does the work of several iterations. A loop whose trip count is known while compiling can be unrolled completely, leaving no counter and no branch back.
- pointer aliasing
- Two pointers reaching the same memory. A compiler that cannot rule it out must keep loads and stores through them in source order, which can stop it from vectorising a loop.
- undefined behaviour sanitizer
- UndefinedBehaviorSanitizer, the -fsanitize= checks of GCC and Clang that compile extra tests into a program to report undefined behaviour, such as a signed overflow, while it runs.
References
- GCC manual: Options That Control Optimization (-O2, -O3, -fvect-cost-model, -fdelete-null-pointer-checks) (GNU Project)
- GCC 12 Release Series: Changes, New Features, and Fixes (vectorisation at -O2) (GNU Project)
- GCC manual: Options for Code Generation Conventions (-fwrapv) (GNU Project)
- GCC manual: Built-in Functions to Perform Arithmetic with Overflow Checking (GNU Project)
- GCC manual: Restricting Pointer Aliasing (GNU Project)
- Clang command line argument reference (-fwrapv, -fsanitize) (The LLVM Project)
- UndefinedBehaviorSanitizer (The LLVM Project)
- Clang Language Extensions: loop hint pragmas (The LLVM Project)
- Auto-Vectorization in LLVM (The LLVM Project)
- llvm-mca: LLVM Machine Code Analyzer (The LLVM Project)
- N3220, working draft of ISO/IEC 9899:2024 (C23), 6.5 Expressions and 6.5.8 Bitwise shift operators (ISO/IEC JTC1/SC22/WG14)
- N3220, working draft of ISO/IEC 9899:2024 (C23), 7.20 Checked integer arithmetic (ISO/IEC JTC1/SC22/WG14)
- Arm A-profile A64 Instruction Set Architecture (LSLV, BIC, CSINV, CMN, CSET) (Arm Limited)
- Arm C Language Extensions: Advanced SIMD (Neon) intrinsics (Arm Limited)
- The RISC-V Instruction Set Manual: "V" Standard Extension for Vector Operations (RISC-V International)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress