Introduction
Welcome to our first lesson in the "Single-Core Performance Optimization" module. In the previous lesson, we concluded our study of the memory hierarchy by learning how to visually inspect C++ code to identify patterns that are unfriendly to the CPU cache, such as strided memory access and pointer chasing. This gave you a theoretical foundation for what "bad" performance looks like at the code level.
Today, we transition from theory to practice. Instead of guessing where our programs are slow, we will learn how to measure them to find performance bottlenecks empirically. This lesson's outcome is to profile C++ code to identify performance bottlenecks using tools like gprof or perf.
Profiling is the cornerstone of all optimization work. By the end of this lesson, you will be able to compile and run your C++ code with two fundamental Linux profiling tools, interpret their output to find the "hot spots" in your program, and understand the trade-offs between different profiling methodologies.
Why Profile? "Don't Guess, Measure."
Before optimizing any code, we must first know where the performance bottlenecks are. It's common for developers to assume they know which part of their code is slow, but reality is often surprising. A famous quote, often attributed to Donald Knuth, states that "premature optimization is the root of all evil." Profiling is the antidote to premature optimization. It is a form of dynamic analysis that measures the behavior of a program as it runs, telling you exactly where it spends its time and resources.
There are two primary approaches to profiling:
- Instrumentation: The compiler inserts special measurement code directly into your program's executable. When the program runs, this code counts function calls and measures execution time.
gprofis a classic example of an instrumentation-based profiler. - Sampling: The profiler periodically interrupts the program (e.g., thousands of times per second) and records what the program was doing at that instant (i.e., which function or instruction was executing). By aggregating thousands of these samples, it builds a statistical picture of where the program is spending its time.
perfis a modern, powerful sampling profiler.
Let's explore both, starting with the classic gprof.
How to Profile C++ Code Running on Linux
To begin, this article from Baeldung provides a concise overview of why profiling is essential for developing large-scale software.
Please read the first two sections, "1. Introduction" and "2. Why We Profile C++ Code Running on Linux". This will reinforce the motivation behind the tools we are about to learn.
Tool 1: gprof – The Classic Instrumentation Profiler
gprof (the GNU Profiler) is a simple and widely available tool. Its workflow is straightforward and involves three steps:
- Compile with profiling flags: You must compile your C++ code using the
g++compiler with the-pgflag. This tells the compiler to inject the necessary instrumentation code. - Run your program: Execute the compiled program as you normally would. This run will produce a new file, typically named
gmon.out, which contains the raw profiling data. - Analyze the data: Run
gprofon your executable, and it will automatically find and processgmon.outto generate a human-readable report.
Let's watch a practical demonstration of this three-step process. The following video walks through compiling a C++ program for profiling, running it, and interpreting the gprof report.
Please watch these two key segments: 00:32 - 05:46: This part covers the necessary compiler flags (-pg, -no-pie, -fno-builtin) and shows the process of compiling and running the code to generate the gmon.out data file. 09:22 - 12:48: Here, the program is run with a larger dataset to produce meaningful results. Pay close attention to the explanation of the two main parts of the gprof report: the flat profile and the call graph.
As you saw in the video, the gprof report has two main components:
- The Flat Profile: This is a table of all the functions in your program, sorted by the amount of time spent inside each one. The most important columns are
% time(the percentage of total execution time spent in that function) andself seconds(the absolute time spent in the function, excluding time spent in functions it called). This table immediately points you to the "hottest" functions. - The Call Graph: This section provides a more detailed, function-by-function breakdown, showing which functions called the current function (its parents) and which functions it called (its children). This helps you understand the context of the performance cost.
gprof is a great starting point due to its simplicity, but it has limitations. The instrumentation adds overhead, which can slightly alter the program's behavior, and it only provides function-level granularity.
Tool 2: perf – The Modern Sampling Powerhouse
perf is a much more powerful and versatile tool that is integrated directly into the Linux kernel. As a sampling profiler, it has very low overhead and can provide a system-wide view of performance, even capturing interactions with the kernel itself.
The perf workflow is also simple:
- Compile with debug symbols: Use the
-gflag withg++. This doesn't instrument the code, but it providesperfwith the necessary information to map performance events back to your source code functions and line numbers. - Record performance data: Run your program under the supervision of
perf record. For example:perf record ./my_program. This will execute your program and generate aperf.datafile. - Analyze the data: Use
perf reportto open an interactive, text-based user interface where you can explore the collected data.
How to profile your C code using gprof and linux perf
This next video segment demonstrates the perf workflow. You'll see how it differs from gprof and how its interactive report allows for a much deeper analysis.
Please watch from 10:37 to 14:57. Focus on the two main commands, perf record and perf report. Notice how the report not only identifies the hot function (time_waster) but also allows you to 'annotate' the function to see the exact assembly instructions that are consuming the most cycles. This level of detail is a major advantage of perf.
The output of perf report is a table that looks something like this:

