Node.js の perf_hooks で雑に関数のパフォーマンス計測

イントロ

TypeScript で Array#filter を使うとき, 型が期待通りに絞り込まれないというトラブルがよくあります.

const xs: (number | undefined)[] = [1, undefined, 2];

const ys: number[] = xs.filter((x) => x !== undefined);
//    ~~
// Type '(number | undefined)[]' is not assignable to type 'number[]'.

これを解決するには as number[] のようにキャストしてやる (1) か,

const ys: number[] = xs.filter((x) => x !== undefined) as number[];

コールバック関数の戻り値の型を明に x is number のようにして型を絞り込むようにしてやる (2) か,

const ys: number[] = xs.filter((x): x is number => x !== undefined);

あるいは上の 2 つは安全ではない (例えば (x): x is number => true などいくらでも嘘をつけてしまう) ので, かわりに要素の型を変えられる Array#flatMap を使う (3) といった方法があります.

const ys: number[] = xs.flatMap((x) => x !== undefined ? [x] : []);

型安全性の面では, 型の絞り込みが静的に検査できる (3) が最も良く, 次いでコールバック関数を共通化することで安全でない is を書く箇所を減らすこともできる (2), 毎回安全でない as を書く必要がありミスの入りやすい (1) という順に悪くなっていきます.

ということで基本的には flatMap を使っておくとよろしいのですが, ところでパフォーマンスはどの程度違うのでしょうか? (1) と (2) はコンパイルされた JavaScript に差はないのでもちろんパフォーマンスは同じですが, (3) はそれに比べて中間的な配列を作ったりしていていかにも遅そうです.

Node.js のパフォーマンス計測 API

パフォーマンス計測のためのライブラリはいろいろあると思うのですが, いろいろありすぎてどれを使うと良いのかよくわからない!

と思っていたら Node.js に perf_hooks というパフォーマンス計測ためのモジュールが標準で用意されていたので, これを使ってみましょう. このモジュールには Web 標準の Performance API の一部に加えて, Node.js 独自の拡張や便利な関数などが含まれています.

関数のパフォーマンスを計測するには, まずは計測対象の関数を用意して,

// xs: number[] は事前に用意した [0, 1) の数のリスト

// filter.js
function func() {
  xs.filter((x) => x > 0.5);
}

// flatMap.js
function func() {
  xs.flatMap((x) => (x > 0.5 ? [x] : []));
}

perf_hooks に含まれる timerify で関数をラップして,

import perf_hooks from "node:perf_hooks";

const histogram = perf_hooks.createHistogram();
const perf_func = perf_hooks.performance.timerify(func, { histogram });

あとは適当なサンプル数だけ関数を実行するだけ.

const N = 100000;

for (let i = 0; i < N; i++) {
  perf_func();
}

console.log(histogram.toJSON());

本当は関数を実行している間に JIT コンパイルされて最適化されていくので, 最初の方と後の方では随分パフォーマンスが違ったりするのですが, 雑に計測したいだけなので何も考えず全て計測結果に含めています. 最適化後のパフォーマンスのみを計測したい場合は先にいくらか (100 〜 1000 回程度?) 関数を実行して最適化を済ませておくとよいです.

計測結果

あくまで雑な計測なので, スケール感を把握する程度にとどめて結果を間に受けすぎないように.

計測環境は以下.

  • MacBook Pro 14-inch, 2021 (Apple M1 Max Chip, 32 GB Memory)
  • Node.js 18.12.1 (V8 10.2.154.15)

まずは小さめの配列 (要素数 100) を対象にした場合は以下のとおり (時間を表す数値の単位はナノ秒).

// filter.js
{
  count: 100000,
  min: 125,
  max: 182527,
  mean: 334.88866,
  exceeds: 0,
  stddev: 1754.852949373082,
  percentiles: {
    '0': 125,
    '50': 208,
    '75': 209,
    '87.5': 209,
    '93.75': 1126,
    '96.875': 1208,
    '98.4375': 1293,
    '99.21875': 1668,
    '99.609375': 1750,
    '99.8046875': 3542,
    '99.90234375': 37280,
    '99.951171875': 39936,
    '99.9755859375': 41280,
    '99.98779296875': 44864,
    '99.993896484375': 52032,
    '99.9969482421875': 63104,
    '99.99847412109375': 104704,
    '99.99923706054688': 182400,
    '100': 182400
  }
}

