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

原文はこちら。
The original entry 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エンジンを利用するために必要な変更を加えていきます。

-import javax.{script => js}
+import org.graalvm.{polyglot => graalvm}
view raw import_diff hosted with ❤ by GitHub

まず、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を使います。

- private def makeJavascriptEngine(): js.ScriptEngine = {
+ private def makeJavascriptEngine(): graalvm.Context = {
view raw engine_diff hosted with ❤ by GitHub

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

- val newEngine = new js.ScriptEngineManager(null).getEngineByName("nashorn")
+ val newEngine = graalvm.Context.newBuilder("js").allowAllAccess(true).build
view raw engine_create_diff hosted with ❤ by GitHub

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

- newEngine.eval(script)
+ val scriptSource = graalvm.Source.newBuilder("js", script, "setup").buildLiteral
+ newEngine.eval(scriptSource)
view raw source_create_diff hosted with ❤ by GitHub

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

- val htmlOrError = engine.invokeFunction(
- "renderReactServerSide", reactStoreJsonString, cdnOrigin.getOrElse("")).asInstanceOf[String]
+ val htmlOrError = engine.getBindings("js").getMember("renderReactServerSide")
+ .execute(reactStoreJsonString, cdnOrigin.getOrElse("")).asString

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

- dieIf(!result.isArray, "Not an array")
- dieIf(!result.hasSlot(0), "No slot 0")
- dieIf(!result.hasSlot(1), "No slot 1")
- dieIf(result.hasSlot(2), "Has slot 2")
+ dieIf(!result.hasArrayElements, "Not an array")
+ dieIf(result.getArraySize != 2, "Array of size != 2")
- val elem0 = result.getSlot(0)
- dieIf(!elem0.isInstanceOf[String], ...)
- val safeHtml = elem0.asInstanceOf[String]
+ val elem0 = result.getArrayElement(0)
+ dieIf(!elem0.isString, ...)
+ val safeHtml = elem0.asString
- val elem1 = result.getSlot(1)
- dieIf(!elem1.isInstanceOf[ScriptObjectMirror], ...)
- val mentionsArrayMirror = elem1.asInstanceOf[ScriptObjectMirror]
+ val elem1 = result.getArrayElement(1)
+ dieIf(!elem1.hasArrayElements, ...)
+ val mentionsArrayMirror = elem1
val mentions = ArrayBuffer[String]()
var nextSlotIx = 0
- while (mentionsArrayMirror.hasSlot(nextSlotIx)) {
- val elem = mentionsArrayMirror.getSlot(nextSlotIx)
- dieIf(!elem.isInstanceOf[String], ...)
- mentions.append(elem.asInstanceOf[String])
- nextSlotIx += 1
- }
+ while (nextSlotIx < mentionsArrayMirror.getArraySize) {
+ val elem = mentionsArrayMirror.getArrayElement(nextSlotIx)
+ dieIf(!elem.isString, ...)
+ mentions.append(elem.asString)
+ nextSlotIx += 1
+ }

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

diff --git a/app/debiki/Nashorn.scala b/app/debiki/Nashorn.scala
index 13f9f2eb4..bdc2604a7 100644
--- a/app/debiki/Nashorn.scala
+++ b/app/debiki/Nashorn.scala
@@ -22,8 +22,7 @@ import java.io.{BufferedWriter, FileWriter}
import com.debiki.core._
import com.debiki.core.Prelude._
import java.{io => jio}
-
-import javax.{script => js}
+import org.graalvm.{polyglot => graalvm}
import debiki.onebox.{InstantOneboxRendererForNashorn, Onebox}
import org.apache.lucene.util.IOUtils
import play.api.Play
@@ -31,7 +30,6 @@ import play.api.Play
import scala.concurrent.Future
import scala.util.Try
import Nashorn._
-import jdk.nashorn.api.scripting.ScriptObjectMirror
import org.scalactic.{Bad, ErrorMessage, Good, Or}
import scala.collection.mutable.ArrayBuffer
@@ -62,14 +60,9 @@ class Nashorn(globals: Globals) {
/** The Nashorn Javascript engine isn't thread safe. */
private val javascriptEngines =
- new java.util.concurrent.LinkedBlockingDeque[js.ScriptEngine](999)
+ new java.util.concurrent.LinkedBlockingDeque[Option[graalvm.Context]](999)
- private val BrokenEngine = new js.AbstractScriptEngine() {
- override def eval(script: String, context: js.ScriptContext): AnyRef = null
- override def eval(reader: jio.Reader, context: js.ScriptContext): AnyRef = null
- override def getFactory: js.ScriptEngineFactory = null
- override def createBindings(): js.Bindings = null
- }
+ private val BrokenEngine = None
@volatile private var firstCreateEngineError: Option[Throwable] = None
@@ -180,7 +173,7 @@ class Nashorn(globals: Globals) {
}
return
}
- javascriptEngines.putLast(engine)
+ javascriptEngines.putLast(Some(engine))
}
@@ -193,12 +186,13 @@ class Nashorn(globals: Globals) {
}
- private def renderPageImpl[R](engine: js.Invocable, reactStoreJsonString: String)
+ private def renderPageImpl[R](engine: graalvm.Context, reactStoreJsonString: String)
: String Or ErrorMessage = {
val timeBefore = System.nanoTime()
- val htmlOrError = engine.invokeFunction(
- "renderReactServerSide", reactStoreJsonString, cdnOrigin.getOrElse("")).asInstanceOf[String]
+
+ val htmlOrError = engine.getBindings("js").getMember("renderReactServerSide")
+ .execute(reactStoreJsonString, cdnOrigin.getOrElse("")).asString
if (htmlOrError.startsWith(ErrorRenderingReact)) {
logger.error(s"Error rendering page with React server side [DwE5KGW2]")
return Bad(htmlOrError)
@@ -232,44 +226,41 @@ class Nashorn(globals: Globals) {
// The onebox renderer needs a Javascript engine to sanitize html (via Caja JsHtmlSanitizer)
// and we'll reuse `engine` so we won't have to create any additional engine.
oneboxRenderer.javascriptEngine = Some(engine)
- val resultObj: Object = engine.invokeFunction("renderAndSanitizeCommonMark", commonMarkSource,
+ val result: graalvm.Value = engine.getBindings("js").getMember("renderAndSanitizeCommonMark").execute(
+ commonMarkSource,
true.asInstanceOf[Object], // allowClassIdDataAttrs.asInstanceOf[Object],
followLinks.asInstanceOf[Object],
- oneboxRenderer, uploadsUrlPrefix)
+ oneboxRenderer,
+ uploadsUrlPrefix)
oneboxRenderer.javascriptEngine = None
- val result: ScriptObjectMirror = resultObj match {
- case scriptObjectMirror: ScriptObjectMirror =>
- scriptObjectMirror
- case errorDetails: ErrorMessage =>
- // Don't use Die — the stack trace to here isn't interesting? Instead, it's the
- // errorDetails from the inside-Nashorn exception that matters.
- debiki.EdHttp.throwInternalError(
- "TyERCMEX", "Error rendering CommonMark, server side in Nashorn", errorDetails)
- case unknown =>
- die("TyERCMR01", s"Bad class: ${classNameOf(unknown)}, thing as string: ``$unknown''")
+ if (result.isString) {
+ // Don't use Die — the stack trace to here isn't interesting? Instead, it's the
+ // errorDetails from the inside-Nashorn exception that matters.
+ debiki.EdHttp.throwInternalError(
+ "TyERCMEX", "Error rendering CommonMark, server side in Nashorn", result.asString)
+ } else if (!result.hasMembers) {
+ die("TyERCMR01", s"Bad class: ${classNameOf(result)}, thing as string: ``$result''")
}
- dieIf(!result.isArray, "TyERCMR02", "Not an array")
- dieIf(!result.hasSlot(0), "TyERCMR03A", "No slot 0")
- dieIf(!result.hasSlot(1), "TyERCMR03B", "No slot 1")
- dieIf(result.hasSlot(2), "TyERCMR03C", "Has slot 2")
+ dieIf(!result.hasArrayElements, "TyERCMR02", "Not an array")
+ dieIf(result.getArraySize != 2, "TyERCMR03D", "Array of size != 2")
- val elem0 = result.getSlot(0)
- dieIf(!elem0.isInstanceOf[String], "TyERCMR04", s"Bad safeHtml class: ${classNameOf(elem0)}")
- val safeHtml = elem0.asInstanceOf[String]
+ val elem0 = result.getArrayElement(0)
+ dieIf(!elem0.isString, "TyERCMR04", s"Bad safeHtml class: ${classNameOf(elem0)}")
+ val safeHtml = elem0.asString
- val elem1 = result.getSlot(1)
- dieIf(!elem1.isInstanceOf[ScriptObjectMirror],
+ val elem1 = result.getArrayElement(1)
+ dieIf(!elem1.hasArrayElements,
"TyERCMR05", s"Bad mentionsArray class: ${classNameOf(elem1)}")
- val mentionsArrayMirror = elem1.asInstanceOf[ScriptObjectMirror]
+ val mentionsArrayMirror = elem1
val mentions = ArrayBuffer[String]()
var nextSlotIx = 0
- while (mentionsArrayMirror.hasSlot(nextSlotIx)) {
- val elem = mentionsArrayMirror.getSlot(nextSlotIx)
- dieIf(!elem.isInstanceOf[String], "TyERCMR06", s"Bad mention class: ${classNameOf(elem)}")
- mentions.append(elem.asInstanceOf[String])
+ while (nextSlotIx < mentionsArrayMirror.getArraySize) {
+ val elem = mentionsArrayMirror.getArrayElement(nextSlotIx)
+ dieIf(!elem.isString, "TyERCMR06", s"Bad mention class: ${classNameOf(elem)}")
+ mentions.append(elem.asString)
nextSlotIx += 1
}
@@ -289,13 +280,13 @@ class Nashorn(globals: Globals) {
def sanitizeHtmlReuseEngine(text: String, followLinks: Boolean,
- javascriptEngine: Option[js.Invocable]): String = {
+ javascriptEngine: Option[graalvm.Context]): String = {
if (isTestSoDisableScripts)
return "Scripts disabled [EsM44GY0]"
- def sanitizeWith(engine: js.Invocable): String = {
- val safeHtml = engine.invokeFunction(
- "sanitizeHtmlServerSide", text, followLinks.asInstanceOf[Object])
- safeHtml.asInstanceOf[String]
+ def sanitizeWith(engine: graalvm.Context): String = {
+ val safeHtml = engine.getBindings("js").getMember("sanitizeHtmlServerSide")
+ .execute(text, followLinks.asInstanceOf[Object])
+ safeHtml.asString
}
javascriptEngine match {
case Some(engine) =>
@@ -312,13 +303,13 @@ class Nashorn(globals: Globals) {
if (isTestSoDisableScripts)
return "scripts-disabled-EsM28WXP45"
withJavascriptEngine(engine => {
- val slug = engine.invokeFunction("debikiSlugify", title)
- slug.asInstanceOf[String]
+ val slug = engine.getBindings("js").getMember("debikiSlugify").execute(title)
+ slug.asString
})
}
- private def withJavascriptEngine[R](fn: (js.Invocable) => R): R = {
+ private def withJavascriptEngine[R](fn: (graalvm.Context) => R): R = {
dieIf(isTestSoDisableScripts, "EsE4YUGw")
def threadId = Thread.currentThread.getId
@@ -337,29 +328,32 @@ class Nashorn(globals: Globals) {
// Don't: Future { createOneMoreJavascriptEngine() }
}
- val engine = javascriptEngines.takeFirst()
+ val maybeEngine = javascriptEngines.takeFirst()
if (mightBlock) {
logger.debug(s"...Thread $threadName (id $threadId) got a JS engine.")
- if (engine eq BrokenEngine) {
+ if (maybeEngine eq BrokenEngine) {
logger.debug(s"...But it is broken; I'll throw an error. [DwE4KEWV52]")
}
}
- if (engine eq BrokenEngine) {
- javascriptEngines.addFirst(engine)
- die("DwE5KGF8", "Could not create Javascript engine; cannot render page",
- firstCreateEngineError getOrDie "DwE4KEW20")
- }
-
- try fn(engine.asInstanceOf[js.Invocable])
- finally {
- javascriptEngines.addFirst(engine)
+ maybeEngine match {
+ case Some(engine) => {
+ try fn(engine)
+ finally {
+ javascriptEngines.addFirst(maybeEngine)
+ }
+ }
+ case None => {
+ javascriptEngines.addFirst(maybeEngine)
+ die("DwE5KGF8", "Could not create Javascript engine; cannot render page",
+ firstCreateEngineError getOrDie "DwE4KEW20")
+ }
}
}
- private def makeJavascriptEngine(): js.ScriptEngine = {
+ private def makeJavascriptEngine(): graalvm.Context = {
val timeBefore = System.currentTimeMillis()
def threadId = java.lang.Thread.currentThread.getId
def threadName = java.lang.Thread.currentThread.getName
@@ -370,7 +364,7 @@ class Nashorn(globals: Globals) {
// Pass 'null' so that a class loader that finds the Nashorn extension will be used.
// Otherwise the Nashorn engine won't be found and `newEngine` will be null.
// See: https://github.com/playframework/playframework/issues/2532
- val newEngine = new js.ScriptEngineManager(null).getEngineByName("nashorn")
+ val newEngine = graalvm.Context.newBuilder("js").allowAllAccess(true).build
val scriptBuilder = new StringBuilder
scriptBuilder.append(i"""
@@ -502,7 +496,8 @@ class Nashorn(globals: Globals) {
}
}
- newEngine.eval(script)
+ val scriptSource = graalvm.Source.newBuilder("js", script, "setup").buildLiteral
+ newEngine.eval(scriptSource)
def timeElapsed = System.currentTimeMillis() - timeBefore
logger.debug(o"""... Done initializing Nashorn engine, took: $timeElapsed ms,
@@ -534,13 +529,13 @@ class Nashorn(globals: Globals) {
}
- private def warmupJavascriptEngine(engine: js.ScriptEngine) {
+ private def warmupJavascriptEngine(engine: graalvm.Context) {
logger.debug(o"""Warming up Nashorn engine...""")
val timeBefore = System.currentTimeMillis()
// Warming up with three laps seems enough, almost all time is spent in at lap 1.
for (i <- 1 to 3) {
val timeBefore = System.currentTimeMillis()
- renderPageImpl(engine.asInstanceOf[js.Invocable], WarmUpReactStoreJsonString)
+ renderPageImpl(engine, WarmUpReactStoreJsonString)
def timeElapsed = System.currentTimeMillis() - timeBefore
logger.info(o"""Warming up Nashorn engine, lap $i done, took: $timeElapsed ms""")
}
diff --git a/app/debiki/onebox/Onebox.scala b/app/debiki/onebox/Onebox.scala
index 77d200d08..355aaf48e 100644
--- a/app/debiki/onebox/Onebox.scala
+++ b/app/debiki/onebox/Onebox.scala
@@ -21,7 +21,7 @@ import com.debiki.core._
import com.debiki.core.Prelude._
import debiki.{Globals, Nashorn}
import debiki.onebox.engines._
-import javax.{script => js}
+import org.graalvm.{polyglot => graalvm}
import scala.collection.mutable
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.{ExecutionContext, Future}
@@ -78,7 +78,7 @@ abstract class OneboxEngine(globals: Globals, val nashorn: Nashorn) {
uploadsLinkRegex.replaceAllIn(safeHtml, s"""="$prefix$$1"""")
}
- final def loadRenderSanitize(url: String, javascriptEngine: Option[js.Invocable])
+ final def loadRenderSanitize(url: String, javascriptEngine: Option[graalvm.Context])
: Future[String] = {
def sanitizeAndWrap(html: String): String = {
var safeHtml =
@@ -157,7 +157,7 @@ class Onebox(val globals: Globals, val nashorn: Nashorn) {
new GiphyOnebox(globals, nashorn),
new YouTubeOnebox(globals, nashorn))
- def loadRenderSanitize(url: String, javascriptEngine: Option[js.Invocable])
+ def loadRenderSanitize(url: String, javascriptEngine: Option[graalvm.Context])
: Future[String] = {
for (engine <- engines) {
if (engine.handles(url))
@@ -167,7 +167,7 @@ class Onebox(val globals: Globals, val nashorn: Nashorn) {
}
- def loadRenderSanitizeInstantly(url: String, javascriptEngine: Option[js.Invocable])
+ def loadRenderSanitizeInstantly(url: String, javascriptEngine: Option[graalvm.Context])
: RenderOnboxResult = {
def placeholder = PlaceholderPrefix + nextRandomString()
@@ -203,7 +203,7 @@ class InstantOneboxRendererForNashorn(val oneboxes: Onebox) {
// Should be set to the Nashorn engine that calls this class, so that we can call
// back out to the same engine, when sanitizing html, so we won't have to ask for
// another engine, that'd create unnecessarily many engines.
- var javascriptEngine: Option[js.Invocable] = None
+ var javascriptEngine: Option[graalvm.Context] = None
def renderAndSanitizeOnebox(unsafeUrl: String): String = {
lazy val safeUrl = org.owasp.encoder.Encode.forHtml(unsafeUrl)
diff --git a/build.sbt b/build.sbt
index b89afa57f..edd2f1840 100644
--- a/build.sbt
+++ b/build.sbt
@@ -97,7 +97,9 @@ val appDependencies = Seq(
// CLEAN_UP remove Spec2 use only ScalaTest, need to edit some tests.
"org.mockito" % "mockito-all" % "1.9.0" % "test", // I use Mockito with Specs2...
"org.scalatest" %% "scalatest" % "3.0.5" % "test", // but prefer ScalaTest
- "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test)
+ "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.2" % Test,
+ // GraalVM SDK for GraalVM JavaScript integration
+ "org.graalvm.sdk" % "graal-sdk" % "19.0.0")
val main = (project in file("."))
view raw graaljs.patch hosted with ❤ by GitHub

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で見られた挙動を維持していることがわかります。

 s/d-cli
...
[talkyard-server] $ test
...
[info] ScalaTest
[info] Run completed in 1 minute, 41 seconds.
[info] Total number of tests run: 396
[info] Suites: completed 37, aborted 0
[info] Tests: succeeded 396, failed 0, canceled 0, ignored 0, pending 12
[info] All tests passed.
[info] Passed: Total 407, Failed 0, Errors 0, Passed 407, Pending 12
[success] Total time: 106 s, completed Sep 10, 2019 8:39:22 AM
view raw testing_port hosted with ❤ by GitHub

注意)これは、このアプリケーションが標準の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 と連携中