Profiling Specific Code Segments of Applications
– by Jan Mühlig
Understanding the interaction between software and hardware has become increasingly essential for building high-performance applications. The architecture of modern hardware systems has grown significantly in complexity, including deep memory hierarchies and advanced CPUs with features like out-of-order execution and sophisticated branch prediction mechanisms.
Linux Perf, Intel VTune, and AMD μProf are helpful tools for understanding how applications use system resources. However, as these tools are typically designed as external applications, they profile the entire program, making it difficult to focus on specific code segments like particular functions. This limitation is particularly challenging when analyzing micro-benchmarks, where the measured code may represent only a fraction of the overall runtime, or distinguishing between different phases of an application’s execution.
Counting Hardware Events
At their core, these tools leverage Performance Monitoring Units (PMUs)–specialized components designed to track hardware events like cache misses and branch mispredictions. Although these tools are far more powerful, this discussion will focus on the essentials of hardware event counting.
Scenario: Random Access Pattern
Consider a random access micro-benchmark designed to access a set of cache lines in a random sequence—a scenario that typically baffles the data prefetcher (see the full source code). The benchmark employs two distinct arrays: one holding the data and another containing indices that establish the random access pattern. After initializing these arrays, we execute the micro-benchmark by sequentially scanning through the indices array and access data from the data array, a method that generally leads to approximately one cache miss per access within the contiguous data array.
Perf Stat
To observe the underlying hardware dynamics, we utilize the perf stat
command, which quantifies low-level hardware events such as L1 data cache accesses and references during the execution of the micro-benchmark:
perf stat -e instructions,cycles,L1-dcache-loads,L1-dcache-load-misses -- ./random-access-bench --size 16777216
After running, perf stat
displays the results on the command line, in combination with metrics such as instructions per cycle:
Performance counter stats for './random-access-bench --size 16777216': 3,697,089,032 instructions # 0.63 insn per cycle 5,879,736,227 cycles 1,186,826,319 L1-dcache-loads 103,262,784 L1-dcache-load-misses # 8.70% of all L1-dcache accesses 1.202831289 seconds time elapsed 0.799309000 seconds user 0.403155000 seconds sys
Zooming into details, the results reveal 103,262,784
L1d misses for 16,777,216
items, which translates to \(\frac{103,262,784}{16,777,216} \approx 6\) misses per item.
This number significantly surpasses the anticipated single cache miss per item.
The source of this discrepancy lies in the comprehensive scope of the perf stat
command, which records events throughout the entire runtime of the benchmark.
This includes the initialization stage of the benchmark where both the data and pattern arrays are allocated and filled.
Ideally, however, profiling should be confined to the specific segment of the code that interacts directly with the data array to achieve more accurate metrics.
One effective strategy for more control over profiling is to start and stop hardware counters at specific code segments using file descriptors.
This technique is well-documented in the perf stat
man page.
Pramod Kumbhar provides a practical guide to implementing this technique on his blog, though some might find the approach somewhat cumbersome to implement.
Controlling Performance Counters from C++ Applications
Another strategy for achieving refined control over PMUs is to leverage the perf subsystem directly from C and C++ applications through the perf_event_open
system call.
Given the complexity of this interface, various libraries have been developed to simplify interaction by embedding the perf_event_open
system call into their framework.
Notable examples include PAPI, PerfEvent, and perf-cpp, each designed to offer a more accessible gateway to these advanced functionalities.
This article will specifically explore perf-cpp and demonstrate practical examples of how to activate and deactivate hardware performance counters for targeted code segments.
The perf::EventCounter
class in perf-cpp allows users to define which events to measure and provides start()
and stop()
methods to manage the counters.
Below is a code snippet that sets up the EventCounter
and focuses the measurement on the desired code segment:
#include <perfcpp/event_counter.h> /// Initialize the hardware event counter auto counters = perf::CounterDefinition{}; auto event_counter = perf::EventCounter{ counters }; /// Specify hardware events to count event_counter.add({"instructions", "cycles", "cache-references", "cache-misses"}); /// Setup benchmark here (this will not be measured) struct alignas(64U) cache_line { std::uint64_t value; }; auto data = std::vector<cache_line>{}; auto indices = std::vector<std::uint64_t>{}; /// Fill both vectors here... auto sum = 0ULL; /// Run the workload and count hardware events event_counter.start(); for (const auto index : indices) { sum += data[index]; // <-- critical memory access } asm volatile("" : : "r,m"(value) : "memory"); // Ensure the compiler will not optimize sum away event_counter.stop();
Once the EventCounter
is initiated and the events of interest are added, we set up the benchmark by initializing the data and pattern arrays.
Enclosing the workload we wish to measure with start()
and stop()
calls enables precise monitoring of that particular code segment.
Upon stopping the counter, the EventCounter
can be queried to obtain the measured events:
const auto result = event_counter.result(); /// Print the performance counters. for (const auto [name, value] : result) { std::cout << value << " " << name << " (" << value / 16777216 << " per access)" << std::endl; }
The output reflects only the activity during the benchmark, effectively excluding the initial setup phase where data is allocated, and patterns are established:
102,284,667 instructions (6.09664 per access) 992,091,716 cycles (59.1333 per access) 34,227,532 L1-dcache-loads (2.04012 per access) 18,944,008 L1-dcache-load-misses (1.12915 per access)
The results obtained are markedly more explicable than those we got from the perf stat
command.
We observe two L1d cache references per access: one for the randomly accessed cache line and another for the index of the pattern array.
Additionally, there are approximately 1.3
cache misses—one for each data cache line and 0.125
for the access index, as eight indices fit into a single cache line of the pattern array.
Hardware-specific Events
While basic performance metrics such as instructions, cycles, and cache misses shed light on the interplay of hardware and software, modern CPUs offer a far broader spectrum of events to monitor.
However, it’s important to note that many of these events are specific to the underlying hardware substrate.
The perf subsystem standardizes only a select group of events universally supported across different processors (see a detailed list).
To discover the full range of events available on specific CPUs, one can utilize the perf list
command.
Additionally, Intel provides an extensive catalog of events for various architectures on their perfmon website.
In order to use hardware-specific counters within applications, the readable event names need to be translated into event codes.
To that end, Libpfm4 provides a valuable tool that translates event names (from perf list
) into codes.
Let us consider the event CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD
on the AMD Zen4 architecture as an example.
The event quantifies the CPU cycles stalled due to pending memory requests, which is particularly insightful for assessing the effects of cache misses on modern systems.
Intel offers analogous events, such as CYCLE_ACTIVITY.STALLS_MEM_ANY
on the Cascade Lake architecture, and both EXE_ACTIVITY.BOUND_ON_LOADS
and EXE_ACTIVITY.BOUND_ON_STORES
on the Sapphire Rapids architecture.
After downloading and compiling Libpfm4, developers can fetch the code for a specific event as shown below:
./examples/check_events CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD Requested Event: CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD Actual Event: amd64_fam19h_zen4::CYCLES_NO_RETIRE:NOT_COMPLETE_MISSING_LOAD:k=1:u=1:e=0:i=0:c=0:h=0:g=0 PMU : AMD64 Fam19h Zen4 IDX : 1077936192 Codes : 0x53a2d6
Incorporating hardware-specific events into an application with perf-cpp would look something like this:
#include <perfcpp/event_counter.h> /// Initialize the hardware event counter auto counters = perf::CounterDefinition{}; counters.add("CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD", 0x53a2d6); // <-- Event code from Libpfm4 output auto event_counter = perf::EventCounter{ counters }; /// Specify hardware events to count event_counter.add({"cycles", "CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD"}); /// Setup and execute the benchmark as demonstrated above...
This precise tracking reveals that approximately 57
of 59
CPU cycles are spent waiting for memory loads to complete–a finding consistent with the inability of the hardware to predict the benchmark’s random access pattern, relying instead on inherent memory latency:
992,091,716 cycles (59.1333 per access) 967,301,682 CYCLES_NO_RETIRE.NOT_COMPLETE_MISSING_LOAD (57.6557 per access)
However, thanks to sophisticated out-of-order execution, the hardware effectively masks much of this latency, which on the specific machine to execute the benchmark is around 700
cycles.
Summary
Profiling tools play a crucial role in identifying bottlenecks and aiding developers in optimizing their code.
Yet, the broad granularity often means that key code segments tracked with perf stat
can be obscured by extraneous data.
Libraries like PAPI, PerfEvent, and perf-cpp offer a solution by allowing direct control over hardware performance counters from within the application itself.
By leveraging the perf subsystem (more precisely the perf_event_open
system call), these tools enable precise measurements of only the code segments that are truly relevant.