Isolates and Compressed References: More Flexible and Efficient Memory Management via GraalVM…

このエントリは以下のエントリをベースにしています。
This entry is based on the one written by Christian Wimmer, VM and compiler researcher at Oracle Labs. Project lead for GraalVM native image generation (Substrate VM).
https://medium.com/graalvm/isolates-and-compressed-references-more-flexible-and-efficient-memory-management-for-graalvm-a044cc50b67e

tl;dr: GraalVMのネイティブイメージでは、isolate(分離/同一プロセス内の複数の独立したVMインスタンス)と圧縮参照(64ビットアーキテクチャ上のJavaオブジェクトへの32ビット参照を使用)をサポートするようになりました。これにより、メモリの占有スペースが削減され、メモリを厳密に分離することが可能になります。 例えばさまざまなユーザーからのWebリクエストに対してメモリを分離できるようになります。

Introduction

多くの人が、JavaとVirtualBoxの両方が全く異なることをしているにもかかわらず、なぜ「仮想マシン (virtual machines) 」と呼ばれるのか疑問に思ってらっしゃいます。よくある回答の1つに、どちらの「仮想マシン」も

  • プログラムに対して「write-once, run-anywhere」を実現しようとしている
  • プログラムの配下にあるマシンを隠そうとしている

というものがありますが、いずれもまったく異なる方法で実現しようとしています。ただし、VirtualBoxのような仮想マシンを使用する主な理由(OSレベルの仮想マシンと呼びましょう)は、物理的なハードウェアリソースをより効率的に使用するため、同一サーバー上の複数のアプリケーションをまとめて他と干渉しないよう隔離するためです。DockerのようなOSレベルのVMの新バージョンは、複数のアプリケーション間でOSを共有することでより効率的な仮想化を実現しています。

GraalVMでは、私たちが「言語レベル」の仮想化と呼ぶ、さらに別の種類の仮想化を提供します。これは複数の言語を同じプロセス(またはスレッド)で実行できるようにする、というものです。ある言語で書かれたライブラリーを、パフォーマンスの犠牲なく別の言語から直接呼び出すことができるようにすることで、別のレベルの「write-once, run-anywhere」を提供します。本日、GraalVMが、複数のアプリケーションがJVMのような言語ランタイムを共有できるようにすることで、ハードウェアリソースをさらに効率的に使用できる方法を提供するようになったことをお知らせします。これは、クラウド環境や、サーバーあたりのテナント数を増やすことでインフラストラクチャコストを直接削減できる環境においては重要になるでしょう。時間が経つにつれて、GraalVMはJavaスタイルとOSレベルの仮想化の境界線を曖昧にし続けると予想されます。

この目的のため、GraalVMにはisolate (分離) と呼ばれる新しい仮想化機能を導入しています。GraalVMのisolateとは、同じVMインスタンス内の複数のタスクを独立して実行することを可能にする、互いに素なヒープです。従来のJavaアプリケーションサーバーでは、すべてのタスクが同じメモリヒープを共有しています。1つのタスクが大量のメモリを使用すると、ガベージコレクション(GC)が発生し、そのヒープを共有している他のタスクの速度が低下します。isolateは明らかにばらばらなので、各isolateを独立してガベージコレクションできます(またはGCが必要になる前に破棄することができます)。isolateは、アプリケーションのマルチテナント管理や、単一のモノリシックアプリケーションを管理可能なマイクロサービスに分割したりするための優れたツールです。

Isolateによって、圧縮ポインタという別の最適化も可能になります。isolateヒープは通常1つのタスクだけで使用されることを目的としているため(Javaアプリケーションサーバーがサポートを求められる可能性のある大量のタスクではありません)、Isolateは小さくてよく、isolateヒープに含まれるすべてのメモリをアドレッシングするための64ビットポインタを必要としません。オブジェクト指向言語のほとんどのデータ構造は、プリミティブデータよりもポインタのためのスペースを多く使用するため、ポインタのためのスペースを小さくできると、アプリケーションのフットプリントに大きな影響を与える可能性があります。

万事OKのように聞こえますが、isolateには注意すべき重要な制限があります。第1に、データベースやサーバーレスクラウドなどのような、タスクを管理する上位レベルのテクノロジが使用するよう設計された低レベルの機能である、という点です。例えばデータベースの場合、Graalコンパイラはそれ自身のオブジェクトとアプリケーションデータを配置するために、それぞれisolateを使います。第2に、isolateには、本格的な仮想化テクノロジに期待されるスナップショットなどの機能がまだ含まれていません。最後に、isolateと圧縮ポインタはSubstrate VMでのみ利用可能、という点です。換言すると、Java HotSpot VMでは独自のヒープ管理をしているため、Java HotSpot VMに組み込まれたGraalVMとして実行する場合には利用できません。isolateはGraalVMの任意のエディションでも利用できますが、圧縮ポインタはEnterprise Editionでしか利用できません。

