From 2298bc2e6cebae38079f384663488215cb5e7ad0 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:07 +0800 Subject: [PATCH 01/11] Draw Flutter on the processor with Skia Without a real GPU, the OpenGL that Impeller draws with is Mesa's software rasterizer and its compiler, which take as much memory as the rest of the application. The Linux embedder of Flutter 3.47 draws with Skia on the processor when FLUTTER_LINUX_RENDERER=software, and Impeller has to be off for that: the engine stops with "Impeller DlText cannot be drawn to a Skia canvas" otherwise, which my_application.cc prevents with fl_dart_project_set_enable_impeller. main.cc sets the variable, and GDK_GL=disable so that GDK opens no OpenGL for its windows on Wayland, before GTK starts, unless they are already set. Takes the Home page from 339 to 172 MB of PSS, and the processor time until it is shown from 4 to 5 seconds to 0.6. The engine still creates its OpenGL contexts when it starts (fl_opengl_manager_new in fl_engine.cc), whatever it draws with, so Mesa's compiler is part of the 172 MB. check, e2e and screenshots still pass, and the pages look the same. Measured again with sandbox/sandbox.sh measure. --- flutter/README.md | 34 +++++++++++++++++--------- flutter/linux/runner/main.cc | 6 +++++ flutter/linux/runner/my_application.cc | 2 ++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/flutter/README.md b/flutter/README.md index 83bf697..1fbc206 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -186,20 +186,32 @@ the steps and puts the result next to the other implementations: | Page | RSS | PSS | | --- | ---: | ---: | -| Home | 369 MB | 329 MB | -| Available versions of Node.js (872) | 384 MB | 345 MB | +| Home | 212 MB | 172 MB | +| Available versions of Node.js (872) | 223 MB | 183 MB | The application runs in two processes: the second, `glycin-svg`, is the helper that -gdk-pixbuf starts to load the SVG icon of the window, and has 18 MB of the RSS. In -six runs on an earlier day, with the sandbox images of that day, the PSS was 332 to -342 MB on the Home page and 344 to 369 MB with the versions. - -Software rendering dominates these numbers; measure on the target hardware when -comparing implementations, with +gdk-pixbuf starts to load the SVG icon of the window, and has 18 MB of the RSS. + +`linux/runner/main.cc` sets `FLUTTER_LINUX_RENDERER=software` and `GDK_GL=disable`, +and `my_application.cc` turns Impeller off (the engine of Flutter 3.47 stops with +"Impeller DlText cannot be drawn to a Skia canvas" otherwise): Skia draws on the +processor instead of OpenGL, which maps Mesa's software OpenGL and its compiler when +there is no real GPU. The same build took 339 MB of PSS on the Home page without +them, and 4 to 5 seconds of processor time until the page was shown, against 0.6. +The engine creates its OpenGL contexts when it starts, whatever it draws with +(`fl_opengl_manager_new` in `fl_engine.cc`), so Mesa's compiler is still part of the +172 MB. Without the SVG icon of the window, which `set_window_icon` of +`my_application.cc` loads through gdk-pixbuf and its helper, the application takes +about 18 MB less, in one process; the desktop entry gives the window its icon when +the package is installed. In six runs on an earlier day, with the sandbox images of +that day and OpenGL, the PSS was 332 to 342 MB on the Home page and 344 to 369 MB +with the versions. + +Measure on the target hardware when comparing implementations, with `awk '/^Rss:|^Pss:/' /proc/$(pidof vfox-plus)/smaps_rollup`. Before `measure` existed, the application was measured under WSLg, which renders in software with Mesa llvmpipe too, 10 seconds after start-up with a 1280 × 820 -window, for the process of the application alone: 336 MB of RSS and 320 MB of PSS on -the Home page, against 254 MB and 247 MB for the counter app that `flutter create` -makes, which is what Flutter itself needs. +window, for the process of the application alone, with OpenGL: 336 MB of RSS and +320 MB of PSS on the Home page, against 254 MB and 247 MB for the counter app that +`flutter create` makes, which is what Flutter itself needed with OpenGL. diff --git a/flutter/linux/runner/main.cc b/flutter/linux/runner/main.cc index bd1317b..eda44ad 100644 --- a/flutter/linux/runner/main.cc +++ b/flutter/linux/runner/main.cc @@ -15,6 +15,12 @@ #include "my_application.h" int main(int argc, char** argv) { + // Draw on the processor instead of OpenGL: without a real GPU, OpenGL means + // Mesa's software rasterizer and its compiler, which take as much memory as + // the rest of the application (see docs/memory.md). The engine draws with + // Skia then (my_application.cc), and GDK is told not to open OpenGL either. + g_setenv("FLUTTER_LINUX_RENDERER", "software", FALSE); + g_setenv("GDK_GL", "disable", FALSE); g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } diff --git a/flutter/linux/runner/my_application.cc b/flutter/linux/runner/my_application.cc index d8d1f3c..6634841 100644 --- a/flutter/linux/runner/my_application.cc +++ b/flutter/linux/runner/my_application.cc @@ -91,6 +91,8 @@ static void my_application_activate(GApplication* application) { set_window_icon(window); g_autoptr(FlDartProject) project = fl_dart_project_new(); + // Skia draws to the processor, which Impeller cannot do (see main.cc). + fl_dart_project_set_enable_impeller(project, FALSE); fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); From f5472cd696a4c831c57340cafed10096f5767678 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:14 +0800 Subject: [PATCH 02/11] Draw Flet on the processor like Flutter The Flutter engine of Flet 1.0 (3.44) takes FLUTTER_LINUX_RENDERER=software too, and does not need more for it: it does not stop on the text of Impeller as the engine of 3.47 does. tools/patch_build_template.py, which patches the cached template of flet build for the size of the window, now has main.cc of the template set FLUTTER_LINUX_RENDERER=software and GDK_GL=disable before GTK starts, unless they are already set, as the Flutter implementation does. It checks each patched file on its own, and sees that one is already patched by the new text, not by the absence of the old. Takes the Home page from about 340 to 216 MB of PSS (the same build with OpenGL: 332 to 356 MB in three runs). flet build keeps the project that it generated from the template, so a cache of an older build, such as the vfox-plus-sandbox-flet-build volume of the sandbox, has to be deleted for a changed template to take effect; the README says so. check and e2e still pass. Measured again with sandbox/sandbox.sh measure. --- flet/README.md | 14 +++++-- flet/tools/patch_build_template.py | 61 +++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/flet/README.md b/flet/README.md index 042cd27..75fe833 100644 --- a/flet/README.md +++ b/flet/README.md @@ -165,11 +165,19 @@ software rendering, a 1280 × 800 window. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 378 MB | 346 MB | -| Versions of Node.js listed | 415 MB | 383 MB | +| Home page | 257 MB | 216 MB | +| Versions of Node.js listed | 266 MB | 224 MB | The Flutter engine and the embedded interpreter share nothing with the -libraries of the desktop; compare implementations measured the same way. Only +libraries of the desktop; compare implementations measured the same way. The +patch of the Flet build template (`tools/patch_build_template.py`, which also +sets the size of the window) makes `main` set `FLUTTER_LINUX_RENDERER=software` +and `GDK_GL=disable`: Skia draws on the processor instead of OpenGL, which maps +Mesa's software OpenGL and its compiler when there is no real GPU, and the same +build took about 340 MB of PSS on the Home page instead of 216. `flet build` +keeps the project that it generated from the template, so a cache of an older +build (the `vfox-plus-sandbox-flet-build` volume of the sandbox) has to be +deleted for a changed template to take effect. Only the rows of the available versions that are in view are built ([`src/vfox_plus/ui/widgets/virtual_list.py`](src/vfox_plus/ui/widgets/virtual_list.py)). diff --git a/flet/tools/patch_build_template.py b/flet/tools/patch_build_template.py index 71824be..f27bf3f 100755 --- a/flet/tools/patch_build_template.py +++ b/flet/tools/patch_build_template.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Patches the cached Flet build template so that the Linux window opens at -the default size of the UI specification (1280 x 820). +"""Patches the cached Flet build template of the Linux window: it opens at the +default size of the UI specification (1280 x 820), and draws on the processor. `flet build` renders its Flutter shell from `flet-build-template.zip`, which it downloads once into `~/.flet/cache` (or `$FLET_CACHE_DIR`) and never @@ -24,6 +24,12 @@ of the tests. Patching the cached template sets the size before the first frame, with no resize at run time. +The template also draws with OpenGL, which means Mesa's software rasterizer +and its compiler when there is no real GPU, and takes as much memory as the +rest of the application (docs/memory.md). The patched `main` has the engine +draw with Skia on the processor (`FLUTTER_LINUX_RENDERER`) and GDK leave +OpenGL alone, as the Flutter implementation does. + Run it before `flet build` (the sandbox image and CI do): python3 tools/patch_build_template.py @@ -45,8 +51,20 @@ HEIGHT = 820 """The default window size of the UI specification (`docs/ui-spec.md`).""" -_MEMBER = "build/{{cookiecutter.out_dir}}/linux/my_application.cc" -OLD = "gtk_window_set_default_size(window, 1280, 720);" +_DIRECTORY = "build/{{cookiecutter.out_dir}}/linux/" +SIZE = ( + _DIRECTORY + "my_application.cc", + "gtk_window_set_default_size(window, 1280, 720);", + f"gtk_window_set_default_size(window, {WIDTH}, {HEIGHT});", +) +"""The window size, in the application of the GTK window.""" + +_MAIN = " g_autoptr(MyApplication) app = my_application_new();\n" +_SETTINGS = ' g_setenv("FLUTTER_LINUX_RENDERER", "software", FALSE);\n g_setenv("GDK_GL", "disable", FALSE);\n' +RENDERER = (_DIRECTORY + "main.cc", _MAIN, _SETTINGS + _MAIN) +"""The renderer, which `main` sets before GTK and the engine start.""" + +PATCHES = (SIZE, RENDERER) def cache_path() -> pathlib.Path: @@ -70,27 +88,34 @@ def main() -> None: print(f"Downloaded {path}") with zipfile.ZipFile(path) as archive: members = [(info, archive.read(info.filename)) for info in archive.infolist()] - copies = [data.decode() for info, data in members if info.filename == _MEMBER] - if not copies: - sys.exit(f"{_MEMBER} is not in {path}; the template of Flet {FLET_VERSION} changed.") - new = f"gtk_window_set_default_size(window, {WIDTH}, {HEIGHT});" - if all(OLD not in copy for copy in copies): - if not any(new in copy for copy in copies): - sys.exit(f"Cannot find {OLD!r} in {_MEMBER}; the template of Flet {FLET_VERSION} changed.") - print(f"{path} is already patched.") + pending = [] + for member, old, new in PATCHES: + copies = [data.decode() for info, data in members if info.filename == member] + if not copies: + sys.exit(f"{member} is not in {path}; the template of Flet {FLET_VERSION} changed.") + if new in copies[0]: + print(f"{member} is already patched.") + continue + if old not in copies[0]: + sys.exit(f"Cannot find {old!r} in {member}; the template of Flet {FLET_VERSION} changed.") + pending.append((member, old, new, copies[0])) + if not pending: return - seen = False + replacements = {member: (old, new, copy) for member, old, new, copy in pending} + seen: set[str] = set() with zipfile.ZipFile(path.with_suffix(path.suffix + ".tmp"), "w", zipfile.ZIP_DEFLATED) as archive: for info, data in members: - if info.filename != _MEMBER: + if info.filename not in replacements: archive.writestr(info, data) - elif not seen: + elif info.filename not in seen: # There is only one copy after this rewrite, even if an # earlier run of this script appended one already. - seen = True - archive.writestr(info, copies[0].replace(OLD, new)) + seen.add(info.filename) + old, new, copy = replacements[info.filename] + archive.writestr(info, copy.replace(old, new)) path.with_suffix(path.suffix + ".tmp").replace(path) - print(f"Patched {_MEMBER} in {path}") + for member in replacements: + print(f"Patched {member} in {path}") if __name__ == "__main__": From 71af32a33ec5993dca90e1ef2f91cac7e5e3be0a Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:14 +0800 Subject: [PATCH 03/11] Draw Compose on the processor and compile with C1 only Skiko draws with OpenGL by default, which maps Mesa's software OpenGL and its compiler when there is no real GPU, and the optimizing compiler of the JIT (C2) keeps tens of megabytes of arenas of its own. -Dskiko.renderApi=SOFTWARE has Skia draw on the processor and -XX:TieredStopAtLevel=1 leaves C2 out. Each takes about 50 MB of PSS off the Home page, 253 to 129 MB together, and the processor time until the page is shown halves. The pages look the same. Heap sizes, the class data sharing archive that the jlink runtime of jpackage lacks, and the size of the code cache and of the stacks made no difference, or a worse one. check and e2e still pass (the end-to-end tests run in the test window of Compose, not with these arguments). Measured again with sandbox/sandbox.sh measure. --- compose/README.md | 18 ++++++++++++------ compose/build.gradle.kts | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/compose/README.md b/compose/README.md index bab66e0..d90c41e 100644 --- a/compose/README.md +++ b/compose/README.md @@ -114,9 +114,15 @@ software rendering, a 1280 × 800 window. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 290 MB | 266 MB | -| Versions of Node.js listed | 301 MB | 277 MB | - -The JVM runs with a heap of at most 256 MB and the serial collector; Skia -draws the window itself, so there are no processes of a web view. Compare -implementations measured the same way. +| Home page | 135 MB | 129 MB | +| Versions of Node.js listed | 158 MB | 152 MB | + +The JVM runs with a heap of at most 256 MB, the serial collector and only the +first compiler of its JIT (`-XX:TieredStopAtLevel=1`: the optimizing one keeps +tens of megabytes of its own), and Skia draws on the processor +(`-Dskiko.renderApi=SOFTWARE`, in `build.gradle.kts`). Skiko draws with OpenGL +by default, which maps Mesa's software OpenGL and its compiler when there is no +real GPU: the same build took 253 MB of PSS on the Home page without the two +settings, about half of the difference each, and used about twice the +processor time. Skia draws the window itself, so there are no processes of a +web view. Compare implementations measured the same way. diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 7b1f44d..300ce25 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -218,8 +218,20 @@ compose.desktop { application { mainClass = "dev.vfox.plus.MainKt" // Lets Main.kt name the window class after the application ID; a small - // heap and the serial collector keep the memory of the JVM low. - jvmArgs += listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "-Xms32m", "-Xmx256m", "-XX:+UseSerialGC") + // heap and the serial collector keep the memory of the JVM low, and so + // do the first compiler of the JIT alone (the optimizing one keeps tens + // of megabytes of its own), and Skia on the processor: Skiko draws with + // OpenGL by default, which maps Mesa's software OpenGL and its compiler + // when there is no real GPU. + jvmArgs += + listOf( + "--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", + "-Xms32m", + "-Xmx256m", + "-XX:+UseSerialGC", + "-XX:TieredStopAtLevel=1", + "-Dskiko.renderApi=SOFTWARE", + ) nativeDistributions { packageName = "vfox-plus" packageVersion = version.toString() From c04c9ae802c3812b8cdbbf6abbbc4a8b2c874ed6 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:23 +0800 Subject: [PATCH 04/11] Turn off more of WebKitGTK's memory in Tauri On top of WEBKIT_DISABLE_COMPOSITING_MODE (#20): WEBKIT_DISABLE_DMABUF_RENDERER=1 keeps WebKit from sharing buffers with the GPU, which maps Mesa's software OpenGL and its compiler in the processes of the web view when there is no real GPU, and JSC_useJIT=false leaves out the JIT compilers of the JavaScript engine, which have memory of their own in the web process. The pages of vfox+ need neither: the versions page opens as fast. GDK_GL=disable keeps GDK from opening OpenGL for its windows on Wayland (Tauri on a headless sway: 242 MB of PSS without it, 207 with it; no difference on X11). All four are set in lib.rs before the webview starts, unless they are already set. Takes the Home page from 262 to 208 MB of PSS; check and both e2e scenarios (fake and real) still pass. Measured again with sandbox/sandbox.sh measure. --- tauri/README.md | 22 +++++++++++++--------- tauri/src-tauri/src/lib.rs | 30 ++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/tauri/README.md b/tauri/README.md index 2967ad1..3856920 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -105,12 +105,16 @@ processes together. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 442 MB | 275 MB | -| Versions of Node.js listed | 452 MB | 285 MB | - -`lib.rs` sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` before the webview starts, -unless it is already set: without it, accelerated compositing maps Mesa's -software OpenGL and its compiler, which the pages of vfox+ do not need, and -the same build took 419 MB of PSS on the Home page instead of 275. Software -rendering dominates what is left; compare implementations measured the same -way. +| Home page | 382 MB | 208 MB | +| Versions of Node.js listed | 394 MB | 220 MB | + +`lib.rs` sets these variables before the webview starts, unless they are +already set: `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` +and `JSC_useJIT=false` for WebKitGTK and its JavaScript engine, and +`GDK_GL=disable`, which keeps GDK from opening OpenGL for its windows on +Wayland. Without them, accelerated compositing and the buffers that WebKit +shares with the GPU map Mesa's software OpenGL and its compiler, which the +pages of vfox+ do not need, and the JIT compiler of the web process keeps +memory of its own: the same build took 262 MB of PSS on the Home page instead +of 208 (and 419 MB in the snapshot before any of them was set). Compare +implementations measured the same way. diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index 05a9bfc..d8400da 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -36,17 +36,27 @@ pub fn run() { // Before GTK starts: X11 takes WM_CLASS and Wayland the app ID from it. #[cfg(target_os = "linux")] glib::set_prgname(Some(APP_ID)); - // Accelerated compositing maps Mesa's software OpenGL and its compiler when - // there is no real GPU; the pages of vfox+ do not need it. See - // docs/memory.md and the WebKitGTK documentation of this variable. + // Accelerated compositing, the buffers that WebKit shares with the GPU and + // the OpenGL of GDK on Wayland map Mesa's software OpenGL and its compiler + // when there is no real GPU, and the JIT compiler of the web process keeps + // memory of its own; the pages of vfox+ need none of it. See + // docs/memory.md and the variables of WebKitGTK and of its JavaScript + // engine. #[cfg(target_os = "linux")] - if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { - // Nothing else runs yet (no other thread can read the environment - // while this sets it), so this is the one place an exception to - // `unsafe_code = "deny"` is justified. - #[allow(unsafe_code)] - unsafe { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + for (name, value) in [ + ("WEBKIT_DISABLE_COMPOSITING_MODE", "1"), + ("WEBKIT_DISABLE_DMABUF_RENDERER", "1"), + ("JSC_useJIT", "false"), + ("GDK_GL", "disable"), + ] { + if std::env::var_os(name).is_none() { + // Nothing else runs yet (no other thread can read the environment + // while this sets it), so this is the one place an exception to + // `unsafe_code = "deny"` is justified. + #[allow(unsafe_code)] + unsafe { + std::env::set_var(name, value); + } } } tauri::Builder::default() From 133974519fb014a4f4b8577665c6483ace3fdc65 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:23 +0800 Subject: [PATCH 05/11] Turn off more of WebKitGTK's memory in Dioxus Same as Tauri: WEBKIT_DISABLE_DMABUF_RENDERER=1, JSC_useJIT=false and GDK_GL=disable, with WEBKIT_DISABLE_COMPOSITING_MODE=1, set in main.rs before the webview starts, unless they are already set. Takes the Home page from 261 to 203 MB of PSS; check and both e2e scenarios (fake and real) still pass. Measured again with sandbox/sandbox.sh measure. --- dioxus/README.md | 23 ++++++++++++++--------- dioxus/src/main.rs | 25 +++++++++++++++++-------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/dioxus/README.md b/dioxus/README.md index 3221646..33d53ac 100644 --- a/dioxus/README.md +++ b/dioxus/README.md @@ -122,15 +122,20 @@ processes together. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 434 MB | 258 MB | -| Versions of Node.js listed | 473 MB | 296 MB | - -`main.rs` sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` before the webview starts, -unless it is already set: without it, accelerated compositing maps Mesa's -software OpenGL and its compiler, which the pages of vfox+ do not need, and -the same build took 427 MB of PSS on the Home page instead of 258. Software -rendering dominates what is left; compare implementations measured the same -way. Only the rows of the available versions that are in view are rendered. +| Home page | 385 MB | 203 MB | +| Versions of Node.js listed | 402 MB | 220 MB | + +`main.rs` sets these variables before the webview starts, unless they are +already set: `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` +and `JSC_useJIT=false` for WebKitGTK and its JavaScript engine, and +`GDK_GL=disable`, which keeps GDK from opening OpenGL for its windows on +Wayland. Without them, accelerated compositing and the buffers that WebKit +shares with the GPU map Mesa's software OpenGL and its compiler, which the +pages of vfox+ do not need, and the JIT compiler of the web process keeps +memory of its own: the same build took 261 MB of PSS on the Home page instead +of 203 (and 427 MB in the snapshot before any of them was set). Compare +implementations measured the same way. Only the rows of the available versions +that are in view are rendered. Dioxus Native (the experimental [Blitz](https://github.com/DioxusLabs/blitz) renderer, WGPU, no system webview) was tried, to see whether dropping diff --git a/dioxus/src/main.rs b/dioxus/src/main.rs index f88ac9d..0012eaa 100644 --- a/dioxus/src/main.rs +++ b/dioxus/src/main.rs @@ -29,15 +29,24 @@ const APP_ID: &str = "dev.vfox.VfoxPlus"; fn main() { glib::set_prgname(Some(APP_ID)); glib::set_application_name("vfox+"); - // Accelerated compositing maps Mesa's software OpenGL and its compiler when - // there is no real GPU; the pages of vfox+ do not need it. See - // docs/memory.md and the WebKitGTK documentation of this variable. Nothing - // else runs yet, so this is the one place an exception to + // Accelerated compositing, the buffers that WebKit shares with the GPU and + // the OpenGL of GDK on Wayland map Mesa's software OpenGL and its compiler + // when there is no real GPU, and the JIT compiler of the web process keeps + // memory of its own; the pages of vfox+ need none of it. See + // docs/memory.md and the variables of WebKitGTK and of its JavaScript + // engine. Nothing else runs yet, so this is the one place an exception to // `unsafe_code = "deny"` is justified. - if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { - #[allow(unsafe_code)] - unsafe { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + for (name, value) in [ + ("WEBKIT_DISABLE_COMPOSITING_MODE", "1"), + ("WEBKIT_DISABLE_DMABUF_RENDERER", "1"), + ("JSC_useJIT", "false"), + ("GDK_GL", "disable"), + ] { + if std::env::var_os(name).is_none() { + #[allow(unsafe_code)] + unsafe { + std::env::set_var(name, value); + } } } // The first frame already has the colours of the stored settings; the From 84acde344aeb16a85cdf82b0defb4efd5f61c3fa Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:23 +0800 Subject: [PATCH 06/11] Turn off more of WebKitGTK's and GTK 4's memory in Wails WEBKIT_DISABLE_DMABUF_RENDERER=1 and JSC_useJIT=false, as in Tauri and Dioxus. GTK 4 also draws its own window with OpenGL (its ngl renderer), which maps Mesa's software OpenGL and its compiler in the application when there is no real GPU: GDK_DISABLE=gl,vulkan keeps GDK from opening OpenGL and Vulkan, and GSK_RENDERER=cairo has GTK draw with Cairo. That was most of what WebKitGTK 6.0 with GTK 4 needed more than WebKitGTK 4.1 with GTK 3: Wails is 15 MB above Tauri now, not 71 (building with the gtk3 tag, the legacy path of Wails 3, would save 9 MB more). All five variables are set in main.go before the application starts, unless they are already set. Takes the Home page from 341 to 223 MB of PSS; check and both e2e scenarios (fake and real) still pass, and on a headless sway the pages are drawn as before. Measured again with sandbox/sandbox.sh measure. --- wails/README.md | 28 ++++++++++++++++------------ wails/main.go | 21 ++++++++++++++++----- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/wails/README.md b/wails/README.md index 605bf12..0a02891 100644 --- a/wails/README.md +++ b/wails/README.md @@ -115,15 +115,19 @@ processes together. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 525 MB | 346 MB | -| Versions of Node.js listed | 538 MB | 358 MB | - -`main.go` sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` before the application -starts, unless it is already set: without it, accelerated compositing maps -Mesa's software OpenGL and its compiler, which the pages of vfox+ do not -need, and the same build took 482 MB of PSS on the Home page instead of 346. -WebKitGTK 6.0 with GTK 4 still needs more than the WebKitGTK 4.1 with GTK 3 of -the [Tauri implementation](../tauri/README.md#memory) for the same front end -(275 MB, also with compositing off), by a larger margin than before this -fix narrowed both. Software rendering dominates what is left; compare -implementations measured the same way. +| Home page | 408 MB | 223 MB | +| Versions of Node.js listed | 431 MB | 237 MB | + +`main.go` sets these variables before the application starts, unless they are +already set: `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` +and `JSC_useJIT=false` for WebKitGTK and its JavaScript engine, and +`GDK_DISABLE=gl,vulkan` and `GSK_RENDERER=cairo` for GTK 4. Without them, +accelerated compositing, the buffers that WebKit shares with the GPU and the +OpenGL renderer of GTK map Mesa's software OpenGL and its compiler, which the +pages of vfox+ do not need, and the JIT compiler of the web process keeps +memory of its own: the same build took 341 MB of PSS on the Home page instead +of 223 (and 482 MB in the snapshot before any of them was set). WebKitGTK 6.0 +with GTK 4 still needs 15 MB more than the WebKitGTK 4.1 with GTK 3 of the +[Tauri implementation](../tauri/README.md#memory) for the same front end (208 +MB, with the same settings but the ones of GTK); it was 71 MB more with the +compositing alone off. Compare implementations measured the same way. diff --git a/wails/main.go b/wails/main.go index bc13e6c..3ed7781 100644 --- a/wails/main.go +++ b/wails/main.go @@ -92,11 +92,22 @@ func (desktop) Exit(code int) { } func main() { - // Accelerated compositing maps Mesa's software OpenGL and its compiler - // when there is no real GPU; the pages of vfox+ do not need it. See - // docs/memory.md and the WebKitGTK documentation of this variable. - if _, set := os.LookupEnv("WEBKIT_DISABLE_COMPOSITING_MODE"); !set { - _ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1") + // Accelerated compositing, the OpenGL renderer of GTK and the buffers that + // WebKit shares with the GPU map Mesa's software OpenGL and its compiler + // when there is no real GPU, and the JIT compiler of the web process keeps + // memory of its own; the pages of vfox+ need none of it. The first three + // are variables of WebKitGTK and of its JavaScript engine, the others + // keep GTK 4 from opening OpenGL and Vulkan. See docs/memory.md. + for _, setting := range [...]struct{ name, value string }{ + {"WEBKIT_DISABLE_COMPOSITING_MODE", "1"}, + {"WEBKIT_DISABLE_DMABUF_RENDERER", "1"}, + {"JSC_useJIT", "false"}, + {"GDK_DISABLE", "gl,vulkan"}, + {"GSK_RENDERER", "cairo"}, + } { + if _, set := os.LookupEnv(setting.name); !set { + _ = os.Setenv(setting.name, setting.value) + } } frontend, err := fs.Sub(assets, "frontend/dist") if err == nil { From db3c67fdfd0a983f4fa910ee6e772a13466866e3 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:34 +0800 Subject: [PATCH 07/11] Keep Electron's GPU and network service in the main process On top of app.disableHardwareAcceleration() (#20): in-process-gpu puts the GPU thread in the browser process, NetworkServiceInProcess2 does the same for the network service, and --js-flags=--lite-mode has V8 leave out its optimizing compiler. Six processes become four, and the Home page goes from 305 to 267 MB of PSS. --no-zygote saved nothing in the test and needs the sandbox of Chromium off, and --single-process does not start the application. The README no longer says that the web view implementations cost more than this one: with the settings of Tauri, Dioxus and Wails they are 203 to 223 MB. The experiment that hosted this front end in the shell of Tauri (422 MB) predates them, and is marked so. check and both e2e scenarios (fake and real) still pass. Measured again with sandbox/sandbox.sh measure. --- capacitor/README.md | 23 +++++++++++++---------- capacitor/electron/main.ts | 8 +++++++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/capacitor/README.md b/capacitor/README.md index f474e27..ddd1251 100644 --- a/capacitor/README.md +++ b/capacitor/README.md @@ -154,12 +154,12 @@ of vfox+. Measured with `sandbox/sandbox.sh measure`: Ubuntu 26.04 in Docker, Xvfb with software rendering, a 1280 × 800 window, the Electron processes together (the -browser, the zygotes, the network and renderer processes). +browser, the zygotes and the renderer). | State | RSS | PSS | | -------------------------- | -----: | -----: | -| Home page | 655 MB | 303 MB | -| Versions of Node.js listed | 672 MB | 313 MB | +| Home page | 468 MB | 267 MB | +| Versions of Node.js listed | 487 MB | 281 MB | Electron brings its own Chromium, so nothing is shared with the libraries of the desktop; compare implementations measured the same way. `main.ts` calls @@ -167,17 +167,20 @@ the desktop; compare implementations measured the same way. `main.ts` calls Chromium's GPU process maps Mesa's software OpenGL and its compiler, the same tax that the Gio and Flutter implementations pay and that the Uno and mygo implementations avoid, and the same build took 388 MB of PSS on the Home page -instead of 303. On a machine with a GPU the numbers differ; measure there when -it matters. Only the rows of the available versions that are in view are -rendered ([TanStack Virtual](https://tanstack.com/virtual)). +instead of 303. It also puts the GPU and the network service in the browser +process (`in-process-gpu`, `NetworkServiceInProcess2`) and has V8 leave out its +optimizing compiler (`--js-flags=--lite-mode`), for 267 MB of PSS in four +processes instead of 305 in six. On a machine with a GPU the numbers differ; +measure there when it matters. Only the rows of the available versions that +are in view are rendered ([TanStack Virtual](https://tanstack.com/virtual)). A system WebView was tried instead of Electron for this same front end: the native side of [the Tauri implementation](../tauri/README.md), unchanged, hosting this implementation's own built page through a small -`VfoxHostPlugin`. It measured worse, 422 MB of PSS on the Home page — a -system WebView is not automatically cheaper than a bundled Chromium; compare -[the whole table](../docs/memory.md), where Tauri, Dioxus and Wails all cost -more than Capacitor even with their own accelerated compositing turned off. +`VfoxHostPlugin`. It measured worse then, 422 MB of PSS on the Home page +against 303 for Electron — before the settings that Tauri, Dioxus and Wails +have now, which put them at 203 to 223 MB, below this implementation (see +[the whole table](../docs/memory.md)). The shell was not measured again. Not pursued further. ## Differences from the Flutter implementation diff --git a/capacitor/electron/main.ts b/capacitor/electron/main.ts index 3ffdee4..1e86f8a 100644 --- a/capacitor/electron/main.ts +++ b/capacitor/electron/main.ts @@ -41,8 +41,14 @@ const RENDERER = join(__dirname, 'renderer'); protocol.registerSchemesAsPrivileged([ { scheme: SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, ]); -// EXPERIMENT(memory): no GPU process, Skia rasterizes on the CPU instead. +// No hardware acceleration: Skia rasterizes on the CPU, which keeps Mesa's software +// OpenGL and its compiler out of the GPU process when there is no real GPU. app.disableHardwareAcceleration(); +// And fewer processes with memory of their own: the GPU and the network service run +// in the browser process, and V8 leaves out its optimizing compiler. +app.commandLine.appendSwitch('in-process-gpu'); +app.commandLine.appendSwitch('enable-features', 'NetworkServiceInProcess2'); +app.commandLine.appendSwitch('js-flags', '--lite-mode'); // X11 takes WM_CLASS from the name, which the desktop entry names (StartupWMClass). app.setName(APP_ID); // Chromium keeps its profile apart from the settings of vfox+, among the caches. From 078079e9a0a7e11b5d8bf063d11fa923c53d23b5 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:34 +0800 Subject: [PATCH 08/11] Hold less in the heap of Gio The heap of Go was 57 MB for 22 MB that were live, 12 of them copies of the same tables. Every one of the three weights of the variable font of the interface parsed the font again, because Gio tells faces apart by their *Font. They now get a Font of their own that is a copy of the one parsed once, which shares its tables, and main.go has the collector run at 25 % growth instead of 100 %, so that the heap stays near what the application holds (10 to 12 MB). The heap of Go goes from 57 to 20 MB, and the Home page from 200 to 165 MB of PSS, in a number that varies by 20 MB from one run to the next with Mesa's llvmpipe, which is most of it: Gio only draws with OpenGL. check and e2e still pass, and the pages look the same. Measured again with sandbox/sandbox.sh measure. --- gio/README.md | 10 ++++++++-- gio/main.go | 5 +++++ gio/ui/kit/fonts.go | 17 +++++++++++------ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/gio/README.md b/gio/README.md index 219eff5..84abbb6 100644 --- a/gio/README.md +++ b/gio/README.md @@ -207,8 +207,14 @@ one process. | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 240 MB | 215 MB | -| Versions of Node.js listed | 253 MB | 228 MB | +| Home page | 202 MB | 165 MB | +| Versions of Node.js listed | 208 MB | 171 MB | + +Gio only draws with OpenGL, so Mesa's software OpenGL and its compiler are most +of the number, and it varies by some 20 MB from one run to the next. The heap +of Go is about 20 MB: `main.go` lets it grow by 25 % between collections, not +100 %, and the three weights of the variable font of the interface share the +tables of one parse ([`ui/kit/fonts.go`](ui/kit/fonts.go)); it was 57 MB. ## Limits diff --git a/gio/main.go b/gio/main.go index fb6d1a0..edf0c3c 100644 --- a/gio/main.go +++ b/gio/main.go @@ -18,6 +18,7 @@ package main import ( "log" "os" + "runtime/debug" "strings" "github.com/version-fox/vfox-plus/gio/core" @@ -27,6 +28,10 @@ import ( ) func main() { + // The live heap is 10 to 12 MB. The default of 100 % lets it double before + // the next collection, 25 % keeps what the application holds near what it + // needs, for a few collections more. + debug.SetGCPercent(25) environment := make(map[string]string) for _, entry := range os.Environ() { if key, value, ok := strings.Cut(entry, "="); ok { diff --git a/gio/ui/kit/fonts.go b/gio/ui/kit/fonts.go index 450ef2a..ccbc64d 100644 --- a/gio/ui/kit/fonts.go +++ b/gio/ui/kit/fonts.go @@ -101,8 +101,9 @@ func (f faceOfVariation) Face() *fontapi.Face { } // parseFont parses a font of the data. Gio tells faces apart by their *Font, -// so every variation of a variable font needs a Font of its own, and parses -// the data again. +// so every variation of a variable font needs a Font of its own: a copy of +// the parsed one, which shares its tables (parsing the data again for each +// would keep a copy of the tables for each in the memory). func parseFont(data []byte, index int) (*fontapi.Font, bool, error) { if index > 0 || bytes.HasPrefix(data, []byte("ttcf")) { loaders, err := opentype.NewLoaders(bytes.NewReader(data)) @@ -260,11 +261,15 @@ func loadFamily(faces *[]text.FontFace, regular fontSource, pattern string, type regular.variable = variable } if regular.variable { + parsed, _, err := parseFont(data, regular.index) + if err != nil { + return false + } for _, weight := range weights { - f, _, err := parseFont(data, regular.index) - if err != nil { - return false - } + // A Font of its own for every weight, which shares the tables of + // the one that was parsed. + clone := *parsed + f := &clone *faces = append(*faces, text.FontFace{ Font: giofont.Font{Typeface: typeface, Weight: giofont.Weight(weight - 400)}, Face: faceOfVariation{font: f, variations: []fontapi.Variation{ From 74bef8e01701d0cfb68c1e6bc85bac248ec04a97 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 20:17:41 +0800 Subject: [PATCH 09/11] Measure the memory of the implementations again docs/memory.md has the numbers of the commits before, and of mygo and Uno, which did not change (mygo 41 MB of PSS on the Home page, Uno 98), measured on the same day, one after another, with nothing else running (sandbox/sandbox.sh measure of each): Compose 129, Gio 165, Flutter 172, Dioxus 203, Tauri 208, Flet 216, Wails 223 and Capacitor 267. The same code before the commits took 253, 200, 339, 261, 262, about 340, 341 and 305. mygo is still the lowest by far, now by a factor of 2.4 to 6.5. The Results bullets and the No GPU paragraph say what each implementation did and what is left: Gio still draws with OpenGL, the engine of Flutter and the processes of WebKit still create OpenGL contexts, and on Wayland GDK opens OpenGL for the windows of GTK 3 unless told not to. Uno's README has its own numbers of the day (98 and 113 MB); the others are in their own commits. --- docs/memory.md | 104 ++++++++++++++++++++++++++++++------------------- uno/README.md | 6 +-- 2 files changed, 68 insertions(+), 42 deletions(-) diff --git a/docs/memory.md b/docs/memory.md index 7f0d975..c1f3cfd 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -15,39 +15,45 @@ memory columns, the PSS of the Home page, from the smallest to the largest (the is not in that order: see [Reading the numbers](#reading-the-numbers)); "Versions" is the SDKs page with the 872 available versions of Node.js listed. -| Implementation | Technology | Processes | Home PSS | Home RSS | Versions PSS | Versions RSS | The list adds (PSS) | -| ------------------------------------------ | ----------------------------------------------- | --------: | -------: | -------: | -----------: | -----------: | ------------------: | -| [mygo](../mygo/README.md#memory) | mygo 0.5, Go, GTK 3 (purego), CPU renderer | 1 | 41 | 54 | 47 | 60 | +6 | -| [Uno](../uno/README.md#memory) | Uno Platform 6, C# on .NET 10, Skia on the CPU, Native AOT | 1 | 99 | 106 | 116 | 123 | +17 | -| [Gio](../gio/README.md#memory) | Gio, Go, OpenGL ES through EGL | 1 | 215 | 240 | 228 | 253 | +13 | -| [Dioxus](../dioxus/README.md#memory) | Dioxus 0.7, Rust, WebKitGTK, compositing off | 3 | 258 | 434 | 296 | 473 | +38 | -| [Compose](../compose/README.md#memory) | Compose Multiplatform, Kotlin on the JVM, Skia | 1 | 266 | 290 | 277 | 301 | +11 | -| [Tauri](../tauri/README.md#memory) | Tauri 2, Rust, WebKitGTK 4.1 (GTK 3), compositing off | 3 | 275 | 442 | 285 | 452 | +10 | -| [Capacitor](../capacitor/README.md#memory) | Ionic Vue and Capacitor in Electron, no hardware GPU | 6 | 303 | 655 | 313 | 672 | +10 | -| [Flutter](../flutter/README.md#memory) | Flutter 3.47, Dart, Impeller (OpenGL ES), GTK 3 | 2 | 329 | 369 | 345 | 384 | +16 | -| [Flet](../flet/README.md#memory) | Flet, Python 3.14 in a Flutter application | 2 | 346 | 378 | 383 | 415 | +37 | -| [Wails](../wails/README.md#memory) | Wails 3, Go, WebKitGTK 6.0 (GTK 4), compositing off | 3 | 346 | 525 | 358 | 538 | +12 | - -- Turning off GPU-dependent rendering where the pages do not need it, and compiling - ahead of time where that lets trimming do a better job, closes most of the gap - between the four implementations that run in one process and draw themselves - (mygo, Uno, Gio, Compose, 41 to 266 MB of PSS on the Home page) and the four that - bring a web view or a browser: with WebKitGTK's accelerated compositing turned off, - Dioxus and Tauri are 258 and 275 MB, in three processes, both now cheaper than - Compose; Capacitor, with Electron's hardware acceleration off, is 303, in six - processes. mygo still uses under half of what Uno does, drawing on the processor - from a Go executable of 12 MB and loading GTK 3 from the system; Uno, compiled with - Native AOT instead of ReadyToRun, is a single piece of machine code with no separate - managed runtime, and keeping no XAML pages suits that well. Flutter and Flet, whose - client is a Flutter application too, follow at 329 and 346, each in two processes. - Wails, in the same three processes and with the same fix as Tauri and Dioxus, stays - at 346, tied with Flet: it links the heavier WebKitGTK 6.0 with GTK 4 instead of their - WebKitGTK 4.1 with GTK 3 (see below). -- Showing the list of versions now adds 6 to 38 MB of PSS to every implementation; before - this page turned their accelerated compositing off, it added 73 to 84 MB to Tauri, - Dioxus and Wails, which show the list in WebKitGTK. -- WebKitGTK 6.0 with GTK 4 (Wails) still needs 71 MB more than WebKitGTK 4.1 with GTK 3 - (Tauri) for the same web front end, with accelerated compositing off on both. +| Implementation | Technology | Processes | Home PSS | Home RSS | Versions PSS | Versions RSS | The list adds (PSS) | +| ------------------------------------------ | -------------------------------------------------------------------- | --------: | -------: | -------: | -----------: | -----------: | ------------------: | +| [mygo](../mygo/README.md#memory) | mygo 0.5, Go, GTK 3 (purego), CPU renderer | 1 | 41 | 54 | 47 | 60 | +6 | +| [Uno](../uno/README.md#memory) | Uno Platform 6, C# on .NET 10, Skia on the CPU, Native AOT | 1 | 98 | 106 | 113 | 122 | +15 | +| [Compose](../compose/README.md#memory) | Compose Multiplatform, Kotlin on the JVM, Skia on the CPU, C1 only | 1 | 129 | 135 | 152 | 158 | +23 | +| [Gio](../gio/README.md#memory) | Gio, Go, OpenGL ES through EGL | 1 | 165 | 202 | 171 | 208 | +6 | +| [Flutter](../flutter/README.md#memory) | Flutter 3.47, Dart, Skia on the CPU, GTK 3 | 2 | 172 | 212 | 183 | 223 | +11 | +| [Dioxus](../dioxus/README.md#memory) | Dioxus 0.7, Rust, WebKitGTK, no compositing, DMA-BUF or JIT | 3 | 203 | 385 | 220 | 402 | +17 | +| [Tauri](../tauri/README.md#memory) | Tauri 2, Rust, WebKitGTK 4.1 (GTK 3), no compositing, DMA-BUF or JIT | 3 | 208 | 382 | 220 | 394 | +12 | +| [Flet](../flet/README.md#memory) | Flet, Python 3.14 in a Flutter application, Skia on the CPU | 2 | 216 | 257 | 224 | 266 | +8 | +| [Wails](../wails/README.md#memory) | Wails 3, Go, WebKitGTK 6.0 (GTK 4), as Tauri, GTK OpenGL off | 3 | 223 | 408 | 237 | 431 | +14 | +| [Capacitor](../capacitor/README.md#memory) | Ionic Vue and Capacitor in Electron, no GPU process, no V8 JIT | 4 | 267 | 468 | 281 | 487 | +14 | + +- Without a GPU, what a toolkit does by default is to draw with the software OpenGL of + Mesa, and the memory of that and of its compiler (LLVM) is in the application. Taking it + out where the pages do not need it, compiling ahead of time and keeping the runtime + small moved the rows of this table more than anything else (see [No GPU](#reading-the-numbers) + below). mygo (41 MB of PSS on the Home page, in one process) draws on the processor from a + Go executable of 12 MB and loads GTK 3 from the system, and uses under half of what Uno + does: Uno (98) is compiled with Native AOT, a single piece of machine code with no + separate managed runtime, and keeping no XAML pages suits that well. Compose (129) draws + with Skia on the processor and runs the JVM with the first compiler of its JIT alone, + which keeps what the optimizing one needs out of the process. Gio (165) is the one that + still draws with OpenGL, and it has no renderer for the processor: the memory of Mesa is + most of its number, which also varies by 20 MB from one run to the next. Flutter and Flet + (172 and 216), whose client is a Flutter application too, draw with Skia on the processor + in two processes each; the engine still creates its OpenGL contexts at start-up, so + Mesa's compiler is in these numbers as well. The three implementations in WebKitGTK + (Dioxus 203, Tauri 208, Wails 223, in three processes) turn off the accelerated + compositing of WebKit, the buffers that it shares with the GPU and the JIT compiler of + the web process; Wails, with GTK 4 instead of GTK 3, keeps GTK from opening OpenGL + as well. Capacitor (267) is the largest: Electron's hardware acceleration is off, and the + GPU and the network service run in the main process, which leaves four processes. +- Showing the list of versions now adds 6 to 23 MB of PSS to every implementation; before + the pages turned the accelerated compositing of WebKit off, it added 73 to 84 MB to + Tauri, Dioxus and Wails, which show the list in WebKitGTK. +- WebKitGTK 6.0 with GTK 4 (Wails) needs 15 MB more than WebKitGTK 4.1 with GTK 3 (Tauri) + for the same web front end, with the settings of this table on both. It was 71 MB more + before Wails kept GTK 4 from opening OpenGL. ## How it is measured @@ -78,9 +84,10 @@ nothing but Docker); the numbers above come from a WSL2 host with 16 logical CPU processes. PSS divides each shared page among the processes that map it, so the sum is what the application uses. Compare PSS. - **No GPU.** The virtual display has no GPU, so everything that draws with OpenGL - uses the software rasterizer of Mesa (llvmpipe), and its memory is in the numbers - (the Gio README says that it is most of what Gio uses). Uno was set to draw with Skia on the - processor, and the same build of it took 295 MB on the Home page with Mesa's + uses the software rasterizer of Mesa (llvmpipe), and its memory, with that of its + compiler (LLVM), is in the numbers (the Gio README says that it is most of what Gio + uses). The implementations that can do without it do. Uno was set to draw with Skia on + the processor, and the same build of it took 295 MB on the Home page with Mesa's software GL and 165 MB without it, with every other setting equal. mygo draws on the processor too, and the application has GDK leave OpenGL alone: the same build took about 78 MB when GDK opened its OpenGL connection, and about 43 MB when it did not. @@ -89,9 +96,28 @@ nothing but Docker); the numbers above come from a WSL2 host with 16 logical CPU it off. Tauri, Dioxus and Wails set `WEBKIT_DISABLE_COMPOSITING_MODE=1` before their webview starts, which keeps WebKitGTK from entering accelerated compositing mode at all: the same builds took 419, 427 and 482 MB with it on, against 275, 258 and 346 - with it off. On a machine with a GPU the numbers of the implementations that draw - with OpenGL, and perhaps the order, differ (Uno, mygo, Capacitor, Tauri, Dioxus and - Wails no longer depend on the GPU); measure there when it matters. + with it off. Later changes of the same kind, each measured against the build without + it on the day of this table: Compose with `-Dskiko.renderApi=SOFTWARE` (Skia on the + processor, not OpenGL) and `-XX:TieredStopAtLevel=1` (the JIT of the JVM without its + optimizing compiler), about 50 MB each, 253 to 129 MB together; Flutter with + `FLUTTER_LINUX_RENDERER=software` and Impeller turned off (the engine of 3.47 cannot + draw the text of Impeller to the canvas of the processor), 339 to 172 MB, and Flet, + whose Flutter 3.44 needs nothing but the variable, about 340 to 216 MB; Tauri and Dioxus with `WEBKIT_DISABLE_DMABUF_RENDERER=1` and + `JSC_useJIT=false` (WebKit's own buffers for a GPU, and the JIT of its JavaScript + engine), 262 to 208 and 261 to 203 MB; Wails with these and `GDK_DISABLE=gl,vulkan` + and `GSK_RENDERER=cairo` (GTK 4 opens OpenGL for its own window otherwise), 341 to 223 + MB; Capacitor with `--in-process-gpu`, `NetworkServiceInProcess2` and + `--js-flags=--lite-mode`, 305 to 267 MB and from six processes to four; Gio with one + copy of the tables of its variable font instead of three and a garbage collector that + runs at 25 % growth, which takes the heap of Go from 57 to 20 MB, inside a number that + varies by 20 MB. On Wayland, GDK opens OpenGL for the windows of GTK 3, which Tauri, + Dioxus, Flutter and Flet prevent with `GDK_GL=disable` (Tauri on a headless sway: 242 + MB without it, 207 with it); the numbers here are those of X11. Still created without + a GPU: the OpenGL contexts of the engine of Flutter, and those of the processes of + WebKit, which cannot start without EGL. On a machine with a GPU the numbers of the + implementations that draw with OpenGL, and perhaps the order, differ (Gio draws with + it; the others no longer depend on it, except for those contexts); measure there when it + matters. - **One measurement per row.** Nothing is averaged over runs, so small differences mean little; the implementations were measured one after another, with nothing else running. Flutter, which was measured six times on an earlier day, had 332 to 342 MB diff --git a/uno/README.md b/uno/README.md index f5606f4..a1ff991 100644 --- a/uno/README.md +++ b/uno/README.md @@ -232,8 +232,8 @@ Measured with `sandbox/sandbox.sh measure`: Ubuntu 26.04 in Docker, Xvfb, a | State | RSS | PSS | | --- | ---: | ---: | -| Home page | 106 MB | 99 MB | -| Versions of Node.js listed | 123 MB | 116 MB | +| Home page | 106 MB | 98 MB | +| Versions of Node.js listed | 122 MB | 113 MB | Skia draws with the processor. Uno would otherwise draw with OpenGL, and the graphics stack that this loads takes more memory than the application does: @@ -241,7 +241,7 @@ with Mesa's software GL (LLVM) it was 295 MB on the Home page instead of 165 MB for the same build, with every other setting equal. An interface this size does not need the GPU, and the memory does not depend on the driver of the machine. Native AOT takes the Home page from 126 MB of PSS with the same build compiled -ahead of time but still managed (ReadyToRun) to 99: no separate runtime loads +ahead of time but still managed (ReadyToRun) to 98: no separate runtime loads reflection metadata or JITs anything at start-up, because there is no separate runtime, only the one piece of machine code Native AOT compiles the application and the parts of the .NET runtime it uses into together. This From 09bd164ec2f825a59419fdbe50f558d265b21477 Mon Sep 17 00:00:00 2001 From: Qiheng He Date: Fri, 23 Oct 2026 21:37:50 +0800 Subject: [PATCH 10/11] Cache the Ubuntu packages of the workflows and bound their download On 2026-10-23 the Ubuntu mirror of the runners was throttled to about 40 KB/s: the index (3 MB) still came at 10 MB/s, but the 280 packages of the Wails workflow, 94 MB, took 45 minutes, the timeout of the job, where the same step took a minute on 2026-10-19. The runs of #22 show it: Wails was cancelled after 45 minutes in "Install the Linux packages", the packaging job of Flutter hit its 25 minutes in the same kind of step, in two attempts, and Uno (36 minutes), Dioxus (30) and Capacitor (21) spent most of their time there. Nothing else of those jobs was slow. The throttle seems to be for each connection: the rate stayed at about 40 KB/s from 12:23 to 12:38 UTC while the number of jobs that were downloading fell from eleven to five. apt downloads from one host over one connection, one file after the other. The workflows install their packages with the new action .github/actions/install-linux-packages: * It keeps the downloaded archives in a cache, for each list of packages and each week, so that a day with a slow mirror costs the first run only. The newest cache of any earlier week starts the next one, and apt-get autoclean removes the archives of superseded versions. * install.sh downloads what the cache lacks eight files at a time with curl (fetch.sh, from the addresses that apt prints with --print-uris, with the address of the mirror file of the runners filled in), then has apt fetch what is left and check every archive, and installs from the archives, which needs no network and no time limit. A download has a time limit of five minutes and three attempts, and keeps what it got. A request that gets no data for 30 seconds fails and is tried again, up to three times; apt waited for two minutes. Where the files cannot be fetched this way, apt does all of it, as before. * A run that cannot finish saves what it downloaded under another key, so that the next run, such as a re-run of the failed jobs, starts from there and gets further each time. The job that runs the end-to-end tests of Flutter no longer installs patchelf, lintian and desktop-file-utils, which only the job that builds the packages uses. Dependabot also looks at the action, which pins actions too. Tested in an ubuntu:26.04 container with the mirror of this network and a mirror file like that of the runners: a cold run, a run from the saved archives (0 B to download), eight connections against one when curl is limited to 150 kB/s each (all of 164 MB by curl in about two and a half minutes, where one connection ran into the time limit after 47 MB), archive names with an epoch (%3a), an attempt that ran out of time, and an installation that fails after its last attempt; the package list of the end-to-end job of Flutter installs its toolchain. actionlint 1.7.12 and ShellCheck 0.11 pass. Not tested on a runner: the cache steps, and whether the throttle is for each connection. --- .../actions/install-linux-packages/action.yml | 76 ++++++++++++ .../actions/install-linux-packages/fetch.sh | 30 +++++ .../actions/install-linux-packages/install.sh | 114 ++++++++++++++++++ .github/dependabot.yml | 5 +- .github/workflows/capacitor.yml | 7 +- .github/workflows/compose.yml | 7 +- .github/workflows/dioxus.yml | 7 +- .github/workflows/flet.yml | 7 +- .github/workflows/flutter.yml | 18 +-- .github/workflows/gio.yml | 7 +- .github/workflows/mygo.yml | 7 +- .github/workflows/tauri.yml | 7 +- .github/workflows/uno.yml | 7 +- .github/workflows/wails.yml | 7 +- .github/workflows/web.yml | 6 +- CONTRIBUTING.md | 9 ++ 16 files changed, 272 insertions(+), 49 deletions(-) create mode 100644 .github/actions/install-linux-packages/action.yml create mode 100755 .github/actions/install-linux-packages/fetch.sh create mode 100755 .github/actions/install-linux-packages/install.sh diff --git a/.github/actions/install-linux-packages/action.yml b/.github/actions/install-linux-packages/action.yml new file mode 100644 index 0000000..bfef885 --- /dev/null +++ b/.github/actions/install-linux-packages/action.yml @@ -0,0 +1,76 @@ +# Copyright 2026 Han Li and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Install the Linux packages +description: >- + Installs Ubuntu packages from a cache of their archives, and downloads what + the cache lacks eight files at a time, with a time limit and new attempts (see + install.sh), because the Ubuntu mirror of the runners is sometimes throttled to + about 40 KB/s for each connection. + +inputs: + packages: + description: The packages, separated by spaces or new lines. + required: true + +runs: + using: composite + steps: + - name: Name the cache + id: names + shell: bash + env: + PACKAGES: ${{ inputs.packages }} + run: | + # A cache for each list of packages and each week: a week is long enough + # for many runs to use it, and short enough to stay near the versions + # of the mirror. The newest cache of any earlier week (or what an + # interrupted run saved, see below) starts the cache of a new week, + # which install.sh trims. + hash="$(echo "$PACKAGES" | tr -s '[:space:]' '\n' | sort -u | sha256sum | cut -c1-16)" + echo "prefix=apt-${ImageOS:-ubuntu}-$hash-" >> "$GITHUB_OUTPUT" + echo "key=apt-${ImageOS:-ubuntu}-$hash-$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + - name: Let the cache write the archives + shell: bash + run: sudo chmod a+rwx /var/cache/apt/archives + - name: Restore the archives + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /var/cache/apt/archives/*.deb + key: ${{ steps.names.outputs.key }} + restore-keys: ${{ steps.names.outputs.prefix }} + - name: Install the packages + id: install + shell: bash + env: + PACKAGES: ${{ inputs.packages }} + run: | + # shellcheck disable=SC2086 # The list is meant to be split. + "$GITHUB_ACTION_PATH/install.sh" $PACKAGES + - name: Save the archives + if: steps.install.outcome == 'success' && steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /var/cache/apt/archives/*.deb + key: ${{ steps.names.outputs.key }} + # A download that did not finish within its attempts is not lost: the next + # run starts from what it got (by the prefix above) instead of from nothing, + # so that a run on a slow mirror gets further each time. + - name: Save what was downloaded + if: always() && steps.install.outcome == 'failure' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /var/cache/apt/archives/*.deb + key: ${{ steps.names.outputs.prefix }}partial-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/actions/install-linux-packages/fetch.sh b/.github/actions/install-linux-packages/fetch.sh new file mode 100755 index 0000000..5697b40 --- /dev/null +++ b/.github/actions/install-linux-packages/fetch.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Copyright 2026 Han Li and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Downloads one archive for install.sh, which starts several of these at the same +# time: fetch.sh . +# +# The file goes to partial/ first, where apt keeps what it is downloading, so that +# apt continues with what is left of a download that was cut, and checks it, as it +# checks every archive, before it installs it. +set -euo pipefail + +directory="$1" +url="$2" +name="$3" + +curl --fail --silent --show-error --location --retry 3 --connect-timeout 10 --max-time 600 \ + --output "$directory/partial/$name" "$url" +mv "$directory/partial/$name" "$directory/$name" diff --git a/.github/actions/install-linux-packages/install.sh b/.github/actions/install-linux-packages/install.sh new file mode 100755 index 0000000..43c8c20 --- /dev/null +++ b/.github/actions/install-linux-packages/install.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Copyright 2026 Han Li and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Installs Ubuntu packages on a runner of GitHub Actions in two steps, which the +# action next to this file (action.yml) wraps in a cache: +# +# 1. Downloads the packages into /var/cache/apt/archives, with a time limit +# for each attempt, and tries again when an attempt runs out of time. The +# Ubuntu mirror of the runners is sometimes throttled to about 40 KB/s, and +# downloading 94 MB took 45 minutes then (a minute at other times), which +# is what a job of 25 or 45 minutes cannot wait for. The throttle seems to +# be for each connection (the rate did not change while the number of jobs +# that were downloading fell from 11 to 5), and apt downloads from a host +# over one connection, one file after the other: eight files at a time +# (fetch.sh) are eight times as fast then, and as fast as before when the +# mirror is not throttled. apt then fetches what is left and checks every +# archive. What an attempt has downloaded is kept, so the next one +# continues with the rest. +# 2. Installs them from the archives, which needs no network. +# +# Finally it removes the archives of versions that the package lists no longer +# have, so that the cache does not grow from week to week. +# +# Usage: install.sh ... +# +# Set APT_ATTEMPTS and APT_ATTEMPT_LIMIT (a time for timeout(1), such as 5m) to +# change the number of attempts of the download (3) and the time of each (5m): +# the three of them have to end before the timeout of the job, for the action to +# save what they downloaded. APT_PARALLEL is the number of files that are +# downloaded at the same time (8; 0 leaves it all to apt). +set -euo pipefail + +[[ $# -gt 0 ]] || { echo "Usage: install.sh ..." >&2; exit 2; } + +packages=("$@") +attempts="${APT_ATTEMPTS:-3}" +limit="${APT_ATTEMPT_LIMIT:-5m}" +parallel="${APT_PARALLEL:-8}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +archives=/var/cache/apt/archives + +# A request that gets no data for 30 seconds fails and is tried again, up to 3 +# times; apt waits for 2 minutes by default. The archives stay where they are +# after the installation: a runner has no hook that removes them, but a +# container of Docker has. +apt_options=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 + -o APT::Keep-Downloaded-Packages=true) + +# apt_get