Performance

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C is fast because it compiles to what you wrote and hides nothing — and because its optimizers exploit every guarantee the standard gives them. Both halves of that sentence matter here: the leverage is real, and so is the requirement that your code be free of undefined behavior for the optimizer’s assumptions to hold.

Measure First

The order of operations is not negotiable: measure, find the hot spot, change one thing, measure again. C makes it especially tempting to skip this, because micro-optimizations are so easy to write.

#include <stdio.h>
#include <time.h>

static double elapsed_seconds(struct timespec start, struct timespec end)
{
    return (double)(end.tv_sec - start.tv_sec)
         + (double)(end.tv_nsec - start.tv_nsec) / 1e9;
}

// volatile on the accumulator stops the optimizer deleting the whole loop as
// dead code -- the classic way a benchmark comes out at "0 nanoseconds".
static double workload(size_t iterations)
{
    volatile double sink = 0.0;

    for (size_t i = 1; i <= iterations; ++i) {
        sink += 1.0 / (double)i;
    }
    return sink;
}

int main(void)
{
    const size_t iterations = 5000000;
    const int repetitions = 5;

    // Warm up: first-run effects (page faults, cold caches, CPU frequency ramp)
    // routinely dominate a single measurement.
    (void)workload(iterations / 10);

    double best = 1e30;
    for (int r = 0; r < repetitions; ++r) {
        struct timespec start, end;

        if (timespec_get(&start, TIME_UTC) != TIME_UTC) {
            return 1;
        }
        (void)workload(iterations);
        if (timespec_get(&end, TIME_UTC) != TIME_UTC) {
            return 1;
        }

        double seconds = elapsed_seconds(start, end);
        if (seconds < best) {
            best = seconds;
        }
    }

    printf("best of %d: %.4f s (%.1f ns/iteration)\n",
           repetitions, best, best * 1e9 / (double)iterations);
    return 0;
}

Rules for a measurement you can trust: build with the same optimization level you will ship, keep the result alive so the loop is not deleted, warm up, repeat and report the minimum (or a distribution) rather than a single run, and change one variable at a time. For serious work, use a harness — Google Benchmark, hyperfine for whole programs, or perf stat -r.

Optimization Levels

Flag Effect

-O0

No optimization. Debug builds only; inline is ignored and code can be 10× slower.

-O1

Cheap optimizations, fast compile.

-O2

The default choice for release builds. Inlining, vectorization, the full standard suite, without space-for-speed trades that hurt.

-O3

More aggressive inlining and vectorization. Sometimes faster, sometimes slower (code size, i-cache pressure) — measure, do not assume.

-Os / -Oz

Optimize for size. Often a win on embedded targets and anything i-cache bound.

-Og

Optimize but keep debugging usable. The right level for a development build.

-march=native -mtune=native

Use every instruction this CPU has. Big wins for floating-point and SIMD code, and the binary may not run on an older machine.

-flto

Link-time optimization: inlining and analysis across translation units. Frequently the single largest easy win in a multi-file project.

-ffast-math

Breaks IEEE-754 semantics (reassociation, no NaN/Inf handling). Do not use it unless you have proven your numerics tolerate it.

# A release build worth copying:
$ clang -std=c23 -O2 -march=native -flto -DNDEBUG -Wall -Wextra -o app *.c

# A development build worth copying:
$ clang -std=c23 -Og -g -Wall -Wextra -Werror -fsanitize=address,undefined -o app *.c

inline and Its Linkage Rules

Inlining removes call overhead and, more importantly, exposes the callee’s body to the caller’s optimizer. The compiler decides — inline only affects linkage, as detailed in Functions.

#include <stddef.h>

// static inline in a header: the default choice. Every TU gets a private copy;
// unused copies are discarded, and the optimizer can specialize each call site.
static inline size_t round_up_to_multiple(size_t value, size_t multiple)
{
    return multiple == 0 ? value : ((value + multiple - 1) / multiple) * multiple;
}

