mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:07:02 +01:00
Document why the Linux desktop build must move off Flutter's embedder
The desktop app is unusably slow at 4K while the phone is fine. Measured it rather than guessing, and the cause is not what the previous session assumed. Flutter's Linux embedder has two independent defects, neither fixable in app code: - It schedules frames at a fixed 60 FPS regardless of the monitor (flutter/flutter#183703, P2, no PR). - Far worse, at 3840x2160 it achieves 18.6 fps while each frame costs only ~3ms of a 16.7ms budget -- ~50ms per frame is dead time in the presentation path. This is the lag actually being reported. The stock `flutter create` counter app collapses identically (20.5 fps vs todo's 20.6 at 4K), and todo's own build time is 0.1-0.4ms everywhere, so no application-level change can help. Impeller does not help either: it changes rasterisation, not presentation. The same Dart code as a web build hits 142.9 fps at the same 4K size on the same machine -- a 7x difference -- so the fix is to drop the Linux desktop embedder, not Flutter or Dart. Also records the agreed migration plan and the Xephyr -glamor test harness, including the two measurement mistakes made along the way (synthetic xdotool input never reached the app; software Xephyr dominated an early 4K run) so the numbers here can be trusted and reproduced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f463e50d67
commit
7a7a7e7e99
278
docs/desktop-performance-findings.md
Normal file
278
docs/desktop-performance-findings.md
Normal file
@ -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:<port>`.
|
||||
- **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.
|
||||
Loading…
Reference in New Issue
Block a user