Improve React.js Server-Side Rendering by 150% with GraalVM

原文はこちら。
The original was written by Jiří Maršík.
https://medium.com/graalvm/improve-react-js-server-side-rendering-by-150-with-graalvm-58a06ccb45df

GraalVMは、JavaScriptを含む数多くの人気のある言語をサポートする、ハイパフォーマンスな仮想マシンです。廃止対象のNashornエンジンでJavaScriptを動かしている場合、ECMAScriptの優れた互換性をもたらすGraalVMをチェックしてください。Nashornからの簡単な移行方法も用意しています。

Migration Guide from Nashorn to GraalVM JavaScript
https://github.com/graalvm/graaljs/blob/master/docs/user/NashornMigrationGuide.md

このエントリでは、React.jsをサーバーサイドで稼働するようにNashornで既述された、既存のWebアプリケーションを例に、GraalVMに移植する方法をご紹介します。アプリケーションの移行は正確性を損なわずに簡単であり、実際にJavaScriptコンポーネントのパフォーマンスが向上する点を確認いただけることでしょう。

Talkyard.io — a React.js client application with server-side features, written in Scala and TypeScript

本日は、Talkyardを例にします。これはStackOverflowやDiscourse、Slack、その他のオンラインプラットフォームにヒントを得た、機能セットを持つディスカッションのためのWebサイトです。

Talkyard
https://www.talkyard.io/

Webサイトの利用者は質問を投稿したり、回答を投げたりできますし、お気に入りの投稿に対して投票したり、ダイレクトメッセージを交換したりできます。アプリケーションのサーバーサイドはPlayフレームワークで実装されており4万4千行のScalaコードにのぼります。クライアントサイドはReact.jsで実装されており、JavaScriptに変換される2万7千行のTypeScriptのコードが含まれています。

Screenshot from the talkyard application as displayed in a browser
Screenshot of the Talkyard application as presented in a browser.

TalkyardのようなWebアプリケーションはReact.jsのようなリッチなクライアントサイドUIフレームワークを使っており、ページロードが遅くなるというリスクがあります。利用者のブラウザがページをレンダリングするために、ブラウザはまずアプリケーションコードをダウンロードし解析して実行する必要がありますが、アプリケーション開発者はサーバーサイドで事前にレンダリングし。すぐに表示できるHTMLをコードとともにクライアントに提供することで、この作業を回避できます。これはつまりクライアントが最初にアプリケーションコードを実行せずにページをレンダリングできるということですが、アプリケーションコードを実行してクライアントサイドのビューを進化させることが可能ゆえ、ページはまだリアクティブです。しかし、サーバーサイドのReact.jsのレンダリングを活用できるアプリケーションの場合、JavaScriptコードをサーバーサイドで実行できる必要があります。幸運にもJavaエコシステムの場合にはJavaScript実行のための複数の選択肢があります。このエントリではそのうちの2個、NashornとGraalVMを取り上げます。

NashornはJDK 8から搭載されているJavaScriptエンジンで、TalkyardがReact.jsのサーバーサイドレンダリングを実現するために現在利用しています。GraalVMはJVM上に作られた言語ランタイムで、JVMベースの言語だけでなく、JavaScriptを含む、その他のプログラミング言語も効率よく実行できます。このエントリではTalkyardのGraalVMへの移植について取り上げます。この移行を検討する理由が2個あります。

  1. GraalVMのJavaScriptは(Nashornに比べ)ピークパフォーマンスに優れている
  2. NashornはJDK 11で廃止された

JEP 335: Deprecate the Nashorn JavaScript Engine
https://openjdk.java.net/jeps/335

Migrating the application

The interface between the TalkyardアプリケーションとNashorn JavaScriptエンジン間のインターフェースは、Nashornと呼ばれる1個のScalaモジュール (Nashorn.scala) で完全にカプセル化されています。

Nashorn.scala
https://github.com/debiki/talkyard/blob/master/app/debiki/Nashorn.scala

このモジュールに対し、NashornではなくGraalVM JavaScriptエンジンを利用するために必要な変更を加えていきます。

まず、ScriptEngine APIの代わりにGraalVM SDK APIを使用します。

Package org.graalvm.polyglot
https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/package-summary.html

