原文はこちら。
The original article was written by Sangheon Kim (Senior Member of Technical Staff, Oracle)
https://sangheon.github.io/2020/11/03/g1-numa.html
デフォルトのGCであるG1 GCはJDK 14で強化され、JEP 345[1]でG1GCのメモリアロケータがNUMA対応しました。この記事では、その実装について少し説明します。G1 GCの一般的な動作について最低限の説明をします。
JEP 345: NUMA-Aware Memory Allocation for G1
https://openjdk.java.net/jeps/345
https://bugs.openjdk.java.net/browse/JDK-8210473
What is NUMA?
まず、NUMAとは何かを思い出してみましょう。
NUMAとはNon-Uniform Memory Accessの略で、要するにメモリアクセス時間はプロセッサと相対するメモリの位置に依存するということです。プロセッサにとっては、非ローカルメモリよりもローカルメモリアクセスの方が高速です。このようなシステムでは、ローカルメモリアクセスに最適化したいものです。プロセッサとそのローカルメモリは、ネットワークのノードを形成します。
私の2ソケットマシンのXeon E5-2665 2.4GHzでは、ローカルメモリアクセス時間が非ローカルメモリアクセス時間よりも61.98%短縮されています[2]。
NUMA
https://en.wikipedia.org/wiki/Non-uniform_memory_access
https://ja.wikipedia.org/wiki/NUMA
https://sangheon.github.io/2020/11/03/g1-numa.html#fn:my_numactl
一般に、NUMA対応のしくみを既存のGCに追加する場合、パフォーマンス向上が期待されるのは疑いありません[3]。この点、Parallel GCではJDK 6のとあるアップデートからNUMAサポートが追加されており、ZGCはNUMA対応で開発されています。
Before NUMA-aware implementation?
JEP-345でNUMAを意識した実装がJEP 345で導入される前から、G1 GCアルゴリズムはNUMAノード間でメモリインタリーブしていました。これはつまり、メモリがアクティブなノードに均等に分散され、コミットされたときにラウンドロビン方式で割り当てられることを意味します。このインターリーブにより、UseNUMAを有効化している場合には、すでにパフォーマンスが向上しています[4]。そのため、NUMA対応の実装だけによるパフォーマンス改善を比較したい場合、パッチ前およびパッチ実行後の両方で、-XX:+UseNUMA
を追加する必要があります。
NUMA-aware Heap Initialization
G1 GCは、Javaヒープを複数の同じサイズのチャンクに分割した後、Java ヒープを管理します。各チャンクはG1ヒープ領域と呼ばれます。そのため、NUMAを有効にしたJavaヒープの初期化中に、適切なNUMAノード上にG1ヒープ領域を見つけるようにOSに要求しています。ここでいう適切とは、G1ヒープ領域上のアクティブなNUMA IDを均等にローテーションさせることを意味します。OSはこの要求を尊重し、与えられたヒープ全体がアクティブなNUMAノードの間で均等に配置されると仮定します(図1)。

G1 GCのヒープ領域がOSページの扱い方について疑問に思ってらっしゃる方もいるかもしれません。G1GCにおけるNUMA idの粒度は、ヒープ領域です。これはつまり、1個のヒープ領域は、そのヒープ領域に対して同じNUMA IDを持っているとみなされます。そしてこれはOSの粒度であるページとは異なります。OSのページがG1のヒープ領域のサイズより小さい場合には簡単なのですが、1個のG1ヒープ領域は複数のOSページから構成されています。つまり、G1のヒープ領域サイズが1MBであり、ページサイズが4KBである場合、256ページを1個のヒープ領域のために利用します。G1のヒープ領域サイズがOSのページサイズよりも小さい場合にはどうなるでしょうか?このような場合、1個のラージページを複数のG1ヒープ領域のために利用します。例えばG1ヒープ領域のサイズが1MBでページサイズが2MBの場合、2ページをヒープ領域2個に使います(図2)。もちろん、G1ヒープ領域サイズとOSページサイズは、どちらか一方のサイズが他方のサイズの倍数であることが望ましいのです。

NUMA-aware Memory Allocation
基本的なヒープの初期化の準備ができたので、今度はJavaアプリケーションへのメモリ割り当て方法を説明しましょう。この変更までは、JavaスレッドがJVMにメモリを要求すると、G1 GCは単一のMutatorAllocRegionインスタンスからメモリを確保します[5]。現在では、G1 GCは複数のMutatorAllocRegionを持ち、NUMAノードごとに 1 つのインスタンスを持っています(図3)。そのため、JavaスレッドがJVMにメモリを要求すると、G1 GCはMutator(Javaスレッド)のNUMAノードをチェックし、同じNUMAノードを持つMutatorAllocRegionからメモリを割り当てます。

NUMA-aware Surviving Object Allocations during Young Evacuation
上記コンセプトをSurvivor領域にも適用したことで、G1 GCは複数のSurvivorGCAllocRegion(NUMA ノードごとに 1 つのインスタンス)を持つことになります。それに加えて、もう一つ考慮すべきことがあります。それは、G1 GCが生存オブジェクトをコピーする際に使用するバッファです。このバッファはPLAB(Promotion Local Allocation Buffer)と呼ばれています。
Young世代でGCが発生すると、G1 GCは生存オブジェクトをsurvivor領域に複数のGCスレッドを使ってコピーします[6]。各GCスレッドは生存オブジェクトを割り当てるための自身PLABを持ちます。各PLABをSurvivorGCAllocRegionから割り当てます。PLABを使うと、G1 GCは直接SurvivorGCAllocRegionから割り当てるのに比べてSurvivorGCAllocRegionへの同期遅延を最小化できます。これらの2個が追加されたことにより、young世代への待避時の生存オブジェクトのメモリ割り当ては現在NUMA対応になっています(図4)。

