Claude Media
Claude CodeでOpenMPのデータ競合をThreadSanitizerで見つけて直す

Claude CodeでOpenMPのデータ競合をThreadSanitizerで見つけて直す

OpenMPの並列ループで共有変数が競合するバグを、-fsanitize=threadのビルドで検出します。報告をhookでClaude Codeへ返し、reductionやprivateで直させる手順です。

OpenMPの #pragma omp parallel for に reduction や private を付け忘れると、合計値が実行のたびにずれます。ThreadSanitizer(TSan)は、複数のスレッドが同じ変数に同時にアクセスし、少なくとも一方が書き込みであるデータ競合を実行時に見つけるC/C++向けの検出器です。-fsanitize=thread を付けてビルドし直すだけで、競合した2か所の行番号が報告に出ます。

この記事は、その報告をClaude Codeに読ませて修正まで回す設定をまとめます。肝は1つで、TSanは競合を見つけると終了コード66で落ちるため、報告は PostToolUse ではなく PostToolUseFailure に届きます。この違いを知らずに PostToolUse へ書くと、hookは一度も動きません。

競合は「合計値のずれ」として現れる

次のコードを例にします。配列の合計を取るループで、共有の sum に全スレッドが加算し、作業用の tmp も共有されています。

#include <stdio.h>
#include <omp.h>
 
int main(void) {
  double sum = 0.0, tmp;
  #pragma omp parallel for
  for (int i = 0; i < 1000000; i++) {
    tmp = i * 0.5;
    sum += tmp;
  }
  printf("sum=%f\n", sum);
  return 0;
}

スレッドが1本なら正しい値が出るので、通常のテストでは気づきにくいバグです。2本以上で走ると、sum の読み出し・加算・書き戻しが交錯して加算が失われ、tmp は別のスレッドの値で上書きされます。

直し方は変数の性質で決まります。

変数の使われ方典型的な直し注意
全スレッドが足し込む合計・最大値典型的な直しreduction(+:sum) など注意浮動小数点では加算の順序が変わり、末尾の桁がずれることがある
反復ごとに書き直す作業変数典型的な直しprivate(tmp)、または宣言をループ内へ移す注意宣言の移動のほうが差分が小さく、付け忘れも起きない
配列の要素を反復ごとに別々に書く典型的な直し通常は競合しない注意添字が反復間で重なっていないかを確認する
上のどれでも表せない更新典型的な直しatomic や critical注意直列化されて遅くなるため、最後に検討する

この表の優先順位は、後で CLAUDE.md に書いてClaudeへ渡します。

TSan付きの実行をmake tsanにまとめる

TSanの使い方は、マニュアルでは「-fsanitize=thread を付けてコンパイルとリンクをする」と案内されています。実行速度のために -O1 以上、報告にファイル名と行番号を出すために -g を付けます。OpenMPのコードには -fopenmp も必要です。

Claude Codeに毎回フラグを組み立てさせないよう、ビルドから実行までを1つのターゲットにします。

CC      = clang
TSANFLAGS = -fopenmp -fsanitize=thread -g -O1
 
tsan: race.c
	$(CC) $(TSANFLAGS) race.c -o race_tsan
	OMP_NUM_THREADS=4 ./race_tsan

CC ?= clang ではなく CC = clang と書くのは、makeが CC に最初からccを入れていて、?= では上書きされないからです。ccがgccを指すLinuxでは、そのままだとgccとlibgompでビルドされます。gccで代用する場合、libgompはTSanに計装されていないため、実際には競合していない箇所が報告される偽陽性が増えます。

OMP_NUM_THREADS=4 を固定しているのは、スレッド数が1だと競合が起きようがないからです。TSanが見つけられるのは実行された経路の競合だけなので、並列ループを通る入力でテストを回すことも欠かせません。

ターゲットに tsan と名付けておくと、後でhookの if で Bash(make tsan*) と絞り込めます。Makefileの検証ループ全般はmake checkを許可して回す手順に、CMakeを使う構成ならctestを検証コマンドにする手順にまとめています。tsan も同じ枠に加えるだけです。

macOS標準のApple clang 21.0.0では、-fopenmp を付けると clang: error: unsupported option '-fopenmp' で止まります。OpenMPのランタイムを別途入れた環境か、Linuxで試す前提で読んでください。

競合の報告はPostToolUseFailureに届く

TSanは、競合を報告するとプロセスの終了コードを66にします。exitcode というフラグの既定値が66で、clangの文書にも「他のサニタイザーと違い、TSanの既定は66」と書かれています。

Claude Codeの側では、Bashコマンドが0以外で終了するとツール呼び出しが失敗として扱われます。hooksのリファレンスでは、PostToolUse は「ツールが正常に完了した直後」に動くイベントで、失敗したときは PostToolUseFailure に入ります。失敗の入力には、先頭行が Exit code N で、その後に標準出力と標準エラーが交互に入った error 文字列が入ります。

