Skip to content

Performance Profiling — perf, Valgrind, Callgrind, Cachegrind, Flame Graphs, Optimization Techniques

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Performance Profiling. We cover key concepts, practical examples, and best practices to help you master this topic.

C++ performance profiling uses perf for CPU sampling, Valgrind/Callgrind for instruction-level analysis, and Cachegrind for cache miss measurement — converting guesswork into data-driven optimization.

What You'll Learn

You will use perf for CPU profiling with stack sampling, interpret Callgrind output to find hot functions, use Cachegrind to measure L1/L2/L3 cache misses, generate flame graphs for visual bottleneck identification, apply micro-benchmarking with Google Benchmark, and apply targeted optimizations based on profiling data.

Why It Matters

Premature optimization is the root of all evil — but no optimization at all is worse. Profiling tells you what to optimize, replacing intuition with data. C++'s zero-overhead principle means the compiler can produce efficient code, but only if you guide it correctly. Profiling is essential for game engines, databases, real-time systems, and any performance-sensitive code.

Learning Path

graph LR
    A["67: Debugging (GDB)"] --> B["68: Performance Profiling"]
    B --> C["69: Best Practices"]
    C --> D["70: Final Capstone Project"]
    style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style D fill:#4a90d9,stroke:#2c5f8a,color:#fff

Sample Program for Profiling

// profile_me.cpp — intentionally inefficient code
#include <iostream>
#include <vector>
#include <algorithm>
#include <chrono>
#include <random>
#include <string>

struct Person {
    std::string name;
    int age;
    double salary;
};

std::vector<Person> generatePeople(size_t count) {
    std::vector<Person> people;
    people.reserve(count);
    for (size_t i = 0; i < count; ++i) {
        people.push_back({
            "Person_" + std::to_string(i),
            static_cast<int>(i % 80),
            static_cast<double>(i * 1000)
        });
    }
    return people;
}

// Inefficient: passing by value copies the entire vector
std::vector<Person> sortByAge(std::vector<Person> people) {
    std::sort(people.begin(), people.end(),
        [](const Person& a, const Person& b) {
            return a.age < b.age;
        });
    return people;
}

// Inefficient: string concatenation in loop
std::string buildReport(const std::vector<Person>& people) {
    std::string report;
    for (const auto& p : people) {
        report += p.name + ": " + std::to_string(p.age) +
                  " earns $" + std::to_string(p.salary) + "\n";
    }
    return report;
}

// Cache-inefficient: random access pattern
double computeAverage(const std::vector<Person>& people) {
    double total = 0;
    for (size_t i = 0; i < people.size(); ++i) {
        total += people[i].salary;
    }
    return total / people.size();
}

int main() {
    auto people = generatePeople(100000);

    auto sorted = sortByAge(people);
    auto report = buildReport(sorted);
    auto avg = computeAverage(sorted);

    std::cout << "Average salary: " << avg << "\n";
    std::cout << "Report length: " << report.size() << "\n";

    return 0;
}

Profiling with perf (Linux)

Perf uses hardware performance counters with minimal overhead.

# Compile with debug symbols and optimizations
g++ -g -O2 -o profile_me profile_me.cpp

# CPU sampling — record where CPU time is spent
perf record ./profile_me

# View report
perf report

# Record with call graph (see parents/children)
perf record -g ./profile_me

# Report with call graph
perf report -g

# Record specific events
perf record -e cache-misses ./profile_me
perf record -e branch-misses ./profile_me

# Statistics summary
perf stat ./profile_me

# Annotate specific function
perf annotate sortByAge

Perf Output Interpretation

# perf report output (simplified)
Samples: 2K of event 'cycles'
Event count (approx.): 850000000

Overhead  Command     Shared Object     Symbol
  45.2%   profile_me  profile_me        buildReport(std::vector<...> const&)
  20.1%   profile_me  profile_me        sortByAge(std::vector<...>)
  12.5%   profile_me  libstdc++.so      std::string::_M_replace
   8.3%   profile_me  [kernel]          clear_page_rep
   5.8%   profile_me  profile_me        generatePeople(unsigned long)
   ...

# perf stat output
    850,000,000      cycles
    450,000,000      instructions
          2.5        IPC (instructions per cycle)
    125,000,000      cache-misses
    350,000,000      cache-references
          5.2%       of all cache refs are misses

The bottleneck is buildReport at 45.2% — string concatenation creates many temporary allocations. The solution is to pre-allocate or use std::stringstream.

Valgrind Callgrind

Callgrind provides instruction-level profiling with exact counts.

# Run with Callgrind
valgrind --tool=callgrind ./profile_me

# Output: callgrind.out.<pid>

# View with kcachegrind (GUI)
kcachegrind callgrind.out.12345

# View with text tools
callgrind_annotate --auto=yes callgrind.out.12345

Callgrind shows exact instruction counts by function, function call counts, and a call graph. It's more detailed than perf but 10-50x slower.

Cachegrind — Cache Miss Analysis

# Simulate L1/L2/L3 cache
valgrind --tool=cachegrind ./profile_me

# Output: cachegrind.out.<pid>

