JProfiler MCPでjclasslibのパフォーマンスを2倍にした方法
最近、私はオープンソースのJVMバイトコードビューアであるjclasslibのパフォーマンスを、Codexとプロファイリングエージェントを使って改善しました。特定の痛みの種があったからではなく、JProfiler 16.1に搭載された私たちのMCPサーバーを試してみたことがきっかけです。以下では、エージェントがクラスファイルの読み込みを2倍以上に高速化した経緯と、プロファイリングを開始する理由がもはや必要なくなった理由を解説します。
出発点
jclasslibは、バイトコードを読み書きするライブラリと、それを表示・編集するSwing UIという2つの主要部分から成り立っています。このプロジェクトは、クラスファイル読み込みの業界標準となっているASMが存在する以前に始まりました。ビューアはクラスファイルリーダーが生成する表現に密接に結び付いているため、乗り換えを検討したことは一度もありませんでした。
ASMはリソース使用量に対して強く最適化されており、jclasslibのように構造全体をメモリに読み込む代わりにビジターパターンを実装しています。実際に表示される少数のクラスファイルの読み込みにはごくわずかな時間しかかからないため、これは一度も問題になりませんでした。しかし、jclasslib 7.0の新機能Find usagesはクラスパス全体をスキャンします。クラスファイルの読み込み速度が突然顕在化しましたが、それでも許容範囲だったため、追及はしませんでした。


