FFM vs. Unsafe. Safety (Sometimes) Has a Cost

原文はこちら。
The original article was written by Marizio Cimadamore (Compiler architect, Oracle) and Per-Ake Minborg (Consulting Member of Technical Staff, Oracle).
https://inside.java/2025/06/12/ffm-vs-unsafe/

Background

Foreign Function & Memory API(FFM API)は Java 22 で最終化され、Javaからネイティブメモリおよびネイティブ関数と直接やり取りができます。ネイティブメモリとのやり取りにおいて、FFM APIは Unsafe経由のアクセスとは対照的に、安全な API を提供します。

JEP 454: Foreign Function & Memory API
https://openjdk.org/jeps/454

Safety

FFM APIでは、ネイティブ(およびヒープ)メモリは、64 ビットのアドレス指定とオフセット、およびさまざまなMemoryLayoutクラスによる構造化メモリアクセスを提供するMemorySegmentクラスによってモデル化されています。MemorySegment経由のアクセスでは、いくつかの安全メカニズムが提供されます。そのチェックには以下も含まれています。

  • Bounds(境界)
  • Liveness(生存)
  • Alignment(アラインメント)
  • Read-only state(読み取り専用状態)

これらのメカニズムはUnsafeメモリアクセスでは提供されていません。したがって、Unsafeを使用すると、次のようなことが可能です。

  • 境界外のアドレス指定、任意のメモリの読み取り/書き込み、および/または VM のクラッシュ
  • 既に解放されたメモリまたは再割り当てされたメモリへのアクセス
  • 他に問題の無いメモリ領域内で不正なオフセットの使用

さらに、Unsafeはメモリ領域の読み取り専用ビューを提供していません。

What is the Performance Cost of Safety

MemorySegmentへの不適切なアクセスについては、Unsafeと同じパフォーマンスを期待することはできません。それは上記の必須チェックを実行する必要があるためです。ただし、セグメントが(ループ内などで)複数回アクセスされる場合、アクセスパターンが予測可能であれば、これらのチェックのコストは分散されるという仮定があります。実際、JIT コンパイラはこれらのチェックをループや類似の構文の外側に移動できます。

Loop-invariant code motion
https://en.wikipedia.org/wiki/Loop-invariant_code_motion

これにより、例えば、ループを100回や1,000回繰り返しても、チェックは1回だけ行えば済みます。

以下のベンチマークは、外出しのメリットを説明する例です。ここでは、foreign memory access(FMA)とUnsafeを使用して、ループ内でint型要素に1回、10回、1,000回アクセスし、読み取りと書き込みの両方を使用しています。

Benchmark                                        Mode  Cnt    Score   Error  Units
FMASerDeOffHeap.fmaReadSingle                    avgt   10    1.482 ± 0.020  ns/op
FMASerDeOffHeap.fmaReadLoop_10                   avgt   10    2.978 ± 0.051  ns/op
FMASerDeOffHeap.fmaReadLoop_100                  avgt   10   15.385 ± 0.218  ns/op
FMASerDeOffHeap.fmaReadLoop_1000                 avgt   10  116.588 ± 3.114  ns/op

FMASerDeOffHeap.fmaWriteSingle                   avgt   10    1.646 ± 0.024  ns/op
FMASerDeOffHeap.fmaWriteLoop_10                  avgt   10    3.289 ± 0.024  ns/op
FMASerDeOffHeap.fmaWriteLoop_100                 avgt   10   10.085 ± 0.561  ns/op
FMASerDeOffHeap.fmaWriteLoop_1000                avgt   10   32.705 ± 0.448  ns/op


FMASerDeOffHeap.unsafeReadSingle                 avgt   10    0.569 ± 0.012  ns/op
FMASerDeOffHeap.unsafeReadLoop_10                avgt   10    1.747 ± 0.023  ns/op
FMASerDeOffHeap.unsafeReadLoop_100               avgt   10   13.087 ± 0.099  ns/op
FMASerDeOffHeap.unsafeReadLoop_1000              avgt   10  117.363 ± 0.081  ns/op

