diff --git a/docs/perf-mobile-shell.md b/docs/perf-mobile-shell.md new file mode 100644 index 00000000..4293b737 --- /dev/null +++ b/docs/perf-mobile-shell.md @@ -0,0 +1,115 @@ +# Mobile shell perf — baseline vs perf (OnePlus 6T) + +Device: OnePlus 6T (ONEPLUS A6013), Snapdragon 845, 1080×2340 @ 60 Hz, over USB (`bf0a4730`). +Both builds sit on `fix/hosted-appcard-mobile` @ fedd802 (PR #24): +- **baseline**: `perf/mobile-shell-baseline` @ 7b253a8. The frame monitor, PerfGraph and `[perf]` census only, no fixes. +- **perf**: `perf/mobile-shell` @ 57a3134. The same instrument plus four fix commits. + +## Method +**Two instruments per scenario.** +1. **SurfaceFlinger** (what the display got). `dumpsys SurfaceFlinger --latency 'SurfaceView - dev.makepad.octosense/…#0'`, intervals between actual present times (`sf_sample.py`). +2. **makepad's frame monitor** (`Cx::perf_monitor`), switched on with a battery-icon triple tap. The `[perf]` line every 2 s gives: + - the main-thread gap; + - per-channel ms per shell frame; + - what asked for the frame; + - a renderer census: repaints against scenes drawn. + + Plus one PerfGraph screenshot per scenario (`scratchpad/perf-baseline/`, `scratchpad/perf-perf/`). + +**Procedure.** Driven by `perf_scenarios2.sh `, with gestures injected through `input tap/swipe/motionevent`. The raw logs are `perf-baseline.txt` and `perf-perf.txt`, and the tables come from `perf_tables.py`. + +**Before, for reference.** `perf-before.md` holds the first measurement on the installed feat/mobile-standalone @ 8197abf, SurfaceFlinger only. It reproduces within noise in the baseline column below. + +## Table 1 — SurfaceFlinger (display), baseline vs perf + +| scenario | baseline fps | baseline p50/p95/max ms | baseline >16.7 / >33 | perf fps | perf p50/p95/max ms | perf >16.7 / >33 | +|---|---|---|---|---|---|---| +| idle-home | 56.9 | 16.6 / 17.3 / 66.5 | 22/454 · 3/454 | 56.2 | 16.6 / 33.1 / 66.4 | 24/448 · 5/448 | +| open-app(tap AppCard icon) | 24.3 | 34.6 / 66.7 / 515.9 | 49/96 · 48/96 | 27.3 | 16.7 / 66.6 / 549.3 | 46/108 · 43/108 | +| idle-app-screen | 17.5 | 50.2 / 66.8 / 83.3 | 138/138 · 138/138 | 18.6 | 49.9 / 66.9 / 116.7 | 147/147 · 146/147 | +| swipe-up-home | 42.8 | 16.6 / 66.4 / 83.2 | 32/170 · 25/170 | 40.6 | 16.6 / 66.4 / 83.3 | 36/161 · 30/161 | +| shade-pull+close | 40.1 | 16.6 / 50.1 / 99.8 | 61/199 · 34/199 | 41.1 | 16.7 / 50.0 / 83.1 | 66/204 · 27/204 | +| page-swipe(right then back) | 56.1 | 16.6 / 32.7 / 66.4 | 16/279 · 4/279 | 55.3 | 16.6 / 16.8 / 116.5 | 9/275 · 7/275 | +| recents(swipe-up-hold) | 38.7 | 16.7 / 49.5 / 199.7 | 82/192 · 10/192 | 39.9 | 16.7 / 33.6 / 182.8 | 81/198 · 7/198 | +| recents-idle | 30.4 | 33.3 / 66.6 / 116.9 | 94/120 · 8/120 | 33.7 | 33.3 / 49.9 / 99.8 | 92/133 · 10/133 | +| recents-to-home | 46.1 | 16.6 / 33.5 / 182.9 | 44/183 · 5/183 | 48.6 | 16.6 / 33.5 / 99.7 | 36/193 · 6/193 | +| island-demo(triple-tap clock) | 53.6 | 16.6 / 33.3 / 116.5 | 26/320 · 6/320 | 52.9 | 16.6 / 33.3 / 116.0 | 22/316 · 9/316 | +| group-open(tap Work) | 46.1 | 16.6 / 33.5 / 99.8 | 41/183 · 6/183 | 47.3 | 16.6 / 33.5 / 116.3 | 38/188 · 6/188 | +| group-idle | 41.8 | 16.6 / 33.7 / 149.4 | 51/166 · 7/166 | 44.1 | 16.6 / 49.9 / 83.2 | 46/175 · 11/175 | +| group-close(tap scrim) | 51.1 | 16.6 / 33.5 / 133.5 | 17/152 · 4/152 | 53.7 | 16.6 / 33.3 / 66.6 | 18/160 · 1/160 | + +CPU after *idle-home*: baseline 78.0 % · perf 43.0 % +CPU after *idle-app-screen*: baseline 41.0 % · perf 34.0 % + +## Table 2 — makepad frame monitor (`[perf]`), baseline vs perf + +Gap = main-thread paint-to-paint interval (p50 / p95 / max ms, with (>16.7·>33) counts). ms/frame columns: event · home · module · glass · overlay · shade · groups. Asked by: a=animation g=gesture x=action e=external (nothing in the shell asked). + +| scenario | baseline scenes/s | baseline repaints/s | baseline gap | baseline ms/frame | baseline asked by | perf scenes/s | perf repaints/s | perf gap | perf ms/frame | perf asked by | +|---|---|---|---|---|---|---|---|---|---|---| +| idle-home | 57.0 | 57.0 | 16.5 / 26.2 / 81.9 (102·15) | 4.48 · 4.76 · 0.00 · 0.02 · 0.03 · 0.00 · 0.00 | a513 g0 x0 e0 | 1.0 | 56.3 | 0.0 / 0.0 / 0.0 (0·0) | 20.61 · 18.75 · 0.00 · 0.00 · 0.12 · 0.00 · 0.00 | a0 g0 x0 e10 | +| open-app(tap AppCard icon) | 26.3 | 33.1 | 16.6 / 264.6 / 264.6 (26·18) | 6.10 · 4.62 · 0.78 · 0.02 · 0.03 · 0.00 · 0.00 | a177 g0 x0 e7 | 2.6 | 30.4 | 0.0 / 348.6 / 348.6 (11·11) | 26.00 · 6.57 · 8.21 · 0.00 · 0.11 · 0.00 · 0.00 | a7 g0 x0 e11 | +| idle-app-screen | 1.0 | 17.3 | 0.0 / 0.0 / 0.0 (0·0) | 49.08 · 1.31 · 0.18 · 0.03 · 0.16 · 0.00 · 0.00 | a0 g0 x0 e12 | 1.0 | 18.5 | 0.0 / 0.0 / 0.0 (0·0) | 26.19 · 0.03 · 0.23 · 0.00 · 0.22 · 0.00 · 0.00 | a0 g0 x0 e11 | +| swipe-up-home | 34.8 | 40.2 | 17.3 / 76.4 / 94.5 (57·11) | 5.05 · 4.46 · 0.01 · 0.02 · 0.10 · 0.00 · 0.00 | a203 g3 x0 e3 | 2.9 | 35.0 | 0.0 / 478.0 / 478.0 (15·7) | 15.43 · 6.81 · 0.12 · 0.04 · 0.54 · 0.00 · 0.00 | a11 g3 x1 e8 | +| shade-pull+close | 43.3 | 43.3 | 16.8 / 66.9 / 102.4 (97·55) | 5.32 · 4.96 · 0.00 · 0.02 · 0.04 · 0.40 · 0.00 | a298 g5 x0 e0 | 6.1 | 46.1 | 17.3 / 380.5 / 380.5 (28·20) | 9.30 · 5.70 · 0.00 · 0.04 · 0.06 · 1.83 · 0.00 | a18 g4 x8 e19 | +| page-swipe(right then back) | 55.2 | 55.2 | 16.5 / 28.2 / 103.1 (81·10) | 6.34 · 1.39 · 0.00 · 0.02 · 0.04 · 0.00 · 0.00 | a319 g12 x0 e0 | 4.0 | 55.1 | 16.6 / 262.4 / 262.4 (10·5) | 18.14 · 8.52 · 0.00 · 0.00 · 0.06 · 0.00 · 0.00 | a1 g9 x8 e10 | +| recents(swipe-up-hold) | 37.9 | 37.9 | 29.0 / 69.9 / 144.8 (199·18) | 9.34 · 2.02 · 0.00 · 0.05 · 0.47 · 0.00 · 0.07 | a193 g110 x0 e0 | 8.7 | 43.0 | 27.6 / 92.8 / 490.9 (69·13) | 15.34 · 1.53 · 0.00 · 0.09 · 0.84 · 0.00 · 0.24 | a1 g67 x0 e10 | +| recents-idle | 30.7 | 30.7 | 29.6 / 96.9 / 115.2 (210·13) | 11.44 · 4.12 · 0.00 · 0.08 · 0.74 · 0.00 · 0.03 | a215 g0 x0 e0 | 1.2 | 33.2 | 26.1 / 26.1 / 26.1 (1·0) | 40.74 · 9.00 · 0.00 · 0.14 · 1.89 · 0.00 · 0.07 | a0 g0 x0 e7 | +| recents-to-home | 43.3 | 43.3 | 16.8 / 32.2 / 142.4 (154·12) | 6.65 · 4.07 · 0.00 · 0.04 · 0.24 · 0.00 · 0.01 | a345 g6 x0 e0 | 3.2 | 45.9 | 0.0 / 93.0 / 371.0 (20·4) | 26.39 · 5.89 · 0.00 · 0.06 · 0.61 · 0.00 · 0.02 | a15 g3 x0 e11 | +| island-demo(triple-tap clock) | 52.5 | 52.5 | 16.6 / 34.0 / 115.1 (99·16) | 4.81 · 4.65 · 0.00 · 0.02 · 0.30 · 0.00 · 0.00 | a425 g0 x0 e0 | 4.4 | 52.7 | 16.3 / 75.0 / 166.6 (5·4) | 19.10 · 7.33 · 0.00 · 0.00 · 1.21 · 0.00 · 0.00 | a20 g0 x6 e5 | +| group-open(tap Work) | 47.4 | 47.4 | 20.7 / 26.0 / 124.5 (248·16) | 5.50 · 4.49 · 0.00 · 0.03 · 0.39 · 0.00 · 0.50 | a422 g0 x0 e0 | 26.0 | 46.5 | 20.4 / 44.0 / 112.5 (82·7) | 4.83 · 4.88 · 0.00 · 0.01 · 0.42 · 0.00 · 0.50 | a153 g0 x2 e1 | +| group-idle | 41.2 | 41.2 | 21.1 / 73.6 / 109.3 (240·11) | 5.85 · 4.52 · 0.00 · 0.03 · 0.39 · 0.00 · 0.95 | a247 g0 x0 e0 | 1.0 | 44.7 | 0.0 / 0.0 / 0.0 (0·0) | 71.17 · 16.11 · 0.00 · 0.09 · 1.12 · 0.00 · 2.60 | a0 g0 x6 e0 | +| group-close(tap scrim) | 49.0 | 49.0 | 20.3 / 30.7 / 110.6 (89·8) | 5.00 · 4.34 · 0.00 · 0.03 · 0.37 · 0.00 · 0.32 | a250 g0 x0 e0 | 4.5 | 52.5 | 15.3 / 307.7 / 343.4 (4·3) | 19.24 · 8.53 · 0.00 · 0.03 · 0.47 · 0.00 · 0.68 | a20 g0 x6 e1 | + +scenes/s = phone scenes the shell drew; repaints/s = window repaints the renderer did (census). + +**How to read Table 2's ms/frame columns.** They are per *shell* frame. When the shell draws only 1 scene/s, the time of every event in that second lands on that one frame, so a larger number there is not a slower frame. + +## What the numbers say +### The shell now draws on demand +- **Idle screens.** The phone scene went from 57 → 1.0 draws/s on idle Home, 30.7 → 1.2 on idle Recents, and 41.2 → 1.0 on the idle group window. +- **Why they were busy.** On baseline every idle frame was asked for by the animation loop (`asked by: anim`), which re-armed only to drift the wallpaper. On perf nothing in the shell asks. +- **Transitions.** Frames are drawn only while something moves: the swipe home 34.8 → 2.9 scenes/s, the page swipe 55.2 → 4.0, the island demo 52.5 → 4.4. + +### CPU +- **Idle Home:** 78 % → 43 %. +- **Idle App screen:** 41 % → 34 %. + +### What the display still gets +**SurfaceFlinger** is essentially unchanged: ~56 presents/s on idle Home on both builds, and the same 33–66 ms spikes during transitions. + +**The cause is makepad, not the shell.** The census shows the renderer repainting the window at display rate on both builds (repaints/s ≈ presents) while the perf shell draws 1 scene/s. Sampled directly on the idle phone: +- `demo_time_repaint` is set on every tick; +- no pass is dirty and no repaint is requested; +- `retirement-debt: allocations` every time. + +**The mechanism.** makepad's retained-upload allocation ledger never settles on the GL backend, so `retire_free_items` sets `demo_time_repaint` on every render (`opengl.rs:409`). That makes `compute_pass_repaint_order` repaint every pass recorded in the latest redraw. + +**What was ruled out.** +- The worker pool is healthy. +- Dropping the only per-frame timer before the app doesn't change the repaints. +- Neither does drawing a single flat quad for the whole scene (desktop repro). +- No drawn shader reads the pass time. + +This is **upstream** (the makepad fork) and unresolved here. + +### What the shell controls +**The cost of each forced repaint.** +- **Baseline:** three full-screen passes: the window, the `WmScene` framebuffer cache, and the desktop wallpaper `CachedView`. +- **Perf:** the window pass alone. The census's hot list on idle Home is `pass#15/window` only. + +### Why the 66 ms frames on the App screen remain +The idle App screen (17–18 presents/s, every interval ≥ 33 ms) is the hosted AppCard presenting on its own. Its frames are in makepad's repaint path, not the shell's (1 scene/s on both builds). + +## Visual check +Home, shade (pulled open) and Recents were captured on both builds with the monitor off: +- baseline: `scratchpad/regression-baseline/{home,shade,recents}.png` +- perf: `scratchpad/regression/{home,shade,recents}.png` + +They match, apart from the wallpaper's drift position. + +**A flip that is not a regression.** On both builds the frosted backdrop under the shade and the Recents overview samples the scene **upside down**: a mirrored clock and mirrored tiles show through at the bottom. It is identical on the baseline, so it predates these commits. It is the GL snapshot orientation the desk code already notes ("the final-glass snapshot is upside down on GL"), and it is left for a separate fix. + +## Device housekeeping +- **What the run changed.** It set `svc power stayon true` and `settings put system screen_off_timeout 1800000`. +- **Restored at the end.** `settings put system screen_off_timeout 30000` (the original value) and `svc power stayon false`. diff --git a/src/desk.rs b/src/desk.rs index 85a8154d..de2881e3 100644 --- a/src/desk.rs +++ b/src/desk.rs @@ -810,6 +810,10 @@ pub struct WmDesk { #[rust] desktop_frames: HashMap, #[rust] pub wallpaper: WidgetRef, #[rust] pub compositor: Option, + /// This frame of the phone scene goes through the compositor + /// (`PhoneState::scene_plan`); off, the scene draws straight to the + /// window and the content notes are dropped (desk/phone.rs). + #[rust] pub phone_compose: bool, #[rust] composing: bool, #[rust] terminal_clients: HashSet, #[rust] blur_counts: (usize, usize), diff --git a/src/desk/phone.rs b/src/desk/phone.rs index f5f37565..150a4e78 100644 --- a/src/desk/phone.rs +++ b/src/desk/phone.rs @@ -111,10 +111,17 @@ impl WmDesk { } if let Some(item)=self.item(cx,client) { with_tile_host(&item,|tile| {tile.set_target_size(Some(rect.size));tile.set_close_crop(None);tile.set_fade(1.0);}); + let t=std::time::Instant::now(); item.draw_walk_all(cx,scope,Walk::abs_rect(rect)); + if crate::mobile_perf::enabled() {let ch=crate::mobile_perf::channels(cx.cx);crate::mobile_perf::span(cx.cx,ch.module,t);} } capture.frame.end(cx); - self.compositor.as_mut().unwrap().content_pass(capture.frame.pass_id()); + if self.phone_compose {self.compositor.as_mut().unwrap().content_pass(capture.frame.pass_id());} + } + /// A composited object covered `rect` this frame: the compositor's + /// backdrop reuse needs to know, on the frames that compose. + pub(crate) fn phone_content(&mut self,rect:Rect) { + if self.phone_compose {if let Some(c)=self.compositor.as_mut() {c.content(rect);}} } /// A group member's live look (mobile_groups.rs): its compact capture, /// else its full one, fitted into `cell` by aspect. False without one. @@ -138,7 +145,7 @@ impl WmDesk { self.draw_phone.radius=radius; self.draw_phone.y_flip=0.0; self.draw_phone.draw_abs(cx,rect); - self.compositor.as_mut().unwrap().content(rect); + self.phone_content(rect); } /// The live tiles on the home page: each tile client's compact capture, /// or a placeholder card while it builds, starts, or has not confirmed @@ -200,11 +207,12 @@ impl WmDesk { ("Tap to open", String::new()) } else { crate::mobile_tiles::placeholder_text(&status,connected,gave_up) }; self.phone_ui.draw_tile_placeholder(cx,crate::mobile_tiles::TileSlot{rect:shown_rect,..slot},style,dark,opacity,headline,&detail); - self.compositor.as_mut().unwrap().content(shown_rect); + self.phone_content(shown_rect); } } } pub(super) fn draw_phone_scene(&mut self,cx:&mut Cx2d,scope:&mut Scope,full:Rect) { + crate::mobile_perf::frame_boundary(cx.cx); let state=scope.data.get_mut::().unwrap(); // The wallpaper fills the desk; the shell lays out inside the // platform's safe area (the notch, the system bars): the status bar @@ -237,24 +245,46 @@ impl WmDesk { let style=state.style.target; let dark=state.style.dark; let app=mobile::app_rect(screen); - self.compositor.get_or_insert_with(||BackdropCompositor::new(cx)).begin(cx); + // What this frame needs (mobile.rs): the compositor only when a + // frosted surface samples the scene, the wallpaper and the home + // page only while an open app does not cover them. + let plan=phone.scene_plan(style==crate::desktop::DesktopStyle::Ios); + self.phone_compose=plan.compose; + let perf=crate::mobile_perf::enabled(); + let ch=crate::mobile_perf::channels(cx.cx); + let mut clock=std::time::Instant::now(); + if plan.compose {self.compositor.get_or_insert_with(||BackdropCompositor::new(cx)).begin(cx);} self.phone_ui.begin(); - self.phone_ui.draw_wallpaper(cx,full,style,dark,phone.wallpaper_time); - self.compositor.as_mut().unwrap().content(full); - let home_backdrop=if style==crate::desktop::DesktopStyle::Ios && phone.openness<0.999 { - Some(self.compositor.as_mut().unwrap().backdrop(cx,PhoneSurface::home_dock(screen),4.0)) + if plan.wallpaper { + self.phone_ui.draw_wallpaper(cx,full,style,dark,phone.wallpaper_phase); + } else { + // Under an open app only the system-bar strips can show: the + // status bar's own colour, one flat quad. + self.phone_ui.rounded(cx,full,0.0,if dark {crate::shell::rgb(24,24,28)}else{crate::shell::rgb(248,248,252)}); + } + self.phone_content(full); + let home_backdrop=if plan.compose && style==crate::desktop::DesktopStyle::Ios && phone.openness<0.999 { + let t=std::time::Instant::now(); + let b=self.compositor.as_mut().unwrap().backdrop(cx,PhoneSurface::home_dock(screen),4.0); + if perf {crate::mobile_perf::span(cx.cx,ch.glass,t);} + Some(b) }else{None}; - self.phone_ui.draw_home(cx,state,screen,home_backdrop); - self.compositor.as_mut().unwrap().content(screen); - if phone.home_visible() {self.draw_home_tiles(cx,scope,screen);} + if plan.home { + self.phone_ui.draw_home(cx,state,screen,home_backdrop); + self.phone_content(screen); + } + if plan.home && phone.home_visible() {self.draw_home_tiles(cx,scope,screen);} + if perf {crate::mobile_perf::span(cx.cx,ch.home,clock);clock=std::time::Instant::now();} if phone.groups.window_visible() {let state=scope.data.get_mut::().unwrap();self.draw_group_window(cx,state,screen);} + if perf {crate::mobile_perf::span(cx.cx,ch.groups,clock);clock=std::time::Instant::now();} if phone.overview>0.001 { let blur = (phone.overview.clamp(0.0, 1.0) * 3.0) as f32; self.phone_ui.overview_glass.set_blurriness(cx, blur); let backdrop=self.compositor.as_mut().unwrap().backdrop(cx,screen,blur as f64); self.phone_ui.overview_glass.draw_surface_with_backdrop(cx,screen,Some(backdrop),phone.overview as f32); - self.compositor.as_mut().unwrap().content(screen); + self.phone_content(screen); } + if perf {crate::mobile_perf::span(cx.cx,ch.glass,clock);clock=std::time::Instant::now();} let mut excluded:Vec=Vec::new(); let mut order=phone.order.clone(); order.reverse(); @@ -305,7 +335,7 @@ impl WmDesk { // yet: a plain launch card, never the squeezed tile. let app_id=phone.tiles.get(client).map(|t|t.app.clone()).unwrap_or_default(); self.phone_ui.draw_launch_card(cx,display,&app_id,style,dark,opacity,radius); - self.compositor.as_mut().unwrap().content(display); + self.phone_content(display); } } self.phone_frames.insert(client,stored); @@ -316,13 +346,16 @@ impl WmDesk { if phone.screen==PhoneScreen::App && owns_edges.contains(&client) {excluded.push(display);} } } + // The captures' own draws are the `module` channel (record_capture). + if perf {clock=std::time::Instant::now();} // The shade's frosted sheet samples the finished scene here (the // final-glass snapshot is upside down on GL). - let shade_backdrop=if phone.shade.open>0.001 {Some(self.compositor.as_mut().unwrap().backdrop(cx,screen,3.0))}else{None}; - let glass=if phone.keyboard>0.5 { + let shade_backdrop=if plan.compose && phone.shade.open>0.001 {Some(self.compositor.as_mut().unwrap().backdrop(cx,screen,3.0))}else{None}; + let glass=if plan.compose && phone.keyboard>0.5 { Some((Rect {pos:screen.pos+dvec2(0.0,screen.size.y-phone.keyboard-24.0),size:dvec2(screen.size.x,phone.keyboard)},4.0)) }else{None}; - let (backdrop,_,_)=self.compositor.as_mut().unwrap().finish(cx,screen,glass); + let backdrop=if plan.compose {self.compositor.as_mut().unwrap().finish(cx,screen,glass).0} else {None}; + if perf {crate::mobile_perf::span(cx.cx,ch.glass,clock);} let state=scope.data.get_mut::().unwrap(); for r in excluded {state.phone.exclusions.add(r,[false,false,true,true]);} if phone.keyboard>0.5 { diff --git a/src/main.rs b/src/main.rs index 6107923d..0769b44d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod mobile_shade; mod mobile_pages; mod mobile_island; mod mobile_groups; +mod mobile_perf; mod scene; mod dock_warp; mod host; @@ -423,6 +424,9 @@ pub struct App { #[rust] stylesheet: Option, #[rust] phone_frame: NextFrame, #[rust] phone_time: f64, + /// The status-bar clock the phone last drew: `phone_tick` redraws when + /// the minute changes (mobile_app.rs). + #[rust] phone_clock_shown: Option, /// The shell gesture recognizer (mobile_gestures.rs): the one owner of /// the finger the phone shell claims. #[rust] phone_gestures: GestureRecognizer, @@ -3731,6 +3735,14 @@ impl App { continue; } if self.island_test_action(cx, name) { i += 2; continue; } + // perf:on / perf:off: the phone shell's frame-time + // reporter (mobile_perf.rs), a `[perf]` line every 2 s. + if let Some(what) = name.strip_prefix("perf:") { + mobile_perf::set_enabled(cx, what.trim() != "off"); + self.animate_phone(cx); + i += 2; + continue; + } match test_action(name) { Some(action) => { log!("wm: --test-action {} -> {:?}", name, action); @@ -4059,6 +4071,7 @@ impl MatchEvent for App { self.pane_links.open_os(cx, AiBus::os_manifest(&Self::registry_apps())); } self.tick = cx.start_interval(1.0); + mobile_perf::init_from_env(cx); // The warm pool's pump. Started even when the pool is off: it // costs one no-op wakeup and keeps the timer id stable. if self.warm_pool.enabled() { @@ -4387,6 +4400,7 @@ impl AppMain for App { return; } } + mobile_perf::saw_event(event); self.phone_animation_event(cx,event); if let Some(ne) = self.style_frame.is_event(event) { if self.state.is_some() { @@ -4569,6 +4583,7 @@ impl AppMain for App { self.explain_first_exec_scan(cx); self.update_status(cx); self.update_bar(cx); + self.phone_tick(cx); // The pool fills itself here: at startup, after an // adoption, and after any death it healed from. One spawn // per second, so a cold desktop never forks four cargo diff --git a/src/mobile.rs b/src/mobile.rs index 36a5e5c2..184587d8 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -22,6 +22,9 @@ pub enum PhoneHit { /// A page indicator dot: jump the home pager there (mobile_pages.rs). Page(i64), Island(crate::mobile_island::IslandHit), + /// The status bar's battery icon: three quick taps switch the + /// frame-time reporter (mobile_perf.rs). + Perf, /// Tile groups (mobile_groups.rs): the tile, a member in its window, /// the window's scrim, a pair's "Open both", a Recents card's split /// button and the split divider. @@ -43,7 +46,13 @@ pub struct PhoneGesture { #[derive(Clone)] pub struct PhoneState { pub clock: String, + /// The frame clock of the last stepped frame (the shade stamps its + /// cards on it). pub wallpaper_time: f64, + /// How far the wallpaper's ribbons have drifted: advances only while + /// the shell animates or a finger is down, so an idle home page never + /// asks for a frame just to move the wallpaper (mobile_app.rs). + pub wallpaper_phase: f64, pub screen: PhoneScreen, pub client: Option, pub order: Vec, @@ -93,7 +102,7 @@ pub struct PhoneState { } impl Default for PhoneState { fn default() -> Self { - Self { clock: "9:41".into(), wallpaper_time: 0.0, screen: PhoneScreen::Home, client: None, order: Vec::new(), + Self { clock: "9:41".into(), wallpaper_time: 0.0, wallpaper_phase: 0.0, screen: PhoneScreen::Home, client: None, order: Vec::new(), openness: 0.0, overview: 0.0, page: 0.0, dismiss_y: 0.0, gesture: None, touch: None, keyboard: 0.0, keyboard_target: 0.0, keyboard_sent_height: 0.0, keyboard_client: None, search_query: String::new(), search_focused: false, search_scroll: 0.0, @@ -202,6 +211,33 @@ impl PhoneState { } } +/// What one frame of the phone scene has to set up, from the state alone +/// (desk/phone.rs follows it). The backdrop compositor — a full-screen +/// off-screen scene pass plus a full-screen copy, and a blur pyramid per +/// glass — is only worth paying for on a frame where some frosted surface +/// samples the scene; the wallpaper and the home page are only worth +/// drawing while they can be seen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScenePlan { + /// Route the scene through the backdrop compositor. + pub compose: bool, + /// Draw the wallpaper (else a flat fill under the system bars). + pub wallpaper: bool, + /// Draw the home page's chrome and tiles. + pub home: bool, +} +impl PhoneState { + pub fn scene_plan(&self, ios: bool) -> ScenePlan { + let app_settled = self.screen == PhoneScreen::App && self.openness >= 0.999 && self.overview <= 0.001; + let glass = (ios && self.openness < 0.999) + || self.overview > 0.001 + || self.groups.window_visible() + || self.shade.open > 0.001 + || (ios && self.keyboard > 0.5); + ScenePlan { compose: glass, wallpaper: !app_settled, home: !app_settled } + } +} + pub fn phone_size(style: DesktopStyle) -> Vec2d { if style == DesktopStyle::Ios { dvec2(402.0, 874.0) } else { dvec2(412.0, 892.0) } } @@ -242,6 +278,28 @@ mod tests { assert!(!phone.island.shade_open, "the island returns as the sheet closes"); } #[test] + fn the_scene_composes_only_for_glass_and_skips_what_an_open_app_covers() { + let mut phone = PhoneState::default(); + // An idle Android home page: nothing frosted, straight to the window. + assert_eq!(phone.scene_plan(false), ScenePlan { compose: false, wallpaper: true, home: true }); + // iOS's dock is glass: the home page composes. + assert_eq!(phone.scene_plan(true), ScenePlan { compose: true, wallpaper: true, home: true }); + // The shade over the home page samples the scene. + phone.shade.open = 0.5; + assert!(phone.scene_plan(false).compose); + phone.shade.open = 0.0; + // An open, settled app covers the wallpaper and the home page. + phone.activate(3); + for _ in 0..80 { phone.step(1.0 / 60.0); } + assert_eq!(phone.scene_plan(false), ScenePlan { compose: false, wallpaper: false, home: false }); + // …until it starts to leave (a home swipe, Recents). + phone.overview = 0.3; + assert_eq!(phone.scene_plan(false), ScenePlan { compose: true, wallpaper: true, home: true }); + phone.overview = 0.0; + phone.openness = 0.9; + assert_eq!(phone.scene_plan(false), ScenePlan { compose: false, wallpaper: true, home: true }); + } + #[test] fn both_orientations_reserve_system_bars_and_keep_selected_card_inside() { for size in [phone_size(DesktopStyle::Ios), phone_size(DesktopStyle::Android)] { for size in [size, dvec2(size.y, size.x)] { diff --git a/src/mobile_app.rs b/src/mobile_app.rs index 810e2a03..17f99184 100644 --- a/src/mobile_app.rs +++ b/src/mobile_app.rs @@ -31,9 +31,32 @@ impl App { } } pub(super) fn animate_phone(&mut self,cx:&mut Cx) { + crate::mobile_perf::asked(crate::mobile_perf::Reason::Action); self.phone_frame=cx.new_next_frame(); self.redraw_all(cx); } + /// The shell's 1 s tick on a phone: the things that change on their + /// own without a frame loop running — the status-bar clock, an + /// island timer, the shade's relative times, a tile client that is + /// still starting — get one frame when they need one. The frame loop + /// itself (`phone_animation_event`) runs only while something moves + /// or a finger is down; an idle screen draws nothing. + pub(super) fn phone_tick(&mut self,cx:&mut Cx) { + crate::mobile_perf::tick(cx); + if !self.state.as_ref().is_some_and(|s|s.style.target.mobile()) {return;} + let clock=self.state_mut().phone.clock.clone(); + let clock_changed=self.phone_clock_shown.as_ref()!=Some(&clock); + if clock_changed {self.phone_clock_shown=Some(clock);} + let phone=&self.state_mut().phone; + let wake=clock_changed + || phone.island.needs_step() + || phone.shade.open>0.001 + || (phone.home_visible() && phone.tiles.clients().any(|t|!t.tile_ready())); + // A tile client that is still binding, launching or confirming its + // face is followed here instead of on every frame. + if phone.home_visible() {self.sync_home_tiles(cx);} + if wake {self.animate_phone(cx);} + } // -------------------------------------------------------------- // Home tiles (mobile_tiles.rs) @@ -305,6 +328,15 @@ impl App { self.ui.widget(cx,ids!(phone_controls)).set_visible(cx,style.mobile()); } self.ui.widget(cx,ids!(shell_ai_pane)).set_visible(cx,!style.mobile()); + // The desktop's own full-screen layers stay out of the phone's frame: + // the phone paints its wallpaper itself (desk/phone.rs), and the + // scene's texture cache only exists for the desktop styles' + // crossfade. Each is a full-screen pass the renderer repaints on + // every frame it repaints at all — on Android that is every vsync + // while makepad's retained-upload ledger reports retirement debt + // (see mobile_perf.rs, `retirement-debt`), idle or not. + self.ui.widget(cx,ids!(wallpaper)).set_visible(cx,!style.mobile()); + if let Some(mut scene)=self.ui.widget(cx,ids!(scene)).borrow_mut::() {scene.set_caching(cx,!style.mobile());} self.phone_time=0.0; self.animate_phone(cx); } @@ -325,8 +357,10 @@ impl App { // switcher without moving; the frame keeps running while // the recognizer owns a finger so the hold can land. let mut tracking = false; + let mut gesture_reason = false; if self.phone_gestures.active() { tracking = true; + gesture_reason = true; if let Some(out) = self.phone_gestures.tick(frame.time) { log!("wm: gesture {:?}", out); let from = phone.gesture.as_ref().map(|g| g.screen).unwrap_or(phone.screen); @@ -341,8 +375,16 @@ impl App { } let moving = phone.step(dt); crate::mobile_groups::follow(phone); - let wallpaper_visible = phone.screen != PhoneScreen::App || phone.openness < 0.999 || phone.overview > 0.001; - if moving || wallpaper_visible || tracking {self.phone_frame=cx.new_next_frame();} + // The wallpaper's ribbons drift while the shell is alive + // (something animating, a finger down) and hold still on an + // idle screen: the frame loop never runs just for them. It + // used to, and an idle home page cost a full core (see + // scratchpad/perf-before.md: 54 frames/s, 75 % CPU). + if moving || tracking { + phone.wallpaper_phase += dt; + crate::mobile_perf::asked(if gesture_reason {crate::mobile_perf::Reason::Gesture} else {crate::mobile_perf::Reason::Anim}); + self.phone_frame=cx.new_next_frame(); + } // The tiles follow the phone state every frame: a window // takes its compact face only once its dismissal settled. self.sync_home_tiles(cx); @@ -471,6 +513,7 @@ impl App { PhoneHit::Shade(ShadeHit::Toggle(Toggle::DarkMode))=>self.toggle_phone_appearance(cx), PhoneHit::Shade(hit)=>self.state_mut().phone.shade.tap(hit), PhoneHit::Island(hit)=>{if let Some(app)=self.island_hit(hit) {self.phone_action(cx,PhoneHit::App(app));}} + PhoneHit::Perf=>{crate::mobile_perf::battery_tap(cx,host::now());} } self.sync_phone_keyboard(cx); self.sync_home_tiles(cx); diff --git a/src/mobile_groups.rs b/src/mobile_groups.rs index be8fb27b..b4822e52 100644 --- a/src/mobile_groups.rs +++ b/src/mobile_groups.rs @@ -404,7 +404,7 @@ impl WmDesk { let backdrop = self.compositor.as_mut().unwrap().backdrop(cx, window.panel, 4.0); self.phone_ui.group_glass.draw_surface_with_backdrop(cx, window.panel, Some(backdrop), t); self.phone_ui.rounded(cx, window.panel, 28.0, alpha(face, 0.55 * t)); - self.compositor.as_mut().unwrap().content(window.panel); + self.phone_content(window.panel); let label_a = (t * t).max(0.0); self.phone_ui.d.label_elided(cx, window.title, true, 17.0, alpha(ink, label_a), HAlign::Left, &group.name); let client_of = |app: &str| state.clients.iter() @@ -431,7 +431,7 @@ impl WmDesk { let name_r = if live { rect(r.pos.x + size * 0.5 + 16.0, r.pos.y + r.size.y - 26.0, r.size.x - size * 0.5 - 24.0, 20.0) } else { rect(r.pos.x + 6.0, r.pos.y + r.size.y * 0.5 + size * 0.5 - 2.0, r.size.x - 12.0, 22.0) }; if live { self.phone_ui.rounded(cx, rect(name_r.pos.x - 6.0, name_r.pos.y - 2.0, name_r.size.x + 12.0, name_r.size.y + 4.0), 8.0, alpha(face, 0.85 * t)); } self.phone_ui.d.label_elided(cx, name_r, true, 13.0, alpha(ink, label_a), if live { HAlign::Left } else { HAlign::Center }, &name); - self.compositor.as_mut().unwrap().content(r); + self.phone_content(r); if groups.open.is_some() { self.phone_ui.hits.push((r, PhoneHit::GroupApp(group.name.clone(), cell.app.clone()))); } } if let Some(button) = window.open_both { @@ -439,7 +439,7 @@ impl WmDesk { let pressed = self.phone_ui.pressed_hit() == Some(&PhoneHit::OpenBoth(group.name.clone())); self.phone_ui.rounded(cx, button, (button.size.y * 0.5) as f32, alpha(accent, (if pressed { 0.75 } else { 1.0 }) * t)); self.phone_ui.d.label_elided(cx, button, true, 15.0, alpha(rgb(255, 255, 255), label_a), HAlign::Center, "Open both"); - self.compositor.as_mut().unwrap().content(button); + self.phone_content(button); if groups.open.is_some() { self.phone_ui.hits.push((button, PhoneHit::OpenBoth(group.name.clone()))); } } } diff --git a/src/mobile_island.rs b/src/mobile_island.rs index 77823687..4870f658 100644 --- a/src/mobile_island.rs +++ b/src/mobile_island.rs @@ -459,9 +459,13 @@ impl IslandState { if (*value - target).abs() < 0.002 { *value = target; } moving |= *value != target; } + // A deadline within a frame or two is stepped on the frame loop; + // a farther one is the 1 s tick's (`needs_step`), so a demo with a + // 25-minute countdown does not hold the loop at 60 fps. + let soon = |at: f64| at - now < 0.05; moving - || !self.auto_finish.is_empty() - || self.activities.iter().any(|a| a.done() || matches!(a.kind, ActivityKind::Countdown { until } if until - now < 1.5)) + || self.auto_finish.iter().any(|(_, at)| soon(*at)) + || self.activities.iter().any(|a| a.done() || matches!(a.kind, ActivityKind::Countdown { until } if soon(until))) || !self.anim.get().settled } } @@ -726,6 +730,19 @@ mod tests { assert!(!island.finish("lin:p", 21.0)); } + #[test] + fn far_deadlines_wake_through_the_tick_not_the_frame_loop() { + let mut island = IslandState::default(); + island.demo(100.0); + settle(&mut island, 100.5); + // The demo's 25-minute countdown and its 6 s auto-finish are due + // later: the frame loop may stop, the 1 s tick keeps the island. + assert!(!island.step(1.0 / 60.0, 101.0, None), "nothing moves this frame"); + assert!(island.needs_step(), "the tick still wakes it"); + // Within a frame of the auto-finish the loop runs it. + assert!(island.step(1.0 / 60.0, 100.0 + 6.0 - 0.02, None)); + } + #[test] fn elapsed_and_countdown_format_as_clocks() { assert_eq!(format_clock(0.0), "00:00"); diff --git a/src/mobile_perf.rs b/src/mobile_perf.rs new file mode 100644 index 00000000..26df30dc --- /dev/null +++ b/src/mobile_perf.rs @@ -0,0 +1,475 @@ +//! The phone shell on makepad's frame monitor (`Cx::perf_monitor`), off +//! unless asked for. +//! +//! Switched on by `--test-action perf:on`, `OCTOSENSE_PERF=1`, or three +//! quick taps on the status bar's battery icon. While on: +//! +//! - the shell's surfaces are app channels of the monitor — `home` +//! (wallpaper, home page, tile placeholders), `module` (hosted apps and +//! tiles drawing into their captures), `glass` (backdrop snapshots, the +//! blur pyramids, the compositor's finish), `overlay` (status bar, island, +//! keyboard, navigation), `shade`, `groups` — next to the platform's own +//! (`event`, and on macOS `draw`/`wait`/`gpu`), so the stacked plot shows +//! where main-thread time goes and the gap strip shows pacing; +//! - the `PerfGraph` widget is drawn topmost in the phone shell +//! (`PhoneSurface::draw_overlay`), on the frames the shell draws anyway — +//! it never schedules frames of its own here, so an idle screen stays idle; +//! - every 2 s the shell's tick logs one `[perf]` line read back from the +//! monitor's ring: frames, p50/p95/max gap, missed 16.7/33 ms frames, the +//! per-channel cost per frame, and what asked for the frames (an +//! animation, a finger, an action, or something outside the shell such +//! as a hosted app redrawing itself). +//! +//! The line ends with a pass census: how many times the platform repainted +//! (`painted_serial` of the newest pass, which is the repaint id) against +//! how many phone scenes were drawn, and which attached passes the latest +//! repaint painted, sampled at every tick. A window that presents without +//! the shell drawing (a capture, blur or cache pass kept dirty from inside) +//! shows up there by name. +//! +//! The Android backend never closes the monitor's frames itself (only +//! macOS calls `frame_boundary`), so the shell does, at the start of every +//! phone scene draw. Only six app channels exist (`PERF_MONITOR_MAX_CHANNELS` +//! minus the built-ins): the island is inside `overlay`. +use makepad_widgets::*; +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +static ENABLED: AtomicBool = AtomicBool::new(false); + +/// Why the shell asked for the frame that was drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + /// `PhoneState::step` still had something moving. + Anim, + /// A finger the recognizer owns (the frame keeps running for the hold). + Gesture, + /// A phone action or pointer event asked for a redraw. + Action, + /// Nothing in the shell asked: a hosted app redrew, or a platform event. + External, +} +const REASONS: [Reason; 4] = [Reason::Anim, Reason::Gesture, Reason::Action, Reason::External]; + +/// The shell's app channels, registered once by name (indexes are stable). +#[derive(Clone, Copy, Debug)] +pub struct Channels { + pub home: PerfChannel, + pub module: PerfChannel, + pub glass: PerfChannel, + pub overlay: PerfChannel, + pub shade: PerfChannel, + pub groups: PerfChannel, +} + +struct Collector { + channels: Option, + reasons: [u32; 4], + pending: Option, + /// `frames_painted` at the last report: the frames since are the new ones. + reported_frames: u64, + last_report: Option, + taps: Vec, + /// The newest `painted_serial` at the last report, and which passes the + /// latest repaint painted at each tick since (name, ticks seen hot). + last_serial: u64, + hot: Vec<(String, u32)>, + census_ticks: u32, + /// Events the app saw in the window: next-frame, signal, timer, + /// actions, draw, other. + events: [u32; 6], + /// Timer events in the window by timer id. + timers: Vec<(u64, u32)>, + /// Tick samples of the backend's repaint flags: demo_time_repaint set, + /// any pass paint_dirty, any repaint_requested, draw items owing an upload. + flags: [u32; 4], +} + +thread_local! { + static COLLECTOR: RefCell = RefCell::new(Collector { + channels: None, reasons: [0; 4], pending: None, reported_frames: 0, last_report: None, taps: Vec::new(), + last_serial: 0, hot: Vec::new(), census_ticks: 0, events: [0; 6], timers: Vec::new(), flags: [0; 4], + }); +} + +/// How often a line is logged. +pub const REPORT_EVERY_S: f64 = 2.0; +/// Three taps on the battery icon inside this window switch the monitor. +/// The island's clock uses the same window: long enough for three +/// `adb shell input tap`s (each one is ~0.7 s of process start on a phone), +/// short enough that ordinary taps on the status bar never add up to three. +pub const TAP_WINDOW_S: f64 = 1.6; +/// A gap longer than this is the shell being idle (it draws on demand), +/// not a late frame: counted apart from the pacing quantiles. +pub const IDLE_GAP_MS: f32 = 500.0; + +pub fn enabled() -> bool { ENABLED.load(Ordering::Relaxed) } + +pub fn channels(cx: &mut Cx) -> Channels { + COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + *c.channels.get_or_insert_with(|| Channels { + home: cx.perf_monitor.channel("home", 0x6aa9ff), + module: cx.perf_monitor.channel("module", 0xff6ad5), + glass: cx.perf_monitor.channel("glass", 0x4fd0c8), + overlay: cx.perf_monitor.channel("overlay", 0xffe06a), + shade: cx.perf_monitor.channel("shade", 0xc0c0ff), + groups: cx.perf_monitor.channel("groups", 0x8aff6a), + }) + }) +} + +pub fn set_enabled(cx: &mut Cx, on: bool) { + let was = ENABLED.swap(on, Ordering::Relaxed); + cx.perf_monitor.set_enabled(on); + if on { channels(cx); } + if was != on { + log!("[perf] monitor {}", if on { "on" } else { "off" }); + COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + c.reasons = [0; 4]; + c.pending = None; + c.reported_frames = cx.perf_monitor.frames_painted(); + c.last_report = Some(Instant::now()); + c.last_serial = pass_rows(cx).iter().map(|r| r.1).max().unwrap_or(0); + c.hot.clear(); + c.census_ticks = 0; + }); + } +} + +/// `OCTOSENSE_PERF=1` in the environment switches the monitor on at start. +pub fn init_from_env(cx: &mut Cx) { + if std::env::var("OCTOSENSE_PERF").is_ok_and(|v| !v.is_empty() && v != "0") { set_enabled(cx, true); } +} + +/// A tap on the battery icon at `now` (seconds): three within +/// `TAP_WINDOW_S` toggle the monitor. True when this tap toggled it. +pub fn battery_tap(cx: &mut Cx, now: f64) -> bool { + let third = COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + c.taps.retain(|t| now - *t < TAP_WINDOW_S); + c.taps.push(now); + if c.taps.len() >= 3 { c.taps.clear(); true } else { false } + }); + if third { set_enabled(cx, !enabled()); } + third +} + +/// The shell asked for the next frame for this reason. The strongest +/// reason wins when several ask before the frame is drawn. +pub fn asked(reason: Reason) { + if !enabled() { return; } + COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + c.pending = Some(match (c.pending, reason) { + (Some(Reason::Gesture), _) | (_, Reason::Gesture) => Reason::Gesture, + (Some(Reason::Anim), _) | (_, Reason::Anim) => Reason::Anim, + _ => reason, + }); + }); +} + +/// Every event the app handles, counted by kind while the monitor is on. +pub fn saw_event(event: &Event) { + if !enabled() { return; } + let i = match event { + Event::NextFrame(_) => 0, + Event::Signal => 1, + Event::Timer(_) => 2, + Event::Actions(_) => 3, + Event::Draw(_) => 4, + _ => 5, + }; + COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + c.events[i] += 1; + if let Event::Timer(te) = event { + match c.timers.iter_mut().find(|(id, _)| *id == te.timer_id) { + Some((_, n)) => *n += 1, + None => c.timers.push((te.timer_id, 1)), + } + } + }); +} + +/// The phone scene starts drawing: close the monitor's frame (the backend +/// does not on Android) and account for who asked. +pub fn frame_boundary(cx: &mut Cx) { + if !enabled() { return; } + #[cfg(not(target_os = "macos"))] + { + let time = cx.seconds_since_app_start(); + cx.perf_monitor.frame_boundary(time); + } + #[cfg(target_os = "macos")] + let _ = &cx; + COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + let reason = c.pending.take().unwrap_or(Reason::External); + c.reasons[REASONS.iter().position(|r| *r == reason).unwrap()] += 1; + }); +} + +/// `since` until now went to `channel`. +pub fn span(cx: &mut Cx, channel: PerfChannel, since: Instant) { + if !enabled() { return; } + cx.perf_monitor.add(channel, since.elapsed().as_micros() as u64); +} + +/// Called from the shell's 1 s tick: every `REPORT_EVERY_S` one line from +/// the monitor's ring, and the counters start over. +pub fn tick(cx: &mut Cx) { + if !enabled() { return; } + let rows = pass_rows(cx); + let line = COLLECTOR.with(|c| { + let mut c = c.borrow_mut(); + let newest = rows.iter().map(|r| r.1).max().unwrap_or(0); + for name in hot_passes(&rows, newest) { + match c.hot.iter_mut().find(|(n, _)| *n == name) { + Some((_, count)) => *count += 1, + None => c.hot.push((name, 1)), + } + } + c.census_ticks += 1; + let (demo, dirty, requested, uploads) = backend_flags(cx); + c.flags[0] += demo as u32; + c.flags[1] += dirty; + c.flags[2] += requested; + c.flags[3] += uploads; + let window = c.last_report.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0); + if window < REPORT_EVERY_S { return None; } + let painted = cx.perf_monitor.frames_painted(); + let new = (painted - c.reported_frames).min(PERF_MONITOR_HISTORY as u64) as usize; + let mut ring = Vec::new(); + cx.perf_monitor.read(&mut ring); + let frames = &ring[ring.len().saturating_sub(new)..]; + let names: Vec = cx.perf_monitor.channels().iter().map(|ch| ch.name.clone()).collect(); + let mut line = report_line(window, frames, &names, c.reasons); + line.push_str(&census_text(newest.saturating_sub(c.last_serial), new, &c.hot, c.census_ticks)); + let f = c.flags; + line.push_str(&format!(" | flags over {} ticks: demo_time={} dirty_passes={} repaint_requested={} upload_pending_items={}", c.census_ticks, f[0], f[1], f[2], f[3])); + c.flags = [0; 4]; + let terms = cx.draw_lists.instance_retirement_terms(); + if !terms.is_empty() { line.push_str(&format!(" | retirement-debt: {}", terms)); } + let ts = time_shaders(cx); + if !ts.is_empty() { line.push_str(&format!(" | time-shaders: {}", ts.join(" "))); } + let e = c.events; + line.push_str(&format!(" | events: next={} signal={} timer={} actions={} draw={} other={}", e[0], e[1], e[2], e[3], e[4], e[5])); + c.events = [0; 6]; + let mut timers = std::mem::take(&mut c.timers); + timers.sort_by(|a, b| b.1.cmp(&a.1)); + if !timers.is_empty() { + line.push_str(" | timers:"); + for (id, n) in timers.iter().take(5) { line.push_str(&format!(" #{}×{}", id, n)); } + } + c.last_serial = newest; + c.hot.clear(); + c.census_ticks = 0; + c.reasons = [0; 4]; + c.reported_frames = painted; + c.last_report = Some(Instant::now()); + Some(line) + }); + if let Some(line) = line { log!("{}", line); } +} + +/// Every pass: its name (with its slot, and `-` when detached), the repaint +/// that last painted it, and whether it is attached to a consumer. +fn pass_rows(cx: &Cx) -> Vec<(String, u64, bool)> { + cx.passes.id_iter().map(|id| { + let pass = &cx.passes[id]; + let attached = !matches!(pass.parent, CxDrawPassParent::None); + let kind = match pass.parent { + CxDrawPassParent::Window(_) => "window", + CxDrawPassParent::DrawPass(_) => "child", + CxDrawPassParent::Xr => "xr", + CxDrawPassParent::None => "-", + }; + let name = if pass.debug_name.is_empty() { "pass" } else { pass.debug_name.as_str() }; + (format!("{name}#{:?}/{kind}", id), pass.painted_serial, attached) + }).collect() +} + +/// Draw calls whose shader reads the pass time (`uses_time`): one of them +/// on screen makes the GL/Metal backends repaint every live pass at display +/// rate (`demo_time_repaint`). Listed by shader, with the pass and how many +/// calls, for the lists recorded in the root's latest redraw. +fn time_shaders(cx: &Cx) -> Vec { + let mut out: Vec<(String, u32)> = Vec::new(); + for list_id in cx.draw_lists.id_iter() { + let list = &cx.draw_lists[list_id]; + for i in 0..list.draw_items.len() { + let Some(call) = list.draw_items[i].kind.draw_call() else { continue }; + let Some(sh) = cx.draw_shaders.shaders.get(call.draw_shader_id.index) else { continue }; + if !sh.mapping.uses_time { continue; } + let key = format!("{:?}@{:?}", sh.debug_id, list.draw_pass_id); + match out.iter_mut().find(|(k, _)| *k == key) { + Some((_, n)) => *n += 1, + None => out.push((key, 1)), + } + } + } + out.into_iter().map(|(k, n)| format!("{k}×{n}")).collect() +} + +/// The backend's own reasons to repaint, sampled now: the time-repaint +/// flag, passes still dirty, passes asked for by name, and draw items that +/// owe the backend another upload frame. +fn backend_flags(cx: &Cx) -> (bool, u32, u32, u32) { + let mut dirty = 0; + let mut requested = 0; + for id in cx.passes.id_iter() { + let pass = &cx.passes[id]; + if matches!(pass.parent, CxDrawPassParent::None) { continue; } + dirty += pass.paint_dirty as u32; + requested += pass.repaint_requested as u32; + } + let mut uploads = 0; + for list_id in cx.draw_lists.id_iter() { + let list = &cx.draw_lists[list_id]; + for i in 0..list.draw_items.len() { + uploads += list.draw_items[i].instance_upload_pending as u32; + } + } + // Freed draw items still waiting for a worker to retire their backing: + // while any are pending the GL backend sets `demo_time_repaint` on + // every render (opengl.rs `retire_free_items`), Metal keeps its loop up. + let retiring = cx.draw_lists.has_pending_instance_retirements() as u32; + (cx.demo_time_repaint, dirty, requested, uploads + retiring * 1_000_000) +} + +/// The attached passes the latest repaint (`newest`) painted. +pub fn hot_passes(rows: &[(String, u64, bool)], newest: u64) -> Vec { + if newest == 0 { return Vec::new(); } + rows.iter().filter(|(_, serial, attached)| *attached && *serial == newest).map(|(name, _, _)| name.clone()).collect() +} + +/// ` | repaints=R scenes=S | hot: name×k/n …` — the platform's repaints in +/// the window against the phone scenes drawn, and how often each pass was +/// in the latest repaint across `ticks` samples. +pub fn census_text(repaints: u64, scenes: usize, hot: &[(String, u32)], ticks: u32) -> String { + let mut s = format!(" | repaints={} scenes={}", repaints, scenes); + if !hot.is_empty() { + let mut hot = hot.to_vec(); + hot.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + s.push_str(" | hot:"); + for (name, count) in hot.iter().take(8) { + s.push_str(&format!(" {}×{}/{}", name, count, ticks)); + } + } + s +} + +/// p50, p95 and max of `v` (zeros when empty). +pub fn quantiles(v: &[f32]) -> (f32, f32, f32) { + if v.is_empty() { return (0.0, 0.0, 0.0); } + let mut s = v.to_vec(); + s.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let q = |f: f64| s[((s.len() as f64 * f) as usize).min(s.len() - 1)]; + (q(0.5), q(0.95), *s.last().unwrap()) +} + +/// The `[perf]` line for `frames` (the ring's newest, oldest first) drawn +/// in a window of `window_s`. `names` are the monitor's channel names in +/// index order; `reasons` count anim/gesture/action/external. +pub fn report_line(window_s: f64, frames: &[PerfMonitorFrame], names: &[String], reasons: [u32; 4]) -> String { + // The first frame after enabling has no gap; an idle pause is not a late frame. + let gaps: Vec = frames.iter().map(|f| f.gap_ms).filter(|g| *g > 0.0 && *g <= IDLE_GAP_MS).collect(); + let idle = frames.iter().filter(|f| f.gap_ms > IDLE_GAP_MS).count(); + let (p50, p95, max) = quantiles(&gaps); + let over16 = gaps.iter().filter(|g| **g > 17.5).count(); + let over33 = gaps.iter().filter(|g| **g > 34.0).count(); + let mut s = format!( + "[perf] {:.1}s: frames={} ({:.1}/s) gap p50={:.1} p95={:.1} max={:.1} ms >16.7={} >33={} idle-gaps={}", + window_s, frames.len(), frames.len() as f64 / window_s.max(1e-6), p50, p95, max, over16, over33, idle, + ); + if !frames.is_empty() { + s.push_str(" | ms/frame:"); + for (i, name) in names.iter().enumerate().take(PERF_MONITOR_MAX_CHANNELS) { + let total: u64 = frames.iter().map(|f| f.channel_us[i] as u64).sum(); + if total == 0 { continue; } + let max = frames.iter().map(|f| f.channel_us[i]).max().unwrap_or(0); + s.push_str(&format!(" {}={:.2}/{:.2}", name, total as f64 / frames.len() as f64 / 1000.0, max as f64 / 1000.0)); + } + } + s.push_str(&format!( + " | asked by: anim={} gesture={} action={} external={}", + reasons[0], reasons[1], reasons[2], reasons[3] + )); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(gap_ms: f32, us: &[(usize, u32)]) -> PerfMonitorFrame { + let mut f = PerfMonitorFrame { gap_ms, ..Default::default() }; + for (i, v) in us { f.channel_us[*i] = *v; } + f + } + + #[test] + fn quantiles_pick_the_middle_the_tail_and_the_worst() { + let v: Vec = (1..=100).map(|x| x as f32).collect(); + assert_eq!(quantiles(&v), (51.0, 96.0, 100.0)); + assert_eq!(quantiles(&[]), (0.0, 0.0, 0.0)); + } + + #[test] + fn the_line_counts_missed_frames_idle_pauses_channels_and_reasons() { + let names: Vec = ["event", "script", "gc", "draw", "wait", "gpu", "home", "module"].iter().map(|s| s.to_string()).collect(); + let frames = [ + frame(0.0, &[(0, 500), (6, 1000)]), // first frame after enabling: no gap + frame(16.6, &[(0, 500), (6, 3000)]), + frame(20.0, &[(0, 500), (6, 2000), (7, 4000)]), + frame(40.0, &[(0, 500)]), + frame(1200.0, &[(0, 500)]), // an idle pause, not a late frame + ]; + let line = report_line(2.0, &frames, &names, [1, 0, 2, 2]); + assert!(line.starts_with("[perf] 2.0s: frames=5 (2.5/s) gap p50=20.0 p95=40.0 max=40.0 ms >16.7=2 >33=1 idle-gaps=1"), "{line}"); + assert!(line.contains(" event=0.50/0.50 home=1.20/3.00 module=0.80/4.00"), "{line}"); + assert!(!line.contains("script="), "silent channels are left out: {line}"); + assert!(line.ends_with("asked by: anim=1 gesture=0 action=2 external=2"), "{line}"); + } + + #[test] + fn the_census_names_attached_passes_the_latest_repaint_painted() { + let rows = vec![ + ("main#DrawPassId(0)/window".to_string(), 120, true), + ("pass#DrawPassId(3)/child".to_string(), 120, true), + ("wm_phone_capture#DrawPassId(7)/-".to_string(), 120, false), // detached: not a consumer + ("gauss_scene#DrawPassId(9)/child".to_string(), 64, true), // painted earlier + ]; + assert_eq!(hot_passes(&rows, 120), vec!["main#DrawPassId(0)/window".to_string(), "pass#DrawPassId(3)/child".to_string()]); + assert!(hot_passes(&rows, 0).is_empty()); + let text = census_text(108, 2, &[("pass#3/child".into(), 2), ("main#0/window".into(), 2), ("x#1/child".into(), 1)], 2); + assert_eq!(text, " | repaints=108 scenes=2 | hot: main#0/window×2/2 pass#3/child×2/2 x#1/child×1/2"); + assert_eq!(census_text(0, 0, &[], 2), " | repaints=0 scenes=0"); + } + + #[test] + fn an_empty_window_still_reports_zero_frames() { + let line = report_line(2.0, &[], &[], [0; 4]); + assert!(line.starts_with("[perf] 2.0s: frames=0 (0.0/s) gap p50=0.0"), "{line}"); + assert!(!line.contains("ms/frame"), "{line}"); + } + + #[test] + fn three_quick_taps_toggle_and_slow_ones_do_not() { + let mut cx = Cx::new(Box::new(|_, _| {})); + // ENABLED is one flag for the process; this test owns it. + set_enabled(&mut cx, false); + assert!(!battery_tap(&mut cx, 10.0) && !battery_tap(&mut cx, 10.2)); + assert!(battery_tap(&mut cx, 10.5) && enabled() && cx.perf_monitor.enabled()); + assert!(!battery_tap(&mut cx, 20.0) && !battery_tap(&mut cx, 22.0) && !battery_tap(&mut cx, 24.0)); + // Three adb taps, ~0.72 s apart, land inside the window. + assert!(!battery_tap(&mut cx, 40.0) && !battery_tap(&mut cx, 40.72) && battery_tap(&mut cx, 41.44) && !enabled()); + assert!(!battery_tap(&mut cx, 50.0) && !battery_tap(&mut cx, 50.72) && battery_tap(&mut cx, 51.44) && enabled()); + assert!(enabled()); + assert!(!battery_tap(&mut cx, 30.0) && !battery_tap(&mut cx, 30.1) && battery_tap(&mut cx, 30.2)); + assert!(!enabled() && !cx.perf_monitor.enabled()); + } +} diff --git a/src/mobile_surface.rs b/src/mobile_surface.rs index 34e2a51a..28ab80c2 100644 --- a/src/mobile_surface.rs +++ b/src/mobile_surface.rs @@ -56,6 +56,7 @@ script_mod! { lensing_strength: 0.0 specular_strength: 0.0 border_alpha: 0.0 } } + perf_graph: PerfGraph {panel_width: 380.0 panel_height: 210.0 panel_margin: 12.0} keyboard_glass: GlassPanel { draw_bg +: { blur_level: 4.0 corner_radius: 0.0 @@ -117,6 +118,10 @@ pub struct PhoneSurface { #[live] key_backspace: DrawSvg, #[live] glass: GaussRoundedView, #[live] keyboard_glass: GaussRoundedView, + /// makepad's frame profiler panel, drawn topmost while the monitor is + /// on (mobile_perf.rs). Never handed events: it draws on the frames + /// the shell draws and schedules none of its own. + #[live] perf_graph: PerfGraph, #[live] pub overview_glass: GaussRoundedView, #[live] pub group_glass: GaussRoundedView, #[rust] pressed: Option, @@ -394,6 +399,9 @@ impl PhoneSurface { } } pub fn draw_overlay(&mut self, cx: &mut Cx2d, state: &WmState, screen: Rect, backdrop: Option) { + let perf=crate::mobile_perf::enabled(); + let ch=crate::mobile_perf::channels(cx.cx); + let mut clock=std::time::Instant::now(); let phone=&state.phone; self.pressed=phone.gesture.as_ref().and_then(|g|g.hit.clone()); let ios=state.style.target==DesktopStyle::Ios; @@ -408,6 +416,8 @@ impl PhoneSurface { self.rounded(cx,rect(screen.pos.x+screen.size.x-38.0,screen.pos.y+(status_h-7.0)*0.5,16.0,7.0),1.5,ink); crate::mobile_shade::status_bar_hits(&mut self.hits,state,screen); crate::mobile_island::draw(cx,&mut self.chrome,&mut self.d,&mut self.icons,&mut self.hits,state,screen); + // The battery icon: three quick taps switch the frame-time reporter. + if phone.shade.open<0.001 {self.hits.push((rect(screen.pos.x+screen.size.x-46.0,screen.pos.y,46.0,status_h),PhoneHit::Perf));} if phone.overview>0.01 { for (index,client) in phone.order.iter().enumerate() { if let Some(slot)=state.clients.get(client) { @@ -420,7 +430,9 @@ impl PhoneSurface { } if phone.order.is_empty() {self.label(cx,screen,"No recent apps",20.0,false,ink);} } + if perf {crate::mobile_perf::span(cx.cx,ch.overlay,clock);clock=std::time::Instant::now();} self.draw_groups_overlay(cx,state,screen); + if perf {crate::mobile_perf::span(cx.cx,ch.groups,clock);clock=std::time::Instant::now();} if phone.keyboard>0.5 {self.draw_keyboard(cx,state,screen,backdrop.clone());} let bottom=rect(screen.pos.x,screen.pos.y+screen.size.y-24.0,screen.size.x,24.0); if phone.screen==PhoneScreen::App || phone.keyboard>0.5 { @@ -435,7 +447,14 @@ impl PhoneSurface { let back=rect(bottom.pos.x+12.0,bottom.pos.y-10.0,40.0,34.0); self.d.icon_centered(cx,Ico::ChevronLeft,back,16.0,nav_ink);self.hits.push((back,PhoneHit::Back)); } + if perf {crate::mobile_perf::span(cx.cx,ch.overlay,clock);clock=std::time::Instant::now();} crate::mobile_shade::draw(cx,&mut self.d,&mut self.chrome,&mut self.icons,&mut self.overview_glass,&mut self.hits,state,screen,backdrop); + if perf { + crate::mobile_perf::span(cx.cx,ch.shade,clock); + // Above the shade, inside the navigation band's top edge. + let pane=rect(screen.pos.x,screen.pos.y,screen.size.x,screen.size.y-24.0); + let _=self.perf_graph.draw_walk(cx,&mut Scope::empty(),Walk::abs_rect(pane)); + } } /// Where the shell keyboard sits while it is up (or sliding up). pub fn keyboard_rect(phone: &PhoneState, screen: Rect) -> Rect { diff --git a/src/scene.rs b/src/scene.rs index 77c5f5a2..2d357c84 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -35,6 +35,18 @@ pub struct WmScene { last: f64, } impl WmScene { + /// Render the subtree through the framebuffer cache (the crossfade's + /// source) or straight into the window. Off, a transition is a cut. + /// `View` reads `texture_caching` only when a script is applied, so the + /// switch goes through `set_optimize`, which forces one re-render. + pub fn set_caching(&mut self, cx: &mut Cx, on: bool) { + if self.view.texture_caching == on { return; } + self.view.texture_caching = on; + self.view.set_optimize(cx, if on { ViewOptimize::Texture } else { ViewOptimize::DrawList }); + if !on { self.frozen.clear(); self.progress = 1.0; } + self.view.redraw(cx); + } + pub fn caching(&self) -> bool { self.view.texture_caching } pub fn cut(&mut self, cx: &mut Cx) { self.frozen.clear(); self.progress = 1.0; diff --git a/src/shell/launcher.rs b/src/shell/launcher.rs index cf31e498..832bddf5 100644 --- a/src/shell/launcher.rs +++ b/src/shell/launcher.rs @@ -75,6 +75,26 @@ fn icon_for(id: &str) -> Option { /// terminal first, then by rarity — a deliberate deviation from omarchy's /// alphabetical provider). The live filter never reorders. pub fn apps() -> Vec { + thread_local! { + static MEMO: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + if let Some(items) = MEMO.with(|m| m.borrow().as_ref().filter(|(at, _)| at.elapsed().as_secs_f64() < APPS_MEMO_S).map(|(_, items)| items.clone())) { + return items; + } + let items = apps_uncached(); + MEMO.with(|m| *m.borrow_mut() = Some((std::time::Instant::now(), items.clone()))); + items +} + +/// How long `apps()` answers from memory. The list depends on two settings +/// files (`wm/launcher.hides`, `wm/apps.splash`) and on which app binaries +/// exist, all read afresh per call before: the phone shell asked several +/// times per frame, so a home page burnt milliseconds of file I/O per +/// frame. A change to those files shows within a second. +pub const APPS_MEMO_S: f64 = 1.0; + +/// The list, read from the settings and the file system right now. +pub fn apps_uncached() -> Vec { let hides = hides(); let items: Vec = clients::registry() .iter() @@ -110,6 +130,17 @@ mod tests { assert!(!is_hidden("libreoffice", &hides)); } + #[test] + fn the_apps_list_is_answered_from_memory_within_a_second() { + let first = apps(); + let again = apps(); + assert_eq!(first.len(), again.len()); + assert!(first.iter().zip(&again).all(|(a, b)| a.id == b.id && a.label == b.label)); + // The memo is the uncached answer, not a stale or reordered one. + let fresh = apps_uncached(); + assert!(first.iter().zip(&fresh).all(|(a, b)| a.id == b.id) && first.len() == fresh.len()); + } + #[test] fn the_apps_provider_keeps_the_curated_order() { let items = apps();