Tối ưu hiệu suất Ruby

1. Mở đầu

Không phải bàn cãi nhiều, ai trong chúng ta cũng biết rằng Ruby là một ngôn ngữ tuyệt vời, nó giúp cho việc xây dựng nên một ứng dụng web trở nên đơn giản và nhanh chóng hơn bao giờ hết. Nhưng song song với điều đó, luôn có ý kiến cho rằng các ứng dụng viết bằng ngôn ngữ Ruby (hay các Framwork có nền tảng Ruby) thường chạy chậm hơn các ứng dụng mà sử dụng các ngôn ngữ lập trình khác. Bài viết này sẽ giúp các bạn hiểu được vì sao Ruby lại chậm, và làm thế nào để cải thiện điều đó.

2. Vì sao codes viết bằng Ruby lại chậm ?

Chắc hẳn việc đầu tiên chúng ta nghĩ tới việc ứng dụng chạy chậm chạp đó là do việc xây dựng các thuật toán phức tạp, sử dụng nhiều vòng lặp lồng nhau ... Thông thường giải pháp được đưa ra trong trường hợp này sẽ là cấu trúc lại code, xác định khoanh vùng những phần bị chậm, nhận định nguyên nhân và viết lại đoạn code đó giống như việc tránh các nút thắt cổ chai vậy. Và chúng ta cứ lặp đi lặp lại điều đó cho đến khi ứng dụng trở nên nhanh hơn.

Giải pháp trên dường như khá là hợp lý, tuy nhiên đối với Ruby code đôi lúc lại không áp dụng được. Thuật toán phức tạp có thể là nguyên nhân chính dẫn đến các vấn đề về performance, nhưng có một nguyên nhân nữa mà các developer thường bỏ qua. Hãy xem ví dụ dưới đây:

require 'benchmark'

rows = 30000
cols = 10

data = Array.new(rows){Array.new(cols){"test" * 1000 }}

time = Benchmark.realtime do
csv = data.map{ |row| row.join(",")}.join("\n")
end
puts time.round(2)

Nguyên nhân đầu tiên mà tôi muốn nói đến là việc lựa chọn phiên bản ruby sử dụng trong ứng dụng cũng ảnh hưởng đến performance. Cụ thể là mỗi phiên bản ruby sẽ thực thi đoạn code trên nhanh chậm khác nhau.

1.9.3 2.1.0 2.1.5 2.3.0
18.69 10.12 8.22 6.23

Bạn có thể thấy ruby version cũ thực thi mất nhiều thời gian nhất, các version mới hơn có vẻ khả quan, tuy nhiên thử xét xem một chương trình khá đơn giản - tạo ra 1 file csv với 30000 và 10 cột mất tận hơn 10s, với ruby 2.3.0 là hơn 6s tuy nhiên vẫn quá chậm. Vậy tại sao thực thi chương trình trên lại tốn thời gian đến vậy ?

Hãy cùng nhìn lại đoạn code thêm một lần nữa, nó chỉ là 2 vòng lặp lồng nhau, không có gì đặc biệt cả. Làm sao để chương trình trên nhanh hơn đây? Hãy cùng thử chạy chương trình trên và disable cơ chế garbage collection

require 'benchmark'

rows = 30000
cols = 10

data = Array.new(rows){Array.new(cols){"test" * 1000 }}
GC.disable  # Insert this line

time = Benchmark.realtime do
csv = data.map{ |row| row.join(",")}.join("\n")
end
puts time.round(2)

Và đây là kết quả thu được

1.9.3 2.1.0 2.1.5 2.3.0
GC Enabled 18.69 10.12 8.22 6.23
GC Disabled 6.58 5.0 4.04 3.43
Thời gian để GC 65% 51% 51% 45%

Giờ thì bạn đã thấy lí do khiến cho code chở nên chậm chạp là gì rồi chứ? Chương trình của chúng ta dành phần lớn thời gian để thực hiện thu gom "rác". Thật đáng ngạc nhiên đúng không, đặc biệt là với những ai trước đó đã làm việc với C# hay java, cũng là những ngôn ngữ có cơ chế GC thì có lẽ sẽ cảm thấy khá choáng với điều này, đúng là khó tin nhưng ... thật.

Vậy tóm lại thì nguyên nhân chính là do code của chúng ta sử dụng quá nhiều tài nguyên bộ nhớ, hay là cơ chế GC của Ruby quá chậm ? Câu trả lời là cả 2 điều trên.

Tốn tài nguyên bộ nhớ thì vốn là đặc trưng của Ruby rồi, cái này là do đặc thù của ngôn ngữ. "Mọi thứ đều là Object" nghĩa là chương trình cần thêm bộ nhớ để cấp cho các đối tượng Ruby này. Ngoài ra thì cái cơ chế garbage collection trên Ruby chậm, cũng là vấn đề thiên cổ của các version ruby từ trước đến nay (dĩ nhiên từ 2.2.2 trở đi thì cũng có cải thiện hơn thật, không phủ nhận điều đó). Không những cái thuật toán đánh dấu, quét, ngăn chặn GC nó chậm, mà nó còn dừng luôn cả ứng dụng trong thời gian GC chạy 😦. Đó là lý do vì sao chương trình cỏn con của chúng ta mất cả chục giây để chạy.

3. Tối ưu bộ nhớ

Việc tiêu tốn quá nhiều tài nguyên bộ nhớ là nguyên nhân khiến các ứng dụng Ruby trở nên chậm chạp. Do đó để tối ưu hóa các ứng dụng này ta cần phải làm sao để chúng dùng càng ít tài nguyên thì càng tốt, nó cũng rút ngắn thời gian dành cho việc thu gon rác (GC) đi.

Chắc hẳn cũng có bạn thắc mắc, sao không disable luôn cái cơ chế GC luôn đi cho rồi. Tuy nhiên việc này không nên chút nào, nó sẽ kéo theo một hệ lụy rất lớn đó là tăng đáng kể mức độ tiêu thụ bộ nhớ lúc cao điểm. Nó giống như việc rác tràn ngập chiếm dụng không gian nhà bạn vậy.

Quay trở lại với ví dụ bên trên

  time = Benchmark.realtime do
    csv = data.map{ |row| row.join(",")}.join("\n")
  end

Chúng ta thấy những dòng csv tạo ra bên trong block là kết quả trung gian được lưu vào bộ nhớ, cho đến khi join chúng bằng kí tự xuống dòng, và đây chính là nguyên nhận gây tổn hao bộ nhớ. Hãy cùng thay đổi đoạn code trên một chút

require 'benchmark'

rows = 30000
cols = 10

data = Array.new(rows){Array.new(cols){"test" * 1000 }}
# GC.disable
time = Benchmark.realtime do
  csv = ''
  rows.times do |r|
    cols.times do |c|
      csv << data[r][c]
      csv << "," unless c == cols -1
    end
    csv << "\n" unless r == rows -1
  end
end
puts time.round(2)

Hãy xem kết quả thu được sau khi optimized

1.9.3 2.1.0 2.1.5 2.3.0
GC Enabled 18.69 10.12 8.22 6.23
GC Disabled 6.58 5.0 4.04 3.43
Optimized 1.53 1.12 1.35 1.5

Kết quả thu được khá khả quan, chỉ cần một thay đổi nhỏ thôi nhưng hiệu quả nó mang lại thì rất rõ rệt. Chương trình sau khi được optimized thậm chí còn chạy nhanh hơn bản gốc mà ko dùng cơ chế GC. Và nếu chúng ta đặt GC.disable vào đoạn code trên nữa thì sẽ thấy thời gian dành để GC chỉ chiếm 10% tổng thời gian thực thi chương trình, cải thiện hơn nhiều so với việc phải sử dụng đến 50 - 80% thời gian để chạy GC như trước kia.

Ở đây chúng ta rút ra cho mình một nguyên lý để tối ưu hiệu suất, đó là nguyên lý 80/20: 80% các tối ưu performance đến từ việc optimize bộ nhớ. Vì vậy phải ưu tiên tối ưu bộ nhớ trước.

4. Tiết kiệm bộ nhớ

Bất cứ khi nào chúng ta khởi tạo hay sao chép thứ gì vào bộ nhớ thì cũng chính là lúc chúng ta thêm việc cho GC. Dưới đây là một số cách viết code giúp chúng ta khôi tiêu tốn quá nhiều bộ nhớ.

Sửa đổi String thay vì sao chép ra một String mới

Các ứng dụng Ruby thường xuyên sử dụng String, và cũng thường xuyên sao chép chúng. Trong hầu hết các trường hợp, ta không nên làm điều đó. Thay vì tạo ra một bản sao, ta sửa đổi trực tiếp bản gốc. Ruby cung cấp một loạt các "bang!" function hỗ trợ modify string tại chỗ như gsub!, upcase!, delete!, slice! ...

require 'benchmark'

str = "test" * 1024 * 1024 * 100
measurement1 = Benchmark.measure do
  str = str.downcase
end

measurement2 = Benchmark.measure do
  str.downcase!
end

Trong block của measurement1 có thể thấy hàm String#dowcase đã dùng 10MB bộ nhớ để sao chép chuỗi str rồi sau đó convert nó sang upcase. Trong khi hàm downcase! không cần phải cấp phát bộ nhớ.

Một chức năng hữu ích khác cho phép sửa đổi code tại chỗ , đó là sử dụng String::<<.

  x = "Ruby"
  x += "On Rails"

Thay vì làm như cách trên chúng ta có thể dùng

  x = "Ruby"
  x << "On Rails"

Biến đổi array hoặc hash tại chỗ

Tương tự như String, array & hash cũng có các function như map!, select!, reject!... Ý tưởng cũng tương tự như String, đó là không tạo ra một bản sao dùng để sửa đổi trừ khi thực sự cần thiết

require 'wrapper'

data = "test" * 1024 * 1024 * 200
measure do
  data.map{ |t| t.upcase }
end

measure do
  data.map! {|t| t.upcase!}
end

Kết quả:

map&upcase map!&upcase!
Total time 0.22 0.14
Extra Memory 100MB 0

Chỉ cần thêm ! mà đoạn code trên có thể thực thi nhanh hơn 35% so với trước kia. Thật dễ dàng đúng không các bạn.

5. Kết luận

Bài viết này đề cập đến một số nguyên nhân làm cho ứng dụng chạy chậm, một số phương pháp tối ưu hóa code cũng như bộ nhớ, làm sao để tiết kiệm tài nguyên cho hệ thống. Bài viết sau xin đề cập đến việc làm sao để các ứng dụng Rails chạy nhanh hơn (faster ActiveRecord, faster action view, profile... )