Entry

Startup, halved

Three weeks of profiling a file manager: 336 ms to 163 ms, 184 MB to 109 MB, and the four numbers I had to publicly take back.

Sheet 9.4Written 20 Sep 2026About HyprFMReading 18 min

summary
163ms to a mapped window, from 336
109MB at rest, from 184
81ms for a second window, from 347
39.6ms into a 10k directory, from 70.7
text

HyprFM is a file manager I wrote in C++ and QML for Hyprland. At the end of August it took 336 ms to put a window on screen and sat at 184 MB. Both numbers were fine. Nobody complained. I made the mistake of measuring them properly anyway.

text

Three weeks later it maps a window in 163 ms and rests at 109 MB, opens a second window in 81 ms instead of 347, and lists ten thousand files in 11 ms of scanning instead of 39. The work took maybe thirty commits. About a third of my time went into changes that did nothing, and the single most useful thing I produced was not an optimisation at all: it was a list of measurements that were wrong.

text

This is the readable version of that log. The numbers are medians of repeated runs on one AMD laptop, Arch, Hyprland, Qt 6.11.2, and I will flag it when a number turned out to be a lie.

Sec 01

Three things are called startup

text

Before any of this was worth arguing about, I had to stop using one word for three different measurements. People say an app starts in 200 ms and mean whichever of these flatters them.

readout
<tr><th>MetricWhat it isHow it is taken
mapfrom exec to Hyprland announcing the windowa script listening on Hyprland's event socket
first frameapplication object built to the first swapped framemarks inside the process
idlelaunch until the process tree stops burning CPUsampling the kernel's own counters
PSSmemory it actually costs the machine, 4 s insmaps_rollup
text

The harness rules matter more than the tooling, and every one of them is scar tissue:

checklist
  • Apps run round robin, never in blocks, so the laptop getting hot lands on everyone equally.
  • Every measured run has a discarded warm-up in front of it.
  • Two builds are always interleaved, A, B, A, B, in one session. Numbers from two different sessions are not comparable and I do not care how stable they look.
  • Benchmark launches go through a sandbox with their own HOME, because of the session file I will get to in section 08.
  • Windows open on a workspace attached to a headless output, since a window no monitor shows never receives a frame callback, and a benchmark waiting for the first frame of an invisible window waits forever.
text

That last one took an hour of staring at a hung script to work out.

Sec 02

The descent

instrument
FIG. 1336 down to 163ms

Launch to a mapped window, home directory, one AMD laptop under Hyprland.

FIG. 1Lower is better. Each point is the median of at least seven runs, round robin against the other builds, one discarded warm-up each.

Raw values
map, home (ms)
30 Aug, baseline336
19 Sep, renderer, lazy UI, lighter models194
20 Sep, full benchmark as reference180
20 Sep, collation keys, readdir, warm Vulkan170
20 Sep, font stack on a worker163
Sec 03

What actually worked

In rough order of how much they paid, not the order I tried them in. The order I tried them in was much stupider.

subject

Ask the GPU what it is, once

text

Qt Quick renders with OpenGL by default. On this laptop Vulkan is cheaper, and Qt is happy to use it, with one catch: if you ask for Vulkan and there is no Vulkan, the window simply never appears. No error you can catch, no fallback. Just a process sitting there owning nothing.

text

So the app probes first. It reaches the Vulkan loader through dlopen with hand-declared structs, which means the build needs neither the library nor the headers, and the ABI is frozen so this is less reckless than it sounds. A software-only device gets rejected, because OpenGL on a real GPU beats Vulkan on a CPU every time. The answer is cached under the fingerprint of the installed driver manifests, with a per-process marker file so that a driver crash demotes the next launch to OpenGL instead of crashing in a loop forever.

text

One detail I got wrong first: I counted any early exit as a Vulkan failure, so a typo in QML would permanently convince the app that its GPU was broken. Now it only blames the driver if the process dies before painting anything.

subject

Build less of the UI before showing it

text

The settings window, the quick preview, every dialog, both context menus, the badge icons on cut and paste, and each file view now come into existence the first time something needs them. This is the least clever item on the list and it bought about 40 ms.

logged
subject

1.3 KB per file, times ten thousand

text

Each row in the directory model carried a fat struct with every display string precomputed. For a 10,000-file folder that is 12.7 MB of rows. A compact struct with strings built on demand takes it to 1.3 MB, roughly 130 bytes a row.

text