No NUMA-aware Processing for Old Generation
冒頭で、Javaヒープ全体がアクティブなNUMAノードに均等に分割されると述べましたが、Young世代(Eden領域とSurvivor領域)についてのみ説明しました。G1 GCは残りの部分、NUMAノードの情報を持たないOld世代を管理します。
現在、Old世代は空のヒープ領域をNUMAノード情報なしでリストの先頭から取得します[7]。そのため、OldGCAllocRegionの1個のインスタンスしかありません。これはNUMAの変更が導入される前と同じです。そして、これにより、NUMAノード間のヒープ領域のバランスを取ることができます。
実は、別のアプローチがあったのですが、こちらは受け入れられませんでした。そのアプローチとは、生存オブジェクトをOld世代に昇格したときに、NUMAノードの情報を保持するというものです。しかし、このアプローチは特定のNUMAノードのヒープ領域を消費する可能性があり、NUMAノード間のヒープ領域のバランスが悪くなります。また、この方法では性能の向上は見られませんでした。ParallelGCでも性能向上につながらなかったため、Old世代のためのNUMA対応の処理はありません。
今後、目に見えるメリットがあれば、Old世代にNUMA対応の処理を追加することを検討するでしょう。
Logging
これらの新規追加された実装のロギングはnumaタグによって管理されます。-Xlog:gc*
を付けると、VMはNUMAのinfoレベルのログも出力します。NUMA関連のログだけを見たい場合には、 -Xlog:numa*={log level}
が必要です。
SpecJBB2015 results comparison
この変更がもたらすパフォーマンスの向上を調べてみましょう。
このテストにはSpecJBB20158を使用しています[8]。他の変更の影響を最小限に抑えるために、JEP 345 が統合された JDK 14 build 24と、この変更が入っていないbuild 23を比較しています。さらに、最新のLTS(Long Term Support、JDK 11)と、最新のJDK(JDK 15)のテスト結果もあります。

全てのテストはJavaヒープサイズ512 GB、UseNUMA
オプションを有効化し、4個のNUMAノードマシン上で実行しました[9]。
Both Max-jOPSとCritical-jOPSの両方で、JDK 14 b23に比べてJDK 14 b24では有望な改善が見られました。Max-jOPSは20.64 %の改善、Critical-jOPSは9.52 %改善されています。
SPECjbb2015 Benchmark User Guide
https://www.spec.org/jbb2015/docs/userguide.pdf
NUMA の変更とは関係ありませんが、より新しいJDKのバージョンでは、Max-jOPSとCritical-jOPSのスコアが古いJDKよりも優れていることは特筆に値します。
テストシステムのNUMAノードが多ければ多いほど、さらにパフォーマンスが改善されます。別の考慮点としては、1個のNUMAノードからメモリ確保できる小さなJavaヒープサイズをテスト対象のJavaアプリケーションが設定している場合、性能は全く改善されない、ということです。単にそれはJavaアプリケーションのmutatorスレッドとGCスレッドが非ローカルメモリにほとんどアクセスしていないためです。例えばメモリ32GB、2個のNUMAノードのテスト用のマシンであれば、各ノードには16GBのメモリを持つことになるでしょう。Javaアプリケーションが1GBしかメモリを使わない場合、全てのメモリをNUMAノードから確保するため、そのような場合では改善されません。
Lastly!
G1 GCでG1 GCで実装した内容を説明しました。是非お試しください。
- Linux でのみ実装されています。
numactl --hardware
の出力結果node distances:
node 0 1
0: 10 20
1: 20 10
Memory Latency Checker v3.5,
Measuring idle latencies (in ns)…
Node 0 1
0 69.7 119.0
1 112.9 69.9- 状況によってはパフォーマンスの差異がない可能性がありますが、一般にはいくらかの改善が期待できます。
UseNUMA
を有効化すると自動的に有効化されるUseNUMAInterleaving
オプションで、NUMAインターリーブを管理します。MutatorAllocRegion
はG1ヒープ領域を管理する抽象クラスです。- GCスレッドの数は
-XX:ParallelGCThreads={number}
で管理します。 - 全てのG1ヒープ領域を
HeapRegionManager::FreeRegionList
と呼ばれるリストから確保します。Old世代のヒープ領域はNUMAノード情報なしで確保されます。 - SPEC® とそのベンチマーク名である SPECjbb® はStandard Performance Evaluation Corporationの登録商標です。SPECjbbの詳細情報は以下をご覧ください。
SPECjbb®2015
https://www.spec.org/jbb2015/ - 全てのVMオプションは以下の通りです。
-Xmx512g -Xms512g -XX:+AlwaysPreTouch -XX:+PrintFlagsFinal -XX:ParallelGCThreads=48 -XX:ConcGCThreads=4 -XX:+UseG1GC -XX:+UseLargePages -XX:+UseNUMA -Xlog:gc*,ergo*=debug:gc.log::filesize=0 -XX:-UseBiasedLocking