22 điểm bởi GN⁺ 2025-06-24 | 3 bình luận | Chia sẻ qua WhatsApp
  • Phân tích hiệu năng của pipe Unix được triển khai trên Linux thông qua quá trình tối ưu hóa dần từng bước
  • Băng thông của chương trình pipe đơn giản ban đầu được đo vào khoảng 3.5GiB/s, và bài viết trình bày quá trình cải thiện con số này hơn 20 lần bằng profiling và thay đổi system call
  • Giải thích nhiều kỹ thuật tối ưu khác nhau như tận dụng các system call zero-copy như vmsplice, splice để giảm sao chép dữ liệu không cần thiết, đồng thời tăng kích thước page
  • Giải quyết nút thắt bằng sử dụng Huge Page và kỹ thuật busy loop, ghi nhận tốc độ xử lý tối đa 62.5GiB/s
  • Cung cấp góc nhìn về những yếu tố quan trọng trong lập trình kernel và máy chủ hiệu năng cao như pipe, paging, chi phí đồng bộ hóa và zero-copy

Tổng quan và mở đầu

  • Bài viết này trình bày cách Unix pipe được triển khai trên Linux, đồng thời tự viết chương trình thử nghiệm đọc và ghi dữ liệu qua pipe để tối ưu hiệu năng theo từng bước
  • Ban đầu bắt đầu với một chương trình đơn giản có băng thông khoảng 3.5GiB/s, sau nhiều tối ưu đã đạt được mức cải thiện hiệu năng khoảng 20 lần
  • Mỗi bước tối ưu được quyết định dựa trên kết quả profiling bằng công cụ perf, và mã nguồn liên quan được công khai tại GitHub - pipes-speed-test
  • Nguồn cảm hứng xuất phát từ việc quan sát tốc độ xử lý dữ liệu qua pipe trong chương trình FizzBuzz hiệu năng cao (36GiB/s)
  • Chỉ cần kiến thức cơ bản về ngôn ngữ C là có thể theo dõi nội dung

Đo hiệu năng pipe: phiên bản chậm đầu tiên

  • Từ kết quả chạy ví dụ của chương trình FizzBuzz hiệu năng cao, có thể xác nhận rằng nó xử lý 36GiB dữ liệu mỗi giây thông qua pipe
  • FizzBuzz xuất dữ liệu theo khối có kích thước bằng cache L2 (256KiB) để cân bằng giữa truy cập bộ nhớ và overhead IO
  • Chương trình kiểm tra hiệu năng pipe trong bài này cũng lặp lại thao tác xuất/đọc theo khối 256KiB, và để đo đạc, cả hai đầu read và write đều được tự triển khai trực tiếp
  • write.cpp liên tục ghi cùng một buffer 256KiB, còn read.cpp đọc 10GiB rồi kết thúc và hiển thị throughput
  • Kết quả thử nghiệm cho thấy thao tác read/write qua pipe đạt 3.7GiB/s, chậm hơn khoảng 10 lần so với FizzBuzz

Nút thắt của thao tác write và cấu trúc bên trong

  • Khi dùng công cụ perf để theo dõi call graph trong lúc chạy chương trình, có thể thấy gần một nửa tổng thời gian bị tiêu tốn ở bước ghi vào pipe, tức pipe_write
  • Trong pipe_write, phần lớn thời gian được dùng cho sao chép và cấp phát page bộ nhớ (copy_page_from_iter, __alloc_pages)
  • Pipe trên Linux được triển khai dưới dạng ring buffer, trong đó mỗi entry tham chiếu tới page chứa dữ liệu thực tế
  • Tổng kích thước buffer của pipe là cố định, vì vậy khi pipe đầy thì write sẽ bị block, còn khi rỗng thì read sẽ bị block
  • Trong các cấu trúc C (pipe_inode_info, pipe_buffer), head và tail lần lượt biểu thị vị trí ghi/đọc, đồng thời chứa thông tin offset và độ dài của từng page

Logic đọc/ghi của pipe

  • pipe_write hoạt động theo trình tự sau
    • Nếu pipe đầy thì chờ đến khi có chỗ trống
    • Trước tiên lấp đầy phần trống còn lại tại head hiện tại
    • Nếu vẫn còn dữ liệu, cấp phát page mới, sao chép dữ liệu vào buffer và cập nhật head
  • Toàn bộ thao tác được bảo vệ bằng lock nên phát sinh overhead đồng bộ hóa
  • Phía đọc (read) cũng hoạt động theo cùng cấu trúc, di chuyển tail và giải phóng các page đã đọc
  • Về bản chất, dữ liệu bị sao chép hai lần: từ bộ nhớ người dùng vào kernel, rồi từ kernel quay lại không gian người dùng, tạo ra overhead đáng kể

