1 điểm bởi GN⁺ 2024-12-19 | 1 bình luận | Chia sẻ qua WhatsApp
  • Gem json mặc định của Ruby đã được cải thiện theo hướng loại bỏ các điểm nghẽn qua profiling, giảm áp lực thực tế phải chuyển sang oj chỉ vì tốc độ
  • Mục tiêu không phải là luôn đánh bại oj, mà là cung cấp xử lý JSON đủ nhanh và dễ dự đoán mà không cần monkey patching như Oj.mimic_JSONOj.optimize_rails
  • oj nhanh hơn trong một số benchmark, nhưng việc bỏ qua tùy chọn script_safe, khác biệt trong serialization của Rails, và crash Ruby đã tạo ra gánh nặng về độ ổn định vận hành và tương thích API
  • Các tối ưu hóa chính gồm loại bỏ kiểm tra UTF-8 trùng lặp, kiểm tra điều kiện phổ biến trước, giảm chi phí cấu hình generator, tránh truy vết con trỏ encoding, và kiểm tra escape dựa trên bảng tra cứu
  • Trong benchmark tạo twitter.json 467KiB, từng thay đổi lần lượt mang lại cải thiện 3%, 8%, 15%, 30%, và việc tạo Hash nhỏ nhanh hơn 1,51 lần chỉ nhờ giảm chi phí cấu hình

Bối cảnh khiến gem json trở nên nhanh hơn

  • Sau khi trở thành maintainer của gem json gần đây, tác giả đã tập trung cải thiện hiệu năng song song với việc sửa các bug cũ, và kết quả là nó trở thành parser và generator JSON nhanh nhất cho Ruby trong phần lớn benchmark
  • Phần lớn các bản vá hiệu năng không đến từ bí quyết đặc biệt nào mà gần với việc dùng profiling để tìm điểm nghẽn rồi cắt giảm lãng phí đơn giản
  • Động lực cốt lõi là đưa ruby/json đến mức đủ nhanh để người dùng không còn phải chọn gem thay thế chỉ vì tốc độ

Gánh nặng do dùng oj thay thế

  • Chênh lệch giữa json 2.7.2oj trong một số benchmark gần với kích thước thực tế không lớn
    • Parse tài liệu JSON 467KiB gồm 100 tweet: json 2.7.21.9ms, oj1.6ms
    • Tạo tài liệu đó: json 2.7.20.8ms, oj0.4ms
  • Trong nhiều trường hợp sử dụng, phần chậm không phải bản thân việc serialize JSON mà là lớp phía trên chuyển model Active Record thành Ruby Hash và Array
  • oj đã được dùng trong nhiều dự án, bao gồm codebase của Shopify, và mức độ phổ biến đó nhiều khả năng đến từ tốc độ
  • Sự lệch API do monkey patching

    • Oj.mimic_JSON thường được dùng để monkey patch gem json, còn Oj.optimize_rails để monkey patch ActiveSupport::JSON
    • JSON.dump(data, script_safe: true) có thể escape </script> thành <\\/script> để nhúng JSON an toàn bên trong thẻ <script>
    • oj không biết tùy chọn script_safe và bỏ qua nó, một gem vốn an toàn khi chạy độc lập vẫn có thể tạo ra khả năng bị tấn công XSS bên trong ứng dụng đã gọi Oj.mimic_JSON
    • Oj.optimize_rails cũng có thể tạo ra khác biệt tinh vi trong việc serialize object
    • Khi ActiveSupport::JSON::Encoding.time_precision = 0, ActiveSupport::JSON.encode(t) có thể tạo chuỗi chỉ đến giây
    • Sau Oj.optimize_railsOj.mimic_JSON, có trường hợp chuỗi đầu ra lại bao gồm mili giây
    • Trường hợp này là một corner case do thứ tự load, nhưng trước đây còn có nhiều thay đổi hành vi hơn thế
  • Vấn đề ổn định trong môi trường vận hành

    • Trong môi trường quy mô lớn, oj là một trong những nguyên nhân nổi bật gây crash Ruby và chỉ đứng sau grpc về mức độ rắc rối
    • Việc viết native gem đòi hỏi hiểu Ruby VM và đặc biệt là GC, nếu không có thể gây crash hoặc hỏng bộ nhớ
    • Codebase của oj có các hack khiến khó đặt niềm tin, và từng có lúc nó vô hiệu hóa GC trong một số tình huống để lách bug
    • Khi bật lại GC, điều đó có thể kích hoạt major GC cycle
    • Kiểu code này có thể có lợi trong microbenchmark nhưng lại làm hiệu năng thực tế trong production tệ hơn
    • Vì trải nghiệm đó, Shopify đã loại bỏ Oj khỏi monolith của mình, và trong quá trình này xác nhận các khác biệt tinh vi giữa Oj.mimic_JSONjson thực tế