Isolateは、メモリ使用量を削減するようにサービスを構築するための強力なツールであり、最大遅延やスループットなどの他の指標にもプラスの効果をもたらします。これは、サービスがJavaやScalaで作成され、事前コンパイル(Ahead-of-Time compilation)されるGraalVMの native-imageツールと組み合わせて使用​​できます(起動時間だけでなく、メモリ使用量も削減されます)。

Ahead-of-time Compilation
https://www.graalvm.org/docs/reference-manual/aot-compilation/

この件は以前のエントリで詳説しています。

Understanding Class Initialization in GraalVM Native Image Generation
https://medium.com/graalvm/understanding-class-initialization-in-graalvm-native-image-generation-d765b7e4d6ed

あるいは、isolateを使ってTruffle API上に構築された動的言語(JavaScript、Pythonなど)を管理することもできます。これはGraalVMをデータベースに埋め込むときに使っている方法です。

isolateの最も一般的な使用法として、リクエストごとに別々のisolateを使うマルチテナントサーバー構築時を想定しています。リクエスト処理がすめば、isolateは、ガベージコレクションを実行せずに単純に破棄できます。このアプローチは、Webサーバーのようなイベントを並行処理するアプリケーションに最適です。このエントリでは、isolateを使ってそのようなタイプのアプリケーションを構築する方法の詳細を説明します。

Isolates

isolateは、同じプロセス内に複数の独立したVMインスタンスを提供します。isolateを作成すると、起点としてイメージヒープを持つ、新しいヒープを作成します。これは、イメージ生成中に行われるすべての初期化が、すべてのisolateで即時に使用可能になることを意味します。すべてのisolateは、事前コンパイルされた同じコードを共有します。つまり、isolateごとに個別の静的分析やコンパイルは実施しません。コードは不変なので、このコード共有は望ましいものです。

各isolateは別々のヒープを持っているので、2つのisolate間でJavaオブジェクトの直接参照を持つことはできません。これは制限であると同時に利点でもあります。アプリケーション開発者は、オブジェクトグラフが完全にパーティション化されていることを確認する必要があります。例えば、すべてのisolateからアクセス可能なグローバルキャッシュを持つことはできませんが、isolateを使うと、各isolateにおいて独立してガベージコレクションを行うことができます。つまり、他のisolateを停止したり影響を与えたりすることはありません。isolateによって割り当てられたすべてのメモリは、isolateが破棄されると自動的に解放されます。このときガベージコレクションは必要ありません。

メモリの分離により、セキュリティの保証ももたらします。つまり、(おそらく特定のユーザーと関連付いている)あるisolateのオブジェクトに対し、(他のユーザーと関連付いている)別のisolateが誤ってアクセスすることはできません。静的フィールドまたはライブラリが維持しているキャッシュを介してユーザ間で情報を漏らしてしまうようなバグは、isolateによって防止されます。

下図は、プロセス内の2つのisolateを示しています。各isolateには、個々のイメージヒープのコピー(メモリ使用量を削減するために copy-on-writeマッピングを使用して効率的に管理)と、新しく割り当てられたオブジェクトが配置される独自のランタイムヒープがあります。

isolateを作成、管理するためのAPIとして2種類のAPI、つまりJava APIとC APIを提供します。Java APIはJavaでのみ(もしくはScalaやKotlinで)作られているアプリケーションで複数のisolateを使いたい場合の利用を想定しています。C APIは既存のCアプリケーションとJava(やScala、Kotlin)のコードを統合し、Cのコードでisolateを管理したい場合の利用を想定しています。まずJava API、続いてC APIの概要をご紹介します。繰り返しになりますが、Java APIとC APIの両方ともネイティブイメージでのみ利用できます。Java HotSpot VMのようなJava VM上では利用できませんのでご注意ください。

Javaのmain()メソッドを持つ実行可能アプリケーションを構築すると、main()メソッドが呼び出される前にデフォルトのisolateを自動作成します。Cのコードと統合されている共有ライブラリを構築する場合、isolateは自動作成されません。つまり、最初のisolateはC APIを使用して作成する必要があります。

Java API for Isolates

APIでは、IsolateIsolateThreadという2つのOpaqueなポインタ型を導入しています。

Interface Isolate
https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/Isolate.html

Interface IsolateThread
 https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/IsolateThread.html

