1 điểm bởi GN⁺ 2024-08-27 | 1 bình luận | Chia sẻ qua WhatsApp
  • Khi ghi dữ liệu vào đường ống Linux bằng write, tốc độ chậm hơn rất nhiều so với ghi bộ nhớ đơn thuần; trong môi trường thử nghiệm, ghi vào bộ đệm bộ nhớ đạt 167GB/s trong khi ghi vào pipe chỉ ở mức 17GB/s
  • Nút thắt không thể giải thích chỉ bằng một lần sao chép dữ liệu; chi phí cấp phát trang, khóa và routine sao chép của kernel bên trong pipe_write cùng cộng dồn lại
  • vmsplice gắn bộ đệm không gian người dùng vào pipe mà không sao chép sang kernel, qua đó tránh các đường đi tốn kém như __alloc_pages, _raw_spin_lock_irq, copy_user_enhanced_fast_string
  • Trong ví dụ thông lượng Fizz Buzz, cách dùng vmsplice đạt 60.8GiB/s trên một lõi, 208.3GiB/s trên nhiều lõi, và một ví dụ vmsplice ở thí nghiệm khác ghi nhận 210GB/s
  • Do có các đính chính, cách diễn giải về mức phạt do không dùng SIMD không còn đáng tin cậy; giao tiếp liên tiến trình cũng không thể chỉ dừng ở cache L1 nên khó kỳ vọng 167GB/s là thông lượng pipe thực tế

Điểm xuất phát: khoảng cách lớn do vmsplice tạo ra

  • Một số chương trình dùng system call vmsplice để di chuyển dữ liệu qua pipe nhanh hơn
  • Trong cuộc thi thông lượng Fizz Buzz trên Code Golf StackExchange, các lời giải chia thành hai nhóm lớn
    • Các lời giải không dùng vmsplice chỉ ở mức vài GiB mỗi giây; lời giải của neil đạt 8.4GiB/s
    • Các lời giải dùng vmsplice tăng lên tới 15.5GiB/s của tkluck, 60.8GiB/s của ais523, và 208.3GiB/s đa lõi của david
  • Chỉ riêng tác dụng giảm sao chép giữa không gian kernel và không gian người dùng là chưa đủ để giải thích mức chênh gần 7 lần ở bài đo đơn lõi
  • Trong thử nghiệm riêng, lời giải của ais523 đạt 96.4GiB/s, còn lời giải của david đạt 277GB/s khi dùng 7 lõi, khoảng 40GB/s mỗi lõi

Mốc chuẩn: ghi bộ nhớ trong không gian người dùng

  • Một chương trình Rust lặp lại việc sao chép bộ đệm 32KiB trong không gian người dùng mà không gọi system call đã đạt 167GB/s trong môi trường thử nghiệm
  • Mức này được xem là tương ứng với tốc độ ghi vào cache L1 của CPU được dùng
    • Hệ thống thử nghiệm dùng Ryzen 9 7950X3D, DDR5 6000T/s, Debian 12, Linux 6.1.0-18-amd64
    • Các tùy chọn giảm thiểu CPU bị tắt bằng mitigations=off
  • Trong profiling bằng ftrace, phần lớn thời gian nằm ở __memset_avx512_unaligned_erms
  • Tuy vậy, các đính chính đã giới hạn cách diễn giải này
    • Lệnh tại điểm ngắt là rep stos, không phải lệnh AVX-512
    • Ngay cả khi giới hạn chỉ dùng AVX2 và SSE2, thông lượng vẫn là 167GB/s vì trong mọi trường hợp đều dùng rep stos

Chi phí thực tế khi ghi vào pipe bằng write

  • Khi ghi bộ đệm cùng kích thước vào pipe bằng stdout.write() và tiêu thụ bằng pv >/dev/null, thông lượng giảm xuống còn 17GB/s
  • Kết quả profiling cho thấy phần lớn thời gian bị tiêu tốn trong system call write, đặc biệt 95% nằm trong pipe_write
  • Bên trong pipe_write, việc chuẩn bị trang, khóa và sao chép cùng tạo ra chi phí
    • __alloc_pages: 36% tổng thời gian, dùng để chuẩn bị các trang bộ nhớ mới cho pipe
    • __mutex_lock.constprop.0: 25% tổng thời gian, là chi phí khóa cho thao tác ghi pipe
    • _raw_spin_lock_irq: 5% tổng thời gian, cũng là chi phí khóa liên quan đến ghi pipe
    • copy_user_enhanced_fast_string: khoảng 20% tổng thời gian, sao chép dữ liệu từ không gian người dùng sang phía kernel
  • pv tiêu thụ dữ liệu bằng cách dùng splice để chuyển các trang sang /dev/null, nên khó tái sử dụng lặp đi lặp lại cùng vài trang trong vòng lặp

Routine sao chép của kernel và cách diễn giải đã được đính chính

  • Khi dịch ngược copy_user_enhanced_fast_string, việc sao chép bộ đệm lớn được thực hiện bằng lệnh REP MOV
  • Hàm này không được viết bằng C mà là mã assembly của Linux kernel, nên đây là đường đi có chủ đích chứ không phải do thiếu tối ưu hóa từ compiler
  • Trong thí nghiệm ban đầu, việc gọi trực tiếp rep movsb ở không gian người dùng đạt 80GB/s, từ đó dẫn đến cách diễn giải rằng routine sao chép của kernel chậm hơn khoảng 2 lần
  • Sau đó, các đính chính cho thấy điều kiện thí nghiệm đã khác
    • Hai bộ đệm 32KiB làm bão hòa cache dữ liệu L1
    • Khi dùng bộ đệm 16KiB, hiệu năng tăng lên 153GB/s
  • Vì vậy, cách diễn giải ban đầu rằng việc không dùng lệnh vector trong sao chép của kernel gây mức phạt lớn là không còn đáng tin cậy
  • Dù vậy, overhead quản lý bộ nhớ trong lúc ghi pipe vẫn là một yếu tố lớn

Các đường đi kernel mà vmsplice tránh được

  • vmsplice chuyển toàn bộ bộ đệm ở không gian người dùng vào pipe mà không sao chép sang kernel, nhờ đó giảm chi phí dùng pipe
  • Ví dụ ./write trong pipes-speed-test của Francesco được dùng như một ví dụ tối giản liên tục ghi 'X'
  • Ví dụ này ghi nhận 210GB/s, nhưng vì nó lặp lại việc đưa cùng một bộ đệm vào vmsplice, nên khác với công việc tạo dữ liệu thông thường
    • Nếu không phải là một luồng byte hằng, cần phải nạp dữ liệu mới vào bộ đệm
    • Khi đó, giới hạn ghi bộ nhớ đơn thuần 167GB/s lại trở nên liên quan
  • Ngay cả trong đường đi vmsplice, __mutex_lock.constprop.0 vẫn chiếm 37% thời gian
  • Tuy nhiên, các thành phần __alloc_pages, _raw_spin_lock_irq, copy_user_enhanced_fast_string xuất hiện ở đường write thì không còn thấy nữa
  • Thay vào đó, các đường đi chính là add_to_pipe, import_iovec, iov_iter_get_pages2, cho thấy vmsplice tránh được những phần tốn kém của write

Kết luận còn lại và những điểm cần lưu ý

  • Theo thí nghiệm, đường ghi vào pipe Linux bằng write chậm hơn khoảng 10 lần so với ghi bộ nhớ đơn thuần
  • Kết luận ban đầu cho rằng chi phí khóa và chi phí lưu/khôi phục ngữ cảnh SIMD trong lúc ghi pipe là lớn, và splice cùng vmsplice đã tránh được điều đó
  • Sau đính chính, kết luận cần được nhìn nhận dè dặt hơn
    • Overhead quản lý bộ nhớ của kernel vẫn là yếu tố quan trọng làm giảm hiệu năng pipe
    • Cách diễn giải rằng việc không dùng lệnh vector gây mức phạt lớn như dự đoán là không chính xác
    • Giao tiếp liên tiến trình không thể chỉ diễn ra trong cache L1, nên 167GB/s khó đạt được như thông lượng thực tế khi tính cả thao tác đọc pipe
  • Một số sai sót quan trọng đã được đính chính và độ tin cậy của kết quả có thể bị giới hạn, vì vậy nên hiểu các con số này theo hướng chỉ báo xu hướng
  • Muốn tăng thông lượng pipe, không chỉ cần giảm số lượng system call mà còn phải xem cả các đường đi nội bộ trong kernel mà write đi qua và cách xử lý bộ đệm

