KDE 錯誤追蹤器中有一個幾乎和部分貢獻者年紀一樣老的錯誤:bug 342056,「Ridiculously slow file copy (multiple small files)」,由 Alexander Nestorov 在 2014 年回報。這份報告直指痛處:複製一個 15 GB、約 300 萬個小檔案的資料夾,在 KDE 中需要 5 到 10 小時,而使用 rsync 只需約 20 分鐘。一位留言者總結了當時的心情:
超過幾個檔案以上,我通常都用 cp 和 rsync,而不是 dolphin。
這個錯誤一直在我腦中揮之不去,所以這篇文章就是要介紹如何終於解決它。我一直致力於 KIO 的複製路徑,並想帶大家看看時間都花在哪裡、解釋背後的歷史,並展示跨 KIO 版本的數字,包含純 cp 作為參考。
任何最佳化工作都先從量測開始:
複製 5000 個小檔案時,KIO 6.28 的阻擋時間分布,沒有單一熱點。每個檔案都會在短暫的系統呼叫連發中阻擋:讀取掛載表(/proc/self/mountinfo)、開啟來源與目的、以及透過內部 socket 往返 worker 的指令。socket 往返(傳送、接收以及等待回應)就是以紅色標示的畫面,大約佔了阻擋時間的 15%,分散成薄薄的片段橫跨兩個執行緒,因為數千個檔案每個都要付出代價。綠色畫面則是複製作業無法避免的真實檔案系統工作:讀取 statx 與 openat、使用 copy_file_range 移動位元組,以及底層的 ext4 中繼資料更新。這每檔案一陣的風暴,而不是任何單一呼叫,正是本文接下來要討論的內容。(Off-CPU 火焰圖來自 sched:sched_switch,依阻擋切換次數加權;點擊即可開啟完整 SVG。)
一點歷史
KIO 是 KDE 軟體中幾乎所有檔案操作的幕後層級,從 Dolphin 的複製貼上,到讓你能像開啟本地檔案一樣開啟 sftp:// 或 smb:// URL 的網路透明度。它的設計可追溯到 2000 年左右的 KDE 2 時代。當時「如何進行 I/O 而不凍結 UI」的答案不是執行緒,而是獨立程序:針對特定協定啟動一個 worker(過去稱為 kioslaves),透過 socket 與應用程式溝通。這早於 Linux 上可用的執行緒(Native POSIX Thread Library 直到 2003 年的 Linux 2.6 才出現),所以程序加上 socket IPC 是可攜且穩健的選擇,至今對網路協定來說仍然如此:如果 smb:// worker 當掉,檔案管理員不會跟著一起掛掉。
但另一面是成本。每個請求與回應都會序列化並透過 socket 傳送,雙方都要經過事件迴圈。對於 file:// 來說,這個開銷毫無價值,因為另一端並非不受信任的網路,只是本機磁碟。
2022 年,David Faure 改變了這一切:Implement running KIO workers in-process using a thread 已在 Frameworks 5.95 實作。從此 file worker 改在應用程式內的執行緒上執行,而非獨立程序(其他協定仍保留獨立程序以確保穩健性)。這移除了程序啟動與上下文切換的成本。
執行緒與應用程式之間不再有 socket(6.29)
但仍有個隱而未顯的問題:即使是同程序,worker 執行緒與應用程式之間仍透過 socketpair 互相溝通,每個指令都像獨立程序一樣序列化。一旦改用執行緒,這個 socket 就純粹是開銷。
這就是第一張火焰圖中紅色標示的部分。
因此,同程序 worker 現在改用真正的記憶體內傳輸,而非迴送 socket。新的 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)。
在 6.29 上使用批次複製進行相同的複製:紅色的 socket 往返已消失。移除它們的兩件事是:前一節提到的記憶體內傳輸(根本沒有 socketpair),以及批次複製將一整串檔案折疊成單一指令,而非每個檔案各走一趟。剩下的就是真實的檔案系統工作:綠色畫面是複製本身、copy_file_range 移動位元組、openat 與 statx 呼叫,以及底層的 ext4 中繼資料。那片寬廣且非綠色的高原是基準測試迴圈在下次執行前刪除每個 pass 的檔案(unlink),這是拆解而非複製的一部分。
在複製前,CopyJob 會 stat 每個來源,這又是另一個逐檔往返。後續更新會透過同樣的同程序通道批次處理 stat 階段。這就是下表中的 batch-copy-stat 欄位。
這兩項更新仍在審核中,目標是 6.29 之後的版本,因此表格中的欄位應視為即將到來的預覽,而非你現在就能安裝的功能。
數字
方法:13 代 Intel Core i7-1365U 筆電,所有版本皆以 RelWithDebInfo 建置且未啟用任何 sanitizer,複製到真實的 ext4 磁碟區(因此沒有 reflink 捷徑,copy_file_range 會移動真實位元組)。每個 KIO 版本都提供自己的 libKIOCore 與 kio_file worker。時間為 5 次執行(大型集合為 3 次)的中位數,且在捨棄暖機執行後量測,因此每個版本的快取皆為暖機且相等。cp -r --preserve=mode,timestamps 作為下限包含在內,因為這正是錯誤回報者用來取代 Dolphin 的工具。
5000 個 4 KB 的檔案(來自錯誤報告的大量小檔案案例):
| 版本 | 中位數時間 |
|---|---|
cp -r (coreutils) | 81 ms |
| KIO 6.28.0 | 1616 ms |
| KIO 6.29-dev (master) | 396 ms |
| KIO 6.29-dev + batch-copy | 88 ms |
| KIO 6.29-dev + batch-copy-stat | 93 ms |
1000 個 5 MB 的檔案(大量資料,複製作業受 I/O 限制):
| 版本 | 中位數時間 |
|---|---|
cp -r (coreutils) | 1312 ms |
| KIO 6.28.0 | 1997 ms |
| KIO 6.29-dev (master) | 1687 ms |
| KIO 6.29-dev + batch-copy | 1506 ms |
| KIO 6.29-dev + batch-copy-stat | 1454 ms |
小檔案案例就是這篇文章的故事。KIO 6.28 比 cp 慢了大約 20 倍,這正是 2014 年報告抱怨的比例。移除同程序 worker 的 socket,加上不再每個檔案都探測目的端檔案系統一次,將時間從約 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.