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.2Written 20 Sep 2026About HyprFMReading 18 min
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.
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.
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.
Three things are called startup
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.
| <tr><th>Metric | What it is | How it is taken |
|---|---|---|
| map | from exec to Hyprland announcing the window | a script listening on Hyprland's event socket |
| first frame | application object built to the first swapped frame | marks inside the process |
| idle | launch until the process tree stops burning CPU | sampling the kernel's own counters |
| PSS | memory it actually costs the machine, 4 s in | smaps_rollup |
The harness rules matter more than the tooling, and every one of them is scar tissue:
- 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.
That last one took an hour of staring at a hung script to work out.
The descent
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.
The numbers behind this figure
| map, home (ms) | |
|---|---|
| 30 Aug, baseline | 336 |
| 19 Sep, renderer, lazy UI, lighter models | 194 |
| 20 Sep, full benchmark as reference | 180 |
| 20 Sep, collation keys, readdir, warm Vulkan | 170 |
| 20 Sep, font stack on a worker | 163 |
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.
Ask the GPU what it is, once
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.
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.
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.
Build less of the UI before showing it
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.
1.3 KB per file, times ten thousand
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.
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.
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.
Two ways to not read a directory
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.
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.
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.
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.
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.
The numbers behind this figure
| listing (ms) | sort (ms) | building rows (ms) | |
|---|---|---|---|
| before | 18 | 20 | 0.90 |
| collation keys | 15.5 | 6.20 | 1 |
| and readdir | 4 | 7 | — |
One process, every window
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.
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.
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.
The update cliff
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.
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.
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.
The numbers behind this figure
| shipping .qml (ms) | precompiled (ms) | |
|---|---|---|
| cold cache | 560 | 161 |
| warm cache | 133 | 132 |
| two branches, alternating | 743 | 336 |
The 6.5 ms every Qt process pays once
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.
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.
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.
Open the window on the folder you were asked for
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.
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.
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.
Home directory, warm page cache.
FIG. 4Lower is better. Every bar is a median of repeated runs, never a single launch.
The numbers behind this figure
| map (ms) | |
|---|---|
| HyprFM, software | 163 |
| HyprFM, Vulkan | 180 |
| Dolphin | 194 |
| HyprFM, August build | 336 |
| HyprFM AppImage | 338 |
| Nautilus | 718 |
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.
The numbers behind this figure
| PSS (MB) | |
|---|---|
| Dolphin | 64 |
| HyprFM, software | 79 |
| HyprFM, Vulkan | 103 |
| Nautilus | 103 |
| HyprFM, August build | 170 |
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.
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.
The renderer nobody picks
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.
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.
Resident memory grouped by mapping, Vulkan build, at rest.
FIG. 6One mapping the application never calls is the whole Vulkan memory premium.
The numbers behind this figure
| PSS (MB) | |
|---|---|
| libLLVM, never called | 28.5 |
| anonymous | 26.8 |
| Qt libraries, 13 of them | 20.1 |
| everything else | 11.6 |
| heap | 9.60 |
| hyprfm binary | 7.40 |
| libvulkan_radeon | 4.60 |
| ICU | 3.40 |
| QML JS heap | 1.60 |
Navigating, tabs, split view, scrolling, then measured.
FIG. 7Lower is better. Every bar is a median of repeated runs, never a single launch.
The numbers behind this figure
| PSS (MB) | |
|---|---|
| Qt software | 175 |
| OpenGL | 340 |
| Vulkan | 342 |
| before this work | 351 |
| llvmpipe | 814 |
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.
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.
Does it feel faster?
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.
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.
The numbers behind this figure
| before the pass (ms) | after (ms) | |
|---|---|---|
| into a 10k directory | 70.7 | 39.6 |
| the same one again | 72.5 | 44.9 |
| open a new tab | 48 | 41.5 |
| into /usr/bin | 41.2 | 37.5 |
| open split view | 28.7 | 25.3 |
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.
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.
Four things I had to take back
This is the part of the log I would keep if I had to delete the rest.
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.
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.
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.
Traps, and what they cost
Each of these cost at least an hour. They are in no particular order because pain has no ordering.
- 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 patternkills 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.pyshadows Python's standard librarybisect, which breaksstatistics, which is how the harness computes medians. Half an hour, and it was not even a real bug.
Where it stands
| <tr><th>Measure | before | now |
|---|---|---|
| Launch to window, home | 336 ms | 163 to 170 ms |
| Launch to window, 10k directory | 332 ms | 169 ms |
| Memory at rest | 170 to 184 MB | 103 to 114 MB |
| Navigate into a 10k directory | 70.7 ms | 39.6 ms |
| Second window | 347 ms | 81 ms |
| First launch after an update | plus 400 ms of QML compilation | none |
| A machine with no GPU | 427 ms, 290 MB, 1.93 s CPU | 166 ms, 79 MB, 0.28 s CPU |
| PDF page thumbnail, repeated | 207 ms | 0.05 ms |
Still open
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.
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.
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.
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 2 of 5. Figures drawn with Observable Plot during the build and served as inline SVG; the browser downloads no charting library.