プログラムを高速化するためには、性能を計測する必要があります。最もシンプルな性能計測方法は、処理の前後にタイマー(C++ であれば std::chrono など)を埋め込む方法で、古典的ながら多くの場合はこれで事足ります。しかし、次のような場合には、タイマー埋め込みだけでは不十分です。

  • 命令数やサイクル数を計測して、理論性能と比較したい
  • キャッシュヒット率や分岐予測ミスの発生率など、性能改善の手がかりとなる情報を集めたい

Linux であれば、こうした情報は perf コマンドで取得できます。例えば、プログラムのサイクル数、実行命令数、L1 データキャッシュミス数を知りたければ、次のように実行すればよいです。

1
perf stat -e cycles,instructions,L1-dcache-load-misses ./my_program

しかし、perf stat はプログラム全体の情報を集計するため、特定のコード区間のプロファイリングには向いていません。 例えば、次のようなプログラムで compute_kernel() 単体の性能を計測したくても、initialize() の分まで集計されてしまいます。

1
2
3
4
5
...
initialize(data, n);     // 前処理

compute_kernel(data, n); // メイン処理
...

そこで作成したのが、プログラム中の特定のコード区間だけを対象にパフォーマンスイベントを計測するライブラリ perf-counter です。

本稿では、perf-counter の基本的な使い方を紹介します。より実践的な使い方や内部実装については、別の記事で扱う予定です。

perf-counter の使い方

perf-counter で特定のコード区間の性能を計測する手順は、次の 4 ステップから成ります。

  1. 計測したいイベントを指定してカウンタを開く
  2. カウンタを有効化する
  3. 対象のコード区間の前後でカウンタの値を読む
  4. カウンタを無効化・解放する

例えば compute_kernel() の実行にかかるサイクル数は次のようにして計測できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 1. サイクル数を計測するカウンタを開く
struct perf_counter pc = perf_counter_open_by_id(
    PERF_TYPE_HARDWARE,       // event_type
    PERF_COUNT_HW_CPU_CYCLES, // event_config
    -1                        // group_fd
);

// 正しく開けたかチェックする
if (!perf_counter_is_valid(&pc)) {
    fprintf(stderr, "Failed to open a cycle counter.\n");
    return 1;
}

// 2. カウンタを有効化する
perf_counter_enable(&pc); // イベントの計測を開始する

// 3. compute_kernel() 呼び出し前後のカウンタの値を読む
uint64_t start = perf_counter_read(&pc);
compute_kernel();
uint64_t end = perf_counter_read(&pc);

// compute_kernel() にかかるサイクル数を出力する
printf("cycles: %lu\n", end - start);

// 4. カウンタを無効化・解放する
perf_counter_disable(&pc); // イベントの計測を終了する
perf_counter_close(&pc);

perf_counter_open_by_id(event_type, event_config, group_fd) の各引数の意味は次の通りです。

  • event_typeevent_configperf_event_open(2) に掲載されている typeconfig の組み合わせを使用して、計測するイベントを指定します。
  • group_fd:単体のイベントを計測する場合は -1 を指定します。複数のイベントを同時に計測する場合は、最初のカウンタを -1 で開き、他のカウンタには最初のカウンタの fd を指定します。

event_typeevent_config の組み合わせには例えば次のようなものがあります。

event_type event_config 計測内容
PERF_TYPE_HARDWARE PERF_COUNT_HW_CPU_CYCLES サイクル数
PERF_TYPE_HARDWARE PERF_COUNT_HW_INSTRUCTIONS 実行命令数
PERF_TYPE_HARDWARE PERF_COUNT_HW_BRANCH_INSTRUCTIONS 分岐命令数
PERF_TYPE_HARDWARE PERF_COUNT_HW_BRANCH_MISSES 分岐予測ミス数
PERF_TYPE_SOFTWARE PERF_COUNT_SW_PAGE_FAULTS ページフォルト数

イベント名を指定してカウンタを開く

サイクル数のような基本的なイベントは単一の定数で指定できますが、イベントによっては複数の定数を組み合わせて event_config を構築する必要があります。例えば L1 データキャッシュミスを計測したい場合、次のようなコードを書くことになり、なかなか面倒です。

1
2
3
4
5
6
uint64_t config =
    PERF_COUNT_HW_CACHE_L1D
    | (PERF_COUNT_HW_CACHE_OP_READ << 8)
    | (PERF_COUNT_HW_CACHE_RESULT_MISS << 16);

