はじめに

前回の記事では、perf-counter を使って特定のコード区間のイベントを計測する方法を紹介しました。 今回は特に、キャッシュミスや分岐予測ミスなどのハードウェアイベントを x86-64 Linux 上で計測する方法について解説します。

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

CPU には、ハードウェアイベントをカウントするための専用レジスタ PMC(Performance Monitoring Counter)が搭載されています。一般的な CPU では、コアあたり 4〜12 個程度の PMC が利用可能です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 では特定のコード区間の完全な情報を取得したいので、時分割を行わないように設定しています。 この方法のデメリットは、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 = 1exclude_hv = 1 は、計測対象をユーザー空間のコードに限定する設定です。これにより、環境によって実行に sudo 権限が必要になるのを回避しています。 ただし、計測区間内にシステムコール呼び出しが含まれる場合などに、一部のイベントが計測対象外となることに注意が必要です。

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

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

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

しかし システムコールの呼び出しはユーザーモードからカーネルモードへの切り替えを伴い、1回あたりだいたい数百サイクル以上かかります。カウンタから値を読み出すたびにこのコストがかかると、計測のオーバーヘッドが無視できません。

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

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」を保持しているためです。

カウンタの値は、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 にそれまでの累積値が保存されるため、これを加算することで正しいカウンタ値が得られます。

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

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

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

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

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

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

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

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

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

これを防ぐために、perf_event_mmap_page 構造体にはシーケンスロックとして機能する lock フィールドが用意されています。カウンタの値を読み出すときは、読み出しの途中で 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 のフィールドの読み出し順序を入れ替えたりするのを防ぐためのものです。

まとめ

  • x86-64 Linux においてハードウェアイベントを低オーバーヘッドで計測する方法を解説しました。
  • perf_event_open でハードウェアイベントのカウンタを開き、mmap でメタデータページをマッピングすると、rdpmc 命令を使ってカウンタの値を取得できるようになります。
  • read システムコールを使う方法と異なりカーネルモードへの切り替えが不要なため、計測のオーバーヘッドを減らすことができます。

参考