Tối ưu hóa JSON của Ruby, Phần 1
(byroot.github.io)- Gem
jsonmặ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 sangojchỉ 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_JSONvàOj.optimize_rails ojnhanh hơn trong một số benchmark, nhưng việc bỏ qua tùy chọnscript_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.json467KiB, 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
jsongầ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.2vàojtrong 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.2là1.9ms,ojlà1.6ms - Tạo tài liệu đó:
json 2.7.2là0.8ms,ojlà0.4ms
- Parse tài liệu JSON 467KiB gồm 100 tweet:
- 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_JSONthường được dùng để monkey patch gemjson, cònOj.optimize_railsđể monkey patchActiveSupport::JSONJSON.dump(data, script_safe: true)có thể escape</script>thành<\\/script>để nhúng JSON an toàn bên trong thẻ<script>- Vì
ojkhông biết tùy chọnscript_safevà 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ọiOj.mimic_JSON Oj.optimize_railscũ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_railsvàOj.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,
ojlà một trong những nguyên nhân nổi bật gây crash Ruby và chỉ đứng saugrpcvề 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
ojcó 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ỏ
Ojkhỏ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ữaOj.mimic_JSONvàjsonthực tế
- Trong môi trường quy mô lớn,
Tìm điểm nghẽn bằng benchmark và profiling
- Mục tiêu là để
ruby/jsonhoạt động tương đươngojtrong cả tình huống thực tế lẫn microbenchmark, qua đó giảm sức hấp dẫn củaOj.mimic_JSONkhi người ta chỉ quan tâm đến tốc độ - Bước đầu tiên là xây dựng bộ benchmark
- Bao gồm cả microbenchmark lẫn benchmark sát thực tế hơn
- Dựa trên bộ benchmark của gem rapidjson-ruby do John Hawthorn phát triển và bổ sung thêm một số phần
- 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.dumpvới payloadtwitter.json, riêngisLegalUTF8của JSON chiếm9%, cònrb_enc_str_asciionly_pchiếm1.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étENC_CODERANGE_UNKNOWN: chưa được quétENC_CODERANGE_VALID: encoding hợp lệENC_CODERANGE_7BIT: encoding hợp lệ và chỉ có ký tự ASCIIENC_CODERANGE_INVALID: encoding không hợp lệ
convert_UTF8_to_JSON_ASCIItrước đây gọirb_enc_str_asciionly_ptrướ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ẽ raiseJSON::GeneratorErrorkhistring.encoding != Encoding::UTF_8hoặc!string.valid_encoding? - Cả
#ascii_only?và#valid_encoding?đều dùngcoderangeđã cache, nên chuỗi chỉ bị quét nhiều nhất một lần
- Theo cách viết Ruby, nếu không phải
- 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 sangconvert_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.jsontăng từ1077.3 i/slên1113.3 i/s, tức nhanh hơn1.03x
- Một phần đáng kể thời gian từng nằm trong
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_capachiếm5.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->capasẽ là0, nên cũng trùng phần nào với kiểm trarequired > 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_LIKELYvàRB_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.jsontừ1068.6 i/slên1224.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/jsoncó 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 microbenchmarkJSON.generatecó 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
- Ví dụ:
- 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/slên3,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ằngrb_enc_get(obj)rồi so sánh với US-ASCII hoặc UTF-8 rb_enc_getlà 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ờ
- Vì
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ùngRB_ENCODING_GETđể so sánh trực tiếp encoding index - Thay đổi này nâng benchmark tạo
twitter.jsontừ1159.6 i/slên1253.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,\bkhô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.jsontừ1258.1 i/slên1630.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
Ý 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
Dù là về C extension, nó giúp hiểu một số khái niệm
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?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
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?
Oj có một API rất lớn mà gem
jsonmặ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 đó
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ắcNhì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 đề
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?
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 đenTruffleRuby 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
“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”