diff --git a/crates/nsh-platform-macos/src/canvas.rs b/crates/nsh-platform-macos/src/canvas.rs index f0986bf95..d6d9f8b9e 100644 --- a/crates/nsh-platform-macos/src/canvas.rs +++ b/crates/nsh-platform-macos/src/canvas.rs @@ -20,6 +20,15 @@ //! does, and rasterizing one runs on the main thread, where a full replay //! is only recorded for Core Animation's drawing threads. //! +//! Where a canvas draws ([`CanvasState::draws_asynchronously`]): at once on +//! the main thread, unless it paints often ([`FREQUENT`] times a second or +//! more: an animation, live data) and keeps no layer, when Core Animation +//! replays it on its threads, which keeps the main thread free. Drawing +//! there costs graphics memory (~50 MB for the process on macOS 27, held +//! while any canvas draws so) and Metal work per layer, which a canvas +//! redrawn now and then (a chart updated every few seconds) gains nothing +//! from. +//! //! Images are loaded through [`crate::image::load`] when a drawing names //! them, kept per canvas and drawn once loaded, at their natural size in //! pixels as points (as a canvas bitmap's CSS pixels). @@ -29,6 +38,7 @@ use std::{ f64::consts::TAU, rc::Rc, sync::Arc, + time::{Duration, Instant}, }; use nsh_core::{ @@ -81,6 +91,48 @@ pub(crate) struct CanvasState { prefix: RefCell>, /// How often the drawing was painted since it was set. redrawn: Cell, + /// How often it paints ([`CanvasState::draws_asynchronously`]). + cadence: Cadence, +} + +/// How many paints a second make a canvas draw on Core Animation's threads. +const FREQUENT: u32 = 10; + +/// How often a canvas paints, counted a second at a time. +#[derive(Default)] +struct Cadence { + /// When the current second began, and its paints so far. + since: Cell>, + paints: Cell, + /// [`FREQUENT`] paints in the current second, or in the last one. + frequent: Cell, +} + +impl Cadence { + /// A paint at `now`. + fn paint(&self, now: Instant) { + match self.since.get() { + Some(since) if now.duration_since(since) < Duration::from_secs(1) => { + let paints = self.paints.get() + 1; + self.paints.set(paints); + if paints >= FREQUENT { + self.frequent.set(true); + } + } + since => { + // A new second: the last one was slow, or a second without + // paints came between. + if since.is_some_and(|since| { + self.paints.get() < FREQUENT + || now.duration_since(since) >= Duration::from_secs(2) + }) { + self.frequent.set(false); + } + self.since.set(Some(now)); + self.paints.set(1); + } + } + } } /// How often a drawing is painted unchanged before its static part is kept @@ -235,9 +287,16 @@ impl CanvasState { 0 < self.split && self.redrawn.get() >= STABLE } + /// Whether Core Animation draws it on its threads + /// (`drawsAsynchronously`): when it paints often and keeps no layer. + pub(crate) fn draws_asynchronously(&self) -> bool { + self.cadence.frequent.get() && !self.keeps_prefix() + } + /// [`draw`]'s replay; `asynchronous`: `cx` records for Core Animation's /// drawing threads. fn paint(&self, cx: &CGContext, dirty: CGRect, size: CGSize, scale: f64, asynchronous: bool) { + self.cadence.paint(Instant::now()); let canvas = self; let Some(drawing) = canvas.drawing.as_ref() else { return; @@ -1355,6 +1414,41 @@ mod tests { canvas } + #[test] + fn a_canvas_painting_often_draws_asynchronously() { + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + // Redrawn every 2 s: at once. + let rare = Cadence::default(); + for n in 0..5 { + rare.paint(at(n * 2000)); + } + assert!(!rare.frequent.get()); + // An animation at 120 Hz: on Core Animation's threads from its + // tenth frame. + let animated = Cadence::default(); + for n in 0..9 { + animated.paint(at(n * 8)); + } + assert!(!animated.frequent.get()); + animated.paint(at(72)); + assert!(animated.frequent.get()); + for n in 10..240 { + animated.paint(at(n * 8)); + } + assert!(animated.frequent.get()); + // It stops: the next paint, a second on, draws at once again. + animated.paint(at(240 * 8 + 3000)); + assert!(!animated.frequent.get()); + // A canvas keeping its static part draws at once however often. + let mut canvas = sparkline_canvas(5.0); + canvas.cadence.frequent.set(true); + canvas.split = MIN_PREFIX; + assert!(!canvas.draws_asynchronously()); + canvas.redrawn.set(0); + assert!(canvas.draws_asynchronously()); + } + #[test] fn the_static_part_is_drawn_from_its_layer() { // Not before the drawing has been painted unchanged a while. diff --git a/crates/nsh-platform-macos/src/view.rs b/crates/nsh-platform-macos/src/view.rs index 2d6feee71..7633627bc 100644 --- a/crates/nsh-platform-macos/src/view.rs +++ b/crates/nsh-platform-macos/src/view.rs @@ -375,10 +375,10 @@ define_class!( crate::canvas::draw(cx, &state, dirty, size, scale, asynchronous); } CGContext::restore_g_state(c); - // A canvas that keeps its static part redraws little: drawn at - // once, without the drawing threads' cost per layer. + // Where the next paint happens (`canvas.rs`): Core Animation's + // threads for a canvas painting often, at once for the rest. if let Some(canvas) = &state.canvas - && canvas.keeps_prefix() == asynchronous + && canvas.draws_asynchronously() != asynchronous { layer.setDrawsAsynchronously(!asynchronous); } @@ -1197,11 +1197,12 @@ impl NshView { layer.setDelegate(Some(objc2::runtime::ProtocolObject::from_ref(&*drawer))); layer.setAnchorPoint(CGPoint::new(0.0, 0.0)); layer.setNeedsDisplayOnBoundsChange(true); - // Canvases are drawn on Core Animation's threads (paths and - // gradients are costly; measured on the Stocks demo, main-thread - // time per frame went from 3.5 to 1.8 ms); text is cheap to draw - // and drawn at once, which was faster. - layer.setDrawsAsynchronously(self.state().canvas.is_some()); + // Drawn at once: text is cheap to draw, which measured faster than + // asynchronously, and a canvas moves to Core Animation's threads + // once it paints often (`canvas.rs`; paths and gradients are + // costly: on the Stocks demo main-thread time per frame went from + // 3.5 to 1.8 ms). + layer.setDrawsAsynchronously(false); layer.setContentsScale(self.backing_scale()); // Above the decoration behind it (fills, gradients, inset // shadows), below the children and the borders in front. diff --git a/docs/dev/canvas.md b/docs/dev/canvas.md index 25ec305b4..c8cdefe67 100644 --- a/docs/dev/canvas.md +++ b/docs/dev/canvas.md @@ -58,8 +58,9 @@ that operation on runs again (the operations before it still set the state; thei animated part begins a path of its own). macOS keeps that part too, as a `CGLayer` (`canvas.rs` `Prefix`), when it is long (128 operations or more: drawing a layer costs about what replaying a sparkline does) and the drawing has been painted unchanged 30 times (a drawing that keeps changing would rasterize it on the main thread more often than it -draws from it); such a canvas then draws at once instead of asynchronously, where its layer is made, and goes back to -Core Animation's threads when its drawing changes. On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not +draws from it); such a canvas draws at once, where its layer is made. Other canvases draw at once too unless they +paint 10 times a second or more (`canvas.rs` `Cadence`), when Core Animation replays them on its threads +(`docs/dev/macos.md`). On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not drawn until it comes into sight: its animations ask for no frames. Android, the web runtime and the GPUI backend (which rasterizes the drawing, text outlines included, with tiny-skia into a pixmap in device pixels: `nsh-platform-gpui` `canvas.rs`) still redraw the whole canvas. Numbers: diff --git a/docs/dev/macos.md b/docs/dev/macos.md index 328090c7a..5c828459e 100644 --- a/docs/dev/macos.md +++ b/docs/dev/macos.md @@ -57,12 +57,17 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m every frame of a scroll (~165 `viewWillDraw` and backing-store checks per frame in the gallery's Lists demo, ~70% of the frame's main-thread time). Plain sublayers are not visible-rect managed. A content layer is drawn again when its content changes and when its size does (`needsDisplayOnBoundsChange`), not when the view moves (transitions - redrew every moving text on every frame before). Canvases draw asynchronously (`drawsAsynchronously`: Core - Animation replays the drawing on its threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU - ~60% → ~30%); text draws at once, which measured faster than asynchronously. A canvas whose long static part is - kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged 30 times) draws at once instead: each frame draws the - layer and replays the animated rest, and Core Animation's drawing threads, their Metal work and their IOSurface - backing stores cost more than that (the gallery's Canvas page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB). + redrew every moving text on every frame before). Text draws at once, which measured faster than asynchronously. + A canvas draws at once too until it paints 10 times a second or more (an animation, live data: `canvas.rs` + `Cadence`), when it draws asynchronously (`drawsAsynchronously`: Core Animation replays the drawing on its + threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU ~60% → ~30%), and at once again after + a slow second. Drawing asynchronously costs graphics memory (`footprint`'s "Owned physical footprint (unmapped) + (graphics)", ~50–60 MB for the process while any canvas draws so) and Metal work per layer, which a canvas redrawn + every few seconds gains nothing from: three 340×40 charts redrawn every 2 s hold ~21 MB at once against ~83 MB + asynchronously. A canvas whose long static part is kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged + 30 times) draws at once however often it paints: each frame draws the layer and replays the animated rest, which + costs less than the drawing threads, their Metal work and their IOSurface backing stores (the gallery's Canvas + page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB). Contents stay in the screen's format: 8-bit contents (`kCAContentsFormatRGBA8Uint`) measured slower on the ProMotion display, which converts them on every frame. - **No masks on what scrolls, where avoidable** (`visual.rs`): the window server pays for a mask on every frame its diff --git a/docs/dev/performance.md b/docs/dev/performance.md index e69069b54..d271fe6a7 100644 --- a/docs/dev/performance.md +++ b/docs/dev/performance.md @@ -245,6 +245,11 @@ draw a panel. every canvas at once (Stocks p95 ~3.3 → ~7.5 ms, 2% of frames over a refresh: the main thread fills the screen's RGBA16F backing stores), 8-bit contents (canvas phase ~0.45 → ~1.6 ms). + Canvases drawn at once unless they paint often (`canvas.rs` `Cadence`: 10 paints a second): three 340×40 line + charts redrawn every 2 s (a menu-bar panel's) hold ~83 MB of footprint asynchronously and ~21 MB at once, with + ~30% less CPU time over 40 s. The gallery's tour is unchanged in frame time and CPU; peak memory on the pages with + canvases drops (Canvas scrolling ~383 → ~310 MB, Animations ~290 → ~232, Gestures ~279 → ~221). + List binds while scrolling (release host, 120 Hz MacBook Pro; the list scrolled by chained native `scrollToOffset({ animated: true })` every 300 ms, no input; each display link refresh logged with its link timestamp and main-thread time; three runs each, refreshes missed = link gaps of two periods or more). Before = diff --git a/website/docs/performance-report.md b/website/docs/performance-report.md index 7fe8e5549..b7487e324 100644 --- a/website/docs/performance-report.md +++ b/website/docs/performance-report.md @@ -213,7 +213,7 @@ Monitor shows); *resident* adds the pages of the system's shared libraries: Through the gallery's tour the process holds 84–230 MB up to the Canvas page and ~350–360 MB from the Text View page on, with what it caches on purpose (decoded images, bounded at 128 MB; the Text View page's documents) and the -frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~380 MB with all three +frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~310 MB with all three canvases on screen, go when the page closes. ### App size @@ -238,8 +238,10 @@ What makes these numbers: - **Frames only for changes**: the display link ticks while a native animation runs and stops with it; an animation stops with its page (its view's deletion ends its keyframes, a loop stops in its component's `cleanup`). -- **Canvases on Core Animation's threads**: canvases draw asynchronously (`drawsAsynchronously`), and an animated value - redraws only the area the operations reading it cover. +- **Canvases drawn where they cost least**: a canvas that paints often (an animation, live data) draws on Core + Animation's threads (`drawsAsynchronously`), which keeps the main thread free; one redrawn now and then draws at + once, without the graphics memory asynchronous drawing holds. An animated value redraws only the area the + operations reading it cover. - **Static canvas parts kept**: once a drawing stays unchanged, a long part before its first animated operation is kept as a layer; each frame draws that layer and replays only the animated rest, at once on the main thread (the Canvas page's gauge, clock and dot matrix: no GPU work and half the memory). diff --git a/website/zh-CN/docs/performance-report.md b/website/zh-CN/docs/performance-report.md index 2a5750d62..ac7a022e1 100644 --- a/website/zh-CN/docs/performance-report.md +++ b/website/zh-CN/docs/performance-report.md @@ -199,7 +199,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 × 浏览 gallery 的过程中,进程在 Canvas 页之前占用 84–230 MB,从 Text View 页起约 350–360 MB,增长的是它有意保留的缓存(解码后的图片, 上限 128 MB;Text View 页的文档)以及首次使用时加载的框架(网页视图用的 WebKit);Canvas 页的栅格在三个 canvas 都可见时最多约 -380 MB,离开页面即释放。 +310 MB,离开页面即释放。 ### 应用体积 @@ -221,7 +221,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 × 这些数字来自: - **只为变化绘制帧**:display link 在原生动画运行时跳动,并随动画一起停止;动画随页面一起停止(视图删除时它的关键帧动画结束,循环在组件的 `cleanup` 中停止)。 -- **Canvas 在 Core Animation 的线程上绘制**:canvas 异步绘制(`drawsAsynchronously`),动画值变化时只重绘读取它的操作覆盖的区域。 +- **Canvas 在开销最小的地方绘制**:频繁重绘的 canvas(动画、实时数据)在 Core Animation 的线程上异步绘制(`drawsAsynchronously`),不占用主线程;偶尔才重绘的 canvas 直接在主线程上绘制,省下异步绘制占用的显存。动画值变化时只重绘读取它的操作覆盖的区域。 - **保留 canvas 的静态部分**:绘制内容稳定后,第一个动画操作之前较长的部分保留为一个图层;每帧绘制这个图层,只重放动画的其余部分,并直接在主线程上完成(Canvas 页的仪表盘、时钟和点阵:不占 GPU,内存减半)。 - **内容变化时才绘制**:文本和 canvas 绘制在各自的 layer 里,内容或尺寸变化时才重绘,移动或滚动时不重绘。