Tìm điểm nghẽn bằng benchmark và profiling

  • Mục tiêu là để ruby/json hoạt động tương đương oj trong cả tình huống thực tế lẫn microbenchmark, qua đó giảm sức hấp dẫn của Oj.mimic_JSON khi người ta chỉ quan tâm đến tốc độ
  • Bước đầu tiên là xây dựng bộ benchmark
  • Với C profiler, tác giả dùng samply
    • Ưu điểm là xuất báo cáo tương thích Firefox Profiler nên dễ chia sẻ

Loại bỏ kiểm tra UTF-8 trùng lặp

  • Khi profiling JSON.dump với payload twitter.json, riêng isLegalUTF8 của JSON chiếm 9%, còn rb_enc_str_asciionly_p chiếm 1.9%
  • Ruby String có một thuộc tính nội bộ tên coderange, dùng để cache trạng thái encoding hay việc chuỗi có chỉ gồm ASCII hay không sau một lần quét
    • ENC_CODERANGE_UNKNOWN: chưa được quét
    • ENC_CODERANGE_VALID: encoding hợp lệ
    • ENC_CODERANGE_7BIT: encoding hợp lệ và chỉ có ký tự ASCII
    • ENC_CODERANGE_INVALID: encoding không hợp lệ
  • convert_UTF8_to_JSON_ASCII trước đây gọi rb_enc_str_asciionly_p trước, rồi lại tự quét chuỗi lần nữa để xác nhận tính hợp lệ của UTF-8, tức là làm việc trùng lặp
  • Sau thay đổi, việc xác định tính hợp lệ UTF-8 được thực hiện bằng cách so sánh coderange đã tính sẵn
    • Theo cách viết Ruby, nếu không phải string.ascii_only? thì sẽ raise JSON::GeneratorError khi string.encoding != Encoding::UTF_8 hoặc !string.valid_encoding?
    • Cả #ascii_only?#valid_encoding? đều dùng coderange đã cache, nên chuỗi chỉ bị quét nhiều nhất một lần
  • Trái với kỳ vọng 9%, cải thiện thực tế chỉ khoảng 3%
    • Một phần đáng kể thời gian từng nằm trong isLegalUTF8 đã chuyển sang convert_UTF8_to_JSON
    • Lý do chưa chắc chắn, nhưng có thể phần lớn trong 9% đó là chi phí đưa byte của chuỗi từ RAM vào CPU cache
    • Benchmark tạo twitter.json tăng từ 1077.3 i/s lên 1113.3 i/s, tức nhanh hơn 1.03x

Kiểm tra trước những điều kiện rẻ hơn và có xác suất cao hơn

  • fbuffer_inc_capa chiếm 5.7% tổng runtime, và phần lớn thời gian được dùng chỉ để kiểm tra xem buffer đã được cấp phát hay chưa
  • Hàm này được gọi mỗi lần ghi gì đó vào buffer, nhưng sau lần gọi đầu tiên thì buffer luôn đã được cấp phát
  • Cấu trúc cũ kiểm tra trước một điều kiện hầu như không khớp nên gây lãng phí lớn, và nếu buffer chưa được cấp phát thì fb->capa sẽ là 0, nên cũng trùng phần nào với kiểm tra required > fb->capa
  • Sau khi sửa, trường hợp phổ biến nhất là “dung lượng buffer đã đủ” được kiểm tra trước, đồng thời dùng RB_LIKELYRB_UNLIKELY để gợi ý cho CPU branch prediction
  • Hàm được đánh dấu inline để giảm chi phí gọi, và trong đa số trường hợp công việc cần làm chỉ còn khoảng phép trừ và so sánh
  • Thay đổi này nâng benchmark tạo twitter.json từ 1068.6 i/s lên 1224.7 i/s, tức nhanh hơn 1.15 lần
  • Nguyên lý tương tự cũng áp dụng được cho code Ruby: kiểm tra trước những điều kiện rẻ nhất và dễ xảy ra nhất

