Skip to content

Dolphin 26.08 and KIO, part two: what got faster and smaller

Thursday, 27 August 2026  |  Méven Car

Tags:  Dolphin  KIO  performance

This is the second half of what changed in Dolphin 26.08. The first half is what you can see: the features, the behaviour changes, the bugs. This one is what you can measure.

Dolphin does very little file work itself. It asks KIO, so the numbers below are mostly KIO numbers: 6.25 is the version that shipped alongside Dolphin 26.04, 6.29 is the one that ships alongside 26.08, four framework releases and 294 commits apart. The last section is 6.30, the release still to come, since several of its changes are large enough to be worth showing early.

Every figure here was measured on the same machine, with the two versions built the same way and run one after another, round by round. Where a number is not solid I say so rather than rounding it up.

Memory and object lifetimes

The 26.04 post ended by saying the next thing was to point LeakSanitizer at Dolphin. It turned into a lot of fixes, and into a CI job: KIO now runs its tests under LeakSanitizer on every merge request, which is what stops the list below from growing back.

Sebastian Englbrecht was the busiest contributor to Dolphin this cycle by commit count, and most of that work was lifetime and ownership: killing in-flight KIO jobs in destructors, deleting the Konsole part, destroying owned objects before the KIO cache tears down and fixing unowned allocations. He also added a smoke test, eight places-panel unit tests, and a rule forbidding bare QTest::qWait() with the existing waits replaced by signal-based ones, which is the sort of thing that makes the rest of the CI trustworthy. Wendi Gan fixed an occasional use-after-free crash in KConfig::sync() during exit, closing two reports, 516481 and 518433.

My own share was in both projects. In Dolphin: aborting the folder stat job when the Information Panel is destroyed, a use-after-free of the current version-control plugin, a leaked submenu in the folder-icon action and a leak in the trash settings page on exit. In KIO, besides turning the sanitiser on in CI and teaching the tests to wait for what they allocate to be deleted, three fixes are ones a user could have met: the "Move Into New Folder" drop action had done nothing at all since 6.25, because the menu was parented to a plugin destroyed the moment the action fires while the folder creation it starts runs asynchronously; FilePreviewJob armed a repeating timeout timer it never stopped, so with a context menu held open the orphaned timer could fire on a finished job and re-enter emitResult(); and KDirModel dereferenced the node of a directory that had already left the model when an earlier listing completed late.

Performance improvements in KIO between 6.25 and 6.29

The big one is the in-process worker transport. For local files KIO has for some time run the file worker in a thread of the application rather than a separate process. Even in that thread it still talked to the application over a socket, serialising every command and every block of file content through a QLocalSocket pair. In 6.29 that channel is a new ThreadConnectionBackend, an in-memory queue, and kio_file hands over owned byte arrays instead of copying them, which makes in-process reads zero-copy. This is the first half of the improvements described in the copy post from July.

Second, CopyJob no longer re-probes the destination filesystem type for every file. It called KFileSystemType::fileSystemType() per file for the FAT and NTFS checks, and on libmount builds each of those calls parses the entire mount table. Copying N files parsed /proc/self/mountinfo N times. It is determined once now. Each parse is about 57 microseconds on a host with 39 mounts, and it scales with the number of mounts.

Third, and this is the memory one, the directory lister cache got much smaller. A directory that no lister was showing any more went into a cache of ten and stayed there until nine others had displaced it. For a picture folder of 50000 files that is a lot of KFileItem and UDSEntry retained for nothing. The cache now holds three directories, which is what going back a level or two actually needs, and drops anything no lister has asked for in three minutes.

Fourth, UDSEntry got smaller, which saves memory. An entry now keeps its numbers and its strings in two separate vectors, which buys a byte for every field an entry holds. And loading now sizes each vector from what the entry actually holds: both vectors used to be sized from the count of fields alone, a third of it for the strings and two thirds for the numbers. A stat of a local file gives one string, the name, and eight numbers, so the numbers grew past their room while the strings kept more than they needed. That sizing change on its own takes a listing of 200000 local files from 528 bytes an entry to 400, which is 105.6 MB down to 80 MB, 25.6 MB saved. Loading also stopped hunting for a shared value on the fields where values cannot repeat: no two entries of a listing carry the same name, url or local path, so comparing them with the entry before never found anything to share. Reading one entry of a local folder off the wire went from 530 to 488 nanoseconds.

