2 điểm bởi GN⁺ 2025-02-14 | 1 bình luận | Chia sẻ qua WhatsApp
  • Khi gọi hàm native từ Ruby, FFI rất tiện lợi, nhưng trong benchmark strlen, overhead khi gọi lớn hơn so với C extension, trở thành nút thắt hiệu năng
  • Trong benchmark cơ sở, gọi trực tiếp String#bytesize đạt 39.879M i/s, C extension đạt 30.661M i/s, gọi gián tiếp qua Ruby đạt 28.697M i/s, còn FFI chậm nhất với 15.682M i/s
  • Ý tưởng cốt lõi là dùng tên hàm, kiểu tham số và kiểu trả về đã biết tại thời điểm attach_function để tạo mã máy dùng cho việc gọi hàm bên ngoài ngay trong runtime
  • Bản chứng minh khái niệm FJIT đã nâng tốc độ gọi strlen lên 32.508M i/s trong môi trường Ruby 3.5.0dev ARM64, nhanh hơn FFI hơn 2 lần và thậm chí hơi nhanh hơn C extension
  • Hiện vẫn có các hạn chế như chỉ hỗ trợ ARM64, một tham số·một giá trị trả về, kiểu dữ liệu còn giới hạn, phụ thuộc --rjit --rjit-disable và một commit Ruby head cụ thể, nên cần mở rộng triển khai trước khi dùng thực tế

Điểm khiến FFI chậm trong Ruby

  • Hướng đi cơ bản là viết càng nhiều mã Ruby càng tốt, và chỉ gọi mã native khi thật sự cần
  • YJIT có thể tối ưu mã Ruby nhưng không tối ưu được mã C
  • Nếu cần thư viện native, cách lý tưởng là đặt một lớp wrapper C extension mỏng quanh hàm thực tế, còn phần lớn công việc được xử lý trong Ruby
  • Những API đơn giản như vậy rất hợp với FFI, nhưng FFI hiện tại không đạt được hiệu năng như C extension

Benchmark cơ sở với strlen

  • Có bốn đối tượng được so sánh
    • Gọi strlen của C bằng FFI
    • Gọi gián tiếp x.bytesize trong phương thức Ruby B.strlen
    • Gọi C extension được tạo bằng Ruby Gem strlen
    • Gọi trực tiếp str.bytesize
  • Kết quả trong môi trường Ruby 3.5.0dev, ARM64:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-cext: 30.661M i/s, 32.61 ns/i, chậm hơn gọi trực tiếp 1,30 lần
    • strlen-ruby: 28.697M i/s, 34.85 ns/i, chậm hơn gọi trực tiếp 1,39 lần
    • strlen-ffi: 15.682M i/s, 63.77 ns/i, chậm hơn gọi trực tiếp 2,54 lần
  • Gọi trực tiếp String#bytesize là nhanh nhất, còn gọi gián tiếp bổ sung sẽ làm tăng overhead
  • Chênh lệch giữa ruby-directstrlen-ruby cho thấy chi phí push/pop stack frame; việc loại bỏ kiểu overhead này là lĩnh vực mà các trình biên dịch JIT như YJIT xử lý tốt
  • Khoảng cách giữa strlen-cextstrlen-ffi cho thấy chi phí bổ sung khi gọi hàm native qua FFI là đáng kể

Cách thay thế lệnh gọi FFI bằng JIT

  • Tại thời điểm gọi attach_function :strlen, [:string], :int, các thông tin cần thiết đã có sẵn
    • Tên hàm cần gọi: strlen
    • Kiểu tham số: string
    • Kiểu trả về: int
  • Với các thông tin này, có thể tạo mã máy để mở Ruby value thành kiểu native, gọi hàm bên ngoài, rồi bọc giá trị trả về lại thành đối tượng Ruby
  • Có ba thành phần cần thiết
  • Chỉ tạo mã máy là chưa đủ; Ruby cũng phải có khả năng nhảy tới mã máy đó thì mới có thể bỏ qua overhead FFI

Con đường tận dụng RJIT

  • RJIT là trình biên dịch JIT cho Ruby được viết bằng Ruby và được phân phối cùng Ruby
  • Cấu trúc nội bộ của nó tương tự YJIT, nhưng vì không nhắm tới sử dụng production nên không được biết đến rộng rãi như YJIT
  • Kokubun đã gửi một yêu cầu tính năng để tách RJIT thành Gem
  • Đề xuất này cung cấp hai nền tảng để dễ dàng tạo trình biên dịch Ruby JIT của bên thứ ba
    • Tách RJIT thành Gem
    • Tạo các kiểu nội bộ của Ruby dưới dạng cấu trúc dữ liệu Ruby, để JIT bên thứ ba có thể lấy thông tin cần thiết cho việc bọc và mở các kiểu dữ liệu Ruby
  • Một thay đổi khác là luôn thực thi nếu có con trỏ hàm entry của JIT
    • Khi JIT bên thứ ba đăng ký mã máy, Ruby có thể tự động nhảy tới mã đó
  • Với hai yếu tố này, có thể tạo một trình biên dịch JIT nhỏ, một mục đích, đóng vai trò như giao diện FFI

Bản chứng minh khái niệm FJIT

  • Bản chứng minh khái niệm FJIT là viết tắt của “FFI JIT”, tạo mã máy để gọi hàm bên ngoài tại runtime
  • Ví dụ gắn strlen bằng giao diện tương tự FFI
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • Khi attach_function được gọi, FJIT tạo mã máy để mở chuỗi Ruby, gọi strlen của C, rồi trả độ dài chuỗi về dưới dạng đối tượng Ruby

Kết quả benchmark FJIT

  • Kết quả trong môi trường Ruby 3.5.0dev, +RJIT +PRISM, ARM64:
    • ruby-direct: 41.907M i/s, 23.86 ns/i
    • strlen-fjit: 32.508M i/s, 30.76 ns/i, chậm hơn gọi trực tiếp 1,29 lần
    • strlen-cext: 29.778M i/s, 33.58 ns/i, chậm hơn gọi trực tiếp 1,41 lần
    • strlen-ruby: 28.851M i/s, 34.66 ns/i, chậm hơn gọi trực tiếp 1,45 lần
    • strlen-ffi: 15.629M i/s, 63.98 ns/i, chậm hơn gọi trực tiếp 2,68 lần
  • Gọi trực tiếp String#bytesize vẫn là nhanh nhất
  • Mã máy do FJIT tạo ra nhanh thứ hai và cho kết quả hơi tốt hơn C extension strlen
  • FJIT nhanh hơn hơn 2 lần so với lệnh gọi FFI, đồng thời cũng nhanh hơn lệnh gọi gián tiếp qua Ruby
  • Kết quả này cho thấy có thể duy trì cách tiếp cận “viết Ruby nhiều nhất có thể” mà vẫn đạt tốc độ ngang bằng hoặc tốt hơn C extension

Những hạn chế còn lại trước khi áp dụng thực tế

  • Trình biên dịch JIT chứng minh khái niệm hiện chỉ giới hạn ở nền tảng ARM64
    • Để mở rộng thành triển khai thực tế, cần thêm backend x86_64
  • Chưa xử lý mọi kiểu tham số và kiểu trả về
    • Việc hỗ trợ mọi kiểu tham số là khả thi và được cho là khối lượng công việc không quá lớn
  • Hiện chỉ xử lý các hàm nhận một tham số và trả về một giá trị
  • Hiện phải chạy Ruby với các flag --rjit --rjit-disable
    • Dự kiến điều kiện này sẽ không còn cần thiết nếu tính năng của Kokubun được đưa vào
  • Bản chứng minh khái niệm chỉ chạy trên Ruby head tại thời điểm đó
  • Theo bản cập nhật, RJIT đã bị loại bỏ khỏi Ruby head, nên để chạy script, cần checkout Ruby về commit f32d5071b7b01f258eb45cf533496d82d5c0f6a1

1 bình luận

 
GN⁺ 2025-02-14
Ý kiến trên Hacker News
  • Bộ giải ràng buộc Java Timefold đã xử lý khá nhiều FFI để cho phép gọi các hàm được định nghĩa trong CPython, và phần lớn vấn đề hiệu năng phát sinh khi đi qua lại bằng proxy giữa ngôn ngữ host và ngôn ngữ bên ngoài
    Nếu gọi FFI trực tiếp bằng JNI hoặc giao diện ngoại mới thì nhanh gần như gọi trực tiếp phương thức Java, nhưng bộ gom rác của CPython và Java không thật sự khớp nhau nên việc đồng bộ cần đến “ma thuật đen”
    Ngược lại, các proxy như JPype hay GraalPy cần chuyển đổi tham số và giá trị trả về, và còn có thể gây thêm các lệnh gọi FFI theo chiều ngược lại. Nếu truyền một đối tượng CPython sang Java, Java sẽ tạo proxy cho đối tượng đó; rồi nếu truyền proxy đó lại sang CPython, nó không gỡ proxy ra mà lại tạo proxy của proxy
    Kết quả là proxy JPype chậm hơn 1402% so với gọi CPython trực tiếp qua FFI, còn proxy GraalPy chậm hơn 453%
    Cuối cùng, họ dịch bytecode CPython sang bytecode Java và tạo các cấu trúc dữ liệu Java tương ứng với các lớp CPython được dùng, nhờ đó nhanh hơn 100 lần so với dùng proxy. Nói thêm, bytecode CPython rất bất ổn định, tài liệu nghèo nàn, và đặc tính VM cũng khó nhằn nên khó ánh xạ trực tiếp sang bytecode khác; tốt nhất là đừng cố dịch hay đọc nó
    Chi tiết đã được tổng hợp trong bài viết: https://timefold.ai/blog/java-vs-python-speed

    • Nói khi chưa có kinh nghiệm thực tế thì FFI từ Python và Java sang C có vẻ tốt hơn nhiều; tôi nghĩ nối hai bên bằng một C bridge nhỏ có thể trở thành lời giải tổng quát
    • cgo is not Go cũng đáng tham khảo
      Mã Go và mã C phải thống nhất cách chia sẻ các tài nguyên như không gian địa chỉ, signal handler, slot TLS của thread; nhưng trên thực tế điều đó gần giống với việc Go phải né các giả định của mã C. Mã C có thể giả định nó chỉ chạy trên một thread, hoặc hoàn toàn không chuẩn bị cho môi trường đa luồng
      Vì C không biết quy ước gọi hàm của Go hay stack có thể tăng trưởng, nên để gọi mã C cần ghi lại chi tiết stack của goroutine, chuyển sang stack C, rồi chạy mã C vốn không biết mình đã được gọi như thế nào hay toàn bộ Go runtime
      Dù bạn binding hoặc bọc mã C bằng Python, Java dùng JNI, ngôn ngữ dùng libffi, hay Go dùng cgo, cuối cùng bạn vẫn đang sống trong thế giới của C
      https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
    • Tôi tò mò trong những trường hợp như vậy thì các cách giao tiếp liên tiến trình như queue, file, HTTP sẽ khớp vào đâu
  • Nhờ Rails At Scale và các bài viết của byroot, đây thực sự là thời điểm rất tốt để đọc những bài đi sâu vào cấu trúc nội bộ và hiệu năng của Ruby. Nhìn cả các cải tiến gần đây của Ruby và Rails thì đây cũng là thời điểm khá tốt để làm lập trình viên Ruby

    • Tôi không chắc có thật vậy không. Ruby có vẻ đang đi xuống https://www.tiobe.com/tiobe-index/ruby/
      Nó vẫn phổ biến với một số loại ứng dụng nhất định, nhưng thời hoàng kim có lẽ đã qua khá lâu; các cải tiến gần đây thì tốt, nhưng tôi không rõ vào năm 2025 JIT còn thú vị đến vậy về mặt kỹ thuật hay không
  • Cách tiếp cận “thay vì gọi thư viện bên thứ ba, liệu có thể JIT luôn đoạn mã cần thiết để gọi hàm ngoại không?” trông khá giống nền tảng của LuaJIT FFI: https://luajit.org/ext_ffi.html
    Vì vậy có lẽ FFI của LuaJIT rất nhanh

  • Câu “hãy viết bằng Ruby nhiều nhất có thể. Đặc biệt, YJIT có thể tối ưu mã Ruby nhưng không tối ưu được mã C” nghe chưa thật dễ hiểu
    Ruby chẳng phải là một ngôn ngữ khá chậm sao? Nếu đã đi xuống native, có lẽ người ta sẽ muốn càng nhiều phần càng tốt là mã native

    • Vào thời Java bắt đầu có một JIT đúng nghĩa, đã có một câu chuyện nhỏ như thế này
      Trong một bản phát hành lớn, đoạn mã Java xử lý hành vi của một phần tử UI nào đó được phát hiện là nút thắt cổ chai, nên ở bản phát hành lớn tiếp theo nó được viết lại bằng C
      Sau đó, khi JIT trở nên thực sự hữu ích, overhead FFI còn lớn hơn chênh lệch giữa mã C được tinh chỉnh thủ công và mã do JIT sinh ra, nên ở bản phát hành lớn tiếp theo nó lại được đưa về triển khai Java thuần
      Theo chuẩn của các ngôn ngữ thế hệ đó, FFI của Java khá nhanh, nhưng vài bản phát hành sau nó được thay bằng cách tốt hơn; đến lúc đó tôi không còn làm nhiều về mã UI Java nữa nên không theo dõi tiếp. Cùng thời điểm đó, họ cũng đang dọn dẹp interface giữa mã theo từng nền tảng và mã UI Java thông thường, nên cuối cùng mọi chuyện ra sao thì tôi không chắc
      Trong những việc kiểu này cần luôn để mắt tới hiệu ứng bập bênh như vậy. Phải cân nhắc xem có nên chờ thêm vài milestone để giảm bớt việc tinh chỉnh thủ công, hay vì lý do chính trị/kỹ thuật mà cần làm ngay bây giờ
    • Đây là lúc JIT xuất hiện. Lý tưởng thì JIT có thể tối ưu lại mã sang trạng thái tốt hơn
      Với tác vụ chạy một lần rồi kết thúc thì có thể kém hiệu quả, nhưng với workload desktop/server sống lâu, xét trên toàn bộ ứng dụng sẽ có phần bù lại
      Ví dụ, JIT của Dalvik khá yếu nên gọi các hàm toán học bằng C nhanh hơn, nhưng sau ART thì không còn cần làm vậy nữa, và JIT có thể vượt qua chi phí gọi C
      https://developer.android.com/reference/android/util/FloatMa...
    • Trong ngôn ngữ được quản lý có JIT hoặc trình biên dịch AOT, thường lý tưởng là viết nhiều mã bằng chính ngôn ngữ đó. Như vậy mới có thể inline và áp dụng các tối ưu khác vốn không thể làm khi vượt qua ranh giới gọi C
      Xu hướng này đôi khi được gọi là “self-hosting”, và trình duyệt cũng tận dụng nhiều theo kiểu chuyển các phần vốn có thể đã được viết bằng C/C++ sang JavaScript có đặc quyền. Một phần khá lớn của thư viện chuẩn, bất ngờ thay, không phải là mã native
    • FFI tạo ra một ranh giới mờ đục, không thể tối ưu giữa các đoạn mã. Mã thường xuyên đi qua đi lại kiểu này sẽ tốn chi phí lớn
      Ngay cả trong các ngôn ngữ nhanh hơn nhiều và có chi phí tương tác gần như bằng 0 như C#, vẫn có chi phí lời gọi; đôi khi còn phải trả chi phí đổi cờ trạng thái của VM hoặc chuyển đổi liên quan tới garbage collection
      Nếu Ruby YJIT đã bắt đầu trở thành một yếu tố đo lường được, thì quy tắc trên cũng sẽ ngày càng quan trọng hơn
    • Có một bài viết rất hay của JPCamara giải thích vì sao phương thức each của Ruby được viết lại bằng Ruby: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      Cũng có một bài bonus của tender love: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
      Tóm lại là JIT thắng
  • FFI là Foreign Function Interface, tức cách gọi C từ Ruby

    • Cách tiếp cận hoàn toàn an toàn và hợp lý là viết mã C nhận dữ liệu từ dòng lệnh khi chạy và phun kết quả ra dòng lệnh hoặc một trang bộ nhớ
      Sau đó, từ Ruby chạy chương trình C đó trong terminal cùng các flag hoặc dữ liệu, là Ruby có thể thực thi mã C
  • Không hiểu vì sao phải biên dịch JIT cái này. Nếu có thể viết bằng C thì chẳng phải có thể cứ biên dịch ngay lúc load sao?

    • Tôi không rõ “có thể viết bằng C” nghĩa là gì. Thư viện FFI cho phép Ruby bind động và chạy các phương thức thư viện mà không cần viết native extension
      Điều này tăng năng suất đáng kể và cho phép chia sẻ cùng một mã giữa CRuby, JRuby, TruffleRuby
      Nếu có thể biết tĩnh tất cả binding lúc khởi động thì có thể viết stub rồi đưa vào method table, nhưng dù vậy đó vẫn là việc xảy ra ở runtime nên vẫn thuộc JIT. Và vì không thể thích nghi với các kiểu chảy qua hệ thống, nó buộc phải bảo thủ về giá trị chấp nhận hoặc tối ưu hóa; điều đó gần với những gì libffi đang làm hiện nay
      Cách tiếp cận AOT là viết native extension
  • Nói hơi ngoài lề, thường thì tôi tránh các gem dùng FFI hơn là tránh chính FFI. Việc biên dịch thường quá phiền phức, nên bỏ qua bước trung gian Rubygems/bundler và tự build trực tiếp lại dễ hơn

  • Liên quan đôi chút, thư viện này dùng JVMCI để tạo tại chỗ mã arm64/amd64 gọi thư viện native mà không cần JNI: https://github.com/apangin/nalim

  • Đây chẳng phải chính xác là việc libffi làm sao?

    • libffi chậm, và theo tôi biết thì nó không JIT
      Trong libffi, người ta tạo một đối tượng descriptor cho hàm, và cấu trúc dữ liệu runtime này biểu diễn kiểu của tham số và giá trị trả về
      Khi gọi FFI, phải truyền descriptor cùng với một mảng con trỏ tới các giá trị muốn truyền vào. Bên trong có lẽ nó duyệt đồng thời mảng giá trị và descriptor, đặt các giá trị lên stack theo đúng kiểu, rồi sau khi hàm kết thúc thì lấy giá trị ra tùy theo kiểu trả về. Trong quá trình này rất có khả năng có các nhánh rẽ theo từng kiểu ở nhiều chỗ
      Ngay cả nếu cơ chế gọi của libffi được JIT, việc chuẩn bị mảng tham số vẫn chậm. Nó kém trực tiếp hơn FFI JIT, vốn truy cập thẳng vào tham số mà không đi qua mảng trung gian
      Mã FFI JIT nhận trực tiếp các giá trị tham số, chuyển đổi từ kiểu Ruby sang kiểu C, rồi dùng mã inline cho từng giá trị để đặt vào đúng vị trí trên stack hoặc thanh ghi, gọi hàm, và chuyển giá trị trả về sang kiểu Ruby. Về cơ bản nó giống như tự viết mã extension bằng tay
      Nếu có suy luận kiểu, có thể bỏ qua kiểm tra kiểu trong mã chuyển đổi. Ví dụ, nếu đảm bảo arg1 là chuỗi Ruby, có thể dùng phiên bản unsafe nhanh hơn của hàm chuyển đổi
      Trong trường hợp xấu nhất, mã JIT chỉ cần phản ánh đến mức kiểu Ruby, và không cần mảng hay danh sách liên quan đến tham số. Vì kiểu C sẽ được chuyển đổi sang đã được hard-code trong mã, nên không cần duyệt cấu trúc dữ liệu mô tả phía C ở runtime
    • Vì libffi không biết Ruby là gì nên nó không thể biết cách unbox kiểu Ruby
      Ưu điểm của bài viết này là, dựa trên thông tin người dùng truyền khi gọi attach_function, mã unbox kiểu thực chất được cache trong mã máy được sinh ra
    • libffi không JIT lời gọi FFI, và vẫn phải tự đặt trực tiếp các giá trị tham số. Ví dụ, nếu tham số là chuỗi, cần viết riêng mã chuyển đối tượng chuỗi Ruby thành con trỏ chuỗi C. Và libffi khá chậm
      tramp.c được liên kết trong bình luận anh em là dành cho “FFI ngược”, tức phơi bày thao tác động của người dùng dưới dạng con trỏ hàm; JIT ở đó chỉ ở mức tổng cộng 3 lệnh để gọi mã đã biên dịch sẵn
    • Tôi cứ tưởng mình biết libffi làm gì, và nghĩ nó đang nghịch kiểu GOT gì đó. Nhưng có vẻ bạn nói đúng
      https://github.com/libffi/libffi/blob/master/src/tramp.c