GCC and Clang Internals

On this page

In systems programming, the most practical entry point to understanding compiler internals is to trace the artifacts: where source code becomes IR, when cross-file information becomes visible, and which runtime libraries are finally linked. Only then can you determine whether an option affects semantics, optimization, target instructions, or the linking process.

Use the Same Program Across Stages

First, save prog.c in a separate directory. It selects one of two datasets based on the number of arguments, and all calculations use unsigned values to avoid undefined behavior from signed overflow. The results are used for the exit status and output, making it easy to check whether different builds maintain consistent behavior.

#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

uint32_t sum(const uint32_t *values, size_t count) {
    uint32_t total = 0;
    for (size_t i = 0; i < count; ++i) total += values[i];
    return total;
}
int main(int argc, char **argv) {
    (void)argv;
    const uint32_t values[] = {1, 2, 3, 4};
    uint32_t result = argc > 1 ? sum(values, 4) : sum(values, 2);
    printf("%lu\n", (unsigned long)result);
    return result == (argc > 1 ? 10u : 3u) ? 0 : 1;
}

Running with no arguments should print 3, and running with any argument should print 10. This tiny workload is useful for observing the compilation chain but is insufficient to prove that a specific optimization option is faster; the compiler might even fold the known data calculations in main directly, which is exactly why you need to inspect the final artifacts.

The Driver Orchestrates Multiple Stages

gcc and clang are typically user-facing driver entry points; they select the frontend, assembler, linker, startup files, and runtime libraries. Final linking for C++ should use the corresponding g++ or clang++ drivers to bring in matching C++ runtime support; you cannot assume that directly invoking ld is fully equivalent just because .o files already contain machine code.

clang '-###' -O2 prog.c -o prog
clang -E prog.c -o prog.i
clang -S -emit-llvm -O2 prog.c -o prog.ll
clang -S -O2 prog.c -o prog.s
clang -c -O2 prog.c -o prog.o

The first command displays the commands that would be invoked without executing them; the subsequent commands stop at the preprocessing, LLVM IR, assembly, and object file stages, respectively. A full build can fuse multiple stages and does not necessarily write every intermediate file to disk. Clang can use an integrated assembler and different linkers, and LLVM IR does not require that the final output be handed off to LLD. Clang Toolchain

Same compilation task: frontend semantics, optimized representation, and target code Source and compiler options GCC Frontend Language Tree → GENERIC / GIMPLE Clang Frontend AST → LLVM IR GIMPLE SSA → RTL Optimization and Target-Specific Processing LLVM IR → Machine IR Optimization and Target-Specific Processing The driver coordinates compilation, assembly, linking, and runtime libraries; intermediate layers also use target layout and cost information.

GIMPLE and LLVM IR Are Not Simply "High vs. Low Level"

The GCC frontend reduces language structures to a generic representation. GIMPLE provides a simplified statement form, and many optimizations work on its SSA representation before moving to RTL and other target-machine-close processing. LLVM IR also uses SSA values but includes target data layout, calling conventions, memory semantics, and attributes.

Reuse at the intermediate layer does not mean the architecture is completely irrelevant: vector widths, legal instructions, and cost models can all affect optimization. The backend is also not a fixed single run of "select instructions, allocate registers, schedule"; different targets and optimization pipelines combine these processes at different points. GCC passes

gcc -O2 -c -fdump-tree-optimized prog.c
clang -O2 -c -Rpass=loop-vectorize -Rpass-missed=loop-vectorize \
  -Rpass-analysis=loop-vectorize prog.c

Dumps are used to observe results, and optimization remarks are used to understand decisions. A loop might not be vectorized due to aliasing, safety, benefit estimation, or target capabilities; you cannot simply conclude that SIMD should be generated just because "O3 is enabled." For conceptual derivation, see Classic Optimizations.

When Reading IR, Look for Data Flow and Control Flow First

