関数の戻り値をキャッシュするためのタプルキー付きコレクションの高性能な実装(JavaScript)
要約
関数の戻り値をキャッシュする高階関数を実装するために、タプルをキーとするMapであるTupleKeyedMapを実装した。
ベタな実装として、タプルキーをJSON.stringifyで文字列化して、内部の標準のMapに文字列化されたキーと値の関係を保持するStringifiedTupleKeyedMapが考えられるが、JSON.stringifyがあらゆるオブジェクトを文字列化できるわけではないことや性能のことを考えると不安がある。
そこで、タプルキーと値の関係を保持するためにツリーを使うTreeTupleKeyedMapを実装した。
実験では、TreeTupleKeyedMapがStringifiedTupleKeyedMapに対して、5倍〜77倍ほど高速であり、ユースケースによってはメモリ効率でも優位であった。
背景
重い計算をする関数の戻り値を、引数をキーとしてキャッシュしたい。
キャッシュされるべき関数は、オブジェクト(配列も含む)を引数に取ることもある。そのためキャッシュキーとしてオブジェクトの一部を扱えるようにしたい。
コードによる説明
code:ts
// このようなシグネチャを持つ、キャッシュされた戻り値を返す高階関数が欲しい。
const cachedFunctionFrom = <A extends unknown[], R>(
func: (...args: A[]) => R,
): ((...args: A[]) => R) => {
// ...
};
// とても重い計算をする関数
const heavyFunction(a: number[][], b: number[][]): number => {
return doSomeHeavyProcess();
}
// このループはとても時間がかかるが、
for (let i = 0; i < 10; i++)
heavyFunction(1, 2, 3], [4, 5, 6, 7, 8, 9], [10, 11, 12);
const cachedHeavyFunction = cachedFunctionFrom(heavyFunction);
// 高階関数を使うこのループでは、実際に重い計算をするのが最初の1回だけで、
// 残りの9回はキャッシュされた戻り値を利用するので、10分の1の時間で終わる。
for (let i = 0; i < 10; i++)
cachedHeavyFunction(1, 2, 3], [4, 5, 6, 7, 8, 9], [10, 11, 12);
cachedFunctionFromを実装するにはTupleKeyedMapを実装する必要がある。
cachedFunctionFromのためには、引数とそれに対応する戻り値のペアを保持するためのMapが必要である。引数はタプルなので(配列として表現されるので)、配列と任意の値のペアを保持するMapがあればよい。
JavaScriptの標準のMapを使う場合、配列の中身が同じでも、別々に初期化された配列であれば別のキーとして扱われる。
JavaScriptの配列はオブジェクトである。オブジェクトは、各プロパティが同じ値を保持していても、別々に初期化されているのであれば別のオブジェクトである。
今回は、配列の中身が同じであれば同じキーとして扱われるようにしたい。そのようなMapを実装する必要がある。
キーの計算にJSON.stringifyを使う実装はやめよう。
ベタな実装を考えると、タプルキーをJSON.stringifyして得られる文字列をキーとするMapに戻り値を保存するだろう(そのようなMapをStringifyTupleKeyedMapと呼ぶこととする)。
StringifyTupleKeyedMapは少し考えるだけで明らかにヤバそうである。mgn901.icon
キーの計算時にJSON.stringifyをする必要がある。キャッシュの読み書きの度にJSON.stringifyをするの?キャッシュの読み書きの度に文字列キーの計算結果のためのメモリを要求することになる。
巨大なオブジェクトをJSON.stringifyして大丈夫なのだろうか?
循環参照を持つオブジェクトなど、JSON.stringifyができないオブジェクトもあるだろう。
JSON.stringifyであれば簡易的な深い比較(注1)ができるが、今回はその必要はない。
注1: オブジェクトの挿入順が異なる場合JSON.stringifyの結果も異なるので、完全な深い比較はできない。
パフォーマンス面で不安があるStringifyTupleKeyedMapを捨てても要件は達成できるので、もっと速いだろうTupleKeyedMapを考えよう。
ツリーを用いたTupleKeyedMapの実装
「タプルキーの第1要素を根にぶら下げ、第2要素を第1要素にぶら下げ、……、最後の要素を葉として、葉に値を持たせる」という方法で木を作る。
保持された値を読み書きするときは、タプルキーを舐めながら、ツリーを葉に向かって探索していく。探索中に要素同士の深い比較はしない。
この方法であれば、タプルキーの要素数がどんなに多かったとしても、あるいは要素の中身がどんなに複雑なオブジェクトであったとしても、
最良ケースで、タプルキーの要素数に線形に比例する時間(線形時間)でキャッシュの読み書きができる。
最悪ケース(タプルキーの長さが必ず1)でも、ツリーの要素として線形時間未満で探索できるMapを使えば、二乗時間未満で済む。
実際の実装
性能比較
性能比較を行った環境
マシン: MacBook Air (M4, 2024)
CPU: Apple M4 8コアCPU, 10コアGPU
RAM: 16GB
JavaScriptの実行環境: Node.js v24.13.1
性能比較用のプログラム
冒頭のheavyFunctionの戻り値のキャッシュを保持することを想定し、キーの型はnumber型の配列のタプル(number[][])とする。
Mapを読み書きするのにかかった時間と、Mapのために確保されたヒープメモリの消費量を計測する。
読み書きはランダムに行われるものとする。
時間はテストデータの準備が完了してから読み書きが完了するまでを計測対象とする。
ヒープメモリの消費量は、読み書きが完了したときの消費量から、テストデータの準備が完了したときの消費量を引くことで算出する。
code:ts
import { memoryUsage } from "node:process";
const COUNT = 100000;
const MAX_TUPLE_LENGTH = 10;
const MAX_INNER_OBJ_SIZE = 10;
// テスト用のデータ。
const entries: [number[][], number][] = [];
for (let i = 0; i < COUNT; i++) {
const key = Array.from(
{ length: Math.floor(Math.random() * MAX_TUPLE_LENGTH) },
() =>
Array.from(
{ length: Math.floor(Math.random() * MAX_INNER_OBJ_SIZE) },
() => Math.floor(Math.random() * 2 ** 53),
),
);
}
const ops = Array(COUNT)
.fill(0)
.map(() => Math.random());
const heapTotalOnReady = memoryUsage().heapTotal;
// この関数に、上記で拵えたデータとテスト対象のMapを渡して呼び出してテストする。
const runMapPerformanceTest = (
map: Map<number[][], number>,
entries: [number[][], number][],
ops: number[],
): { time: number; heapTotal: number } => {
const startedAt = globalThis.performance.now();
let heapTotal: number = 0;
for (let i = 0; i < ops.length; i++) {
if (op < 0.4) map.set(key, value);
else if (op < 0.8) map.get(key);
else map.delete(key);
if (i === ops.length - 1)
heapTotal = memoryUsage().heapTotal - heapTotalOnReady;
}
const endedAt = globalThis.performance.now();
return { time: endedAt - startedAt, heapTotal };
};
実験1(100,000操作、タプルキー最大要素数10、各要素の配列の最大長さ10、5回の平均)
table:結果1
実装 op/s op/s 比較 Heap (bytes) Heap 比較
StringifiedTupleKeyedMap 386,301 1 8,257,536 1
TreeTupleKeyedMap 2,710,569 7.02 11,147,674 1.35
タプルキーの要素数が最大10、各要素の配列の長さが最大10というのは、一般的な関数のキャッシュキーとしてあり得る状況である。
このような通常のユースケースでも、TreeTupleKeyedMapは、StringifiedTupleKeyedMapの7倍以上高速という結果になった。
実験2(100,000操作、タプルキー最大要素数10、各要素の配列の最大長さ100、5回の平均)
table:結果2
実装 op/s op/s 比較 Heap (bytes) Heap 比較
StringifiedTupleKeyedMap 39,583 1 143,720,448 1
TreeTupleKeyedMap 3,049,163 77.0 14,974,976 0.104
実験1における条件のうち、キーの要素の大きさを10倍にしたものである。引数の数は変わらないが、一つひとつの引数が巨大なオブジェクトである場合を想定したケースである。
キーと値のペアを保持するツリーの大きさがキーの要素数に依存するTreeTupleKeyedMapでは、実験1の時とほぼ同等の速度とメモリ効率を達成している。
StringifiedTupleKeyedMapでは、実験1のケースの1割強の速度しか出ず、メモリ消費量は17倍以上となっている。大きなオブジェクトを持つタプルをJSON.stringifyで文字列化したものをキーとして使うことが、いかに時間を要して、いかにメモリを無駄遣いするかを示す結果である。
このユースケースでは、TreeTupleKeyedMapがStringifiedTupleKeyedMapに対して、速度面でもメモリ効率の面でも圧倒的に高性能という結果になった。
実験3(100,000操作、タプルキー最大要素数100、各要素の配列の最大長さ10、5回の平均)
table:結果3
実装 op/s op/s 比較 Heap (bytes) Heap 比較
StringifiedTupleKeyedMap 37,812 1 150,863,872 1
TreeTupleKeyedMap 195,891 5.18 454,754,304 3.01
実験2ではキーの中身の要素を増やしたが、これはキー自体を長くしたケースである。引数が100個もある関数は珍しいと思うので、このユースケースで使われることはあまり考えられない。
TreeTupleKeyedMapは、キーと値のペアを保持するツリーの大きさがキーの要素数に依存するので、前の2つのケースに対して、速度もメモリ効率も悪くなっている。ツリーのノードは参照しか持っていないので小さいメモリ使用量で済むかと思ったが、そうでもないらしい。それでもStringifiedTupleKeyedMapの5倍の速度を達成している。
StringifiedTupleKeyedMapは、タプルの文字列化にかかる時間やメモリ使用量が実験2の時とほぼ変わらないからか、実験2の結果に近い結果が出ている。
StringifiedTupleKeyedMapの性能はJSON.stringifyにかかる時間や、その結果の文字列の長さに依存する。そのため、StringifiedTupleKeyedMapは、キーを文字列化した時の長さが短い場合や、タプルキーの長さが極端に長い場合は高性能である。
それ以外の場合はTreeTupleKeyedMapの方が高性能である。
TreeTupleKeyedMapの限界
キャッシュを読み書きするときに、検索キーの各要素と、実際にキャッシュに含まれるキーの各要素が等価(===)でない場合は、違うキーとして扱われてしまう。
とはいえ「引数に渡されるオブジェクトも何らかの関数の中で初期化しているだろうし、その関数の戻り値も同様にキャッシュすればよい」という話で済む。せっかくイミュータブルさを達成するために頑張っているのであれば、中身が同じオブジェクトはどんどん使い回して、性能向上にも繋げるのが良いと思う。mgn901.icon 似た目的を持つ既存の実装
関数の値をキャッシュすることに特化している。
mgn901.iconのTreeTupleKeyedMapはMapのインタフェースをすべて実装しているが、micro-memoizeはMapを実装していない。
Mapを実現するためのコードが含まれるTreeTupleKeyedMapよりもmicro-memoizeの方が速そう。mgn901.icon