Giảm chi phí cấu hình của JSON generator

  • Ruby committer Yusuke Endoh aka Mame cũng tham gia tối ưu ruby/json, và từng có một PR cũ chứa nhiều tối ưu hóa
  • Nhiều thay đổi tập trung vào việc giảm chi phí cấu hình cần thiết trước khi tạo JSON
    • parse tham số
    • cấp phát generator và các struct liên quan
    • các bước chuẩn bị trước khi bắt đầu tạo dữ liệu
  • ruby/json có chi phí cấu hình này lớn hơn các triển khai thay thế nên trông kém trong microbenchmark
  • JSON.generate có thể nhận các tùy chọn như array_nl, object_nl, indent, space để tạo pretty JSON
  • Trước đây, nó tính trước các buffer phân tách từ những chuỗi được truyền vào
    • Ví dụ: ",#{opts[:array_nl]}", ",#{opts[:object_nl]}", ":#{opts[:space]}"
    • Ý định là append một đoạn dài hơn chỉ trong một lần, nhưng lượng công việc tiết kiệm được trên thực tế là nhỏ
    • Trong đa số trường hợp các tùy chọn này không được dùng, nên chi phí tính trước còn lớn hơn lợi ích
  • Mame về cơ bản đã hoàn tác tối ưu hóa này để giảm mạnh chi phí cấu hình
    • Trong benchmark lớn, khác biệt không đáng kể
    • Nhưng benchmark tạo Hash nhỏ 65 bytes tăng từ 2,112,189.3 i/s lên 3,199,311.0 i/s, tức nhanh hơn 1.51 lần

Tránh truy vết con trỏ và so sánh encoding index

  • Một tối ưu khác của Mame là loại bỏ một lần gọi rb_enc_get
  • JSON thường xuyên phải kiểm tra chuỗi có tương thích UTF-8 hay không, và trước đây nó lấy rb_encoding * bằng rb_enc_get(obj) rồi so sánh với US-ASCII hoặc UTF-8
  • rb_enc_get là API cấp cao mang tính phòng thủ nên thực hiện nhiều kiểm tra kiểu khác nhau
    • Nó phải xử lý được nhiều loại object như String, Symbol, Regexp, File, Data...
    • Có nhiều nhánh điều kiện, và nếu CPU branch prediction đoán sai thì chi phí có thể tăng đáng kể
  • Về mặt khái niệm, Ruby String giữ tham chiếu tới encoding, nhưng trên thực tế thay vì lưu con trỏ 64-bit, nó lưu một encoding index 7-bit nhỏ hơn trong bitmap nội bộ của mỗi String
  • Để lấy con trỏ object encoding đầy đủ, VM phải tra encoding thực trong một mảng toàn cục nội bộ, và đây chính là truy vết con trỏ ở mã mức thấp
    • Nếu dữ liệu đã nằm trong CPU cache thì nhanh, nhưng nếu phải lấy từ RAM thì CPU sẽ phải chờ
  • json đã biết đối tượng là String và chỉ cần biết nó có phải ASCII hoặc UTF-8 hay không, nên có thể dùng RB_ENCODING_GET để so sánh trực tiếp encoding index
  • Thay đổi này nâng benchmark tạo twitter.json từ 1159.6 i/s lên 1253.3 i/s, tức nhanh hơn 1.08 lần