FMASerDeOffHeap.unsafeWriteSingle                avgt   10    0.563 ± 0.016  ns/op
FMASerDeOffHeap.unsafeWriteLoop_10               avgt   10    1.169 ± 0.027  ns/op
FMASerDeOffHeap.unsafeWriteLoop_100              avgt   10    6.148 ± 0.528  ns/op
FMASerDeOffHeap.unsafeWriteLoop_1000             avgt   10   30.940 ± 0.147  ns/op

一見すると、書き込みが読み取りよりも一般的に高速であることは不思議に思えるかもしれませんが、これはベンチマークの設定によるものです。JIT コンパイラは書き込みには自動ベクトル化を使用できますが、読み取りには使用できないためです。ただし、FMAとUnsafeの相対的な差は依然として有意です。

Automatic vectorization
https://en.wikipedia.org/wiki/Automatic_vectorization

続いて、FFMを使用した単一の読み込み/書き込みは、Unsafeに比べてほぼ3倍低速です。ループのバリエーションを進めていくと、状況は改善され、読み込みの場合には10から100回の反復で均衡に達するのに対し、書き込みの場合には100から1,000回の反復で均衡に達することがわかります。この差は再び自動ベクトル化によるものです:書き込みコードがベクトル化されることで(複数の要素が単一のSIMD命令で書き込まれるため)、CPUが実行するコード量が減少します、これによりFFMが導入する「固定」コストが償却されるまでに時間がかかります。

より良い方法はあるでしょうか?ベンチマークでの問題は、メモリセグメントをフィールドmemSegmentから読み込んでいる点にあります(以下のコードを参照)。そのため、JITコンパイラはセグメントサイズを「認識」できず、コンパイルされたコードから境界チェックを削除できません。

@Benchmark
public void fmaReadLoop_1000(Blackhole blackhole) {
    for (int i = 0 ; i < 4000 ; i+=4) {
        blackhole.consume(memSegment.get(ValueLayout.JAVA_INT_UNALIGNED, i));
    }
}

しかし、unsafe(または元のセグメント)アドレスに基づいて「実行中に」メモリセグメントを作成したらどうなるでしょうか?このトリックは過去にも議論されていて、例えば次のようなものです。

@Benchmark
public void fmaReadLoop_1000(Blackhole blackhole) {
    MemorySegment memSegment = MemorySegment.ofAddress(bufferUnsafe).reinterpret(4000);
    for (int i = 0 ; i < 4000 ; i+=4) {
        blackhole.consume(memSegment.get(ValueLayout.JAVA_INT_UNALIGNED, i));
    }
}

表面上は以前と変わらないように見えます(すべてのチェックを実行する必要があります!)。しかし、重要な違いがあります。JITコンパイラは以下の2点を認識できます。

  • MemorySegment::ofAddressにより)memSegmentが常にglobal arenaにマッピングされていること
  • MemorySegment::reinterpretにより)そのサイズが常に 4,000であること

この情報をさらに活用して、一部のチェックのコストを削減できます。以下のベンチマークで示します。

Benchmark                                        Mode  Cnt    Score   Error  Units
FMASerDeOffHeapReinterpret.fmaReadSingle         avgt   10    0.588 ± 0.016  ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_10        avgt   10    1.762 ± 0.025  ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_100       avgt   10   13.370 ± 0.028  ns/op
FMASerDeOffHeapReinterpret.fmaReadLoop_1000      avgt   10  124.499 ± 1.051  ns/op

FMASerDeOffHeapReinterpret.fmaWriteSingle        avgt   10    0.548 ± 0.002  ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_10       avgt   10    1.180 ± 0.010  ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_100      avgt   10    6.278 ± 0.301  ns/op
FMASerDeOffHeapReinterpret.fmaWriteLoop_1000     avgt   10   38.298 ± 0.792  ns/op


FMASerDeOffHeapReinterpret.unsafeReadSingle      avgt   10    0.564 ± 0.005  ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_10     avgt   10    1.661 ± 0.013  ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_100    avgt   10   12.514 ± 0.023  ns/op
FMASerDeOffHeapReinterpret.unsafeReadLoop_1000   avgt   10  115.906 ± 4.542  ns/op

