0
1

NodeJS: V8エンジン編

Posted at

NodeJSについて学ぶべき要素

この記事では、NodeJSに関してより具体的に学ぶ資料です。

  1. イベントループとlibuv
  2. V8エンジンとメモリライフサイクル
  3. コアモジュール

V8エンジン

NodeJSのJavaScript実行エンジンは、GoogleのV8 JavaScriptエンジンが採用されています。

V8エンジンは、Chromeでも採用されている高速なJavaScript実行エンジンで、その名前の由来は燃焼機関のほうのV8エンジンに由来していると言われています。

どのV8エンジンを利用しているか

NodeJSでは、それぞれのバージョンで利用しているV8エンジンのバージョンが異なります。
NodeJSのREPL環境で確認してみましょう。

> process.version
'v16.14.2'
> process.versions
{
  node: '16.14.2',
  v8: '9.4.146.24-node.20',
  uv: '1.43.0',
  zlib: '1.2.11',
  brotli: '1.0.9',
  ares: '1.18.1',
  modules: '93',
  nghttp2: '1.45.1',
  napi: '8',
  llhttp: '6.0.4',
  openssl: '1.1.1n+quic',
  cldr: '40.0',
  icu: '70.1',
  tz: '2021a3',
  unicode: '14.0',
  ngtcp2: '0.1.0-DEV',
  nghttp3: '0.1.0-DEV'
}
> 

V8エンジンはECMAScriptとWebAssemblyを実行でき、多数のプラットフォームとアーキテクチャをサポートしています。NodeJSのJavaScriptの実行はV8によって実現しています。

V8エンジンの基礎

まずは、どのようにJavaScriptが実行されるか学びます。
image.png
出典:https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775

JavaScript AST

JavaScriptのソースコードは、Parserを通じてAST(Abstract Syntx Tree, 抽象構文ツリー)に変換されます。