Two more things in the same area were embarrassing. The listing was fetched in a way that kept a second full copy alive, 2.4 MB per ten thousand files, for no reason at all. And resolving a file's owner and group name cost 492 microseconds per file, because each one walked out through the name service stack. Cache the id to name mapping and it happens once per distinct id. Populating a 10,000-file directory went from 6.0 s to 0.68 s across that group of commits, which is the kind of number that makes you glad nobody else was watching.

text

The icon cache was also capped at 512 entries, which sounds bounded until you notice nothing bounds how big an entry is. It is now capped at 24 MB.

subject

Two ways to not read a directory

text

Sorting file names has to be locale aware and numeric, so file2 comes before file10 and Ä sorts after z. Qt's collator does that correctly and costs a lot, and a comparison sort calls it O(n log n) times, about 120,000 calls for ten thousand files.

text

The fix is as old as Perl: compute one sort key per entry, then sort the keys, since a collation key is a byte string whose plain memory comparison gives the collator's order. Identical output, verified against the old ordering on a list full of accents and embedded numbers before I touched the model. Sorting fell from 20 ms to 6.2. Bonus: the directory listing was also being sorted by code point first, purely so the result could be thrown away and sorted again.

text

Then the listing itself. Asking Qt for file info objects builds a path string, a shared data block and a pile of cached metadata per entry, 15 to 22 ms for ten thousand files, to answer two questions the kernel already answered while reading the directory. Now it is opendir and readdir reading the dirent type directly, and it only calls stat for what a dirent cannot answer: symlinks, filesystems that report an unknown type, and every entry when the sort is by size or time.

text

That last clause is where the bugs live, so the awkward cases are pinned by a test: a directory holding a fifo, a unix socket, a symlink to a directory, a symlink to a file and a broken symlink has to come back exactly as Qt would have returned it.

instrument
FIG. 2Scanning 10,000 filesms

The three phases of a directory scan, stacked.

listingsortbuilding rows

FIG. 2Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
listing (ms)sort (ms)building rows (ms)
before18200.90
collation keys15.56.201
and readdir47—
subject

One process, every window

text

A second window used to cost 368 ms, because it was a second process doing the whole startup again. Now every window is served by one process, each with its own QML context, and a launch that finds an instance already running hands its arguments over a socket and exits. A second window takes 95 ms. The client process that forwarded the request lives for 36 ms.

text

The trade is real and worth saying out loud instead of burying: one process means one crash takes every window with it, and one freeze freezes all of them. That is exactly why the next three commits moved blocking work off the GUI thread. Metadata for the properties dialog runs exiftool and ffprobe, about 120 ms each, on a worker pool. Folder item counts go to a worker. Remote directory properties over SFTP are fetched asynchronously with an 8 s kill timer, so a dead mount no longer takes the window down with it.

text

Closing windows also used to ratchet memory upward. A garbage collection and a malloc_trim one second after a window closes turned eight open-and-close cycles from 202 MB into 129.

subject

The update cliff

text

Qt caches compiled QML on disk, keyed by each source file's path and modification time. Which means the cache is empty after every install and every update, and the first launch after an update compiles all 86 QML files. That is 400 ms, paid by exactly the person who just updated and is watching.

text

The switch to compile QML into the binary ahead of time already existed. It defaulted to off, because development builds want it off, and only the AppImage script turned it on. So every package I shipped for months shipped uncompiled QML. Release builds now default it on, and the app produces no cache files at all afterwards, because there is nothing left to compile.

instrument
FIG. 3First frame, Flatpakms

Shipping QML sources against compiling them into the binary.

shipping .qmlprecompiled

FIG. 3Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
shipping .qml (ms)precompiled (ms)
cold cache560161
warm cache133132
two branches, alternating743336
subject

The 6.5 ms every Qt process pays once

text

The oddest line in the profile was 6.2 ms to create two text items in the status bar. Two labels. The status bar was innocent: that is the cost of the first string any Qt process measures, where fontconfig matches the family, FreeType loads the face and the shaper wakes up. Measured on its own it is 6.5 to 6.9 ms for the first call, and 1.4 ms for a second typeface.

text

The font database and FreeType are shared across threads under their own locks, and only the per-thread font cache is private. So a worker thread can pay the bill, and the GUI thread arrives to find the expensive part already done. Launch went from 171 ms to 163.

text