これらはJavaインターフェースのように見えますが、実際は機械語サイズの値であって、Javaオブジェクトではありません。詳細はこのエントリの範囲外ですが、単純にCのvoid*ポインタとしてこれらの値を考えてください。

Isolate はisolateのメインディスクリプタであり、この値を持っていれば、isolateにフルアクセスできます。isolateにアタッチする各スレッドをIsolateThreadで表現します。これはよく使われる型で、isolateのメソッドを呼び出したい場合、IsolateThreadを渡す必要があります。

Isolatesクラスにはisolateを管理するためのAPIが含まれていて、ここにisolateのライフサイクルを管理するための重要なメソッドがあります。

IsolateThread createIsolate(CreateIsolateParameters params);
void tearDownIsolate(IsolateThread thread);

メソッドcreateIsolate()は、新しい独立VM​​インスタンスを作成、初期化します。新しいisolateのJavaヒープはイメージ・ヒープのみで構成、つまり、呼び出し元isolateのオブジェクトは新しいisolateでは使用できません。現在のスレッドは新しいisolateにアタッチされ、IsolateThreadディスクリプタは呼び出し元isolateに返されます。この後、新しいisolateのメソッドを呼び出すことができます。メソッドtearDownIsolate()はisolateを破棄します。とりわけ、isolateに関連するすべてのメモリをOSに返すことで解放するため、ガベージコレクションは必要ありません。

スレッドのアタッチ、デタッチ、スレッドのアタッチ済みかどうかのチェック、IsolateIsolateThread間の変換するための機能もあります。詳細はAPI仕様をご覧ください。

Class Isolates
https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/Isolates.html

このエントリの実行例を紹介しましょう。Netty Webサーバーを使用して、関数のプロットを求めるWebリクエストに応答しています。

Netty
https://netty.io/

この関数は、httpリクエスト内でユーザーが指示します。httpレスポンスはScalable Vector Graphics(SVG)オブジェクトです。式の評価にはexp4jを、SVGファイルのレンダリングにはSVGGraphics2Dを使います。

exp4j
https://www.objecthunter.net/exp4j/
SVGGraphics2D
http://www.jfree.org/jfreesvg/javadoc/org/jfree/graphics2d/svg/SVGGraphics2D.html

式の評価とレンダリングの両方で一時的なJavaデータ構造が割り当てられます。これらのオブジェクトはすべて、リクエスト処理後は到達不能になります。しかし従来のJava VMの場合、これらの一時オブジェクトを最終的に破棄するには、依然として高価なガベージコレクションが必要です。httpリクエストは様々なユーザーから到着するため、依存しているすべてのライブラリを完全にコードレビューしなければ、あるユーザーが他のユーザーの式のプロパティを観察できるようなグローバルデータ構造やキャッシュがあるかどうかはわかりません。isolateは両方の問題を解決します。リクエストハンドラでリクエストごとに新しいisolateを作成し、式の評価後にそのisolateを破棄するからです。

static ByteBuffer plotAsSVGInNewIsolate(String function, double xmin, double xmax) {
/* Create a new isolate for the function evaluation and rendering. */
IsolateThread renderingContext = Isolates.createIsolate(CreateIsolateParameters.getDefault());
/* Render the function in the new isolate. */
...
/* Tear down the isolate, freeing all the temporary objects. */
Isolates.tearDownIsolate(renderingContext);
return result;
}

今回は実際のレンダリング機能の呼び出しを追加する必要がありますが、これは通常のJavaメソッド呼び出しよりも少し複雑です。というのも、Nettyのisolate(アプリケーションの起動時に自動的に作成されたデフォルトのisolate)を離れ、レンダリングのisolate(明示的に作成した新しいisolate)に入る必要があるためです。また、2つのisolateのヒープは完全に分離されているため、Javaオブジェクトを直接渡すことはできません。文字列引数functionはNettyのisolate内のオブジェクトなので、レンダリングのisolateからはアクセスできません。最初に文字列をレンダリングのisolateにコピーする必要があります。戻り値についても同じことが言えます。JavaオブジェクトByteBufferを直接返すことはできず、Javaオブジェクトへのハンドルのみを渡すことができます。「ハンドル」とは、Javaオブジェクトへの不透明な間接指定です。ハンドルが参照するオブジェクトは、そのオブジェクトとハンドルが作成されたisolate内でのみアクセスできます。

結果として、今回のレンダリングのメソッドを以下のように定義します。

@CEntryPoint
static ObjectHandle plotAsSVG(
@CEntryPoint.IsolateThreadContext IsolateThread renderingContext,
IsolateThread nettyContext,
ObjectHandle functionHandle,
double xmin, double xmax)