{
  "type": "Program",
  "start": 0,
  "end": 192,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 179,
      "end": 192,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 183,
          "end": 192,
          "id": {
            "type": "Identifier",
            "start": 183,
            "end": 186,
            "name": "num"
          },
          "init": {
            "type": "Literal",
            "start": 189,
            "end": 191,
            "value": 42,
            "raw": 42
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

Ignitionインタープリタ

Ignitionインタープリタは、ASTをバイトコードに変換します。
image.png
出典:https://dev.to/khattakdev/chrome-v8-engine-working-1lgi

バイトコードは、d8シェルを用いることでデバッグすることができます。

サンプル

function add(x, y) {
  return x + y;
}

console.log(add(1, 2));

これは、V8の内部では次のようにASTに変換されています。

$ out/Debug/d8 --print-ast add.js
…
--- AST ---
FUNC at 12
. KIND 0
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fbd5e818210) (mode = VAR) "x"
. . VAR (0x7fbd5e818240) (mode = VAR) "y"
. RETURN at 23
. . ADD at 32
. . . VAR PROXY parameter[0] (0x7fbd5e818210) (mode = VAR) "x"
. . . VAR PROXY parameter[1] (0x7fbd5e818240) (mode = VAR) "y"

さらに、これらはBytecodeGeneratorに渡され、バイトコードへと変換されます。

$ out/Debug/d8 --print-bytecode add.js
…
[generated bytecode for function: add]
Parameter count 3
Frame size 0
   12 E> 0x37738712a02a @    0 : 94                StackCheck
   23 S> 0x37738712a02b @    1 : 1d 02             Ldar a1
   32 E> 0x37738712a02d @    3 : 29 03 00          Add a0, [0]
   36 S> 0x37738712a030 @    6 : 98                Return
Constant pool (size = 0)
Handler Table (size = 16)

含まれているバイトコードはこのようなシーケンスになっています。

StackCheck
Ldar a1
Add a0, [0]
Return

このバイトコードは、まだマシンで直接実行できるものではありませんが、マシンコードを抽象化したものになっています。

TurboFanコンパイラ

TurboFanは、コードを最適化されたマシンコードにコンパイルするコンパイラです。
image.png

TurboFanは、レジスタマシンの一種1で、マシンコードへと変換していきます。

詳細は、こちらの記事を読んでください。

TurboFanはV8のコアとも呼べる部分で、最適化されたマシンコードを生成するため、いくつかの特徴を持っています。

ホットコードと投機的最適化

IgnitionとTurboFanコンパイラは、何度も繰り返し実行される関数やコードブロックを「ホットコード」として検出します。そして、このホットコードを最適化しようとします。

例えば、Ignitionはバイトコードを生成する際に、型情報などいくつかの情報をプロファイリングしています。これらは「フィードバックベクター」と呼ばれ、Ignition自身がバイトコードを生成するためのインラインキャッシュとして用いられます。

DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
…
 - feedback vector: 0xb5101eaa091: [FeedbackVector] in OldSpace
 - length: 1
 SharedFunctionInfo: 0xb5101ea99c9 <SharedFunctionInfo add>

デバッグプリントを有効にし、--allow-natives-syntax を使ってd8シェルを実行すると、上記のような情報が得られます。これが実際にメモリ上にマップされているフィードバックベクターです。

このフィードバックは、TurboFanにも送られます。
TurboFanはフィードバックをもとに、型情報をもとにコードの一部を省略し、コードを(まだ仮ですが)最適化します。

例えば、先ほどのコードの

return x + y;

Add a0, [0]

というバイトコードに変換されていました。ここでの Add は、 JavaScriptの +演算子を変換したものですので、必ずしも「加算」であるとは限りません。文字列の結合の可能性、異なる型同士の演算である可能性もあるわけです。

ここで、型情報フィードバックが役に立ちます。もし、xyが常にnumber型であれば、+ というオペレータは、常に加算です。そのため、他のセマンティクスを省略することができます。
結果として、生成されるバイトコードは小さなものになります。

これが、TurboFanが行う最適化です。

最適化の失敗

ただし、JavaScriptは動的型付け言語ですから、コードを読むだけでその変数の型を正確に推定することはできず、実行するまではどの型の値が渡されるかは分かりません。
TurboFanはIgnitionのフィードバックを利用することで、上記のような推測による最適化を行っていますが、場合によっては失敗するわけです。

コードの実行に失敗した場合、Ignitionに戻って非最適化したコードを生成し、実行します。

どの程度最適化されたかは、先ほどのデバッグプリントで確認することができます。

$ out/Debug/d8 --allow-natives-syntax add.js
DebugPrint: 0xb5101ea9d89: [Function] in OldSpace
…
 - feedback vector: 0xb5101eaa091: [FeedbackVector] in OldSpace
 - length: 1
 SharedFunctionInfo: 0xb5101ea99c9 <SharedFunctionInfo add>
 Optimized Code: 0
 Invocation Count: 1
 Profiler Ticks: 0
 Slot #0 BinaryOp BinaryOp:SignedSmall
…

なお、ここでは add()関数は繰り返されているわけではないため、ホットコーど扱いになっておらず、 OptimizedCode は0という結果になっています。

また、フィードバックベクターに BinaryOp というベクターが含まれています。
これは、型情報に関連するもので、ここでは SignedSmall という型入力が認識されています。

フィードバックベクターでの型
JavaScriptは、ほとんど全てがオブジェクトであり、ヒープ領域を利用しています。
つまり、ほとんどの変数は、ヒープ内のメモリを表すアドレス情報が格納されています。しかし、一部のプリミティブなどはヒープ領域ですし、先ほどの型情報の予測によるコードの最適化も行う必要があります。
そのため、フィードバックベクターでは、JavaScriptとは異なる型が用いられています。

  • SignedSmall: 整数値
  • Number: 数値
  • NumberOrOddall: Numberに加え、undefined, null, boolean
  • String: 文字列
  • BigInt: JSにおけるBigInt
  • Any: それ以外

TurboFanは、これらの型情報にもとづいて、ビアとコードからマシンコードへの最適化を行っています。
これを見ると、配列や関数での演算においては、なるべく統一的な型、それも整数値や数値を用いたほうが効率が良いことを察することができますね。

最終的に得られるアセンブリコード

参考までに、下記のようなコードが最終的に得られます(環境などによりますので、あくまで参考までに)。

leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xdb0]
jna StackCheck

movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize

movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize

movq rdx,rbx
shrq rdx, 32

movq rcx,rax
shrq rcx, 32

addl rdx,rcx
jo Deoptimize

shlq rdx, 32
movq rax,rdx

movq rsp,rbp
pop rbp
ret 0x18

これでもなお、Word32への変換を行っているコードですから、もう少し最適化できるわけですが、それ以外は非常に最適化されていることがわかります。

今回は、TurboFanパイプラインいおけるTypedOptimizationのみを紹介しましたが、実際にはより複雑なパイプラインが実行され、最終的なコードが生成されています。

かつては、JavaScriptといえば、処理の遅いイメージのある言語の代表格でしたが、今やその代表処理系であるV8は、これらのJITコンパイリングによって、非常に効率よくコードを実行しています。

メモリライフサイクル

続いて、NodeJSのメモリライフサイクルについてです。

JavaScriptでは、オブジェクトがもう使用されないと判断された場合に、自動的にメモリ解放を行うことが知られています。が、実態はむしろ、手動でメモリ解放ができない、という表現の方が正しく、V8エンジンでどのようなメモリライフサイクルが実現されているかを知ることは、コードのメモリリークを防ぐためにもよく知っておく必要があります。

メモリの使用量と割り当ての確認

NodeJSのメモリは、process.memoryUsage() で確認することができます。