// flatMap.js
{
  count: 100000,
  min: 7788,
  max: 325631,
  mean: 8434.45926,
  exceeds: 0,
  stddev: 4153.5817042451745,
  percentiles: {
    '0': 7788,
    '50': 7916,
    '75': 7960,
    '87.5': 8124,
    '93.75': 8416,
    '96.875': 8832,
    '98.4375': 11000,
    '99.21875': 45728,
    '99.609375': 49408,
    '99.8046875': 50144,
    '99.90234375': 52096,
    '99.951171875': 57984,
    '99.9755859375': 64736,
    '99.98779296875': 72192,
    '99.993896484375': 82624,
    '99.9969482421875': 100288,
    '99.99847412109375': 132608,
    '99.99923706054688': 325376,
    '100': 325376
  }
}

続いて大きめの配列 (要素数 10,000) を対象にした場合.

// filter.js
{
  count: 100000,
  min: 19488,
  max: 665599,
  mean: 32934.85112,
  exceeds: 0,
  stddev: 29796.692681422126,
  percentiles: {
    '0': 19488,
    '50': 22992,
    '75': 28704,
    '87.5': 40416,
    '93.75': 132736,
    '96.875': 135680,
    '98.4375': 136704,
    '99.21875': 141056,
    '99.609375': 176640,
    '99.8046875': 185856,
    '99.90234375': 204416,
    '99.951171875': 208768,
    '99.9755859375': 232704,
    '99.98779296875': 311296,
    '99.993896484375': 373504,
    '99.9969482421875': 475648,
    '99.99847412109375': 560128,
    '99.99923706054688': 665088,
    '100': 665088
  }
}

// flatMap.js
{
  count: 100000,
  min: 728064,
  max: 5099519,
  mean: 746459.3536,
  exceeds: 0,
  stddev: 47869.930365069136,
  percentiles: {
    '0': 728064,
    '50': 736768,
    '75': 739840,
    '87.5': 748544,
    '93.75': 784896,
    '96.875': 868864,
    '98.4375': 876032,
    '99.21875': 881664,
    '99.609375': 899584,
    '99.8046875': 942592,
    '99.90234375': 979968,
    '99.951171875': 1072128,
    '99.9755859375': 1467392,
    '99.98779296875': 2342912,
    '99.993896484375': 3614720,
    '99.9969482421875': 4358144,
    '99.99847412109375': 4997120,
    '99.99923706054688': 5095424,
    '100': 5095424
  }
}

配列の要素数を増やした場合は, 単純に線形に時間が増えるだけでした.

percentiles を見るとかなり分布の幅が広くなっていることがわかります. これは実行されるにつれて最適化が進んだりするのもあるのですが, それを除いてもたまに遅いこともあるみたいです (GC は発生していなさそう. deopt されたりするタイミングがある?)

どちらも 90%ile 付近を見ると flatMapfilter の数倍〜数十倍程度遅いです. とはいえマイクロ秒の世界なので, 大量のデータを処理したり, あるいは 1 フレームに何回も実行されるような計算であれば気にした方が良いか, という印象. 結局はバランスの問題ですが, まあ普通は闇雲にパフォーマンスを気にして (型安全性を犠牲にして) 全てを filter に置き換えるよりは, 先にどこがボトルネックになっているかを明らかにするのがよいと思います.

Node.js 以外の場合

ここでは Node.js の API を使って計測を行いましたが, 高精度なタイマー (Web 標準の performance.now() など) と適当なヒストグラム記録のためのライブラリ (hdr-histogram-js など) さえ用意できれば同じような計測ができるはずです. ブラウザやその他 Node.js 以外の環境で計測したい方はお試しください.

まとめ

  • Node.js には標準でシュッと関数のパフォーマンス計測ができる API がある
  • flatMapfilter の数倍〜数十倍遅いものの, 型安全性の面では優れているので要はバランス
  • この文脈では as はノーメリット. 使うな