つまり終了コードの設定で、hookを置くイベントが変わります。

TSanの終了コードBashの結果hookが動くイベント報告の入り口
66(既定)Bashの結果失敗hookが動くイベントPostToolUseFailure報告の入り口入力の error
0(TSAN_OPTIONS=exitcode=0)Bashの結果成功hookが動くイベントPostToolUse報告の入り口入力の tool_response

exitcode=0 にすると競合があっても成功扱いになり、Claudeが見落としやすくなります。既定の66のまま、PostToolUseFailure で拾うほうが素直です。

なお error は長い出力だと途中が切り詰められます。リファレンスには、長い文字列は ... [N characters truncated] ... の印を挟んで中央が省略されるとあります。報告が長くなるプログラムでは、ターゲット側で 2> tsan.log に落とし、hookにはそのファイルを読ませる構成も使えます。

報告を抽出してClaudeへ返すhook

.claude/settings.json に、make tsan が失敗したときだけ動くhookを置きます。if はpermissionルールの書式で、Bash(make tsan*) のようにコマンドで絞り込めます。

{
  "hooks": {
    "PostToolUseFailure": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(make tsan*)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tsan-report.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

スクリプトは標準入力のJSONから error を取り出し、TSanの警告があれば自分のソースのフレームだけを抜き出して additionalContext で返します。PostToolUseFailure は、このフィールドで失敗したツール結果の横にClaudeへの補足を足せます。

#!/bin/bash
input=$(cat)
text=$(echo "$input" | jq -r '.error // empty')
echo "$text" | grep -q 'WARNING: ThreadSanitizer: data race' || exit 0
 
frames=$(echo "$text" | grep -E '^\s+#[0-9]+ ' \
  | grep -v -E 'libgomp|libomp|libtsan' | head -8)
 
jq -n --arg f "$frames" '{
  hookSpecificOutput: {
    hookEventName: "PostToolUseFailure",
    additionalContext: ("TSanがdata raceを報告した。自分のコードのフレーム:\n"
      + $f + "\nparallel for内の共有変数を洗い、CLAUDE.mdの優先順位で直すこと。")
  }
}'

OpenMPやTSanのランタイム由来のフレームを grep -v で落としているのは、Claudeが読むべき行を、自分のソースの行番号に絞るためです。終了コードは0のまま、JSONだけを標準出力に出します。

スクリプトの抽出部分は、報告を模したJSONを渡すだけで試せます。

printf '%s' '{"error":"Exit code 66\nWARNING: ThreadSanitizer: data race (pid=1)\n  Write of size 8 by thread T2:\n    #0 main.omp_outlined race.c:7 (a.out+0x1)\n    #1 __kmp_invoke_microtask libomp.so (x)\n"}' \
  | .claude/hooks/tsan-report.sh

libomp.so のフレームが落ち、race.c:7 の行だけが additionalContext に残ります。WARNING を含まない失敗(コンパイルエラーなど)では何も出力せずに終わるので、他の失敗には干渉しません。

失敗時にhook自身が操作を止める onFailure の設定は、onFailureで失敗時に操作を止める設定にあります。

CLAUDE.mdに書く直し方の優先順位

hookが返すのは「どの行が競合したか」だけで、どう直すかは返しません。直し方の方針は CLAUDE.md に置きます。Claudeは競合を最短で消そうとして、critical で全体を囲むことがあるからです。

## OpenMPのデータ競合を直すとき
- 検証は `make tsan`。警告が0件になるまで繰り返す
- 直す順序: 1) reduction  2) private か宣言をループ内へ移す
  3) atomic  4) critical(最後。遅くなる理由をコメントに書く)
- TSANの suppressions ファイルや `-fno-sanitize=thread` を足さない。
  誤検出と疑うときは直さず、報告のフレームを人に見せる
- reduction に変えたら、直列実行の結果と許容誤差内で一致することを
  テストで確かめる

最後の行が大切です。reduction にすると浮動小数点の加算順序が実行ごとに変わることがあるため、直列の結果とビット単位で一致するテストは通らなくなります。許容誤差つきの比較に変える作業まで、Claudeに任せられます。

exitcode=0にする場合はPostToolUseに置く

CIの都合などで TSAN_OPTIONS=exitcode=0 にする運用なら、競合があってもコマンドは成功します。その場合は同じスクリプトを PostToolUse に登録し、入力の読み取り先を tool_response に変えます。リファレンスでは、Bashの出力は stdout・stderr・interrupted・isImage の4項目を持つオブジェクトとされています。TSanの報告は標準エラーに出るため、読むのは stderr です。

text=$(echo "$input" | jq -r '(.tool_response.stderr // "")')