$ node
Welcome to Node.js v16.14.2.
Type ".help" for more information.
> process.memoryUsage()
{
  rss: 31944704,
  heapTotal: 6725632,
  heapUsed: 4764456,
  external: 910512,
  arrayBuffers: 10435
}
プロパティ 概要
rss Resident Set Size。C++とJavaScriptオブジェクトを含むプロセスのメモリ専有領域の量
heapTotal V8のヒープ用メモリサイズ
heapUsed 実際のヒープ使用量
external C++とJavaScriptオブジェクトのメモリ使用量(V8管理)
arrayBuffers externalのうち、ArrayBuffer, SharedArrayBuffer, Bufferに割り当てられたメモリ量

JavaScriptコード内で処理されるオブジェクトは、V8が管理するヒープ領域内で管理されています。
image.png

世代別ガベージコレクション(GC)

V8のGCは世代別GCと呼ばれる方法が用いられています。V8には2つの領域が用意されています。

  • New Space: 新しく作成されたオブジェクトが配置されるヒープ領域
  • Old Space: 一定期間以上利用され続けているオブジェクトが配置されるヒープ領域

コピーGC

New Spaceでは、コピーGCという方式が利用されます。
コピーGCでは、ヒープをFrom領域とTo領域の2つに分割することで実現される方法です。

  1. 通常、オブジェクトは、From領域に割り当てられます。
  2. From領域の容量が少なくなると、生きているオブジェクトを探し、To領域にコピーしていきます
  3. From領域の探索が終わると、ToとFromを入れ替えます。

コピーGCは、メモリ効率が悪いものの、GC自体は高速に実行されるため、New Spaceでは新しいオブジェクトは高速かつ頻繁に処理されていきます。GCによってアプリケーションが停止する時間はごくわずかです。

マークスイープGC/マークコンパクトGC

一方、Old Spaceでは、マークスイープGCとマークコンパクトGCという方式が利用されます。
そもそもOld Spaceは、New SpaceのコピーGCを経て生き残ったオブジェクトのみが配置されています。そのため、GCを行う頻度はNew Spaceよりも低くくても大きな肥大化はあまり起きません。

  • マークスイープGCでは、globalオブジェクトから到達可能かどうかを調べ(マーク)、到達できないオブジェクトを処理(スイープ)します。
  • マークコンパクトGCでは、マークしたオブジェクトの詰め直し(コンパクション)を行います。バラバラに配置されていたオブジェクトがヒープに連続して配置するようになります。

マークスイープGCは、コピーGCよりも速度に劣りますが、ヒープ領域を最大限使うことができます。

V8エンジンでは、これら2つの世代別スペースと、さらにそれぞれに応じたGCを使うことで、メモリ効率とGCを最適化しています。

メモリが足りなくなると、次のようなエラーに直面することがあります。

<--- Last few GCs --->

[98560:0x7fad80008000] 67514287 ms: Mark-sweep 3961.4 (4142.0) -> 3946.0 (4143.3) MB, 967.1 / 0.0 ms  (average mu = 0.690, current mu = 0.662) allocation failure scavenge might not succeed
[98560:0x7fad80008000] 67518038 ms: Mark-sweep 3962.8 (4143.3) -> 3947.4 (4145.5) MB, 1878.8 / 0.0 ms  (average mu = 0.586, current mu = 0.499) allocation failure scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
 1: 0x10d281a25 node::Abort() (.cold.1) [/foo/bar/node]
 2: 0x10bf770f9 node::Abort() [/foo/bar/node]
 3: 0x10bf7726f node::OnFatalError(char const*, char const*) [/foo/bar/node]
 4: 0x10c0f87e7 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [/foo/bar/node]
 5: 0x10c0f8783 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [/foo/bar/node]
 6: 0x10c299e65 v8::internal::Heap::FatalProcessOutOfMemory(char const*) [/foo/bar/node]
 7: 0x10c2987ec v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/foo/bar/node]
 8: 0x10c2a5090 v8::internal::Heap::AllocateRawWithLightRetrySlowP [/foo/bar/node]

この例では、Old SpaceでのメモリがGCを実行していたところ、約4GBのヒープ領域のメモリ割り当てを超えてしまっています。
JS Stacktraceには、v8のどの実行でエラーが吐き出されたかが出力されますが、このスタックトレースから原因となったJavaScriptのコードを推定することは困難です。

メモリ不足とその対応

そもそも大規模データを取り扱っていて、メモリが足りない場合には、V8のヒープサイズを変更します。

Old Spaceのヒープサイズの変更

$ node --max-old-space-size 8192

New Spaceのヒープサイズの変更

$ node --max-semi-space-size 1024

New SpaceはSemiSpaceSizeの3倍が最大容量になります。通常、こちらを使い潰すことはありませんが、並列度が高く、頻繁にオブジェクトが生成される場合には、スループットの改善などが見込めます。

リファレンス

  1. かつてのV8エンジンはスタックマシンでした。

0
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1