GraalVM JavaScriptはScriptEngine APIをサポートします。適切な場合、これは通常、NashornアプリケーションをGraalVMに移植する最も簡単な方法なのですが、以下でご覧いただくように、この特定のコードベースは非依存ではありません。つまりScriptEngineが使用され、JavaScript実装がNashorn固有のクラスのインスタンスを返すと想定しています。この種のコードは、それぞれが内部で異なるデータ構造を使用するため、異なるScriptEngine間で互換性がありません。一方、GraalVM SDK APIには、JavaScriptランタイムの実装の詳細を知らなくてもJavaScriptオブジェクトにアクセスできる汎用インターフェイスが含まれています。

ScriptEngineを使ってJavaScriptランタイムのインスタンスを参照するかわりに、GraalVM SDK APIでGraalVM Contextを使います。

GraalVM Contextのインスタンスを作成するには、Context.Builderを使って、アクセスしたい言語を指定します(今回の場合、JavaScriptを意味する js を指定します)。そしてNashornの場合と同じように、JavaScriptコードがJVMにアクセスできるように指定します。

Context作成後、アプリケーションコードをロードしてセットアップし、ページの描画リクエストを処理できるようにする必要があります。GraalVM Contextは一般に様々な言語でコードを実行できるため、どの言語を使うか指定する必要があり、その点で以下のコードがわずかに冗長になっています。

GraalVM Contextが準備できたので、Nashornのときと同じように関数を実行できます。

最後に、Scalaコードから直接JavaScriptオブジェクトにアクセスする箇所があります。Nashornの場合、変数の結果はNashornのScriptObjectMirror型であり、GraalVMの場合、GraalVMのValue型です。以下のスニペットは、JavaScriptオブジェクトの形状を照会し、そのメンバーにアクセスするためのインターフェースの両者間での違いを示しています。

これで、NashornからGraalVM JavaScriptへのアプリケーション移植は完了です。完全な差分は以下からご覧頂けます。

https://gist.github.com/jirkamarsik/b282fef51075468b735105ede1c25452

上記変更とは別に、完全なパッチには以下の編集が含まれています。

  • js.ScriptEngineとjs.Invocableへの全ての参照をgraalvm.Contextへの参照に置換
  • Invocable.invoiceをValue.executeに置換
  • Maven依存関係としてgraal-sdk/org.graalvm.sdkを包含
  • AbstractStrictEngineのカスタムサブクラスをOptionに置換

最後に、Webアプリケーションを実行するイメージを作成するDockerfileを変更してオリジナルのJDK (OpenJDK) ではなくGraalVM を含めるようにします。

Comparing changes
https://github.com/jirkamarsik/talkyard/compare/0311dde…3204df9

Testing the port

Talkyardリポジトリには、ユニットテストとSelemiumを使うend-to-endテストが含まれています。

Unit test
https://github.com/debiki/talkyard/tree/master/tests/app
End to end test
https://github.com/debiki/talkyard/tree/master/tests/e2e

これらを実行すれば、GraalVMへの切り替えとGraalVMのJavaScriptエンジンへの切り替えでも、Nashornで見られた挙動を維持していることがわかります。

注意)これは、このアプリケーションが標準のECMAScript機能に制限されているためです。NashornはECMAScriptをカスタム機能で拡張し、その使用はGraalVMでサポートされていますが、その場合、明示的に有効にする必要があります(GraalVM JavaScriptの互換性の詳細については、以下のドキュメントを参照してください)。

Nashorn Compatibility Mode
https://github.com/graalvm/graaljs/blob/master/docs/user/NashornMigrationGuide.md#nashorn-compatibility-mode
GraalVM JavaScript Language Compatibility
https://github.com/graalvm/graaljs/blob/master/docs/user/JavaScriptCompatibility.md

Benchmarking the port

移植がパフォーマンスに与える影響をテストするには、さまざまな実装のスループット/レイテンシを測定および比較できるように、アプリケーションが処理する再現可能なタスクを準備する必要があります。TalkyardはサーバーサイドJavaScriptを使ってユーザーにページを提供するため、単一のページを提供するときにアプリケーションのスループットをテストします。ただし、ベンチマークの全期間にわたって同じページを何度も要求すると、ランタイムが最適化されたコードを生成し結果としてベンチマークが良くなるけれど、幅広いページでのアプリケーションのパフォーマンスを表すものではなくなるというリスクが発生します。これを回避するために、ウォームアッププロセス中に一連の異なるページをランダムに切り替えて、異なるランタイムが異なるページを処理するのに十分な汎用コードになるようにします。テスト対象のページセットについては、いくつかのオープンソースリポジトリのランダムなテキストの段落とMarkdownのREADMEファイルから作成した、より冗長な質問ページを追加したデフォルトのサンプルサイトを選択しました(サーバーサイドJavaScriptの責務の1つはMarkdownをHTMLに変換することです)。このベンチマークの実行に使用したさまざまなスクリプトとサイトデータは以下からご覧頂けます。

