コンテンツにスキップ

パフォーマンスベンチマーク

このページのすべてのシナリオで最速のライブラリはありません。SynState の特長は、下の概要のどのシナリオでも大きく遅れないことです。各シナリオは1つのグラフ形状についての問いなので、ライブラリの比較はシナリオの中で行い、シナリオをまたいで比べないでください。

シナリオ最速SynState ÷ 最速最遅最遅 ÷ 最速
単一ステートRxJS1.0×Jotai43×
派生チェーンRxJS2.0×Jotai103×
ダイヤモンドRxJS ※11.3×Jotai59×
ディープチェーン(K=1000, M=200)SynState1.0×RxJS67×
カスケードダイヤモンド(N=20)Preact Signals1.2×RxJS> 16,129×
条件付きファンアウト(B=1000)Solid2.7× ※2Redux232×
等値カットオフ(W=50)RxJS1.2×Redux552×

SynState はどのシナリオでも最速のライブラリの 2.7 倍以内に収まっている一方、各シナリオの最遅のライブラリは最速の 43 倍以上かかっています。各シナリオが何に負荷をかけているかは、それぞれのセクションで説明しています。

※1 RxJS は glitch-free ではありません。ダイヤモンドでは、subscriber が古い値と新しい値の混ざった組み合わせも受け取ります(RxJS グリッチ)。glitch の起きないライブラリの中では Preact Signals が最速です。

※2 SynState を、MobX・Jotai・Solid・Preact Signals と同じく選択中の分岐だけを購読する switchMap で書いた場合の値です。愚直に全分岐を derive で書くと 819× で、シナリオでは両方を載せ、愚直な方は点線で描いています。SynState と RxJS の愚直な書き方は最遅の列からも除いています。等値カットオフの distinctUntilChanged を書かない RxJS の行も同じです。

> の付いた比率は、タイムアウト(5,000 ms)までに完了しなかったライブラリを、ちょうどその時間がかかったものとして割った値です。

同じ行をライブラリの側から読むと、ライブラリごとの最悪のケースになります。ライブラリそれぞれについて、そのシナリオの最速のライブラリとの差が最も大きくなるシナリオと、その比です。

ライブラリ最速との差が最も大きいシナリオそのシナリオの最速ライブラリ ÷ 最速1 更新あたり:ライブラリ / 最速
SynState条件付きファンアウト(B=1000)Solid2.7×21 ns / 7.8 ns
RxJSカスケードダイヤモンド(N=20)Preact Signals> 16,129×—
MobX等値カットオフ(W=50)RxJS86×6.4 µs / 74 ns
Jotai等値カットオフ(W=50)RxJS265×20 µs / 74 ns
Redux等値カットオフ(W=50)RxJS552×41 µs / 74 ns
Solid等値カットオフ(W=50)RxJS46×3.4 µs / 74 ns
Preact Signals等値カットオフ(W=50)RxJS19×1.4 µs / 74 ns
Zustand ※3単一ステートRxJS1.3×55 ns / 43 ns
Valtio ※3単一ステートRxJS11×469 ns / 43 ns

1 更新あたり:その行の時間を更新回数で割ったものです。タイムアウトしたライブラリは > を付けた下限です。ディープチェーン以外のシナリオは計測の中でグラフも構築するため、その分も含みます。カスケードダイヤモンドは 1 つのグラフで 100 回しか更新せず、時間のほとんどが構築なので、表示していません(—)。比が大きくても時間の差は小さいことがあり、この列がそのどちらかを示します。

※3 ほかのライブラリと同じ条件で実装できるシナリオでのみ計測しているため、最悪のケースはそれらのシナリオの中でのものです。Zustand と Valtio は派生値のノードを持たないため、単一ステートにのみ参加しています(比較を公平にするための条件を参照)。

強調した倍率は、すべてのシナリオで計測したライブラリのうち、最悪のケースでも1桁倍に収まっているものです。

条件付きファンアウトでは、※2 と同じく SynState と RxJS を switchMap で書いた形で数えています。

計測結果から言えることと、言えないこと:

  • SynState の更新ごとのオーバーヘッドは、最も小さい部類です。 何も派生しない場合(単一ステート)、どのライブラリも値を保持して subscriber を1つ呼ぶだけなので、9つすべてを同じ条件で比べられるのはこのシナリオだけです。SynState に対して、Zustand は 1.2倍、Preact Signals は 1.2倍、Solid は 6.5倍、Valtio は 10倍、MobX は 12倍、Redux は 27倍、Jotai は 41倍の時間がかかります。小さな派生グラフでは、順序付けの手順を持たずに subscribe 呼び出しを連ねるだけの RxJS は、派生チェーンで SynState より高速(0.5倍)、ダイヤモンドで SynState より高速(0.8倍)です。各ノードの依存リストを実行から実行へ使い回す Preact Signals は、派生チェーン(1.0倍)とダイヤモンド(1.0倍)のどちらでも SynState とほぼ同じ速さです。Solid・MobX・Jotai・Redux は SynState より低速です。
  • SynState の更新ごとのコストはグラフの形によって爆発しません。 これが、順序付けの手順を持たないプッシュベースのライブラリである RxJS との違いです。ソースが1回更新されるたびに、更新が届くノードを深さ順に1回ずつ訪れ、そこに至る経路がいくつあっても各 subscriber を1回だけ呼ぶので、ダイヤモンドがあっても発行回数は増えません。同じくプッシュベースの Solid は、更新が届くノードをすべて印付けしてから再実行するので、やはり各ノードを1回ずつ実行します。RxJS は経路ごとに発行します。ディープチェーンを4倍深くすると(K=1000K = 1000 で M=50→200M = 50 \to 200)、SynState の時間は 3.6 倍、Solid は 4.1 倍、Preact Signals は 6.1 倍、Jotai は 4.1 倍、MobX は 4.0 倍、Redux は 2.6 倍ですが、RxJS は 17 倍になり、カスケードダイヤモンドでは N=14N=14 で計測を完了できなくなります。RxJS の中間発行はグリッチでもあり、subscriber は古い値と新しい値が混ざった組み合わせを受け取ります。
  • MobX・Jotai・Preact Signals は誰も読まないノードを再計算せず、Solid は直前の実行で読まなかったソースではメモを再実行しません。SynState は持っているノードをすべて再計算します。 前の3つは派生値を何かに読まれたときにだけ再計算するので、誰も読まないノードのコストはゼロです。Solid のメモは SynState の派生値と同じく先行評価されますが、メモは直前の実行で読んだものだけを購読するので、読まなかったソースには通知先がありません。SynState は、ソースが更新されるたびに持っている派生値をすべて評価します。これは next が返った時点ですべての getSnapshot() が整合している、ということの代償で、何を計算するかは誰が読むかではなくグラフの書き方で決まります。条件付きファンアウトはそのために作られたシナリオです。derive が、セレクターが一度も選ばない千個のブランチを抱えているのです。選択中のブランチだけを購読する switchMap で書けば、SynState はどのブランチ数でも約 1.8 ms のままです。アプリケーションで出会う「誰も読まないノード」も同じ種類のもので、それぞれグラフから外す素直な方法があります。選ばれていないブランチは switchMap、画面にないビューはその observable をスコープ内で作ってビューと一緒に dispose、表示されるより頻繁に書かれる値はその手前に audit や throttle を置く、という具合です。各ライブラリがこの処理をいつ行うかは評価モデルを参照してください。
  • SynState と RxJS は値が変わらなかったところで止まります。ほかのライブラリは、まずその下をすべて確かめます。 SynState の map と RxJS の distinctUntilChanged は値を作るところで比較し、等しい値はどこにも流しません。Preact Signals・Solid・MobX・Jotai も比較しますが、書き込みはまず source の下のすべてに印を付け、印の付いたノードを1つずつ確かめてから等しい値にたどり着きます。Redux は dispatch のたびにすべての subscriber を呼びます。等値カットオフはそのために作ったシナリオで、めったに変わらない1つの値の下に W 個の読み手がいます。SynState と RxJS の行は W にほぼ関係なく横ばいです。ほかの行は W とともに増えます。
  • Preact Signals の最悪ケースは最速のライブラリの 19倍で、SynState は 2.7倍です。 MobX や Jotai と同じプルベースですが、前回と同じものを読む実行は何もアロケートしないので(分析を参照)、小さなグラフ(単一ステート・派生チェーン・ダイヤモンド)では更新ごとのオーバーヘッドが SynState と同程度です。最悪ケースはそれが効かない場面、つまり派生値が等しくなるのにその読み手を書き込みのたびにすべてマークする場面で、等値カットオフ(W=50)です。
  • SynState のグラフ構築は低コストです。 カスケードダイヤモンドは毎回の計測の中でグラフを構築しますが、SynState の行は NN に対してほぼ横ばいです。新しいノードは直接の親それぞれに addChild で自分を登録し、深さを最も深い親より1つ深くするだけで、ソースまでの 2N2^N 本の経路をたどることはありません。ディープチェーンのように一度作ったグラフを何度も更新する場合、構築は更新ごとのコストにまったく含まれません。