Zero-copy: tối ưu bằng splice/vmsplice

  • Cách tiếp cận phổ biến để có IO nhanh là bypass kernel hoặc giảm tối đa việc sao chép
  • Linux hỗ trợ các system call splicevmsplice để có thể bỏ qua thao tác copy khi di chuyển dữ liệu giữa pipe và không gian người dùng
    • splice: di chuyển dữ liệu giữa pipe và file descriptor
    • vmsplice: di chuyển dữ liệu giữa bộ nhớ người dùng và pipe
  • Cả hai system call này đều có thể chỉ di chuyển tham chiếu mà không cần di chuyển dữ liệu thực tế
  • Ví dụ, khi dùng vmsplice, buffer 256KiB được chia làm đôi, rồi áp dụng double buffering để vmsplice luân phiên từng nửa vào pipe
  • Trên thực tế, áp dụng vmsplice giúp tăng tốc hơn 3 lần (khoảng 12.7GiB/s), và khi áp dụng thêm splice ở phía đọc thì tiếp tục tăng lên 32.8GiB/s

Nút thắt liên quan đến page và việc dùng Huge Page

  • Kết quả phân tích bằng perf cho thấy nút thắt của vmsplice tập trung vào lock của pipe (mutex_lock)việc lấy page (iov_iter_get_pages)
  • iov_iter_get_pages có nhiệm vụ chuyển đổi bộ nhớ người dùng (virtual address) sang page vật lý (physical page) rồi lưu tham chiếu vào trong pipe
  • Cơ chế paging của Linux không chỉ dùng page 4KiB mà còn hỗ trợ nhiều kích thước khác nhau tùy kiến trúc, chẳng hạn 2MiB (huge page)
  • Khi dùng Huge Page (ví dụ 2MiB), overhead chuyển đổi page giảm đáng kể nhờ giảm chi phí quản lý page table và số lần tham chiếu
  • Khi áp dụng huge page trong chương trình, throughput tối đa tăng thêm khoảng 50%, đạt 51.0GiB/s

Áp dụng busy loop

  • Nút thắt còn lại là các xử lý đồng bộ như wait để chờ pipe có chỗ trống để ghi, hay wake để đánh thức phía đọc
  • Dùng tùy chọn SPLICE_F_NONBLOCK và khi phát sinh EAGAIN thì lặp gọi bằng busy loop để loại bỏ overhead scheduling của kernel
  • Khi áp dụng kỹ thuật này, throughput tối đa tăng thêm 25%, đạt 62.5GiB/s
  • Busy loop tiêu tốn 100% tài nguyên CPU, nhưng đây là mẫu hình thường gặp trong máy chủ hiệu năng cao

Tổng kết và các điểm khác

  • Bài viết giải thích cách cải thiện mạnh hiệu năng của pipe theo từng bước thông qua perf và phân tích mã nguồn Linux
  • Có thể trải nghiệm các vấn đề cốt lõi của lập trình hiệu năng cao như pipe, splice, paging, zero-copy và chi phí đồng bộ hóa qua các ví dụ thực tế
  • Trong mã thực tế, còn có thêm các tinh chỉnh hiệu năng như cấp phát buffer trên các page khác nhau để giảm tranh chấp refcount
  • Bài kiểm thử được chạy bằng cách cố định từng tiến trình vào các core riêng biệt bằng taskset
  • Dòng splice về mặt thiết kế có thể tiềm ẩn rủi ro và từ lâu đã là chủ đề gây tranh luận đối với một số nhà phát triển kernel

3 bình luận

 
iolothebard 2025-06-27

