本稿では、前回の記事で紹介した perf-counter の内部実装を題材に、x86-64 Linux 上でキャッシュミスや分岐予測ミスなどのハードウェアイベントを計測する方法を解説します。

ハードウェアイベント計測の概要

CPU は、ハードウェアイベントをカウントするための専用レジスタ PMC(Performance Monitoring Counter)を持っています。一般的な CPU では、コアあたり 4〜12 個程度が利用可能です1

CPU の実行ユニットやキャッシュコントローラなどは、ハードウェアイベントが発生すると PMU(Performance Monitoring Unit)へ信号を送ります。どのイベントをどの PMC で計測するかあらかじめ設定しておくと、PMU は信号を受け取るたびに対応する PMC の値をインクリメントします。

PMU と PMC

PMU と PMC

Linux 上でハードウェアイベントを計測する手順は、次の 3 つのステップから成ります。

  1. 計測したいイベントのカウンタを開く
  2. カウンタのメタデータを取得する
  3. カウンタの値を読み出す

計測したいイベントのカウンタを開く

まず perf_event_open で計測対象のイベントやプロセスなどを指定してカウンタを開きます。

実行される CPU コアを指定せず、自プロセスのみを計測対象とする場合は、次のように呼び出します。

1
int fd = (int)syscall(SYS_perf_event_open, &attr, 0, -1, group_fd, 0);

attr は計測するイベントの詳細を与える perf_event_attr 構造体です。例えばサイクル数を計測したければ、次のように設定します。

1
2
3
4
5
struct perf_event_attr attr = {0};

attr.size   = sizeof(struct perf_event_attr); // カーネルとのバージョン互換性のための指定
attr.type   = PERF_TYPE_HARDWARE;             // 計測したいイベントの type
attr.config = PERF_COUNT_HW_CPU_CYCLES;       // 計測したいイベントの config

perf-counter ではさらに次のような設定を追加しています。

1
2
3
4
5
6
7
8
if (group_fd == -1)
{
    attr.pinned = 1;
}

attr.disabled       = 1;
attr.exclude_kernel = 1;
attr.exclude_hv     = 1;
  • pinned = 1
    カウンタを常にいずれかの PMC に割り当てたままにする設定です。前述の通り、PMC の個数には限りがあるため、同時に複数のイベントを計測しようとすると、PMC が不足する場合があります。 pinned = 0 を指定すると、そういった場合には、1つの PMC を複数のイベントの計測のために時分割して使います。 perf-counter では特定のコード区間の完全な情報を取得するために、pinned = 1 を指定します。 この方法のデメリットは、PMC の個数より多くのイベントを同時に計測できないことです。
  • disabled = 1
    カウンタを明示的に有効化するまで計測が始まらないようにしています。カウンタの有効化は ioctl(fd, PERF_EVENT_IOC_ENABLE, PERF_IOC_FLAG_GROUP)、無効化は ioctl(fd, PERF_EVENT_IOC_DISABLE, PERF_IOC_FLAG_GROUP) で行います。
  • exclude_kernel = 1, exclude_hv = 1
    計測対象をユーザ空間のコードに限定する設定です。これにより、環境によって実行に sudo 権限が必要になるのを回避しています。 ただし、計測区間内にシステムコール呼び出しが含まれる場合などに、一部のイベントが計測対象外となることに注意が必要です。

カウンタのメタデータを取得する

perf_event_open で開いたカウンタの値を取得する最も簡単な方法は、返り値のファイルディスクリプタに対して read システムコールを呼ぶ方法です。

1
2
uint64_t count;
read(fd, &count, sizeof(count));

しかしシステムコールの呼び出しはユーザモードからカーネルモードへの切り替えを伴うため、一般的には数百サイクル以上かかり、計測のオーバーヘッドが無視できません。

perf-counter では、後述する rdpmc 命令を使ってユーザ空間から直接 PMC の値を読み出します。これには、対象の PMC 番号や補正値といった、カーネルが管理するメタデータが必要です。ユーザ空間からメタデータにアクセスするために、mmap でメタデータページをプロセスのメモリ空間にマッピングします2

1
2
3
long page_size = sysconf(_SC_PAGESIZE);
struct perf_event_mmap_page *metadata_page =
    mmap(NULL, page_size, PROT_READ, MAP_SHARED, fd, 0);

