十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

gpui-kit 性能指南:当脚本不再逐帧运行后,Snapshots、`cx.notify()` 与 FPS 的两类谎言

gpui-kit 性能指南:当脚本不再逐帧运行后,Snapshots、`cx.notify()` 与 FPS 的两类谎言 gpui-kit 性能指南当脚本不再逐帧运行后Snapshots、cx.notify()与 FPS 的两类谎言【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit导读本篇基于 gpui-kit 的 Shell 运行时crates/shell展开回答一个核心问题在渲染不再逐帧进入 JavaScript的设计下脚本开销到底由什么决定、如何测量、又如何控制。你将掌握 GPUI Shell 中 View 的 Snapshot 复用机制、cx.notify()的真实语义与合并规则、把大 View 拆成小 View 的量化收益以及如何用runtime.read_metrics()读出的计数器把画面流畅但数据慢半拍这类 FPS 看不出的问题诊断出来。全文以 website/zh-CN/shell/performance.md 为骨架并结合源码与基准测试补充实现级细节。前提脚本不在每一帧里整个性能模型的起点是一个运行时设计主张见 crates/shell/src/view.rs一个 View 的render()并不是每帧都跑。它产出一份描述description存成 Snapshot之后的每一帧都由 Rust 从这份 Snapshot 画出来——转成 GPUI 元素、布局、绘制——全程不再进入 JavaScript。页面上能动的部分事件回调、task、Host 调用才决定脚本何时执行。因此一旦重绘不进入 JavaScript剩余要算的开销就只有两样JavaScript 的开销 一个 View 多久失效一次 × 描述这个 View 要花多少左边不是帧率窗口以 120 Hz 还是 30 Hz 重绘JavaScript 执行的次数完全一样。右边也不是帧率没有人让它失效的 View一次都不执行。更关键的是两样都在你的手里左边是你在哪里调用cx.notify()右边是一次notify背后压了多少界面。这一页剩下的内容讲的都是这两件事以及出问题时怎么分辨是哪一个。源码证据ScriptView的文档注释给出了完整的调度图——dirty view render ─▶ snapshot still valid? ─yes─▶ materialize不进 VM只有 Snapshot 失效时才走 script render() ─▶ publish snapshot ─▶ materialize。而干净窗口帧则通过 GPUI 的 subtree 缓存连 materialize 都跳过crates/shell/src/view.rs。每个 View 都有自己的 SnapshotGPUI Shell 给每一个 JavaScript View 一份属于它自己的 Snapshot这个 View 的render产出的那份描述保存在 Rust 一侧。只要 View 本身没有变化它的 Snapshot 就一直被复用。中间的每一帧都从这份 Snapshot 画出来——转成 GPUI 元素、布局、绘制——全部在 Rust 里完成不执行任何 JavaScript。View 变了 ──▶ render() ──▶ 新的 Snapshot ──▶ 帧 View 没变 ────────────────▶ 已有的那份 Snapshot ──▶ 帧Snapshot 是按 View 存的不是按窗口存的。一个窗口里有一百个 View就有一百份 Snapshot各自独立失效发生了什么会执行什么Watchlist调用cx.notify()Watchlist.render其余什么都不跑父 View 调用cx.notify()父 View 的render。每个子 View 用自己的 Snapshot 回答这一帧this.chart.set_props({ symbol })那个子 View 的update与render。父 View 不重建子 View 的子 View 调用cx.notify()那个子 View 的render。失效不会向上传播主题切换每一个 View——因为 Snapshot 里烘进了它构建时的颜色实现层面的印证从源码结构看这份每 View 一份 Snapshot直接对应ScriptView的结构体字段current: OptionRenderSnapshot保存已发布的描述dirty: bool记录脚本可见状态可能变了由cx.notify()置位、由重建清除theme: OptionThemeSnapshotKey记录这份 Snapshot 是在哪套主题 token 下构建的crates/shell/src/view.rs。主题字段解释了表格最后一行的行为——主题切换会让每个 View 同时执行因为 Snapshot 里烘进了构建时的颜色而换主题等于颜色全部失效。失效的三阶段循环配图下图为一个窗口画成互相嵌套的 View侧栏、一块装着四行每行本身也是 View的自选清单、图表以及装着两个子 View 的详情面板。三个阶段循环。价格跳动时只有 MSFT 那一行被标为「render 在执行」其余每个 View 都从自己已有的 Snapshot 画出来。列表重排时自选清单本身执行而它的四行不执行——父 View 记录的是每个子 View 的一个句柄不是子 View 的描述。主题切换时所有 View 同时执行因为 Snapshot 里烘进了它构建时的颜色把大 View 拆成小 ViewView 是整体重建的内部没有局部重建如果一个 View 的描述有四百个节点那么任何一点变化都会把这四百个节点全部重建一遍无论变化多小。这就是大 View 贵的原因。它画的所有东西共用一份 Snapshot于是变化最频繁的那部分数据会连带让那些从不变化的部分一起失效。在一个行情终端里一个价格动一下图表、侧栏、盘口也会被重新描述一遍——不是因为它们变了而是因为它们和价格在同一个 View 里。拆分就是解法。把各自独立变化的部分用cx.new拆成各自的 View一次变化就只会落到一份 Snapshot 上而不是全部import { View } from gpui-kit; export default class Terminal extends View { init(props, cx) { this.sidebar cx.new(Sidebar); this.watchlist cx.new(Watchlist, { symbols: props.symbols }); this.chart cx.new(PriceChart, { symbol: props.symbols[0] }); this.detail cx.new(Detail, { symbol: props.symbols[0] }); } render() { return h_flex() .child(this.sidebar) .child(this.watchlist) .child(v_flex().child(this.chart).child(this.detail)); } }量化的差距在本页测量的那块 40 行看板上基准对应 crates/shell/src/tests/benchmark.rs 的describing_a_panel_stays_inside_the_frame_budget构造 40 行 × 5 列、约 250 节点的网格描述整块面板要0.315 ms描述其中一行只要0.012 ms——361 个节点对 9 个约二十六分之一。两个容易误解的点嵌套本身几乎不花钱。父 View 为每个子 View 记录的是一个句柄handle不是子 View 的描述。所以界面复杂本身不是性能问题View 太大才是。为了性能而拆指的是拆成 View不是拆成多个插件、多个应用或多个进程。需要第二个应用是因为你想要第二份授权那是 Capabilities 的事而不是因为你想要第二份缓存。只为用户看得见的变化 notifycx.notify()就是这里全部的依赖系统而它只表达一件事我的描述过期了。它不是事件通知把它当事件通知用是让 JavaScript 变贵的最常见方式。行情回调是典型场景onQuote(quote, cx) { this.quotes.set(quote.symbol, quote); cx.notify(); // 每一跳都通知包括没人在看的那些 }如果这个 View 从两千只订阅里只画二十只这句notify会为它根本没画的标的的每一跳付一次完整的面板描述。解法是一个条件不是更快的 renderonQuote(quote, cx) { this.quotes.set(quote.symbol, quote); if (this.visible.has(quote.symbol)) cx.notify(); }同一个想法推出三条规则让变化的那个 View 失效。只属于某个子 View 的状态就应该放在那个子 View 上、在那里 notify而不是放在挂载它的父 View 上。notify 得比帧率还密也不会更贵。见下——手动攒批换不来什么加条件才有用。在 Host 一侧cx.notify()与ScriptView::refresh是两个不同的请求。单纯的notify只是重绘已有的描述。如果 Rust 改的是脚本通过 HostModule 读到的状态那描述已经陈旧只有refresh能说明这一点。见 Hosting。源码证据ScriptView::refresh的文档注释明确写着 This is what Shellcx.notify()means:mydescription may have gone stale并强调 A barecx.notify()alone is a different request——cx.notify()对应再画一遍这个 View不跑脚本而refresh才表示描述已经陈旧crates/shell/src/view.rs。notify 到底做了什么谁在合并它cx.notify()不重建任何东西。它只是在这个 View 上置一个标志表示「我的描述可能过期了」然后请求 GPUI 绘制。重建发生在之后的那一帧里而且只在标志仍然置位时才发生。所以两帧之间的所有 notify 都会合并成一次render——无论它们来自三个事件回调、一个循环里的 task还是 Hostnotify notify notify ──▶ 一帧 ──▶ 一次 render()一个标志置三次等于置一次。什么都没有被丢掉三个回调都执行了状态也都改了它们共享的只是随后那一次重建。这就给失效的开销划了一个上限每个 View 每帧最多一次脚本 render。一秒跳一千次的行情在 120 Hz 的屏幕上最多也只有每秒 120 次 render而不是一千次。这也是为什么滥用notify表现为白做功而不是失控。运行时不在这之上再加任何自己的节流也没有可调的参数。合并来自 GPUI 自己的调度而且它绝不会把重建推迟到下一帧之后——所以它不带来延迟而延迟正是帧率与呈现延迟那一对里的另一半。这份缓存占多少内存一个 View 持有两份描述它已发布的那份以及被它替换掉的那份。留着第二份是因为事件仍可能派发到一个已经被取代的帧上而那一帧需要的回调属于那份较旧的描述。源码证据ScriptView中正是current: OptionRenderSnapshot与previous: OptionRenderSnapshot两个字段。注释解释了previous的用途GPUI can dispatch an event against the elements of a frame that has already been superseded——a click landing between a rebuild and the repaint that follows it保留上一份 Snapshot 就是让它的回调在那个窗口期内仍然可解析crates/shell/src/view.rs。没有第三份。发布新描述时最旧的那份会被丢弃丢弃它同时会退役随它注册的那些回调。所以上限就是每个存活的 View 两份描述而且不随时间累积一个重渲染过一百万次的 View持有的东西和一个只渲染过两次的 View 完全一样。关掉一个面板它的 View 就没了两份描述一起走。这也是「该拆大 View 而不必怕拆」的另一个理由一百个小 View 持有的是一百对小描述加起来仍然只是把这个界面描述了两遍而不是一百遍。帧率与呈现延迟是两类问题一个运行中的界面可能出两种问题而只有一种会体现在 FPS 上渲染帧率 画面流畅吗 状态 → 呈现 状态变了以后多久用户才看得到漏掉一次cx.notify()一帧都不会掉。GPUI 会继续以满帧率重放上一份完好的描述于是 HUD 稳稳地读出 120 FPS而界面显示的东西早就不成立了——然后在四分之一秒后因为某件不相干的事让这个 View 失效画面突然跳一下。所有渲染指标都会把这种情况判为健康。症状哪个数字不对常见原因应用里什么都没变窗口却卡帧率每帧要物化的描述过大或虚拟列表在按行做额外工作见那次实测行情在跑的时候窗口卡帧率和失效频率某个 View 重建得太频繁、太大或两者都有画面很流畅但数据慢半拍呈现延迟某次notify被漏掉、被压在await之后或该用refresh的地方用了 Host 侧的cx.notify()这两件事要分开诊断。FPS 从没掉过并不能证明失效逻辑是对的。怎么读那几个计数器运行时把这两类事件分开计数Host 用runtime.read_metrics()读取。接口本身以及留一个基线再相减得到每秒速率的用法见 观察它花了多少。从源码看read_metrics()返回的是计数器的快照RuntimeMetrics是Copy值类型读取那一刻定格并提供since(earlier)做两次读数的差——因为计数器属于运行时、没有 reset测量某一段就取基线相减crates/shell/src/metrics.rs、crates/shell/src/engine/quickjs/mod.rs。读数它回答什么script_renders()JavaScript 执行了多少次。跟着cx.notify()、hot-reload 与主题切换走永远不跟帧走materializations()Snapshot 变成元素多少次。跟着帧走干净的窗口帧复用缓存的 GPUI 子树不计入mean_script_render()一次描述要花多少包含其中的 Host 调用mean_native()其中有多少是在 HostModule 函数里而不是在描述界面slowest_script_render()这一段里最慢的那一次构建frame_script_calls()从帧路径进入 VM 的次数——只有虚拟列表的 item 渲染器与 Dock 的 chrome 回调会计入structure_repeat_rate()在有上一份描述可比的重建里有多大比例产出了相同的结构——见下实现细节Metrics用Cell而非原子或RefCell注释给出的理由是 VM 与 GPUI 的App都只在主线程且一个可能在重入借用时 panic 的计数器不适合放在渲染路径上crates/shell/src/metrics.rs。计时的instant在 wasm 上会替换掉std::time::Instant因为后者在 wasm 上直接 panic。一份读数的形状说明什么每秒script_renders远高于数据实际变化的频率——notify正在为用户看不见的东西触发。加条件。script_renders正常但mean_script_render高——View 太大。把它拆开。mean_native占了mean_script_render的大部分——成本在描述过程中调用的那些 Host 函数上而不在描述本身。在render之前把它们一次性读进字段不要按节点调用。slowest_script_render远高于均值——某一次构建付了其余各次没付的东西首次渲染物化的一份集合或一个很少走到、却描述得多得多的分支。如果是均值整体在漂那是系统负载不是这个。回归测试可以直接断言这些计数metrics.rs开篇就说明tests/snapshot.rs反复渲染一个干净 View并断言script_renders没有动——把脚本开销跟随应用活动而非帧率这一核心主张变成了可断言、可防回归的数字crates/shell/src/metrics.rs。关于主题读取的一个补充重复调用cx.theme()并不会反复穿越原生 Snapshot 边界。运行时会在一次描述开始前同步一份轻量主题修订组件随后共享同一个冻结的 JavaScript 对象直到语义 token 或外观变化。因此每个组件里读主题只是一次缓存查找而不是重新序列化一遍调色板这一点在 website/shell/performance.md 的英文版中有明确说明。Snapshot 缓存止步于哪里Snapshot 消除的是没有变化的成本它不消除变化很小的成本。一份 Snapshot 把结构和取值一起存着StockRow ├── Symbol(AAPL) ├── Price(230.42) └── Change(1.42%)当价格变成230.51结构完全一样只有一个叶子不同——但要表达这一点唯一的办法就是产出一份新的描述于是整个 View 被重新描述一遍每个div()、每个.gap()、每个.bg()、每一次进入 Rust 的跨越。这就是 dirty render 那条路径行情一快跑的就是它。下图是这条成本曲线的量化对比三条泳道长条按同一比例绘制View 读到的东西没变——没有任何长条不执行任何 JavaScript这一帧从已有的 Snapshot 画出来取值变了也就是今天的情形——无论变化多小整块面板都被重新描述一遍0.315 ms同样的变化若这一行本身是一个留存的 View——0.012 ms约为二十六分之一因为描述的是 9 个节点而不是 361 个可用的杠杆就是本页开头那一个把必须重建的 View 缩小。在上面那块看板上描述整块面板 0.315 ms描述其中一行 0.012 ms——361 个节点对 9 个。把这一行放进它自己的 View就是把前一个数字变成后一个而这是今天就能做的。structure_repeats()与structure_changes()是用来核对这条线划得对不对的。它们统计一次重建产出的结构与被替换那份是否相同——只有其中的取值不同。如果某块面板报出来的比例很低这件事本身就值得知道你以为只有一个数字在变实际上有东西在改变结构。实现细节structure_repeat_rate()只在存在前一份描述的重建上计算首次构建没有前驱两边都不计入Metrics::record_structure在重建时按结构是否重复分别累加。文档注释特别提醒运行时不根据这个比例跳过任何工作——它是用来测量和核对的数字不是运行时行为的开关crates/shell/src/metrics.rs。小结两条杠杆与两类错误把整页收敛成可操作的三句话描述开销 失效频率 × 描述规模两个因子都与帧率无关也都由你的代码决定。左边用条件控制只为用户看得见的变化notify让状态落在它所属的那个 View 上右边用拆分控制把独立变化的部分拆成各自的小 View。FPS 只覆盖两类错误中的一类帧率问题物化过大、虚拟列表逐行做额外工作与呈现延迟问题漏notify、压在await后、该用refresh却用了 Host 侧notify必须分开诊断。读数要回答这一秒发生了什么就用read_metrics()留基线再相减。若要继续深入可以接着读引擎中关于那次实测的细节、Hosting 里 Host 状态刷新与计数器用法的完整 API以及 HostModule 关于 Host 函数如何进入描述路径的说明。【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表