Wow! Thú vị thật đấy! (Nhưng mình hoàn toàn chẳng hiểu đang nói về cái gì cả… )

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
Ý kiến Hacker News
  • Không thể quên lần từng port một ứng dụng dựa trên pipe của Linux sang Windows; vì đều theo chuẩn POSIX nên đã nghĩ hiệu năng sẽ không chênh lệch nhiều, nhưng thực tế lại chậm khủng khiếp. Tôi còn nhớ có vấn đề nghiêm trọng đến mức gần như làm cả Windows bị treo khi chờ kết nối pipe. Vài năm sau, khi viết lại thứ tương tự bằng C# trên Win10 thì có khá hơn đôi chút, nhưng khoảng cách hiệu năng vẫn là một nỗi xấu hổ lớn.

    • Tôi nhớ là trong vài năm gần đây Windows đã bổ sung socket AF_UNIX; tôi khá tò mò không biết so với pipe Win32 thì bên nào nhanh hơn. Dự đoán của tôi là AF_UNIX sẽ tốt hơn.

    • Khi nói "hiệu năng rất tệ", tôi tò mò không biết ý là I/O sau khi pipe đã kết nối xong, hay là giai đoạn trước khi kết nối. Nếu là sau khi đã kết nối thì khá bất ngờ, nhưng nếu vấn đề nằm ở việc lặp đi lặp lại kết nối/ngắt kết nối thì tôi chấp nhận khả năng là HĐH không tối ưu chỗ đó, vì trên thực tế hiếm khi cần làm như vậy, nên còn tùy vào use case mà đánh giá.

    • Theo những gì tôi kiểm tra gần đây, trên Windows, hiệu năng của TCP cục bộ vượt trội hơn pipe rất nhiều.

    • POSIX chỉ định nghĩa hành vi chứ không định nghĩa hiệu năng; nên cần nhớ rằng mỗi nền tảng và mỗi HĐH đều có những đặc điểm hiệu năng riêng.

    • Ngày xưa tôi từng có trải nghiệm theo hướng ngược lại. Không phải pipe, nhưng khi một ứng dụng PHP trên Linux giao tiếp với SOAP API viết bằng .NET thì tôi nhớ phía triển khai .NET lại phản hồi nhanh hơn.

  • Nhân tiện, có nhiều cách như readv() / writev(), splice(), sendfile(), funopen(), io_buffer()... splice() đặc biệt xuất sắc khi truyền dữ liệu lớn theo kiểu zero-copy giữa pipe và UNIX socket, nhưng nó chỉ có trên Linux. splice() là cách nhanh nhất để xử lý trực tiếp trong lúc truyền dữ liệu mà không cần cấp phát bộ nhớ không gian người dùng, quản lý buffer bổ sung, memcpy() hay duyệt iovec. Với họ BSD, cũng muốn xác nhận liệu readv()/writev() có thực sự là lựa chọn tối ưu cho pipe hay không. Dù sao thì bài này cũng rất ấn tượng.

    • sendfile() mang lại hiệu năng rất cao với mô hình zero-copy từ file → socket, dùng được trên cả Linux lẫn BSD, nhưng chỉ hỗ trợ file → socket. sendmsg() thì không dùng được cho pipe thông thường; nó dành cho UNIX domain/INET/các loại socket khác. Nhân tiện, trên Linux, do sendfile được hiện thực nội bộ bằng splice nên tôi từng thực sự dùng nó để truyền file → block device.

    • splice() là lựa chọn số một cho truyền khối lượng dữ liệu cực lớn siêu nhanh giữa các pipe trên Linux, nhưng nếu dùng io_uring đúng cách thì có thể đạt hiệu năng tương đương, thậm chí vượt qua.

    • Chia sẻ bộ nhớ như shm_open cùng với cơ chế truyền file descriptor trên thực tế còn nhanh hơn, và hoàn toàn portable.

  • Có người dẫn lại các link cho biết đây là những lần HN trước đó đã thảo luận rất sôi nổi về bài này: https://news.ycombinator.com/item?id=31592934 (200 bình luận), https://news.ycombinator.com/item?id=37782493 (105 bình luận)

  • Một bài thật sự rất hay, và việc nó được nhắc lại định kỳ cũng là điều rất đáng mừng.

    • Sửa lỗi chính tả: comes → comes up
  • Cảm giác hơi tiếc vì vẫn chưa có bình luận nào, và tôi muốn dùng splice nhiều hơn, nhưng lại lo về các vấn đề bảo mật hay tương thích ABI được nhắc ở cuối bài. Tôi cũng thắc mắc liệu splice có tiếp tục được duy trì lâu dài không, và việc vá để pipe mặc định luôn dùng splice nhằm cải thiện hiệu năng sẽ khó đến mức nào.

  • Có ai biết trên Linux hiện đại có thứ gì tương tự Doors của SunOS không? Tôi đang tìm một công nghệ tốt hơn AF_UNIX cho ứng dụng nhúng cần trao đổi lượng dữ liệu nhỏ nhưng cực kỳ nhạy với độ trễ.

    • Xét về độ trễ, shared memory là nhanh nhất, nhưng cần cơ chế đánh thức task, thường là dùng futex. Google từng phát triển system call FUTEX_SWAP, dự kiến cho phép handoff trực tiếp từ task này sang task khác, nhưng sau đó thì tôi không rõ tình hình thế nào.

    • 'Doors' là một từ quá phổ biến nên khó tìm kiếm; xin giải thích thêm nó là gì.

    • Cụ thể thì hiện tại bạn đang gặp vấn đề gì với AF_UNIX? Nó thiếu tính năng cần thiết, độ trễ cao hơn mong muốn, hay là mô hình API socket kiểu server/client không phù hợp?

  • Bổ sung ngắn gọn rằng thời điểm bài viết được đăng là năm 2022.