Performance Profiling — perf, Valgrind, Callgrind, Cachegrind, Flame Graphs, Optimization Techniques
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
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.
What does a flame graph show? Answer: Stack samples over time. Wider bars = more CPU time. Parent functions below children.
What is the L1 cache miss penalty vs main memory? Answer: L1 hit ~1ns, L2 ~10ns, L3 ~40ns, main memory ~100ns.
What compiler flag should you use for release builds? Answer:
-O2 -DNDEBUG(or-O3 -march=native -fltofor maximum performance).How do you prevent the compiler from optimizing away a benchmark? Answer: Use
benchmark::DoNotOptimize(result)orasm volatile("" : "+r"(var)).
FAQ
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:
- Pre-allocate unordered_map buckets
- Replace char-by-char tolower with SIMD or table lookup
- Use string_view to avoid copies
- 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