// The compiler inlines plenty of functions never marked inline, and ignores the
// hint when the body is large or the call is cold. Force it only with a measurement:
[[gnu::always_inline]] static inline int fast_min(int a, int b)
{
    return a < b ? a : b;
}

// ...and the opposite, to keep a cold path out of the hot function's i-cache:
[[gnu::noinline]] static void report_failure(const char *message)
{
    (void)message;
}

int main(void)
{
    return (int)round_up_to_multiple(17, 8) + fast_min(1, 2) - 25;
}

Note the vendor-namespaced attribute spelling ([[gnu::always_inline]]), which C23 lets you use in place of __attribute__((always_inline)).

restrict

The most consequential single keyword for optimization. It promises no aliasing, which lets the compiler keep values in registers across stores and vectorize loops:

#include <stddef.h>

// Without restrict, the compiler must assume out may alias a or b, so it reloads
// a[i] and b[i] after every write to out[i] -- and cannot vectorize.
void add_vectors_maybe_aliasing(size_t n, float *out, const float *a, const float *b)
{
    for (size_t i = 0; i < n; ++i) {
        out[i] = a[i] + b[i];
    }
}

// With restrict, the loads and stores are independent: the loop vectorizes.
void add_vectors(size_t n, float *restrict out,
                 const float *restrict a, const float *restrict b)
{
    for (size_t i = 0; i < n; ++i) {
        out[i] = a[i] + b[i];
    }
}

Inspect the difference rather than trusting the theory — clang -O2 -S -mllvm --x86-asm-syntax=intel or -Rpass=loop-vectorize will tell you whether the loop actually vectorized. And remember restrict is an unchecked promise: passing overlapping buffers is undefined behavior.

C23 Function Attributes

Two new attributes let you tell the optimizer that a function is effectively pure, which enables common subexpression elimination across calls:

#include <stdio.h>

// [[unsequenced]]: no side effects, no dependence on any state, and the result
// depends only on the arguments -- the strongest promise (like GCC's "const").
[[unsequenced]] static int triple(int x)
{
    return x * 3;
}

// [[reproducible]]: no side effects observable by the caller, and equal arguments
// give equal results within one program run -- may read memory (like GCC's "pure").
[[reproducible]] static size_t length_of(const char *text)
{
    size_t n = 0;
    while (text[n] != '\0') {
        ++n;
    }
    return n;
}

int main(void)
{
    // The compiler may now evaluate triple(7) once and length_of(s) once,
    // hoisting either out of a loop.
    int total = 0;
    const char *s = "hello";

    for (int i = 0; i < 3; ++i) {
        total += triple(7) + (int)length_of(s);
    }

    printf("%d\n", total);
    return 0;
}

Both are promises: if a [[unsequenced]] function actually mutates global state, the resulting behavior is undefined. The GCC/Clang predecessors — __attribute__((const)) and __attribute__((pure)) — are still what most codebases use, and remain available.

Compiler support for these two attributes lags the rest of C23: Clang 18 does not implement them and warns unknown attribute 'unsequenced' ignored under -Wunknown-attributes, so the example above is written to the standard rather than compile-verified here. Guard them with __has_c_attribute(unsequenced), or keep using __attribute__((const))/__attribute__((pure)), until your baseline implements them.

Where the Real Wins Are

Ordered roughly by how much they typically matter:

  1. Algorithmic complexity. An O(n log n) replacement for an O(n²) loop beats every flag in this page combined.

  2. Memory access patterns. A cache miss costs hundreds of cycles; an arithmetic instruction costs one. Traverse arrays sequentially, keep hot data contiguous, and prefer a struct-of-arrays layout when you touch one field of many elements.

  3. Fewer allocations. Reuse buffers, allocate in blocks, and prefer the stack for short-lived objects.

  4. Struct layout. Order members widest-first to remove padding — see Structures, Unions and Type Aliases. Smaller structs mean more per cache line.

  5. -flto and -O2. Compiler flags, essentially free.

  6. Branch predictability. A branch that alternates unpredictably costs a pipeline flush; a branchless form ((a > b) - (a < b), a table lookup, arithmetic on a bool) can win.

  7. restrict and the pure-function attributes, where profiling shows the loop matters.

  8. SIMD intrinsics or hand-written assembly. Last, and rarely: modern auto-vectorizers are good, and intrinsics are unportable and hard to maintain.