The real power of perf lies in its ability to monitor specific hardware performance counters. These are special registers in the CPU that count events like cache misses, branch mispredictions, and CPU cycles. This allows you to directly measure the impact of the cache-unfriendly patterns we discussed in the last lesson.
Performance Analysis and Optimization on Linux
To appreciate the capabilities of perf, it's useful to see the variety of events it can monitor. These slides from a CERN tutorial are an excellent reference.
Briefly review slides 18 (perf list hw) and 19 (perf list sw). You don't need to memorize these events, but notice key ones like cache-misses, branch-misses, and cpu-cycles. This is what enables deep, hardware-level analysis and makes perf an indispensable tool for serious optimization.
Hands-On Practice: Find the Bottleneck
Now it's time to apply what you've learned. Here is a C++ program containing a deliberately inefficient function. Your task is to use both gprof and perf to identify it.
Save the following code as profile_test.cpp:
#include <iostream>
#include <vector>
#include <chrono>
const int SIZE = 512; // Reduced size for quicker testing
// A deliberately slow function that accesses memory inefficiently
void process_data_inefficiently() {
std::vector<std::vector<int>> matrix(SIZE, std::vector<int>(SIZE, 0));
// Column-major traversal in a row-major language (C++)
for (int j = 0; j < SIZE; ++j) {
for (int i = 0; i < SIZE; ++i) {
matrix[i][j] = i + j;
}
}
}
// A function that does very little work
void initialize_system() {
int x = 0;
for(int i = 0; i < 1000; ++i) {
x++;
}
std::cout << "System initialized." << std::endl;
}
int main() {
auto start = std::chrono::high_resolution_clock::now();
initialize_system();
process_data_inefficiently();
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "Execution finished in " << diff.count() << " seconds." << std::endl;
return 0;
}
Activity 1: Profiling with gprof (Estimated time: 10 minutes)
- Open your Linux terminal.
- Compile the code with
gprofinstrumentation:
g++ -pg -O2 -o profile_test profile_test.cpp
Note: We use an optimization flag like-O2because profiling unoptimized code is often not representative of real-world performance. - Run the executable:
./profile_test - Analyze the results with
gprof. It's helpful to pipe the output tolessto make it scrollable:
gprof profile_test gmon.out | less - Examine the flat profile at the top of the report. Which function consumes nearly 100% of the execution time?
Activity 2: Profiling with perf (Estimated time: 10 minutes)
- Compile the code with debug symbols:
g++ -g -O2 -o profile_test profile_test.cpp - Record performance data using
perf record:
perf record ./profile_test
(If you get a permissions error, you may need to run this withsudoor adjust your system'sperf_event_paranoidsetting.) - Analyze the data using
perf report:
perf report - You should see an interactive report. The function
process_data_inefficientlyshould be at the top. - Bonus: Navigate to the
process_data_inefficientlyline and press theakey to annotate the source code.perfwill show you the source code of the function with the percentage of samples that occurred on each line. You'll see that the vast majority of time is spent on the linematrix[i][j] = i + j;inside the nested loops. This confirms that our strided memory access is the bottleneck, just as we predicted in the last lesson. Pressqto exit the annotation view andqagain to exitperf report.
Conclusion
In this lesson, you've acquired the fundamental skill of performance profiling. You are no longer "flying blind" but can now scientifically measure your code to find the areas most in need of improvement.
Key Takeaways:
- Profiling is Essential: Always measure before you optimize to focus your efforts effectively.
gprofis a Simple Start: It uses instrumentation and a 3-stepcompile-run-analyzeworkflow to give you a function-level breakdown of where time is spent.perfis a Professional's Tool: It uses low-overhead sampling and is capable of monitoring deep hardware events like cache misses, providing line-by-line and even assembly-level insights.- Workflow is Key: For
gprof, compile with-pg. Forperf, compile with-gfor debug symbols to get source-level annotation.
Preview of the Next Lesson
Now that you can precisely identify performance bottlenecks, the next logical step is to learn how to fix them. In the upcoming lesson, we will begin exploring powerful optimization techniques. Our first topic will be Instruction-Level Parallelism (ILP) and CPU pipelining. You'll learn how modern CPUs execute multiple instructions simultaneously and how you can write code that takes full advantage of this incredible hardware capability.