Micro-optimize Ruby
Có nhiều mức độ optimize Ruby code:
- Design: kiến trúc và thuật toán(ví dụ: n + 1 query)
- Source: viết code tối ưu nhất
- Build: build phiên bản ruby phù hợp với yêu cầu của source code.
- Compile: dùng các compiler khác nhau của Ruby. Ví dụ: mrbc, jrubyc, rbx
- Runtime: tối ưu ở bước runtime(ví dụ như dùng các biến môi trường khi execute ruby:
RUBY_GC_MALLOC_LIMIT
)
Trong bài viết này, ta sẽ tập trung vào các kỹ thuật optimize trên source code.
Một vấn đề quan trọng khi optimize chính là cần phải benchmark, nghĩa là không dựa vào cảm giác để quyết định một đoạn code này sẽ chạy nhanh hơn đoạn code kia, mà phải dựa vào kết quả benchmark khách quan. Vì vậy trước tiên, ta sẽ làm quen với các công cụ benchmark của Ruby.
Các công cụ benchmark
Benchmark IPS
Rất hữu dụng khi muốn so sánh tốc độ thực thi giữa 2 đoạn code. Cách sử dụng rất đơn giản:
Khi execute đoạn code trên ta sẽ được 1 report như sau:
Calculating -------------------------------------
slow 71.254k i/100ms
fast 68.658k i/100ms
-------------------------------------------------
slow 4.955M (± 8.7%) i/s - 24.155M
fast 24.011M (± 9.5%) i/s - 114.246M
Comparison:
slow 23958619.8 i/s - 1.00x slower
fast: 24011974.8 i/s
Tham khảo: https://github.com/evanphx/benchmark-ips
memory_profiler
Dùng gem này khi muốn kiểm tra số lượng object được tạo ra.
Tham khảo: https://github.com/SamSaffron/memory_profiler
allocation_tracer
Có chức năng khá tương tự với memory_profiler
, gem này tương thích với Ruby 2 trở lên.
Tham khảo: https://github.com/ko1/allocation_tracer
Một số phương pháp optimize với Ruby
1. Giảm số lượng object tạo ra
Giảm số lượng object tạo ra sẽ giúp bộ nhớ ít chiếm dụng hơn, Garbage Collector ít bị overhead hơn dẫn đến code sẽ được execute nhanh hơn. Dưới đây là một cách để hạn chế phát chiếm dụng bộ nhớ.
Sử dụng String#freeze
Mỗi khi một string được gọi, Ruby sẽ tạo một vùng nhớ mới để lưu string này, do đó nếu sử dụng cùng một chuỗi nhiều lần, nên dùng method String#freeze. Việc này sẽ giúp tránh allocate nhiều vùng nhớ không cần thiết.
Ví dụ:
Sử dụng ! method(a.k.a bang method)
Trong Ruby có 2 loại method: method trả về object và method thay đổi object. Ví dụ ta có map
và map!
Dùng các hàm ! nếu chỉ cần thay đổi object sẽ giúp giảm việc allocate bộ nhớ.
Vì khi gọi method thường sẽ allocate một vùng nhớ mới và trả về vùng nhớ đó cho câu gọi hàm.
Ngoài các ! method, Ruby còn có một số cặp method trả về object và thay đổi một object. Chẳng hạn:
select vs. keep_if
reject vs. delete_if
2. Dùng các method đã được optimize sẵn của Ruby
map.flatten(1) vs. flat_map
Kết quả benchmark:
slow 12348.2 (±9.0%) i/s - 61450 in 5.017159s
fast 56647.8 (±7.2%) i/s - 282152 in 5.006973s
reverse.each vs. reverse_each
Kết quả benchmark:
slow 12348.2 (±9.0%) i/s - 61450 in 5.017159s
fast 56647.8 (±7.2%) i/s - 282152 in 5.006973s
Hash#keys và Enumerable#each vs. Hash#each_key
Hash#keys.each sẽ tạo một mảng mới, trong khỉ each_key
không sinh một mảng mới, do đó dùng each_key
sẽ hiệu quả hơn.
Kết quả benchmark:
slow 12348.2 (±9.0%) i/s - 61450 in 5.017159s
fast 56647.8 (±7.2%) i/s - 282152 in 5.006973s
shuffle.first vs. sample
Array#shuffle
sẽ allocate một array mới bộ nhớ, trong khi Array#sample
chỉ thực hiện trên index của array, do đó không cần allocate thêm bộ nhớ.
Tốc độ thực thi sẽ nhanh hơn 15X.
Kết quả benchmark:
slow 324806.7 (±8.1%) i/s - 1620738 in 5.028042s
fast 5069719.9 (±9.5%) i/s - 24872400 in 5.011482s
https://github.com/rails/rails/pull/14240
https://github.com/rails/rails/pull/17244