In the source code, sum repeatedly modifies total, but in the SSA representation, each result name has only one definition; loops typically use phi nodes to select different previous values from the entry and back edge. Phi nodes are not evaluated on all predecessors, nor do they necessarily correspond to a single machine instruction. Optimizations may unroll, vectorize, or completely replace the loop, so specific outputs should be read in the context of the current parameters. LLVM phi

Observation PointQuestions to AskConclusions to Avoid
Preprocessed output prog.iWhat do macros and headers expand to?Business logic has completed type checking.
AST / IRHow are types, branches, calls, and memory accesses expressed?Every IR instruction has a corresponding machine instruction.
Optimization remarksWhy did a specific pass accept or reject a transformation?No message means no optimization occurred.
prog.sWhich instructions does the target actually select?Layout and addresses are determined after linking.
Final executableAre calls preserved? How are symbols resolved?Disappearing symbols necessarily mean faster execution.

For example, adding restrict for element-wise transformations may help the compiler exclude pointer aliasing, but it simultaneously requires callers to adhere to stricter aliasing contracts. If the caller still overlaps input and output in disallowed ways, you cannot call subsequent result changes "optimization instability." You must first prove the premises, then optimize based on them.

LTO: Deferring Some Optimizations to Link-Time Visibility

Prepare a.c and b.c for the following two-file command. a.c only knows the function declaration, while b.c contains the implementation:

/* a.c */
#include <stdio.h>
int doubled(int value);
int main(void) {
    int result = doubled(21);
    printf("%d\n", result);
    return result == 42 ? 0 : 1;
}
/* b.c: In this example, called only with 21, so multiplication will not overflow */
int doubled(int value) { return value * 2; }

Ordinary separate compilation mainly looks at the current translation unit; LTO saves information in object files or other intermediate artifacts that can be processed during the linking stage, enabling cross-file inlining, constant propagation, and dead code elimination.

gcc -O2 -flto -c a.c b.c
gcc -O2 -flto a.o b.o -o prog

Pass the required options in both stages and use the compiler driver to select the matching toolchain. Object files participating in LTO, archive tool plugins, compiler versions, and linker support need to be compatible; not all files named .o contain representations consumable by the same LTO implementation.

Clang ThinLTO can be written as:

clang -O2 -flto=thin -c a.c b.c
clang -O2 -flto=thin -fuse-ld=lld a.o b.o -o prog

ThinLTO uses summaries to complete cross-module analysis and allows backend processing to be parallelizable and cacheable. It is not "doing slightly less optimization," nor does it eliminate the need for a linking stage. Version matching and caching strategies affect build effectiveness. ThinLTO Documentation

LTO does not automatically see all code loaded at runtime; shared library boundaries, symbol visibility, dynamic replacement semantics, and external calls all limit the assumptions that can be made. It may reduce or increase code size, shorten or lengthen execution time; you must measure the target workload rather than promising a fixed benefit range.

Cross-file optimization: establish visibility first, then examine final code a.c: doubled(21) Ordinary compilation sees only declarations and calls b.c: value * 2 Function body is in another translation unit LTO performs joint analysis within the allowed link-time scope May inline and propagate constant 42, or may preserve independent symbols External visibility, dynamic replacement, and optimization strategies limit transformations; rely on final disassembly.

Compare Artifacts of Ordinary Linking vs. LTO

gcc -O2 -c a.c b.c
gcc a.o b.o -o normal
objdump -d normal
nm normal
gcc -O2 -flto a.c b.c -o with-lto
objdump -d with-lto
nm with-lto

Both programs should print 42. First, look for the call to doubled in the disassembly of main, then see if the LTO version changes to directly use the constant; whether external symbols still exist and whether calls are inlined are related but distinct issues. nm is suitable for checking symbols but cannot alone prove how each call site is executed.

Tool versions must also correspond: clang, ld.lld, and llvm-profdata in the PATH may come from different installations. Record their --version outputs; when bitcode or profile version incompatibilities appear, first verify the actual tools being invoked; do not assume they can be arbitrarily mixed just because they all bear the LLVM name.

What information blocks cross-file folding?