perf_event_mmap_page 構造体のうち、カウンタ値の読み出しに使うのは次の3つのフィールドです。

フィールド 意味
lock __u32 シーケンスロック
index __u32 PMC の識別子(0 は PMC 未割り当て)
offset __s64 カウンタ値に加算する補正値

カウンタの値を読み出す

PMC の値を読み出すために使用するのが、x86-64 の rdpmc(Read Performance-Monitoring Counters)命令です。

ECX レジスタに PMC 番号をセットして実行すると、PMC の値が EDX:EAX に格納されます。 次のコードを実行すると、PMC の値の下位 32 ビットが low に、上位 32 ビットが high に格納されます。

1
2
uint32_t low, high;
__asm__ volatile("rdpmc" : "=a"(low), "=d"(high) : "c"(metadata_page->index - 1));

ここで PMC 番号を metadata_page->index - 1 としているのは、perf_event_mmap_page 構造体の index が「PMC 番号 + 1」を保持している3ためです。

カウンタの値は、lowhigh を結合し、perf_event_mmap_page 構造体の offset を加算して取得します。

1
2
uint64_t count = ((uint64_t)high << 32) | low;
return count + (uint64_t)metadata_page->offset;

後述するように、カーネルはカウンタを別の PMC に割り当て直すことがあります。その際、offset にそれまでの累積値が保存されるため、これを加算することで正しいカウンタ値が得られるようになっています。

命令の実行順序を保証する

特定のコード区間のイベントを正確に計測するためには、コンパイラや CPU によって意図せず命令の実行順序が変更されないようにする必要があります。

例えば、次のようなコードで命令 A と命令 B の実行中に発生したイベントを計測したいとします。

ところが、コンパイラの最適化や CPU のアウトオブオーダー実行により命令の実行順序が変更されてしまうと、目的の区間を計測できません。

perf-counter では、rdpmc の前後に次の文を配置することで、rdpmc をまたいだ命令の並べ替えを防ぎます。

1
__asm__ volatile("lfence" ::: "memory");

lfence は、先行するすべての命令が完了するまで後続の命令の実行を待機させる命令で、CPU のアウトオブオーダー実行による命令の並べ替えを防ぎます。また、"memory" clobber を指定し、コンパイラによる並べ替えも同時に抑制します。

カーネルによるメタデータ更新との競合を防ぐ

カウンタのメタデータページはカーネルによって書き換えられることがあります。例えばコンテキストスイッチが発生すると、カーネルは PMC を割り当て直し、フィールドの値を更新します。この更新が読み出しの途中で起きると、indexoffset が不整合な状態になってしまう可能性があります。

これを防ぐために、perf_event_mmap_page 構造体にはシーケンスロックとして機能する lock フィールドが用意されています。 カウンタの値を読み出す間に lock が変わっていた場合は、カーネルの更新と競合したと判断し、読み出しを最初からやり直します。

 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
28
29
30
31
32
uint32_t seq;
uint32_t low, high;
int64_t offset;

__asm__ volatile("lfence" ::: "memory");

do
{
    // 読み出し前の lock の値
    seq = metadata_page->lock;

    __asm__ volatile("" ::: "memory"); // コンパイラバリア

    const uint32_t index = metadata_page->index;
    offset = metadata_page->offset;

    if (index == 0)
    {
        // PMC 未割り当ての場合の処理
    }

    // PMC の読み出し
    __asm__ volatile("rdpmc" : "=a"(low), "=d"(high) : "c"(index - 1));

    __asm__ volatile("" ::: "memory"); // コンパイラバリア
} while (metadata_page->lock != seq); // 読み出し後に lock が更新されていたら、やり直す

__asm__ volatile("lfence" ::: "memory");

// カウンタ値を求める
const uint64_t count = ((uint64_t)high << 32) | low;
return count + (uint64_t)offset;

ループ内のコンパイラバリアは、コンパイラが lock の値をレジスタにキャッシュしてループの条件判定で使い回すようにしたり、metadata_page のフィールドの読み出し順序を入れ替えたりするのを防ぐためのものです。

参考


  1. LIKWID wiki にマイクロアーキテクチャごとの PMC の種類や個数がまとめられています。 ↩︎

  2. メタデータページの構造は perf_event_open(2) に記載されています。 ↩︎

  3. 0 を PMC 未割り当てとして扱うため ↩︎