# View results
cg_annotate cachegrind.out.12345

# Key metrics:
# D refs:      data references
# D1 misses:   L1 data cache misses
# LL misses:   last-level cache misses (L3)
# D miss rate: data cache miss rate

# Recompile without optimization to see code structure
# For real profiling, use -O2

Flame Graphs

Flame graphs visualize stack samples as a heat map.

# 1. Record with perf
perf record -g ./profile_me

# 2. Generate flame graph (requires FlameGraph scripts)
# git clone https://github.com/brendangregg/FlameGraph
perf script | ./FlameGraph/stackcollapse-perf.pl > out.folded
./FlameGraph/flamegraph.pl out.folded > flame.svg

# Open flame.svg in browser

A flame graph shows:

  • Width: how much CPU time is spent in a function (wider = more)
  • Stack: parent functions below children
  • Color: typically random, or colored by function/library

Google Benchmark

Micro-benchmark specific functions with statistical rigor.

#include <benchmark/benchmark.h>
#include <vector>
#include <string>
#include <sstream>

// Link with -lbenchmark

static void BM_StringConcatenation(benchmark::State& state) {
    std::string a = "Hello, ";
    std::string b = "World!";
    for (auto _ : state) {
        std::string result = a + b;
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(BM_StringConcatenation);

static void BM_StringStream(benchmark::State& state) {
    std::string a = "Hello, ";
    std::string b = "World!";
    for (auto _ : state) {
        std::ostringstream oss;
        oss << a << b;
        std::string result = oss.str();
        benchmark::DoNotOptimize(result);
    }
}
BENCHMARK(BM_StringStream);

static void BM_ReservePushBack(benchmark::State& state) {
    for (auto _ : state) {
        std::vector<int> v;
        v.reserve(state.range(0));
        for (int i = 0; i < state.range(0); ++i) {
            v.push_back(i);
        }
    }
}
BENCHMARK(BM_ReservePushBack)->Range(8, 8<<10);

BENCHMARK_MAIN();
# Compile and run
g++ -O2 -std=c++20 benchmark.cpp -lbenchmark -o benchmark
./benchmark

# Output:
# BM_StringConcatenation    25.4 ns
# BM_StringStream           89.2 ns  (slower: overhead)
# BM_ReservePushBack/8      31.2 ns
# BM_ReservePushBack/64     112 ns
# BM_ReservePushBack/512    823 ns
# BM_ReservePushBack/4096   6542 ns

Optimization Techniques

Based on profiling data, apply targeted optimizations.

#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <algorithm>

// ORIGINAL (slow): string concatenation in loop
std::string buildReportOriginal(const std::vector<Person>& people) {
    std::string report;
    for (const auto& p : people) {
        report += p.name + ": " + std::to_string(p.age) +
                  " earns $" + std::to_string(p.salary) + "\n";
    }
    return report;
}

// OPTIMIZED 1: Pre-allocate string capacity
std::string buildReportOptimized1(const std::vector<Person>& people) {
    std::string report;
    // Estimate total size
    size_t estimated = 0;
    for (const auto& p : people) {
        estimated += p.name.size() + 8 + 4 + 8 + 4 + 1;  // rough estimate
    }
    report.reserve(estimated);

    for (const auto& p : people) {
        report += p.name + ": " + std::to_string(p.age) +
                  " earns $" + std::to_string(p.salary) + "\n";
    }
    return report;
}

// OPTIMIZED 2: Use stringstream
std::string buildReportOptimized2(const std::vector<Person>& people) {
    std::ostringstream oss;
    for (const auto& p : people) {
        oss << p.name << ": " << p.age
            << " earns $" << p.salary << "\n";
    }
    return oss.str();
}

// OPTIMIZED 3: Manual formatting with pre-allocated buffer
std::string buildReportOptimized3(const std::vector<Person>& people) {
    size_t estimated = 0;
    char buf[128];
    for (const auto& p : people) {
        int len = std::snprintf(buf, sizeof(buf), "%s: %d earns $%.0f\n",
                                p.name.c_str(), p.age, p.salary);
        if (len > 0) estimated += len;
    }

    std::string report;
    report.resize(estimated);
    size_t pos = 0;
    for (const auto& p : people) {
        pos += std::snprintf(&report[pos], report.size() - pos,
                             "%s: %d earns $%.0f\n",
                             p.name.c_str(), p.age, p.salary);
    }
    return report;
}

int main() {
    std::vector<Person> people = generatePeople(1000);

    // Use Google Benchmark for accurate comparison
    auto t1 = buildReportOriginal(people);
    auto t2 = buildReportOptimized1(people);
    auto t3 = buildReportOptimized2(people);
    auto t4 = buildReportOptimized3(people);

    std::cout << "All versions produce output of sizes: "
              << t1.size() << " " << t2.size() << " "
              << t3.size() << " " << t4.size() << "\n";
    return 0;
}

Common Optimizations

// 1. Pass by const ref, not by value
void slow(std::vector<int> v);      // Copies the whole vector
void fast(const std::vector<int>& v); // No copy

// 2. Reserve vector capacity
std::vector<int> v;
v.reserve(1000);  // Pre-allocate
for (int i = 0; i < 1000; ++i) v.push_back(i);

// 3. Use emplace_back instead of push_back
v.push_back(Person("Alice", 30));    // Constructs then moves
v.emplace_back("Alice", 30);         // Constructs in place

// 4. Avoid std::function in hot paths (has type erasure overhead)
// Use templates or auto instead

// 5. Move instead of copy
std::string s = std::move(other);  // O(1) instead of O(n)

// 6. Use std::string_view for read-only string params
void process(std::string_view sv);  // No allocation

// 7. Reserve for map/unordered_map if size known
std::unordered_map<int, std::string> m;
m.reserve(10000);

Compiler Optimization Flags

# Common optimization levels
-O0    # No optimization (debug)
-O1    # Basic optimization
-O2    # Standard optimization (default for release)
-O3    # Aggressive optimization (loop unrolling, inlining)
-Os    # Optimize for size
-Ofast # O3 + fast math (may break IEEE compliance)

# Link-time optimization (LTO)
-flto  # Whole-program optimization at link time

# Profile-guided optimization (PGO)
-fprofile-generate  # Step 1: instrument
-fprofile-use       # Step 2: optimize based on profile

# Architecture-specific
-march=native       # Optimize for current CPU
-mtune=native       # Tune for current CPU

# Example:
g++ -O3 -march=native -flto -DNDEBUG -o program program.cpp

Common Mistakes

Mistake 1: Optimizing without profiling

"If you optimize without profiling, you're guessing." Always measure first.

Mistake 2: Using -O0 for Performance Testing

Debug builds are 10-100x slower. Benchmark with -O2 or -O3.

Mistake 3: Micro-benchmarking with dead code elimination

for (auto _ : state) {
    result = compute();  // Optimized away if unused!
}

Use benchmark::DoNotOptimize(result).

Mistake 4: Ignoring cache effects

The difference between L1 hit (1ns) and main memory (100ns) is 100x. Use Cachegrind.

Mistake 5: Assuming O3 is always faster

O3 increases code size, which can cause cache pressure. Test O2 vs O3.

Practice Questions

  1. What is the difference between perf and Callgrind? Answer: perf uses hardware counters with low overhead (statistical sampling). Callgrind does instruction-level simulation with exact counts but 10-50x slowdown.

  2. What does a flame graph show? Answer: Stack samples over time. Wider bars = more CPU time. Parent functions below children.

  3. What is the L1 cache miss penalty vs main memory? Answer: L1 hit ~1ns, L2 ~10ns, L3 ~40ns, main memory ~100ns.

  4. What compiler flag should you use for release builds? Answer: -O2 -DNDEBUG (or -O3 -march=native -flto for maximum performance).

  5. How do you prevent the compiler from optimizing away a benchmark? Answer: Use benchmark::DoNotOptimize(result) or asm volatile("" : "+r"(var)).

FAQ

What is C++ profiling

Profiling measures where a program spends CPU time, memory bandwidth, cache misses, and other resources. Tools include perf, Valgrind, and Google Benchmark.

When should I optimize C++ code

After profiling. Find the top 1-2 bottlenecks and optimize those. Most code is not a bottleneck — optimizing non-hot code wastes time and can hurt readability.

What is the difference between O2 and O3

O3 enables more aggressive optimizations like loop unrolling and function inlining over O2. O3 can be slower if it increases code size too much (icache pressure).

What is profile-guided optimization (PGO)

PGO uses runtime profiling data to guide compiler decisions (inline, branch prediction, basic block ordering). It requires a two-step build: instrument, train, then optimize.

How do I identify cache misses

Use Cachegrind (valgrind --tool=cachegrind) or perf (perf stat -e cache-misses). A high miss rate indicates poor data locality.

Mini Project

Profile and optimize a text processing function that counts word frequencies in a large file:

#include <iostream>
#include <fstream>
#include <unordered_map>
#include <string>
#include <vector>

// Profile this function and optimize it
std::unordered_map<std::string, int> countWords(const std::string& filename) {
    std::ifstream file(filename);
    std::unordered_map<std::string, int> freq;
    std::string word;

    while (file >> word) {
        // Convert to lowercase
        for (char& c : word) {
            c = std::tolower(c);
        }
        ++freq[word];
    }

    return freq;
}

int main() {
    auto freq = countWords("large_text.txt");

    // Print top 10 words
    // (use std::partial_sort_copy for efficiency)
    std::cout << "Unique words: " << freq.size() << "\n";

    return 0;
}

Optimizations to try:

  1. Pre-allocate unordered_map buckets
  2. Replace char-by-char tolower with SIMD or table lookup
  3. Use string_view to avoid copies
  4. Parallelize with std::thread

This project demonstrates real-world C++ optimization — profiling identifies the bottleneck; targeted changes fix it. Compare with Java profiling tools (JProfiler, VisualVM).

What's Next

You now profile and optimize C++ code with data-driven techniques. Next, you will learn best practices and coding standards — the conventions and patterns that make C++ code maintainable, portable, and correct.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro