Learn C Language - Performance Optimization
Episode 15 of 24

Learn C Language - Performance Optimization

This episode optimizes C programs scientifically: profiling with perf, gprof, and Valgrind to find the slow spots, code optimization with loop unrolling, inline, and compiler flags, memory locality and cache friendliness, as well as readability tradeoffs.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Performance is the main reason to choose C, but misdirected optimization just wastes time and damages code. Episode 15 teaches optimization scientifically: measure first, find the slow spots, then fix the parts that are truly the bottleneck.

We'll use three profiling tools: gprof for function profiles, Valgrind callgrind for counting instructions and cache misses, and perf for lightweight kernel-based sampling. Once the slow spots are found, we talk optimization: compiler flags, inline, loop unrolling, and cache-friendly data layout.

The golden rule throughout the episode: don't optimize code that isn't measured. Speed must be measured with tools, not feelings.

Profiling with perf, gprof, and Valgrind

gprof: Function Profiles

gprof counts the time each function spends. Build with the -pg flag, run the program, then read its report:

Profile with gprof
gcc -pg -O2 program.c -o program
./program
gprof ./program gmon.out

The -pg flag injects time recording into functions, and the output of gprof ./program gmon.out shows the percentage of time per function. The functions consuming the most time are the primary optimization candidates.

Valgrind callgrind and perf

For cache and instruction detail, use callgrind:

Count instructions and cache misses
valgrind --tool=callgrind ./program

valgrind --tool=callgrind records every instruction and memory access. The results are analyzed with callgrind_annotate to see which lines are executed most often. perf stat, on the other hand, measures hardware counters such as cache misses without modifying the program:

Hardware counters
perf stat ./program

The output of perf stat ./program shows the clock, instructions, and cache misses that occur during execution. Combine gprof to see functions, and perf or callgrind to see lines and memory access.

Code Optimization and Compiler Flags

Optimization Compiler Flags

Modern compilers are already good at optimizing. Start by choosing the optimization level:

  • -O0: no optimization, fastest to compile, for debugging.
  • -O1: safe basic optimization.
  • -O2: the default production level, balancing speed and size.
  • -O3: aggressive optimization that can enlarge the binary.
  • -march=native: uses the specific instructions of the local CPU.
Production optimization build
gcc -O2 -march=native -flto program.c -o program

The -march=native flag enables instructions your CPU supports, and -flto allows cross-file optimization at link time. Note: binaries built with -march=native can't be moved to another CPU.

Loop Unrolling and Inline

Loop unrolling duplicates the loop body to reduce the overhead of condition checks, and inline inserts a function body at the call site. Both can be written manually, but compilers with -O2 often do it better on their own. Write clear code, mark inline and restrict when needed, then let the compiler decide.

Memory Locality and Cache Friendliness

Leveraging the Cache

The CPU is far faster than main memory; the cache is the buffer in between. Programs that read data sequentially reuse the same cache lines and are much faster than ones that jump around randomly. This is why sequential C arrays are accessed much faster than scattered linked lists.

Good Data Layout

For arrays of structs, order members from largest to smallest to reduce padding. Access data in sequential patterns. When processing a two-dimensional array, iterate row by row following memory order:

Cache-friendly iteration
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        jumlah += matriks[i][j];
    }
}

The loop for (int i...) { for (int j...) } accesses matriks[i][j] row by row, following storage order. Swapping the loop order to column by column makes memory access jump and destroys cache locality.

Readability and Performance Tradeoffs

Optimize Only If Measured

Most programs don't need aggressive optimization. Start with clear, correct code, then measure. Optimize only the parts proven to be bottlenecks, and keep measurements before and after so changes truly bring improvement.

Choosing the Balance

Readable code is easier to maintain and debug. Optimization adds complexity. A simple guide: write clear code, enable -O2, measure, and only then optimize the parts that become bottlenecks with numeric evidence. Save the profile before changes as a baseline, and consider short comments explaining why an unclear trick exists.

Tip

If speed is truly critical, consider comparing several implementations with the same benchmark. Data picks the winner, not intuition.

Closing

Key takeaways:

  • Measure first with gprof, perf, or Valgrind before optimizing.
  • Start optimization levels at -O2, and -march=native for a specific CPU.
  • Compiler flags and LTO are often more effective than manual optimization.
  • Sequential memory access leverages the cache and is much faster.
  • Struct padding can be reduced by ordering members.
  • Don't sacrifice readability without measurement evidence.

In the next episode 16 we will discuss concurrency and parallel programming — multithreading with pthreads, synchronization primitives such as mutex, semaphore, and condition variables, shared memory, race conditions, and deadlock avoidance, up to an introduction to OpenMP for parallel loops.

Learn C Language - Performance Optimization | Learn C Language