Phê bình GPUI: mô hình trạng thái của framework Rust
Một bài phân tích kỹ thuật cho rằng mô hình trạng thái của GPUI (framework đứng sau Zed và được Discord chọn để viết lại ứng dụng) và egui sẽ không mở rộng tốt cho ứng dụng desktop phức tạp, kèm benchmark cho thấy chi phí cập nhật tăng theo quy mô giao diện.

Một bài phân tích kỹ thuật trên blog.zortax.de đặt câu hỏi liệu các framework GUI native viết bằng Rust như GPUI và egui có thực sự là nền tảng tốt cho ứng dụng desktop phức tạp hay không. Tác giả không phủ nhận chất lượng của các dự án này, nhưng cho rằng mô hình trạng thái của chúng sẽ đi vào ngõ cụt khi giao diện lớn dần.
Bối cảnh của bài viết khá đáng chú ý: ngày càng nhiều ứng dụng desktop được viết bằng framework GUI Rust, và Discord đã công bố viết lại ứng dụng của mình bằng GPUI. Trong khi đó, nhiều bản sao LLM được phát hành lại chọn egui.
Tác giả công bố rõ xung đột lợi ích: anh đã chuyển bộ kết xuất (renderer) Linux hiện tại của GPUI sang wgpu, xây dựng bộ đo benchmark dùng trong bài, và đang phát triển zgui, một framework Rust dựa trên signal cũng xuất hiện trong các biểu đồ.
Câu hỏi duy nhất quan trọng
Bài viết dẫn lại nhận định của Raph Levien, dẫn lời Ron Minsky: "ẩn bên trong mọi UI framework là một loại framework incrementalization nào đó". Giao diện là một hàm của trạng thái, và trạng thái chỉ thay đổi từng chút một. Vì vậy mọi framework đều phải trả lời một câu hỏi: khi một giá trị thay đổi, cái gì sẽ chạy lại?
Tác giả lập bảng so sánh năm mô hình:
- Immediate mode (egui): mỗi lần ghi chạy toàn bộ mã UI ở frame kế tiếp, không ai phát hiện thay đổi.
- Elm (Iced): chạy lại toàn bộ
viewrồi diff. - Views (GPUI): chạy lại mọi view không có cache, lập trình viên phải tự gọi
notify. - Signals, chạy lại component (Freya, Compose): framework xác định component nào đọc giá trị đó.
- Signals, chạy một lần (Floem, Solid, zgui): framework xác định closure nào đọc giá trị đó.
Với GPUI, notify chính là zone.js
GPUI tự gọi mình là framework "lai giữa immediate và retained mode". Trạng thái được lưu trong các entity, UI được định nghĩa trong hàm render, và thay đổi trạng thái phải kích hoạt chạy lại:
self.threads[i].unread = false;
cx.notify();
Vì Rust không có cơ chế chặn ghi trên &mut, GPUI không thể biết trường nào đã thay đổi, hay thậm chí có thay đổi hay không. Lời gọi notify chỉ mang theo id của entity. Tác giả so sánh điều này với zone.js của Angular — thứ mà chính đội Angular năm 2023 thừa nhận "chỉ có thể thông báo khi một cái gì đó có thể đã xảy ra" và "cách tiếp cận này không mở rộng tốt".
Kết quả là GPUI phải render lại mọi view không nằm sau ranh giới .cached(), và trên thực tế .cached() hiếm khi được dùng.
Để kiểm chứng, tác giả chạy benchmark với 3.000 dòng đang hiển thị:
- Đổi một nhãn với một cached view cho mỗi dòng: 8,4 ms
- Cùng phép đổi đó khi các dòng là element thuần: 92 ms
- Thêm một kiểm tra bằng nhau viết tay đưa phép ghi giá trị không đổi từ 7,9 ms xuống 0,9 µs
- Chi phí tăng khoảng 3 ms cho mỗi 1.000 dòng
Chi phí cập nhật tăng tuyến tính theo số dòng
Tác giả thừa nhận Zed có cảm giác nhanh, nhưng lý giải rằng Zed là ứng dụng tương đối đơn giản về mặt UI: phần khung chỉ có vài nút, một sidebar và một trình soạn thảo văn bản — mà trình soạn thảo lại là element tùy biến, bỏ qua hoàn toàn hệ thống layout và view của GPUI. Theo chính đội Zed, một cửa sổ "có hàng chục node và vài nghìn element".
Càng chính xác càng mất GPUI
Cách sửa hiển nhiên là lưu giữ (retain) nhiều hơn. gpui-fast của Longbridge là một bản fork giữ lại cây con của mỗi view giữa các frame. Nó hoạt động, nhưng đơn vị nhỏ nhất có thể tái sử dụng vẫn là một view.
Để cập nhật một nhãn hiển thị số tin chưa đọc mà không dựng lại sidebar, lập trình viên phải tạo một struct, một impl Render, một subscription, một bản sao giá trị, một kiểm tra bằng nhau viết tay và một chủ sở hữu cho entity đó. Trong framework signal, việc tương đương chỉ là một Memo và một Label::derived.
Tác giả nhấn mạnh đây chính là điều khó chấp nhận nhất: người ta chọn GPUI vì render là hàm thuần của trạng thái. Nhưng một UI mà mỗi phép chiếu là một entity riêng có subscription và guard thì không còn là hàm thuần nữa — nó là một đồ thị signal dựng tay từ những khối nặng hơn nhiều, nơi một cạnh bị bỏ sót sẽ gây lỗi âm thầm.
Kết quả benchmark 60 panel của chính gpui-fast cũng cho thấy giới hạn: khi mỗi panel sở hữu trạng thái riêng, một thay đổi tiết kiệm 83%; nhưng với mô hình trạng thái chia sẻ, việc lưu giữ làm frame chậm hơn 11%. Đội Zed đo được retention "chậm hơn 1–3% trên khối lượng công việc thông thường" và cảnh báo rằng thắng lợi trên microbenchmark không dự đoán được kết quả trên Zed thực tế.
Bài viết cũng chỉ ra web đã đi qua con đường này: Redux khuyên "ưu tiên nhiều component UI subscribe vào store và đọc dữ liệu ở mức chi tiết hơn", còn MobX khuyên "tạo nhiều observer component nhỏ" — cả hai đều bảo lập trình viên tự tạo ranh giới cho từng phép chiếu vì framework không thể suy ra.
Elm: đơn giản một cách đánh lừa ở quy mô nhỏ
Kiến trúc Elm ở kích thước thường được trình diễn là một mô hình rất dễ hiểu: một giá trị trạng thái, một update, một view. Nhưng với cùng số tin chưa đọc, giá trị đó phải đúng sau mọi thao tác, nên hoặc view tính lại mỗi lần, hoặc update phải duy trì nó.
Khi đó định nghĩa của "số tin chưa đọc" không còn nằm ở một chỗ cụ thể mà rải khắp mọi nhánh xử lý. Trình biên dịch kiểm tra match có đủ nhánh, nhưng không kiểm tra phép tính.
Về mặt hiệu năng, Iced dựng lại toàn bộ view sau mỗi lô message: đổi một nhãn ở 3.000 dòng tạo ra 39.023 widget và mất 7,3 ms. Dùng lazy trên mọi dòng chỉ đưa về 5,7 ms, vì view vẫn phải đi qua từng dòng để băm key. Tác giả lưu ý công bằng rằng trên danh sách ảo hóa, Iced là một trong những framework nhanh nhất được đo, ở mức 0,19 ms.
Một động cơ, nhiều cách nối
Điểm đáng chú ý là Floem có thể được nối theo hai cách trên cùng một động cơ. Ở trường hợp thô, mỗi cell đọc một signal revision toàn cục — tác giả gọi đó là "notify khoác áo signal" — và đây là kết quả chậm nhất trên biểu đồ.
So sánh độ trễ của các framework khi cập nhật một nhãn
Kết luận: signal không cứu được trạng thái thô. Trạng thái phải được định hình theo cách nó được đọc. Đó là lý do tác giả gọi đây là vấn đề của mô hình trạng thái.
Vì sao nên "chạy một lần"
Nếu đã chấp nhận framework cần theo dõi lượt đọc, API đơn giản nhất là để thân component chạy một lần, còn các closure truyền cho framework chạy lại khi giá trị chúng đọc thay đổi. Không có quy tắc hook, không có mảng dependency, không có closure cũ, không cần PartialEq cho props.
Tác giả dẫn lập luận của Dan Abramov rằng "chạy một lần" đẩy logic render vào closure, khiến luồng điều khiển ở cấp cao nhất không còn hoạt động. Ông cũng thừa nhận điểm này, nhưng cho rằng nếu ứng dụng cần độ chính xác đó thì kiểu gì cũng phải tái cấu trúc mã — vấn đề chỉ là làm từ đầu hay làm sau, dưới profiler.
Signal không tự làm ứng dụng nhanh
Phần quan trọng nhất của bài: phản ứng hạt mịn không tự làm ứng dụng nhanh. Floem chạy đúng 5 effect, tốn 0,01 ms cho công việc reactive, rồi mất khoảng 14 ms cho layout vì Taffy không biết một lá đã di chuyển. Vizia chạy 4 binding rồi duyệt toàn bộ 120.091 entity, mất 46 ms. Slint mất 172 ms cho một nhãn.
Tỷ lệ giữ lại và chi phí bộ nhớ của các framework
Sự khác biệt cốt lõi: ở những trường hợp trên, chi phí tuyến tính nằm trong engine, nên có thể sửa mà không đổi API ứng dụng. Ở Iced và egui, chi phí tuyến tính nằm trong hợp đồng API — API nói rằng mã của bạn sẽ chạy — nên không có cải tiến engine nào loại bỏ được.
Tác giả cũng lưu ý mặt trái của signal: chi phí khởi tạo đồ thị dependency không hề miễn phí. Lọc ở 3.000 dòng mất 56 ms với Iced, 67 ms với egui và 123 ms với GPUI, nhưng lên tới 371 ms với zgui, 835 ms với Floem và 982 ms với Freya. Bộ nhớ thường trú cho cảnh lớn cũng cao hơn khi framework lưu giữ nhiều hơn.
Góc nhìn
Với lập trình viên Rust đang cân nhắc viết ứng dụng desktop, bài viết này là một lời nhắc rằng "nhanh" trên demo không đồng nghĩa với mở rộng tốt. GPUI có chi phí frame toàn phần tương đối thấp, đủ tốt cho nhiều trường hợp, và đủ cho một trình soạn thảo có bề mặt đắt đỏ được xử lý thủ công. Nhưng khi giao diện lớn lên, câu hỏi không còn là chọn framework nào nhanh hơn, mà là mô hình trạng thái có cho phép framework biết chính xác cái gì cần chạy lại hay không.
Với các đội đang xây dựng sản phẩm bằng GPUI hay egui, điều thực tế nhất là đo đạc trên đúng khối lượng công việc của mình thay vì tin vào microbenchmark, và thiết kế ranh giới trạng thái theo cách dữ liệu được đọc ngay từ đầu — bởi chi phí tái cấu trúc về sau thường xuất hiện sớm hơn dự kiến.
Nguồn: blog.zortax.de · Quy trình biên tập