In the same spirit, Qt was spending 47 ms between "the QML tree is ready" and "the scene graph is up", creating its Vulkan instance and device on the GUI thread, after the window was already shown, while the machine's other eleven cores did nothing. Bringing a throwaway Vulkan instance up on a worker as soon as the renderer choice is known overlaps that with the 60 ms of QML construction. The phase fell to 34 ms.

subject

Open the window on the folder you were asked for

text

Running hyprfm ~/Downloads opened a window on my home directory and then immediately navigated to Downloads. Two listings, two model resets, and the grid building its delegates twice: 112 delegate creations for a folder with 30 files in it. The launch path is now handed to the window before it opens.

logged
text

That trade is recorded in the margin rather than buried in a commit message, because it is the only change in the pass that made one case measurably worse.

Sec 04

Against the neighbours

Same machine, same session, round robin, seven runs each. Dolphin and Nautilus were launched exactly the way a user would launch them.

instrument
FIG. 4Launch to a mapped windowms

Home directory, warm page cache.

FIG. 4Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
map (ms)
HyprFM, software163
HyprFM, Vulkan180
Dolphin194
HyprFM, August build336
HyprFM AppImage338
Nautilus718
instrument
FIG. 5Memory at restMB

Proportional set size four seconds after launch, home directory.

FIG. 5Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
PSS (MB)
Dolphin64
HyprFM, software79
HyprFM, Vulkan103
Nautilus103
HyprFM, August build170
text

Dolphin is the honest benchmark here. It maps its window in 194 ms and rests at 64 MB, and that 64 MB is a number I am not going to reach while Qt Quick owns a GPU context. Nautilus takes 718 ms to appear and burns 1.83 s of CPU listing ten thousand files, against 0.32 s here, which is the one comparison I am allowed to feel smug about.

text

The AppImage is still 158 ms behind the native build, and I was sure that was QML compilation until I proved otherwise. It is not. A native build with compilation enabled loads QML just as fast. The AppImage pays for FUSE and for reading a bundled Qt out of a squashfs image, and no application-side change will fix that.

Sec 05

The renderer nobody picks

text

I spent a day making the Vulkan path work and then measured Qt's software renderer out of completeness. It starts 14 ms faster and uses 25 MB less.

text

The 25 MB has a name. About 28.5 MB of resident memory belongs to libLLVM, which Mesa links into the Radeon Vulkan driver even though that driver compiles shaders with its own compiler and never calls into LLVM. Its constructors run when the library loads. There is no application-side change that can decline it. That single unused dependency is the entire Vulkan versus software memory gap.

instrument
FIG. 6Where 113.6 MB goesMB

Resident memory grouped by mapping, Vulkan build, at rest.

FIG. 6One mapping the application never calls is the whole Vulkan memory premium.

Raw values
PSS (MB)
libLLVM, never called28.5
anonymous26.8
Qt libraries, 13 of them20.1
everything else11.6
heap9.60
hyprfm binary7.40
libvulkan_radeon4.60
ICU3.40
QML JS heap1.60
instrument
FIG. 7Memory after a full interaction sessionMB

Navigating, tabs, split view, scrolling, then measured.

FIG. 7Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
PSS (MB)
Qt software175
OpenGL340
Vulkan342
before this work351
llvmpipe814
text

Vulkan stays the default anyway, because startup is not the only thing that matters: it keeps work off the GUI thread and it does not spend a core on filling pixels. And llvmpipe, the software OpenGL path, is in that chart as a warning. It reaches 814 MB and 1.66 s of CPU, and it is what a user gets on a machine with no render node if the app does not check. HyprFM now checks, and a machine with no GPU at all maps a window in 166 ms at 79 MB, where it used to take 427 ms, 290 MB and nearly two seconds of CPU.

text

Whether software rendering should become the default is still open, and I refuse to decide it from this laptop. The whole argument for software rendering here is measured on a 1080p display, where fill rate costs nothing. On a HiDPI screen the answer could easily flip, and I do not own one.

Sec 06

Does it feel faster?

text

Startup is the number people quote, but it happens once. So there is a second harness that drives real interactions and measures how long each one takes to reach a painted frame, interleaved against a binary built from the commit before the optimisation pass.

instrument
FIG. 8Interaction latency, A against Bms

Two rounds of ten repetitions, alternating between the two binaries.

before the passafter

FIG. 8Lower is better. Every bar is a median of repeated runs, never a single launch.

Raw values
before the pass (ms)after (ms)
into a 10k directory70.739.6
the same one again72.544.9
open a new tab4841.5
into /usr/bin41.237.5
open split view28.725.3
text