最適化
JProfiler MCPが利用可能になった際、私はCodexにクラスファイルの読み込みの高速化に取り組んでもらうよう頼みました。すると、あっという間に一連のコミットが生成されました。エージェントが見つけたものを最もはっきりと示しているのはこのコミットです。このコミットは、高コストなマップの二重生成を取り除いています。コードを見ていて私が気付くことは到底なく、プロファイリングデータを見ても「プログラムが本来やるべきことをやっているだけ」という括りに入ってしまうような箇所です。しかしCodexは、マップの生成を2つの発生元まで遡り、それを1つに統合しました。
この1つのコミットだけで、プロファイリング対象のユニットテストは662 msから410 msへと短縮され、38%の高速化となりました。同じ一連の実行における他のコミットが残りを仕上げました:
- cb992aac: 読み込みパスから重複するマップ挿入を削除
- c7c0155d: 純ASCIIのUTF-8エントリに対するCharArrayの割り当てをスキップ
- 4798108d: 定数プール文字列の遅延デコード
- a1ae6e7d: Find usagesに向けたクラスパススキャンの高速化
さらに、これより小規模な修正がいくつかあります。これらを合計すると、JDK 25のJREに含まれるすべてのクラスファイル(25,000以上のクラス)の読み込みは851 msから347 msへと短縮され、クラスファイルの読み込みは約2.5倍高速になりました。タイトルの「2倍」はここから来ています。
ソースコードの調査 vs. プロファイリングデータ
コーディングエージェントがJProfiler MCPサーバーなしでもこれを発見できたのか気になり、尋ねてみました。エージェントの推論の形は、到達する結論と同じくらい重要であるため、以下の回答は手を加えずそのまま載せています。
Q: JProfiler MCPはどの程度重要でしたか?また、単にソースコードを調査するだけの場合と比べて、結果はどのくらい良かったですか?
A: このタスクでは重要でした。プロファイラーがなければ、最初に間違った対象の最適化に時間を費やしていた可能性が高いです。
- 「まさにこのテストをプロファイリング付きで実行する」状態から、すぐに使えるホットスポットデータまでの最短経路を提供してくれました。渡すだけで使えるJVM引数を
prepare_profilingが返してくれるのが効果的でした。- ワークフローがシンプルで組み合わせやすかったです:
prepare_profiling→ Gradleテストの実行 →check_status→get_performance_hotspots→expand_performance_hotspot。- ホットスポットの展開結果は、パーサーのコストとテストハーネスのコストを区別できる十分なものでした。
- 本当の勝ちどころを直接浮かび上がらせてくれました。
ClassFile.setConstantPoolにおける逆引き定数プールインデックスの即時構築と、そこから波及するString.hashCode/HashMap.putのコストです。ソースコードレベルで最も推測しやすいものではありませんでした。- 起こりがちな誤った最適化を防いでくれました。ソースコードだけを調べていると、
ConstantUtf8Info.readUTFが主要な標的に見えていました。プロファイリングによれば、そこは確かにホットではあるものの、最初に手を付けるべき最善の修正ではありませんでした。ソースコードの調査よりどのくらい良かったか?
- 質の面では、大幅に優れていました。
- ソースコードの調査だけでは、おそらく最初にmodified UTF-8デコードの最適化を試み、次にJRTの走査オーバーヘッドを調べたり、構造読み込みに対する汎用的なマイクロ最適化を試したりすることになっていたでしょう。
- プロファイリングにより、最も価値の高い修正は別のところにあると示されました。クラスを読み込むたびに行われる逆引きインデックスの即時構築を取り除くことです。
- その結果、実測で約662 msから410 msへの改善、およそ38%の高速化が得られました。
- ソースコードの調査だけから、その特定の修正を最初の一手として見つけられたとは思っていません。
総合的な重要度
- このタスクに対しては:高い。
- 作業の順序が「推測し、パッチを当て、ベンチマークする」から「計測し、最大の実際のコストにパッチを当て、検証する」へと変わりました。
- 最終的な結果は、直感ではなく観測されたホットスポットに結び付いていたため、より高速なだけでなく、より説得力のあるものになりました。
ASMの追い越し
作業の途中で予想していなかったことが起こりました。jclasslibが、ビューアにとって最も重要なモードでASMを追い越したのです。リポジトリにあるAsmPerformanceComparisonTestは、両ライブラリでJRE内のすべてのクラスをスキャンし、jclasslibの所要時間とASMの所要時間の比率を報告します。比率1.0は互角、1.0未満はjclasslibの方が高速であることを意味します。JDK 17での最適化前のベースラインでは、jclasslibは読み取り専用モードでASMの約2倍遅く(比率1.95)、HEADでは、同じ26,653クラスに対する同じベンチマークが現在次のように報告しています:
- 完全なクラス構造の読み込み:0.77(jclasslib 347 ms、ASM 453 ms)
- クラスの読み込みと書き換え:0.87(jclasslib 917 ms、ASM 1050 ms)
- 読み込み、バイトコードのデコード、書き換え:1.23(jclasslib 1311 ms、ASM 1068 ms)
これはかなり驚くべき結果です。jclasslibのアーキテクチャではメモリ内に組み立てられるクラスファイル構造が必要であるのに対し、ASMのアーキテクチャではクラスファイルはただ「流れて通り過ぎる」だけだからです。バイトコード自体を命令リストにデコードしてから再エミットしなければならない場合には、ASMが依然として優位に立っています。そこでは、ASMのストリーミングモデルが中間表現を省いてくれるのが効いています。それ以外のすべてのケースでは、差はなくなりました。
理由のないプロファイリング
プロファイリングには以前は理由が必要でした。アプリが遅くなったりメモリを使い果たしたりしたときに行うものです。コーディングエージェントとMCPサーバーがあれば、その理由はもはや不要です。コーディングエージェントは、パフォーマンス問題をバックグラウンドで修正し、アプリのパフォーマンスをベースラインと比較し、必要に応じて対処できます。
とはいえ、JProfiler MCPサーバーは決定的に重要です。これがなければ、コーディングエージェントは役に立たない「最適化」を自信満々に実行し、しばしばコードを壊してしまいます。プロファイリングデータは、手を加えるべき箇所のシグナルを与えると同時に、その作業が成功したことを検証する手段も提供します。
JProfiler MCPのページでぜひお試しください。コードを読んでいては見つけられないホットスポットがアプリにある場合、エージェントがそれを見つけ出します。