1 bình luận

 
GN⁺ 2024-08-27
Các ý kiến trên Hacker News
  • Có một dự án phụ đang cố xử lý vấn đề này: https://lwn.net/Articles/976836/
    Ý tưởng là tạo một system call để lấy ring buffer cho mọi file descriptor được hỗ trợ. Pipe cũng được bao gồm; nếu cả hai đầu đều hỗ trợ dùng ring buffer, chúng có thể ánh xạ cùng một ring buffer để thực hiện I/O không sao chép, và trong một số trường hợp thậm chí không cần gọi kernel lần nào. Họ đang tìm người cùng tham gia.

    • Ít nhất với các trường hợp dùng trong user space, tôi không chắc có nhất thiết cần tính năng kernel mới hay không. Trước đây tôi từng triển khai một ring buffer single-producer/single-consumer trong user space mô phỏng hành vi của pipe khá sát bằng eventfd.
      Khi ring buffer đầy hoặc rỗng thì có thể sleep/poll, còn ngoài các trường hợp đó thì hoạt động không khóa và không có overhead system call.
    • Tôi tò mò liệu có kế hoạch về một cách báo hiệu chuẩn để hai đầu pipe cho bên kia biết rằng mình hỗ trợ ring buffer hay không. Như vậy libc mới có thể xử lý trong suốt; nếu không thì, riêng với pipe, tôi không rõ nó có ưu điểm gì so với shared memory và đồng bộ bằng futex.
    • Có lẽ ringbuffer_wait() cũng có thể báo hiệu bằng cách làm cho poll() thấy nó ở trạng thái có thể đọc.
    • Tôi tò mò liệu các giao diện ring buffer hiện có có dùng cái này không, hay sẽ xảy ra tình huống xkcd927. Dù sao thì đây có vẻ là một nỗ lực thú vị.
    • Buffering tồn tại là có lý do, và cách này có thể tạo ra các failure mode kỳ lạ và lỗ hổng trong script. Điểm cốt lõi là bất kỳ producer của stream nào cũng có thể trở nên chậm hơn một consumer cụ thể.
      Chỉ cần một hiccup tức thời, nếu không có đủ buffering thì pipe có thể rối tung hoàn toàn, và kích thước buffer cần thiết lại khác nhau tùy hệ thống.
  • Lý do JMP không đơn giản là RET là vì tùy chọn CONFIG_RETHUNK. Những gì thấy trong phần disassembly của objdump là kết quả của việc RET bị thay bằng JMP __x86_return_thunk.
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
    Các lệnh NOP ở đầu và cuối hàm không phải dành cho ftrace, mà đến từ các macro ASM_CLAC/ASM_STAC. Các macro này chừa chỗ để lúc runtime điền lệnh CLAC/STAC nếu phát hiện X86_FEATURE_SMAP. Cả hai lệnh đều dài 3 byte, nên số NOP cũng tương ứng.
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...

    • Có lẽ chỉ đếm trên đầu ngón tay số kernel developer vừa biết những chuyện này mà lại chọn một nickname dùng một lần tinh quái như vậy.
  • Gọi pipe của Linux là “chậm” cũng giống như gọi Toyota Corolla là “chậm”. Nếu không phải use case cực đoan thì nó đủ nhanh.
    Bạn đang đua xe à? Và còn là thể loại mà tốc độ quan trọng hơn kỹ thuật? Vậy thì mua xe nhanh hơn. Còn không thì cứ tiếp tục đi Corolla.

    • Đây không phải là đoạn code của một dự án nào đó chỉ chạy vài tỷ lần trong suốt vòng đời, mà là code được dùng thường xuyên trên hàng triệu đến hàng tỷ máy tính.
      Vì vậy, chỉ một cải thiện hiệu suất rất nhỏ cũng đáng bỏ nhiều thời gian tối ưu xét về mặt kinh tế.
    • Thực tế trong trường hợp của tác giả, cái pipe bị gọi là chậm đang truyền dữ liệu ở 17GB/s, tức hơn 130Gbps.
      Tôi đã dùng pipe cho nhiều mục đích hơn 10 năm nhưng chưa từng bị giới hạn bởi tốc độ pipe; thường thì bottleneck có lẽ là các công cụ như tar, gzip, find, grep, nc. Tất nhiên, so với việc chúng làm thì các công cụ này cũng khá nhanh.
    • Có một dự án dùng SDK độc quyền để giải mã video raw. Dữ liệu đã giải mã được xuất ra RGBA thuần, rồi FFMpeg đọc qua pipe để mã hóa lại bằng codec chuẩn.
      FFMpeg không thể đưa SDK Non-Free vào source, còn lưu RGBA thuần ra file thì phi thực tế đến mức vô lý. Vì vậy pipe là cách duy nhất, và đây là lý do chính đáng để cần pipe thông lượng cao.
    • Làm cho thứ được dùng khắp nơi nhanh hơn vài phần trăm là một khoản đầu tư rất đáng giá. Dù từng tác vụ riêng lẻ không nhanh hơn nhiều, cộng trên phạm vi toàn cầu thì tiết kiệm được lượng điện năng và thời gian khổng lồ.
    • Đôi khi một chiếc Corolla nhanh hơn thực sự lại là câu trả lời tốt nhất.
      https://www.toyota.com/grcorolla/
      Những chiếc xe này có kỹ thuật và hiệu năng rất ấn tượng, và cũng giống như một cách hack để lách các quy định khiến GR Yaris — mẫu xe vốn được nhắm tới — khó được đưa vào thị trường Mỹ. Tôi nghĩ nó có đủ bối cảnh về kỹ thuật/hiệu năng/hacking/thị trường để người HN đón nhận nhẹ nhàng. Hơn nữa, chủ tịch công ty vẫn còn tự lái và trực tiếp điều khiển nó.
  • Dù không liên quan trực tiếp đến ý chính của bài, trên các CPU hiện đại, rep movsb nhanh ngang với phiên bản vector hóa nhanh nhất. Lý do là ta biết CPU có tăng tốc cho lệnh này
    Tên hàm kernel copy_user_enhanced_fast_string cũng ngụ ý điều đó. Các tính năng CPU liên quan là ERMS (Enhanced Repeat Move String, làm rep movsb nhanh hơn khi độ dài vượt một mức nhất định) và FSRM (Fast Short Repeat Move String, làm các phép sao chép ngắn cũng nhanh hơn)

    • Nhưng không chỉ có thế. rep movsb nhanh đến một ngưỡng nào đó, nhưng sau ngưỡng ấy thì store thông thường hoặc non-temporal store nhanh hơn
      Tất cả các ngưỡng đều được mô tả tại https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch...
      Và các giá trị này cũng không cố định; Noah Goldstein vẫn cập nhật chúng hằng năm
    • Cũng đáng chú ý là kể từ kernel 6.1 được dùng trong bài, Linux đã nhiều lần thay đổi cách dùng ERMS và FSRM trong sao chép trên x86. Để tham khảo, trên máy của tôi có cả FSRM lẫn ERMS — điều đáng ngạc nhiên là cái trước không bao hàm cái sau — với Linux 6.8, chỉ dùng pipe thông thường và buffer 32KiB đã đạt 17GB/s
    • Với memcpy ngắn, tôi vẫn đang chờ đến lúc rep movsbrep stosb đủ nhanh để có thể xóa luôn phiên bản vòng lặp C đơn giản
    • Nếu vậy thì tôi cũng tò mò khi nào trình biên dịch C sẽ inline cả memcpy() độ dài biến đổi, giống như cách chúng inline memcpy độ dài cố định
  • Có một điểm về AVX512 mà tôi không thấy trong bài: ngoài overhead của xsave/xrstor, AVX512 tiêu thụ nhiều điện năng và gây ra CPU frequency scaling. Xem [1], [2] để biết chi tiết và thấy việc này có thể tinh vi đến mức nào
    [1] https://www.intel.com/content/dam/www/central-libraries/us/e...
    [2] https://www.intel.com/content/www/us/en/developer/articles/t...

    • Điều đó chỉ đúng với một số mẫu CPU Intel nhất định
  • Gần như mọi hình thức giao tiếp liên tiến trình đều “chậm”. Đó là lựa chọn trả chi phí hiệu năng để đổi lấy an toàn

    • Không cần phải trả chi phí lớn đến vậy. Pipe gần như không cung cấp gì nhiều, nên chi phí cũng nên rất nhỏ
      Đặc biệt, không có nhiều lý do để giao tiếp liên tiến trình nhanh nhất lại chậm hơn một lời gọi hàm dài
    • Pipe tồn tại không phải vì an toàn, mà như một tối ưu hóa để truyền dữ liệu giữa các chương trình hiện có
  • Lại đang hứng cú ôm nghẹt thở của Hacker News. Nhờ cache trang của WordPress nên đỡ hơn lần trước, nhưng trang vẫn có thể mất vài giây để tải, mong mọi người thông cảm

  • Tôi vốn không hiểu rõ vì sao splice phải chậm đến vậy. Bài có nêu nguyên nhân nó chậm hơn vmsplice là do cấp phát buffer và dùng lệnh scalar, nhưng tôi không hiểu vì sao những thứ đó lại cần thiết
    Tại sao không thể triển khai lại splice đơn giản giống như vmsplice? Chắc hẳn có lý do hay nào đó mà tôi đã bỏ lỡ

  • Sẽ rất thú vị nếu xem cả phiên bản dùng io_uring. Có thể chia sẻ trước buffer với kernel để tránh một phần việc sao chép, và cũng có thể tránh overhead của system call. Tuy nhiên trong trường hợp này, phần sau có vẻ gần như không đáng kể

  • Một tuyên bố khá táo bạo đối với một blog mất khoảng 20 giây để tải

    • Bài này đã lên đến đầu Hacker News rồi, nên có lẽ nên rộng lượng một chút
      Bản thân bài viết có vẻ rất hay, và có nhiều điều để học về những gì diễn ra bên trong