#include <stdio.h>

#define ROWS 512
#define COLS 512

static double grid[ROWS][COLS];

// Row-major traversal: consecutive iterations touch consecutive bytes, so each
// cache line is used fully. This is the fast one.
static double sum_rows_first(void)
{
    double total = 0.0;
    for (size_t r = 0; r < ROWS; ++r) {
        for (size_t c = 0; c < COLS; ++c) {
            total += grid[r][c];
        }
    }
    return total;
}

// Column-major traversal of a row-major array: every access is a new cache line.
// Same operation count, several times slower on any real machine.
static double sum_columns_first(void)
{
    double total = 0.0;
    for (size_t c = 0; c < COLS; ++c) {
        for (size_t r = 0; r < ROWS; ++r) {
            total += grid[r][c];
        }
    }
    return total;
}

int main(void)
{
    printf("%g %g\n", sum_rows_first(), sum_columns_first());
    return 0;
}

Profiling and Inspection

# Where the time goes (Linux). The first tool to reach for.
$ perf stat ./app                       # cycles, instructions, cache misses, IPC
$ perf record -g ./app && perf report   # per-function, with call graphs
$ perf annotate                         # per-instruction, next to the source

# Repeat a whole-program measurement properly:
$ perf stat -r 10 ./app
$ hyperfine './app --input big.dat'

# Instrumented profiling (needs -pg at build time):
$ gcc -std=c23 -O2 -pg -o app app.c && ./app && gprof ./app gmon.out | head -40

# Cache and branch simulation, no special build required:
$ valgrind --tool=cachegrind ./app
$ valgrind --tool=callgrind ./app && callgrind_annotate callgrind.out.*

# What the compiler actually generated:
$ clang -std=c23 -O2 -S -o - app.c | less        # assembly
$ objdump -d --demangle app | less               # disassembly of the binary

# Why a loop did (or did not) vectorize:
$ clang -std=c23 -O2 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize -c app.c
$ gcc -std=c23 -O2 -fopt-info-vec-missed -c app.c

`perf stat’s instructions-per-cycle figure and cache-miss rate usually identify the problem class in one run: low IPC with high miss rates means memory, high IPC with a lot of instructions means algorithm.

Undefined Behavior Is a Performance Feature

The optimizer assumes your program has no undefined behavior, and deletes code that would only run if it did. This is why UB is not merely a correctness issue:

#include <stdio.h>

// The compiler may assume p is non-null: it has already been dereferenced, and
// dereferencing null would be UB. The later check can therefore be deleted.
static int surprising(int *p)
{
    int value = *p;             // if p were null, this is UB

    if (p == nullptr) {         // ...so the optimizer may remove this entirely
        return -1;
    }
    return value;
}

// Signed overflow is UB, so the compiler may assume i + 1 > i always holds,
// and treat this loop as infinite-or-terminating on the count alone.
static int count_up(int start, int limit)
{
    int steps = 0;
    for (int i = start; i < limit; ++i) {
        ++steps;
    }
    return steps;
}

int main(void)
{
    int value = 42;
    printf("%d %d\n", surprising(&value), count_up(0, 10));
    return 0;
}

Two practical consequences:

  • Check before you dereference, not after. A null check placed after a use may simply vanish.

  • Do not rely on wrapping. Use unsigned types where you want modular arithmetic, and <stdckdint.h> where you want detection.

The corollary is that the sanitizers in Error Handling and Program Failure are a performance tool as well as a correctness tool: they let you keep `-O2’s assumptions honest.

See Also

References