FMASerDeOffHeapReinterpret.unsafeWriteSingle     avgt   10    0.577 ± 0.005  ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_10    avgt   10    1.114 ± 0.003  ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_100   avgt   10    6.028 ± 0.140  ns/op
FMASerDeOffHeapReinterpret.unsafeWriteLoop_1000  avgt   10   30.631 ± 0.928  ns/op

ご覧のとおり、FFMバージョンとUnsafeバージョンは互いに非常に近い結果になりました(ただし、読み取りと書き込みの差による若干のばらつきは依然として存在します)。

Is It Worth It?

レースカーから安全ベルトとロールケージを取り外せば、確かに少しは速くなるでしょう。しかし、これはほぼ絶対に実施されず、主要なレースクラスでは規則で禁止されています。ソフトウェア開発についても同じように考えるべきです。ほとんどの場合、安全性を犠牲にして最後の数パーセントのパフォーマンスを引き出すためにUnsafeを使うことは、絶対に避けるべきです。

では、FFMとUnsafeの比較はどうなるのでしょうか?残念ながら、ここでは直接比較するのは難しいです。なぜなら、ある意味では梨と林檎を比較しているようなものだからです。(その名の通り「安全でない」)Unsafeはメモリに直接アクセスし、追加のチェックを行いません。一方、FFMは安全なメモリアクセスを前提に設計されたAPIです。この安全性はコストを伴い、特に不正なメモリアクセスの場合に顕著です。

OpenJDKコミュニティはFFMのパフォーマンス向上を継続的に追求していますが、不正メモリアクセスのケースでUnsafeと同等になると期待するのは現実的ではありません。ただし、現実的なユースケースでは、この問題はほとんど発生しません。実際のコードにおけるオフヒープメモリアクセスは、通常、2種類の異なるパターンに分類されます。

  • 他のコードに囲まれた不正メモリアクセスが発生する場合
  • Apache Luceneのように、ループ内で繰り返し同じセグメントにアクセスする場合(場合によってはvector APIを使用する場合もある)

Apache Lucene
https://lucene.apache.org/

1つ目のケースの最適化はそれほど興味深いものではありません。このようなケースでは、単一のメモリアクセスのパフォーマンスは通常、無関係です。一方、2つ目のケースの最適化は非常に重要です。上記のベンチマークでも示されているように、同じセグメントを繰り返しループするにつれ、FFMはUnsafeとほぼ同等のパフォーマンスに達します(もちろん、将来的には「収支均衡点」を下げたいと考えています)。

もちろん、アクセスパターンが予測不能で推測できない特異なケースも存在します(オフヒープの二分探索などを考えてみてください)。このような場合、チェックの追加コストが蓄積し始める可能性があります。このような状況では、上記で示した(reinterpretを使用する)ようなトリックが、パフォーマンスプロファイルをUnsafeに近づけるために非常に有用かもしれません。ただし、これらのテクニックは慎重に使うべきです。ほとんどのケースでは、そのようなテクニックは必要ない可能性が高いからです。なぜなら、メモリアクセス性能がそれほど重要でないか、JITコンパイラが既に十分最適化可能なループ内でアクセスが発生しているからです。

(二分探索のような)予測不可能なアクセスパターン下でのアクセスを自動ベクトル化機能は、UnsafeよりもFFM APIを使用するコストを十分に回収できる可能性があります。それゆえ、このようなアルゴリズムは、Unsafeを使用する単純なアルゴリズムよりも高速かつ安全であることを慎重に設計し、評価する必要があります。安全なアクセスを維持しつつパフォーマンスを大幅に向上させる手法も存在します。例えば、「分岐なし二分探索アルゴリズム」を使用する方法です。ただし、これについては別の投稿で詳しく説明します。

What’s the Next Step?

メモリアクセスにUnsafeを使用しており、JDK 22以前のリリースで実行している場合は、JDK 25をダウンロードし、FFM API へ移行することで現在のアプリケーションの安全性がどのように向上するかを確認してください。

OpenJDK JDK 25 Early-Access Builds
https://jdk.java.net/25/

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください