GPUI(GUIフレームワーク)への批判
A Critique of GPUI (blog.zortax.de)
要約
この記事は、RustのGUIフレームワークであるGPUIとeguiのステートモデルが、複雑なデスクトップアプリケーションには限界があるという見解を示しています。著者は、パフォーマンススケーリングのためにはUIアーキテクチャが変更を追跡し、依存関係を把握する必要があると主張し、信号ベースの「一度だけ実行」モデルが最もシンプルで効率的なAPIであると結論付けています。
全文翻訳
GPUIへの批判
最近、ネイティブRust GUIフレームワークでデスクトップGUIアプリを構築するトレンドがあります。有名なElectronアプリでさえ公式リライトが行われており、DiscordはGPUIを使用してアプリを書き直すと発表しました[1]。一方、公開されているLLMクローン(模倣アプリ)の多くはeguiを選択しているようです。この記事では、これらのフレームワークが構築の基盤として適切かどうかについて、私の懸念を説明したいと思います。
率直に言って、GPUIとeguiの両方のステートモデルは、複雑なデスクトップアプリケーションにとって行き止まりだと考えています。どちらも印象的なプロジェクトであり、それぞれに適切な場所があります。Zedは実際に高速で使いやすく、私はbevyゲームのデバッグインターフェースにeguiを使用しています。しかし、これらのフレームワークがネイティブデスクトップアプリのデフォルトの選択肢になるべきではないと思います。
私は2つの議論をしたいと考えています。
- どのようなUIアーキテクチャであっても、最終的には何が変更され、何がそれに依存しているかを知る必要があります。そうでなければ、パフォーマンス特性はスケーリングしません。ステートモデルがそれを言えないフレームワークでは、アプリケーションが手動でそれを言う必要があり、そのフレームワークのAPIセマンティクスが魅力的であった理由をすべて犠牲にすることになります。
- その精度が必要だと認めれば、一度だけ実行され、信号とリアクティブな穴を持つコンポーネントが、それに対する最もシンプルなAPIとなります。
開示:私はGPUIの現在のLinuxレンダラーをwgpuにポートしました[2]。以下で使用されているベンチマークハーネスを構築しました。また、チャートに表示されている信号ベースのRustフレームワークであるzguiに取り組んでいます。
唯一重要な質問
Raph LevienはRon Minskyの言葉を引用しています。「すべてのUIフレームワークの内部には、何らかのインクリメンタライズ(増分処理)フレームワークが隠されている」[3]。UIはステートの関数であり、ステートは少しずつ変化します。しかし、その関数が高価になる場合、インクリメンタルなステート変更から画面上のピクセルをより高速に更新する方法を導き出す方法を探したくなるかもしれません。したがって、すべてのフレームワークは1つの質問に答える必要があります。ある値が変更されたとき、何が実行されるのか?
| モデル | 書き込みが実行するもの | 変更を検出するもの |
|---|---|---|
| 即時モード (egui) | すべてのUIコード、次のフレーム | なし |
| Elm (Iced) | view のすべて、その後差分 | 差分 |
| Views (GPUI) | キャッシュされていないすべてのビュー | notify を使ったあなた |
| Signals, 再実行 (Freya, Compose) | それを読み取るコンポーネント | フレームワーク |
| Signals, 一度だけ実行 (Floem, Solid, zgui) | それを読み取るクロージャ | フレームワーク |
最初の3つが好まれる理由は理解できます。同期するステートがない、ステートが存在する場所が1つ、プレーンなRustの render 関数、奇妙なラッパーや別のコンパイラがない。これらのアプローチがスケーリングしないこと、そして根本的な問題を修正することが、これらのモデルをシンプルで理解しやすくしているものを損なうことを主張します。
GPUI: notify は zone.js
GPUIは自身を「ハイブリッドな即時モードとリテインドモード」フレームワークと呼んでいます[4]。エンティティ内にステートを保持します。UIは render 関数で定義され、ステートの変更は再実行をトリガーする必要があります。
impl Render for Sidebar {
fn render(
&mut self,
_: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let inbox = self.inbox.read(cx);
div()
.child(format!("{} unread", inbox.unread()))
.children(inbox.threads.iter().map(|t| t.title.clone()))
}
}
これは通常のRustです。if、for、ローカル変数を使用でき、奇妙なラッパーはありません。ステートの変更には明示的な通知が必要です。
self.threads[i].unread = false;
cx.notify();
notify はエンティティIDを渡します[5]。Rustには &mut に対する書き込みバリアがないため、GPUIはどのフィールドが変更されたか(あるいは何も変更されなかったか)を知ることができません。Angularチームは2023年にzone.jsについて次のように書いています。「何かが起こった可能性があるときにのみ通知できます」そして「このアプローチはうまくスケーリングしませんでした」[6]。notify はゾーンよりも正確ですが、エンティティ名を指定するため、最終的には同じようなことを言っています。そのIDの下では、GPUIは何も知らないため、有効な .cached() 境界の内側にないすべてのビューを再レンダリングします。そして実際には、アプリケーション作成者は .cached() をめったに使用せず、レイアウトセマンティクスに特定の制限を課すため、GPUIはしばしばすべてのビューを再レンダリングします。
GPUIは「高速」という評判があります。定数は比較的低いかもしれませんが、漸近的には多くのものをテーブルに残しています。そこで、大規模なUIでのスパースなステート変更が実際にどれだけコストがかかるかを具体的にテストするベンチマークをいくつか実行しました。
3,000のライブ行があり、行ごとにキャッシュされたビューがある場合、1つのラベルを変更するのに8.4ミリ秒かかります。ホバー、キー入力、古い値と同じ値の書き込みは、コストがツリーに沿って増加し、変更に沿って増加しないため、ほぼ同じコストがかかります。1,000行ごとに約3ミリ秒増加します。上記のコードのように、行をプレーンな要素として書き込むと、同じラベルは92ミリ秒かかります。手書きの等価性ガードは、同じ書き込みを7.9ミリ秒から0.9マイクロ秒に短縮します(各呼び出しサイトが見つけるためにどれだけ残っているかを示しています)。
[画像: 線形軸上では、行あたりのコストは傾きです][画像: 線形軸上では、行あたりのコストは傾きです]
これはすべてのGPUIアプリが遅いという意味ではありませんが、ステートモデルの限界を示しています。特にレイアウトが高価な場合、実際にはこの壁にぶつかります。Zedは高速に感じられるかもしれませんが、真実はZedはUI的には比較的シンプルなアプリであるということです。アプリのクロームには、いくつかのボタン、シンプルなレイアウト、サイドバー、テキストエディタが含まれており、テキストエディタはカスタム要素として実装されており、GPUIのレイアウトとビューセマンティクスを完全にバイパスしています。彼ら自身の説明によると、ウィンドウは「数十のノードと数千の要素」を持っています[7]。
GPUIを正確にすることはGPUIを損なう
明白な修正は、より多くを保持することです。Zedにはそのための実験的なブランチがあり、Longbridgeのgpui-fastは、フレーム間で各ビューのサブツリーを保持するフォークです[8][9]。これは限界内で機能します。どちらも再利用できる最小のものはビューです。したがって、サイドバーを再構築せずに1つのラベルを更新するには、ラベルがビューである必要があります。
pub struct UnreadBadge { unread: usize, _watch: Subscription }
impl UnreadBadge {
pub fn new(inbox: Entity<Inbox>, cx: &mut Context<Self>) -> Self {
let unread = inbox.read(cx).unread();
let _watch = cx.observe(&inbox, |this, inbox, cx| {
let unread = inbox.read(cx).unread();
if unread != this.unread {
this.unread = unread;
cx.notify();
}
});
Self { unread, _watch }
}
}
impl Render for UnreadBadge {
fn render(
&mut self,
_: &mut Window,
_: &mut Context<Self>,
) -> impl IntoElement {
format!("{} unread", self.unread)
}
}
その所有者は、それを作成し、ハンドルを保持する必要があります。
let badge = cx.new(|cx| UnreadBadge::new(inbox.clone(), cx));
これは、エンティティ、Render 実装、サブスクリプション、値の保存されたコピー、手書きのガード、および所有者であり、すべて1つのラベルが1つのカウントに依存することを表現するためです。信号フレームワークでは、これは次のようになります。
let unread = Memo::new(move |_| {
threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count())
});
Label::derived(move || format!("{} unread", unread.get()))
GPUIの他のAPIもこれに反して機能します。Render はプロップを受け取らないため、親は自身のレンダリング中に値を子エンティティにプッシュします。RenderOnce コンポーネント(ボタンや行など)は、親のビュー内で描画され、境界線はまったくありません。リストの行はクロージャであるため、行ビューはエンティティのキー付きマップを手動で管理することを意味します。
そして、それはステートがビューと一致する場合にのみ役立ちます。gpui-fastの60パネルベンチマークを再実行し、各フレームを1回描画してから、ステートを移動させました。
[画像: リテンションは、ステートがすでにビューと一致する場所にのみ役立ちます][画像: リテンションは、ステートがすでにビューと一致する場所にのみ役立ちます]
各パネルがステートを所有している場合、1回の変更で83%節約されます。共有モデルでは、リテンションにより同じフレームが11%遅くなります。ウィンドウと編集は同一です。実際のZedでは、Zedチームはリテンションを「通常のワークロードではオフの場合より1〜3%遅い」と測定し、「実際のZedの勝利のほとんど」は他の作業から来たと述べ、「マイクロベンチマークの勝利は実際のZedの結果を予測しなかった」と警告しました[8]。gpui-fastは実際の進歩であり、採用コストはほとんどかかりません。3,000行のラベルを8.4ミリ秒から3.4ミリ秒に短縮します。しかし、そのフレームは3つのビューをレンダリングし、1つの要素をレイアウトするだけで、まだ3.4ミリ秒かかります。なぜなら、フレームは依然としてツリー全体をウォークするからです。
Webはすでにこの道を歩んできました。Reduxのスタイルガイドには、「より多くのUIコンポーネントをReduxストアにサブスクライブさせ、より細かいレベルでデータを読み取ることを優先してください」とあります[10]。MobXは「多数の小さなオブザーバーコンポーネントを作成する」ことを推奨しています[11]。どちらも、フレームワークが推測できないため、境界をプロジェクションごとに手動で作成するように指示しています。
これが私が乗り越えるのが最も難しい部分です。人々は、render がステートのプレーンな関数であるため、GPUIを選択します。各プロジェクションが個別に所有されるエンティティとサブスクリプション、ガードを持つUIはもはやそうではありません。それは、はるかに重い部品から手動で構築された信号グラフであり、エッジの欠落はサイレントです。Zed自身の設計ノートには、「通知の欠落はアプリケーションのバグです」とあります[12]。快適なAPIを維持すれば、ビューサイズの更新が得られます。精度を得れば、快適なAPIを失います。信号クレートを追加しても、トレードオフは変わりません。なぜなら、その最小のシンクは依然としてビューだからです。フレームワークが見ることができる書き込みとフレームを超えるノードが必要なため、1つのノードに到達するには、新しいステートモデルと新しい要素契約が必要です。
Elm: 小規模では見かけによらずシンプル
通常示されるサイズでは、Elmアーキテクチャは非常にシンプルなUIモデルです。1つのステート値、1つの update、1つの view、そしてすべての変更には名前があります。大規模なアプリケーションでも成功裏に使用されていますが[13][14]、この明確さは小規模ケースに属し、複雑なアプリケーションでは信号とストアの方が理解しやすいと主張したいです。
同じ unread カウントを考えてみましょう。これは各メッセージの直後に正しい必要があります。そのため、view が更新ごとに再計算するか、update がそれを維持する必要があります。
match message {
InboxMessage::MarkRead(i) => {
if self.threads[i].unread {
self.threads[i].unread = false;
self.unread -= 1;
}
}
InboxMessage::Delete(i) => {
if self.threads.remove(i).unread {
self.unread -= 1;
}
}
InboxMessage::Arrived(thread) => {
self.unread += thread.unread as usize;
self.threads.push(thread);
}
}
unread の定義は、どこにも特定されていません。スレッドに触れるすべての腕に分散されており、新しいメッセージごとにそれを覚えておく必要があります。コンパイラは match が網羅的であることをチェックしますが、算術はチェックしません。上記のメモには1つの定義があり、新しいライターはそれを忘れることができません。
場所についても同じことが起こります。ホバー状態や半入力中の下書きはどこに保存されますか?単一の真実の源はモデルにあり、メッセージタイプが下にルーティングされ、結果が上にルーティングされます。Elmのガイドには、「Elmでコンポーネントを積極的に作成しようとすることは悲劇への道です」とあります[15]。Icedは親に Action を返すことを推奨しています[16]。Icedのメンテナーは、このアーキテクチャのコンポーネントは常に「苦痛で終わる」と述べており、規律の最良のケースも提示しています。「それらを避けることは、データモデルについて一生懸命考えさせる」[17]。まあ、それは妥当な議論だと思います。しかし、cosmic-filesには136のトップレベルメッセージバリアントとタブ用のさらに73のバリアントがあり、さらに親が解釈するためのコマンドを返す Tab::update があります[18]。そのすべてが明示的であり、どれもシンプルではありません。
このアーキテクチャはGPUIと同じ問題を抱えています。update 実装はメッセージが何を書くかを示しますが、フィールドを読んだものが何であるかは示せません。view 内のものは何でもそうかもしれません。Elmのアーキテクチャは「何が変わったか?」ではなく、「何が起こったか?」に答えます。セッションエクスポートは一部のシナリオで役立つかもしれませんが、スケーラブルなパフォーマンス特性には役立ちません[19]。
これはWebの歴史から読み取れることです。Reduxはこのデザインの最も有名な親戚であり、そのドキュメントはフィールドレポートのように読めます。「ストアの一部を『データベースのように扱う』」[20]。セレクターは「ディスパッチされた各アクションの後に再実行されます」[21]。コアパッケージは「廃止」され、ボイラープレートを削除するために作られたツールキットに置き換えられました[22]。React以外では、主流は追跡読み取りに移行しました。Angular、Vue、Svelte、Solidは信号または同等のものの上に構築されており、Preactはそれを提供し、Svelteチームは次のように述べています。「私たちは、Knockoutが最初から正しかったという結論に至りました」[23]。React内では、JotaiとMobXがライブラリとして追跡を導入し、Legend-Stateは「一度だけレンダリング」スタイルさえ提供しています[24]。ほとんどのアプリケーションはまだReactを使用しており、Reactチームはこれに反対しています。これは後で説明します。したがって、収束はステートモデルに向かっており、1つの大きな例外を除いてです。
パフォーマンスも同様です。Icedは各メッセージバッチの後にすべての view を再構築するため、3,000行のラベル1つは39,023ウィジェットを構築し、7.3ミリ秒かかります。各行に Lazy を適用しても、view が各行をハッシュするためにキーを訪問する必要があるため、5.7ミリ秒にしかなりません。公式の lazy 例は version: u8 をキーとして使用しており、これは4つの update アームが手動でインクリメントします[25]。それはより良い名前を持つダーティフラグです。公平を期せば、cosmic-filesとHalloyは lazy をまったく使用しておらず、仮想化されたリストではIcedは私が測定した中で最も高速なフレームワークの1つであり、0.19ミリ秒です。境界付きスクリーンは安価に再構築されます。しかし、漸近的な複雑さは明らかです。そしてGPUIと同様に、APIセマンティクスは必要な情報を表現できません。
これには実際に前例があります。Viziaは、このモデルに精度を追加するとどうなるかを示しています。2026年3月にレンズから信号に移行し、Model とイベントディスパッチを維持しました[26]。これは短縮されたカウンターです[27]。
pub enum AppEvent { Increment, }
impl Model for AppData {
fn event(&mut self, _: &mut EventContext, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::Increment => self.count.update(|count| *count += 1),
});
}
}
let count = Signal::new(0);
AppData { count }.build(cx);
Button::new(cx, |cx| Label::new(cx, "Increment"))
.on_press(|cx| cx.emit(AppEvent::Increment));
Label::new(cx, count).width(Pixels(50.0)).live(Live::Polite);
ラベルは信号を直接読み取り、ボタンを構築するのと同じクロージャがそれを作成しました。列挙型、ディスパッチ、ハンドラは、1回の update 呼び出しを伝達するために存在します。Viziaは厳密なElmではありませんでした。なぜなら、そのビューは一度だけ構築され、信号は賢明な動きでした。フィールドごとの書き込みが可能になりました。しかし、Elmの理由の残骸を見てください。ステートはミュータブルセルのグラフであり、Model はハンドルのバッグであり、event は &mut self の副作用です。これで、何かが変更されたことを示す2つのシステムを持つことになります。したがって、Elmアーキテクチャに信号を追加することへの私の反対は、GPUIへの反対と同じです。それらは機能し、あなたが求めていたものを削除します。
eguiのピッチは、この分野で最高です。「アプリのステートとGUIのステートが同期ずれする心配をする必要はありません」[28]。ツールとしては、これは打ち負かすのが難しいです。
for row in rows {
ui.horizontal(|ui| {
ui.label(&row.name);
ui.label(row.price.to_string());
if ui.button("+").clicked() {
row.price += 1;
}
});
}
その代償は、フレームがUIのサイズと同じコストがかかることであり、変更されたものに関係なくです。3,000行の場合、すべての小さな編集は約32ミリ秒、アクセシビリティオフの場合は約19ミリ秒かかります。eguiの作者はトレードオフについて率直です。即時モードは「使いやすいが、それほど強力ではない」[28]。
アプリケーションが成長するにつれて、元の魅力は失われます。レイアウトのパラドックスは「根本的な欠点」です[28]。長いリストには show_rows が必要ですが、これは固定された行の高さを1つ取ります。私が知る限り最大のeguiアプリケーションであるRerunは、「前のフレームのegui状態を検査する」ことでクエリを制限しています[29]。高価なものはすべて、データと一致する必要があるキャッシュの後ろに置かれます。データと一致する必要があるキャッシュは、即時モードが削除しようとした同期問題であり、今度はアプリケーションコードで、1フレーム遅れています。
1つのエンジン、異なる配線
ハーネスはこれを直接示すことができます。なぜなら、一部のフレームワークは同じエンジン上で粗くも細かくも配線できるからです。
いくつかのエンジンのベンチマークを2つの方法で実施しました。1つは可能な限り細かい粒度で使用し、もう1つはより粗い方法で使用しました。結果は、細かい粒度のリアクティブ性でどれだけ得られるかを示しています。
[画像: トラッキングは役立ちますが、ビュー関数がまだ実行される必要がある場合は役立ちません][画像: トラッキングは役立ちますが、ビュー関数がまだ実行される必要がある場合は役立ちません]
2つの点が際立っています。Icedの係数が小さいのは、ビュー関数がまだ実行されるためです。そして、Floemの粗いケースは注目に値します。各セルは1つのグローバルリビジョン信号を読み取ります。これは信号の服を着た notify です。チャートで最も遅い結果です。したがって、信号は粗いステートを救いません。Reactチームも同様のことを反対側から発見しました。巨大な入力のベンチマークでは、信号スタイルのランタイムは失望し、Joe Savonaはデータモデルはアルゴリズムよりも「重要、あるいはそれ以上」であると結論付けました[30]。ステートは読み取りに合わせて形状付けられる必要があります。だからこそ、これをステートモデルの問題と呼んでいます。良いAPIは、データモデルをUIプロジェクションに合わせて簡単に調整できるようにするべきです。
なぜ一度だけ実行するのか
すべてを受け入れたと仮定しましょう。フレームワークに読み取りを追跡させたい、そしてステートが一致するように形状付けたい。最もシンプルなAPIは何でしょうか?
pub fn sidebar(threads: RwSignal<Vec<Thread>>) -> impl IntoView {
サイドバーがマウントされたときに一度だけ実行されます。
let unread = Memo::new(move |_| {
threads.with(|ts| ts.iter().filter(|t| t.unread.get()).count())
});
Stack::vertical((
Label::derived(move || format!("{} unread", unread.get())),
dyn_stack(move || threads.get(), |t| t.id, thread_row),
))
}
それがすべてです。本体は一度だけ実行されます。フレームワークに渡すクロージャは、読み取った何かが変更されたときに再度実行されます。ローカル変数はローカル変数です。フックのルール、依存配列、古いクロージャ、プロップの PartialEq はありません。行のIDを再構築するものはありません。なぜなら、行はまだそこにあるからです。
コンポーネントを再実行する信号と比較してください。これはFreyaのモデルです。うまく機能します。3,000行での1回のキー入力は1.2ミリ秒かかります。しかし、カウンターを見てみましょう[31]。
#[derive(PartialEq)]
struct Counter;
impl Component for Counter {
fn render(&self) -> impl IntoElement {
let mut count = use_state(|| 0);
}
}
use_state が存在する理由は、その行が再度実行されるためです。あなたは両方のモデルを持っています。読み取り追跡はどのスコープが再実行されるかを決定し、その後、フック、プロップの等価性、差分を使用してその内部で再レンダリングセマンティクスが適用されます。より細かい更新には、より小さなコンポーネントが必要であり、これはGPUIの問題をはるかに低いコストで解決します。再実行される本体が購入するのは通常の制御フローであり、それが深刻な反対意見です。
Dan Abramovは説得力のある議論をしています[32]。一度だけ実行はレンダリングロジックをクロージャに入れるため、トップレベルの制御フローが停止し、ロジックではなく値の周りにコードをグループ化します。「Reactの美しさは、物事を『計算済み』にすることが要件ではなく、最適化であることです。」彼はそれに正しいです。これはコンパイルされますが、最初の行は更新されません。
Label::new(count.get()), // 一度だけ読み取られる; 更新されない
Label::derived(move || count.get()), // 穴; 更新される
リアクティブな if は dyn_container になり、for は dyn_stack になる必要があります。Solid 2.0では、トップレベルの読み取りに対して開発者警告を追加する必要がありました[33]。
しかし、同じコメントには私が反対の立場を取る理由が含まれています。彼は、Solidのリグループは「useMemoで最適化するときに値の周りにコードを再構成する必要があるのと似ています」と述べています。まさに。アプリケーションがそれを必要としない場合、Reactのモデルはよりシンプルかもしれません。必要な場合、どちらの場合もリグループのコストを支払うことになり、唯一の選択肢はいつかということです。最初から、または後でプロファイラーの下で。Ryan Carniatoの返信は私が同意するものです。「Solidでは、データが派生しているかのように常に記述するため、最適化のためにリファクタリングすることはありません」[32]。この精度が必要だとわかったら、一度だけ実行は、そうでなくなっていると偽るのをやめるAPIです。失敗モードもそれを支持します。忘れられた穴は、それを書いた場所に、最初の更新で古くなります。忘れられた notify は、再描画された他のものに依存します。
正直に言うと、エコシステムのより広い収束は、全般的には読み取り追跡に向かっており、一度だけ実行に特化しているわけではありません。Angularはバインディングごとの更新を検討し、書き込みではビューごとに更新することを選択しました。これは、部分的な更新でSolidと約1ミリ秒の範囲になります[6][34]。AppleとGoogleは、言語を制御しており、@Observable とComposeでボディの再実行を維持しています[35][36]。穴自体は再実行スコープですが、違いはデフォルトスコープの大きさです。これについて2つの点を指摘したいと思います。
- 第一に、これらのシステムはコンパイラで大きなスコープのコストを支払います。Composeにはコンパイラプラグインがあり、Angularはテンプレートをコンパイルし、Reactのコンパイラはすべてに対する深刻な競合相手です。APIを維持し、多くの不要な再レンダリングを削除し、最大12%向上します[37]。Rustの場合、即時モードまたはElm(あるいはGPUIが何であれ)を好むほとんどの人が追加のコンパイラのアイデアを好まないだろうと推測します。プロックマクロはトークンしか見ず、型は見ません。
renderは任意の関数なので、スコープに分割できるパスはありません。Rustが安価に表現できるのはクロージャです。 - 第二に、それら自身のガイダンスは同じ問題を説明し続けています。「Composeでは、読み取りを可能な限り遅延させる」そして「不要なビューボディの更新は本当に積み重なる可能性があります」SwiftUIの場合[38][39]。ボディを一度だけ実行するフレームワークは、コンポーネントレベルでそのカテゴリを削除します。
ビューレベルの再実行と細かい粒度の信号は、同じパフォーマンスの天井を持つことができます。しかし、信号は最初から細かい粒度のステートプロジェクションとメモ化を奨励します。ビューレベルの再実行では、それは明示的な選択であり、しばしばより多くのボイラープレートにつながります。
信号は自動的にアプリを速くしない
細かい粒度のリアクティブ性は、それ自体ではアプリを速くしません。
[画像: 信号だけでは曲線は平坦になりません][画像: 信号だけでは曲線は平坦になりません]
Floemのリアクティブ性は、約束どおりに機能します。ラベルの変更は5つのエフェクトを実行し、リアクティブな作業に0.01ミリ秒、レイアウトに約14ミリ秒かかります。なぜなら、Taffyは1つのリーフが移動したことを知らないからです。(Floemには、その編集が再描画されないという既知のバグもあります。したがって、これを下限と見なしてください。)Viziaは4つのバインディングを実行し、その後120,091個のエンティティすべてをウォークし、46ミリ秒かかります。Slintは4マイクロ秒で同等の値を書き込み、ラベルに172ミリ秒かかります。これは約30,000項目をウォークするパスです。Freyaは5つのミューテーションを生成し、その後57,050のレイアウトノードを訪問して14を再計算します。GPUIはこの編集ではほとんどのものを上回っています。
これは、議論が依存する区別です。これらの線形コストはすべて、それぞれエンジン内にあります。アプリケーションコードにはそれらを要求するものはありません。したがって、アプリケーションを変更せずに同じAPIの下で修正できます。zguiはそれがどこまで行くかを示しています。3,000行のラベルで0.69ミリ秒、1,000行あたり約0.26ミリ秒で、GPUIの約12倍平坦です。まだ平坦ではなく、私のフレームワークであり、このハーネスで調整されたため、これらの数字は控えめに見るべきです。Icedとeguiでは、線形コストは契約内にあります。APIはコードが実行されると述べています。エンジンの作業ではそれを削除できません。アプリコードを変更する必要があります。GPUIはおそらくその中間に位置しており、gpui-fastが状況をある程度改善できる理由です。
この時点で、より詳細なベンチマークマトリックスを共有したいと思います。再実行列を最初の5行に沿って読み取ると、それらはほとんど動きません。なぜなら、それらは編集に依存しないからです。信号列は大きく動きます。なぜなら、基盤となる実装が異なるからです。
[画像: 再実行により、ほとんどの編集コストは同じになります。信号はそうではありません][画像: 再実行により、ほとんどの編集コストは同じになります。信号はそうではありません]
そのグリッドの底にも、信号のコストが明らかになります。
- 作成。 3,000のライブ行でのフィルタリングは、Icedでは56ミリ秒、eguiでは67ミリ秒、GPUIでは123ミリ秒かかります。zguiでは371ミリ秒、Floemでは835ミリ秒、Freyaでは982ミリ秒です。仮想化されたリストでは、Icedで1.0ミリ秒、zguiで7.2ミリ秒です。「依存関係グラフのセットアップは無料ではありません」[6]。
- メモリ。 大規模なシーンの常駐メモリ使用量は、フレームワークが保持する量が多いほど、一般的に高くなります。
- エフェクト。 Facebookは、連鎖的な更新が予測困難になったため、双方向バインディングから移行しました[40]。その歴史は真実であり、Angularのドキュメントには「エフェクトは、あなたが最初に手を出すAPIであるべきです」とまだ書かれています[41]。最新のグラフはグリッチフリーです。不注意なエフェクトは依然として推論が難しく、すぐにフィードバックループや同様のものを引き起こします。
- Rust。 信号はアリーナハンドルと
moveクロージャに依存します。Levienは、そのようなパターンは「Rustにうまく適応しない」と考えています[3]。私はそれらが十分にうまく適応すると考えており、GPUIのエンティティもアリーナハンドルですが、確かに無料のランチではありません。
結論
GPUIはフルフレームを比較的安価に作成し、そのため多くのユースケースで十分である可能性が高いです。高価な表面が手動で処理されるエディタには確かに十分です。大規模なアプリケーションの場合、選択肢が残ります。人々が好むAPIを維持してビューごとに支払うか、エンティティから信号グラフを記述して精度を得るかです。Elmと即時モードは同じ問題を抱えています。それぞれが小規模では最も明確であり、精度が必要になると、選択したものを失います。そして私の個人的な経験では、それは人々が予想するよりも速く起こります。
追跡読み取りは、ほとんどの分野が最終的にたどり着いた場所であり、それがどこから始まったとしてもです。そこに向かっているのであれば、そこから始め、コンポーネントを一度だけ実行される関数にすべきだと考えます。
方法
- 数値は、私の未発表のハーネスからの適用と完了の中央値であり、1台のマシンで測定されました。i9-13900K、RTX 3080 Ti、Vulkan、Linuxです。Windows、コンポジター、入力は含まれていません。フレームワークのメンテナーはアダプターをレビューしていません。
- GPUIは
ddc9eda925、gpui-fastは86a8228229です。これらは2026年9月から11月にかけて、3回のパスのベストです。 - グリッドで†でマークされたセルは、正確性オラクルに失敗しました。Floemのラベルとキー入力編集(前述)と、Slintのフォントサイズです。チャート化された他のすべてのセルは合格しました。
- 60パネルの結果は、gpui-fastの
retained_benchに私の追加を加えたもので、テストモードであるため、GPU作業は含まれていません。そこでのリテンションオフは依然としてgpui-fastです。 - Zedの計画からの数値は、その作者によるもので、実験的なブランチからのものです。
- Rustの例は、バンドルされたフレームワークに対して型チェックされました。ViziaとFreyaの例は、それら自身のソースから短縮されたものです。
参考文献
[2] タイトル: Branching Beyond Leonardo: The Evolution of Tree Architecture Theory
URL: https://blue-continuum.com/branching-beyond-leonardo
本文:
[画像: レオナルド・ダ・ヴィンチのノートのページに描かれた木の規則: 円の中に描かれた分岐図と鏡文字の筆記]
レオナルド・ダ・ヴィンチの木の規則に関するノートのページ。大英図書館。
地球
分岐
レオナルドを超えて
木の建築理論の進化
Gayil Nalls著
2026年11月3日公開
6分で読む
数年前、私はフランスのロワール渓谷にあるアンボワーズのクロ・リュセ城を訪れました。そこはルネサンス期の万能の芸術家、建築家、エンジニア、解剖学者、そして飛行機械の設計者であるレオナルド・ダ・ヴィンチが晩年を過ごした場所です。1516年、彼はフランシス1世の招きを受け、そこに定住し、様々なプロジェクトに取り組みました。レオナルドは1519年、67歳で、私が立っていたまさにその部屋で亡くなりました。その部屋は、そびえ立つ木々で満たされた庭園と敷地に開かれていました。私はそこに立ち、広大な木々の天蓋を見上げました。レオナルドの自然界への好奇心は無限でした。彼のノートの多くの主題の中に、木の成長に関する彼の観察があり、それは今日まで科学者や芸術家を魅了し続けています。「木のすべての枝は、その高さのあらゆる段階で、すべて合わせると幹と同じ太さになる…」
Gayil Nalls はBlue Continuumの共同創設者兼シニアエディターであり、World Sensorium Conservancyの会長です。
この記事の以前のバージョンはPlantings Magazineに掲載されました。
木曜日の無料ニュースレター、The Continuum を購読してください。