perf c2cでfalse sharingを見つける: 1本のキャッシュラインを奪い合うコードの読み方

スレッドを増やしても速くならないとき疑うべきfalse sharingを、Linuxのperf c2cで見つける方法。記録とレポートのオプション、オフセットの読み方、CPUごとの対応差、緩和策を公式ドキュメントに基づいて整理します。

linuxperfperformancecpu-cache

別々のスレッドが別々の変数を触っているだけなのに、性能が伸びないことがあります。コードを見る限り共有しているデータはないので、ロックも要らないように見えます。ところがCPUキャッシュは変数単位ではなく、キャッシュライン単位でデータをやり取りします。2つの変数が同じラインに入っていて、その一方が頻繁に書き込まれると、もう一方を読み書きするCPUもそのラインを何度も取り直すことになります。これがfalse sharingです。

この記事では、Linuxのperf c2cでそうしたキャッシュラインを見つける方法、レポートのどの列を見ればよいか、見つけた後に何ができるかを扱います。根拠はLinuxカーネルドキュメントのFalse Sharingのページと、perf-c2cのマニュアルです。計測結果や高速化の数値は載せていません。ハードウェアによって結果が大きく違うので、数字を添えるとかえって誤解を招きます。

false sharingが起きる条件

カーネルドキュメントは、有害なfalse sharingの条件を2つにまとめています。

  • 多くのCPUからアクセス(共有)されるグローバルなデータがある。
  • その同時アクセスの中に少なくとも1つ書き込みがある。つまり書き込み同士、または書き込みと読み込みの組み合わせである。

読むだけのフィールドが複数あっても、そのラインの中で書き込みが一切起きない限り、同じラインに置いて問題ありません。ドキュメントの例はこうです。構造体のrefcountとnameが同じキャッシュラインにあり、refcountは1つのCPUが頻繁に増やし、nameは生成時に一度書かれたきり変わりません。それでもnameを読む他のCPUは、refcountが変わるたびにライン全体を読み直さなければなりません。nameそのものは一度も変わっていないのにです。

キャッシュラインのサイズは、カーネルドキュメントの表現では「通常64バイト以上」です。正確な値はプラットフォームごとに確認してください。

ドキュメントが挙げる、よく見られるパターンも知っておく価値があります。

  • ロック(spinlock、mutex、semaphore)と、それが保護するデータを意図的に1つのラインに置いた場合。CPUが少なかった時代にはキャッシュ効率のための配置でしたが、ロック競合が激しい大きなシステムでは、ロックの所有者がデータを書き込んでいる間、他のCPUが同じラインでロックを待ってスピンすることになります。
  • 小さなグローバル変数(4バイトのパラメータなど)がいくつも自然と1つのラインにまとまった場合。
  • 大きな構造体のメンバーが、誰にも気づかれないまま同じラインに並んでいる場合。ドキュメントはmem_cgroupを例に挙げています。

共有はまったく無関係な2つのコンポーネントから来ることもあれば、同じコンポーネントの別のコードパスから来ることもあります。これが診断を難しくしています。コードレビューではなかなか見えず、計測して初めて見えます。

perf c2cが記録するもの

C2CはCache To Cacheの略です。マニュアルはこのツールを「Shared Data C2C/HITM Analyzer」と紹介し、キャッシュラインの競合を追跡するツールだと説明しています。使うイベントからは次の情報が得られます。

  • アクセスしたメモリアドレス
  • アクセスの種類(load/storeの区別と詳細)
  • loadアクセスのレイテンシ(サイクル)

perf c2cはこのデータを集め、HITMアクセスが最も多いキャッシュライン、つまり最も競合しているラインから順に表示します。流れは他のperfツールと同じで、recordで記録し、reportで見ます。

大事なのは、これらのイベントがハードウェアの機能に依存している点です。マニュアルに書かれたアーキテクチャごとの方式は次のとおりです。

アーキテクチャ 使う機能 マニュアルにある注意点
Intel load latencyイベントとprecise storeイベント 既定イベントはcpu/mem-loads,ldlat=30/Pとcpu/mem-stores/P。AUXイベントがあるCPUではグループ形式になる
AMD IBS op PMU ハードウェアの制約によりZen3では非対応
PowerPC しきい値付きのランダム命令サンプリング
Arm64 SPEによるload/storeのサンプリング ハードウェアとカーネルの対応が必要。統計的サンプリングなので、すべてのメモリ操作が拾われるわけではない