アノテーション @CEntryPoint は、メソッドをisolate-transition (分離の遷移) メソッドとしてマークします。このメソッドをCのコードから直接呼び出すこともできます(注釈名が由来するところです)。パラメータ renderingContext は関数が呼び出された際に入るisolateです。これはパラメータ・アノテーション @CEntryPoint.IsolateThreadContext で表されます。後にNettyのisolateにコールバックする必要があるため、パラメータとしてnettyContextも渡しますが、当該パラメータに特別な意味はありません。

パラメータ functionHandleと戻り値はObjectHandleという型です。各ハンドルごとに、どのisolateで有効かを知る必要があります。そこで、レンダリングのisolateのハンドルとしてfunctionHandleを、Nettyのisolateのためのハンドルとして戻り値をそれぞれ定義します。これは意識的な決断です。

static ByteBuffer plotAsSVGInNewIsolate(String function, double xmin, double xmax) {
/* Create a new isolate for the function evaluation and rendering. */
IsolateThread renderingContext = Isolates.createIsolate(CreateIsolateParameters.getDefault());
IsolateThread nettyContext = CurrentIsolate.getCurrentThread();
/* Copy the function String from the Netty isolate into the rendering isolate. */
ObjectHandle functionHandle = copyString(renderingContext, function);
/* Render the function. This call performs the transition from the Netty isolate to the rendering isolate, triggered by the annotations of plotAsSVG. */
ObjectHandle resultHandle = plotAsSVG(renderingContext, nettyContext, functionHandle, xmin, xmax);
/* Resolve and delete the resultHandle, now that execution is back in the Netty isolate. */
ByteBuffer result = ObjectHandles.getGlobal().get(resultHandle);
ObjectHandles.getGlobal().destroy(resultHandle);
/* Tear down the isolate, freeing all the temporary objects. */
Isolates.tearDownIsolate(renderingContext);
return result;
}
@CEntryPoint
private static ObjectHandle plotAsSVG(@CEntryPoint.IsolateThreadContext IsolateThread renderingContext, IsolateThread nettyContext, ObjectHandle functionHandle, double xmin, double xmax) {
/* Resolve and delete the functionHandle, now that execution is in the rendering isolate. */
String function = ObjectHandles.getGlobal().get(functionHandle);
ObjectHandles.getGlobal().destroy(functionHandle);
...
}

このサンプルの完全な実装は、GitHubリポジトリにあるコメント付きのソースコードをご覧ください。

Isolates for GraalVM Native Images
https://github.com/graalvm/graalvm-demos/tree/master/native-netty-plot

isolateへ入ると、内部レジスタのセットアップと内部スレッド状態をactiveモードに遷移するコードを実行します。isolateを離れると、すべての例外を捕捉、レポートし、内部スレッド状態をinactiveモードに遷移するコードを実行します。plotAsSVG()のコールサイトでは、以下が行われます。

  • 呼び出し前、Nettyのisolateはactiveでレンダリングのisolateはinactive
  • Nettyのisolateを離れる。その結果、両方のisolateはinactiveに遷移
  • レンダリングのisolateに入る
  • レンダリングのisolate内でplotAsSVG()を呼び出す
  • レンダリングのisolateを離れる。その結果、両方のisolateはinactiveに遷移
  • Netty のisolateに入る
  • Nettyのisolateで呼び出し後のコードを実行

おわかりの通り、どのスレッドでも最大で1つのisolateが任意のタイミングでactiveです。下図はスタックを視覚化したものです。2つの異なるリクエストハンドラスレッドで2つのレンダリングisolateがactiveになっているとします。

すべてのスレッドは、スレッド開始ルーチン(Linuxの pthread 関数)用のCのコードで始まります。Nettyのisolateはすべてのリクエストハンドラ・スレッドを開始するので、Nettyのisolateに属するフレームはすべてのリクエスト・ハンドラ・スレッドにあります。最後のフレームはメソッドplotAsSVGInIsolate()用です。各遷移(isolateへの出入り)は遷移フレーム (transition frame) になります。レンダリングisolate内の遷移フレームの後の最初のフレームは、メソッドplotAsSVG()用です。

スタック上でisolateを複数回切り替えることができます。この例では、レンダリングのisolateは結果のByteBufferインスタンスを割り当てるためにNettyのisolateを呼び出します(当該コードはこのエントリでは省略しています)。以下のリストは、プロット結果のためにByteBufferインスタンスを割り当てるメソッドcreateByteBuffer()で停止したときのGDBスタックトレースです。