Smaller ones worth naming: KFileItem no longer reads .directory on slow filesystems when working out an icon name (6.28, closing bug 519189), Sebastian Englbrecht fixed QPluginLoader, QLibraryPrivate and thread lifecycle leaks in the worker machinery (6.28).

Nineteen reports were closed by fixes in KIO across those four releases. Most of them are filed against the applications rather than against KIO, since that is where a user meets the problem: Dolphin, Plasma, Konsole, KWin.

Benchmarks

Methodology

I built KIO 6.25.0 and 6.29.0 from their tags, Release on gcc 16.2.1 and Qt 6.11.1, and ran the same harness against each. The arms are interleaved one round at a time, so a CPU frequency or load excursion hits both equally, and the best of N rounds is reported.

Everything ran on one 13th Gen Core i7-1365U with 30 GB of RAM, kernel 7.1.4, on ext4, with the benchmark processes pinned to the same two cores.

Copying

KIO::copy() of N files into an empty directory, timed from job start to the result signal. Source files are created untimed, the destination is cleared untimed. cp -r is there as a raw-tool floor, not as a target.

Copying files into an empty directory. 1000 x 256 B: 421 ms in 6.25, 189 ms in 6.29, 34 ms for cp. 5000 x 256 B: 2279, 942, 176. 5000 x 4 KB: 2536, 1024, 188. 500 x 1 MB: 538, 327, 230.

Copying many small files is more than twice as fast as it was in April. The gain falls off as files get larger, which is what you would expect: the fix is to the per-file overhead, and once each file carries a megabyte of actual I/O the overhead stops being what you are waiting for.

There is still a gap with cp, discussed at length in the July post. KIO is doing more than cp does, but not five times more, and the batching work that closes most of the rest of that gap is still in progress.

Deleting

KIO::del() over a freshly created tree, best of three rounds. rm -rf is the raw-tool floor here, as cp -r is above, and it is handed the same list of paths the job is given rather than the folder that holds them.

Deleting a tree. 1000 x 256 B: 100.0 ms in 6.25, 81.0 ms in 6.29, 13.6 ms for rm -rf. 5000 x 256 B: 509.3 ms, 406.8 ms and 69.4 ms.

Deletion improves less than copying, as expected: a delete carries no file content, so the zero-copy half of the transport work does nothing for it. What is left is the cheaper per-command round trip, 1.23x on the smaller tree and 1.25x on the larger one.

rm -rf is six times quicker than 6.29 on both trees, a wider gap than copying shows against cp. That fits: a copy at least spends real time moving bytes, where a delete is almost nothing but the per-file round trip, so what KIO adds is most of what there is to measure.

How the job is asked matters as much as the count. These runs hand KIO::del() the 5000 files one by one, the way a select-all in a file manager does. Handing it the one folder instead takes about a fifth of that on the same filesystem, because the worker then walks the tree itself instead of taking a command per file. rm given the folder rather than the 5000 paths goes from 69.4 ms to 66.8, which is the difference the filesystem charges for the two shapes. The rest of it is KIO's. That second path is the one the 6.30 section below measures.

Listing

One KCoreDirLister over an existing directory. The cold number is the first listing in a fresh process. The warm number is a later listing of the same directory, which KCoreDirListerCache answers without going to disk. The second chart is the resident growth while the lister is still showing the folder, which is what a file manager displaying that folder actually costs.

First listing of a folder. 5000 files: 136 ms in 6.25, 124 ms in 6.29. 50000 files: 4026 ms and 3443 ms.

Memory held when a folder is shown. 5000 files: 10.2 MB in 6.25, 8.8 MB in 6.29. 50000 files: 58.7 MB and 50.5 MB.

Listing is about 9 percent quicker on the 5000-file folder and 15 percent on the 50000-file one, and holds about 14 percent less memory in both cases. The memory part is the UDSEntry work described above. The warm listing is unchanged either way, 2.6 ms against 2.8 ms on the 5000-file folder and 37 ms on the 50000-file one, which is the cache answering rather than the disk.

Memory kept for folders you have left

Of everything measured here, this is what a long-running Dolphin notices most. Walk twelve directories one after another, the way you do going down and back up a tree, and then look at how much memory the process is still holding. Nothing is on screen in either case, so all of it is held for folders nobody is looking at any more.

Memory still held after walking twelve folders and leaving them. 12 x 5000 files: 63.5 MB in 6.25, 22.7 MB in 6.29, 2.8x less. 12 x 20000 files: 238.7 MB and 77.5 MB, 3.1x less.

