HN 日本語サマリー

← 一覧へ戻る
プログラミング

Value Classes はコンパイラの配慮を依然として必要とします

Value Classes Still Need Compiler Sympathy (johan-sjolen.github.io)

17 pointsby lichtenberger4 コメント

要約

JDK 28 にプレビュー機能として統合された Valhalla のマイルストーンである Value Classes は、プログラムのセマンティクス伝達能力と JVM の最適化機会を向上させます。しかし、Value Classes が常にパフォーマンスを向上させるわけではなく、JVM が異なる表現形式(フラット化された表現と参照表現)間で変換を強いられる状況が発生し得ます。記事では、Value Classes の主な最適化利点であるアイデンティティの放棄が、JVM に表現形式の選択肢を与えることを説明し、不変性、フィールド初期化、そして型消去がパフォーマンスに与える影響を具体的なコード例と共に解説しています。

全文翻訳

この投稿では、JDK 28 のプレビュー機能について議論します。 JEP 401、Valhalla の主要なマイルストーンが、JDK 28 でプレビュー機能として統合されました。これは非常にエキサイティングです。なぜなら、Value Classes は、プログラムのセマンティクスを他者に伝える能力と、JVM で利用可能な最適化機会の両方を増加させるからです。しかし、オンラインで人々が「すべてのクラスを Value にする!」というアプローチを取っているのを見かけました。通常のクラスはパフォーマンスの底辺を提供し、Value Classes はそれをその底辺より上に引き上げる最善を尽くすが、決してそれ以下にはしない、という信念があるのではないかと心配しています。残念ながら、それは真実ではありません。意図されたプログラムであっても、JVM を、フラット化された表現が一部のメソッドでは高速だが、参照表現が他のメソッドでは高速であるという状況に追い込む可能性があります。これらのメソッドが相互作用すると、JVM はそれらの表現間での変換を強いられます。私は Value Classes が単なる魔法以上のものになってほしいので、今日は JVM が現在何が可能で、その限界はどこにあるのかを示します。これにより、あなた(またはあなたのAIエージェント)が書くコードを推論するための文脈が得られることを願っています。 Value Classes の主な最適化上の利点は、アイデンティティを放棄することです。これにより、JVM は特定の状況に適した表現を選択する自由を得ます。アイデンティティの要件なしでは、ランタイムは値をより容易にフラット化(ポインタ追跡を回避)し、レジスタやスタックにコンポーネントを個別に表現することでスカラー化できます。Value オブジェクト自体については、エスケープ解析は些細なものになります:エスケープが観測不可能であることが証明されなければならないアイデンティティはありません。私たちは3つの例を調べます:フラットに格納された大きなファイナル値、割り当てなしでコンパイルされた直接の値変換、そしてマテリアライゼーションを必要とするジェネリック仮想呼び出し。 不変性はフラット化を可能にします JEP 539、JVM における Strict Field Initialization は、JVM がファイナルフィールドが、それを囲むオブジェクトが観測可能になる前に初期化されていることに依存できるようにします。そのようなフィールドは後で更新できないため、JVM はティアード(断片化された)割り当てをリスクなしで、非アトミックなフラット化レイアウトを使用できます。しかし、ミュータブル(変更可能)なフィールドは、ティアフリー(断片化されない)割り当てを維持する必要があります。ミュータブルなフィールドに、アトミックなフラット化更新には大きすぎる値が含まれている場合、JVM は代わりに参照レイアウトを使用しなければなりません。 Strict Initialization 保証は、多くの最適化の可能性を開きます。この小さな例を考えてみましょう: value record FourLongs(long a, long b, long c, long d) {} record Envelope(FourLongs payload) {} FourLongs は 32 バイトのペイロードを持っており、現在の JVM ではアトミックなフラット化更新には大きすぎます。しかし、Envelope.payload はレコードコンポーネントであり、したがって厳密に初期化されたファイナルフィールドです:一度初期化されると、決して更新されません。したがって、JVM は FourLongs を非アトミックなフラット化レイアウトを使用して格納する自由があります。 現在の Valhalla マスタービルドでは、フィールドレイアウト診断は PrintFieldLayout を使用すると次のように報告します: Layout of class FourLongs @8 REGULAR 8/8 "a" J @16 REGULAR 8/8 "b" J @24 REGULAR 8/8 "c" J @32 REGULAR 8/8 "d" J @40 NULL_MARKER 1/1 NULLABLE_NON_ATOMIC_FLAT layout: 33/8 Layout of class Envelope @8 FLAT 33/8 "payload" LFourLongs; FourLongs NULLABLE_NON_ATOMIC_FLAT ここで、FourLongs は4つのコンポーネントと1バイトのヌルマーカーで構成されており、ヌル可能で非アトミックなフラット化レイアウトをサポートしていることがわかります。ランタイムはこの事実を Envelope レコードで使用し、FourLongs をフラット化することを許可します。重要な点は、Envelope も不変であることです。もしそれをミュータブルなクラスに置き換えた場合、レイアウトは変更されなければなりません: class MutableEnvelope { public FourLongs payload; public MutableEnvelope(FourLongs payload) { this.payload = payload; } } Layout of class MutableEnvelope @8 REGULAR 4/4 "payload" LFourLongs; なぜそうなるのでしょうか?2つのスレッド間のデータ競合を考えてみましょう: void thread1(MutableEnvelope a) { a.payload = new FourLongs(1, 0, 0, 0); } void thread2(MutableEnvelope a) { a.payload = new FourLongs(0, 1, 0, 0); } void main() throws InterruptedException { MutableEnvelope a = new MutableEnvelope(new FourLongs(0, 0, 0, 0)); var t1 = new Thread(() -> thread1(a)); var t2 = new Thread(() -> thread2(a)); t1.start(); t2.start(); t1.join(); t2.join(); IO.println(a.payload); } フラット化されたフィールドを書き込むには、その個々のコンポーネントを書き込む必要があります。もしスレッド1とスレッド2がそれらのコンポーネントを独立して書き込んだ場合、別のスレッドは2つの異なる割り当ての一部から組み立てられた、(1, 1, 0, 0) のようなティアード値(断片化された値)を観測する可能性があります。Java Memory Model はそのようなティアリングを禁止しています:両方のスレッドがジョインした後、このプログラムは (1, 0, 0, 0) または (0, 1, 0, 0) のみを出力する可能性があります。そのようなティアフリー割り当てを保証することは高価であるため、現在の JVM は参照レイアウトを使用します。各スレッドは完全な FourLongs を構築し、その後アトミックな参照ストアを実行します。 アイデンティティの削除は割り当てを削除します ループ内でコンポーネントを変更する小さな関数がある場合、例えば: static FourLongs bumpA(FourLongs value) { return new FourLongs(value.a() + 1, value.b(), value.c(), value.d()); } static long run(long iterations) { FourLongs value = new FourLongs(0, 2, 3, 4); for (long i = 0; i < iterations; i++) { value = bumpA(value); } return value.a() + value.b() + value.c() + value.d(); } ソースレベルでは、bumpA の各呼び出しが新しい FourLongs を構築していることがわかります。しかし、run の呼び出しサイトでは、返される値が実質的に value の a コンポーネントを変更するためだけのものであることがわかります。良い最適化コンパイラならそれを認識できるはずです。C2 はかなり良いコンパイラであることが判明しました!C2 は表現をスカラー化されたまま保持し、最終結果が iterations >= 0 の場合、iterations + (2 + 3 + 4) = iterations + 9 になることを認識します。生成されたコードの抜粋を以下に示します: mov x0, #9 ; 9 を x0 に入れる、x0 は戻り値を保持 cmp x1, #0 ; x1 (iterations を保持) を 0 と比較 b.le done ; 0 以下なら done にジャンプ add x0, x0, w1, sxtw ; x0 = x0 + w1、w1 を 64 ビットに符号拡張 done: ret もし bumpA を変更せずに FourLongs をアイデンティティレコードにした場合、C2 はそのアイデンティティが計算に影響を与えないことを証明しなければなりません。コンパイラはしばしばこれを実行できますが、今や各ケースでそれを証明することに依存します。私がこれを試みたとき(FourLongs 宣言から value を削除することによって)、C2 はこの最適化を実行できませんでした。IdentityRecordExperiment::run の C2 によるコンパイルの抜粋を以下に示します。run のループには、bumpA がインライン化されても、割り当てが残っています: # {method} static 'run' '(J)J' in 'IdentityRecordExperiment' ; 初期 FourLongs 割り当て ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; 40 バイトを予約 cmp x11, x10 b.hs slow_allocation ; object-header セットアップは省略 str x11, [x28, #TLAB_TOP] ; 割り当てをコミット ; object 初期化は省略 ; ループ本体:インライン化された bumpA からの割り当て ldr x0, [x28, #TLAB_TOP] ldr x10, [x28, #TLAB_END] add x11, x0, #0x28 ; さらに 40 バイトを予約 cmp x11, x10 b.hs slow_allocation ; object-header セットアップは省略 str x11, [x28, #TLAB_TOP] ; 割り当てをコミット ; object 初期化は省略 明らかに、コンパイラに強力なセマンティック保証を提供することは、非常に大きな利益をもたらすことがあります。 型消去は割り当てを元に戻します 今度はもう少し複雑なものを見ていきます。この例は、valhalla-dev メーリングリストに寄せられたユーザーからのメールから派生しています。彼は Elm から Java へのパーシングライブラリを移植し、すべてのレコードを Value レコードに変換した後、パフォーマンスの低下に気づきました。パフォーマンスの低下は明らかに望ましい結果ではありませんが、このような予期しない結果は魅力的です:Value Classes は JVM により多くのセマンティック情報とより大きな自由を与えますが、それらを使用することでプログラムが遅くなる可能性があるのはなぜでしょうか?私は原因とソースレベルの修正のために詳細な調査を行いました。この調査は興味深いコンパイルを開きました。