返すJSONの hookEventName も "PostToolUse" に合わせます。ここを PostToolUseFailure のままにすると、イベントの種類が食い違って補足が届きません。迷う場合は、終了コード66のまま PostToolUseFailure に置く構成から始めるのが安全です。

直す前と後

冒頭のコードを、tmp と sum の扱いを変えて比べます。

前と後

共有していた2変数の直し方

BEFORE

直す前(共有変数のまま)

tmp を外側で宣言し、sum に全スレッドが直接加算します。TSanは tmp への書き込みと sum の更新の両方を報告します。

AFTER

直した後

再実行すると、警告が出なくなります。

直したループは次の形です。tmp はループ内の宣言へ移し、sum は reduction にします。

#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < 1000000; i++) {
  double tmp = i * 0.5;
  sum += tmp;
}

Claude Codeに渡す依頼文は短くて足ります。

make tsan を実行して。data raceの警告が出たら、CLAUDE.mdの優先順位で直して、警告が0件になるまで繰り返して。

警告が出ると失敗として返り、hookの補足が付き、Claudeが修正して make tsan をもう一度走らせます。同じ型の確認は、Goならgo test -raceのレポートを読む手順で扱っています。言語が違っても、検出器の報告から競合する2か所を特定し、直して再実行する流れは同じです。

1件ずつ直すか、全件を出させるか

TSanは既定で、1件目の報告の後も実行を続け、見つけた競合をすべて出します。clangの文書によると、halt_on_error を有効にすると最初の報告で終了します。

  • 既定(全件を出す): 1回の実行で競合が一覧になり、同じ原因の報告をまとめて直せる。hookが返す文字列は長くなる
  • TSAN_OPTIONS="halt_on_error=1": 返る報告は1件で短いが、競合の数だけ実行を繰り返す

並列ループが少ないプログラムなら後者でも十分です。ループが数十あるコードで、競合が同じ変数の使い方から生まれているなら、既定で一覧を出させたうえで、Claudeに「同じパターンの箇所はまとめて直して」と頼むほうが往復が減ります。

報告に failed to restore the stack と出て2つ目のスタックが欠けるときは、マニュアルのFAQにある history_size を増やします。TSAN_OPTIONS="history_size=7" のように指定する例が文書に載っています。2か所目の行が分からない報告はClaudeにとって情報が足りないので、先にこのフラグで補います。

報告が自分のコードを指さないとき

TSanのマニュアルには、すべてのコードを -fsanitize=thread でコンパイルするのが前提で、計装されていないライブラリがあると偽陽性や見逃しが起きることがあると書かれています。OpenMPのランタイムが計装されていない構成では、スレッドの待ち合わせなどの同期をTSanが認識できず、実際には競合していない箇所が報告される可能性があります。

切り分けは報告のフレームの出どころで考えます。

報告の特徴疑うものClaudeへの指示
競合する2か所がどちらも自分のソースの行疑うもの本物の競合Claudeへの指示優先順位に従って直す
片側がOpenMPランタイムの内部で、ソースの行が出ない疑うもの計装されていない同期Claudeへの指示直さず、報告全文を人に見せる
反復ごとに書く配列の別の添字が競合と出る疑うもの添字の重なりClaudeへの指示添字の式を確認し、範囲が重ならないことを示す

マニュアルと文書に載っている回避策は、ignore_noninstrumented_modules や ignore_interceptors_accesses のランタイムフラグと、suppressionsファイルの called_from_lib:<ライブラリ名> です。ただしこれらは「本物の競合の報告」まで黙らせます。CLAUDE.md の例で、Claudeが勝手に足さないよう書いたのはそのためです。足すかどうかは人が報告を読んで決めます。

実行コストにも注意があります。clangの文書では、TSanによる実行の遅延はおよそ5〜15倍、メモリの増加は5〜10倍とされています。そのため make tsan は、本番サイズの入力ではなく小さい入力に対して回します。CLAUDE.md に入力サイズを書いておくと、Claudeが大きなデータで実行して時間切れになるのを避けられます。

まとめ

TSanの終了コード66と PostToolUseFailure を結びつければ、OpenMPの競合の検出から修正までを、hookと CLAUDE.md の2つで回せます。hookは報告から自分のソースの行を拾う役、CLAUDE.md は直し方の順序を決める役と分けると、Claudeが critical やsuppressionsに逃げにくくなります。

逆に言えば、この仕組みで見つかるのは、実行した入力とスレッド数で通った経路の競合だけです。並列ループを通るテスト入力を1つ決めておくところから始めると、いちばん手戻りが少なくなります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. clang.llvm.org/docs/ThreadSanitizer.html
  3. github.com/google/sanitizers/wiki/ThreadSanitizerCppManual
この記事を共有:XはてブLinkedIn