Linux管道到底能有多快?

【CSDN 編者按】本文作者通過一個示例程序,演示了通過Linux管道讀寫資料的性能最佳化過程,使吞吐量從最初的 3.5GiB/s,提高到最終的 65GiB/s。即便只是一個小例子,可它涉及的知識點卻不少,包括零複製操作、環形緩衝區、分頁與虛擬記憶體、同步開銷等,尤其對Linux核心中的拼接、分頁、虛擬記憶體地址對映等概念從原始碼級進行了分析。文章從概念到程式碼由淺入深、層層遞進,雖然是圍繞管道讀寫性能最佳化展開,但相信高性能應用程序或Linux核心的相關開發人員都會從中受益匪淺。

原文連結:https://mazzo.li/posts/fast-pipes.html

注:本文由CSDN組織翻譯,未經授權,禁止轉載!

作者 | Francesco 譯者 |王雪迎

本文將對一個通過管道寫入和讀取資料的測試程序進行反覆最佳化,以此研究 Unix 管道在 Linux 中的實現方式。

我們從一個吞吐量約為 3.5GiB/s 的簡單程序開始,並逐步將其性能提升 20 倍。性能提升通過使用 Linux 的 perf tooling 分析程序加以確認,程式碼可從GitHub上獲得(https://github.com/bitonic/pipes-speed-test)。

管道測試程序性能圖

管道測試程序性能圖

本文的靈感來自於閱讀一個高度最佳化的 FizzBuzz 程序。在我的膝上型電腦上,該程序以大約 35GiB/s 的速度將輸出推送到一個管道中。我們的第一個目標是達到這個速度,並會說明最佳化過程中每一步驟。之後還將增加一個 FizzBuzz 中不需要的額外性能改進措施,因為瓶頸實際上是計算輸出,而不是 IO,至少在我的機器上是這樣。

我們將按以下步驟進行:

  1. 首先是一個管道基準測試的慢版本;

  2. 說明管道內部如何實現,以及為什麼從中讀寫會慢;

  3. 說明如何利用vmsplice和splice系統調用,繞過一些(但不是全部!)緩慢環節;

  4. 說明Linux分頁,以及使用huge pages實現一個快速版本;

  5. 用忙循環代替輪詢以進行最後的最佳化;

  6. 總結

第4步是 Linux 核心中最重要的部分,因此即使你熟悉本文中討論的其他主題,也可能對它感興趣。對於不熟悉相關主題的讀者,我們假設你只了解 C 語言的基本知識。

挑戰第一個慢版本

挑戰第一個慢版本

我們先按照 StackOverflow 的發帖規則,來測試傳說中的 FizzBuzz 程序的性能:

    % ./fizzbuzz | pv >/dev/null422GiB 0:00:16 [36.2GiB/s]

    pv 指「pipe viewer」,是一種用於測量流經管道的資料吞吐量的簡便工具。所示為 fizzbuzz 以 36GiB/s 的速率產生輸出。

    fizzbuzz 將輸出寫入與二級快取一樣大的塊中,以在廉價訪問記憶體和最小化 IO 開銷之間取得良好平衡。

    在我的機器上,二級快取為 256KiB。本文中還是輸出 256KiB 的塊,但不做任何「計算」。我們本質上是想測量出程序寫入具有合理緩衝區大小的管道的吞吐量上限。

    fizzbuzz 使用 pv 測量速度,而我們的設置會略有不同:我們將在管道的兩端執行程序,這樣就可以完全控制從管道中推拉資料所涉及的程式碼。

    該程式碼可從 in my pipes-speed-test rep 獲得。write.cpp 實現寫入,read.cpp 實現讀取。write 一直重複寫入相同的 256KiB,read 讀取 10GiB 資料後終止,並以 GiB/s 為單位列印吞吐量。兩個可執行程序都接受各種命令列選項以更改其行為。

    從管道讀取和寫入的第一次測試將使用 write 和 read 系統調用,使用與 fizzbuzz 相同的緩衝區大小。下面所示為寫入端程式碼:

      int main() {size_t buf_size = 1 << 18; // 256KiBchar* buf = (char*) malloc(buf_size);memset((void*)buf, 'X', buf_size); // output Xswhile (true) {size_t remaining = buf_size;while (remaining > 0) {// Keep invoking `write` until we've written the entirety// of the buffer. Remember that write returns how much// it could write into the destination -- in this case,// our pipe.ssize_t written = write(STDOUT_FILENO, buf + (buf_size - remaining), remaining);remaining -= written;}}}

      為了簡潔起見,這段及後面的程式碼段都省略了所有錯誤檢查。memset 除了保證輸出可被列印,還起到了另一個作用,我們將在後面討論。

      所有的工作都是通過 write 調用完成的,其餘部分是確保整個緩衝區都被寫入。讀取端非常類似,只是將資料讀取到 buf 中,並在讀取足夠的資料時終止。

      構建後,資料庫中的程式碼可以按如下方式運行:

        % ./write | ./read3.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

        我們寫入相同的 256KiB 緩衝區,其中填充了 40960 次「X」,並測量吞吐量。令人煩惱的是,速度比 fizzbuzz 慢 10 倍!我們只是將位元組寫入管道,而沒做任何其他工作。事實證明,通過使用 write 和 read,我們無法獲得比這快得多的速度。

        write的問題

        write的問題

        為了找出運行程序的時間花在了什麼上,我們可以使用 perf:

          % perf record -g sh -c './write | ./read'3.2GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)[ perf record: Woken up 6 times to write data ][ perf record: Captured and wrote 2.851 MB perf.data (21201 samples) ]

          -g 選項指示 perf 記錄調用圖:這可以讓我們從上到下查看時間花費在哪裡。

          我們可以使用 perf report 來查看花費時間的地方。下面是一個稍加編輯的片段,詳細列出了 write 的時間花費所在:

            % perf report -g --symbol-filter=write-   48.05%     0.05%  write    libc-2.33.so       [.] __GI___libc_write- 48.04% __GI___libc_write- 47.69% entry_SYSCALL_64_after_hwframe- do_syscall_64- 47.54% ksys_write- 47.40% vfs_write- 47.23% new_sync_write- pipe_write+ 24.08% copy_page_from_iter+ 11.76% __alloc_pages+ 4.32% schedule+ 2.98% __wake_up_common_lock0.95% _raw_spin_lock_irq0.74% alloc_pages0.66% prepare_to_wait_event

            47% 的時間花在了 pipe_write 上,也就是我們在向管道寫入時,write 所幹的事情。這並不奇怪——我們花了大約一半的時間進行寫入,另一半時間進行讀取。

            在 pipe_write中,3/4 的時間用於複製或分配頁面(copy_page_from_iter和__alloc_page)。如果我們已經對核心和使用者空間之間的通訊是如何工作的有所了解,就會知道這有一定道理。不管怎樣,要完全理解發生了什麼,我們必須首先理解管道如何工作。

            管道是由什麼構成?

            管道是由什麼構成?

            在 include/linux/pipe_fs_i.h 中可以找到定義管道的資料結構,fs/pipe.c 中有對其進行的操作。

            Linux 管道是一個環形緩衝區,保存對資料寫入和讀取的頁面的引用:

            上圖中的環形緩衝區有 8 個槽位,但可能或多或少,默認為 16 個。x86-64 架構中每個頁面大小是 4KiB,但在其他架構中可能有所不同。這個管道總共可以容納 32KiB 的資料。這是一個關鍵點:每個管道都有一個上限,即它在滿之前可以容納的資料總量。

            圖中的陰影部分表示當前管道資料,非陰影部分表示管道中的空餘空間。

            有點反直覺,head 儲存管道的寫入端。也就是說,寫入程序將寫入 head 指向的緩衝區,如果需要移動到下一個緩衝區,則相應地增加 head。在寫緩衝區中,len 儲存我們在其中寫了多少。

            相反,tail 儲存管道的讀取端:讀取程序將從那裡開始使用管道。offset 指示從何處開始讀取。

            注意,tail 可以出現在 head 之後,如圖中所示,因為我們使用的是循環/環形緩衝區。還要注意,當我們沒有完全填滿管道時,一些槽位可能沒有使用——中間的 NULL 單元。如果管道已滿(頁面中沒有NULL和可用空間),write 將被阻塞。如果管道為空(全 NULL),則 read 將被阻塞。

            下面是 pipe_fs_i.h 中 C 資料結構的節略版本:

              struct pipe_inode_info {unsigned int head;unsigned int tail;struct pipe_buffer *bufs;};struct pipe_buffer {struct page *page;unsigned int offset, len;};

              這裡我們省略了許多欄位,也還沒有解釋 struct page 中存什麼,但這是理解如何從管道進行讀寫的關鍵資料結構。

              讀寫管道

              讀寫管道

              現在讓我們回到 pipe_write 的定義,嘗試理解前面顯示的 perf 輸出。pipe_write 工作原理簡要說明如下:

              1.如果管道已滿,等待空間並重新啟動;

              2.如果 head 當前指向的緩衝區有空間,首先填充該空間;

              3.當有空閒槽位,還有剩餘的位元組要寫時,分配新的頁面並填充它們,更新head。

              寫入管道時的操作

              寫入管道時的操作

              上述操作被一個鎖保護,pipe_write 根據需要獲取和釋放該鎖。

              pipe_read 是 pipe_ write 的映象,不同之處在於消費頁面,完全讀取頁面後將其釋放,並更新 tail。

              因此,當前的處理過程形成了一個令人非常不快的狀況:

              • 每個頁面複製兩次,一次從使用者記憶體複製到核心,另一次從核心複製到使用者記憶體;

              • 一次複製一個 4KiB 的頁面,期間還與諸如讀寫之間的同步、頁面分配與釋放等其他操作交織在一起;

              • 由於不斷分配新頁面,正在處理的記憶體可能不連續;

              • 處理期間需要一直獲取和釋放管道鎖。

              在本機上,順序 RAM 讀取速度約為 16GiB/s:

                % sysbench memory --memory-block-size=1G --memory-oper=read --threads=1 run...102400.00 MiB transferred (15921.22 MiB/sec)

                考慮到上面列出的所有問題,與單執行緒順序 RAM 速度相比,慢 4 倍便不足為怪。

                調整緩衝區或管道大小以減少系統調用和同步開銷,或者調整其他參數不會有多大幫助。幸運的是,有一種方法可以完全避免讀寫緩慢。

                用拼接進行改進

                用拼接進行改進

                這種將緩衝區從使用者記憶體複製到核心再複製回去,是需要進行快速 IO 的人經常遇到的棘手問題。一種常見的解決方案是將核心操作從處理過程中剔除,直接執行 IO 操作。例如,我們可以直接與網路卡互動,並繞過核心以低延遲聯網。

                通常當我們寫入套接字、檔案或本例的管道時,首先寫入核心中的某個緩衝區,然後讓核心完成其工作。在管道的情況下,管道就是核心中的一系列緩衝區。如果我們關注性能,則所有這些複製都是不可取的。

                幸好,當我們要在管道中移動資料時,Linux 包含系統調用以加快速度,而無需複製。具體而言:

                • splice 將資料從管道移動到檔案描述符,反之亦然;

                • vmsplice 將資料從使用者記憶體移動到管道中。

                關鍵是,這兩種操作都在不復制任何內容的情況下工作。

                既然我們知道了管道的工作原理,就可以大概想象這兩個操作是如何工作的:它們只是從某處「抓取」一個現有的緩衝區,然後將其放入管道環緩衝區,或者反過來,而不是在需要時分配新頁面:

                我們很快就會看到它是如何工作的。

                Splicing 實現

                Splicing 實現

                我們用 vmsplice 替換 write。vmsplice 簽名如下:

                  struct iovec {void  *iov_base; // Starting addresssize_t iov_len;  // Number of bytes};// Returns how much we've spliced into the pipessize_t vmsplice(int fd, const struct iovec *iov, size_t nr_segs, unsigned int flags);

                  fd是目標管道,struct iovec *iov 是將要移動到管道的緩衝區陣列。注意,vmsplice 返回「拼接」到管道中的數量,可能不是完整數量,就像 write 返回寫入的數量一樣。別忘了管道受其在環形緩衝區中的槽位數量的限制,而 vmsplice 不受此限制。

                  使用 vmsplice 時還需要小心一點。使用者記憶體是在不復制的情況下移動到管道中的,因此在重用拼接緩衝區之前,必須確保讀取端使用它。

                  為此,fizzbuzz 使用雙緩衝方案,其工作原理如下:

                  1. 將 256KiB 緩衝區一分為二;

                  2. 將管道大小設置為 128KiB,相當於將管道的環形緩衝區設置為具有128KiB/4KiB=32 個槽位;

                  3. 在寫入前半個緩衝區或使用 vmsplice 將其移動到管道中之間進行選擇,並對另一半緩衝區執行相同操作。

                  管道大小設置為 128KiB,並且等待 vmsplice 完全輸出一個 128KiB 緩衝區,這就保證了當完成一次 vmsplic 迭代時,我們已知前一個緩衝區已被完全讀取——否則無法將新的 128KiB 緩衝區完全 vmsplice 到 128KiB 管道中。

                  現在,我們實際上還沒有向緩衝區寫入任何內容,但我們將保留雙緩衝方案,因為任何實際寫入內容的程序都需要類似的方案。

                  我們的寫循環現在看起來像這樣:

                    int main() {size_t buf_size = 1 << 18; // 256KiBchar* buf = malloc(buf_size);memset((void*)buf, 'X', buf_size); // output Xschar* bufs[2] = { buf, buf + buf_size/2 };int buf_ix = 0;// Flip between the two buffers, splicing until we're done.while (true) {struct iovec bufvec = {.iov_base = bufs[buf_ix],.iov_len = buf_size/2};buf_ix = (buf_ix + 1) % 2;while (bufvec.iov_len > 0) {ssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, 0);bufvec.iov_base = (void*) (((char*) bufvec.iov_base) + ret);bufvec.iov_len -= ret;}}}

                    以下是使用 vmsplice 而不是 write 寫入的結果:

                      % ./write --write_with_vmsplice | ./read12.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

                      這使我們所需的複製量減少了一半,並且把吞吐量提高了三倍多,達到 12.7GiB/s。將讀取端更改為使用 splice 後,消除了所有複製,又獲得了 2.5 倍的加速:

                        % ./write --write_with_vmsplice | ./read --read_with_splice32.8GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
                        頁面改進

                        頁面改進

                        接下來呢?讓我們問 perf:

                          % perf record -g sh -c './write --write_with_vmsplice | ./read --read_with_splice'33.4GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)[ perf record: Woken up 1 times to write data ][ perf record: Captured and wrote 0.305 MB perf.data (2413 samples) ]% perf report --symbol-filter=vmsplice-   49.59%     0.38%  write    libc-2.33.so       [.] vmsplice- 49.46% vmsplice- 45.17% entry_SYSCALL_64_after_hwframe- do_syscall_64- 44.30% __do_sys_vmsplice+ 17.88% iov_iter_get_pages+ 16.57% __mutex_lock.constprop.03.89% add_to_pipe1.17% iov_iter_advance0.82% mutex_unlock0.75% pipe_lock2.01% __entry_text_start1.45% syscall_return_via_sysret

                          大部分時間消耗在鎖定管道以進行寫入(__mutex_lock.constprop.0)和將頁面移動到管道中(iov_iter_get_pages)兩個操作。關於鎖定能改進的不多,但我們可以提高 iov_iter_get_pages 的性能。

                          顧名思義,iov_iter_get_pages 將我們提供給 vmsplice 的 struct iovecs 轉換為 struct pages,以放入管道。為了理解這個函數的實際功能,以及如何加快它的速度,我們必須首先了解 CPU 和 Linux 如何組織頁面。

                          快速了解分頁

                          快速了解分頁

                          如你所知,進程並不直接引用 RAM 中的位置:而是分配虛擬記憶體地址,這些地址被解析為實體地址。這種抽象被稱為虛擬記憶體,我們在這裡不介紹它的各種優勢——但最明顯的是,它大大簡化了運行多個進程對同一物理記憶體的競爭。

                          無論何種情況下,每當我們執行一個程序並從記憶體載入/儲存到記憶體時,CPU 都需要將虛擬地址轉換為實體地址。儲存從每個虛擬地址到每個對應實體地址的對映是不現實的。因此,記憶體被分成大小一致的塊,叫做頁面,虛擬頁面被對映到物理頁面:

                          4KiB 並沒有什麼特別之處:每種架構都根據各種權衡選擇一種大小——我們將很快將探討其中的一些。

                          為了使這點更明確,讓我們想象一下使用 malloc 分配 10000 位元組:

                            void* buf = malloc(10000);printf("%p\n", buf);          // 0x6f42430

                            當我們使用它們時,我們的 10k 位元組在虛擬記憶體中看起來是連續的,但將被對映到 3 個不必連續的物理頁:

                            核心的任務之一是管理此對映,這體現在稱為頁表的資料結構中。CPU 指定頁表結構(因為它需要理解頁表),核心根據需要對其進行操作。在 x86-64 架構上,頁表是一個 4 級 512 路的樹,本身位於記憶體中。 該樹的每個節點是(你猜對了!)4KiB 大小,節點內指向下一級的每個條目為 8 位元組(4KiB/8bytes = 512)。這些條目包含下一個節點的地址以及其他元資料。

                            每個進程都有一個頁表——換句話說,每個進程都保留了一個虛擬地址空間。當核心上下文切換到進程時,它將特定暫存器 CR3 設置為該樹根的實體地址。然後,每當需要將虛擬地址轉換為實體地址時,CPU 將該地址拆分成若干段,並使用它們遍歷該樹,以及計算實體地址。

                            為了減少這些概念的抽象性,以下是虛擬地址 0x0000f2705af953c0 如何解析為實體地址的直觀描述:

                            搜尋從第一級開始,稱為「page global directory」,或 PGD,其物理位置儲存在 CR3 中。地址的前 16 位未使用。 我們使用接下來的 9 位 PGD 條目,並向下遍歷到第二級「page upper directory」,或 PUD。接下來的9位用於從 PUD 中選擇條目。該過程在下面的兩個級別上重複,即 PMD(「page middle directory」)和 PTE(「page table entry」)。PTE 告訴我們要查找的實際物理頁在哪裡,然後我們使用最後12位來查找頁內的偏移量。

                            頁面表的稀疏結構允許在需要新頁面時逐步建立對映。每當進程需要記憶體時,核心將用新條目更新頁表。

                            struct page 的作用

                            struct page 的作用

                            struct page 資料結構是這種機制的關鍵部分:它是核心用來引用單個物理頁、儲存其實體地址及其各種其他元資料的結構。例如,我們可以從 PTE 中包含的資訊(上面描述的頁表的最後一級)中獲得 struct page。一般來說,它被廣泛用於處理頁面相關事務的所有程式碼。

                            在管道場景下,struct page 用於將其資料保存在環形緩衝區中,正如我們已經看到的:

                              struct pipe_inode_info {unsigned int head;unsigned int tail;struct pipe_buffer *bufs;};struct pipe_buffer {struct page *page;unsigned int offset, len;};

                              然而,vmsplice 接受虛擬記憶體作為輸入,而 struct page 直接引用物理記憶體。

                              因此我們需要將任意的虛擬記憶體塊轉換成一組 struct pages。這正是 iov_iter_get_pages 所做的,也是我們花費一半時間的地方:

                                ssize_t iov_iter_get_pages(struct iov_iter *i,  // input: a sized buffer in virtual memorystruct page **pages, // output: the list of pages which back the input bufferssize_t maxsize,      // maximum number of bytes to getunsigned maxpages,   // maximum number of pages to getsize_t *start        // offset into first page, if the input buffer wasn't page-aligned);

                                struct iov_iter 是一種 Linux 核心資料結構,表示遍歷記憶體塊的各種方式,包括 struct iovec。在我們的例子中,它將指向 128KiB 的緩衝區。vmsplice 使用 iov_iter_get_pages 將輸入緩衝區轉換為一組 struct pages,並保存它們。既然已經知道了分頁的工作原理,你可以大概想象一下 iov_iter_get_pages 是如何工作的,下一節詳細解釋它。

                                我們已經快速了解了許多新概念,概括如下:

                                • 現代 CPU 使用虛擬記憶體進行處理;

                                • 記憶體按固定大小的頁面進行組織;

                                • CPU 使用將虛擬頁對映到物理頁的頁表,把虛擬地址轉換為實體地址;

                                • 核心根據需要向頁表添加和刪除條目;

                                • 管道是由對物理頁的引用構成的,因此 vmsplice 必須將一系列虛擬記憶體轉換為物理頁,並保存它們。

                                獲取頁的成本

                                獲取頁的成本

                                在 iov_iter_get_pages 中所花費的時間,實際上完全花在另一個函數,get_user_page_fast 中:

                                  % perf report -g --symbol-filter=iov_iter_get_pages-   17.08%     0.17%  write    [kernel.kallsyms]  [k] iov_iter_get_pages- 16.91% iov_iter_get_pages- 16.88% internal_get_user_pages_fast11.22% try_grab_compound_head

                                  get_user_pages_fast 是 iov_iter_get_ pages 的簡化版本:

                                    int get_user_pages_fast(// virtual address, page alignedunsigned long start,// number of pages to retrieveint nr_pages,// flags, the meaning of which we won't get intounsigned int gup_flags,// output physical pagesstruct page **pages)

                                    這裡的「user」(與「kernel」相對)指的是將虛擬頁轉換為對物理頁的引用。

                                    為了得到 struct pages,get_user_pages_fast 完全按照 CPU 操作,但在軟體中:它遍歷頁表以收集所有物理頁,將結果儲存在 struct pages 裡。我們的例子中是一個 128KiB 的緩衝區和 4KiB 的頁,因此 nr_pages = 32。get_user_page_fast 需要遍歷頁表樹,收集 32 個葉子,並將結果儲存在 32 個 struct pages 中。

                                    get_user_pages_fast 還需要確保物理頁在調用方不再需要之前不會被重用。這是通過在核心中使用儲存在 struct page 中的引用計數來實現的,該計數用於獲知物理頁在將來何時可以釋放和重用。get_user_pages_fast 的調用者必須在某個時候使用 put_page 再次釋放頁面,以減少引用計數。

                                    最後,get_user_pages_fast 根據虛擬地址是否已經在頁表中而表現不同。這就是 _fast 後綴的來源:核心首先將嘗試通過遍歷頁表來獲取已經存在的頁表條目和相應的 struct page,這成本相對較低,然後通過其他更高成本的方法返回生成 struct page。我們在開始時memset記憶體的事實,將確保永遠不會在 get_user_pages_fast 的「慢」路徑中結束,因為頁表條目將在緩衝區充滿「X」時創建。

                                    注意,get_user_pages 函數族不僅對管道有用——實際上它在許多驅動程序中都是核心。一個典型的用法與我們提及的核心旁路有關:網路卡驅動程序可能會使用它將某些使用者記憶體區域轉換為物理頁,然後將物理頁位置傳遞給網路卡,並讓網路卡直接與該記憶體區域互動,而無需核心參與。

                                    大體積頁面

                                    大體積頁面

                                    到目前為止,我們所呈現的頁大小始終相同——在 x86-64 架構上為 4KiB。但許多 CPU 架構,包括 x86-64,都包含更大的頁面尺寸。x86-64 的情況下,我們不僅有 4KiB 的頁(「標準」大小),還有 2MiB 甚至 1GiB 的頁(「巨大」頁)。在本文的剩餘部分中,我們只討論2MiB的大頁,因為 1GiB 的頁相當少見,對於我們的任務來說純屬浪費。

                                    當今常用架構中的頁大小,來自維基百科

                                    大頁的主要優勢在於管理成本更低,因為覆蓋相同記憶體量所需的頁更少。此外其他操作的成本也更低,例如將虛擬地址解析為實體地址,因為所需要的頁表少了一級:以一個 21 位的偏移量代替頁中原來的12位偏移量,從而少一級頁表。

                                    這減輕了處理此轉換的 CPU 部分的壓力,因而在許多情況下提高了性能。但是在我們的例子中,壓力不在於遍歷頁表的硬體,而在核心中運行的軟體。

                                    在 Linux 上,我們可以通過多種方式分配 2MiB 大頁,例如分配與 2MiB 對齊的記憶體,然後使用 madvise 告訴核心為提供的緩衝區使用大頁:

                                      void* buf = aligned_alloc(1 << 21, size);madvise(buf, size, MADV_HUGEPAGE)

                                      切換到大頁又給我們的程序帶來了約 50% 的性能提升:

                                        % ./write --write_with_vmsplice --huge_page | ./read --read_with_splice51.0GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

                                        然而,提升的原因並不完全顯而易見。我們可能會天真地認為,通過使用大頁, struct page 將只引用 2MiB 頁,而不是 4KiB 頁面。

                                        遺憾的是,情況並非如此:核心程式碼假定 struct page 引用當前架構的「標準」大小的頁。這種實現作用於大頁(通常Linux稱之為「複合頁面」)的方式是,「頭」 struct page 包含關於背後物理頁的實際資訊,而連續的「尾」頁僅包含指向頭頁的指針。

                                        因此為了表示 2MiB 的大頁,將有1個「頭」struct page,最多 511 個「尾」struct pages。或者對於我們的 128KiB 緩衝區來說,有 31個尾 struct pages:

                                        即使我們需要所有這些 struct pages,最後生成它的程式碼也會大大加快。找到第一個條目後,可以在一個簡單的循環中生成後面的 struct pages,而不是多次遍歷頁表。這樣就提高了性能!

                                        Busy looping

                                        Busy looping

                                        我們很快就要完成了,我保證!再看一下 perf 的輸出:

                                          -   46.91%     0.38%  write    libc-2.33.so       [.] vmsplice- 46.84% vmsplice- 43.15% entry_SYSCALL_64_after_hwframe- do_syscall_64- 41.80% __do_sys_vmsplice+ 14.90% wait_for_space+ 8.27% __wake_up_common_lock4.40% add_to_pipe+ 4.24% iov_iter_get_pages+ 3.92% __mutex_lock.constprop.01.81% iov_iter_advance+ 0.55% import_iovec+ 0.76% syscall_exit_to_user_mode1.54% syscall_return_via_sysret1.49% __entry_text_start

                                          現在大量時間花費在等待管道可寫(wait_for_space),以及喚醒等待管道填充內容的讀程序(__wake_up_common_lock)。

                                          為了避免這些同步成本,如果管道無法寫入,我們可以讓 vmsplice 返回,並執行忙循環直到寫入為止——在用 splice 讀取時做同樣的處理:

                                            ...// SPLICE_F_NONBLOCK will cause `vmsplice` to return immediately// if we can't write to the pipe, returning EAGAINssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, SPLICE_F_NONBLOCK);if (ret < 0 && errno == EAGAIN) {continue; // busy loop if not ready to write}...

                                            通過忙循環,我們的性能又提高了25%:

                                              % ./write --write_with_vmsplice --huge_page --busy_loop | ./read --read_with_splice --busy_loop62.5GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
                                              總結

                                              總結

                                              通過查看 perf 輸出和 Linux 原始碼,我們系統地提高了程序性能。在高性能程式設計方面,管道和拼接並不是真正的熱門話題,而我們這裡所涉及的主題是:零複製操作、環形緩衝區、分頁與虛擬記憶體、同步開銷。

                                              儘管我省略了一些細節和有趣的話題,但這篇博文還是已經失控而變得太長了:

                                              • 在實際程式碼中,緩衝區是分開分配的,通過將它們放置在不同的頁表條目中來減少頁表爭用(FizzBuzz程序也是這樣做的)。

                                              • 記住,當使用 get_user_pages 獲取頁表條目時,其 refcount 增加,而 put_page 減少。如果我們為兩個緩衝區使用兩個頁表條目,而不是為兩個緩衝器共用一個頁表條目的話,那麼在修改 refcount 時爭用更少。

                                              • 通過用taskset將./write和./read進程綁定到兩個核來運行測試。

                                              • 資料庫中的程式碼包含了我試過的許多其他選項,但由於這些選項無關緊要或不夠有趣,所以最終沒有討論。

                                              • 資料庫中還包含get_user_pages_fast 的一個綜合基準測試,可用來精確測量在用或不用大頁的情況下運行的速度慢多少。

                                              • 一般來說,拼接是一個有點可疑/危險的概念,它繼續困擾著核心開發人員。

                                              請讓我知道本文是否有用、有趣或不一定!

                                              致謝

                                              致謝

                                              非常感謝 Alexandru Scvorţov、Max Staudt、Alex Appetiti、Alex Sayers、Stephen Lavelle、Peter Cawley和Niklas Hambüchen審閱了本文的草稿。Max Staudt 還幫助我理解了 get_user_page 的一些微妙之處。

                                              1. 這將在風格上類似於我的atan2f性能調研(https://mazzo.li/posts/vectorized-atan2.html),儘管所討論的程序僅用於學習。此外,我們將在不同級別上最佳化程式碼。調優 atan2f 是在組合語言輸出指導下進行的微最佳化,調優管道程序則涉及查看 perf 事件,並減少各種核心開銷。

                                              2. 本測試在英特爾 Skylake i7-8550U CPU 和 Linux 5.17 上運行。你的環境可能會有所不同,因為在過去幾年中,影響本文所述程序的 Linux 內部結構一直在不斷變化,並且在未來版本中可能還會調整。

                                              3. 「FizzBuzz」據稱是一個常見的編碼面試問題,雖然我個人從來沒有被問到過該問題,但我有確實發生過的可靠證據。

                                              4. 儘管我們固定了緩衝區大小,但即便我們使用不同的緩衝區大小,考慮到其他瓶頸的影響,(吞吐量)數字實際也並不會有很大差異。

                                              5. 關於有趣的細節,可隨時參考資料庫。一般來說,我不會在這裡逐字複製程式碼,因為細節並不重要。相反,我將貼出更新的程式碼片段。

                                              6. 注意,這裡我們分析了一個包括管道讀取和寫入的shell調用——默認情況下,perf record跟蹤所有子進程。

                                              7. 分析該程序時,我注意到 perf 的輸出被來自「Pressure Stall Information」基礎架構(PSI)的資訊所汙染。因此這些數字取自一個禁用PSI後編譯的核心。這可以通過在核心構建配置中設置 CONFIG_PSI=n 來實現。在NixOS 中:

                                                boot.kernelPatches = [{name = "disable-psi";patch = null;extraConfig = ''PSI n'';}];

                                                此外,為了讓 perf 能正確顯示在系統調用中花費的時間,必須有核心調試符號。如何安裝符號因發行版而異。在最新的 NixOS 版本中,默認情況下會安裝它們。

                                                8. 假如你運行了 perf record -g,可以在 perf report 中用 + 展開調用圖。

                                                9. 被稱為 tmp_page 的單一「備用頁」實際上由 pipe_read 保留,並由pipe_write 重用。然而由於這裡始終只是一個頁面,我無法利用它來實現更高的性能,因為在調用 pipe_write 和 pipe_ read 時,頁面重用會被固定開銷所抵消。

                                                10. 從技術上講,vmsplice 還支持在另一個方向上傳輸資料,儘管用處不大。如手冊頁所述:

                                                vmsplice實際上只支持從使用者記憶體到管道的真正拼接。反方向上,它實際上只是將資料複製到使用者空間。

                                                11. Travis Downs 指出,該方案可能仍然不安全,因為頁面可能會被進一步拼接,從而延長其生命期。這個問題也出現在最初的 FizzBuzz 帖子中。事實上,我並不完全清楚不帶 SPLICE_F_GIFT 的 vmsplice 是否真的不安全——vmsplic 的手冊頁說明它是安全的。然而,在這種情況下,絕對需要特別小心,以實現零複製管道,同時保持安全。在測試程序中,讀取端將管道拼接到/dev/null 中,因此可能核心知道可以在不復制的情況下拼接頁面,但我尚未驗證這是否是實際發生的情況。

                                                12. 這裡我們呈現了一個簡化模型,其中物理記憶體是一個簡單的平面線性序列。現實情況複雜一些,但簡單模型已能說明問題。

                                                13. 可以通過讀取 /proc/self/pagemap 來檢查分配給當前進程的虛擬頁面所對應的實體地址,並將「頁面幀號」乘以頁面大小。

                                                14. 從 Ice Lake 開始,英特爾將頁表擴展為5級,從而將最大可定址記憶體從256TiB 增加到 128PiB。但此功能必須顯式開啟,因為有些程序依賴於指針的高 16 位不被使用。

                                                15. 頁表中的地址必須是實體地址,否則我們會陷入死循環。

                                                16. 注意,高 16 位未使用:這意味著我們每個進程最多可以處理 248 − 1 位元組,或 256TiB 的物理記憶體。

                                                17. struct page 可能指向尚未分配的物理頁,這些物理頁還沒有實體地址和其他與頁相關的抽象。它們被視為對物理頁面的完全抽象的引用,但不一定是對已分配的物理頁面的引用。這一微妙觀點將在後面的旁註中予以說明。

                                                18. 實際上,管道程式碼總是在 nr_pages = 16 的情況下調用 get_user_pages_fast,必要時進行循環,這可能是為了使用一個小的靜態緩衝區。但這是一個實現細節,拼接頁面的總數仍將是32。

                                                19. 以下部分是本文不需要理解的微妙之處!

                                                如果頁表不包含我們要查找的條目,get_user_pages_fast 仍然需要返回一個 struct page。最明顯的方法是創建正確的頁表條目,然後返回相應的 struct page。

                                                然而,get_user_pages_fast 僅在被要求獲取 struct page 以寫入其中時才會這樣做。否則它不會更新頁表,而是返回一個 struct page,給我們一個尚未分配的物理頁的引用。這正是 vmsplice 的情況,因為我們只需要生成一個 struct page 來填充管道,而不需要實際寫入任何記憶體。

                                                換句話說,頁面分配會被延遲,直到我們實際需要時。這節省了分配物理頁的時間,但如果頁面從未通過其他方式填充錯誤,則會導致重複調用 get_user_pages_fast 的慢路徑。

                                                因此,如果我們之前不進行 memset,就不會「手動」將錯誤頁放入頁表中,結果是不僅在第一次調用 get_user_pages_fast 時會陷入慢路徑,而且所有後續調用都會出現慢路徑,從而導致明顯地減速(25GiB/s而不是30GiB/s):

                                                  % ./write --write_with_vmsplice --dont_touch_pages | ./read --read_with_splice25.0GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)

                                                  此外,在使用大頁時,這種行為不會表現出來:在這種情況下,get_user_pages_fast 在傳入一系列虛擬記憶體時,大頁支持將正確地填充錯誤頁。

                                                  如果這一切都很混亂,不要擔心,get_user_page 和 friends 似乎是核心中非常棘手的一角,即使對於核心開發人員也是如此。

                                                  20. 僅當 CPU 具有 PDPE1GB 標誌時。

                                                  21. 例如,CPU包含專用硬體來快取部分頁表,即「轉換後備緩衝區」(translation lookaside buffer,TLB)。TLB 在每次上下文切換(每次更改 CR3 的內容)時被刷新。大頁可以顯著減少 TLB 未命中,因為 2MiB 頁的單個條目覆蓋的記憶體是 4KiB 頁面的 512 倍。

                                                  22. 如果你在想「太爛了!」正在進行各種努力來簡化和/或最佳化這種情況。最近的核心(從5.17開始)包含了一種新的類型,struct folio,用於顯式標識頭頁。這減少了運行時檢查 struct page 是頭頁還是尾頁的需要,從而提高了性能。其他努力的目標是徹底移除額外的 struct pages,儘管我不知道怎麼做的。

                                                  相關文章

                                                  最強 AI ChatGPT 真要取代程式設計師?

                                                  最強 AI ChatGPT 真要取代程式設計師?

                                                  【CSDN 編者按】ChatGPT 一出,「程式設計師要失業了」、「程式設計師要下崗了」之聲不絕於耳,引得程式設計師們不由得一陣驚慌,最強 ...

                                                  中國資料庫的諸神之戰

                                                  中國資料庫的諸神之戰

                                                  作者 | 唐小引 出品 | 《新程式設計師》編輯部 「現在的資料庫產品實在是太多了!」 前幾天,我和深耕資料庫/大資料近 30 年的盧東明老...

                                                  雲原生時代的DevOps平臺設計之道

                                                  雲原生時代的DevOps平臺設計之道

                                                  【CSDN 編者按】雲原生時代,開發和運維的分工愈加分明,運維人員通過建設並維護一套 IaaS 雲平臺,將計算資源進行池化。開發人員按需申請...