シグナルブースト
Preact Signalsの新しいリリースでは、リアクティブシステムの基礎となる部分に大幅なパフォーマンスのアップデートが加えられました。これがどのように実現されたのか、その手法について説明します。
最近、Preact Signalsパッケージの新しいバージョンを発表しました。
- @preact/signals-core 1.2.0(共有コア機能用)
- @preact/signals 1.1.0(Preactバインディング用)
- @preact/signals-react 1.1.0(Reactバインディング用)
この投稿では、@preact/signals-coreの最適化のために実行した手順の概要を示します。これは、フレームワーク固有のバインディングのベースとして機能するパッケージですが、独立して使用することもできます。
Signalsは、Preactチームによるリアクティブプログラミングへの取り組みです。Signalsの概要とPreactとの連携方法について簡単に知りたい場合は、Signals発表ブログ投稿をご覧ください。より深く理解したい場合は、公式ドキュメントを参照してください。
これらの概念は、私たちが発明したものではないことに注意してください。リアクティブプログラミングには長い歴史があり、Vue.js、Svelte、SolidJS、RxJSなど、JavaScriptの世界で既に広く普及しています。これらすべてに敬意を表します!
Signals Coreの概要
@preact/signals-coreパッケージの基本機能の概要から始めましょう。
以下のコードスニペットでは、パッケージからインポートされた関数を使用しています。新しい関数が導入される場合のみ、インポート文が表示されます。
Signals
プレーンなsignalsは、リアクティブシステムの基礎となる基本的なルート値です。他のライブラリでは、例えば「observables」(MobX、RxJS)または「refs」(Vue)と呼ばれる場合があります。Preactチームは、SolidJSで使用されている「signal」という用語を採用しました。
Signalsは、リアクティブなシェルにラップされた任意のJavaScript値を表します。初期値をsignalに渡し、後で読み書きできます。
import { signal } from "@preact/signals-core";
const s = signal(0);
console.log(s.value); // Console: 0
s.value = 1;
console.log(s.value); // Console: 1
REPLで実行それ自体では、signalsは、他の2つのプリミティブであるcomputed signalsとeffectsと組み合わせるまでは、それほど興味深いものではありません。
Computed Signals
Computed signalsは、compute関数を使用して他のsignalsから新しい値を導出します。
import { signal, computed } from "@preact/signals-core";
const s1 = signal("Hello");
const s2 = signal("World");
const c = computed(() => {
return s1.value + " " + s2.value;
});
REPLで実行computed(...)
に渡されるcompute関数は、すぐに実行されません。これは、computed signalsが遅延評価されるため、つまり、値が読み取られたときに評価されるためです。
console.log(c.value); // Console: Hello World
REPLで実行計算された値もキャッシュされます。それらのcompute関数は非常に高価になる可能性があるため、必要な場合にのみ再実行する必要があります。上記の例では、a.value
とb.value
の両方が同じままである限り、以前に計算されたc.value
を無期限に再利用できます。この依存関係の追跡を容易にするために、プリミティブ値をsignalsにラップする必要があるのです。
// s1 and s2 haven't changed, no recomputation here
console.log(c.value); // Console: Hello World
s2.value = "darkness my old friend";
// s2 has changed, so the computation function runs again
console.log(c.value); // Console: Hello darkness my old friend
REPLで実行実際、computed signalsはそれ自体がsignalsです。computed signalは、他のcomputed signalsに依存できます。
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
console.log(quadruple.value); // Console: 4
count.value = 20;
console.log(quadruple.value); // Console: 80
REPLで実行依存関係のセットは静的である必要はありません。computed signalは、最新の依存関係セットの変更にのみ反応します。
const choice = signal(true);
const funk = signal("Uptown");
const purple = signal("Haze");
const c = computed(() => {
if (choice.value) {
console.log(funk.value, "Funk");
} else {
console.log("Purple", purple.value);
}
});
c.value; // Console: Uptown Funk
purple.value = "Rain"; // purple is not a dependency, so
c.value; // effect doesn't run
choice.value = false;
c.value; // Console: Purple Rain
funk.value = "Da"; // funk not a dependency anymore, so
c.value; // effect doesn't run
REPLで実行依存関係の追跡、遅延、キャッシングというこれら3つの要素は、リアクティブライブラリでは一般的な機能です。Vueのcomputedプロパティは顕著な例の1つです。
Effects
Computed signalsは、副作用のない純粋関数に適しています。それらは遅延評価でもあります。では、信号値の変化に絶えずポーリングすることなく反応したい場合はどうすればよいでしょうか?Effectsが役立ちます!
Computed signalsと同様に、effectsも関数(effect関数)で作成され、依存関係を追跡します。ただし、遅延評価ではなく、早期評価です。effect関数は、effectが作成されるとすぐに実行され、依存関係の値が変更されるたびに繰り返し実行されます。
import { signal, computed, effect } from "@preact/signals-core";
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
effect(() => {
console.log("quadruple is now", quadruple.value);
}); // Console: quadruple value is now 4
count.value = 20; // Console: quadruple value is now 80
REPLで実行これらの反応は、通知によってトリガーされます。プレーンなsignalが変更されると、その直接の従属関係者に通知します。それらは順番に独自の直接の従属関係者に通知し、これが続きます。リアクティブシステムでは一般的なように、通知のパスに沿ったcomputed signalsは、古くなったものとしてマークされ、再計算の準備が整います。通知がeffectまで完全に伝わる場合、そのeffectは、以前にスケジュールされたすべてのeffectが完了するとすぐに実行されるようにスケジュールします。
effectが完了したら、effectが最初に作成されたときに返されたディスポーザーを呼び出します。
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
const dispose = effect(() => {
console.log("quadruple is now", quadruple.value);
}); // Console: quadruple value is now 4
dispose();
count.value = 20; // nothing gets printed to the console
REPLで実行batch
などの他の関数もありますが、これら3つは、後続の実装ノートと最も関連性があります。
実装ノート
上記のプリミティブの高性能バージョンを実装する際に、次のすべてのサブタスクを迅速に実行する方法を見つける必要がありました。
- 依存関係の追跡:使用されたsignals(プレーンまたはcomputed)を追跡します。依存関係は動的に変更される可能性があります。
- 遅延評価:compute関数は、オンデマンドでのみ実行する必要があります。
- キャッシング:computed signalは、その依存関係が変更された場合にのみ再計算する必要があります。
- 早期評価:依存関係チェーン内の何かが変更された場合、effectはできるだけ早く実行する必要があります。
リアクティブシステムは、無数の異なる方法で実装できます。@preact/signals-coreの最初のリリースバージョンはSetsに基づいていたので、対比と比較のためにそのアプローチを引き続き使用します。
依存関係の追跡
compute/effect関数の評価が開始されるときはいつでも、実行中に読み取られたsignalsをキャプチャする方法が必要です。そのため、computed signalまたはeffectは、現在の評価コンテキストとして自身を設定します。signalの.value
プロパティが読み取られると、getterを呼び出します。getterは、signalを評価コンテキストの依存関係、ソースとして追加します。コンテキストも、signalの従属関係、ターゲットとして追加されます。
最終的に、signalsとeffectsは常に、その依存関係と従属関係の最新ビューを持ちます。各signalは、その値が変更されるたびに、その従属関係者に通知できます。例えば、effectが破棄されると、Effectsとcomputed signalsは、その依存関係セットを参照して、それらの通知の登録解除を行うことができます。
同じsignalは、同じ評価コンテキスト内で複数回読み取られる可能性があります。このような場合、依存関係と従属関係のエントリに対して何らかの重複排除を行うことが便利です。また、依存関係のセットの変更を処理する方法も必要です。つまり、毎回依存関係のセットを再構築するか、依存関係/従属関係をインクリメンタルに追加/削除する方法です。
JavaScriptのSetオブジェクトは、これらすべてに適しています。他の多くの実装と同様に、Preact Signalsの最初のバージョンではそれらを使用していました。Setsを使用すると、一定のO(1)時間(償却時間)、および線形O(n)時間で現在のアイテムを反復処理することができます。重複も自動的に処理されます!多くのリアクティブシステムがSets(またはMaps)を利用しているのも不思議ではありません。まさに適切なツールです。
しかし、代替アプローチがあるかどうか疑問に思っていました。集合(Set)の作成は比較的コストがかかり、少なくとも計算済みシグナルは、依存関係用と被依存関係用の2つの別々の集合を必要とする可能性があります。Jasonはまたしても完全にJasonらしいことをして、ベンチマークで集合の反復処理が配列と比べてどの程度か検証しました。多くの反復処理が行われるため、すべてが積み重なります。
集合には、挿入順序で反復処理されるという特性もあります。これは素晴らしいことで、キャッシング処理を行う際にまさに必要なものです。しかし、順序が常に維持されるとは限りません。次のシナリオを見てください。
const s1 = signal(0);
const s2 = signal(0);
const s3 = signal(0);
const c = computed(() => {
if (s1.value) {
s2.value;
s3.value;
} else {
s3.value;
s2.value;
}
});
REPLで実行s1
に応じて、依存関係の順序はs1, s2, s3
またはs1, s3, s2
のいずれかになります。集合の順序を維持するには、特別な手順を踏む必要があります。項目を削除してから追加し直す、関数の実行前に集合を空にする、または実行ごとに新しい集合を作成することなどが考えられます。いずれのアプローチにも、メモリチャーンが発生する可能性があります。そして、これらはすべて、依存関係の順序が変わるという理論上はありうるが、おそらくまれなケースに対応するためだけに必要なことです。
これに対処する方法は他にも複数あります。たとえば、番号を付けてから依存関係をソートすることなどが考えられます。最終的に、リンクリストを検討することにしました。
リンクリスト
リンクリストは非常にプリミティブなものとみなされることがよくありますが、私たちの目的においては、いくつかの非常に優れた特性を持っています。双方向リンクリストノードを使用する場合、次の操作は非常に安価に行うことができます。
- リストの一端にアイテムを挿入する(O(1)時間)。
- リスト内の任意の場所からノード(ポインタを既に持っているノード)を削除する(O(1)時間)。
- リストを反復処理する(O(n)時間(ノードあたりO(1)))。
これらの操作は、依存関係/被依存関係リストの管理に必要なすべてです。
各依存関係ごとに「ソースノード」を作成することから始めます。ノードのsource
属性は、依存されているシグナルを指します。各ノードには、依存関係リスト内の次のソースノードと前のソースノードを指すnextSource
とprevSource
プロパティがあります。エフェクトまたは計算済みシグナルには、リストの最初のノードを指すsources
属性があります。これで、依存関係を反復処理し、新しい依存関係を挿入し、再順序付けのためにリストから依存関係を削除することができます。
今度は逆に、各被依存関係ごとに「ターゲットノード」を作成します。ノードのtarget
属性は、被依存関係のエフェクトまたは計算済みシグナルを指します。nextTarget
とprevTarget
は双方向リンクリストを構築します。プレーンシグナルと計算済みシグナルには、被依存関係リストの最初のターゲットノードを指すtargets
属性があります。
しかし、依存関係と被依存関係はペアで存在します。すべてのソースノードには、対応するターゲットノードが**必ず**存在します。この事実を利用して、「ソースノード」と「ターゲットノード」を単なる「ノード」に統合することができます。各ノードは、被依存関係が依存関係リストの一部として使用できる一種の4重リンクの怪物となり、その逆も同様になります。
各ノードには、簿記目的で追加の情報を添付することができます。各計算/エフェクト関数の実行前に、前の依存関係を反復処理し、各ノードの「未使用」フラグを設定します。また、後で使用する為に、ノードをその.source.node
プロパティに一時的に保存します。その後、関数の実行を開始できます。
実行中、依存関係が読み取られるたびに、簿記値を使用して、その依存関係が今回の実行または前回のいずれかの実行ですでに参照されているかどうかを検出できます。依存関係が前回のやり直しからのものである場合、そのノードを再利用できます。以前参照されていない依存関係の場合、新しいノードを作成します。次に、ノードをシャッフルして、使用順序の逆順に維持します。実行の最後に、依存関係リストをもう一度走査し、「未使用」フラグが設定されたままになっているノードをパージします。その後、残りのノードのリストを反転させて、後で使用できるように整理します。
この繊細なデスのダンスにより、各依存関係-被依存関係ペアごとに1つのノードのみを割り当て、依存関係が存続する限り、そのノードを無期限に使用することができます。依存関係ツリーが安定している場合、初期構築フェーズの後もメモリ消費量は事実上安定した状態を保ちます。同時に、依存関係リストは最新の状態に保たれ、使用順序で維持されます。ノードごとに一定のO(1)の作業量で。素晴らしい!
早期実行エフェクト
依存関係の追跡が完了したので、変更通知を介して早期実行エフェクトを比較的簡単に実装できます。シグナルは、値の変更について被依存関係に通知します。被依存関係自体が被依存関係を持つ計算済みシグナルである場合、通知をさらに転送します。通知を受けたエフェクトは、自身の実行をスケジュールします。
ここではいくつかの最適化を追加しました。通知の受信側が以前に既に通知されており、まだ実行する機会がなかった場合、通知を転送しません。これは、依存関係ツリーが分岐または収束する場合の、カスケード状の通知の殺到を軽減します。プレーンシグナルは、シグナルの値が実際には変更されない場合(例:s.value = s.value
)も、被依存関係に通知しません。単なる丁寧さです。
エフェクトが自身をスケジュールできるようにするには、スケジュールされたエフェクトのリストが必要です。各エフェクトインスタンスに専用の属性.nextBatchedEffect
を追加し、エフェクトインスタンスを単方向リンクのスケジューリングリスト内のノードとして二重に使用できるようにしました。これにより、同じエフェクトを何度もスケジュールする際に、追加のメモリ割り当てや割り当て解除が不要になるため、メモリチャーンが削減されます。
インターリュード:通知サブスクリプションとGC
完全に正直ではありませんでした。計算済みシグナルは、実際には常に依存関係から通知を受け取るわけではありません。計算済みシグナルは、エフェクトなど、シグナル自体をリッスンしているものがある場合にのみ、依存関係の通知を購読します。これにより、このような状況での問題を回避できます。
const s = signal(0);
{
const c = computed(() => s.value)
}
// c has gone out of scope
c
が常にs
からの通知を購読する場合、s
もスコープ外になるまでc
はガベージコレクションされません。これは、s
がc
への参照を保持し続けるためです。
WeakRefを使用する、または計算済みシグナルを手動で破棄する必要があるなど、この問題には複数の解決策があります。私たちの場合、リンクリストは、すべてのO(1)処理のおかげで、依存関係の通知に動的に登録および登録解除するための非常に便利な方法を提供します。その結果、ぶら下がっている計算済みシグナルの参照に特別な注意を払う必要はありません。これは、最も人間工学的に優れ、かつ高性能なアプローチであると考えました。
計算済みシグナルが通知を**購読している**場合、その知識をさらに最適化するために使用できます。これにより、遅延とキャッシングについて説明できます。
遅延とキャッシュされた計算済みシグナル
遅延計算済みシグナルを実装する最も簡単な方法は、その値が読み取られるたびに再計算することです。しかし、これはあまり効率的ではありません。そこで、キャッシングと依存関係の追跡が役立ちます。
プレーンシグナルと計算済みシグナルには、それぞれ独自のバージョン番号があります。それらは、独自の値の変更に気づいたたびにバージョン番号を増分します。計算関数が実行されると、依存関係の最後に確認されたバージョン番号がノードに格納されます。代わりに、バージョン番号ではなく、ノードに前の依存関係の値を格納することもできました。しかし、計算済みシグナルは遅延しているため、古い可能性があり、コストのかかる値を無期限に保持する可能性があります。したがって、バージョン番号付けは安全な妥協点であると考えました。
計算済みシグナルが休んでキャッシュされた値を再利用できるかどうかを判断するためのアルゴリズムを次に示します。
前回のやり直し以降、どこにもシグナルの値が変更されていない場合は、中断してキャッシュされた値を返します。
プレーンシグナルが変更されるたびに、すべてのプレーンシグナルで共有されるグローバルバージョン番号も増分されます。各計算済みシグナルは、最後に確認されたグローバルバージョン番号を追跡します。前回の計算以降グローバルバージョンが変更されていない場合は、再計算を早期にスキップできます。その場合、計算済み値に変更は発生しません。
計算済みシグナルが通知をリッスンしており、前回のやり直し以降通知されていない場合は、中断してキャッシュされた値を返します。
計算済みシグナルが依存関係から通知を受け取ると、キャッシュされた値が古くなったことを示すフラグが設定されます。前述のように、計算済みシグナルは常に通知を受けるわけではありません。しかし、通知を受けた場合は、それを利用できます。
依存関係を順序どおりに再評価します。バージョン番号を確認します。再評価後でも、依存関係のバージョン番号が変更されていない場合は、中断してキャッシュされた値を返します。
この手順は、依存関係を使用順序で維持することに特別な注意を払った理由です。依存関係が変更された場合、リストの後続の依存関係を再評価する必要がない可能性があります。最初の依存関係の変更によって、次の計算関数の実行で後続の依存関係が削除される可能性があります。
計算関数を実行します。返された値がキャッシュされた値と異なる場合は、計算済みシグナルのバージョン番号を増分します。新しい値をキャッシュして返します。
これは最後の手段です!しかし、少なくとも新しい値がキャッシュされた値と等しい場合、バージョン番号は変更されず、後続の被依存関係はそれを独自のキャッシングの最適化に使用できます。
最後の手順は、多くの場合、依存関係に再帰的に適用されます。そのため、前述の手順は再帰を短絡しようとしています。
エンディング
典型的なPreactの方法として、途中で複数の小さな最適化が追加されました。ソースコードには、役立つ場合もある、場合もあるコメントが含まれています。実装の堅牢性を確保するために考案したコーナーケースの種類に興味がある場合は、テストを確認してください。
この投稿は、ある種のブレインダンプです。@preact/signals-coreバージョン1.2.0を「より良い」というある種の定義で改善するために私たちが取った主な手順の概要を示しています。ここに記載されているアイデアのいくつかが共感を呼び、他の人々によって再利用およびリミックスされることを願っています。少なくともそれが夢です!
貢献してくれたすべての人に感謝します。そして、ここまで読んでいただきありがとうございます!素晴らしい旅でした。