
本文作者通過一個示例程序,演示了通過Linux管道讀寫數(shù)據(jù)的性能優(yōu)化過程,使吞吐量從最初的 3.5GiB/s,提高到最終的 65GiB/s。即便只是一個小例子,可它涉及的知識點(diǎn)卻不少,包括零拷貝操作、環(huán)形緩沖區(qū)、分頁與虛擬內(nèi)存、同步開銷等,尤其對Linux內(nèi)核中的拼接、分頁、虛擬內(nèi)存地址映射等概念從源碼級進(jìn)行了分析。文章從概念到代碼由淺入深、層層遞進(jìn),雖然是圍繞管道讀寫性能優(yōu)化展開,但相信高性能應(yīng)用程序或Linux內(nèi)核的相關(guān)開發(fā)人員都會從中受益匪淺。本文將對一個通過管道寫入和讀取數(shù)據(jù)的測試程序進(jìn)行反復(fù)優(yōu)化,以此研究 Unix 管道在 Linux 中的實(shí)現(xiàn)方式。我們從一個吞吐量約為 3.5GiB/s 的簡單程序開始,并逐步將其性能提升 20 倍。性能提升通過使用 Linux 的 perf tooling 分析程序加以確認(rèn),代碼可從GitHub上獲得(https://github.com/bitonic/pipes-speed-test)。本文的靈感來自于閱讀一個高度優(yōu)化的 FizzBuzz 程序。在我的筆記本電腦上,該程序以大約 35GiB/s 的速度將輸出推送到一個管道中。我們的第一個目標(biāo)是達(dá)到這個速度,并會說明優(yōu)化過程中每一步驟。之后還將增加一個 FizzBuzz 中不需要的額外性能改進(jìn)措施,因?yàn)槠款i實(shí)際上是計(jì)算輸出,而不是 IO,至少在我的機(jī)器上是這樣。- 說明管道內(nèi)部如何實(shí)現(xiàn),以及為什么從中讀寫會慢;
- 說明如何利用vmsplice和splice系統(tǒng)調(diào)用,繞過一些(但不是全部!)緩慢環(huán)節(jié);
- 說明Linux分頁,以及使用huge pages實(shí)現(xiàn)一個快速版本;
- 用忙循環(huán)代替輪詢以進(jìn)行最后的優(yōu)化;
第4步是 Linux 內(nèi)核中最重要的部分,因此即使你熟悉本文中討論的其他主題,也可能對它感興趣。對于不熟悉相關(guān)主題的讀者,我們假設(shè)你只了解 C 語言的基本知識。我們先按照 StackOverflow 的發(fā)帖規(guī)則,來測試傳說中的 FizzBuzz 程序的性能:% ./fizzbuzz | pv >/dev/null
422GiB 0:00:16 [36.2GiB/s
pv 指“pipe viewer”,是一種用于測量流經(jīng)管道的數(shù)據(jù)吞吐量的簡便工具。所示為 fizzbuzz 以 36GiB/s 的速率產(chǎn)生輸出。fizzbuzz 將輸出寫入與二級緩存一樣大的塊中,以在廉價訪問內(nèi)存和最小化 IO 開銷之間取得良好平衡。在我的機(jī)器上,二級緩存為 256KiB。本文中還是輸出 256KiB 的塊,但不做任何“計(jì)算”。我們本質(zhì)上是想測量出程序?qū)懭刖哂泻侠砭彌_區(qū)大小的管道的吞吐量上限。fizzbuzz 使用 pv 測量速度,而我們的設(shè)置會略有不同:我們將在管道的兩端執(zhí)行程序,這樣就可以完全控制從管道中推拉數(shù)據(jù)所涉及的代碼。該代碼可從 in my pipes-speed-test rep 獲得。write.cpp 實(shí)現(xiàn)寫入,read.cpp 實(shí)現(xiàn)讀取。write 一直重復(fù)寫入相同的 256KiB,read 讀取 10GiB 數(shù)據(jù)后終止,并以 GiB/s 為單位打印吞吐量。兩個可執(zhí)行程序都接受各種命令行選項(xiàng)以更改其行為。從管道讀取和寫入的第一次測試將使用 write 和 read 系統(tǒng)調(diào)用,使用與 fizzbuzz 相同的緩沖區(qū)大小。下面所示為寫入端代碼:int main() {
size_t buf_size = 1 << 18; // 256KiB
char* buf = (char*) malloc(buf_size);
memset((void*)buf, 'X', buf_size); // output Xs
while (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 調(diào)用完成的,其余部分是確保整個緩沖區(qū)都被寫入。讀取端非常類似,只是將數(shù)據(jù)讀取到 buf 中,并在讀取足夠的數(shù)據(jù)時終止。構(gòu)建后,資料庫中的代碼可以按如下方式運(yùn)行:% ./write | ./read
3.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
我們寫入相同的 256KiB 緩沖區(qū),其中填充了 40960 次“X”,并測量吞吐量。令人煩惱的是,速度比 fizzbuzz 慢 10 倍!我們只是將字節(jié)寫入管道,而沒做任何其他工作。事實(shí)證明,通過使用 write 和 read,我們無法獲得比這快得多的速度。為了找出運(yùn)行程序的時間花在了什么上,我們可以使用 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 選項(xiàng)指示 perf 記錄調(diào)用圖:這可以讓我們從上到下查看時間花費(fèi)在哪里。我們可以使用 perf report 來查看花費(fèi)時間的地方。下面是一個稍加編輯的片段,詳細(xì)列出了 write 的時間花費(fèi)所在:% 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_lock
0.95% _raw_spin_lock_irq
0.74% alloc_pages
0.66% prepare_to_wait_event
47% 的時間花在了 pipe_write 上,也就是我們在向管道寫入時,write 所干的事情。這并不奇怪——我們花了大約一半的時間進(jìn)行寫入,另一半時間進(jìn)行讀取。在 pipe_write中,3/4 的時間用于復(fù)制或分配頁面(copy_page_from_iter和__alloc_page)。如果我們已經(jīng)對內(nèi)核和用戶空間之間的通信是如何工作的有所了解,就會知道這有一定道理。不管怎樣,要完全理解發(fā)生了什么,我們必須首先理解管道如何工作。在 include/linux/pipe_fs_i.h 中可以找到定義管道的數(shù)據(jù)結(jié)構(gòu),fs/pipe.c 中有對其進(jìn)行的操作。Linux 管道是一個環(huán)形緩沖區(qū),保存對數(shù)據(jù)寫入和讀取的頁面的引用:上圖中的環(huán)形緩沖區(qū)有 8 個槽位,但可能或多或少,默認(rèn)為 16 個。x86-64 架構(gòu)中每個頁面大小是 4KiB,但在其他架構(gòu)中可能有所不同。這個管道總共可以容納 32KiB 的數(shù)據(jù)。這是一個關(guān)鍵點(diǎn):每個管道都有一個上限,即它在滿之前可以容納的數(shù)據(jù)總量。圖中的陰影部分表示當(dāng)前管道數(shù)據(jù),非陰影部分表示管道中的空余空間。有點(diǎn)反直覺,head 存儲管道的寫入端。也就是說,寫入程序?qū)懭?head 指向的緩沖區(qū),如果需要移動到下一個緩沖區(qū),則相應(yīng)地增加 head。在寫緩沖區(qū)中,len 存儲我們在其中寫了多少。相反,tail 存儲管道的讀取端:讀取程序?qū)哪抢镩_始使用管道。offset 指示從何處開始讀取。注意,tail 可以出現(xiàn)在 head 之后,如圖中所示,因?yàn)槲覀兪褂玫氖茄h(huán)/環(huán)形緩沖區(qū)。還要注意,當(dāng)我們沒有完全填滿管道時,一些槽位可能沒有使用——中間的 NULL 單元。如果管道已滿(頁面中沒有NULL和可用空間),write 將被阻塞。如果管道為空(全 NULL),則 read 將被阻塞。下面是 pipe_fs_i.h 中 C 數(shù)據(jù)結(jié)構(gòu)的節(jié)略版本: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 中存什么,但這是理解如何從管道進(jìn)行讀寫的關(guān)鍵數(shù)據(jù)結(jié)構(gòu)。現(xiàn)在讓我們回到 pipe_write 的定義,嘗試?yán)斫馇懊骘@示的 perf 輸出。pipe_write 工作原理簡要說明如下:2.如果 head 當(dāng)前指向的緩沖區(qū)有空間,首先填充該空間;3.當(dāng)有空閑槽位,還有剩余的字節(jié)要寫時,分配新的頁面并填充它們,更新head。上述操作被一個鎖保護(hù),pipe_write 根據(jù)需要獲取和釋放該鎖。pipe_read 是 pipe_ write 的鏡像,不同之處在于消費(fèi)頁面,完全讀取頁面后將其釋放,并更新 tail。因此,當(dāng)前的處理過程形成了一個令人非常不快的狀況:- 每個頁面復(fù)制兩次,一次從用戶內(nèi)存復(fù)制到內(nèi)核,另一次從內(nèi)核復(fù)制到用戶內(nèi)存;
- 一次復(fù)制一個 4KiB 的頁面,期間還與諸如讀寫之間的同步、頁面分配與釋放等其他操作交織在一起;
- 由于不斷分配新頁面,正在處理的內(nèi)存可能不連續(xù);
在本機(jī)上,順序 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 倍便不足為怪。調(diào)整緩沖區(qū)或管道大小以減少系統(tǒng)調(diào)用和同步開銷,或者調(diào)整其他參數(shù)不會有多大幫助。幸運(yùn)的是,有一種方法可以完全避免讀寫緩慢。這種將緩沖區(qū)從用戶內(nèi)存復(fù)制到內(nèi)核再復(fù)制回去,是需要進(jìn)行快速 IO 的人經(jīng)常遇到的棘手問題。一種常見的解決方案是將內(nèi)核操作從處理過程中剔除,直接執(zhí)行 IO 操作。例如,我們可以直接與網(wǎng)卡交互,并繞過內(nèi)核以低延遲聯(lián)網(wǎng)。通常當(dāng)我們寫入套接字、文件或本例的管道時,首先寫入內(nèi)核中的某個緩沖區(qū),然后讓內(nèi)核完成其工作。在管道的情況下,管道就是內(nèi)核中的一系列緩沖區(qū)。如果我們關(guān)注性能,則所有這些復(fù)制都是不可取的。幸好,當(dāng)我們要在管道中移動數(shù)據(jù)時,Linux 包含系統(tǒng)調(diào)用以加快速度,而無需復(fù)制。具體而言:- splice 將數(shù)據(jù)從管道移動到文件描述符,反之亦然;
- vmsplice 將數(shù)據(jù)從用戶內(nèi)存移動到管道中。
關(guān)鍵是,這兩種操作都在不復(fù)制任何內(nèi)容的情況下工作。既然我們知道了管道的工作原理,就可以大概想象這兩個操作是如何工作的:它們只是從某處“抓取”一個現(xiàn)有的緩沖區(qū),然后將其放入管道環(huán)緩沖區(qū),或者反過來,而不是在需要時分配新頁面:6. Splicing 實(shí)現(xiàn)我們用 vmsplice 替換 write。vmsplice 簽名如下:struct iovec {
void *iov_base; // Starting address
size_t iov_len; // Number of bytes
};
// Returns how much we've spliced into the pipe
ssize_t vmsplice(
int fd, const struct iovec *iov, size_t nr_segs, unsigned int flags
);
fd是目標(biāo)管道,struct iovec *iov 是將要移動到管道的緩沖區(qū)數(shù)組。注意,vmsplice 返回“拼接”到管道中的數(shù)量,可能不是完整數(shù)量,就像 write 返回寫入的數(shù)量一樣。別忘了管道受其在環(huán)形緩沖區(qū)中的槽位數(shù)量的限制,而 vmsplice 不受此限制。使用 vmsplice 時還需要小心一點(diǎn)。用戶內(nèi)存是在不復(fù)制的情況下移動到管道中的,因此在重用拼接緩沖區(qū)之前,必須確保讀取端使用它。為此,fizzbuzz 使用雙緩沖方案,其工作原理如下:- 將管道大小設(shè)置為 128KiB,相當(dāng)于將管道的環(huán)形緩沖區(qū)設(shè)置為具有128KiB/4KiB=32 個槽位;
- 在寫入前半個緩沖區(qū)或使用 vmsplice 將其移動到管道中之間進(jìn)行選擇,并對另一半緩沖區(qū)執(zhí)行相同操作。
管道大小設(shè)置為 128KiB,并且等待 vmsplice 完全輸出一個 128KiB 緩沖區(qū),這就保證了當(dāng)完成一次 vmsplic 迭代時,我們已知前一個緩沖區(qū)已被完全讀取——否則無法將新的 128KiB 緩沖區(qū)完全 vmsplice 到 128KiB 管道中。現(xiàn)在,我們實(shí)際上還沒有向緩沖區(qū)寫入任何內(nèi)容,但我們將保留雙緩沖方案,因?yàn)槿魏螌?shí)際寫入內(nèi)容的程序都需要類似的方案。我們的寫循環(huán)現(xiàn)在看起來像這樣:int main() {
size_t buf_size = 1 << 18; // 256KiB
char* buf = malloc(buf_size);
memset((void*)buf, 'X', buf_size); // output Xs
char* 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 寫入的結(jié)果:% ./write --write_with_vmsplice | ./read
12.7GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
這使我們所需的復(fù)制量減少了一半,并且把吞吐量提高了三倍多,達(dá)到 12.7GiB/s。將讀取端更改為使用 splice 后,消除了所有復(fù)制,又獲得了 2.5 倍的加速:% ./write --write_with_vmsplice | ./read --read_with_splice
32.8GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
% 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.0
3.89% add_to_pipe
1.17% iov_iter_advance
0.82% mutex_unlock
0.75% pipe_lock
2.01% __entry_text_start
1.45% syscall_return_via_sysret
大部分時間消耗在鎖定管道以進(jìn)行寫入(__mutex_lock.constprop.0)和將頁面移動到管道中(iov_iter_get_pages)兩個操作。關(guān)于鎖定能改進(jìn)的不多,但我們可以提高 iov_iter_get_pages 的性能。顧名思義,iov_iter_get_pages 將我們提供給 vmsplice 的 struct iovecs 轉(zhuǎn)換為 struct pages,以放入管道。為了理解這個函數(shù)的實(shí)際功能,以及如何加快它的速度,我們必須首先了解 CPU 和 Linux 如何組織頁面。如你所知,進(jìn)程并不直接引用 RAM 中的位置:而是分配虛擬內(nèi)存地址,這些地址被解析為物理地址。這種抽象被稱為虛擬內(nèi)存,我們在這里不介紹它的各種優(yōu)勢——但最明顯的是,它大大簡化了運(yùn)行多個進(jìn)程對同一物理內(nèi)存的競爭。無論何種情況下,每當(dāng)我們執(zhí)行一個程序并從內(nèi)存加載/存儲到內(nèi)存時,CPU 都需要將虛擬地址轉(zhuǎn)換為物理地址。存儲從每個虛擬地址到每個對應(yīng)物理地址的映射是不現(xiàn)實(shí)的。因此,內(nèi)存被分成大小一致的塊,叫做頁面,虛擬頁面被映射到物理頁面:4KiB 并沒有什么特別之處:每種架構(gòu)都根據(jù)各種權(quán)衡選擇一種大小——我們將很快將探討其中的一些。為了使這點(diǎn)更明確,讓我們想象一下使用 malloc 分配 10000 字節(jié):void* buf = malloc(10000);
printf("%p\n", buf); // 0x6f42430
當(dāng)我們使用它們時,我們的 10k 字節(jié)在虛擬內(nèi)存中看起來是連續(xù)的,但將被映射到 3 個不必連續(xù)的物理頁:內(nèi)核的任務(wù)之一是管理此映射,這體現(xiàn)在稱為頁表的數(shù)據(jù)結(jié)構(gòu)中。CPU 指定頁表結(jié)構(gòu)(因?yàn)樗枰斫忭摫恚?,?nèi)核根據(jù)需要對其進(jìn)行操作。在 x86-64 架構(gòu)上,頁表是一個 4 級 512 路的樹,本身位于內(nèi)存中。 該樹的每個節(jié)點(diǎn)是(你猜對了?。?KiB 大小,節(jié)點(diǎn)內(nèi)指向下一級的每個條目為 8 字節(jié)(4KiB/8bytes = 512)。這些條目包含下一個節(jié)點(diǎn)的地址以及其他元數(shù)據(jù)。每個進(jìn)程都有一個頁表——換句話說,每個進(jìn)程都保留了一個虛擬地址空間。當(dāng)內(nèi)核上下文切換到進(jìn)程時,它將特定寄存器 CR3 設(shè)置為該樹根的物理地址。然后,每當(dāng)需要將虛擬地址轉(zhuǎn)換為物理地址時,CPU 將該地址拆分成若干段,并使用它們遍歷該樹,以及計(jì)算物理地址。為了減少這些概念的抽象性,以下是虛擬地址 0x0000f2705af953c0 如何解析為物理地址的直觀描述:搜索從第一級開始,稱為“page global directory”,或 PGD,其物理位置存儲在 CR3 中。地址的前 16 位未使用。 我們使用接下來的 9 位 PGD 條目,并向下遍歷到第二級“page upper directory”,或 PUD。接下來的9位用于從 PUD 中選擇條目。該過程在下面的兩個級別上重復(fù),即 PMD(“page middle directory”)和 PTE(“page table entry”)。PTE 告訴我們要查找的實(shí)際物理頁在哪里,然后我們使用最后12位來查找頁內(nèi)的偏移量。頁面表的稀疏結(jié)構(gòu)允許在需要新頁面時逐步建立映射。每當(dāng)進(jìn)程需要內(nèi)存時,內(nèi)核將用新條目更新頁表。struct page 數(shù)據(jù)結(jié)構(gòu)是這種機(jī)制的關(guān)鍵部分:它是內(nèi)核用來引用單個物理頁、存儲其物理地址及其各種其他元數(shù)據(jù)的結(jié)構(gòu)。例如,我們可以從 PTE 中包含的信息(上面描述的頁表的最后一級)中獲得 struct page。一般來說,它被廣泛用于處理頁面相關(guān)事務(wù)的所有代碼。在管道場景下,struct page 用于將其數(shù)據(jù)保存在環(huán)形緩沖區(qū)中,正如我們已經(jīng)看到的: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 接受虛擬內(nèi)存作為輸入,而 struct page 直接引用物理內(nèi)存。因此我們需要將任意的虛擬內(nèi)存塊轉(zhuǎn)換成一組 struct pages。這正是 iov_iter_get_pages 所做的,也是我們花費(fèi)一半時間的地方:ssize_t iov_iter_get_pages(
struct iov_iter *i, // input: a sized buffer in virtual memory
struct page **pages, // output: the list of pages which back the input buffers
size_t maxsize, // maximum number of bytes to get
unsigned maxpages, // maximum number of pages to get
size_t *start // offset into first page, if the input buffer wasn't page-aligned
);
struct iov_iter 是一種 Linux 內(nèi)核數(shù)據(jù)結(jié)構(gòu),表示遍歷內(nèi)存塊的各種方式,包括 struct iovec。在我們的例子中,它將指向 128KiB 的緩沖區(qū)。vmsplice 使用 iov_iter_get_pages 將輸入緩沖區(qū)轉(zhuǎn)換為一組 struct pages,并保存它們。既然已經(jīng)知道了分頁的工作原理,你可以大概想象一下 iov_iter_get_pages 是如何工作的,下一節(jié)詳細(xì)解釋它。我們已經(jīng)快速了解了許多新概念,概括如下:- 現(xiàn)代 CPU 使用虛擬內(nèi)存進(jìn)行處理;
- 內(nèi)存按固定大小的頁面進(jìn)行組織;
- CPU 使用將虛擬頁映射到物理頁的頁表,把虛擬地址轉(zhuǎn)換為物理地址;
- 內(nèi)核根據(jù)需要向頁表添加和刪除條目;
- 管道是由對物理頁的引用構(gòu)成的,因此 vmsplice 必須將一系列虛擬內(nèi)存轉(zhuǎn)換為物理頁,并保存它們。
在 iov_iter_get_pages 中所花費(fèi)的時間,實(shí)際上完全花在另一個函數(shù),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_fast
11.22% try_grab_compound_head
get_user_pages_fast 是 iov_iter_get_ pages 的簡化版本:int get_user_pages_fast(
// virtual address, page aligned
unsigned long start,
// number of pages to retrieve
int nr_pages,
// flags, the meaning of which we won't get into
unsigned int gup_flags,
// output physical pages
struct page **pages
)
這里的“user”(與“kernel”相對)指的是將虛擬頁轉(zhuǎn)換為對物理頁的引用。為了得到 struct pages,get_user_pages_fast 完全按照 CPU 操作,但在軟件中:它遍歷頁表以收集所有物理頁,將結(jié)果存儲在 struct pages 里。我們的例子中是一個 128KiB 的緩沖區(qū)和 4KiB 的頁,因此 nr_pages = 32。get_user_page_fast 需要遍歷頁表樹,收集 32 個葉子,并將結(jié)果存儲在 32 個 struct pages 中。get_user_pages_fast 還需要確保物理頁在調(diào)用方不再需要之前不會被重用。這是通過在內(nèi)核中使用存儲在 struct page 中的引用計(jì)數(shù)來實(shí)現(xiàn)的,該計(jì)數(shù)用于獲知物理頁在將來何時可以釋放和重用。get_user_pages_fast 的調(diào)用者必須在某個時候使用 put_page 再次釋放頁面,以減少引用計(jì)數(shù)。最后,get_user_pages_fast 根據(jù)虛擬地址是否已經(jīng)在頁表中而表現(xiàn)不同。這就是 _fast 后綴的來源:內(nèi)核首先將嘗試通過遍歷頁表來獲取已經(jīng)存在的頁表?xiàng)l目和相應(yīng)的 struct page,這成本相對較低,然后通過其他更高成本的方法返回生成 struct page。我們在開始時memset內(nèi)存的事實(shí),將確保永遠(yuǎn)不會在 get_user_pages_fast 的“慢”路徑中結(jié)束,因?yàn)轫摫項(xiàng)l目將在緩沖區(qū)充滿“X”時創(chuàng)建。注意,get_user_pages 函數(shù)族不僅對管道有用——實(shí)際上它在許多驅(qū)動程序中都是核心。一個典型的用法與我們提及的內(nèi)核旁路有關(guān):網(wǎng)卡驅(qū)動程序可能會使用它將某些用戶內(nèi)存區(qū)域轉(zhuǎn)換為物理頁,然后將物理頁位置傳遞給網(wǎng)卡,并讓網(wǎng)卡直接與該內(nèi)存區(qū)域交互,而無需內(nèi)核參與。到目前為止,我們所呈現(xiàn)的頁大小始終相同——在 x86-64 架構(gòu)上為 4KiB。但許多 CPU 架構(gòu),包括 x86-64,都包含更大的頁面尺寸。x86-64 的情況下,我們不僅有 4KiB 的頁(“標(biāo)準(zhǔn)”大?。€有 2MiB 甚至 1GiB 的頁(“巨大”頁)。在本文的剩余部分中,我們只討論2MiB的大頁,因?yàn)?1GiB 的頁相當(dāng)少見,對于我們的任務(wù)來說純屬浪費(fèi)。當(dāng)今常用架構(gòu)中的頁大小,來自維基百科大頁的主要優(yōu)勢在于管理成本更低,因?yàn)楦采w相同內(nèi)存量所需的頁更少。此外其他操作的成本也更低,例如將虛擬地址解析為物理地址,因?yàn)樗枰捻摫砩倭艘患墸阂砸粋€ 21 位的偏移量代替頁中原來的12位偏移量,從而少一級頁表。這減輕了處理此轉(zhuǎn)換的 CPU 部分的壓力,因而在許多情況下提高了性能。但是在我們的例子中,壓力不在于遍歷頁表的硬件,而在內(nèi)核中運(yùn)行的軟件。在 Linux 上,我們可以通過多種方式分配 2MiB 大頁,例如分配與 2MiB 對齊的內(nèi)存,然后使用 madvise 告訴內(nèi)核為提供的緩沖區(qū)使用大頁:void* buf = aligned_alloc(1 << 21, size);
madvise(buf, size, MADV_HUGEPAGE)
切換到大頁又給我們的程序帶來了約 50% 的性能提升:% ./write --write_with_vmsplice --huge_page | ./read --read_with_splice
51.0GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
然而,提升的原因并不完全顯而易見。我們可能會天真地認(rèn)為,通過使用大頁, struct page 將只引用 2MiB 頁,而不是 4KiB 頁面。遺憾的是,情況并非如此:內(nèi)核代碼假定 struct page 引用當(dāng)前架構(gòu)的“標(biāo)準(zhǔn)”大小的頁。這種實(shí)現(xiàn)作用于大頁(通常Linux稱之為“復(fù)合頁面”)的方式是,“頭” struct page 包含關(guān)于背后物理頁的實(shí)際信息,而連續(xù)的“尾”頁僅包含指向頭頁的指針。因此為了表示 2MiB 的大頁,將有1個“頭”struct page,最多 511 個“尾”struct pages?;蛘邔τ谖覀兊?128KiB 緩沖區(qū)來說,有 31個尾 struct pages:即使我們需要所有這些 struct pages,最后生成它的代碼也會大大加快。找到第一個條目后,可以在一個簡單的循環(huán)中生成后面的 struct pages,而不是多次遍歷頁表。這樣就提高了性能!我們很快就要完成了,我保證!再看一下 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_lock
4.40% add_to_pipe
+ 4.24% iov_iter_get_pages
+ 3.92% __mutex_lock.constprop.0
1.81% iov_iter_advance
+ 0.55% import_iovec
+ 0.76% syscall_exit_to_user_mode
1.54% syscall_return_via_sysret
1.49% __entry_text_start
現(xiàn)在大量時間花費(fèi)在等待管道可寫(wait_for_space),以及喚醒等待管道填充內(nèi)容的讀程序(__wake_up_common_lock)。為了避免這些同步成本,如果管道無法寫入,我們可以讓 vmsplice 返回,并執(zhí)行忙循環(huán)直到寫入為止——在用 splice 讀取時做同樣的處理:...
// SPLICE_F_NONBLOCK will cause `vmsplice` to return immediately
// if we can't write to the pipe, returning EAGAIN
ssize_t ret = vmsplice(STDOUT_FILENO, &bufvec, 1, SPLICE_F_NONBLOCK);
if (ret < 0 && errno == EAGAIN) {
continue; // busy loop if not ready to write
}
...
通過忙循環(huán),我們的性能又提高了25%:% ./write --write_with_vmsplice --huge_page --busy_loop | ./read --read_with_splice --busy_loop
62.5GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
通過查看 perf 輸出和 Linux 源碼,我們系統(tǒng)地提高了程序性能。在高性能編程方面,管道和拼接并不是真正的熱門話題,而我們這里所涉及的主題是:零拷貝操作、環(huán)形緩沖區(qū)、分頁與虛擬內(nèi)存、同步開銷。盡管我省略了一些細(xì)節(jié)和有趣的話題,但這篇博文還是已經(jīng)失控而變得太長了:- 在實(shí)際代碼中,緩沖區(qū)是分開分配的,通過將它們放置在不同的頁表?xiàng)l目中來減少頁表爭用(FizzBuzz程序也是這樣做的)。
- 記住,當(dāng)使用 get_user_pages 獲取頁表?xiàng)l目時,其 refcount 增加,而 put_page 減少。如果我們?yōu)閮蓚€緩沖區(qū)使用兩個頁表?xiàng)l目,而不是為兩個緩沖器共用一個頁表?xiàng)l目的話,那么在修改 refcount 時爭用更少。
- 通過用taskset將./write和./read進(jìn)程綁定到兩個核來運(yùn)行測試。
- 資料庫中的代碼包含了我試過的許多其他選項(xiàng),但由于這些選項(xiàng)無關(guān)緊要或不夠有趣,所以最終沒有討論。
- 資料庫中還包含get_user_pages_fast 的一個綜合基準(zhǔn)測試,可用來精確測量在用或不用大頁的情況下運(yùn)行的速度慢多少。
- 一般來說,拼接是一個有點(diǎn)可疑/危險(xiǎn)的概念,它繼續(xù)困擾著內(nèi)核開發(fā)人員。
非常感謝 Alexandru Scvor?ov、Max Staudt、Alex Appetiti、Alex Sayers、Stephen Lavelle、Peter Cawley和Niklas Hambüchen審閱了本文的草稿。Max Staudt 還幫助我理解了 get_user_page 的一些微妙之處。1. 這將在風(fēng)格上類似于我的atan2f性能調(diào)研(https://mazzo.li/posts/vectorized-atan2.html),盡管所討論的程序僅用于學(xué)習(xí)。此外,我們將在不同級別上優(yōu)化代碼。調(diào)優(yōu) atan2f 是在匯編語言輸出指導(dǎo)下進(jìn)行的微優(yōu)化,調(diào)優(yōu)管道程序則涉及查看 perf 事件,并減少各種內(nèi)核開銷。2. 本測試在英特爾 Skylake i7-8550U CPU 和 Linux 5.17 上運(yùn)行。你的環(huán)境可能會有所不同,因?yàn)樵谶^去幾年中,影響本文所述程序的 Linux 內(nèi)部結(jié)構(gòu)一直在不斷變化,并且在未來版本中可能還會調(diào)整。3. “FizzBuzz”據(jù)稱是一個常見的編碼面試問題,雖然我個人從來沒有被問到過該問題,但我有確實(shí)發(fā)生過的可靠證據(jù)。4. 盡管我們固定了緩沖區(qū)大小,但即便我們使用不同的緩沖區(qū)大小,考慮到其他瓶頸的影響,(吞吐量)數(shù)字實(shí)際也并不會有很大差異。5. 關(guān)于有趣的細(xì)節(jié),可隨時參考資料庫。一般來說,我不會在這里逐字復(fù)制代碼,因?yàn)榧?xì)節(jié)并不重要。相反,我將貼出更新的代碼片段。6. 注意,這里我們分析了一個包括管道讀取和寫入的shell調(diào)用——默認(rèn)情況下,perf record跟蹤所有子進(jìn)程。7. 分析該程序時,我注意到 perf 的輸出被來自“Pressure Stall Information”基礎(chǔ)架構(gòu)(PSI)的信息所污染。因此這些數(shù)字取自一個禁用PSI后編譯的內(nèi)核。這可以通過在內(nèi)核構(gòu)建配置中設(shè)置 CONFIG_PSI=n 來實(shí)現(xiàn)。在NixOS 中:boot.kernelPatches = [{
name = "disable-psi";
patch = null;
extraConfig = ''
PSI n
'';
}];
此外,為了讓 perf 能正確顯示在系統(tǒng)調(diào)用中花費(fèi)的時間,必須有內(nèi)核調(diào)試符號。如何安裝符號因發(fā)行版而異。在最新的 NixOS 版本中,默認(rèn)情況下會安裝它們。8. 假如你運(yùn)行了 perf record -g,可以在 perf report 中用 + 展開調(diào)用圖。9. 被稱為 tmp_page 的單一“備用頁”實(shí)際上由 pipe_read 保留,并由pipe_write 重用。然而由于這里始終只是一個頁面,我無法利用它來實(shí)現(xiàn)更高的性能,因?yàn)樵谡{(diào)用 pipe_write 和 pipe_ read 時,頁面重用會被固定開銷所抵消。10. 從技術(shù)上講,vmsplice 還支持在另一個方向上傳輸數(shù)據(jù),盡管用處不大。如手冊頁所述:vmsplice實(shí)際上只支持從用戶內(nèi)存到管道的真正拼接。反方向上,它實(shí)際上只是將數(shù)據(jù)復(fù)制到用戶空間。
11. Travis Downs 指出,該方案可能仍然不安全,因?yàn)轫撁婵赡軙贿M(jìn)一步拼接,從而延長其生命期。這個問題也出現(xiàn)在最初的 FizzBuzz 帖子中。事實(shí)上,我并不完全清楚不帶 SPLICE_F_GIFT 的 vmsplice 是否真的不安全——vmsplic 的手冊頁說明它是安全的。然而,在這種情況下,絕對需要特別小心,以實(shí)現(xiàn)零拷貝管道,同時保持安全。在測試程序中,讀取端將管道拼接到/dev/null 中,因此可能內(nèi)核知道可以在不復(fù)制的情況下拼接頁面,但我尚未驗(yàn)證這是否是實(shí)際發(fā)生的情況。12. 這里我們呈現(xiàn)了一個簡化模型,其中物理內(nèi)存是一個簡單的平面線性序列。現(xiàn)實(shí)情況復(fù)雜一些,但簡單模型已能說明問題。13. 可以通過讀取 /proc/self/pagemap 來檢查分配給當(dāng)前進(jìn)程的虛擬頁面所對應(yīng)的物理地址,并將“頁面幀號”乘以頁面大小。14. 從 Ice Lake 開始,英特爾將頁表擴(kuò)展為5級,從而將最大可尋址內(nèi)存從256TiB 增加到 128PiB。但此功能必須顯式開啟,因?yàn)橛行┏绦蛞蕾囉谥羔樀母?16 位不被使用。15. 頁表中的地址必須是物理地址,否則我們會陷入死循環(huán)。16. 注意,高 16 位未使用:這意味著我們每個進(jìn)程最多可以處理 248 ? 1 字節(jié),或 256TiB 的物理內(nèi)存。17. struct page 可能指向尚未分配的物理頁,這些物理頁還沒有物理地址和其他與頁相關(guān)的抽象。它們被視為對物理頁面的完全抽象的引用,但不一定是對已分配的物理頁面的引用。這一微妙觀點(diǎn)將在后面的旁注中予以說明。18. 實(shí)際上,管道代碼總是在 nr_pages = 16 的情況下調(diào)用 get_user_pages_fast,必要時進(jìn)行循環(huán),這可能是為了使用一個小的靜態(tài)緩沖區(qū)。但這是一個實(shí)現(xiàn)細(xì)節(jié),拼接頁面的總數(shù)仍將是32。如果頁表不包含我們要查找的條目,get_user_pages_fast 仍然需要返回一個 struct page。最明顯的方法是創(chuàng)建正確的頁表?xiàng)l目,然后返回相應(yīng)的 struct page。然而,get_user_pages_fast 僅在被要求獲取 struct page 以寫入其中時才會這樣做。否則它不會更新頁表,而是返回一個 struct page,給我們一個尚未分配的物理頁的引用。這正是 vmsplice 的情況,因?yàn)槲覀冎恍枰梢粋€ struct page 來填充管道,而不需要實(shí)際寫入任何內(nèi)存。換句話說,頁面分配會被延遲,直到我們實(shí)際需要時。這節(jié)省了分配物理頁的時間,但如果頁面從未通過其他方式填充錯誤,則會導(dǎo)致重復(fù)調(diào)用 get_user_pages_fast 的慢路徑。因此,如果我們之前不進(jìn)行 memset,就不會“手動”將錯誤頁放入頁表中,結(jié)果是不僅在第一次調(diào)用 get_user_pages_fast 時會陷入慢路徑,而且所有后續(xù)調(diào)用都會出現(xiàn)慢路徑,從而導(dǎo)致明顯地減速(25GiB/s而不是30GiB/s):% ./write --write_with_vmsplice --dont_touch_pages | ./read --read_with_splice
25.0GiB/s, 256KiB buffer, 40960 iterations (10GiB piped)
此外,在使用大頁時,這種行為不會表現(xiàn)出來:在這種情況下,get_user_pages_fast 在傳入一系列虛擬內(nèi)存時,大頁支持將正確地填充錯誤頁。如果這一切都很混亂,不要擔(dān)心,get_user_page 和 friends 似乎是內(nèi)核中非常棘手的一角,即使對于內(nèi)核開發(fā)人員也是如此。20. 僅當(dāng) CPU 具有 PDPE1GB 標(biāo)志時。21. 例如,CPU包含專用硬件來緩存部分頁表,即“轉(zhuǎn)換后備緩沖區(qū)”(translation lookaside buffer,TLB)。TLB 在每次上下文切換(每次更改 CR3 的內(nèi)容)時被刷新。大頁可以顯著減少 TLB 未命中,因?yàn)?2MiB 頁的單個條目覆蓋的內(nèi)存是 4KiB 頁面的 512 倍。22. 如果你在想“太爛了!”正在進(jìn)行各種努力來簡化和/或優(yōu)化這種情況。最近的內(nèi)核(從5.17開始)包含了一種新的類型,struct folio,用于顯式標(biāo)識頭頁。這減少了運(yùn)行時檢查 struct page 是頭頁還是尾頁的需要,從而提高了性能。其他努力的目標(biāo)是徹底移除額外的 struct pages,盡管我不知道怎么做的。鏈接:https://mazzo.li/posts/fast-pipes.html
(版權(quán)歸原作者所有,侵刪)