#0 com.oracle.svm.nettyplot.FunctionPlotter.createByteBuffer(FunctionPlotter.java:179)
#1 com.oracle.svm.core.code.CEntryPointCallStubs.com_002eoracle_002esvm_002enettyplot_002eFunctionPlotter_002ecreateByteBuffer
#2 com.oracle.svm.core.code.CEntryPointJavaCallStubs.com_002eoracle_002esvm_002enettyplot_002eFunctionPlotter_002ecreateByteBuffer
#3 com.oracle.svm.nettyplot.FunctionPlotter.plotAsSVG(FunctionPlotter.java:158)
#4 com.oracle.svm.core.code.CEntryPointCallStubs.com_002eoracle_002esvm_002enettyplot_002eFunctionPlotter_002eplotAsSVG
#5 com.oracle.svm.core.code.CEntryPointJavaCallStubs.com_002eoracle_002esvm_002enettyplot_002eFunctionPlotter_002eplotAsSVG
#6 com.oracle.svm.nettyplot.FunctionPlotter.plotAsSVGInNewIsolate(FunctionPlotter.java:81)
#7 com.oracle.svm.nettyplot.FunctionPlotter.plotAsSVG(FunctionPlotter.java:55)
#8 com.oracle.svm.nettyplot.PlotServerHandler.channelRead(PlotServer.java:108)
#9 io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:362)
...
#34 io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
#35 java.lang.Thread.run(Thread.java:748)
#36 com.oracle.svm.core.thread.JavaThreads.threadStartRoutine(JavaThreads.java:476)
#37 com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:199)
#38 com.oracle.svm.core.code.CEntryPointCallStubs.com_002eoracle_002esvm_002ecore_002eposix_002ethread_002ePosixJavaThreads_002epthreadStartRoutine
#39 start_thread(pthread_create.c:333)
#40 clone(clone.S:109)
view raw GDB stacktrace hosted with ❤ by GitHub