したがって、概要では SynState はどのシナリオでも最速のライブラリから大きく遅れません。SynState はグリッチフリーで、グラフの形によらず更新ごとのコストがグラフの大きさに比例したままです。これは Solid と Preact Signals も同じで、RxJS は違います。小さなグラフでの更新ごとのオーバーヘッドは、Solid・MobX・Jotai・Redux よりかなり小さいです。Preact Signals と同程度です。単一ステートでは Zustand と同程度です。RxJS と同程度かそれより大きいです。SynState が最も遅れるのは条件付きファンアウト(B=1000)で、そこで最速のライブラリの 2.7倍ですが、これはすでに誰も読まない分岐を持たない switchMap の形での数字です。残っているのは、どのノードにも届かない書き込みの SynState でのコストです。最初の2つの性質を共有する Preact Signals に対しては、これらの結果に一貫した勝者はありません。Preact Signals の時間は SynState に対して単一ステートで 1.2倍、ディープチェーン(K=1000K=1000、M=200M=200)で 1.2倍、ダイヤモンドでは 1.0倍です。それぞれのモデルのために作られたシナリオもあります。条件付きファンアウト(B=1000)では SynState の時間が Preact Signals の 1.7倍、等値カットオフ(W=50)では Preact Signals の時間が SynState の 15倍です。Preact Signals との違いは、最悪ケース(最速のライブラリの 2.7 倍に対して 19 倍)と、debounce・throttle・switchMap などのオペレーターを同じグリッチフリーなグラフの上で使えることです(SynState の強み)。

ステート1つ、subscriber 1つ、派生値なし。他のすべてのシナリオの土台となる下限で、Zustand と Valtio が参加するのはこのシナリオだけです。両者のバニラ API には派生値のノードがないため、派生値のあるグラフでは subscriber の中でインライン計算するしかなく、それは他のライブラリが構築するグラフとは別の、より小さなグラフになります。

  • ループ: counter を 11 から NN まで同期的に更新(N=105N = 10^5).
  • 検証: 最終値 =N= N.
  • 各ライブラリの subscriber は渡された値を記録します。コールバックに値が渡されない場合(Jotai、Redux、Valtio)はストアから読み直します。Solid と Preact Signals は、シグナルを読む effect で購読します。
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState4.474.345.124.7222,370,1561.00×
RxJS4.264.124.414.4123,452,5010.95×
MobX54.9454.6555.5655.261,820,04612.29×
Jotai183.23181.66185.21184.69545,76740.99×
Redux118.90118.23120.38119.76841,03326.60×
Solid29.0528.7338.7134.293,441,9126.50×
Preact Signals5.285.175.395.3818,937,1921.18×
Zustand5.485.406.085.7018,254,7131.23×
Valtio46.8746.5047.6847.652,133,42710.49×

すべての実装は vitest テストとして実行可能です。

