在 KDE 的缺陷跟踪系统中有一个几乎和我们某些贡献者一样古老的缺陷:bug 342056,“Ridiculously slow file copy (multiple small files)”,由 Alexander Nestorov 在 2014 年报告。该报告直言不讳:复制一个包含约 300 万个小文件、15 GB 的文件夹在 KDE 中耗时 5 到 10 小时,而使用 rsync 只需约 20 分钟。一位评论者总结了这种感受:

超过几个文件时,我通常会用 cp 和 rsync,而不是 dolphin。

这个缺陷一直萦绕在我心头,所以这篇文章就是关于最终修复它的故事。我一直在研究 KIO 的复制路径,我想带大家看看时间都花在哪里、解释其背后的历史,并展示跨 KIO 版本的数值对比(包括普通 cp 作为参考)。

任何优化都从测量开始:

Off-CPU flame graph of a KIO 6.28 copy of 5000 small files, with the socket round-trip frames highlighted

KIO 6.28 复制 5000 个小文件时阻塞时间的分布,没有单一热点。每个文件都会在短系统调用中阻塞:读取挂载表(/proc/self/mountinfo)、打开源文件和目标文件,以及通过内部套接字往返 worker 命令。套接字往返(发送、接收及等待每个回复)用红色高亮显示,约占阻塞时间的 15%,分散在两个线程中,因为成千上万个文件都要支付这些开销。绿色帧是复制无法避免的真实文件系统工作:statxopenat 调用、copy_file_range 移动字节,以及底层的 ext4 元数据更新。真正的问题是这种每文件的“风暴”,而不是任何一个单独调用。本文其余部分将讨论这个问题。(Off-CPU 火焰图来自 sched:sched_switch,按阻塞切换次数加权;点击可打开完整 SVG。)

一点历史

KIO 是 KDE 软件背后几乎所有文件操作的底层,从 Dolphin 的复制粘贴,到能把 sftp://smb:// URL 当作本地文件打开的网络透明度。它的设计可追溯到 KDE 2 时代,大约 2000 年。那时候,要在不冻结 UI 的情况下进行 I/O,答案不是线程,而是单独的进程:一个 worker(我们以前叫 kioslaves)会为某个协议启动,并通过套接字与应用程序通信。这早于 Linux 上可用的线程(Native POSIX Thread Library 直到 2003 年才进入 Linux 2.6),所以进程加套接字 IPC 是可移植且健壮的选择,对于今天的网络协议依然如此:如果 smb:// worker 崩溃,文件管理器不会跟着崩溃。

代价是开销。每个请求及其回复都会被序列化并通过套接字发送,两端各有一次事件循环跳转。对于 file://,这种开销毫无意义,因为另一端并不是不可信的网络,而只是本地磁盘。

2022 年,David Faure 改变了这一点:Implement running KIO workers in-process using a thread 已在 Frameworks 5.95 中落地。从那时起,file worker 就在应用程序内部的一个线程中运行,而不是作为一个单独的进程(其他协议出于健壮性考虑仍保留在进程外)。这消除了进程启动和上下文切换的开销。

线程与应用之间不再有套接字(6.29)

但有一个问题一直隐藏在明处:即使在进程内,worker 线程和应用程序仍然通过 socketpair 相互通信,像独立进程一样序列化每条命令。一旦使用了线程,这个套接字就成了纯开销。

这就是第一张火焰图中用红色高亮的部份。

因此,进程内 worker 现在使用真正的内存内传输,而不是回环套接字。新的 ThreadConnectionBackend 直接在对等线程之间处理命令和读取的实际数据缓冲区,没有序列化也没有系统调用,使得进程内文件读取实现零拷贝。这是下面数值中最大的一次飞跃,已在 master 分支上落地,面向 6.29(kio!2279)。

下一步:批量复制与 stat(审核中)

上述改动让每个命令变得廉价。剩下的代价是命令数量依然很多:复制 N 个文件就是 N 个独立的 file_copy 任务,每个都要经过任务调度器并往返 worker 一次。对于大量小文件,固定的每文件开销(而非数据本身)才是主导因素。

CopyJob 现在可以将一批普通的本地到本地文件一次性派发给 worker,worker 再去复制它们(使用 copy_file_range,在支持 reflink 的文件系统如 Btrfs 或 XFS 上则使用 reflink)。每个文件仍然会收到进度信号、字节统计、撤销条目等;worker 无法盲目处理的情况(目标已存在、源不可读、重命名或跳过决策等)会交还给正常的逐文件路径。为了防止挂载点卡死,批量路径采用了保守的触发条件(kio!2282)。

Off-CPU flame graph after batch-copy, with the socket round-trip frames gone