struct perf_counter pc = perf_counter_open_by_id(PERF_TYPE_HW_CACHE, config, -1);

そこで便利なのが perf_counter_open_by_name() です。この関数は libpfm4 がインストールされた環境で perf-counter をビルドすると使えるようになり、イベント名でカウンタを開けます。 L1 データキャッシュミスであれば、次のように指定できます。

1
struct perf_counter pc = perf_counter_open_by_name("perf::L1-DCACHE-LOAD-MISSES", -1);

指定可能なイベント名の一覧は、perf-counter に付属の list_all_events.c で取得できます。 ビルド方法は後述の「ビルドと実行」を参照してください。

1
./build/examples/list_all_events events.txt  # events.txt にイベントの一覧を保存

出力の各行はイベント名とその説明から成ります。

...
perf::L1-DCACHE-LOADS # L1 cache load accesses

perf::L1-DCACHE-LOAD-MISSES # L1 cache load misses
...

perf_counter_open_by_name() を使うと、プロセッサ固有のイベントも簡単に計測できます。 例えば、AMD Ryzen 7 4700U (Zen 2) では、次のようにして SSE/AVX の浮動小数点乗算の回数を計測するイベントを開くことができます。

1
2
struct perf_counter pc = perf_counter_open_by_name(
    "amd64_fam17h_zen2::RETIRED_SSE_AVX_FLOPS:MULT_FLOPS", -1);

FMA 命令の性能を計測する例

perf-counter を用いた性能計測の実例として、AVX2 の FMA(Fused Multiply-Add)命令のレイテンシとスループットを算出してみます。perf-counter で命令列の実行にかかるサイクル数を計測し、命令数との比を計算します。 完全なコードは perf_avx2_fma.cpp を参照してください。

計測方法

レイテンシの計測は、依存関係のある FMA 命令をたくさん並べることで行います。前の命令が終わるまで次の命令が実行できないため、1命令ずつ逐次的に実行されます。経過サイクル数を命令数で割れば、1つの命令の実行に何サイクルかかるか(CPI)というレイテンシが計測できます。

1
2
3
4
vfmadd231ps ymm0, ymm1, ymm1  ; ymm0 = ymm0 + ymm1 * ymm1
vfmadd231ps ymm0, ymm1, ymm1  ; ymm0 = ymm0 + ymm1 * ymm1
vfmadd231ps ymm0, ymm1, ymm1  ; ymm0 = ymm0 + ymm1 * ymm1
...
レイテンシの計測

レイテンシの計測

スループットの計測は、依存関係のない FMA 命令をたくさん並べることで行います。各命令は互いに独立しているため、並列に実行できます。命令数を経過サイクル数で割れば、サイクルあたり同時に何命令実行できるか(IPC)というスループットが計測できます。

1
2
3
4
vfmadd231ps ymm1, ymm0, ymm0  ; ymm1 = ymm1 + ymm0 * ymm0
vfmadd231ps ymm2, ymm0, ymm0  ; ymm2 = ymm2 + ymm0 * ymm0
vfmadd231ps ymm3, ymm0, ymm0  ; ymm3 = ymm3 + ymm0 * ymm0
...
スループットの計測

スループットの計測

いずれの計測も3回のウォームアップ実行の後に10回実行し、最小値を採用することにします。これは OS のスケジューリングや割り込みによるノイズを排除するためです。

ビルドと実行

サンプルプログラムは -DPERF_COUNTER_BUILD_EXAMPLES=ON を付けることでビルドされます。

1
2
3
cmake -B build -DCMAKE_BUILD_TYPE=Release -DPERF_COUNTER_BUILD_EXAMPLES=ON
cmake --build build
./build/examples/perf_avx2_fma

実行結果

実行環境:AMD Ryzen 7 4700U (Zen 2)、GCC 15.2.1

AVX2 vfmadd231ps - Latency
  Instructions : 1200000
  Best Cycles  : 6000062
  CPI          : 5.000
  IPC          : 0.200

AVX2 vfmadd231ps - Throughput
  Instructions : 1200000
  Best Cycles  : 600066
  CPI          : 0.500
  IPC          : 2.000

レイテンシ 5.000 サイクル/命令、スループット 2.000 命令/サイクルという結果が得られました。 これらはuops.infoの値と一致しています。

参考