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:
Krzysztof Rudnicki 2026-07-20 12:48:38 +02:00
parent f463e50d67
commit 7a7a7e7e99

View 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.