6.29 上使用 batch-copy 后的相同复制:红色套接字往返已消失。移除它们的原因有两点:上一节提到的内存内传输(完全没有 socketpair),以及 batch-copy 将一整批文件折叠成一条命令而不是每个文件一次往返。剩下的都是真实的文件系统工作:绿色帧是复制本身、copy_file_range 移动字节、openatstatx 调用,以及底层的 ext4 元数据。宽阔的非绿色平台是基准测试循环在下一轮运行前删除各次生成的文件(unlink),属于清理而非复制的一部分。

在复制之前,CopyJob 会对每个源文件执行 stat,这又是每文件的往返。后续改动通过相同的进程内通道批量执行 stat 阶段,对应下表中的 batch-copy-stat 列。

这两项改动仍在审核中,目标是 6.29 之后的版本,因此表格中的对应列应视为即将到来的预览,而非今天就能安装的功能。

数值对比

测试方法:第 13 代 Intel Core i7-1365U 笔记本电脑,所有构建均使用 RelWithDebInfo 且无任何 sanitizer,目标是真实 ext4 卷(因此没有 reflink 捷径,copy_file_range 会真正移动字节)。每个 KIO 版本同时提供自己的 libKIOCorekio_file worker。时间为 5 次运行的中位数(大数据集为 3 次),丢弃预热轮次,确保缓存对每个版本都处于相同状态。包含 cp -r --preserve=mode,timestamps 作为下限,因为这是缺陷报告者当时放弃 Dolphin 后改用的工具。

5000 个 4 KB 文件(缺陷中提到的大量小文件场景):

版本中位时间
cp -r (coreutils)81 ms
KIO 6.28.01616 ms
KIO 6.29-dev (master)396 ms
KIO 6.29-dev + batch-copy88 ms
KIO 6.29-dev + batch-copy-stat93 ms

1000 个 5 MB 文件(大块数据,复制受 I/O 限制):

版本中位时间
cp -r (coreutils)1312 ms
KIO 6.28.01997 ms
KIO 6.29-dev (master)1687 ms
KIO 6.29-dev + batch-copy1506 ms
KIO 6.29-dev + batch-copy-stat1454 ms

小文件场景才是重点。KIO 6.28 比 cp 慢约 20 倍,这正是 2014 年报告抱怨的比例。移除进程内 worker 的套接字,加上不再为每个文件探测一次目标文件系统,把时间从约 1.6 秒降到 0.4 秒(约 4 倍提升)。批量复制进一步把时间降到 88 毫秒,基本达到 cp 速度,比 6.28 快约 18 倍。那句“我用 cp 而不是 dolphin”的说法终于有了答案。

大文件场景在这些工作之前就已经处理得很好,表格也显示了这一点:复制受限于移动字节,因此即使 KIO 6.28 也接近 cp,而每文件优化对数值影响很小(batch-copy 落在 cp 约 15% 以内)。这正是本次努力针对大量小文件而不是大文件的原因——在那里,固定的每文件开销(而非数据)才是主导因素。

batch-copy 和 batch-copy-stat 在本次测试中打平:基准测试复制的是单个目录,其条目在一次列举中全部到达,因此 batch-stat 没有可批量处理的内容。它在另一种场景中更有帮助——复制大量单独选择的文件,这些文件原本会各自进行一次 stat 往返。因为它移除的是往返而非 stat 调用本身,所以当 stat 开销较低(而非磁盘慢)时收益最大。在本次测试中,两者可视为相等。

cp 几乎肯定会略微领先,这是设计使然:KIO 做的远不止复制字节。它会报告每文件进度、支持暂停与取消、保留撤销记录、保留权限和时间戳、解决冲突,并通知任何打开的文件管理器发生了什么变化以刷新视图。这些工作并非免费,目标也从来不是击败 cp,而是让本地场景足够快,以至于“用 cp 代替 Dolphin”不再是显而易见的选择。

注意事项与后续工作

这些是单台 ext4 磁盘、暖缓存的笔记本数值,目的是版本间对比,而非绝对值。在 Btrfs 或 XFS 上,批量路径会使用 reflink 而非复制,这会完全改变大文件场景。批量快速路径仅对普通本地到本地复制且文件系统响应正常时生效;其他情况(网络、FAT/NTFS、需要对话框的冲突)会回退到现有的逐文件路径,保持不变。

感谢 David Faure 实现了进程内 worker 线程,使这一切成为可能;感谢 Frank Reininghaus 早前的列举修复;也感谢 Alexander Nestorov 提交了一份历久弥新的缺陷报告,至今仍有价值被关闭。

That's all, folks.