つまり「perf c2cを実行すればいい」というのは半分しか正しくありません。CPUモデル、PMU、カーネル、そしてperfのビルドによって使えるイベントが変わります。まずこのシステムで何が使えるかを確かめておくのが得策です。

perf c2c record -e list

マニュアルが案内している、利用可能なイベントの一覧を出す方法です。ここで何も出てこなかったり、イベントを開けないというエラーが出たりしたら、レポートを読む前に環境の問題を片付ける必要があります。-v(--verbose)を付けると、カウンタを開くときのエラーがより詳しく表示されます。

AMDでload latencyフィルタ(-l、--ldlat)を使うには、マニュアルによれば/sys/bus/event_source/devices/ibs_op/caps/ldlatファイルに1が入っている必要があり、値は128から2048の範囲(両端を含む)です。128の倍数のほうがプロファイリングのオーバーヘッドがわずかに少なく、このフィルタは既定では無効です。

小さな再現コード

話を具体的にするため、わざとfalse sharingを起こすCのコードを用意します。2つのスレッドはそれぞれ自分のカウンタだけを増やしますが、2つのカウンタは1つの構造体の中で隣り合っています。

#include <inttypes.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>

#define ITERS UINT64_C(100000000)

struct counters {
    volatile uint64_t a;   /* bump_a スレッドだけが書く */
    volatile uint64_t b;   /* bump_b スレッドだけが書く */
};

static _Alignas(64) struct counters c;

static void *bump_a(void *arg) {
    (void)arg;
    for (uint64_t i = 0; i < ITERS; i++) c.a++;
    return NULL;
}

static void *bump_b(void *arg) {
    (void)arg;
    for (uint64_t i = 0; i < ITERS; i++) c.b++;
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, bump_a, NULL);
    pthread_create(&t2, NULL, bump_b, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("%" PRIu64 " %" PRIu64 "\n", c.a, c.b);
    return 0;
}

この例はキャッシュラインが64バイトであることを前提にしています。カウンタは固定幅のuint64_tなので、どのプラットフォームでもそれぞれ8バイトで、aはオフセット0x0、bはオフセット0x8に置かれます。_Alignas(64)で構造体の先頭を64バイト境界に揃えているので、ラインが64バイトなら2つのカウンタは同じラインに入ります。volatileは、コンパイラがループをレジスタの中だけで済ませてしまわないようにするためのものです。C11としてビルドし、レポートにソース行が出るようデバッグ情報も付けます。

gcc -std=c11 -O2 -g -pthread fs.c -o fs

記録する

マニュアルの基本形はperf c2c record [<options>] <command>です。このプログラムだけを見るならこう書きます。

perf c2c record ./fs

イベントをユーザー空間だけに絞るなら-u(--all-user)、カーネル空間だけなら-k(--all-kernel)を使います。

perf c2c recordは内部で通常のperf recordを呼び出し、既定で-W,-d,--phys-data,--sample-cpuを有効にします。ほかのperf recordのオプションを渡したいときは--の後ろに置きます。マニュアルは、コールチェーンとシステム全体の収集を有効にする例として次を示しています。

perf c2c record -- -g -a

カーネルドキュメントのFalse Sharingのページには、システム全体をコールグラフ付きで3秒間記録する短い形が出てきます。

perf c2c record -ag sleep 3

すでに動いているサービスで原因を探すなら、このようにシステム全体を短く記録するほうが、再現プログラムを別に作れるなら対象のコマンドだけを記録するほうが扱いやすいでしょう。

レポートを読む

perf c2c report --stdio

レポートは既定ではTUIで、--stdioを付けると標準出力に出ます。-i(--input)で別のperf.dataファイルを指定でき、--statsは統計表だけを表示します。stdio出力には4つの表が出ます。

  1. Trace Event Information: メモリアクセス全体の統計
  2. Global Shared Cache Line Event Information: 共有キャッシュライン全体の統計
  3. Shared Data Cache Line Table: コストの高いキャッシュラインの一覧
  4. Shared Cache Line Distribution Pareto: ラインごとにアクセスされたオフセットの一覧

実際に時間を使うのは3番と4番です。

キャッシュラインの一覧

3番の表の各行がキャッシュライン1本です。0始まりのIndex、ラインのアドレスであるCacheline、このラインが全HITMに占める割合(Rmt/Lcl Hitm)、HITMの件数(LLC Load HitmのTotal、LclHitm、RmtHitm)が出ます。ほかに、load/storeの総数、storeがL1にヒットしたか(Store ReferenceのL1Hit、L1Miss、N/A)、loadがどこでヒットしたか(Fill Buffer、L1、L2、LLC、リモート、DRAM)も並びます。