In 6.25 a directory nothing was showing any more sat in a cache of ten and stayed until nine others displaced it. Browse through a few large folders and you are holding all of them. The cache now keeps three, and drops anything no lister has wanted for three minutes. Repeat listings do not suffer for it, which is what the warm listing above shows: re-opening a folder you just left still finds it. What the eleventh slot bought was going eleven folders back, and every user of a large folder paid for it.

6.30 improvements

None of this has shipped, so treat it as a preview rather than a result. 6.30 has several commits that touch exactly what this post measures, so I built it as a third arm and ran the same harness.

6.30 against 6.29, the longer runs. Listing 50000 files: 1232.9 ms down to 531.5 ms, -57.0%. Copying 5000 x 256 B: 909.3 ms down to 883.8 ms, -2.8%. Deleting a 20000-file tree: 319.9 ms down to 290.9 ms, -9.1%.

6.30 against 6.29, the quicker runs. Listing 5000 files: 36.0 ms down to 33.8 ms, -5.8%. Deleting a 5000-file tree: 80.9 ms down to 72.6 ms, -10.3%.

Allocations for one listing. 5000 files: 151,809 down to 116,393, -23.3%. 50000 files: 1,292,614 down to 983,567, -23.9%.

Peak memory listing 50000 files. Peak heap: 42.45 MB down to 41.68 MB, -1.8%. Peak resident: 81.05 MB down to 78.45 MB, -3.2%.

Most of what 6.30 gains comes from one change: handing over what a message carries as it is when the worker runs in the application's own process. kio_file runs in a thread of the application, but used to write its data down with QDataStream the way a process-based worker has to. Except it does not need to. Avoiding that serialisation is where the fewer allocations come from, and the entries cost less memory besides, since one no longer reserves room for fields most files never carry. Nothing changes for a worker in another process, which still has a socket between it and the application.

The other major improvement was a dormant issue. It had been there for years, and benchmarking made it visible: 50000 files listed in 1233 ms while 5000 listed in 36, 10 times the files taking 34 times as long, where listing should be close to linear.

A profile of the large case revealed 42 percent of the whole listing is KFileItem copy construction and destruction, all of it under KCoreDirListerCache::DirItem::insertSortedItems calling QList::reserve once per batch of entries. Except reserve has no relocatable fast path: on the growth path it allocates exactly the size asked for and copy-appends every element already there, even for a type declared Q_RELOCATABLE_TYPE as KFileItem. Since the previous call had sized the buffer exactly, every batch reallocated and copied the whole list. Heaptrack counted 239 such calls for 50000 files, so about six million copy constructions for fifty thousand items.

Deleting the reserve call and letting the list grow geometrically takes that listing from 1233 ms to 531 ms, over 2x quicker. What cost the time was inside those reallocations, copy-constructing every KFileItem already in the list, and a KFileItem copy is a refcount bump that allocates nothing at all. So none of it showed up in an allocation count, which is why it took a cycles profile to find.

Deleting a folder gained from af3d9e0e7, which removes a tree with the system's own calls instead of walking it, and how much depends on the shape of the tree. openat and unlinkat work from a descriptor for the directory an entry was read from, so they stop resolving a whole path for every entry, and the deeper the tree the more that is worth. Fifteen thousand files on tmpfs, deleted as one folder, with rm -rf over the same tree as the floor:

Deleting 15000 files as one folder, by how deep the tree is. 10 deep: 103.4 ms in 6.29, 84.6 ms in 6.30, 65.3 ms for rm -rf. 100 deep: 136.2, 78.1 and 60.2 ms. 300 deep: 227.2, 77.4 and 62.8 ms. 800 deep: 418.7, 78.7 and 64.0 ms.

6.29 costs more the deeper the tree, four times as much at 800 levels as at ten. 6.30 does not care how deep it is, and stays within a third of rm -rf at every depth, which is about as close as a job that reports progress and can be cancelled is going to get to a tool that does neither.

Closing

The copy path still has more in it than 6.29 ships: the batching that closes most of the remaining gap to cp is written and measured in Making KIO copy many files fast and is not finished yet. The listing path has an open question of its own, which is that an entry could carry its fields more cheaply again; a branch that does it trades about two percent of listing time for eleven percent of the memory an entry holds, and which of the two is the better trade depends on how many entries you are holding.

Enjoy the speed.

Comments