diff --git a/docs/desktop-performance-findings.md b/docs/desktop-performance-findings.md new file mode 100644 index 0000000..822024c --- /dev/null +++ b/docs/desktop-performance-findings.md @@ -0,0 +1,278 @@ +# Linux desktop performance: measured findings + +Investigation date: 2026-07-20. Machine: Arch, X11 + i3, NVIDIA proprietary +610.43.03, RTX 3090. DP-0 = 3840x2160 @144 Hz (primary), HDMI-0 = 2560x1440 +@144 Hz. Flutter 3.44.6 stable. + +## Conclusion + +**The previous session was right: Flutter's Linux desktop embedder cannot drive +this machine's 4K display, and switching technology for the desktop app is +justified.** + +Two independent defects, neither fixable in application code: + +1. **Hard 60 FPS cap.** Flutter's Linux embedder schedules frames at a fixed + 60 FPS regardless of the monitor (upstream + [#183703](https://github.com/flutter/flutter/issues/183703), P2, no PR). +2. **~20 FPS at 4K.** Far worse, and the actual cause of the reported lag. At + 3840x2160 the app achieves 18.6 fps while each frame costs only ~3 ms of a + 16.7 ms budget. The stock `flutter create` counter app behaves identically, + so this is the toolkit, not todo. + +Application code is not implicated anywhere: build (Dart) time is 0.1-0.4 ms in +every configuration and at every window size measured. + +An earlier revision of this document concluded that a rewrite was **not** +warranted. That conclusion was based only on defect 1 and is superseded by the +4K measurements below. + +## Evidence + +Measured with a temporary in-app instrument (`lib/frame_stats.dart`, armed by +`TODO_FRAME_STATS=1`) reporting frame-to-frame cadence alongside build/raster +times, driven by a perpetual animation so frames are produced continuously. + +| Configuration | Cadence p50 | Achieved | build p50 | raster p50 | +|---|---|---|---|---| +| Release, Skia GL (default) | 16.67 ms | **60.0 fps** | 0.30 ms | 1.50 ms | +| Release, Impeller | 16.67 ms | **60.0 fps** | 0.19 ms | 1.33 ms | +| Release, Impeller + Vulkan | 16.67 ms | **60.0 fps** | 0.26 ms | 1.33 ms | +| Driver vsync off (`__GL_SYNC_TO_VBLANK=0`) | 16.67 ms | **60.0 fps** | 0.28 ms | 1.40 ms | +| picom compositor + Skia GL | 16.67 ms | **60.0 fps** | 0.27 ms | 1.62 ms | +| picom compositor + Impeller | 16.67 ms | **60.0 fps** | 0.30 ms | 1.44 ms | +| **Stock `flutter create` app** (no todo code) | 16.67 ms | **60.0 fps** | 0.12 ms | 1.35 ms | +| **Same Flutter code as a web build, in Chrome** | **7.00 ms** | **142.9 fps** | — | — | + +> **Retracted:** an earlier revision of this table had a "real typing + +> scrolling" row. It was invalid. It used `xdotool type --window`, which sends +> synthetic `XSendEvent` keystrokes that GTK/Flutter ignore; the keystrokes +> actually landed in a different application. That run measured an idle app. +> Interaction is measured properly in the next section instead. + +Confounds on the web row, stated for honesty: it ran on HDMI-0 (1440p@144) with +a game occupying the 4K GPU, which is why its p95 was 13.90 ms. The desktop +60.0 fps lock, by contrast, is clean, jitter-free and monitor-independent, so +the comparison's conclusion holds — but the web number should be re-confirmed on +the 4K monitor with the GPU idle. + +Key readings: + +- Cadence p50 **equals** p95 at exactly 16.67 ms in every desktop run — a hard + lock with zero jitter, not contention. +- Total per-frame work is ~1.8 ms against a 6.94 ms 144 Hz budget. The renderer + has **74% headroom at 144 Hz** and 89% at 60 Hz. +- Under real typing and scrolling, **one** frame exceeded the 144 Hz budget in + 14 seconds and **zero** exceeded 60 Hz. There is no dropped-frame jank. +- The stock counter app behaves identically, so this is not `todo`'s code. +- Neither renderer choice, nor driver vsync, nor a compositor moves the number. + +## Part 2: the interaction lag is a separate, larger problem + +The 60 Hz cap above is real but is **not** what the user is complaining about. +Driven by the user directly (synthetic input having proved unreliable), with an +instrument that logs individual outliers plus event-loop blocking rather than +per-second percentiles: + +- Stalls are **raster-dominated**. Build (Dart) stays at 0.1-0.9 ms throughout; + single frames reach 76 ms of raster at a 1280x1417 window. +- The UI isolate does block occasionally (65-101 ms at startup and on some + interactions), but this is the minority of the problem. +- The user reports the lag is **constant, on every interaction** — which rules + out both GitHub sync (periodic) and shader compilation (one-off, warms up). +- Impeller/Vulkan halved slow frames (18 → 7) and removed the worst 76 ms + spike, but the user perceived **no difference**. + +**Raster cost scales hard with window pixel count**, and the user runs the app +fullscreen on the 4K monitor. Measured under a nested X server: + +| Window | Achieved | build p50/p90 | raster p50/p90 | +|---|---|---|---| +| 1280x720 | 68.5 fps | 0.30 / 0.40 ms | 1.50 / 2.00 ms | +| 3840x2160 | 19.9 fps | 0.40 / 0.50 ms | 8.40 / 44.60 ms | + +Those first 4K figures came from plain (software) Xephyr and overstated raster. +Repeated **fullscreen on the real 3840x2160 monitor**: + +| Window (real display) | Achieved | build p50 | raster p50 | +|---|---|---|---| +| 1280x720 | 68.6 fps | 0.30 ms | 1.50 ms | +| 3840x2160 | **18.6 fps** | 0.30 ms | **2.70 ms** | + +This is the key result, and it is *not* a rasterisation wall. At 4K each frame +costs ~3 ms of a 16.7 ms budget, yet the app achieves 18.6 fps (~54 ms per +frame). Roughly **50 ms per frame is dead time** — neither build nor raster. +The bottleneck is the presentation/swap path, which is also why Impeller never +helped: Impeller changes rasterisation, not presentation. + +### It is Flutter, not todo + +Run through an identical harness, the **stock `flutter create` counter app** — +one spinner on a blank Scaffold, zero todo code — collapses the same way: + +| App | 1280x720 | 3840x2160 | +|---|---|---| +| stock Flutter counter | 68.5 fps | **20.5 fps** | +| todo | 68.5 fps | **20.6 fps** | + +Flutter's Linux embedder manages ~20 fps rendering a **blank screen** at 4K. +Build time is 0.1-0.4 ms throughout. No application-level change can fix this. + +Size curve (harness), showing the cliff begins at 1440p: + +| size | Mpx | fps | frame cost | dead time | +|---|---|---|---|---| +| 1280x720 | 0.92 | 68.5 | 1.70 ms | 12.9 ms | +| 1920x1080 | 2.07 | 60.5 | 2.40 ms | 14.1 ms | +| 2560x1440 | 3.69 | 40.3 | 3.70 ms | 21.1 ms | +| 3840x2160 | 8.29 | 20.6 | 8.80 ms | 39.8 ms | + +Related upstream reports: +[#66463](https://github.com/flutter/flutter/issues/66463) (Linux desktop failure +based on window size on 4K monitors), +[#88996](https://github.com/flutter/flutter/issues/88996) (high frame latency on +Linux desktop). + +## Test harness (for iterating without taking over the user's screens) + +`Xephyr -glamor` runs a nested X server that renders through OpenGL on the real +GPU. Validated against ground truth: it reproduced the 4K symptom at 19.8 fps +versus the real display's 18.6 fps. It inflates raster attribution (7.2 ms vs +2.7 ms real), so use it for pass/fail, **not** for attributing where time goes. + +Plain `Xephyr` without `-glamor` is a software X server and is useless here: it +copies a ~33 MB framebuffer per frame at 4K and dominates the measurement. + +**The harness is validated for the Flutter desktop embedder only.** A Chrome +run inside it reported 10 fps at 4K; measuring the same page on the real display +gave 142.9 fps, confirming that number was a harness artifact (Chrome disables +GPU acceleration on an unrecognised nested display). Never benchmark a browser +inside it. + +## The decisive comparison: same content, same machine, real 4K display + +The identical spinner probe, rendered at 3840x2160 on the real monitor: + +| Renderer | Achieved at 4K | +|---|---| +| Flutter **Linux desktop embedder** | **20.5 fps** | +| Flutter **Web (CanvasKit) in Chrome** | **142.9 fps** (cadence p50 7.00 ms) | + +A **7x difference**, with the same framework and the same Dart code. This is not +a hardware limit, a driver limit, or an application limit — it is specific to +Flutter's Linux desktop embedder. It validates the web route at the size that +actually matters. + +## Why the 60 Hz cap exists + +`libflutter_linux_gtk.so` imports `gdk_monitor_get_refresh_rate` (and GTK +correctly reports 144.00 Hz here — verified independently), but imports **no** +`gdk_frame_clock_*` symbols; the engine falls back to a fixed 60 FPS vsync +waiter. + +This is a known, open upstream limitation: +[flutter/flutter#183703 — "[Proposal] Support high refresh rate (>60) on +Linux"](https://github.com/flutter/flutter/issues/183703). Opened 2026-03-14, +labelled **P2**, **no assignee, no linked PR, no milestone**. Linux is the last +Flutter platform without >60 Hz support. Related: +[#93058](https://github.com/flutter/flutter/issues/93058) (Windows, since +fixed), [#49757](https://github.com/flutter/flutter/issues/49757). + +## Implication for the "switch technology" question + +A rewrite in another language is **not** warranted by the evidence. The same +Flutter framework and the same Dart code reach ~143 fps on this exact machine +when built for web and run in Chrome. Only the Linux *desktop embedder* is +capped. + +The leading candidate is therefore **shipping the desktop app as a Flutter web +build in a Chrome `--app` window**, reusing the UI and domain code. + +**Validated at 4K on the real display: 142.9 fps vs the desktop embedder's +20.5 fps on identical content.** That settles the renderer question. + +What remains unproven is todo's *own* widget tree on web: the probe is a +spinner, and web CanvasKit raster plus WASM/JS build costs differ from the +desktop's 0.30/1.50 ms. Given desktop build time is only 0.3 ms and the web +renderer has 7x the headroom, this is very likely fine — but the proof-of- +concept should build the actual app and re-measure before the port is finished. + +What is reused is the UI and domain logic. The platform-adapter layer needs web +variants throughout: persistence durability under a browser profile, token +storage, device auth, sync-on-background (Page Visibility API), and the +launch mechanism (local server vs `file://`). + +Port surface (the storage seam is small, because `crdt_sync_dart` was designed +for it): + +- `LogStore` is deliberately `dart:io`-free and documented as web-usable; + `FileLogPersistence` is already isolated behind the `crdt_sync_io.dart` + entrypoint. `LogPersistence` is a two-method interface (`read()` / + `write(String)`), so a web implementation over IndexedDB/localStorage is + roughly 15 lines. +- `github_client.dart` has **no** `dart:io` and uses `package:http`; the GitHub + REST API sends permissive CORS headers, so sync works from a browser origin. +- `sqlite_crdt`/`sqflite_common_ffi` remain only for the one-time legacy DB + migration (`note_repository.dart:350`), which does not apply on web and can be + skipped behind `kIsWeb`. +- `path_provider` is only used to choose the file path for `FileLogPersistence`; + unnecessary on web. +- `shared_preferences`, `url_launcher`, `share_plus`, `file_selector`, + `flutter_secure_storage` all have web implementations. + +### Known costs of the web route (both are security regressions) + +1. **The token loses the OS keystore.** Commit `9841aec` deliberately moved the + GitHub token off plaintext and into the OS keyring via + `flutter_secure_storage`. A Chrome `--app` build cannot reach the keyring; + `flutter_secure_storage` on web falls back to WebCrypto-wrapped + `localStorage`. That is materially weaker than what is shipping today and + partially undoes that commit. +2. **Device-flow auth breaks.** GitHub's OAuth device-flow endpoints + (`github.com/login/device/code`) do not send CORS headers, so one-tap device + auth will not work from a browser origin. The settings screen's + paste-a-personal-access-token path would have to become the desktop auth + route — which also means a long-lived PAT living in the weaker storage above. + +These two stack, and they are the strongest argument against the web route. + +## Agreed plan (decided 2026-07-20) + +The desktop app moves to a Flutter **web** build launched in Chrome. Decisions +taken, so the next session can start from here: + +- **Auth:** simple PAT pasted into `localStorage` (via + `flutter_secure_storage_web`). The local-helper design that would have kept + the OS keyring and device-flow auth was considered and **rejected** for + simplicity. Consequences accepted: the token is no longer in the keyring, and + the AES-GCM wrapping is obfuscation only (the wrap key sits in the same + `localStorage`). + - Requires a **classic PAT with "No expiration"** to be a one-time paste. + - The launcher must pin a **fixed port** and a **fixed `--user-data-dir`**, + and call `navigator.storage.persist()`. Any of those changing loses the + token: `localStorage` is keyed by origin and lives in the Chrome profile. +- **The Linux desktop build is deleted**, not kept as a fallback. (Android is + unaffected and remains a normal Flutter mobile build.) +- **Launch:** a wrapper script serves the web build on a fixed port and opens + Chrome with `--app=http://localhost:`. +- **Done-condition:** the real todo app, fullscreen at 4K, sustains **>=100 fps** + while scrolling the notes list, and sync works against `kuhyx/syncs`. + +Still to be specified before coding: the `WebLogPersistence` implementation, +what replaces `path_provider`/`sqflite` on web (the legacy sqlite migration path +at `note_repository.dart:350` does not apply), packaging/`install_arch.sh` +changes, and how the desktop entry launches the wrapper. + +## Alternatives, if the web route is rejected + +Ranked by cost. All require re-implementing the UI, and the two non-Dart options +also require a third `crdt_sync` implementation (it exists today only in Python +and Dart). + +1. **Accept 60 fps.** Zero cost. The app is genuinely smooth *at* 60. +2. **Qt 6 / QML (C++).** Vulkan RHI, real 144 Hz, best NVIDIA/X11 record. Weeks. +3. **Rust + Slint.** Lightweight, but the highest total cost. + +Not recommended: **Tauri** — on Linux it renders via WebKitGTK, which has its +own NVIDIA/X11 DMABUF rendering bugs; it trades one renderer problem for +another.