Đường ống Linux nhanh đến mức nào? (2022)
(mazzo.li)- Theo dõi thông lượng của đường ống Linux bằng
perfvà phân tích đường đi trong kernel, từ bản triển khai đơn giản dựa trênwrite/readđạt 3.7GiB/s lên tới 62.5GiB/s sau tối ưu hóa - Điểm khởi đầu của nút thắt là pipe hoạt động như một ring buffer chứa các tham chiếu trang 4KiB, nên phải đồng thời trả chi phí sao chép, cấp phát trang, khóa, chờ và đánh thức
vmsplicevàsplicegiảm sao chép giữa bộ nhớ người dùng và bộ đệm kernel, nâng thông lượng lần lượt lên khoảng 12.7GiB/s và 32.8GiB/s- Sau đó, chi phí chuyển đổi dải địa chỉ ảo thành
struct pagetrở nên nổi bật, và dùng huge page 2MiB giúp giảm gánh nặng dò bảng trang, đưa thông lượng lên 51.0GiB/s - Tối ưu hóa cuối cùng với
SPLICE_F_NONBLOCKvà busy loop còn đẩy thông lượng cao hơn nữa, nhưng sẽ tiếp tục chiếm dụng lõi CPU trong lúc chờ trạng thái sẵn sàng
Mục tiêu thí nghiệm và hiệu năng cơ sở
- Mục tiêu là tìm hiểu Unix pipe được triển khai như thế nào trên Linux, rồi tối ưu hóa từng bước một chương trình kiểm thử ghi và đọc dữ liệu qua pipe
- Môi trường thí nghiệm là CPU Intel Skylake i7-8550U và Linux 5.17; vì cách triển khai nội bộ của Linux liên tục thay đổi nên các con số có thể khác tùy môi trường
- Điểm xuất phát là một chương trình lặp lại việc đẩy bộ đệm 256KiB qua pipe bằng các system call
writevàreadwriteliên tục ghi cùng một bộ đệm 256KiBreadđọc tổng cộng 10GiB rồi in thông lượng- Mã nguồn nằm tại
pipes-speed-test
- Chương trình FizzBuzz được tối ưu hóa rất mạnh dùng để so sánh cho ra khoảng 36.2GiB/s khi đo bằng
pv- FizzBuzz dùng các khối 256KiB, bằng kích thước cache L2
- Thí nghiệm này cũng dùng cùng khối 256KiB, nhưng không tính toán nội dung đầu ra để quan sát giá trị gần với giới hạn IO của pipe
- Kết quả triển khai đầu tiên như sau
./write | ./read- 3.7GiB/s, bộ đệm 256KiB, 40960 lần lặp, tổng truyền 10GiB
Vì sao write và read chậm
- Khi kiểm tra bằng
perf record -gvàperf report, khoảng 47% thời gian phíawriteđược tiêu tốn trongpipe_write - Bên trong
pipe_write,copy_page_from_itervà__alloc_pageschiếm tỷ trọng lớn- sao chép dữ liệu
- cấp phát trang
- lập lịch
- chờ và đánh thức
- lấy và nhả khóa
- Pipe của Linux là một ring buffer chứa tham chiếu trang
pipe_inode_infocóhead,tail, và mảngpipe_bufferpipe_buffercóstruct page *page,offset,len- Kích thước trang thông thường trên x86-64 là 4KiB
- Số slot ring buffer mặc định là 16, và ví dụ pipe 8 slot có thể chứa tối đa 32KiB
headbiểu thị đầu ghi, còntailbiểu thị đầu đọc- Khi pipe đầy,
writesẽ bị block - Khi pipe rỗng,
readsẽ bị block
- Khi pipe đầy,
pipe_writenhìn chung diễn ra theo trình tự sau- Nếu pipe đã đầy thì chờ đến khi có chỗ trống
- Nếu bộ đệm mà
headhiện tại trỏ tới còn chỗ thì điền vào đó trước - Nếu còn slot trống và vẫn còn dữ liệu thì cấp phát trang mới, điền dữ liệu rồi cập nhật
head
pipe_readlàm ngược lại: tiêu thụ các trang, giải phóng trang đã đọc xong hoàn toàn và cập nhậttail- Trên đường đi này, mỗi trang bị sao chép hai lần
- một lần từ bộ nhớ người dùng vào kernel
- một lần nữa từ kernel trở lại bộ nhớ người dùng
- Việc sao chép diễn ra theo đơn vị trang 4KiB, xen giữa là đồng bộ hóa và cấp phát/giải phóng trang
- Trên cùng máy đó, tốc độ đọc RAM tuần tự một luồng khoảng 16GiB/s; xét đến độ phức tạp của đường đi pipe, việc
write/readchậm hơn khoảng 4 lần cũng không có gì lạ
Giảm sao chép bằng vmsplice và splice
- Với IO tốc độ cao, chi phí sao chép giữa bộ nhớ người dùng và bộ đệm kernel có thể trở thành nút thắt
- Linux cung cấp các system call liên quan đến pipe để di chuyển dữ liệu không cần sao chép
vmsplicechỉ định các bộ đệm đưa vào pipe bằng một mảngstruct iovec- Giá trị trả về là số byte thực sự đã được đưa vào pipe
- Do giới hạn kích thước ring buffer của pipe, toàn bộ kích thước yêu cầu có thể không vào hết trong một lần
vmsplicegắn bộ nhớ người dùng vào pipe mà không sao chép, nên cần cẩn thận không tái sử dụng bộ đệm trước khi phía đọc tiêu thụ xong dữ liệu đó- Chương trình FizzBuzz dùng double buffering cho việc này
- Chia bộ đệm 256KiB thành hai nửa 128KiB
- Đặt kích thước pipe thành 128KiB để tạo 32 slot theo trang 4KiB
- Luân phiên điền dữ liệu vào hai nửa bộ đệm rồi đưa vào pipe bằng
vmsplice
- Chương trình kiểm thử thực ra không ghi lại nội dung bộ đệm, nhưng vẫn giữ cấu trúc double buffering tương tự chương trình sinh đầu ra thực tế
- Khi thay
writebằngvmsplice, thông lượng tăng lên 12.7GiB/s- Lượng sao chép giảm một nửa
- Cải thiện hơn 3 lần so với
write/read
- Nếu phía đọc cũng đổi sang
splice, toàn bộ sao chép bị loại bỏ và thông lượng tăng tới 32.8GiB/s - Tuy nhiên, cần lưu ý về độ an toàn của
vmsplice- Nếu một trang bị splice lại, vòng đời của nó có thể kéo dài hơn
- Việc có còn không an toàn khi không dùng
SPLICE_F_GIFThay không vẫn chưa rõ ràng - Muốn dùng pipe zero-copy an toàn thì vẫn cần đặc biệt cẩn trọng
iov_iter_get_pages và chi phí chuyển đổi trang
- Sau khi áp dụng
vmsplicevàsplice, trongperfthời gian ở đường đivmsplicehiện lên rất rõiov_iter_get_pages__mutex_lock.constprop.0add_to_pipe
iov_iter_get_pageschuyển dải bộ nhớ ảo trongstruct iovectruyền vàovmsplicethành danh sáchstruct pagemà pipe có thể chứa- Tiến trình không dùng địa chỉ bộ nhớ vật lý mà dùng địa chỉ ảo
- CPU chuyển đổi địa chỉ ảo sang địa chỉ vật lý qua bảng trang
- Kích thước trang thông thường trên x86-64 là 4KiB
- Bảng trang x86-64 thường được mô tả là cấu trúc cây 4 tầng, mỗi tầng 512 nhánh
pipe_buffercủa pipe tham chiếu tớistruct pagestruct pagelà cấu trúc cốt lõi để kernel xử lý metadata liên quan đến các trang vật lý- Vì vậy
vmsplicephải chuyển dải bộ nhớ ảo đầu vào thành tham chiếu trang vật lý
- Phần lớn thời gian bên trong
iov_iter_get_pagesđược tiêu tốn trongget_user_pages_fast- Với bộ đệm 128KiB và trang 4KiB, tổng cộng cần lấy 32 trang
- Mã pipe do cách triển khai sẽ gọi với
nr_pages = 16mỗi lần và lặp lại khi cần, nhưng tổng số trang được splice vẫn là 32
get_user_pages_fastđi qua bảng trang bằng phần mềm để thu thậpstruct page, tương tự những gì CPU làm- Trong lúc bên gọi đang dùng trang, phải tăng reference count của
struct pageđể trang vật lý không bị tái sử dụng- Sau đó phải giảm reference count bằng
put_page
- Sau đó phải giảm reference count bằng
- Nếu điền bộ đệm trước bằng
memsetkhi bắt đầu, các mục bảng trang sẽ được tạo sẵn nênget_user_pages_fastcó thể tránh đường chậm- Nếu không
memset, trong ví dụ này thông lượng giảm xuống 25.0GiB/s - Khi dùng huge page, hiện tượng này không xuất hiện giống như vậy
- Nếu không
Giảm chi phí xử lý trang bằng huge page
- Ngoài trang thường 4KiB, x86-64 còn hỗ trợ huge page 2MiB và 1GiB
- Các thí nghiệm sau đó chỉ xét huge page 2MiB
- Trang 1GiB tương đối hiếm và được xem là quá mức cần thiết cho công việc này
- Huge page biểu diễn cùng một dải bộ nhớ bằng ít trang hơn nên giảm chi phí quản lý
- Khi chuyển địa chỉ ảo sang địa chỉ vật lý, số tầng bảng trang cũng giảm đi một tầng
- Nó cũng có thể giúp giảm gánh nặng lên TLB của CPU
- Nút thắt trực tiếp trong thí nghiệm này không phải page table walk bằng phần cứng, mà là đường đi phần mềm
get_user_pages_fasttrong kernel - Trên Linux, có thể yêu cầu dùng huge page bằng cách cấp phát bộ nhớ căn chỉnh theo 2MiB rồi gọi
madvise(..., MADV_HUGEPAGE) - Khi áp dụng huge page, thông lượng tăng lên 51.0GiB/s
- Lý do cải thiện không phải vì
struct pagetrỏ thẳng tới toàn bộ một trang 2MiB- Mã kernel nhìn chung giả định
struct pagetrỏ tới kích thước trang chuẩn của kiến trúc hiện tại - Huge page được biểu diễn bằng một head
struct pagevà nhiều tailstruct page - Một huge page 2MiB có thể được biểu diễn bằng tối đa 512
struct pagetheo chuẩn 4KiB
- Mã kernel nhìn chung giả định
- Dù vậy, sau khi tìm được entry đầu tiên thì các
struct pagetiếp theo có thể được tạo bằng một vòng lặp đơn giản, giúp giảm chi phí phải dò bảng trang nhiều lần - Trong kernel sau Linux 5.17 có
struct foliođể nhận diện rõ head page, giảm nhu cầu phải kiểm tra head/tail ở thời gian chạy
Giảm chi phí đồng bộ bằng busy loop
- Sau khi áp dụng huge page,
perfcho thấy thời gian trongwait_for_spacevà__wake_up_common_locktrở nên nổi bật- chi phí chờ không gian có thể ghi
- chi phí đánh thức phía đọc
- Để tránh chi phí đồng bộ này, có thể dùng
SPLICE_F_NONBLOCKvớivmsplicevàsplice- Nếu không thể ghi vào pipe, lời gọi sẽ lập tức trả về
EAGAIN - Bên gọi sẽ quay busy loop cho đến khi sẵn sàng
- Nếu không thể ghi vào pipe, lời gọi sẽ lập tức trả về
- Khi áp dụng busy loop, thông lượng tăng tới 62.5GiB/s
- Cái giá phải trả cũng rất rõ ràng
- Trong lúc chờ
vmsplicehoặcsplicesẵn sàng, lõi CPU sẽ bị chiếm dụng hoàn toàn - Đây là cách đổi thêm mức dùng CPU để lấy độ trễ thấp hơn hoặc thông lượng cao hơn
- Trong lúc chờ
- Cuối cùng, benchmark tổng hợp này được cải thiện từ khoảng 3.5GiB/s lên khoảng 65GiB/s
Các chi tiết còn lại và chủ đề thực tiễn
- Quá trình tối ưu hóa được thực hiện bằng cách xem đồng thời đầu ra
perfvà mã nguồn Linux - Các chủ đề được bàn đến còn rộng hơn chính pipe và splicing, gắn với những chủ đề lập trình hiệu năng cao nói chung
-
Vận hành không sao chép
- ring buffer
- phân trang và bộ nhớ ảo
- overhead đồng bộ hóa
- Trong mã thực tế, hai bộ đệm được cấp phát riêng để giảm tranh chấp bảng trang
get_user_pagestăng reference count của mục bảng trang vàput_pagegiảm nó xuống- Nếu hai bộ đệm dùng các mục bảng trang khác nhau, tranh chấp khi sửa reference count sẽ giảm đi
- Bài kiểm thử được chạy với
tasksetđể cố định hai tiến trình./writevà./readvào hai lõi CPU - Kho lưu trữ cũng có benchmark tổng hợp cho
get_user_pages_fast - Có thể đo chênh lệch tốc độ khi có và không dùng huge page
- Splicing vẫn là một khái niệm còn mơ hồ và rủi ro, và các vấn đề liên quan tiếp tục là gánh nặng cho các nhà phát triển kernel
-
1 bình luận
Các ý kiến trên Hacker News
Nếu tôi hiểu đúng,
vmsplicetrông gần giống một cơ chế bộ nhớ chia sẻ nhỏ giữa hai tiến trình khi được dùng đồng thời ở cả hai đầu đọc và ghiTức là cả hai tiến trình đều phải cực kỳ cẩn thận về thời điểm đọc/ghi bộ đệm và cách trả lại nó sau khi dùng. Đây là cách nhanh nhưng cũng đáng sợ, và cũng đáng tiếc là cách triển khai ngây thơ mà ai cũng có thể viết lại chậm hơn 20 lần so với hiệu năng có thể đạt được
vmsplicelà một cơ chế bộ nhớ chia sẻ nhỏ giữa hai tiến trình. Nó chỉ hỗ trợ zero-copy từ bộ nhớ người dùng vào pipe, còn chiều ngược lại vẫn phát sinh sao chépXem chi tiết tại https://mazzo.li/posts/fast-pipes.html#fn10
Tôi tò mò liệu có thư viện xử lý dữ liệu nào trừu tượng hóa pipe, socket, file, bộ nhớ mà vẫn thực hiện được cả những tối ưu kiểu này không
Tôi muốn biết trong C, C++, Rust hay các ngôn ngữ hệ thống khác có thư viện như vậy không. Vì không quen với các API như
splice()vàvmsplice()được nhắc trong bài, nên tôi tự hỏi liệu có thư viện nào khi xây dựng ứng dụng cấp thấp có thể tự động tận dụng các tối ưu này khi có thể hay không. Tôi cũng tò mò liệulibuv,tokio,Nettycó tự xử lý việc này trên Linux không; qua tìm hiểu nhanh thì có vẻ có thể là vậyMã ở tầng cao hơn hiếm khi dùng các tính năng này, vì chúng khá chuyên biệt và phải được tối ưu riêng cho Linux. Nếu chỉ di chuyển dữ liệu trên Linux mà không cần nhìn vào nội dung,
splicesẽ hữu ích. Các ứng dụng như proxy TCP/UDP chắc chắn cần đến nó, nhưng với một HTTP server thông thường thì không phù hợp lắm. Nếu bạn đang xây các ứng dụng kiểu này, bạn sẽ thường gặp các từ khóa như zero copy, vàsplicelà một trong những kết quả đầu tiên bạn sẽ thấytokio. Không tự động, nhưng có thể thú vị: https://lib.rs/crates/tokio-spliceCó vẻ nó không được biết đến nhiều ngoài môi trường nghiên cứu, và khả năng cao sẽ mất khá nhiều thời gian để viết một triển khai Cosh hiệu quả. Tóm lại, có ba chế độ truyền: move, share, copy. Ví dụ, truyền kiểu move sẽ chuyển hẳn dữ liệu mà bên gửi có quyền đọc/ghi sang cho bên nhận, và có thể được triển khai bằng cách ánh xạ lại bộ nhớ ảo trong bảng trang. Ngoài ra còn có thuộc tính strong/weak cho biết liệu có thể tin rằng bên gửi và bên nhận sẽ hợp tác với nhau hay phải cách ly nghiêm ngặt bằng cách ánh xạ lại quyền bộ nhớ ảo. Thành thật mà nói, tôi không biết liệu nó có thể được tối ưu đủ tốt để cạnh tranh ổn định với những thứ như pipe được tối ưu cực cao hay không; cũng có thể nó sẽ trở thành bài toán “trình biên dịch đủ thông minh”. Dù vậy, tôi nghĩ vẫn đáng để thử
[1] https://barrelfish.org/publications/trios14-baumann-cosh.pdf
Thảo luận trước đó vào năm 2022: https://news.ycombinator.com/item?id=31592934
Một điều đáng ngạc nhiên tôi tình cờ biết được 4 năm trước là việc dùng pipe của Linux có thể tạo ra hành vi không xác định
https://www.gibney.org/the_output_of_linux_pipes_can_be_inde...
echo(echo red; echo green 1>&2) | echo bluetạo ra hai subshell ở hai bên ký hiệu|. Subshell là tiến trình con của shell hiện tại, nên kế thừa các thuộc tính quan trọng như bảng file descriptor đang mở. Hai subshell chạy đồng thời, còn shell cha chỉwait()cho đến khi mọi tiến trình con kết thúc. Nhìn chung không thể dự đoán tiến trình con nào chạy trước, và trên hệ thống đa lõi thì chúng thậm chí có thể chạy đúng nghĩa là đồng thời. Đầu ra chuẩn của subshell bên trái được nối vào đầu ghi của pipe, còn đầu vào chuẩn của subshell bên phải được nối vào đầu đọc. Nhưngecho bluekhông đọc input mà chỉ xuất ra, nên không có gì được đọc từ pipe.echo green >&2gửi đầu ra chuẩn đến nơi mà lỗi chuẩn trỏ tới, chứ không phải vào pipe. Kết quả làecho greenvàecho bluecùng ghi trực tiếp vào cùng một file, có lẽ là terminal, tạo ra race condition, và thứ tự sẽ khác nhau tùy bên nào được lập lịch trướcNếu không thì pipeline sẽ không hữu ích. Ví dụ, trong một pipeline dùng
curlđể tải một file tar rồi giải nén ngay, nếu phải đợicurlkết thúc rồi mới chạytarthì sẽ nảy sinh vấn đề như phải lưu file tar trung gian lớn đó ở đâu.tarcần chạy cùng lúc vớicurlđể giữ buffer nhỏ và chạy nhanh. Luồng điều khiển duy nhất giữa các chương trình trong pipeline là qua đầu vào chuẩn và đầu ra chuẩn. Trong ví dụ này đang ghi vào lỗi chuẩn, nên hiển nhiên nó không thuộc luồng điều khiển mang tính quyết địnhNhân tiện, để tránh nhầm lẫn, “Indeterministic” là thuật ngữ triết học, còn thuật ngữ khoa học máy tính là “nondeterministic”
0. https://blog.superpat.com/zero-copy-in-linux-with-sendfile-a...
Lệnh này có lẽ được cố ý viết theo kiểu kỳ lạ, và nếu là người review code thì chắc chắn sẽ nghiêng đầu thắc mắc. Có
echo rednhưng nó không được truyền tới đâu cả. Cũng có thể là một trò đùa “red herring”.echo greenđi ra lỗi chuẩn nên chỉ thấy trướcecho bluekhi nó kết thúc trước. Thứ tự chính xác phụ thuộc vào buffering đầu ra, điều này thay đổi theo time slice nào được cấp trước, cũng như theo số CPU và tải hệ thống. Vì vậy nó không xác định, nhưng theo cùng kiểu màtopcũng không xác địnhTóm lại, giả sử hai chương trình được viết tối ưu nhất có thể, tốc độ tối đa của pipe gần bằng tốc độ mà một lõi của hệ thống có thể đọc và ghi
Về bản chất, vì kernel ánh xạ cùng các trang bộ nhớ vật lý từ đầu ra chuẩn của một chương trình sang đầu vào chuẩn của chương trình khác, thao tác này trở thành không sao chép, hoặc trong các tình huống kém tối ưu hơn thì gần với một lần sao chép nhanh. Khi biết điều này, việc dùng shell script nối hai hay nhiều công cụ bằng pipe để thực hiện các tác vụ hiệu năng rất cao vừa đáng hài lòng vừa buồn cười. Đây là một trong những công cụ hữu ích nhất trong hộp đồ nghề
splicehoặcvmsplice. Các system call riêng của Linux này khó dùng, đặc biệt làvmsplicePhần lớn chương trình và bộ lọc shell không dùng chúng, ngoại trừ một vài trường hợp nổi bật như
pv, nên vẫn phải trả chi phí sao chép vào bộ nhớ kernel rồi lại lấy raTùy đặc tính tải, ứng dụng bạn đang xây có thể được triển khai gọn gàng hơn bằng pipe+process hoặc green/user-space thread. Có thể kém tiện hơn, nhưng truyền thông điệp thường vẫn tốt hơn địa ngục deadlock
Bài này nói về cách làm pipe Linux nhanh hơn, nhưng các cách khác như shared memory hoặc message queue vẫn có thể nhanh hơn
Trong các hệ thống cần chuyển nhiều dữ liệu thật nhanh, các bước bổ sung của pipe có thể làm giảm tốc độ. Khi nhiều thread chia sẻ dữ liệu, pipe cũng có thể tạo ra nhiều vấn đề hơn so với các cách khác. Vì vậy các cải tiến trong bài có thể không giúp ích nhiều trong những tình huống thực tế nơi tốc độ là yếu tố quan trọng
io_uringsẽ có lợiNhưng với giao tiếp hai chiều, bên nào cũng cần thông báo rằng dữ liệu đã sẵn sàng. Có thể bạn không muốn đốt CPU để polling, và tôi không rõ những lựa chọn đó xử lý phần đồng bộ này nhanh hơn pipe như thế nào
Tôi vốn biết về những thứ như page table, nhưng khi nối nó với phân tích hiệu năng bằng
perfthì mới thấy rõ nó trung tâm đến mức nào đối với throughputPipe rất tuyệt. Thành thật mà nói, việc tiến trình khác ở CPU khác hay ở máy khác cũng không quá quan trọng
https://github.com/nathants/s4/blob/master/examples/nyc_taxi...
Pipe đủ nhanh để kết hợp lặp đi lặp lại
cat,sed,awk,cut,grep,uniq,jqv.v.