Xử lý escape chuỗi nhanh hơn bằng bảng tra cứu

  • Việc dump chuỗi JSON tốn kém vì phải kiểm tra từng ký tự xem có thể sao chép nguyên trạng hay cần escape
  • Cách ngây thơ là kiểm tra nhiều điều kiện cho mỗi ký tự
    • xem có phải ASCII control character không
    • xem có phải \n, \r, \t, \f, \b không
    • xem có phải " hoặc \\ không
  • Cách dùng bảng tra cứu là tính sẵn quyết định này trong một mảng tĩnh, rồi với mỗi ký tự chỉ đọc một boolean ở offset động thay vì so sánh nhiều lần
  • Đổi lại việc dùng thêm một ít bộ nhớ tĩnh, vòng lặp trở nên nhanh hơn nhiều
  • Dựa trên giả định rằng đa số chuỗi không chứa ký tự cần escape, Mame thêm điều kiện tiên quyết để trước tiên kiểm tra rẻ xem có đi theo fast path được không; nếu được thì sao chép cả chuỗi vào buffer một lần
  • Patch của Mame viết bằng C nên phức tạp hơn, nhưng dùng cùng một mẫu
  • Chỉ riêng thay đổi này đã nâng benchmark tạo twitter.json từ 1258.1 i/s lên 1630.2 i/s, tức nhanh hơn 1.30 lần

Các tối ưu hóa tiếp theo

  • Vẫn còn thêm các tối ưu hóa khác nên bài viết hẹn tiếp ở phần sau
  • Sau đó phần hai đã được công bố

1 bình luận

 
GN⁺ 2024-12-19
Ý kiến trên Hacker News
  • Tôi thật sự thích công việc của byroot. Không chỉ ở loại đóng góp, mà quy mô năng suất của anh ấy lúc nào cũng đáng kinh ngạc
    Tôi từng vài lần thử tham gia vào phần lõi Ruby, nhưng không tìm được việc nào vừa hợp với trình độ của mình vừa có thể đóng góp tích cực; nếu vài tuần không có kết quả thì động lực cũng biến mất. Lý do là rất khó có được bối cảnh như những gì bài viết đã chia sẻ
    Nếu những người làm Ruby C viết bài thường xuyên hơn, tôi nghĩ sẽ có nhiều người hơn sở hữu năng lực cần thiết để cải thiện Ruby. Lời khuyên về C profiler cũng rất hay, và tôi nghĩ có thể bắt đầu bằng cách lấy một Ruby gem có mã C rồi thử tối ưu lại

    • Cũng có loạt bài rất hay của Peter Zhu: https://blog.peterzhu.ca/ruby-c-ext/
      Dù là về C extension, nó giúp hiểu một số khái niệm
    • “Năng suất khổng lồ” là đúng, nhưng anh ấy cũng là một người cực kỳ thông minh. Tôi từng làm cùng văn phòng với anh ấy ở Shopify, và đó là kiểu người có trình độ tưởng như không thể với tới
  • Phần 2 cũng đã được đăng: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...

  • Một điều đáng nhắc đến là jbuilder, cách dùng mặc định trong Rails. Bản thân jbuilder không phải phần serialization JSON, nhưng nếu nói về những thứ khiến việc render JSON trong Ruby/Rails chậm, nó sẽ đứng đầu danh sách của tôi
    Render nhiều partial bằng jbuilder thì thật sự rất chậm

  • Các bài viết về chủ đề này rất dễ theo dõi, và khiến tôi muốn benchmark và tối ưu cả mã Ruby của mình. Cả bài viết lẫn công việc đều rất tốt

  • Có thể tôi đã bỏ sót, nhưng có chỗ nào cho biết phiên bản mới với mọi tối ưu được áp dụng mất bao lâu để parse/encode Twitter JSON dump không?

  • Bài viết tuyệt vời và công việc cũng tốt. Về sau còn lý do gì để dùng Oj nữa không?

    • Tôi là tác giả
      Oj có một API rất lớn mà gem json mặc định không có ý định mô phỏng. Ví dụ như “SAJ” (parsing kiểu SAX), nhiều cách escape khác nhau, v.v.
      Mục tiêu của tôi chỉ là khiến Oj không còn cần thiết trong khoảng 95% trường hợp sử dụng, nên Oj vẫn sẽ hữu ích cho nhiều mục đích
  • Sau bài này, tôi tò mò nó đã nhanh hơn bao nhiêu so với implementation hiện không còn được bảo trì này
    https://netflixtechblog.com/fast-json-api-serialization-with...
    Tôi từng nghĩ implementation thuần Ruby này khá gọn gàng, nhưng chưa bao giờ dùng trong production thực tế. Nó đã bị bỏ mặc từ lâu
    Nhìn chung tôi cũng tò mò về tình trạng của các implementation thuần Ruby. Có vẻ như json_pure đã bị loại bỏ, nếu vậy thì hơi tiếc. Có ai biết chi tiết chuyện này không? Phần thú vị nhất trong bài đối với tôi là tối ưu Ruby hơn là tối ưu C

  • Đọc rất thú vị. Tuy vậy, với những tối ưu không đặc thù cho Ruby, chẳng hạn lookup table cho ký tự escape, tôi tự hỏi tại sao không tận dụng các thư viện hiện có như simdjson vốn đã làm những việc đó

    • Phần nào đã được trả lời ở https://news.ycombinator.com/item?id=42450085
      Nói ngắn gọn, vì ruby/json được phân phối cùng Ruby nên phải tương thích với các ràng buộc của Ruby, hiện tại nghĩa là C99 thuần và không C++. Giấy phép Apache 2 của simdjson cũng có thể là vấn đề, nhưng tôi không chắc
      Nhìn chung tôi muốn dùng các thư viện C++ tuyệt vời như dragonbox, nhưng không thể
      Ngoài ra lần cuối tôi kiểm tra, simdjson chỉ cung cấp parser. Gem ruby/json làm cả parsing lẫn encoding, nên nó chỉ giúp được một nửa phạm vi vấn đề
    • Điều hay ở bài này là nó là công việc kỹ thuật thực tế trên một codebase hiện có. Không cố thay toàn bộ thứ gì đó hay đổi thư viện chỉ để nhanh hơn một chút, mà đi sâu vào mã thật và thực sự cải thiện không chỉ tốc độ mà cả hiệu quả
      Trong các dự án hiện đại, kiểu công việc này chưa được làm đủ nhiều. Nếu những việc như vậy được thực hiện đều đặn hơn, tôi tự hỏi liệu ngay từ đầu có cần các thư viện như simdjson hay oj không. Phạm vi vấn đề này cũng không khó đến mức đó
  • Ruby JSON có dùng intrinsics không? Có thể dùng không?
    Và nó tương tác với các JIT khác nhau như thế nào?

    • Tôi không rõ chính xác intrinsics ở đây nghĩa là gì
      Gem json được implement bằng C, nên đối với YJIT, tức JIT của implementation tham chiếu, nó là một hộp đen
      TruffleRuby JIT trước đây từng diễn giải C extension bằng sulong để có thể JIT xuyên qua ranh giới ngôn ngữ, nhưng theo tôi biết gần đây họ đã dừng cách đó vì nhiều vấn đề tương thích
      Ngoài ra trong TruffleRuby, JSON parser được implement bằng C, nhưng encoder là Ruby thuần: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
  • Nếu tôi nhớ đúng thì branch prediction hint vô dụng trên CPU hiện đại

    • Nó từng vô dụng trên CPU hiện đại, nhưng trên một số CPU gần đây lại hữu ích ở mức nào đó. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      “Từ vi kiến trúc Redwood Cove trở đi, nếu bộ dự đoán không có thông tin đã lưu về một nhánh nào đó và nhánh đó có Intel SSE2 branch taken hint, tức tiền tố lệnh 3EH, thì khi codec giải mã nhánh, dự đoán nhánh sẽ được đảo từ not-taken sang taken. Sau đó frontend pipeline bị flush và pipeline được dẫn để lấy đường taken
      ...
      Hint này chỉ được dùng khi bộ dự đoán không có thông tin đã lưu về nhánh đó. Để tránh phình mã và giảm băng thông fetch lệnh, không nên thêm hint vào các nhánh trong hot code, chẳng hạn nhánh bên trong vòng lặp có số lần lặp lớn, vì nhiều khả năng bộ dự đoán đã lưu thông tin về nhánh đó. Lý tưởng nhất là chỉ nên thêm hint cho các nhánh ít khi chạy nhưng phần lớn là taken, dù việc xác định các nhánh như vậy có thể khó. Nếu compiler không thể bố trí một đường thực thi làm fall-through, khuyến nghị thêm hint như một phần của tối ưu dựa trên profile. Vi kiến trúc Redwood Cove giới thiệu các sự kiện giám sát hiệu năng mới để hướng dẫn việc đặt hint”