export const runBenchmark = (n: number): number => {
const [counter, setCounter] = createState(0);
let mut_lastValue = 0;
const subscription = counter.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = new BehaviorSubject(0);
let mut_lastValue = 0;
const subscription = counter.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = observable({ counter: 0 });
let mut_lastValue = 0;
const dispose = reaction(
() => state.counter,
(value) => {
mut_lastValue = value;
},
{ fireImmediately: true },
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
runInAction(() => {
state.counter = mut_i;
});
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterAtom = atom(0);
const store = createStore();
let mut_lastValue = store.get(counterAtom);
const unsub = store.sub(counterAtom, () => {
mut_lastValue = store.get(counterAtom);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.set(counterAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
let mut_lastValue = store.getState().value;
store.subscribe(() => {
mut_lastValue = store.getState().value;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => {
const [counter, setCounterValue] = createSignal(0);
createEffect(() => {
mut_lastValue = counter();
});
return { setCounter: setCounterValue, dispose: disposeRoot };
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = signal(0);
let mut_lastValue = 0;
const dispose = effect(() => {
mut_lastValue = counter.value;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.value = mut_i;
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const store = createStore<Readonly<{ counter: number }>>()(() => ({
counter: 0,
}));
let mut_lastValue = store.getState().counter;
store.subscribe((state) => {
mut_lastValue = state.counter;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.setState({ counter: mut_i });
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = proxy({ counter: 0 });
let mut_lastValue = state.counter;
const unsubscribe = subscribe(
state,
() => {
mut_lastValue = state.counter;
},
true,
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
state.counter = mut_i;
}
unsubscribe();
return mut_lastValue;
};

各ライブラリのリアクティブプリミティブの純粋なオーバーヘッドを測定するシンプルな線形チェーンです:

  • ループ: counter を 11 から NN まで同期的に更新(N=105N = 10^5).
  • 検証: 最終値 =N×4= N \times 4.
  • ダイヤモンド依存関係なし — 純粋な伝搬オーバーヘッドの比較。
  • Zustand と Valtio は、このシナリオ以降の計測対象から外しています(計測は単一ステートのシナリオのみ)。派生値のノード(上の doubled、quadrupled)を実現する手段がないため、counter * 2 * 2 を subscriber でインライン計算することになり、このグラフの主題である中間ノードを含むグラフでの比較ができないからです。
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState14.6014.1916.1415.536,848,9071.00×
RxJS7.307.227.937.4313,707,1770.50×
MobX109.55109.04110.20110.17912,8347.50×
Jotai749.44734.09788.52786.44133,43351.33×
Redux288.67283.74327.16319.22346,42119.77×
Solid89.3088.8190.3189.611,119,7916.12×
Preact Signals13.9213.7814.2014.197,185,6140.95×

各ライブラリは同じ派生チェーンパターンを実装しています。すべての実装は vitest テストとして実行可能です。

export const runBenchmark = (n: number): number => {
const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const quadrupled = computed(() => doubled.get() * 2);
let mut_lastValue = 0;
const dispose = reaction(
() => quadrupled.get(),
(value) => {
mut_lastValue = value;
},
{ fireImmediately: true },
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
runInAction(() => {
state.counter = mut_i;
});
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const quadrupledAtom = atom((get) => get(doubledAtom) * 2);
const store = createStore();
let mut_lastValue = store.get(quadrupledAtom);
const unsub = store.sub(quadrupledAtom, () => {
mut_lastValue = store.get(quadrupledAtom);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.set(counterAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const selectCounter = (state: Readonly<{ value: number }>): number =>
state.value;
// `lruMemoize`, not the default `weakMapMemoize`. Every input here is a new
// number that never repeats, and `weakMapMemoize` keeps every distinct
// primitive it has seen: one round would cache 100,000 results it never hits,
// and that memory and GC would be measured as Redux's cost.
const selectDoubled = createSelector(
selectCounter,
(counter) => counter * 2,
{ memoize: lruMemoize },
);
const selectQuadrupled = createSelector(
selectDoubled,
(doubled) => doubled * 2,
{ memoize: lruMemoize },
);
let mut_lastValue = selectQuadrupled(store.getState());
store.subscribe(() => {
mut_lastValue = selectQuadrupled(store.getState());
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => {
const [counter, setCounterValue] = createSignal(0);
const doubled = createMemo(() => counter() * 2);
const quadrupled = createMemo(() => doubled() * 2);
createEffect(() => {
mut_lastValue = quadrupled();
});
return { setCounter: setCounterValue, dispose: disposeRoot };
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = signal(0);
const doubled = computed(() => counter.value * 2);
const quadrupled = computed(() => doubled.value * 2);
let mut_lastValue = 0;
const dispose = effect(() => {
mut_lastValue = quadrupled.value;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.value = mut_i;
}
dispose();
return mut_lastValue;
};

シナリオ: ダイヤモンド依存関係

Section titled “シナリオ: ダイヤモンド依存関係”

複数の派生値が1つにマージされる場合に各ライブラリがどのように処理するかをテストする、ダイヤモンド型の依存関係グラフです:

  • ループ: counter を 11 から NN まで同期的に更新(N=105N = 10^5).
  • 検証: 最終値 =N×5= N \times 5.
  • Zustand と Valtio は、バニラ API に独立した派生値を定義して組み合わせるメカニズムがないため除外。
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/secvs SynState
SynState24.2923.2624.9524.874,117,3341.00×
RxJS18.4818.3418.8418.755,412,5560.76×
MobX140.71139.99141.89141.48710,6945.79×
Jotai1096.531088.621147.771123.9791,19745.15×
Redux360.89357.51367.38365.38277,09414.86×
Solid107.27106.73108.01107.82932,2584.42×
Preact Signals23.5723.2325.7424.374,243,0280.97×
export const runBenchmark = (n: number): number => {
const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = derive([doubled, tripled], (d, t) => d + t);
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = combineLatest([doubled, tripled]).pipe(map(([d, t]) => d + t));
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const tripled = computed(() => state.counter * 3);
const sum = computed(() => doubled.get() + tripled.get());
let mut_lastValue = 0;
const dispose = reaction(
() => sum.get(),
(value) => {
mut_lastValue = value;
},
{ fireImmediately: true },
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
runInAction(() => {
state.counter = mut_i;
});
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const tripledAtom = atom((get) => get(counterAtom) * 3);
const sumAtom = atom((get) => get(doubledAtom) + get(tripledAtom));
const store = createStore();
let mut_lastValue = store.get(sumAtom);
const unsub = store.sub(sumAtom, () => {
mut_lastValue = store.get(sumAtom);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.set(counterAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const selectCounter = (state: Readonly<{ value: number }>): number =>
state.value;
// `lruMemoize`, not the default `weakMapMemoize`. Every input here is a new
// number that never repeats, and `weakMapMemoize` keeps every distinct
// primitive it has seen: one round would cache 100,000 results it never hits,
// and that memory and GC would be measured as Redux's cost.
const selectDoubled = createSelector(
selectCounter,
(counter) => counter * 2,
{ memoize: lruMemoize },
);
const selectTripled = createSelector(
selectCounter,
(counter) => counter * 3,
{ memoize: lruMemoize },
);
const selectSum = createSelector(
selectDoubled,
selectTripled,
(doubled, tripled) => doubled + tripled,
{ memoize: lruMemoize },
);
let mut_lastValue = selectSum(store.getState());
store.subscribe(() => {
mut_lastValue = selectSum(store.getState());
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
let mut_lastValue = 0;
const { setCounter, dispose } = createRoot((disposeRoot) => {
const [counter, setCounterValue] = createSignal(0);
const doubled = createMemo(() => counter() * 2);
const tripled = createMemo(() => counter() * 3);
const sum = createMemo(() => doubled() + tripled());
createEffect(() => {
mut_lastValue = sum();
});
return { setCounter: setCounterValue, dispose: disposeRoot };
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = signal(0);
const doubled = computed(() => counter.value * 2);
const tripled = computed(() => counter.value * 3);
const sum = computed(() => doubled.value + tripled.value);
let mut_lastValue = 0;
const dispose = effect(() => {
mut_lastValue = sum.value;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.value = mut_i;
}
dispose();
return mut_lastValue;
};

シナリオ: ディープチェーンスループット

Section titled “シナリオ: ディープチェーンスループット”

最初の2つのシナリオは固定サイズのグラフを使用しています。このシナリオはスループットのインタラクティブデモと同じトポロジーを使用します: depth-MM の scan チェーンで、各ステージが前のステージに向かって lerp し、combine がすべての M+1M+1 個の出力を読み取ります。KK 回のソース更新を同期的に実行します(アニメーションフレームあたり KK 回の更新をシミュレート)。

以下の図は M=3M = 3 の場合のグラフ構造です。実際のベンチマークでは MM は 5050〜200200 です:

  • KK(計測あたりの更新回数): 100100, 500500, 10001000.
  • MM(チェーンの深さ): 5050, 100100, 200200.
  • Zustand と Valtio は、派生値のあるほかのシナリオと同じく除外(比較を公平にするための条件を参照)。
  • Redux にも scan はありません。セレクターは自身の前回の出力を読めないため、各ステージの位置をステートとして持ち、1つの reducer がチェーン全体を進めます。MobX の reaction と同じ形です(比較を公平にするための条件を参照)。Preact Signals の computed にも自身の前回の値は渡されないため、各ステージはシグナルで、1つの effect が batch の中でチェーン全体を進めます。MobX と同じ形です。
  • Solid の createMemo には自身の前回の値が渡され、これが scan のアキュムレータにあたるので、チェーンは MM 個のメモです。SynState・RxJS・Jotai と同じく派生ノードの連なりです。
  • 5,000 ms を超える計測は打ち切り。
LibraryK=100, M=50K=500, M=50K=1000, M=50K=100, M=100K=500, M=100K=1000, M=100K=500, M=200K=1000, M=200
SynState1.3 ms2.6 ms4.1 ms0.7 ms3.9 ms7.5 ms7.4 ms14.8 ms
RxJS9.7 ms30.3 ms58.2 ms22.5 ms134.2 ms241.6 ms492.3 ms981.5 ms
Jotai22.7 ms100.0 ms194.3 ms40.0 ms195.0 ms390.9 ms401.3 ms802.7 ms
MobX17.3 ms80.9 ms159.3 ms31.8 ms158.2 ms320.8 ms318.6 ms638.5 ms
Redux0.8 ms2.4 ms5.7 ms0.9 ms1.9 ms6.3 ms2.6 ms14.8 ms
Solid3.6 ms6.7 ms13.4 ms2.4 ms12.6 ms28.0 ms28.0 ms55.0 ms
Preact Signals1.3 ms1.7 ms2.9 ms0.5 ms3.1 ms8.7 ms8.8 ms17.8 ms
  • SynState は KK と MM の両方に対して線形に増加します。K=1000K=1000, M=200M=200 でも 14.8 ms で完了します。各ステージは更新1回につき深さ順に1回だけ実行され、依存関係を追跡し直すこともありません。
  • RxJS は K=1000K=1000, M=200M=200 で 982 ms — SynState の約67倍です。上で説明した通り、これは定数倍の差ではなく combineLatest の冗長な発行による二次関数的 O(M2)O(M^2) な計算量の違いです。インタラクティブデモでも同じ増大が確認できます。
  • Jotai は K=1000K=1000, M=200M=200 で 803 ms — SynState の約54倍です。selectAtom の更新ごとのオーバーヘッド(エポックベースの再計算と動的依存関係追跡)に起因します。
  • MobX は K=1000K=1000, M=200M=200 で 639 ms — SynState の約43倍です。このシナリオは scan(ステートフルな累積:各ステージが自身の前回値と新しい入力を必要とする)を使用します。MobX の computed は純粋な派生であり、自身の前回出力にアクセスする手段を持たないため、ここでは使えません。observable.box + reaction(push-based)を使う必要があり、プロキシ追跡・依存通知・autorun の再読み取りによるステージごとのオーバーヘッドが発生します。対照的に、カスケードダイヤモンドシナリオはステートレスな派生のため computed の遅延評価で高効率に処理できます。
  • Redux は K=1000K=1000, M=200M=200 で 14.8 ms — SynState の1.0倍の時間です。チェーンは reducer の中でプレーンなオブジェクトを回す1つのループと、その結果を読む1つのセレクターで、ステージごとに追跡や通知をするノードがないため、スイープ全体を通して SynState との差は小さな定数倍に収まります。更新ごとに加わるのは、dispatch と、新しいステートオブジェクトと、M+1M+1 個の点の新しい配列です。MobX の行と同じく、派生ノードのチェーンではなく命令的なコードを計測しています。
  • Solid は K=1000K=1000, M=200M=200 で 55.0 ms — SynState の 3.7倍の時間です。MM 個のメモは派生ノードの連なりで、それぞれ更新ごとに1回実行されるので、SynState と同じく線形に増えます。ステージごとに加わるのは、メモを再実行するたびの管理処理です(Solid の分析を参照)。
  • Preact Signals は K=1000K=1000, M=200M=200 で 17.8 ms — SynState の 1.2倍の時間です。MobX と同じく、この行が計測しているのは1つのバッチ内で MM 個のシグナルを回す命令的なループと、M+1M+1 個すべてを読む1つの effect です。MobX と違い、その M+1M+1 個の読み取りを実行のたびに追跡し直しても何もアロケートしません。
  • K=500K=500, M=100M=100(60fps アニメーションで中程度のグラフ複雑度を想定)の場合、SynState(3.9 ms)、Redux(1.9 ms)、Preact Signals(3.1 ms)、Solid(12.6 ms)は 16ms のフレーム時間内に収まりますが、RxJS(134 ms)、Jotai(195 ms)、MobX(158 ms)はいずれも大幅に超過します。

シナリオ: カスケードダイヤモンド

Section titled “シナリオ: カスケードダイヤモンド”

このシナリオは、RxJS の combineLatest でダイヤモンドが直列に連なった場合の指数関数的な増大を実証します。各ステージは前の出力を2つのブランチ(ファンアウト D=2D = 2)に分岐させ、再び結合します。NN 個の二分岐ダイヤモンドがチェーンを形成します:

上図は N=3N = 3 の場合です。ベンチマークでは NN は 22〜2020 です。

  • KK(ソース更新回数): 100100.
  • NN(カスケード深さ): 2,4,6,8,10,12,14,16,18,202, 4, 6, 8, 10, 12, 14, 16, 18, 20.
  • 5,000 ms を超える計測は打ち切り。
LibraryN=2N=4N=6N=8N=10N=12N=14N=16N=18N=20
SynState0.4 ms0.4 ms0.6 ms0.6 ms0.6 ms0.7 ms0.5 ms0.3 ms0.3 ms0.4 ms
RxJS0.5 ms1.0 ms4.2 ms25.6 ms131.9 ms745.5 ms> 5000 ms> 5000 ms> 5000 ms> 5000 ms
Jotai3.7 ms5.5 ms6.3 ms8.1 ms12.8 ms10.0 ms11.9 ms13.4 ms15.0 ms20.9 ms
MobX0.8 ms1.1 ms1.2 ms1.0 ms0.8 ms0.8 ms1.0 ms1.1 ms1.6 ms1.3 ms
Redux1.6 ms1.4 ms1.9 ms3.0 ms1.8 ms3.1 ms3.6 ms2.8 ms3.1 ms4.8 ms
Solid0.3 ms0.5 ms1.2 ms2.0 ms1.1 ms0.8 ms1.0 ms1.1 ms1.2 ms1.4 ms
Preact Signals0.2 ms0.3 ms0.4 ms0.4 ms0.4 ms0.5 ms0.2 ms0.3 ms0.3 ms0.3 ms
  • RxJS は典型的な O(2N)O(2^N) の指数関数的増大(二分岐)を示します。N=14N=14 でタイムアウト(ソース更新あたり 214=16,3842^{14} = 16{,}384 回の発行 ×100\times 100 回の更新)。N=12N=12 では既に 746 ms — SynState(0.7 ms)の約1,096倍です。
  • MobX は遅延 computed 評価により N=20N=20 でも 1.3 ms にとどまります。MobX の computed 値は依存が変化しても即座に再計算されず、実際に読み取られたときに初めて再計算されます。reaction は最終段の computed のみを読み取り、それがチェーンを遡って各 computed を1回ずつ遅延評価するため、更新あたりの総作業量は O(N)O(N) で、オーバーヘッドも最小限です。これはディープチェーンスループットシナリオで MobX が SynState の約43倍遅い結果と対照的です。あちらのシナリオでは computed ではなく reaction チェーン(push-based)を使用しており、パフォーマンス特性は使用する MobX プリミティブに大きく依存します。
  • SynState の行は NN に対してほぼ横ばいです — 小さいほうの3つの NN の中央値で 0.4 ms、大きいほうの3つの中央値で 0.3 ms です。各 runBenchmark 呼び出しはグラフの構築も含みます。更新は 3N+13N+1 個の Observable を深さ順に1回ずつ訪れ、subscriber を1回だけ呼びます。新しい Observable は直接の親それぞれに addChild で自分を登録し、深さを最も深い親より1つ深くするだけなので、構築の際にソースまでの 2N2^N 本の経路をたどることはありません。各祖先を1回ずつ訪れる循環依存チェックは本番環境以外でしか行われないため、ここでは測定に含まれません。
  • Jotai の行は NN に対して増えます — 小さいほうの3つの NN の中央値で 5.5 ms、大きいほうの3つの中央値で 15.0 ms です。更新は 3N3N 個の派生 atom をそれぞれ1回ずつ再計算し、その点は SynState と同じですが、再計算のたびにストアの管理処理(その atom が読んだものの追跡と、それを読むものの無効化)を通るため、atom あたりのコストが O(N)O(N) の項が表に出るほど大きくなります。N=20N=20 では SynState の約55倍遅くなります。
  • Solid と Preact Signals はどの NN でも 2.0 ms 以下に収まります — 小さいほうの3つの NN と大きいほうの3つの中央値で、Solid は 0.5 ms と 1.2 ms、Preact Signals は 0.3 ms と 0.3 ms です。どちらも 3N3N 個の派生ノードを更新ごとに1回ずつ実行します。Solid は書き込みの下流をすべて印付けしてから再実行することで、Preact Signals は computed を読まれたときに、ソースのどれかに新しいバージョンがある場合だけ再計算することで、そうなります。
  • Redux の行は NN に対してほぼ横ばいです — 小さいほうの3つの NN の中央値で 1.6 ms、大きいほうの3つの中央値で 3.1 ms です。NN ごとの SynState との比の中央値は 5.4倍です。createSelector のチェーンは MobX の computed と同じく subscriber から読み出され、入力でメモ化された各セレクターは更新ごとに1回ずつ再計算されます。セレクター呼び出しは 3N3N 回で、それぞれがまず引数を前回のものと比較します。

シナリオ: 条件付きファンアウト

Section titled “シナリオ: 条件付きファンアウト”

これまでのシナリオは伝搬スループットをテストしていました — すべてのソース更新が subscriber に到達します。このシナリオは異なるパターンをテストします: 購読セットに含まれない observable への更新コスト — 動的依存追跡が最も有利に働くケースであり、MobX や Jotai の動作モデルを確かめるためのシナリオです。

グラフには B 個の分岐とセレクターがあります。セレクターは、結果がどの分岐を読むかを選ぶ source で、結果は branch[selector] です。セレクターはベンチマーク中ずっと 00 に固定されており、branch[0] のみが「アクティブ」です。ベンチマークでは branch[1](非アクティブな分岐)を K=105K = 10^5 回更新します。

このシナリオの核心は、ライブラリによって branch[1] の observer リスト(購読者の有無)が異なる 点です:

  • SynState と RxJS を switchMap で(「(switchMap)」の行): selector.pipe(switchMap((sel) => branches[sel])) は選択中の分岐だけを購読し、セレクターが変われば別の分岐に購読を移すので、下の動的購読と同じく branch[1] に observer はいません。SynState と RxJS では、動的な依存関係はこう書きます。
  • 同じ2つを愚直に静的購読で(「(naive …)」の行、グラフでは点線): セレクターと全分岐に対する derive(SynState)や combineLatest(RxJS)が構築時に全分岐を購読するため、branch[1] にも observer が登録されています。branch[1] が更新されるたびにそのノードが発火し、射影関数が前回と同じ値を生成し、等値比較で下流への伝搬を防止します。更新のたびに B 個の分岐をすべて読むため、コストは B に比例します。これらの行は誰も読まない分岐を抱えるコストを示すためのもので、どちらのライブラリでも想定された書き方ではありません。
  • 動的購読(Jotai, MobX, Solid, Preact Signals): computed、メモ、派生 atom の関数が直前の実行で実際に読んだ observable だけを購読します。selector = 0 のとき branch[0].get() のみが呼ばれるため、branch[1] の observer リストは空です。branch[1] を更新しても、通知先が存在しないため何も起きません。
  • 単一ストア(Redux): 分岐ごとの購読というものがそもそもありません。dispatch のたびに、どの分岐を変えたかによらず reducer が走ってすべての subscriber が呼ばれ、subscriber のセレクターは選択中の分岐だけを読みます。書き込み自体が branches 配列をコピーするので、コストの一部は B に比例します。
LibraryB=2B=5B=10B=20B=50B=100B=200B=500B=1000
SynState (switchMap)2.1 ms2.0 ms1.6 ms1.7 ms1.7 ms1.7 ms1.8 ms5.9 ms2.1 ms
SynState (naive derive)11.4 ms12.1 ms14.9 ms20.3 ms37.9 ms68.4 ms131.6 ms310.2 ms638.6 ms
RxJS (switchMap)3.4 ms2.9 ms2.8 ms2.9 ms2.8 ms2.9 ms3.0 ms3.1 ms3.2 ms
RxJS (naive combineLatest)8.5 ms8.3 ms8.7 ms9.1 ms10.0 ms10.8 ms17.2 ms30.2 ms57.8 ms
Jotai137.7 ms138.6 ms138.7 ms137.3 ms137.5 ms137.5 ms136.1 ms138.6 ms138.2 ms
MobX1.5 ms1.5 ms1.4 ms1.4 ms1.4 ms1.4 ms1.4 ms1.5 ms1.5 ms
Redux136.2 ms133.8 ms134.8 ms118.7 ms125.1 ms130.8 ms133.5 ms150.8 ms180.8 ms
Solid1.0 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.7 ms0.8 ms
Preact Signals1.1 ms1.0 ms1.1 ms1.0 ms0.9 ms1.1 ms1.1 ms1.1 ms1.3 ms

点線は愚直な書き方の行です。SynState 本来の線は実線の「SynState (switchMap)」です。

  • MobX・Solid・Preact Signals は読んだものだけを購読します。computed(() => branches[selector.get()].get()) は実行時に selector と branches[0] のみを購読するため、branches[1] の observer リストは空です。branches[1] を更新しても通知先が存在せず、どの行も B に関係なく一定です。MobX は約 1.4 ms、Solid は約 0.7 ms、Preact Signals は約 1.1 ms です。残るのは、observer のいない書き込みそのもののコスト、つまり値の比較と、Preact Signals ではバージョンの更新です。

  • switchMap の SynState は B に関係なく約 1.8 ms、switchMap の RxJS は約 2.9 ms のままです。branch[1] の更新はどちらでもどのノードにも届きません。SynState ではそもそも更新を開きません。branch[1] には子も購読者もないので、書き込みは値を保存して戻ります。Solid で誰も observe していないシグナルへの書き込みと同じです。

  • 愚直に書いた SynState のコストは B に対して線形に増加します。derive が構築時に全分岐を購読するため、どの分岐が更新されても発火します。点線で線形な傾向が見えます:B=2 で 11.4 ms、B=1000 で 639 ms(K=100,000K = 100{,}000 回の更新にわたり、1分岐・1更新あたり約 6 ns)。この行が計測しているのは、それを書いた全分岐の derive であって、モデルの限界ではありません。

  • 愚直に書いた RxJS は同じ静的なアプローチを combineLatest で取ります。コストは B に応じて増加しますが、愚直な SynState より緩やかです。combineLatest は等値比較による下流伝搬スキップを行わず常に発行するため、subscriber は毎回呼ばれますが処理は最小限(値の代入のみ)です。

  • Jotai は MobX と同じ動的購読を行います — resultAtom は非アクティブな分岐を購読していないため再計算はトリガーされません。しかし store.set() 自体が atom store 内で invalidation チェック等の管理処理を実行するため、B に関係なく約 138 ms の一定オーバーヘッドが発生します。このため B≤200 では愚直な SynState よりも遅い結果です。

  • Redux は B=2 で 136 ms、B=1000 で 181 ms です。大部分は B によらず更新ごとにかかる dispatch と subscriber の呼び出しで、B とともに増えるのは branches 配列のコピーです。

export const runBenchmark = (k: number, branchCount: number): number => {
const [selector] = createState(0);
const branches: DeepReadonly<
{
source: Observable<number>;
set: (v: number) => void;
}[]
> = Arr.zeros(asUint32(branchCount)).map(() => {
const [source, setSource] = createState(0);
return { source, set: setSource };
});
// Dynamic dependency: only follows the branch selected by selector
const result = selector.pipe(
switchMap((sel) => branches[sel]?.source ?? just(0)),
);
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.set(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const [selector] = createState(0);
const branches: DeepReadonly<
{
source: Observable<number>;
set: (v: number) => void;
}[]
> = Arr.zeros(asUint32(branchCount)).map(() => {
const [source, setSource] = createState(0);
return { source, set: setSource };
});
// `as const` needs a literal, and `derive` needs the tuple type only the
// literal produces.
// eslint-disable-next-line ts-data-forge/prefer-canonical-array-slicing
const allSources = [
selector,
...branches.map((b) => b.source),
] as const satisfies NonEmptyTuple<Observable<number>>;
const result = derive(
allSources,
(selectorValue, ...branchesValue) => branchesValue[selectorValue] ?? 0,
);
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.set(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = new BehaviorSubject(0);
const branches: readonly BehaviorSubject<number>[] = Arr.zeros(
asUint32(branchCount),
).map(() => new BehaviorSubject(0));
// Dynamic dependency: only follows the branch selected by selector
const result = selector.pipe(switchMap((sel) => branches[sel] ?? of(0)));
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = new BehaviorSubject(0);
const branches: readonly BehaviorSubject<number>[] = Arr.zeros(
asUint32(branchCount),
).map(() => new BehaviorSubject(0));
// `combineLatest` only infers the element types from an array literal.
// eslint-disable-next-line ts-data-forge/prefer-canonical-array-slicing
const result = combineLatest([selector, ...branches]).pipe(
map((values) => {
const sel = values[0];
return values[sel + 1] ?? 0;
}),
);
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = observable.box(0);
const branches: readonly ReturnType<typeof observable.box<number>>[] =
Arr.zeros(asUint32(branchCount)).map(() => observable.box(0));
// Dynamic dependency: only tracks the branch selected by selector
const result = computed(() => {
const sel = selector.get();
const target = branches[sel];
return target === undefined ? 0 : target.get();
});
let mut_lastValue = 0;
const dispose = reaction(
() => result.get(),
(value) => {
mut_lastValue = value ?? 0;
},
{ fireImmediately: true },
);
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.set(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selectorAtom = atom(0);
const branchAtoms: readonly WritableAtom<
number,
Mutable<readonly [number]>,
void
>[] = Arr.zeros(asUint32(branchCount)).map(() => atom(0));
// Dynamic dependency: only reads the branch selected by selectorAtom
const resultAtom = atom((get) => {
const sel = get(selectorAtom);
const targetAtom = branchAtoms[sel];
return targetAtom === undefined ? 0 : get(targetAtom);
});
const store = createStore();
let mut_lastValue = store.get(resultAtom);
const unsub = store.sub(resultAtom, () => {
mut_lastValue = store.get(resultAtom);
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranchAtom = branchAtoms[1];
if (inactiveBranchAtom === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
store.set(inactiveBranchAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
type State = Readonly<{
selector: number;
branches: readonly number[];
}>;
const setBranch =
createAction<Readonly<{ index: number; value: number }>>(
'fanOut/setBranch',
);
const isSetBranch = isAnyOf(setBranch);
const initialState: State = {
selector: 0,
branches: Arr.zeros(asUint32(branchCount)),
} as const;
// A plain reducer rather than `createSlice`: an Immer draft of the `branches`
// array would be finalized element by element on every update, and that would
// be measured as Redux's cost.
const store = configureStore({
reducer: (
state: State | undefined,
action: Readonly<UnknownAction>,
): State => {
const current = state ?? initialState;
return isSetBranch(action)
? {
...current,
branches: current.branches.with(
action.payload.index,
action.payload.value,
),
}
: current;
},
middleware: () => new Tuple(),
});
// Reads only the branch the selector picks. Every update replaces the
// `branches` array, so this recomputes on each one — an index lookup.
const selectResult = createSelector(
(state: State) => state.selector,
(state: State) => state.branches,
(sel, branches) => branches[sel] ?? 0,
{ memoize: lruMemoize },
);
let mut_lastValue = selectResult(store.getState());
store.subscribe(() => {
mut_lastValue = selectResult(store.getState());
});
// Update an INACTIVE branch (branch 1, while selector = 0)
for (let mut_i = 1; mut_i <= k; mut_i++) {
store.dispatch(setBranch({ index: 1, value: mut_i }));
}
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
let mut_lastValue = 0;
const { branches, dispose } = createRoot((disposeRoot) => {
const [selector] = createSignal(0);
const branchSignals: readonly Signal<number>[] = Arr.zeros(
asUint32(branchCount),
).map(() => createSignal(0));
// Dynamic dependency: only tracks the branch selected by selector
const result = createMemo(() => {
const target = branchSignals[selector()];
return target === undefined ? 0 : target[0]();
});
createEffect(() => {
mut_lastValue = result();
});
return { branches: branchSignals, dispose: disposeRoot };
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
const [, setInactiveBranch] = inactiveBranch;
for (let mut_i = 1; mut_i <= k; mut_i++) {
setInactiveBranch(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = signal(0);
const branches: readonly Signal<number>[] = Arr.zeros(
asUint32(branchCount),
).map(() => signal(0));
// Dynamic dependency: only tracks the branch selected by selector
const result = computed(() => {
const target = branches[selector.value];
return target === undefined ? 0 : target.value;
});
let mut_lastValue = 0;
const dispose = effect(() => {
mut_lastValue = result.value;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.value = mut_i;
}
dispose();
return mut_lastValue;
};

このシナリオが計測するのは 途中の値が前回と等しくなる更新 です。その下はすべて省けるはずです。キー入力のたびに変わるリストの下にある「リストが空か」という派生フラグを、多くのコンポーネントが読んでいる場面です。

source には K=100,000K = 100{,}000 回書き込みます。bucket は Math.floor(source / 1000) なので、1000回に1回だけ変わり、残りの更新では前回と等しい値になります。その下に W 個のビュー view[i] = bucket + i があり、それぞれが自分の subscriber を持ちます。

ナイーブな RxJS の行を除き、ここに並ぶライブラリはどれも派生値を比較し、等しければそこで止まります。違うのは 比較で止まるまでに、更新が bucket の下のグラフをどこまで辿るか です:

  • 値を作るところで比較する(SynState、RxJS): SynState の map は結果を Object.is で比較し、RxJS の distinctUntilChanged は流す値を比較します。等しい bucket は何も発行しないので、どのビューにも届きません。RxJS が比較するのは distinctUntilChanged を書いたところだけなので、書かない形も計測しています(「(naive …)」の行、グラフでは点線)。この形では、すべての更新がすべてのビューに届きます。
  • 印を付けてから引く(Preact Signals、Solid、MobX、Jotai): 書き込みはまず、source の下にあるもの — bucket、各ビュー、各 subscriber — に「古いかもしれない」という印を付け、そのあとで bucket を再計算します。各ビューは確認されて bucket が変わっていないことを知り、値を保つので subscriber は走りません。それでも、更新のたびにすべてのビューを訪れています。
  • 単一ストア(Redux): dispatch のたびに、すべての subscriber が呼ばれます。subscriber は、useSelector 1つずつと同じくビューごとに1つです。各 subscriber のセレクターは bucket が変わっていないことを知り、キャッシュした結果を返します。
LibraryW=1W=2W=5W=10W=20W=50W=100W=200W=500W=1000
SynState9.1 ms9.5 ms9.2 ms8.7 ms8.8 ms9.2 ms9.4 ms11.4 ms12.4 ms16.3 ms
RxJS7.4 ms7.5 ms7.1 ms7.3 ms7.2 ms7.4 ms7.7 ms8.6 ms10.8 ms15.6 ms
RxJS (naive, no distinctUntilChanged)17.6 ms20.0 ms30.1 ms48.3 ms92.3 ms185.2 ms341.4 ms688.0 ms1848.1 ms3896.0 ms
Jotai447.4 ms492.6 ms562.1 ms703.0 ms993.5 ms1963.5 ms3710.2 ms> 5000 ms> 5000 ms> 5000 ms
MobX66.3 ms78.9 ms114.5 ms173.8 ms287.4 ms638.5 ms1154.6 ms2243.6 ms> 5000 ms> 5000 ms
Redux282.3 ms358.6 ms574.5 ms920.6 ms1777.9 ms4093.0 ms> 5000 ms> 5000 ms> 5000 ms> 5000 ms
Solid46.0 ms50.6 ms66.8 ms94.4 ms157.5 ms340.2 ms654.2 ms1295.0 ms3469.1 ms> 5000 ms
Preact Signals7.6 ms10.2 ms17.9 ms31.1 ms58.0 ms139.3 ms282.0 ms584.8 ms1585.9 ms3345.8 ms

点線は distinctUntilChanged を書かない RxJS です。W=1000 より手前で終わっている行は、その次の点でタイムアウトしています。

  • SynState と RxJS は W にほぼ関係なく横ばいです。中央値で、SynState は W の小さい3点で 9.2 ms、大きい3点で 12.4 ms、RxJS は 7.4 ms と 10.8 ms です。等しい bucket は何も流さないので、ビューが走るのは bucket が変わる更新だけです。

  • Preact Signals・Solid・MobX・Jotai・Redux は W とともに増えます。bucket が変わったかどうかによらず、すべての更新がすべてのビューに届くためです。各行の傾きは、ビューが1つ増えるごとに1回の更新に加わる時間です:

    • Preact Signals: W=1 で 7.6 ms、W=1000 で 3,346 ms、ビュー1つあたり約 33 ns。
    • Solid: W=1 で 46.0 ms、W=500 で 3,469 ms、ビュー1つあたり約 69 ns。
    • MobX: W=1 で 66.3 ms、W=200 で 2,244 ms、ビュー1つあたり約 109 ns。
    • Jotai: W=1 で 447 ms、W=100 で 3,710 ms、ビュー1つあたり約 330 ns。
    • Redux: W=1 で 282 ms、W=50 で 4,093 ms、ビュー1つあたり約 778 ns。
  • distinctUntilChanged を書かない RxJS も W とともに増えます。W=1 で 17.6 ms、W=1000 で 3,896 ms、ビュー1つあたり約 39 ns で、その点では書いた場合の 250倍の時間です。RxJS でカットオフを担うのはモデルではなく、このオペレーターです。

export const runBenchmark = (k: number, width: number): number => {
const [source, setSource] = createState(0);
// Equal to its previous value on 999 updates in 1000. `map` compares its
// result with `Object.is` and passes nothing on when it is equal.
const bucket = source.pipe(map((v) => Math.floor(v / 1000)));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) =>
bucket.pipe(map((b) => b + i)).subscribe((v) => {
mut_lastValues[i] = v;
}),
);
for (let mut_i = 1; mut_i <= k; mut_i++) {
setSource(mut_i);
}
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const source = new BehaviorSubject(0);
// Equal to its previous value on 999 updates in 1000, which
// `distinctUntilChanged` drops. `shareReplay` makes it one node for every
// view, as a derived value is in the other libraries; without it each
// subscription would run its own `map`.
const bucket = source.pipe(
map((v) => Math.floor(v / 1000)),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) =>
bucket.pipe(map((b) => b + i)).subscribe((v) => {
mut_lastValues[i] = v;
}),
);
for (let mut_i = 1; mut_i <= k; mut_i++) {
source.next(mut_i);
}
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
return Arr.sum(mut_lastValues);
};

RxJS (naive、distinctUntilChanged なし)

Section titled “RxJS (naive、distinctUntilChanged なし)”
export const runBenchmark = (k: number, width: number): number => {
const source = new BehaviorSubject(0);
// The same graph without `distinctUntilChanged`: the bucket is emitted on
// every update, equal or not, and every view runs.
const bucket = source.pipe(
map((v) => Math.floor(v / 1000)),
shareReplay({ bufferSize: 1, refCount: true }),
);
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const subscriptions = Arr.seq(asUint32(width)).map((i) =>
bucket.pipe(map((b) => b + i)).subscribe((v) => {
mut_lastValues[i] = v;
}),
);
for (let mut_i = 1; mut_i <= k; mut_i++) {
source.next(mut_i);
}
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const state = observable({ source: 0 });
// Equal to its previous value on 999 updates in 1000. A `computed` compares
// its result and does not change when it is equal.
const bucket = computed(() => Math.floor(state.source / 1000));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => {
const view = computed(() => bucket.get() + i);
return reaction(
() => view.get(),
(v) => {
mut_lastValues[i] = v;
},
{ fireImmediately: true },
);
});
for (let mut_i = 1; mut_i <= k; mut_i++) {
runInAction(() => {
state.source = mut_i;
});
}
for (const dispose of disposers) {
dispose();
}
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const sourceAtom = atom(0);
// Equal to its previous value on 999 updates in 1000. A derived atom compares
// its result with `Object.is` and does not change when it is equal.
const bucketAtom = atom((get) => Math.floor(get(sourceAtom) / 1000));
const store = createStore();
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => {
const viewAtom = atom((get) => get(bucketAtom) + i);
mut_lastValues[i] = store.get(viewAtom);
return store.sub(viewAtom, () => {
mut_lastValues[i] = store.get(viewAtom);
});
});
for (let mut_i = 1; mut_i <= k; mut_i++) {
store.set(sourceAtom, mut_i);
}
for (const dispose of disposers) {
dispose();
}
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const selectCounter = (state: Readonly<{ value: number }>): number =>
state.value;
// Equal to its previous value on 999 updates in 1000, so each view's
// selector, which compares its input with the previous one, returns its
// cached result. `lruMemoize` for the reason the derived chain gives.
const selectBucket = createSelector(
selectCounter,
(counter) => Math.floor(counter / 1000),
{ memoize: lruMemoize },
);
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
// One subscriber per view, as each `useSelector` is: the store calls every
// one of them on every dispatch.
const disposers = Arr.seq(asUint32(width)).map((i) => {
const selectView = createSelector(selectBucket, (b) => b + i, {
memoize: lruMemoize,
});
mut_lastValues[i] = selectView(store.getState());
return store.subscribe(() => {
mut_lastValues[i] = selectView(store.getState());
});
});
for (let mut_i = 1; mut_i <= k; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
for (const dispose of disposers) {
dispose();
}
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const { setSource, dispose } = createRoot((disposeRoot) => {
const [source, setSourceValue] = createSignal(0);
// Equal to its previous value on 999 updates in 1000. A memo compares its
// result with `===` and does not change when it is equal.
const bucket = createMemo(() => Math.floor(source() / 1000));
for (const i of range(0, asSafeUint(width))) {
const view = createMemo(() => bucket() + i);
createEffect(() => {
mut_lastValues[i] = view();
});
}
return { setSource: setSourceValue, dispose: disposeRoot };
});
for (let mut_i = 1; mut_i <= k; mut_i++) {
setSource(mut_i);
}
dispose();
return Arr.sum(mut_lastValues);
};
export const runBenchmark = (k: number, width: number): number => {
const source = signal(0);
// Equal to its previous value on 999 updates in 1000. A `computed` compares
// its result and keeps its version when it is equal.
const bucket = computed(() => Math.floor(source.value / 1000));
const mut_lastValues = Arr.zeros(asUint32(width)).map(() => 0);
const disposers = Arr.seq(asUint32(width)).map((i) => {
const view = computed(() => bucket.value + i);
return effect(() => {
mut_lastValues[i] = view.value;
});
});
for (let mut_i = 1; mut_i <= k; mut_i++) {
source.value = mut_i;
}
for (const dispose of disposers) {
dispose();
}
return Arr.sum(mut_lastValues);
};
  • ウォームアップ: 5ラウンド(破棄)。
  • 測定: 20ラウンド。
  • NN: 1ラウンドあたり 10510^5 回の更新。
  • タイミング: performance.now()(サブミリ秒精度)。
  • 分離: 各行はそれぞれ別の Node プロセスで計測しています。V8 は関数をそれまでに見た形に合わせて最適化するので、1つのプロセスを共有すると、ある行は前の行が教えたことを引き継ぎ、その数値はどの行が先に走ったかで変わります。
  • ビルド: SynState を含むすべてのライブラリを本番ビルド(NODE_ENV=production)で実行しています。アプリケーションが配布するのはこちらなので、開発時のみのチェックは測定に含まれません。例外は Valtio で、その ES モジュールビルドは import.meta.env.MODE を読みますが、これはバンドラが定義するもので Node は定義しません。残る開発時チェックは購読の作成時とネストしたプロキシの接続時にだけ実行され、更新ごとには実行されません。
  • 統計: 中央値、最小、最大、p95、ops/sec(中央値に基づく)。最初の3シナリオには、各中央値を SynState の中央値で割った「vs SynState」(SynState の何倍かかったか)も載せています。
  • Redux: デフォルトのミドルウェア(thunk、シリアライザブルチェック、イミュータビリティチェック)は middleware: () => new Tuple() で無効化されています。これらは本番ビルドでは tree-shaking で除去される開発時チェックです。
  • Redux(ディープチェーン、条件付きファンアウト): reducer は createSlice の reducer ではなく、createAction と照合するプレーンな関数です。MM 個の点や BB 個の分岐の Immer ドラフトが更新のたびに作られ要素ごとに確定されると、ストアではなく Immer を計測することになるためです。ほかのシナリオはステートが数値1つなので createSlice のままです。
  • Redux(ディープチェーン): セレクターは自身の前回の出力を読めないため、scan チェーンは各ステージの保存された位置を回す reducer のループです。MobX と同じく命令的なコードで、Redux の行はそれを計測しています。
  • Zustand と Valtio(単一ステートのみ): どちらのバニラ API にも派生値のノードがないため(Valtio の derive は別パッケージ)、他のシナリオでは派生値を subscriber 内でインライン計算するしかありません。それは他のライブラリが構築するグラフよりノードが少なく、同じ計測になりません。そのため、どのライブラリも何も派生しないシナリオにだけ参加します。Valtio の subscribe の第3引数 true は通知を同期にし、すべてのミューテーションで subscriber が呼ばれるようにします(Valtio はデフォルトで非同期にバッチ処理します)。
  • RxJS(ダイヤモンド、カスケードダイヤモンド、条件付きファンアウトの愚直な行): combineLatest はタプルを作り、続く map がそれを値に変えるので、合流1つが2ノードになります。SynState の derive、MobX の computed、Jotai の派生 atom では1ノードです。合流ごとにノード1つと配列1つ分のコストがかかりますが、RxJS では合流はこう書きます。
  • MobX(ディープチェーン): computed は自身の前回の出力を読めないため、scan チェーンは observable.box の列として書き、1つの reaction が1つのアクション内でループして更新します。これは派生ノードのチェーンではなく命令的なコードで、MobX の行はそれを計測しています。
  • RxJS(ダイヤモンド依存関係): combineLatest はグリッチ問題により、ソース更新ごとに subscriber を2回発火させます。ベンチマークはこの追加作業を含むウォールクロック時間を測定しており、RxJS におけるダイヤモンド依存関係の実際のコストを反映しています。
  • Solid: シナリオは solid-js/dist/solid.js、つまりバンドラーがブラウザ向けに選ぶビルドを import しています。solid-js のルートの export は Node の node 条件ではサーバービルドに解決され、そこではメモは1回だけ計算されて effect は一度も実行されないため、この Node でのベンチマークでは何も計測できません。関数はアプリケーションが solid-js から import するものと同じです。グラフはすべて createRoot の中で作り(これで dispose できます)、subscriber は createEffect です。計測しているのは Solid 1.9 です。コアを @solidjs/signals として単独で公開する Solid 2.0 は、執筆時点ではリリース候補でした。
  • Solid(ディープチェーン): scan の各ステージは前回の値を受け取るメモ(createMemo(fn, initialValue))で、Solid 自身のアキュムレータの書き方です。
  • Preact Signals: Preact に依存しないパッケージ @preact/signals-core を使っています。subscriber は effect です。値を渡す signal.subscribe(fn) も内部では effect です。
  • Preact Signals(ディープチェーン): computed には前回の値が渡されないため、scan チェーンは1つの effect が batch の中で進めるシグナルの列で、各ステージは peek で読みます。MobX の reaction と同じ形です。前回の値をクロージャに保持する computed もここでは動きます(毎回の更新ですべてのステージが読まれるため)が、それはライブラリが何回評価するかに依存する書き方で、MobX の行もそうは書いていません。
  • MobX: 他のライブラリの subscribe-and-record パターンに合わせるため、autorun ではなく reaction を使用しています。MobX の strict mode で要求されるとおり、runInAction で各ミューテーションをラップしています。
  • Jotai: store.sub() はコールバックに現在の値を渡さないため、リスナー内で追加の store.get() 呼び出しが必要です。初期値もsubscribe前に store.get() で読み取られます。
  • ディープチェーン以外では、すべてのライブラリがベンチマーク関数内でステート、派生値、およびsubscriptionを作成するため、セットアップコストは測定に含まれています。ディープチェーンはタイマーを開始する前にグラフを構築します。
  • RxJS(等値カットオフ): bucket は shareReplay({ bufferSize: 1, refCount: true }) で共有し、他のライブラリの派生値と同じく、すべてのビューにとって1つのノードにしています。これがないと購読ごとに自分の map を走らせることになり、share() では最初より後に購読したビューが現在の値を受け取れません。
  • Redux(等値カットオフ): useSelector 1つずつが購読するのと同じく、ビューごとに store.subscribe を1つ持ち、それぞれが共有の bucket セレクターに対する createSelector でビューを読みます。
  • 条件付きファンアウト: SynState と RxJS は、全ブランチに対する静的な derive / combineLatest で愚直に書いた形(「(naive …)」の行)と、MobX・Jotai・Solid・Preact Signals と同じく選択中のブランチだけを購読する switchMap の2通りで計測しています。

分析: 各ライブラリが遅くなる理由

Section titled “分析: 各ライブラリが遅くなる理由”

このページのどのライブラリにも遅くなる場面があり、その理由はそれぞれが更新のたびに行う処理にあります。この節では、その処理を各ライブラリのソースから読んだとおりに説明し、それで何が得られ、何がかかり、そのコストがどのシナリオで最も表に出るか(ライブラリごとの表のそのライブラリの行)を示します。SynState が更新で行うことは更新を 1 ステップずつ追うで順に追っています。

RxJS: 経路ごとに発行するので、合流するほど処理が増える

Section titled “RxJS: 経路ごとに発行するので、合流するほど処理が増える”

RxJS の更新ごとの処理はわずかです。next は各 subscriber を深さ優先で直接呼び、順序付けの手順も管理処理もありません。単一ステート・派生チェーン・ダイヤモンドで最速の一角にいるのはそのためです。RxJS がしないのは、待つことです。combineLatest は入力のどれか1つが発行した時点で発行するので、PP 本の経路が届くノードはソース更新1回につき PP 回発行し、その下流はそのたびに動きます。これがディープチェーンの O(M2)O(M^2) と、RxJS の最悪の行であるカスケードダイヤモンドの O(2N)O(2^N) です。最後の1回を除くすべての発行はグリッチです。

MobX: 実行のたびに依存関係を追跡し直す

Section titled “MobX: 実行のたびに依存関係を追跡し直す”

書き込みは runInAction の中での observable への代入で、box.set か、observable オブジェクトのプロパティへの代入(その裏の box に set されます)です。アクションがバッチを開き、set が値を比較してすべての observer を stale にし(propagateChanged)、バッチが閉じると stale になった reaction がそれぞれ再実行されます。実行はすべて追跡されます。MobX は新しい依存配列を確保し、実行中の .get() をすべて記録し、その結果を前回の実行のリストと比較して(bindDependencies)、差分のある observer を追加・削除します。この固定コストが、単一ステートでの MobX の12倍です。

ディープチェーンでは、scan のステージは1つのアクションの中で1つずつ set される observable.box で、その M+1M+1 個すべてを読む autorun が更新のたびに再実行され、M+1M+1 個の依存を追跡し直します。何も実行する必要がないところ、つまり誰も読まない computed や誰も観測しない分岐では MobX は何もしません。条件付きファンアウトの MobX の行が約 1.4 ms で B に関係なく一定なのも、最後の computed だけを reaction が読むカスケードダイヤモンドで、NN が増えても SynState に近く、典型的には SynState の 2.2倍(NN ごとの比の中央値)にとどまるのも、そのためです。読まれるが変わらないノードは別です。MobX の最悪の行である等値カットオフでは、書き込みが bucket の下のすべてのビューに「古いかもしれない」印を付け(propagateMaybeChanged)、すべての reaction が自分のビューを確かめてから bucket が変わっていないとわかります。ビュー1つ・更新1回あたり約 109 ns です。

Jotai: set のたびにグラフを解決し直す

Section titled “Jotai: set のたびにグラフを解決し直す”

Jotai はグリッチフリーで、そのコストは更新ごとのフレームワークオーバーヘッド、つまり Jotai のストアが store.set() の呼び出しごとに、さらにその呼び出しが再計算する atom ごとに実行する処理量です。ほかのライブラリのする処理が最も少ないところで最もよく現れます。条件付きファンアウトでは、誰も読まない分岐への書き込みは MobX・Solid・Preact Signals には 15 ns 以下しかかかりませんが、Jotai には store.set() 1回分の 1.4 µs がまるごとかかります。最悪の行は等値カットオフ(W=50)です。これは、store.set() のたびに下の手順 2 と 3 を bucket の下のすべてのビューに対して行い、手順 4 で bucket が変わっていないとわかるシナリオです。ビュー1つ・更新1回あたり約 330 ns です。

store.set(counterAtom, i) が呼び出されると、Jotai のストアは以下のステップを実行します:

  1. 値の更新 — 新しい値を書き込み、Object.is で比較。O(1)O(1)。
  2. 無効化パス(invalidateDependents)— マウントされたすべての依存先の DFS トラバーサル。Map<Atom, EpochNumber> でのエポック番号追跡により各ノードを「再計算が必要」とマーク。O(N)O(N)(NN = 影響を受ける atom の数)。
  3. トポロジカルソート(recomputeInvalidatedAtoms)— 訪問追跡に WeakSet を使用した2回目の DFS 後順トラバーサル。正しい順序の再計算リストを生成。O(N)O(N)。
  4. 再計算 — トポロジカル順序の各 atom に対して read 関数を実行。read 関数内で、各 get(dep) 呼び出しは: (a) 依存先の AtomState を検索、(b) エポック番号を比較して古さをチェック、(c) 将来の無効化のために Map に依存関係を記録。コストは O(∑Di)O(\sum D_i) — 再計算されるすべての atom の直接依存の合計であり、積ではない。各1つの依存を持つ N 個の atom のチェーンでは O(N)O(N)。
  5. subscriber 通知 — リスナーコールバックを発火。Jotai の store.sub() は新しい値を渡さないため、コールバックは store.get() を再度呼び出す必要があり、追加のキャッシュ検索が発生する。リスナーあたり O(1)O(1) だが、追加のトラバーサルが1回発生。
ステップSynStateJotai
グラフの解決深さは構築時に確定し、更新は深さ順のキューで進むstore.set() のたびに(ステップ2-3)
伝搬直接関数呼び出し。子は発行したノードがキューに入れるgetter spy + Map/WeakSet 操作を伴う read 関数実行
依存関係の追跡静的(構築時に設定)動的(getter spy を通じて評価のたびに再検出)
鮮度チェック不要(プッシュ型)get() 呼び出しごとのエポック番号比較
subscriber への値の配送値がコールバックに直接渡されるコールバックが値を読むために store.get() を呼び出す必要がある

ベンチマークのループは更新ごとの差を測定可能にするために100,000回の更新を実行しています。しかし、この定数倍のコストは高頻度イベントを扱う実際のアプリケーションでも問題になります。

例えば、mousemove や pointermove イベントは1秒間に数十回発火します。各イベントがリアクティブパイプラインを駆動する場合(ドラッグ&ドロップ、ツールチップの位置計算、キャンバスアニメーションなど)、更新ごとのオーバーヘッドがイベント発生頻度に比例して増大します。スループットデモは、これより深い同じ種類のチェーンを1フレームに何度も更新し、各ライブラリが1フレームにかかる時間を、このブラウザで測って 60fps の1フレームの予算である 16ms と並べて示します。

更新ごとの差が生じる根本原因:

  • SynState: setCounter(i) → doubled の map 関数を呼び出し → quadrupled の map 関数を呼び出し → subscriber を呼び出し。3回の関数呼び出しで、管理処理はノードごとのバケットへの追加と更新ごとのキューオブジェクト1つだけ。
  • Jotai: store.set(counterAtom, i) → DFS 無効化(WeakSet アロケーション + Map 書き込み)→ DFS トポロジカルソート(WeakSet アロケーション + 配列 push + reverse)→ doubledAtom の再計算(Map 検索 + エポックチェック + Map 書き込み)→ quadrupledAtom の再計算(同上)→ コールバックをフラッシュ → store.get(quadrupledAtom)(キャッシュ検索)。

100,000回のイテレーションにわたって、SynState は3回の関数呼び出しが処理の大半を占める一方、Jotai では更新ごとの管理処理(Map/WeakSet アロケーション、エポック比較、2回の DFS トラバーサル)が蓄積され、観測される約51倍の差になります。

Redux: dispatch のたびにすべての subscriber を呼ぶ

Section titled “Redux: dispatch のたびにすべての subscriber を呼ぶ”

dispatch は、Redux と Redux Toolkit が実行する順に次のステップを踏みます:

  1. アクション — アクションクリエイターが { type, payload } を確保し、dispatch はそれが文字列の type を持つプレーンオブジェクトであることを確かめます。
  2. reducer — ルートの reducer はすべてのアクションで実行されます。createSlice の reducer は Immer の produce の中で実行され、Immer がドラフトのプロキシを作り、そこへの書き込みを記録し、ドラフトを新しいステートオブジェクトに確定します。これを使うシナリオの、数値1つのステートでは、これが reducer のコストの大半です。
  3. 通知 — アクションが何を変えたかによらず、すべての subscriber が呼ばれます。どの subscriber が何を読むかを、ストアは記録していません。
  4. セレクター — 各 subscriber はセレクターを通してステートを読み直します。createSelector のセレクターは、まず引数を自前のキャッシュで引き(デフォルトは weakMapMemoize で、新しいステートオブジェクトのたびにエントリを追加します)、入力セレクターを実行して結果を新しい配列に入れ、それを前回の結果と比較してから結果関数を呼びます。

条件付きファンアウトは、この4ステップだけでできています。誰も選んでいない分岐への書き込みでも reducer が走り、subscriber が呼ばれてそのセレクターが実行され、最小のブランチ数でもすでに約136 msかかります。読んでいるものに購読が従う MobX・Solid・Preact Signals は、ここでは何もしません。Redux の最悪の行である等値カットオフは、ステップ 3 と 4 をビューの数だけ繰り返します。dispatch のたびに W 個の subscriber がすべて呼ばれ、各セレクターが入力を比較してからキャッシュした結果を返します。ビュー1つ・更新1回あたり約 778 ns です。ほかのライブラリがノードとして組むものは、Redux ではただのコード(ディープチェーンでは reducer のループ、カスケードダイヤモンドではセレクターの連鎖)なので、ディープチェーンでは Redux は SynState との差が小さな定数倍に収まり、カスケードダイヤモンドでは SynState より遅く、NN ごとの比の中央値で SynState の5.4倍の時間です。

Solid: 再実行のたびに依存関係を登録し直す

Section titled “Solid: 再実行のたびに依存関係を登録し直す”

setter は新しい値を比較し(既定は ===)、シグナルに observer がいれば更新(runUpdates)を開き、メモ用と effect 用の2つのキューをアロケートします。各 observer はキューに入れられて stale と印付けされ、その下流はすべて pending と印付けされます(markDownstream)。続いてメモのキューが実行されます。上流がまだ stale のメモは先にそれを実行し(runTop、lookUpstream)、これによってダイヤモンドの合流点は両側が更新された後の値を見ます。effect は最後に実行されます。再実行はまず、前回の実行で読んだすべてのソースからその計算を外すことから始まり(cleanNode)、関数が読み直すたびに各ソースへ登録し直されます。つまり MobX と同じく、依存関係は実行のたびに再発見されます。メモは先行評価です。何かに読まれているかどうかにかかわらず、ソースが変わればメモは再実行されます。

書き込みごとの2つのキューと、再実行ごとの解除と再登録が固定コストで、それが単一ステートでの 6.5倍、派生チェーンでの 6.1倍です。最悪の行は等値カットオフ(W=50)です。これは、書き込みが bucket の下のすべてのビューに保留の印を付け(markDownstream)、bucket が変わっていないとわかるまでに各ビューを確かめる(lookUpstream)シナリオです。誰も observe していないシグナルへの書き込みは更新そのものを省くので、条件付きファンアウトの Solid の行は約 0.7 ms で B に関係なく一定です。子も購読者もない observable への書き込みでは SynState も同じように更新を省くので、そこで両者の差になっているのはその書き込み自体のコストです。

Preact Signals: 再実行は安いが、書き込みは読み手をすべてマークする

Section titled “Preact Signals: 再実行は安いが、書き込みは読み手をすべてマークする”

.value への書き込みは !== で比較し、シグナルのバージョンとグローバルなバージョンを増やして、バッチの中でターゲットに通知します。computed は outdated と印付けされて通知を先へ渡し、effect はキューに入ります。最も外側のバッチが終わると、キューの各 effect は、ソースのどれかが前回見たより新しいバージョンを持っているかを確かめ(needsToRecompute)、再実行します。computed を読むときも同じように refresh し、ソースのバージョンが動いたときだけ再計算するので、ダイヤモンドの合流点は両側の後に1回だけ計算されます。MobX や Jotai と同じく、プルによってグリッチフリーです。

ここでも依存関係は実行のたびに追跡されますが、ノードからソースへのリンクは連結リストで、再実行はそれに印を付けて使い回します(prepareSources、cleanupSources)。前回と同じものを読む実行は何もアロケートせず、どの購読者リストにも触れません。そのため、単一ステート(1.2倍)・派生チェーン(1.0倍)・ダイヤモンド(1.0倍)のいずれでも、行は SynState と同程度です。最悪の行は等値カットオフ(W=50)です。これは、書き込みが bucket の下のすべてのビューに古い印を付けてその effect をキューに入れ、bucket が変わっていないとわかるまでに各 effect が自分のビューを確かめるシナリオです。ビュー1つ・更新1回あたり約 33 ns です。

どちらも単一ステートでのみ計測しています。Zustand の setState は変更を新しいオブジェクトにマージし(Object.assign)、各リスナーにそれを渡して呼ぶだけで、SynState とほとんど変わりません(1.2倍)。Valtio の書き込みはプロキシの set トラップを通り、そこで古い値と比較し、オブジェクトの値ならそれ自身のプロキシで包み、バージョン番号を進め、操作を記録します。subscribe が登録したリスナーは、その操作を配列で受け取ります(10倍)。