Skip to content

Lower the memory of Capacitor, Tauri, Dioxus, Wails and Uno - #20

Merged
linghengqian merged 7 commits into
mainfrom
memory-tuning
Oct 19, 2026
Merged

linghengqian merged 7 commits into
mainfrom
memory-tuning

Conversation

@linghengqian

Copy link
Copy Markdown
Member

Chromium's GPU process maps Mesa's software OpenGL and its compiler
when there is no real GPU, which is most of the gap between Capacitor
and the implementations that avoid it. app.disableHardwareAcceleration()
before the window is created takes the Home page from 388 to 303 MB of
PSS; measured again with sandbox/sandbox.sh measure.
WEBKIT_DISABLE_COMPOSITING_MODE=1 keeps WebKitGTK from mapping Mesa's
software OpenGL and its compiler when there is no real GPU, the same
tax that Capacitor's Electron shell pays when hardware acceleration is
on. Set in lib.rs before the webview starts, unless already set. Takes
the Home page from 419 to 275 MB of PSS; check and both e2e scenarios
(fake and real) still pass. Measured again with sandbox/sandbox.sh
measure.
Same fix as Tauri (8559239): WEBKIT_DISABLE_COMPOSITING_MODE=1 before
the webview starts, unless already set. Takes the Home page from 427
to 258 MB of PSS, the cheapest of the implementations that bring a web
view; check and both e2e scenarios still pass. Measured again with
sandbox/sandbox.sh measure.
Same fix as Tauri (8559239) and Dioxus (49eb4af), and it works on
webkitgtk-6.0/GTK4 too, not just webkit2gtk-4.1/GTK3:
WEBKIT_DISABLE_COMPOSITING_MODE=1 before the application starts,
unless already set. Takes the Home page from 482 to 346 MB of PSS;
check and both e2e scenarios still pass. Measured again with
sandbox/sandbox.sh measure.
Dioxus and Tauri are now cheaper than Compose; Wails stays at the top
of the table, tied with Flet, on its heavier WebKitGTK 6.0/GTK 4
stack. Rewrites the Results bullets and the No GPU paragraph to match.
PublishReadyToRun -> PublishAot (mutually exclusive), keeping
PublishTrimmed. Takes the Home page from 126 to 99 MB of PSS: no
separate managed runtime to load reflection metadata into or to JIT
anything from at start-up, just one piece of machine code: this
implementation's own choice to keep no XAML pages and build its
controls in code suits Native AOT's trimming well. The published
output also shrank, 63 MB instead of the ReadyToRun build's ~104 (the
.deb is 18 MB instead of 27), unlike Uno's own published Native AOT
benchmarks, where it is larger: this is a small, reflection-light
application, not a large demo with many feature paths to keep.

Native AOT produces no deps.json, which shared/tools/nuget_licenses.py
relied on for two things: which NuGet packages ship code (now
obj/project.assets.json, restored, filtered to the one assembly name
per package also referenced by the ones Native AOT's trimmer actually
kept, from the new shared/tools/mstat-assemblies reading the
AssemblyRef table of the project's .mstat file, which
-p:IlcGenerateMstatFile=true writes; project.assets.json alone is the
graph before trimming, which over-approximates and wrongly included 7
packages, one of them LGPL, before this fix) and the license of the
bundled .NET runtime (now read directly from the publishing SDK's own
LICENSE.txt and ThirdPartyNotices.txt via --dotnet-root, which also
works for a non-AOT publish and does not depend on finding the right
runtime-pack package). mstat-assemblies has no dependencies, so
building it needs no network, verified under --network none.

Also drops the now-unused lintian override for
libSystem.IO.Compression.Native.so (Native AOT does not ship it as a
separate file; SkiaSharp still links the system's zlib dynamically, so
zlib1g stays a dependency). check, e2e (fake and real), measure,
package (deb, tar.gz, lintian, install test) and screenshots all
verified with sandbox/sandbox.sh; the generated licenses.txt inspected
by hand (17 NuGet packages plus the .NET runtime, no stale or missing
entries). README and docs/memory.md updated to match.
Capacitor: a Tauri (system WebView) shell for the same front end
measured 422 MB of PSS, worse than Electron with hardware acceleration
off (303). Dioxus: a one-div Dioxus Native (Blitz) window alone took
342 MB, more than this whole implementation (258). Both were tried on
throwaway branches (capacitor-tauri-shell-experiment,
dioxus-native-poc-experiment), now deleted; the numbers and the
reasoning live here instead.
@linghengqian linghengqian added the enhancement New feature or request label Oct 19, 2026
@linghengqian
linghengqian marked this pull request as ready for review October 19, 2026 09:25
@linghengqian
linghengqian merged commit 045a567 into main Oct 19, 2026
43 of 45 checks passed
@linghengqian
linghengqian deleted the memory-tuning branch October 19, 2026 09:25
linghengqian added a commit that referenced this pull request Oct 24, 2026
… and Capacitor (#22)

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

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

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

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

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

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

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

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

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

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

* Make the download of the Ubuntu packages continue and check it

The Wails job of #22 failed with the first version of the action (run
39971828235): "Could not download the packages in 3 attempts of 5m"
after 30 minutes, and nothing was saved. The log shows what went wrong:

* fetch.sh wrote each file from its first byte, over what the attempt
  before it had got. libwebkitgtk-6.0-4 is 25.5 MB, 10 minutes at the
  43 KB/s that apt got over one connection of that runner, and an
  attempt had 5, so it could not end however often it was tried. The
  mirror also cut a connection after a few minutes, which nothing
  continued.
* Eight connections got 138 KB/s together (41.5 MB in 5 minutes), not
  eight times 43 KB/s, and four downloads did not connect in 10 seconds.
  In the same minute other jobs downloaded 90 MB from the same mirror in
  seconds: only some runners are slow.
* The save of what had been downloaded failed, because tar cannot read
  an archive that the user of the runner has no right to (libkdb5-10t64,
  one of 280), so that the next run would have started from nothing.

fetch.sh now:

* continues what is in partial/ with a range request, so that a
  connection that is cut, or dropped, loses nothing;
* takes the mirrors of /etc/apt/apt-mirrors.txt in order, drops a
  connection that gets less than 256 KB/s for 10 seconds, and goes on
  with the next mirror. When every mirror was slow, it goes on with the
  best of them and drops only a connection that stalls. A file that no
  mirror sends in three rounds is given up;
* ends at one deadline for the whole download, 15 minutes, instead of
  attempts of 5 minutes that each started over. install.sh starts the
  largest files first, so that none is left alone at the end;
* checks the SHA256 hash that apt prints (-o Acquire::ForceHash=SHA256)
  before it moves an archive into the directory. apt takes an archive of
  the right size that is there for a good one and does not check it: in
  a test, dpkg failed on one with a changed byte. The archives that are
  there already, from the cache, are checked too, and replaced when they
  are wrong. apt prints only the archives that are missing, so install.sh
  shows it an empty directory to get all of them.

The action makes the archives readable before it saves the cache, also
when the installation failed.

.github/scripts/test_fetch.py runs fetch.sh against local mirrors that
are slow, cut their connections, have nothing or send the wrong file; the
step of repository.yml that discovers the tests of that directory runs it
(15 seconds). In an ubuntu:26.04 container with a file of mirrors like
that of the runners, whose first mirror does not answer: a cold run, a
warm run, an archive with a changed byte, a download that ends at its
deadline and is continued by the next run, and no file of mirrors.
actionlint, ShellCheck and ruff pass. Not tested on a runner: the restore
of the cache, and the speed of the other mirrors when the first is slow.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant