2 điểm bởi GN⁺ 2024-12-06 | 1 bình luận | Chia sẻ qua WhatsApp
  • Trong một benchmark ngôn ngữ nhỏ, CRuby đứng thứ ba từ dưới lên, nhưng điều này cho thấy nút thắt không nằm ở toàn bộ ngôn ngữ mà ở phần triển khai vòng lặp mà YJIT không thể nhìn vào
  • Ruby 3.3.6 trên M3 MacBook Pro mất 12,17 giây cho Fibonacci và 28,80 giây cho Loops, trong khi node.js đều ở mức khoảng 1 giây; trên M2 MacBook Air, Ruby còn chậm hơn với lần lượt 16,33 giây và 33,43 giây
  • Chỉ cần bật ruby --yjit, Fibonacci giảm mạnh từ 16,88 giây xuống 2,06 giây, nhưng Loops chỉ giảm từ 33,43 giây xuống 25,57 giây, khiến Range#each vẫn là nút thắt
  • Các phương thức đã chuyển phần triển khai C sang phía Ruby, như Integer#times trong Ruby 3.3 và Array#each trong Ruby 3.4, trở thành đối tượng tối ưu hóa của YJIT, giúp benchmark vòng lặp cải thiện xuống mức 13–14 giây
  • CRuby có thể dùng hook with_yjit để sử dụng triển khai Ruby khi YJIT được bật, và giữ triển khai C khi tắt, tạo ra xu hướng chuyển thư viện lõi sang mã Ruby thân thiện với YJIT

Vị trí của Ruby thể hiện qua benchmark

  • language comparison repo được chia sẻ gần đây là một kho lưu trữ nơi cộng đồng cùng xây dựng các benchmark nhỏ cho nhiều ngôn ngữ
  • Kết quả CRuby trong kho này đứng thứ ba từ dưới lên, chỉ nhanh hơn R và Python
  • Benchmark xem xét hai trục
    • Loops: nhấn mạnh hiệu năng của vòng lặp, câu điều kiện và các phép toán cơ bản
    • Fibonacci: bộc lộ overhead khi gọi hàm và chi phí đệ quy
  • Ví dụ Loops thực hiện tổng cộng 10.000 × 100.000 lần qua các vòng lặp lồng nhau, tức 1 tỷ lần lặp
  • Ví dụ Fibonacci không dùng triển khai đã tối ưu, mà cố ý dùng naive Fibonacci đơn giản

Các số đo ban đầu và hiệu quả khi áp dụng YJIT

  • Trên M3 MacBook Pro, Ruby 3.3.6 ở mức sau
    • Fibonacci: 12,17 giây
    • Loops: 28,80 giây
    • node.js: cả hai ví dụ đều chỉ hơn 1 giây một chút
  • Trên M2 MacBook Air, cùng benchmark cho kết quả chậm hơn
    • Ruby Fibonacci: 16,33 giây
    • Ruby Loops: 33,43 giây
    • node.js Fibonacci: 1,36 giây
    • node.js Loops: 2,07 giây
  • Lệnh chạy trong kho gốc có dạng ruby ./code.rb 40, không bật YJIT
  • Khi chạy bằng ruby --yjit ./code.rb 40, kết quả thay đổi
    • Fibonacci: 2,06 giây
    • Loops: 25,57 giây
  • YJIT tạo ra hiệu quả lớn với Fibonacci, nhưng mức cải thiện ở Loops còn hạn chế

Vì sao Range#each bất lợi cho YJIT

  • Vòng lặp cốt lõi trong mã Loops có dạng Range#each như (0...10_000).each(0...100_000).each
  • Tính đến Ruby 3.4, Range#each vẫn được viết bằng C
  • range.c của CRuby nối lớp Range và phương thức each với hàm C range_each
  • range_each rẽ nhánh theo nhiều đường khác nhau để xử lý nhiều dạng range
    • (0...).each
    • (0...100).each
    • ("a"..."z").each
  • Bản thân hàm C có thể nhanh, nhưng YJIT không thể nhìn vào bên trong nó
    • Việc tối ưu hóa dừng lại ở điểm gọi hàm, rồi tiếp tục sau khi hàm C trả về
    • YJIT có thể tạo tối ưu hóa chuyên biệt cho các đường thực thi nóng, nhưng triển khai C hạn chế lợi thế này

Thay đổi khi chuyển sang Integer#times

  • Trong Ruby 3.3, Integer#times đã chuyển từ hàm C sang phương thức Ruby
  • Cấu trúc cốt lõi là một vòng lặp Ruby đơn giản gồm while i < self, yield i, i = i.succ
  • Nhờ được viết bằng Ruby, YJIT có thể phân tích và tối ưu hóa bên trong
  • Khi đổi vòng lặp Range sang 10_000.times100_000.times, thời gian Loops giảm mạnh
    • Range#each: 25,57 giây
    • Integer#times: 13,66 giây
  • Ở môi trường đo khác, Integer#times giảm xuống 9 giây, và trên Ruby 3.4 còn có kết quả 8 giây

Integer#succ và tối ưu hóa ở cấp VM

  • Triển khai Integer#times dùng i.succ cho phép tăng, thay vì i += 1
  • Integer#succ là phương thức trả về giá trị kế tiếp của một số nguyên
  • Trong bytecode của Ruby VM, i.succ được biểu diễn bằng một bước opt_succ
  • Ngược lại, i += 1 được tách thành hai bước
    • putobject_INT2FIX_1_: đưa số nguyên 1 lên stack của VM
    • opt_plus: thực hiện phép toán +
  • Trong chương trình Ruby thông thường, gần như không cần quan tâm điều này, nhưng ở cấp JIT và VM, khi lặp hàng triệu đến hàng tỷ lần, chênh lệch một bước cũng ảnh hưởng đến hiệu năng

Array#each cũng chuyển sang triển khai Ruby trong Ruby 3.4

  • Trong Ruby 3.4, Array#each cũng được chuyển từ C sang triển khai phía Ruby
  • Thử nghiệm đầu tiên là mã Ruby đơn giản, nhưng có race condition liên quan đến nội bộ CRuby
  • Triển khai cuối cùng dùng Primitive trong mã Ruby
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.cexpr!
    • ary_fetch_next
  • Đây không hoàn toàn là Ruby thuần, mà là dạng pha trộn giữa đánh giá mã C và cấu trúc Ruby, nhưng YJIT vẫn có thể tối ưu hóa đáng kể
  • Nếu tạo sẵn mảng rồi lặp bằng Array#each, hiệu năng gần tương đương Integer#times
    • Range#each: 25,57 giây
    • Integer#times: 13,66 giây
    • Array#each: 13,96 giây

Đo bằng Ruby Microbench

  • Kho riêng ruby_microbench so sánh ví dụ gốc với nhiều dạng vòng lặp Ruby
  • Kết quả trên Ruby 3.4 khi bật YJIT như sau
    • Fibonacci: 2,19 giây
    • array#each: 14,02 giây
    • range#each: 26,61 giây
    • times: 13,12 giây
    • for: 14,91 giây
    • while: 37,10 giây
    • loop do: 13,95 giây
  • Khi tắt YJIT trên Ruby 3.4, hầu hết đều chậm hơn
    • Fibonacci: 16,49 giây
    • array#each: 34,29 giây
    • range#each: 33,88 giây
    • times: 33,18 giây
    • for: 36,32 giây
    • while: 37,14 giây
    • loop do: 50,65 giây
  • Ví dụ while chậm hơn dự kiến, có thể là do vấn đề trong cách triển khai
  • for inarray#each gần như giống nhau ở cấp bytecode của Ruby VM, nên hiệu năng cũng tương tự
    • for in nhìn chung gần với cú pháp đường được VM chuyển thành lời gọi #each

So sánh với các triển khai Ruby khác

  • Cùng benchmark cũng được chạy trên nhiều triển khai Ruby
  • Một số kết quả như sau
    • TruffleRuby 24.1
      • Fibonacci: 0,92 giây
      • array#each: 0,97 giây
      • range#each: 0,92 giây
      • times: 2,39 giây
      • for: 2,06 giây
      • while: 3,90 giây
      • loop do: 0,77 giây
    • MRuby 3.3
      • Fibonacci: 28,83 giây
      • array#each: 144,65 giây
      • range#each: 126,40 giây
      • times: 128,22 giây
    • Artichoke
      • Fibonacci: 19,71 giây
      • array#each: 236,10 giây
      • range#each: 214,55 giây
      • times: 214,51 giây

Thử nghiệm monkey patch Range#each bằng Ruby

  • Khi monkey patch Range#each bằng một triển khai Ruby đơn giản, hiệu năng cải thiện đáng kể
  • Triển khai này là dạng đơn giản sử dụng begin, end, loop, yield, i.succ
  • Số đo như sau
    • Triển khai C Range#each: 25,57 giây
    • Triển khai Ruby Range#each: 16,64 giây
  • Triển khai này là phiên bản đơn giản hóa quá mức, không xử lý mọi trường hợp Range
  • Dù vậy, việc chuyển từ C sang Ruby cho phép YJIT tối ưu hóa, tạo ra cải thiện hiệu năng theo cách khó hoặc không thể tái hiện bằng mã C thông thường

Thư viện chuẩn YJIT và with_yjit

  • Ruby Outperforms C của Aaron Patterson là một trường hợp viết lại extension C dùng để phân tích cú pháp GraphQL bằng Ruby, và nhờ tối ưu hóa của YJIT, mã Ruby đã nhanh hơn C
  • Nhóm YJIT lõi của CRuby đang áp dụng cách loại bỏ mã C khỏi một số chức năng cốt lõi, hoặc chỉ dùng triển khai Ruby khi YJIT được bật
  • Khối with_yjit chỉ áp dụng triển khai Ruby tương ứng khi YJIT đang bật
    • Nếu YJIT tắt, triển khai C tiếp tục chạy
    • Nếu YJIT bật, phiên bản Ruby có thể được YJIT tối ưu hóa sẽ được sử dụng
  • Từ Ruby 3.3, YJIT có thể khởi tạo trễ, và mã with_yjit áp dụng phiên bản phương thức phù hợp ngay khi YJIT được kích hoạt
  • with_yjit là một hook YJIT, và sau khi được gọi, nó bị xóa khỏi runtime bằng undef :with_yjit

Cách xem mã máy do YJIT tạo ra

  • Khi build CRuby với tùy chọn --enable-yjit=dev, có thể xem disassembly mã máy mà YJIT tạo ra
  • Ví dụ build như sau
    • ./configure --enable-yjit=dev
    • make install
  • Khi chạy, dùng flag --yjit-dump-disasm
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • i.succ trong Integer#times xuất hiện dưới dạng opt_succ trong bytecode của VM
  • Triển khai YJIT bằng Rust thực hiện các bước sau với opt_succ
    • Guard rằng receiver là Fixnum
    • Nếu không phải Fixnum, thoát sang đường thực thi khác
    • Nếu là Fixnum, do biểu diễn tag nội bộ, để cộng thêm 1 thì thực tế cộng thêm 2
    • Nếu xảy ra overflow, thoát sang đường khác
  • Ví dụ này cho thấy tối ưu hóa JIT hoạt động qua nhiều tầng, từ mã Ruby, mã C, bytecode VM, triển khai Rust cho đến mã máy

Hướng tối ưu hóa của CRuby

  • Công việc triển khai Ruby phần lớn diễn ra trong các ngôn ngữ cấp thấp hơn Ruby; trong CRuby chủ yếu là C và một phần Rust
  • Các tầng như YJIT mở ra khả năng chuyển nhiều tính năng ngôn ngữ hơn sang mã Ruby thông thường
  • Khi nhiều chức năng cốt lõi hơn được viết bằng Ruby, các lập trình viên Ruby có thể dễ đóng góp cho CRuby hơn
  • Một tương lai khả dĩ của CRuby được nhắc đến là cấu trúc giống Java, trong đó phần lớn ngôn ngữ được viết bằng chính nó trên một lõi cấp thấp nhỏ
  • Theo xu hướng hiện tại, thay vì giữ nguyên các triển khai C, việc đưa các triển khai Ruby mà YJIT có thể tối ưu hóa vào những đường thực thi cốt lõi sẽ tiếp tục quan trọng

1 bình luận

 
GN⁺ 2024-12-06
Ý kiến trên Hacker News
  • Ví dụ vòng lặp trông như một benchmark kỳ lạ chạy vòng lặp lồng nhau 1 tỷ lần, và nếu tối ưu thủ công thì có lẽ hơn 99% thời gian chạy sẽ dồn vào phần đầu
    Có vẻ nếu làm phân tích khoảng sống (liveness analysis) theo từng phần tử mảng thì có thể loại bỏ toàn bộ vòng lặp bên ngoài; không rõ có compiler nào thực sự làm kiểu phân tích này không
    Ngay cả khi u không được biết tại thời điểm biên dịch, vòng lặp bên trong dường như cũng có thể được thay bằng vài lệnh, và đây có vẻ là kiểu tối ưu hóa chuẩn hơn mà các công cụ như clang có thể sớm làm được

    • Compiler thường không làm phân tích khoảng sống cho từng phần tử riêng lẻ của mảng
      Có quá nhiều dữ liệu phải theo dõi, và khả năng cao chỉ hữu ích trong những trường hợp kiểu đoạn mã sai như thế này
      Trước đây khi làm compiler AI, dù phân tích khoảng sống của từng phần tử tensor hẳn đã thực sự hữu ích, nhưng thời gian biên dịch và yêu cầu bộ nhớ quá phi lý nên chúng tôi đã không làm
    • Trong hầu hết trường hợp có thể tính ở dạng đóng:
      result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)
  • Bài viết nói về phiên bản Ruby sắp ra mắt; tò mò xem thử thì có vẻ ruby 3.4.0 sẽ ra vào Giáng sinh năm nay, còn ruby 3.5.0 sẽ ra vào Giáng sinh năm sau
    Cũng tò mò một JIT tối thiểu của Python sẽ ảnh hưởng thế nào trong các vòng lặp kiểu này
    Python 3.13 phải được build với JIT bật sẵn, nên sẽ thú vị nếu ai đó chạy benchmark trong một môi trường đã build như vậy: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...

    • Ruby luôn phát hành vào Giáng sinh, nên lịch trình vừa dễ đoán vừa đáng yêu
      Tôi nhớ là các cải thiện hiệu năng cũng từng có thể được đưa vào các bản point release
  • Integer#succ được dùng thường xuyên không chỉ vì hiệu năng mà còn vì tính dễ đọc
    Ví dụ trong method #bytes của một thư viện UUID, tôi đã dùng nó hai lần để khi đọc code vẫn giữ đầu óc ở “chế độ cắt bit”
    Vòng lặp 16 lần được viết là 0xF.succ, còn phần chia cho 256 bên trong vòng lặp được biểu diễn là 0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Tôi tò mò vì sao trong trường hợp này bạn cảm thấy 0xF.succ tốt hơn 0x10
  • Tôi đang đóng góp cho https://github.com/bddicken/languages, và sau khi thử cách tiếp cận với Lua, tôi cũng nhớ tới TruffleRuby được nhắc ở đâu đó, nhưng khi chạy main.rb thì thực tế không có khác biệt lớn giữa TruffleRuby và Ruby thông thường
    Đôi khi Ruby thông thường còn nhanh hơn TruffleRuby
    Tôi muốn kiểm tra benchmark tốc độ TruffleRuby trong bài xuất hiện sau thay đổi nào, và nếu có thể xác minh thì muốn thêm nó vào repository chính dưới dạng commit
    Nếu bản triển khai TruffleRuby thực sự nhanh hơn Node.js và gần mức Bun hay Go thì khá đáng ngạc nhiên

    • Với TruffleRuby cần tính đến thời gian khởi động và thời gian để đạt hiệu năng tối đa; điều này thay đổi tùy theo thiết lập runtime native/JVM
      Xem chi tiết tại https://github.com/oracle/truffleruby
  • Ruby đã thực sự nhanh hơn, và đặc biệt TruffleRuby còn ấn tượng hơn

    • Nó là của Oracle: https://github.com/oracle/truffleruby
    • Rails không chạy trên Truffle, và theo tôi hiểu thì có lẽ trong thời gian tới cũng vẫn khó
      Thật đáng tiếc vì đây là tổ hợp có khả năng ảnh hưởng lớn nhất đến hiệu năng Ruby
  • Tôi không biết YJIT được viết bằng Rust, thật sự thú vị

    • Ban đầu nó được viết bằng C rồi được port sang Rust, và có vẻ đó là một quyết định đúng
      Điểm bất lợi là nếu không có toolchain hoặc nền tảng phù hợp thì nó có thể không được bật khi build, nhưng có vẻ là một đánh đổi hợp lý
  • Cũng có một repository so sánh ngôn ngữ đã vận hành lâu hơn và bao quát nhiều ngôn ngữ hơn: https://github.com/niklas-heer/speed-comparison

    • Lại thêm một repository so sánh ngôn ngữ nữa, nhưng cách trình bày khó đọc
      Nhãn trục của biểu đồ và nhãn cột chồng lên nhau, cũng không có đường lưới dọc
      Giá mà là một bảng HTML đơn giản thì tốt
  • Thật thú vị là Python là ngôn ngữ chậm nhất trong benchmark nhưng tính đến tháng 10/2024 lại là ngôn ngữ được dùng nhiều nhất trên GitHub
    Có vẻ tồn tại một tương quan kiểu ngôn ngữ càng chậm thì càng phổ biến

    • Nếu so sánh lại thì cũng nên tính cả thời gian biên dịch, và khấu hao nó trên số lần chạy dự kiến của bản build đó
      Dù tôi khá thích Rust ở mức sâu, mọi ngôn ngữ và runtime, interpreter, compiler đều chỉ là công cụ
      Tùy vấn đề và cách tiếp cận giải pháp mà cần một bộ công cụ phù hợp; nếu một chương trình có thể chỉ chạy vài lần thì tốc độ thực thi chậm không quá quan trọng
      Các chương trình bằng Python, R thường rơi vào trường hợp như vậy
    • Giống đồ ăn vậy: nếu rắc đường lên trên thì mọi người sẽ thích hơn nhiều
      Nhìn chung Ruby chậm, nhưng viết code bằng nó thật sự rất thích, nên hấp dẫn hơn với người mới bắt đầu
    • Ngôn ngữ chậm thường bậc cao hơn nên dễ dùng hơn
    • Tôi tò mò liệu tương quan đó có còn đúng nếu nhìn vào top 20 ngôn ngữ phổ biến không
  • Lời giải Advent of Code của tôi trông giống đến ngạc nhiên, nên đây có vẻ là một thay đổi làm xoay chuyển cục diện