Một số trick cải thiện performance trong Ruby

Khi phát triển các ứng dụng với bất kì một ngôn ngữ nào, đặc biệt là với các ứng dụng lớn, với số lượng dữ liệu và các thao tác lớn thì vấn đề cải thiện performance cho những dòng code của bạn là việc hết sức quan trọng. Ruby cũng không phải là ngoại lê. Trong trang Viblo cũng có rất nhiều bài viết hay về chủ đề này. Bài viết này mình xin điểm qua một số tip giúp cải thiện performance trong Ruby mà có thể bạn ít để ý

Đừng dùng exception cho câu điều kiện

Hãy cùng tìm hiểu qua ví dụ sau:

    require 'benchmark'

    class Obj
      def with_condition
        respond_to?(:mythical_method) ? self.mythical_method : nil
      end

      def with_rescue
        self.mythical_method
      rescue NoMethodError
        nil
      end
    end

    obj = Obj.new
    N = 10_000_000

    puts RUBY_DESCRIPTION

    Benchmark.bm(15, "rescue/condition") do |x|
      rescue_report     = x.report("rescue:")    { N.times { obj.with_rescue    } }
      condition_report  = x.report("condition:") { N.times { obj.with_condition } }
      [rescue_report / condition_report]
    end

Và đây là kết quả: Với Ruby 1.9.3:

    ruby 1.9.3p194 (2012-04-20 revision 35410) [x86_64-linux]
                            user     system      total        real
    rescue:           111.530000   2.650000 114.180000 (115.837103)
    condition:          2.620000   0.010000   2.630000 (  2.633154)
    rescue/condition:  42.568702 265.000000        NaN ( 43.991767)

Với Ruby 1.8.3 cũng cho kết quả tương tự

    ruby 1.8.7 (2011-12-28 patchlevel 357) [x86_64-linux]
                        user     system      total        real
rescue:            80.510000   0.940000  81.450000 ( 81.529022)
if:                 3.320000   0.000000   3.320000 (  3.330166)
rescue/condition:  24.250000        inf       -nan ( 24.481970)

Các bạn có thể thấy một sự khác biệt lớn ở trên.

Phép nối xâu(String)

Tránh việc sử dụng method += để nối xâu mà hãy dùng method << . Kết quả nhận được chắc chắn sẽ như nhau, thêm một string vào cuối một string đang tồn tại để tạo thành 1 string mới. Nhưng sự khác biệt ở đây là gì? Hãy cùng xem ví dụ sau:

    str1 = "str1"
    str2 = "str2"
    str1.object_id       # => 16241320

    str1 += str2          # str1 = str1 + str2
    str1.object_id      # => 16241240, id is changed

    str1 << str2
    str1.object_id     # => 16241240, id is the same

Khi bạn sử dụng +=, mặc định Ruby sẽ tạo ra một đối tượng tạm thời nhằm lưu kết quả của str1 + str2, sau đó nó sẽ override lại biến str1 với tham chiếu đến đối tượng tạm thời này. Trong khi đó, method << lại thay đổi giá trị trực tiếp từ object str1 đang tồn tại. Với việc sử dụng +=, sẽ có các hạn chế sau:

  • Thêm tính toán để nối chuỗi
  • Cần thêm memory để lưu trữ đối tượng tạm thời

+= chậm như nào, nó phụ thuộc vào độ dài string mà bạn đem nối:

    require 'benchmark'

    N = 1000
    BASIC_LENGTH = 10

    5.times do |factor|
      length = BASIC_LENGTH * (10 ** factor)
      puts "_" * 60 + "\nLENGTH: #{length}"

      Benchmark.bm(10, '+= VS <<') do |x|
        concat_report = x.report("+=")  do
          str1 = ""
          str2 = "s" * length
          N.times { str1 += str2 }
        end

        modify_report = x.report("<<")  do
          str1 = "s"
          str2 = "s" * length
          N.times { str1 << str2 }
        end

        [concat_report / modify_report]
      end
    end

Và kết qủa là:

    ____________________________________________________________
    LENGTH: 10
                     user     system      total        real
    +=           0.000000   0.000000   0.000000 (  0.004671)
    <<           0.000000   0.000000   0.000000 (  0.000176)
    += VS <<          NaN        NaN        NaN ( 26.508796)
    ____________________________________________________________
    LENGTH: 100
                     user     system      total        real
    +=           0.020000   0.000000   0.020000 (  0.022995)
    <<           0.000000   0.000000   0.000000 (  0.000226)
    += VS <<          Inf        NaN        NaN (101.845829)
    ____________________________________________________________
    LENGTH: 1000
                     user     system      total        real
    +=           0.270000   0.120000   0.390000 (  0.390888)
    <<           0.000000   0.000000   0.000000 (  0.001730)
    += VS <<          Inf        Inf        NaN (225.920077)
    ____________________________________________________________
    LENGTH: 10000
                     user     system      total        real
    +=           3.660000   1.570000   5.230000 (  5.233861)
    <<           0.000000   0.010000   0.010000 (  0.015099)
    += VS <<          Inf 157.000000        NaN (346.629692)
    ____________________________________________________________
    LENGTH: 100000
                     user     system      total        real
    +=          31.270000  16.990000  48.260000 ( 48.328511)
    <<           0.050000   0.050000   0.100000 (  0.105993)
    += VS <<   625.400000 339.800000        NaN (455.961373)

Cẩn thận vơí các phép tính trong vòng lặp

Giả sử bạn cần viết một method để convert một mảng vào trong một hash, với giá trị key và value chính là các element trong mảng:

    func([1, 2, 3])  # => {1 => 1, 2 => 2, 3 => 3}

Giải phap sau sẽ cho ta kết quả mong muốn:

    def func(array)
      array.inject({}) { |h, e| h.merge(e => e) }
    end

Với giải thuật trên, chương trình sẽ cực kì chậm với ở trên một dữ liệu lớn, vì nó chứa các method inject và merge lồng nhau, nó có O(n2). Nhưng rõ ràng là nó phải là O(n). Hãy xem tiếp:

    def func(array)
      array.inject({}) { |h, e| h[e] = e; h }
    end

Trong trường hợp này, chúng ta chỉ có 1 vòng lặp và không có bất kì một phép tính nào trong vòng lặp

    require 'benchmark'

    def n_func(array)
      array.inject({}) { |h, e| h[e] = e; h }
    end

    def n2_func(array)
      array.inject({}) { |h, e| h.merge(e => e) }
    end

    BASE_SIZE = 10

    4.times do |factor|
      size   = BASE_SIZE * (10 ** factor)
      params = (0..size).to_a
      puts "_" * 60 + "\nSIZE: #{size}"
      Benchmark.bm(10) do |x|
        x.report("O(n)" ) { n_func(params)  }
        x.report("O(n2)") { n2_func(params) }
      end
    end

Và kết quả là:

        ____________________________________________________________
    SIZE: 10
                    user     system      total        real
    O(n)        0.000000   0.000000   0.000000 (  0.000014)
    O(n2)       0.000000   0.000000   0.000000 (  0.000033)
    ____________________________________________________________
    SIZE: 100
                    user     system      total        real
    O(n)        0.000000   0.000000   0.000000 (  0.000043)
    O(n2)       0.000000   0.000000   0.000000 (  0.001070)
    ____________________________________________________________
    SIZE: 1000
                    user     system      total        real
    O(n)        0.000000   0.000000   0.000000 (  0.000347)
    O(n2)       0.130000   0.000000   0.130000 (  0.127638)
    ____________________________________________________________
    SIZE: 10000
                    user     system      total        real
    O(n)        0.020000   0.000000   0.020000 (  0.019067)
    O(n2)      17.850000   0.080000  17.930000 ( 17.983827)

Đó chỉ là một ví dụ bình thường. Nhưng hãy cố gắng tránh các phép toán, method trong vòng lặp tối đa có thể

Sử dụng !

Method có thêm ! cũng tương tự như method không có !, chỉ khác mỗi việc chúng sẽ không duplicate một object. Hãy cùng xem lại ví dụ merge! lúc trước và thấy kết quả:

    require 'benchmark'

    def merge!(array)
      array.inject({}) { |h, e| h.merge!(e => e) }
    end

    def merge(array)
      array.inject({}) { |h, e| h.merge(e => e) }
    end

    N = 10_000
    array = (0..N).to_a

    Benchmark.bm(10) do |x|
      x.report("merge!") { merge!(array) }
      x.report("merge")  { merge(array)  }
    end
                     user     system      total        real
    merge!       0.010000   0.000000   0.010000 (  0.011370)
    merge       17.710000   0.000000  17.710000 ( 17.840856)

Sử dụng biến instance

Truy cập trực tiếp một biến instance nhanh hơn khoảng 2 lần so với việc truy cập nó thông qua attr_accessor:

    require 'benchmark'

    class Metric
      attr_accessor :var

      def initialize(n)
        @n   = n
        @var = 22
      end

      def run
        Benchmark.bm(10) do |x|
          x.report("@var") { @n.times { @var } }
          x.report("var" ) { @n.times { var  } }
          x.report("@var =")     { @n.times {|i| @var = i     } }
          x.report("self.var =") { @n.times {|i| self.var = i } }
        end
      end
    end

    metric = Metric.new(100_000_000)
    metric.run

Kết quả:

                     user     system      total        real
    @var         6.980000   0.010000   6.990000 (  7.193725)
    var         13.040000   0.000000  13.040000 ( 13.131711)
    @var =       7.960000   0.000000   7.960000 (  8.242603)
    self.var =  14.910000   0.010000  14.920000 ( 15.960125)

Assign các biến song song chậm hơn so với tuần tự

    require 'benchmark'

    N = 10_000_000

    Benchmark.bm(15) do |x|
      x.report('parallel') do
        N.times do
          a, b = 10, 20
        end
      end

      x.report('consequentially') do |x|
        N.times do
          a = 10
          b = 20
        end
      end
    end

Output:

                          user     system      total        real
    parallel          1.900000   0.000000   1.900000 (  1.928063)
    consequentially   0.880000   0.000000   0.880000 (  0.879675)

Định nghĩa method động

Để định nghĩa một method động thì cách nào nhanh hơn?: class_eval hay define_method?

    require 'benchmark'

    class Metric
      N = 1_000_000

      def self.class_eval_with_string
        N.times do |i|
          class_eval(<<-eorb, __FILE__, __LINE__ + 1)
            def smeth_#{i}
              #{i}
            end
          eorb
        end
      end

      def self.with_define_method
        N.times do |i|
          define_method("dmeth_#{i}") do
            i
          end
        end
      end
    end

    Benchmark.bm(22) do |x|
      x.report("class_eval with string") { Metric.class_eval_with_string }
      x.report("define_method")          { Metric.with_define_method     }

      metric = Metric.new
      x.report("string method")  { Metric::N.times { metric.smeth_1 } }
      x.report("dynamic method") { Metric::N.times { metric.dmeth_1 } }
    end

Output:

                                 user     system      total        real
    class_eval with string 219.840000   0.720000 220.560000 (221.933074)
    define_method           61.280000   0.240000  61.520000 ( 62.070911)
    string method            0.110000   0.000000   0.110000 (  0.111433)
    dynamic method           0.150000   0.000000   0.150000 (  0.156537)

class_eval làm việc chậm hơn, nhưng nó được ưa thích và sử dụng nhiều vì các method được sinh ra từ class_eval lại cho tốc độ nhanh hơn

Thanks for read!