Collecting data for the warmup curve charts
https://github.com/jirkamarsik/talkyard-benchmarking

さらに、これらのベンチマークを可能にするために、Talkyardを少し調整する必要がありました。まず、含まれているレートリミッターを無効にして、ワークロードジェネレーターからのリクエストをブロックしないようにしました。

Disable the RateLimiter for benchmarking
https://github.com/jirkamarsik/talkyard/commit/151daef85e6888bd2fed348297d5ca09da2a3445

Talkyardは、サーバーサイドレンダリングの結果をメモリとRDBMSの両方にキャッシュします。サーバーサイドレンダリングパイプラインの変更を表示するには、これらのキャッシュを無効にする必要がありました。また、各リクエストのレイテンシデータをキャプチャできるいくつかのフックも含まれています。

Disable memcache and RDB cache for rendered pages for benchmarking
https://github.com/jirkamarsik/talkyard/commit/2c8d34313a03ff53ae9062c7fd1318cf741482cb

wrkを使ってアプリケーションにリクエストを送り、スループットを測定しました。以下で示すデータはOpenJDK + NashornはOpenJDK 8u121-b03、GraalVMはGraalVM 19.3.0 Community Edition + GraalVM JavaScript、GraalVM 19.3.0 Enterprise Edition + GraalVM JavaScriptの組み合わせで稼働するワークステーションで取得したものです。

Warmup curve of OpenJDK+Nashorn and GraalVM+JavaScript, including Java, Scala, and JavaScript parts.

グラフから分かるように、素のJVM + Nashorn、GraalVMいずれも長時間のウォームアップのカーブが出ていますが、GraalVMのほうがピークパフォーマンスに早く到達します(GraalVM Enterprise Editionで9分、Community Editionで12分、大してNashornの場合は20分)。より重要なのは、Nashornに比べてピークパフォーマンスが25%以上も高く、GraalVM Enterprise Editionの場合Nashornを35%以上上回っています。

また、サーバーサイドJavaScriptコードの実行を担当するアプリケーションの部分でのみ費やされる時間も測定しました。これにより、React.jsワークロードでのGraalVMとNashornのパフォーマンスの違いをより集中的に確認できます。

Warmup curve of OpenJDK+Nashorn and GraalVM+JavaScript, JavaScript parts only.

(注意)上図では、ページレンダリングに要した平均時間でコア数(8)を割ったものとしてスループットを計算しています。

このグラフで、Nashornの場合、秒間800以下のページレンダリング(1ページレンダリングで10msec)ですが、GraalVMの場合、秒間2000以下のページレンダリング(1ページレンダリングで4msec)と、150%ものレンダリングスループットの上昇を確認できました。

最適化にあたってはパフォーマンスのウォームアップがいつも話題に上がりますので、将来よりよいウォームアップができるものと期待しています。この例は大規模なReact.jsアプリケーションをGraalVMで稼働するという最初の体験でもあるため、この種のワークロードに対するパフォーマンスの最適化はまだ試行されていません。

Summary

JVM内でサーバーサイドScalaとTypeScript/JavaScriptの両方を実行するかなり大きなアプリケーションを見てきました。JavaScriptインターフェイスをNashornからGraalVM JavaScript実装に移植する方法を見てきました。React.jsのサーバーサイドレンダリング時のように、JVMコードとJavaScriptコードの間のインターフェースが小さい場合、移植自体を簡単です。最終的に、Nashornから移植したものはすべてのテストに合格し、パフォーマンスが向上しています。

GraalVM JavaScriptに移植したいJavaScriptアプリケーションをお持ちですか?あなたの体験を聞かせてください。フィードバックによってGraalVMが改善されていきます。

GraalVMのSlack招待ページ
https://www.graalvm.org/slack-invitation/

障害やパフォーマンス上の問題がある場合は喜んでお手伝いします。ぜひGraalVM JavaScript (graaljs)でIssueを立ててください。

graaljs Issue
https://github.com/graalvm/graaljs/issues

コメントを残す

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

WordPress.com ロゴ

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

Google フォト

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

Twitter 画像

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

Facebook の写真

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

%s と連携中