結果は脅威の700倍!?いやいや、もはや意味不明でした。
Node.jsのバージョンをv14からv16へ更新したところ、TypeScriptで書いたテキストデータを処理するプログラムがいつまで経っても完了する気配がなく、性能低下の割合が測定不能な程とてつもなく長くなってしまう状態に陥ってしまいました。調査したところ、jsonpath-plusという外部パッケージで時間を食い潰していることが判明し、幸いにもパッケージのバージョンを上げることで性能劣化の問題は解消することができました。
しかし、一体なぜNode.jsのバージョンを変えただけでそのパッケージの性能が著しく低下し、パッケージのバージョンを上げるとなぜ解消されたのか。パッケージの変更履歴を見てみると、vmモジュールを使った処理部分に起因していることが分かりました。
ということで、vmモジュールの性能をv14、v16、v19で比較して見ました。
測定用に書いたコードはこちら。
import { performance } from 'perf_hooks';
import { Script } from 'node:vm'
const start = performance.now();
for (let i = 0; i < 200000; i++) {
const script: Script = new Script('const str = "Muda code"');
}
const stop = performance.now();
console.log(stop - start);
実行結果はなんとv16で約700倍遅く、v19についてはもはや意味不明です。使用したのは古いMacですが、Windowsだとこれよりはまだ早いですが、それでもv14に比べれば驚くべき遅さです。
v14.19.3 | v16.14.2 | v19.7.0 |
---|---|---|
245.9 | 172668.4 | 8705376.1 |
vm.Scriptって?
vm.Scripはさまざまな用途があると思いますが、1つにはプログラム内で動的にJavaScriptコードを生成して実行するという用途が挙げられると思います。jsonpath-plusでは、jsonpathに含まれる条件式を評価するために使用しています。
以下は、vm.Scriptの説明をNode.jsのDocumentから一部抜粋したものですが、これによるとインスタンス生成時に引数で渡したコードをコンパイルしている可能性があります(Documentを読むと関数など全てのコードが一度に直ちにコンパイルされるわけではなく実際にコードが実行されるときにコンパイルされるようです)。ということはこれが間違っていなければ、Node.jsなのかV8なのかそのどちらに原因があるのかわかりませんが、コンパイラが遅くなっているということになります。
Instances of the vm.Script class contain precompiled scripts that can be executed in specific contexts.
code <string> The JavaScript code to compile.
対策
Scriptインスタンスをキャッシュ/再利用しましょう。いたって普通です。
測定に用いたコードは処理時間測定のために毎回無駄にScriptのインスタンスを生成していますが、コードが変わらなければ毎回作る必要はないはずです。Documentによれば一度生成すれば何度も再利用できるようになっています。
jsonpath-plus v6ではvm.runInNewContext
を使用していたのを、v7でvm.Script
を使用するように変更がなされ、インスタンスをjsonpathをキーにキャッシュしてコンパイル済みコードを再利用することで性能改善を図っています。コンパイルと実行を同時に行うvm.runInNewContext
では再利用を図ることができません。
また別の方法として、ScriptインスタンスメソッドcreateCacheData
を使ってキャッシュデータを作成し、これをScriptコンストラクタに渡すとコンパイルの速度が向上するようです。以下をv19で試したところ986.2となりました。8705376.1が986.2です。2時間以上かかっていたのが1秒足らずまで短縮されました。
import { performance } from 'perf_hooks';
import { Script } from 'node:vm'
const start = performance.now();
let cache: Buffer | undefined = undefined;
for (let i = 0; i < 200000; i++) {
const script: Script = new Script('const str = "Muda code"', { 'cachedData': cache });
cache ??= script.createCachedData();
}
const stop = performance.now();
console.log(stop - start);
なお、必ずしも渡したキャッシュデータがV8によって活用されるわけではないようです。毎回コードが違っていたらキャッシュデータは活用されなさそうなのはなんとなく想像できますが、どの程度の違いまで活用されるのかわかりません。活用されたかどうかはScriptインスタンスのプロパティcreateCachedData
にセットされるのですが、事後結果でしかないこれを知ったところでどうしろと言うのだろう。。。やっぱ今のなしで、とでも?う〜ん。
createCacheData
のユースケースがいまいちピンと来ないのですが(ないよりマシ的な保険?)、コードが同じならScriptインスタンスを再利用した方が良さそうです。