このスタックトレースで、isolateに入るフレーム(#1、#4と#38)とisolateを離れるフレーム (#5と#2) という、複数のスタックフレームを確認できます(合成関数名で表記)。

Impact of Isolates on Memory Footprint

isolateが及ぼすアプリケーションのメモリ使用量を評価するため、関数を繰り返しプロットするリクエストを送信し、各リクエストの後に常駐メモリセットのサイズを出力します。メモリサイズのクエリにはLinuxコマンドpmap -xprocessidを使います。下図は、50件のリクエストでのisolateの有無の結果を示しています。

最初のデータポイントは、Nettyの起動後、リクエストを処理する前のメモリ使用量で、リクエストごとに約1.8 MBのJavaオブジェクトが割り当てられています。isolateがなければ、これらのオブジェクトはすぐに解放されず、young世代のヒープを埋めていきます。young世代がいっぱいになると、GCを実行してオブジェクトを解放します。このベンチマークでは、young世代のサイズを80 MBに固定していて、リクエスト#39でこの制限に到達します。このリクエストの間のGCの後、young世代は再び空の状態からいっぱいになっていきます。最適化目的でメモリをOSにすぐに返さないため、常駐メモリセットのサイズは大きいままですが、それまでのように直線的に増加することはありません。しかし、約40リクエストごとにGCが必要です。

isolateを使用すると、isolateを削除すればレンダリング中に割り当てられた一時オブジェクトを即座に解放します。このときGCは必要ありません。したがって、常駐メモリセットのサイズは、GCのオーバーヘッドなしに小さく保たれるとともに、GCを使用せずにさらに多くのリクエストを処理できます。

Pre-Initialization of Objects using the Image Heap

各isolateは個々でイメージヒープのコピーをもっています。イメージヒープはビルド時、つまりイメージ生成中に準備されることを忘れないでください。この機能を使用して実行時の初期化コード実行を回避できます。この例では、関数レンダリングのコードはレンダリング目的でSVGGraphics2Dクラスのインスタンスを使用します。従来のJavaの実行では、このオブジェクトをシングルトンにはできず、複数のリクエストを同時に異なるスレッドで処理できます。つまり、複数のインスタンスを同時に使用し、すべてのインスタンスを同じヒープに入れる必要があります。実行時に新しいインスタンスを割り当てる必要があります。

byte[] plotAsSVG(String function, double xmin, double xmax) {
SVGGraphics2D g2d = new SVGGraphics2D(...);
... /* Use g2d instance for rendering. */
}

isolateベースのモデルでは、各レンダリングを別々のisolateで実行するため、isolateごとにSVGGraphics2Dのインスタンスが1つだけ存在します。したがってシングルトンインスタンスを持つことができ、イメージ生成中にシングルトンの割り当て、初期化ができます。isolateはイメージ・ヒープ上にすでに存在しているインスタンスで開始され、実行時の割り当てや初期化は不要です。この場合、SVGGraphics2Dは大規模なデータ構造ではないのでメモリ使用量はそれほど節約できませんが、イメージ生成中にはるかに大きなデータ構造を準備する可能性のあるユースケースが多くあります。

簡単にイメージ生成中に事前割り当てするには、クラス初期化子で割り当てを実行し、static finalフィールドを使用します。

class Graphics2DPlotter {
static final SVGGraphics2D g2d = new SVGGraphics2D(...);
byte[] plotAsSVG(String function, double xmin, double xmax) {
... /* Use g2d instance for rendering. */
}
}

しかし、より明示的に事前に初期化をするために、「image singletons」と呼ばれるメカニズムも提供します。

ImageSingletons
https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/ImageSingletons.html

これはイメージ生成中にシングルトンオブジェクトを登録し、実行時にそれらにアクセスできるというものです。初期化は、イメージ生成中に実行される、いわゆる「feature」で行われます。

Interface Feature
https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/Feature.html

class Graphics2DPlotter {
final SVGGraphics2D g2d = new SVGGraphics2D(...);
byte[] plotAsSVG(String function, double xmin, double xmax) {
... /* Use g2d instance for rendering. */
}
}
class PlotterSingletonFeature implements Feature {
@Override
public void afterRegistration(AfterRegistrationAccess access) {
/* This code runs during image generation. */
ImageSingletons.add(Graphics2DPlotter.class, new Graphics2DPlotter());
}
}
/* At run time, look up the singleton object that was created during image generation. */
ImageSingletons.lookup(Graphics2DPlotter.class).plotAsSVG(function, xmin, xmax);

--features=com.oracle.svm.nettyplot.PlotterSingletonFeatureオプションを使用して、feature実装クラスの完全修飾クラス名をnative-imageツールに提供する必要があります。

Running the Example

ここでは、以前の記事で紹介したNettyの例をベースにしたサンプルを使います。

Instant Netty Startup using GraalVM Native Image Generation
https://medium.com/graalvm/instant-netty-startup-using-graalvm-native-image-generation-ed6f14ff7692

サンプルの完全なソースコードはGitHubのGraalVM-demosリポジトリのnative-netty-plotフォルダにあります。

Isolates for GraalVM Native Images
https://github.com/graalvm/graalvm-demos/tree/master/native-netty-plot

実行には、GraalVM 1.0 RC9以降のバージョンが必要です。

GraalVM Downloads
https://www.graalvm.org/downloads/

GraalVMにはオープンソースのCommunity Editionと商用のEnterprise Edition(評価目的で無料でダウンロード可能)の2種類があります。isolateは両方のエディションで利用できますが、この記事の後半で紹介されている圧縮参照はEnterprise Editionでのみ利用可能です。したがって、このサンプルの実行にはEnterprise Editionを使用することをお勧めします。

Nettyの以前のエントリ以降、開発者エクスペリエンスを向上し、ビルド手順を大幅に簡素化できました。 前提として、GraalVM 1.0 RC9をホームディレクトリにインストールし、サンプルのリポジトリを複製済みとします。サンプルのnative-netty-plotディレクトリ(pom.xmlファイルの在処)にて、 mvn packageでサンプルをビルドできます。ビルドすると、1個のJARファイルができあがります。target/netty-plot-0.1-jar-with-dependencies.jarの中には、サンプルとそれに紐付く依存関係がすべて含まれています。これでこのアプリケーションのネイティブイメージを生成できます。

$ ~/graalvm-ee-1.0.0-rc9/bin/native-image -jar target/netty-plot-0.1-jar-with-dependencies.jar

native-imageツールは、サブディレクトリMETA-INF/native-image/のJARファイルに包含されているファイルnative-image.propertiesからnative-imageのいくつかのオプションを自動的に取得します。

ようやくWebサーバを実行できるようになりました。

$ ./netty-plot

最後に、ブラウザを開いて以下のURLを指定し、関数の描画をリクエストします。

http://127.0.0.1:8080/?function=abs((x-31.4)sin(x-pi/2))&xmin=0&xmax=31.4

C API for Isolates

JavaのIsolatesクラスと同じ機能がC APIでも利用可能です。これにより、既存のC言語アプリケーションにJavaコードを埋め込むことができます。このシナリオの場合、C言語のコードでisolateのライフサイクルを管理します。つまり、C言語のコードがisolateを作成し、isolate内でJavaメソッド(アノテーション@CEntryPointが付けられたJavaメソッド)を呼び出し、最後にisolateを破棄します。--sharedオプションを指定してnative-imageを実行すると、共有ライブラリを構築してC APIを自動的に公開します。Javaコード用の共有ライブラリに加えて、native-imageツールは型定義と関数プロトタイプを含むCヘッダーファイルを生成します。このヘッダーファイルには、isolateディスクリプタ用にgraal_isolate_t型、スレッドディスクリプタ用にgraal_isolatethread_t型、そしてisolateの作成のためのgraal_create_isolateやisolateの破棄のためのgraal_tear_down_isolateのような関数が含まれています。

GitHubのGraalVMリポジトリには、このAPIと一般的なネイティブイメージのためのC言語のインターフェースの使い方に関するサンプルがたくさんあります。C言語のコード、Javaのコードはそれぞれ以下からアクセスできます。

cinterfacetutorial.c (C言語の例)
https://github.com/oracle/graal/blob/master/substratevm/src/com.oracle.svm.tutorial/native/cinterfacetutorial.c#L96

CInterfaceTutorial.java (Javaの例)
https://github.com/oracle/graal/blob/master/substratevm/src/com.oracle.svm.tutorial/src/com/oracle/svm/tutorial/CInterfaceTutorial.java

Isolate Implementation Details

以下の2つのパフォーマンス上の目標を念頭に置いてisolateを実装しました。

  1. 多数のisolateを短時間で実行するタスク用に作成できるよう、isolateの新規作成は高速かつメモリのオーバーヘッドを低くすること
  2. ピークパフォーマンス、つまりisolate内で実行するコードに対する影響が少ない、もしくは全く影響しないこと

上記目標を達成するために、Javaオブジェクトへの参照の処理方法を変更し、オブジェクトの絶対メモリアドレスを使用するのではなく、イメージヒープの先頭からの相対参照を使用することにしました。これはオブジェクトや配列要素からフィールドをロードする場合のように、メモリアクセスには間接参照が必要であることを意味し、メモリアクセスの前にヒープの開始位置を参照に追加する必要があります。この処理を可能な限り速くするために、ヒープの開始位置は常に固定レジスタで利用可能です(x64アーキテクチャではレジスタr14を使用)。多くの場合、x86アーキテクチャでのメモリアクセス命令に加算を組み込み、明示的な算術演算を避けることができる点に着目してください。

すべてのオブジェクト参照がイメージヒープの開始を基準にしているため、イメージ生成中に準備されるイメージヒープとネイティブ実行可能ファイルの一部は、アドレス空間で複数回メモリマップできます。これにより、コピーせずにイメージヒープを複製(高速なisolateの作成)できるとともに、イメージヒープをcopy-on-writeで共有することでメモリオーバーヘッドを少なくできます。

以下の手順でisolateを新規作成します。

  1. OSのローダーが作成するメモリマッピングを調べて、ディスク上の実行可能ファイル内のイメージヒープを見つける。このステップの結果をキャッシュできるので、以後isolateを作成する際にはこの手順をスキップできる。
  2. ディスクから、先ほど予約したメモリ範囲の先頭へイメージヒープの読み取り専用マッピングを作成する。
  3. copy-on-writeとして書き込み可能オブジェクトを含むマッピングのパーティションをマークする。
  4. 指定されたヒープベース・レジスタ(x64アーキテクチャではr14)を、予約されたメモリ範囲の開始アドレスを含むように設定する。このメモリアドレスの先頭に、イメージヒープのマッピングがある。
  5. 現在のスレッドをisolateにアタッチし、スレッド固有の実行コンテキストを作成し、アタッチされたスレッドのisolateごとのリストにそのスレッドを追加する。

isolateの作成時に、イメージ・ヒープ内のJavaオブジェクト間の参照は再配置を必要としないことに注意してください。これらの参照は、Javaオブジェクト間の他の参照と同様に、ヒープ開始位置からの相対位置であるためです。

isolateは新しいスレッドを開始することも、既存のスレッドをisolateにアタッチすることもできます。スレッドとisolateの間にはn:mの関係にあって、1つのスレッドを複数のisolateに接続でき、1つのisolateに複数のスレッドを接続できます。

後でisolateを破棄するために、isolateの残存するすべてのスレッドが中断され、その結果、クリーンなシャットダウンのために処理して渡すことが可能な例外が各スレッドで発生します。すべてのスレッドが終了すると、isolateはそのメモリ範囲全体をOSに返すことによって破棄されます。

Compressed References

クラウドでソフトウェアを実行する際の費用に直接影響するため、メモリ使用量は重要です。isolateのためのイメージヒープのcopy-on-write共有により、いくらかのメモリフットプリントの改善が可能ですが、特にJavaのような参照の多いマネージド言語では、各参照のための64ビットが積み重なって、かなりのオーバーヘッドになります。控えめなヒープサイズで実行している限り、32ビットで参照可能な最大ヒープサイズは32 GByte (2³⁵ Byte) なので、参照には32ビットで十分です。32ビットの参照に対する追加の3ビットのアドレス範囲は、通常の8バイトのオブジェクトアライメントによるもので、参照の最下位3ビットを記憶する必要はありません。

参照の圧縮および圧縮解除の操作は、多くの場合x86メモリアドレッシングモードに組み込むことができます。Java HotSpot VMは昔から圧縮参照をサポートしているので、その技法はよく理解されています。しかし、Java HotSpot VMは初期イメージヒープをサポートしていないため、圧縮参照のベースとして定数を使用できます。isolateの導入によってのみ、ネイティブイメージでの圧縮参照をサポートします。すべてのメモリアクセスはすでにイメージヒープの先頭(レジスタr14に格納されている)に対して相対的であるため、完全な64ビット参照ではなく、ヒープの先頭からの相対参照に対して32ビット参照のみを使用することは簡単です。

圧縮参照の実装は、新しいisolateを作成するために上記のステップ1を少し変更するだけで済みます。OSからイメージヒープだけを保持するのに十分な大きさのメモリ範囲を予約するのではなく、最大ヒープサイズをカバーするアドレス範囲を予約します。デフォルトでは、32 GBのアドレス空間をすべて予約します。このメモリを、物理メモリが支えることを要求しません。割り当てられたオブジェクトが実際に占有するアドレス空間の部分だけがコミットされます。

Javaオブジェクトのメモリは、予約済みの連続したメモリ範囲に割り当てる必要があります。そのためには、自身でその範囲を管理し、物理メモリが部分範囲を補助するというOSからのリクエストだけでなく、物理メモリが補助している未使用の部分範囲のOSへの返却も管理する必要があります。

このサンプルでは、圧縮参照を使ってどれだけのメモリを節約できるでしょうか。リクエスト・ハンドラは、isolate作成後および破棄前に、レンダリングのisolateのJavaヒープサイズを出力します。圧縮参照の場合の出力は以下のようになりました。

Rendering isolate initial memory usage: 4114 KByte
Rendering isolate final memory usage: 5910 KByte

初期メモリサイズはイメージヒープのサイズですが、このメモリはcopy-on-writeであることを思い出してください。そのため、OSがコミットすべき部分はごく一部です。初期メモリサイズと破棄前のメモリサイズの差である1795 KByteがレンダリング中に割り当てられたメモリです。

以下は圧縮参照を使わなかった場合の結果です。

Rendering isolate initial memory usage: 4895 KByte
Rendering isolate final memory usage: 6936 KByte

イメージヒープのサイズが大きくなっており、レンダリング中に割り当てられたメモリの量が2041 KByteに増加しています。

Caveats

isolateと圧縮参照はSubstrate VMの新機能です。お試しいただいて是非フィードバックをお寄せください。圧縮参照はデフォルトで有効ですが、-H:-UseCompressedReferencesオプションを使って無効化できます。

isolateはLinux、macOSの両方でサポートされますが、macOSの実装は最適化が進んでいません。新しいisolateを作成すると、Linuxではイメージファイルからマッピングするところ、macOSでは常にイメージヒープをコピーするために少し時間がかかります。macOSでも完全にサポートするように取り組んでいる最中ですので、ご期待ください。

Summary

このエントリでは、GraalVMネイティブイメージの2つの高度な機能(isolate機能によるより柔軟なメモリ管理、および圧縮参照によるネイティブイメージのメモリ使用量の削減)について説明しました。どちらもGraalVMの最新のリリースで利用可能になっているので、これらを試してみたい場合は、Webサイトからバイナリを入手して試してみてください。これらは高度な機能なので、以前にネイティブイメージを作成したことがない場合は、その便益がよくわからない可能性があります。しかし、アプリケーションのメモリの一部を保護したり、全体として解放可能な多くのオブジェクトを生成する計算を実装したり、あるいは外部の制限に基づいてアプリケーションのメモリを個々のチャンクに分割したりしたい場合は、isolateがお役に立ちます。圧縮参照を使用すると、コストをかけずにメモリ使用量を減らすことができます。これは、メモリ使用量が非常に重要なプラットフォームでは特に魅力的です。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中