出発点は、上からHITMの割合が高いラインを選ぶことです。既定の並び順は全HITM(tot)で、-d(--display)でrmt、lcl、peerに切り替えられます。Arm64では既定がpeerで、このモードではHITMの列の代わりに、peerキャッシュやDRAMからのloadを数えるLoad PeerやPeer Snoopの列が出ます。同じ問題でも、アーキテクチャによって違う名前の列に現れるということです。

なお、レポートには既定でHITM割合0.0005という下限がかかっていて、それより小さいラインは一覧に出ません。この下限に関係なく、拾われたHITMラインをすべて見るには--show-allを使います。

オフセットの分布

4番の表がfalse sharingを見分ける要です。1本のラインをオフセット単位に分けて見せてくれるからです。各行には次の項目があります。

  • そのオフセットのHITM割合とstore割合
  • Data addressのOffset
  • アクセスしたPidとTid
  • アクセスしたCode addressと、そのSymbol、Shared Object、Source:Line
  • リモート/ローカルHITMと通常loadのサイクル合計
  • アクセスに関わったCPU数(cpu cnt)とNode

この表で見るべきことは単純です。1本のラインの中で、違うオフセットを違うコードが書いているか。 そうならfalse sharingの候補です。逆に、1つのオフセットに複数の書き手が集中しているなら、それは変数そのものを本当に共有している競合です。この場合はフィールドを離しても解決せず、書き込みの回数そのものを減らす必要があります。

上の再現コードなら、ラインが64バイトだとすると、期待する形はこうです。上位のライン1本にオフセット0x0と0x8が現れ、それぞれのコードアドレスがbump_aとbump_bに分かれます。ただしサンプリングに基づくので、どの行がいくつ拾われるかは実行ごとにも、ハードウェアごとにも変わります。この記事には計測結果を載せていません。

オフセット行のまとめ方は-c(--coalesce)で変えます。選べるフィールドはtid、pid、iaddr、dsoで、既定はpid,iaddrです。スレッドごとに分けて見たいならこうします。

perf c2c report --stdio -c tid,iaddr

-N(--node-info)を付けると、Node列がノードIDだけの表示から、ノードごとのCPU数とHITM/store割合、あるいはCPUリストを示す表示に切り替わります。TUIではnキーで切り替えます。

カーネルドキュメントの実例

カーネルドキュメントは、will-it-scaleのtlb_flush1テストを動かしながら次のコマンドで得たレポートの一部を載せています。

perf c2c record -ag sleep 3
perf c2c report --call-graph none -k vmlinux

サマリーではLoad Local HITMが92117、Load Remote HITMが139です。その下にある1本のキャッシュラインのオフセット表を見ると、オフセット0x8には__mod_lruvec_page_state、folio_lruvec_lock_irqsave、lru_add_fn、release_pagesが、オフセット0x10には__mod_memcg_lruvec_stateが並び、ソースの位置はmemcontrol.h:752、mm_inline.h:41、memcontrol.c:736です。1本のラインの中の隣り合う2つのオフセットを、別々の関数が叩いていることが一目でわかります。サマリーの数字からは、リモートHITMよりローカルHITMが圧倒的に多いことも読み取れます。カーネルのデータを見るときは、このように-kでvmlinuxのパスを指定します。

オフセットからフィールド名へ

レポートが教えてくれるのはラインのアドレスとオフセットです。それがどの構造体のどのメンバーかは、別に突き合わせる必要があります。カーネルドキュメントが勧めるツールは次のとおりです。

  • pahole: 構造体のレイアウトをキャッシュライン境界の単位でデコードします。perf c2cのオフセットとpaholeの出力を突き合わせれば、正確なメンバーを特定できます。
  • System.map: カーネルのグローバルデータなら、データアドレスをここで検索します。
  • addr2line: インライン関数が何層にも重なっていて、命令アドレスだけでは場所がわかりにくいときに役立ちます。

ラインが64バイトではなく128バイト単位で動いているように見えることもあります。マニュアルによると、一部のアーキテクチャにはAdjacent Cacheline Prefetchという機能があり、キャッシュラインの共有がラインサイズを2倍にしたかのように振る舞うことがあります。その場合は--double-clで2ライン単位にまとめて検出できます。オフセット表に隣のラインのフィールドが絡んでいるように見えたら、このオプションで見直します。