With separate compilation, a.c has only a declaration: constant 21 does not prove 42. Link-time body visibility still requires valid target-binding assumptions. LTO supplies cross-file information; symbol semantics constrain its use. Inspect the final binary for the actual decision.

Preparing the visual
What information blocks cross-file folding?

A name and arguments are insufficient. Cross-file propagation needs the body and valid assumptions about the bound target; an opportunity is not a guaranteed compiler decision.

PGO: Using Training Execution to Guide Optimization Decisions

PGO feeds actual path frequency feedback to the compiler, helping with inlining, layout, and hot/cold separation. It does not modify the static configuration of the CPU branch predictor, nor does it guarantee that inputs outside the training set will still be fastest.

Below is the Clang instrumentation-based workflow; prog.c should be directly executable; for programs requiring arguments, pass representative arguments during the training step:

clang -O2 -fprofile-instr-generate prog.c -o prog-train
LLVM_PROFILE_FILE='train-%p.profraw' ./prog-train
llvm-profdata merge -output=prog.profdata train-*.profraw
clang -O2 -fprofile-instr-use=prog.profdata prog.c -o prog

Raw data is merged first, then used for the feedback build; training filenames containing the process ID help avoid concurrent processes writing to the same file. Practical applications must also consider dynamic libraries, weights for multiple training datasets, short-circuit exits that do not write to disk, and matching issues caused by source code changes. Clang PGO User Guide

Arrange Training Inputs and Independent Verification Inputs Separately

For prog.c on this page, the training phase should execute at least the no-argument and with-argument paths separately, then merge the profile generated in this round. Do not let train-*.profraw files left over from old source code in the directory automatically mix into the current experiment; keep independent directories or explicit input lists for each training run.

After the feedback build is complete, run ./prog and ./prog sample again to verify that the output remains 3 and 10. You can also use llvm-profdata show --all-functions --counts prog.profdata to check if function counts were indeed captured; having a non-empty file does not represent that critical business paths have been trained, nor does it mean version matching warnings can be ignored.

The training set should cover important paths and input scales in production. Using only startup self-checks to train a throughput service will likely yield unrepresentative hotspots. Verify the final build with independent evaluation workloads, and record the compiler version, source code, and training data sources; LTO and PGO can be combined, but the build chain and data validity should be confirmed separately.

Optimization Levels Are Sets of Strategies

OptionPrimary IntentCommon Misconception
-O0Retain low-optimization builds suitable for debuggingDoes not mean every line of source code is translated literally.
-O1 / -O2 / -O3Sets of different cost and optimization strategiesO3 does not guarantee being faster than O2; O1 does not guarantee smaller size.
-Os / -OzTendency to reduce code sizeDoes not mean all speed optimizations are disabled.
-march=nativeAllows target-architecture-specific instructions and tuningArtifacts may not run on older or different CPUs.
-fno-omit-frame-pointerRequest to retain frame chains suitable for unwindingThe effect on specific functions and targets still depends on the implementation.

Which passes are included in each level changes with the compiler, version, and target; you cannot statically write "vectorization is only enabled at O3." GCC can query relevant states with gcc -Q -O2 --help=optimizers. GCC Optimization Options

-fno-semantic-interposition changes optimization assumptions about whether symbols can be replaced externally; it is not a simple "do not generate PLT" switch. Options like -ffast-math change the floating-point semantics that can be relied upon and should not be confused with performance switches that do not alter language contracts.

Separate Correctness and Performance Verification

Sanitizer instrumentation is suitable for discovering runtime issues; see Valgrind and Sanitizers in Practice. They change layout, execution time, and runtime libraries, so their measurement values cannot be directly used as baselines for release builds.

When comparing compilation options, fix the completed work, target platform, and inputs; first check behavior, then look at time, code size, and build cost. If an error appears only after optimization at O2, prioritize checking UB, data races, and ABI mismatches, then use a minimal test case to determine if it is a compiler issue; permanently disabling optimization should not be treated as having explained the cause.

Keywords: GCC, Clang, driver, GIMPLE, LLVM IR, RTL, LTO, ThinLTO, PGO, optimization remarks, toolchain