Fourteen other actions came out within 2 ms either way, which is the correct result: the directory scan work should show up in directory scans and nowhere else.

text

One action looked like a 25 ms regression for a while. The context menu's first open is bimodal, 34 ms or 67 ms, one frame or two, and its median flips between rounds in both builds. You cannot subtract medians of a distribution like that. Scroll latency, meanwhile, is identical under every renderer at 16.6 ms, because it is capped by a 60 Hz display. Any benchmark on this monitor that claims to rank renderers by frame latency is measuring my monitor.

Sec 07

Four things I had to take back

This is the part of the log I would keep if I had to delete the rest.

logged
logged
logged
text

Being fast is not a defence. A file type resolver that is wrong twice in two thousand files is wrong in the preview pane, in the icon, and in the menu that decides what you are allowed to do with the file.

logged
text

Two smaller ones, for completeness. I recorded that ahead-of-time QML compilation was not worth it, which was true for the case I measured, a native build with a warm cache, and wrong as a general statement, since the warm cache only exists after a first launch. And I spent a while blaming a test failure on parallelism when it was failing because I had exported an environment variable that made Qt draw to nowhere, while the test counted real windows.

remark

Every wrong number in that list came from the same habit: I measured once, got a plausible answer, and stopped. The correct number takes ten runs and four more minutes.

Sec 08

Traps, and what they cost

Each of these cost at least an hour. They are in no particular order because pain has no ordering.

checklist
  • A profiler changes what it measures. Qt's QML profiler forces recompilation even when compiled units are linked into the binary, so the compilation rows in its output are fabricated. I believed them for a while.
  • A busy-wait in the benchmark drowns the signal. Whole-session CPU came out at 53 to 55 s for every renderer except one, because the settle loop was pegging a core and reporting it as the app's.
  • pkill -f pattern kills the calling shell when the pattern appears in the shell's own command line, including inside a heredoc. I did this to myself five times before moving every kill pattern into a script file. Five.
  • HyprFM rewrites its session file on quit, so a killed instance can save after a script has restored the user's file. This destroyed a real session of mine once, which is why every benchmark launch now runs against a sandboxed home.
  • If your own copy of the app is already running, a benchmark launch is forwarded to it over the single-instance socket and no new window appears. The harness waits patiently for a window that already exists.
  • A scratch file called bisect.py shadows Python's standard library bisect, which breaks statistics, which is how the harness computes medians. Half an hour, and it was not even a real bug.
Sec 09

Where it stands

readout
<tr><th>Measurebeforenow
Launch to window, home336 ms163 to 170 ms
Launch to window, 10k directory332 ms169 ms
Memory at rest170 to 184 MB103 to 114 MB
Navigate into a 10k directory70.7 ms39.6 ms
Second window347 ms81 ms
First launch after an updateplus 400 ms of QML compilationnone
A machine with no GPU427 ms, 290 MB, 1.93 s CPU166 ms, 79 MB, 0.28 s CPU
PDF page thumbnail, repeated207 ms0.05 ms
subject

Still open

text

Loading the QML engine is 58 ms and I have taken it apart: 4 ms to warm the engine, 6 for the Controls import, 14 for the toolbar, 9 for the file view container, 7 for the sidebar, and so on down to nothing. There is no hotspot. It is a user interface with a lot of buttons in it, and the only way to make it cheaper is to build less of it before the first frame.

text

Which leads to the number I find funniest in the whole log. At the first frame the window contains 662 items, and 257 of them are invisible. Thirty-nine percent of the interface is built to not be seen: a hidden text item per file used only to measure whether a filename fits on two lines, whichever of the icon or the thumbnail is not currently showing, two inactive badge loaders per row. That is worth about 2 ms and a lighter row while scrolling. It is recorded as a finding, not shipped as a change, because I ran out of week.

text

Also open: the settings window's first open at 85 ms, about 60 of which is creating a swapchain for a second top level window, the AppImage's FUSE tax, and one clipboard call that is still synchronous on an explicit paste.

text

The long version of this log, with commit hashes and the measurements in the order they happened, lives in the HyprFM repository as docs/performance-history.md. It is less polite than this sheet.

Layout 4 of 5. Figures are ECharts canvases; the library is imported when the first panel comes into view, not on load.

DRAWN BYS. P. Jim
SHEET9.4 of 9
REV2026-09
LayoutConsole
Log V4

Send a message

Replies usually within a day. Or write directly to soyeb.jim@gmail.com.