見つけた後にできること

カーネルドキュメントが挙げる緩和策は4つで、それぞれ実際のカーネルのコミット例が付いています。

  1. 頻繁に書かれるグローバルデータを専用のキャッシュラインに置く。 たとえshort1つでもそうします。代償は、メモリ、キャッシュライン、TLBエントリの消費が増えることです。(例: コミット91b6d3256356「net: cache align tcp_memory_allocated, tcp_sockets_allocated」)
  2. 構造体を並べ替え、干渉し合うメンバーを別のラインに移す。 その代わり、他のメンバー同士で新しいfalse sharingが生まれることがあります。(例: コミット802f1d522d5f「mm: page_counter: re-layout structure to reduce false sharing」)
  3. 可能なら書き込みを読み込みに置き換える。 特にループの中では、無条件に書く代わりに先に読んで必要なときだけ書きます。ドキュメントの例は、set_bit(XXX)の代わりにif (!test_bit(XXX)) set_bit(XXX);、atomicの値ならif (atomic_read(XXX) == AAA) atomic_set(XXX, BBB);という形です。(例: コミット7b1002f7cfe5、292648ac5cf1)
  4. ホットなグローバルデータを「CPUごとのデータ + グローバルデータ」に分ける。 あるいはCPUごとの値をグローバルな値に同期するしきい値を適度に上げ、グローバルへの書き込みを減らすか遅らせます。(例: コミット520f897a3554、56f3547bfa4d)

上の再現コードに1番か2番を当てはめると、2つのフィールドの間にパディングを入れる形になります。

struct counters {
    volatile uint64_t a;
    char pad[64 - sizeof(uint64_t)];
    volatile uint64_t b;
};

64はこの例で仮定したラインサイズにすぎません。ラインがもっと大きいシステムや、隣接ラインのプリフェッチがあるシステムでは、この値では足りないかもしれません。直した後は同じ方法でもう一度記録し、そのラインが一覧の上位から下がったかを確かめるのが順序です。

新しいコードを書くときにカーネルドキュメントが勧める習慣もあります。キャッシュラインの境界を意識すること、主に読まれるだけのフィールドをまとめること、同じタイミングで書かれるフィールドをまとめること、頻繁に読まれるフィールドと頻繁に書かれるフィールドを別のラインに置くこと、そしてそう配置した理由をコメントに残すことです。

直す前に考えること

カーネルドキュメントは、緩和が常に必要なわけではないとはっきり書いています。性能の向上を複雑さやメモリ消費と比べるべきで、あまり使われない構造体や冷えたパスまで最適化する必要はありません。どの緩和策も、副作用がないかを別途確かめる必要があります。

もう1つ、深刻なfalse sharingを見つけて直しても、性能が目に見えて良くならないことがあります。ボトルネックが別の場所に移るからです。修正の前後を同じ条件で計測し、perf c2cの結果で上位のラインがどう変わったかまで併せて見るべきです。

カーネルには構造体メンバーの配置をランダム化する任意の機能があり、それがどのメンバー同士が同じラインを共有するかもランダムにしてしまうことも、ドキュメントに未解決の問題として書かれています。同じカーネルソースでも、設定によって違う結果が出うるということです。

バージョンと範囲

  • この記事のオプションは、2026-10-15に確認したperf-c2cのマニュアル(man7.org版、2026-08-04時点のアップストリームに基づく)と、同日のカーネルソースツリーのドキュメントに基づいています。インストールされているperfがこれより古い場合はオプション構成が違うことがあるので、システムに入っているperf-c2cのマニュアルと照らし合わせてください。
  • 最新のアップストリームのドキュメントには、TUIでTABキーを押すとキャッシュラインではなく関数単位で見られる画面が追加されていますが、man7.org版にはまだありません。この画面が出ない場合はバージョンの違いです。
  • どのイベントが既定で使われるか、HITMの列が出るかpeerの列が出るか、そもそも対応しているかは、CPU、PMU、カーネル、perfのビルドによって異なります。AMD Zen3はマニュアルに非対応と書かれています。
  • レポートの数字はサンプリングの結果です。特にArm SPEについては、すべてのメモリ操作がサンプリングされるわけではないとマニュアル自身が明記しています。件数そのものより、ラインとオフセットの相対的な順位を見る用途で使うほうが安全です。

参考資料

2026-10-15に確認しました。

ブログへ