ハクソク

世界を動かす技術を、日本語で。

40行の修正で400倍のパフォーマンスギャップを解消

概要

OpenJDKのThreadMXBean.getCurrentThreadUserTime()のLinux実装が大幅に高速化。 /procファイル読み取りからclock_gettime()への移行による40倍のパフォーマンス向上。 Linux固有のclockid_tビット操作でユーザーCPU時間のみ取得可能に。 カーネルの高速パス利用で更なる最適化の可能性。 このビット操作は公式ドキュメントに乏しいが、20年以上安定運用。

OpenJDKのスレッドCPU時間取得の劇的高速化

  • OpenJDKのThreadMXBean.getCurrentThreadUserTime()は、Linux上で/proc/self/task/<tid>/statファイルをパースしてユーザーCPU時間を取得していた従来実装。
  • この方法は複数のシステムコール、VFS経由のファイルI/O、カーネル側での文字列生成、ユーザー空間での複雑なパース処理を必要とし、非常に低速
  • 比較対象のgetCurrentThreadCpuTime()は**clock_gettime(CLOCK_THREAD_CPUTIME_ID)**を直接呼び出すだけで、ファイルI/Oもパースも不要。
  • ベンチマークでは従来実装が平均11マイクロ秒、新実装では279ナノ秒に短縮、40倍の高速化を達成。
  • 並列実行時はカーネルリソースのロック競合も発生しやすく、/proc方式はさらに不利。

なぜ2つの実装があったのか

  • POSIX標準ではCLOCK_THREAD_CPUTIME_IDはユーザー+システム時間の合計のみ取得可。
  • ユーザー時間のみ取得するポータブルな方法は標準化されていないため、/proc方式が使われていた経緯。
  • Linux固有の内部仕様を活用することで、より効率的な実装が可能に。

Linuxカーネルのclockid_tビット操作

  • Linux 2.6.12以降、clockid_t値にクロック種別情報をビットでエンコード
    • Bit 2: スレッド vs プロセスクロック
    • Bits 1-0: クロック種別(00=PROF, 01=VIRT(ユーザー時間のみ), 10=SCHED(合計), 11=FD)
    • 残りビットはPID/TIDをエンコード
  • pthread_getcpuclockid()はPOSIX準拠のSCHED(合計)クロックIDを返すが、下位ビットを01(VIRT)に変更することでユーザー時間のみ取得可能
  • 新実装ではこのビット操作を用い、ファイルI/Oやパースを完全排除

カーネルの高速パス活用による更なる最適化

  • clockid_tにPID=0(現在のスレッド)をエンコードすると、カーネルはradix tree探索をスキップし、直接カレントタスクにアクセス。
  • pthread_getcpuclockid()経由ではTIDがセットされるため、毎回radix tree探索が発生。
  • clockid_tを手動で構築しPID=0を指定すれば、より高速なパスが利用可能。
  • JVMは既にclockidのビット操作を行っているため、完全自作も技術的には問題なし。

公式ドキュメントと安定性

  • このclockid_tのビット仕様はmanページ等の公式ドキュメントにはほぼ記載なし
  • カーネルソースコードやCPU_CLOCK_*マクロでのみ確認可能。
  • 20年以上仕様変更なし、glibcも依存しているため今後も安定運用が見込まれる。

まとめ

  • OpenJDKのLinux実装は、Linuxカーネルの内部仕様を活用してスレッドCPU時間取得を大幅高速化。
  • さらにカーネルの高速パスを活かす余地も存在。
  • POSIX非準拠だが実用上安定、パフォーマンスが重要な場面では非常に有用な知見。

Hackerたちの意見

著者です。前回のカーネルバグについての投稿の後、JVMがスレッドのアクティビティをどう報告しているかを調べてみました。「このスレッドのCPU時間はどれくらいですか?」という質問は、思ったよりもずっと高コストな質問だったことがわかりました。
分布の大きなばらつきについて調べてみましたか?中には何桁も跨っているものがあって、面白いですね。
ナノ秒の分数について話すには、時計の安定性と精度を非常に良く理解している必要があると思います。せいぜい何らかの減少があると主張できるかもしれませんが、測定された時間が本当に正確であることを確認するために大量の準備作業をしない限り、絶対的な主張はすごく難しいです。大きな誤差があるかもしれないし、その違いに気づかないこともあります。だから、これらの測定に隠れた原子時計が関与していない限り、何らかの形で条件を付けるべきだと思います。
1行要約の編集に感謝!これについて考え直したんだけど、低品質なコメントになっちゃった;そんなTLDRを出すことで、特にHNではコンテンツを読む価値が大いに増すよね。短い形式で読むのは、まるで先に教えてくれるクールな友達みたいな感じ。
なんてサプライズだ。
すごくいいまとめですね!
clock_gettime()はvDSOを通って、コンテキストスイッチを回避します。フレームグラフにも表示されますよ。
編集:ああ、違う。CLOCK_THREAD_CPUTIME_IDはvDSOを通ってカーネルに行くので、タスク構造体を見なければならないのは理解できます。ここでタスク構造体を取得します:https://elixir.bootlin.com/linux/v6.18.5/source/kernel/time/... そしてここ:https://elixir.bootlin.com/linux/v6.18.5/source/kernel/time/... ここで実際に値を取り出します:https://elixir.bootlin.com/linux/v6.18.5/source/kernel/sched... ここがvDSOの時計選択ロジック:https://elixir.bootlin.com/linux/v6.18.5/source/lib/vdso/get... もしvDSO時計でない場合は、ここがシステムコールへのフォールバックです:https://elixir.bootlin.com/linux/v6.18.5/source/lib/vdso/get...
一部の時計(CLOCK_MONOTONICなど)と一部の時計ソースに限ります。VIRT/SCHEDの場合、vDSOシムは実際のシステムコールを呼び出さなければなりません。スレッドごとのアカウンティングが必要な場合、カーネルの遷移を避けることはできません。
vDSOフレームの下を見ると、まだsyscallがあるね。この特定のクロックIDに対するvDSOの実装には、早いパスが欠けてると思う(実装できるかもしれないけど)。
ソフトウェアのperfイベントを使うと、もっと速く、約8ns(ほぼ10倍の改善)になるよ。PERF_COUNT_SW_TASK_CLOCKはスレッドのCPU時間で、共有ページを通じて読み取れるからsyscallは不要(perf_event_mmap_pageを参照)。それから、最後のコンテキストスイッチ以降のデルタを、seqlock内の単一のrdtsc呼び出しで加算するんだ。残念ながら、これはあまり文書化されてないし、オープンソースの実装も知らない。追記:でも、PERF_COUNT_SW_TASK_CLOCKがユーザー時間だけを選択できるかは分からない。カーネルは確実にできるけど、配線があるかは不明。ただ、これが全体のスレッドCPU時間には確実に効果があるよ。
それは素晴らしいトリックだね。perf_eventのセットアップオーバーヘッドや権限の要件は、任意のスレッドには重いかもしれないけど、長寿命のスレッドにはかなり素晴らしいと思う!シェアしてくれてありがとう!
QuestDBチームはその分野で最高の一つだね。人たちもソフトウェアも大好きだよ。素晴らしいブログだ、ジャロミール!
> フレームグラフ画像 > クリックしてズーム、新しいタブでインタラクティブに表示 まさか「新しいタブで画像を開く」が本当にその通りになるとは思わなかった。SVGで可能だとは知ってたけど、実際に見たことはなかったし、全然期待してなかった。
ブレンダン・グレッグと彼のflamegraph.plスクリプトのおかげで: https://github.com/brendangregg/FlameGraph 普段はasync-profilerに含まれているジェネレーターを使ってる。インタラクティブなHTMLを生成するんだけど、今回はブレンダンのツールを使って、単一のインタラクティブなSVGを作ったよ。
これは、適切な場所での小さな変更が、何年もの漸進的なチューニングを上回る良い例だね。
「オーガニック」やレガシーコードのパフォーマンス改善に努力を注いだ後、10倍未満のスピードアップを見たことはないと思う。誰かが文句を言う前に、コードがどれだけ遅いかはいつも衝撃的だよ。
フレームグラフって素晴らしいよね。俺: コードを見て「うん、まあ、悪くないかな。」 俺: 結果のフレームグラフを見て「これ、何なんだよ?!?!?」 こんな風にして、コードベースでいろんなクレイジーなものを見つけたよ。静的初期化子が静的じゃなかったり、高コストなシリアライズを引き起こすワンライナーのロガー呼び出し、パターンをメモ化しない重い文字列解析の呼び出しとかね。残念ながら、その中には俺のせいのもあるんだ。
これにはアイスクリームグラフも好きだな。フレームグラフの逆順に集約されたものなんだ。(つまり、A->B->CとD->E->Cの呼び出しがあった場合、Cへの呼び出しはBやEの上に積み重ねるんじゃなくて、まとめて表示されるんだ。共通のライブラリに時間をかけすぎているときに、いろんな異なるコードパスがあると、何が問題か見やすくなるよ。)普通のフレームグラフもいいけど、アイスクリームグラフはツールボックスの別の道具って感じだね。
俺がすごく間違ってるかもしれないけど、文字列の解析や操作、メモ化って、すごく変な組み合わせに聞こえるんだけど?最初のは高コストなアロケーションをやってるのは分かるけど、2つ目はJSのコードベース以外ではあまり見ないパターンだよね。実際にどうやってこれが問題になったのか、もう少し詳しく教えてもらえる?文字列のメモ化って、複雑でエラーが起こりやすい「うーん、今は良くなった気がする」領域に思えるから、ちょっと興味あるんだ。
新しいタブで開くと、SVG [0] がインタラクティブになるのもいいね!セクションをクリックするとズームインできるし、ズームレベルをリセットするボタンもあるよ。[0]: https://questdb.com/images/blog/2026-01-13/before.svg
フレームグラフは使ったことないけど、もっと知りたいな。詳しく説明してくれる?それとも、どこから始めればいいかな?
この問題に対処するのに、初回のバグレポートから7年かかったんだ(2018年)。プロファイルされたコードのホットパスでCPU時間を計測するのがどれだけ重要かを考えると、かなり長いよね。
70nsより400倍遅いって言っても、28μsに過ぎないよね。この関数はJVMからどのくらいの頻度で呼ばれてるの?
詳しい人、procfsからの読み込みのオーバーヘッドを大幅に減らすことってできるのかな?俺の理解が正しければ、そこにあるものは全部メモリ上にあるから、データを読み込むのに10μsもかかる理由はないと思うんだけど。