The goal of this workshop is to highlight how you can archive better performance of your Ruby applications. We can choose different ways of writing code, but which one works faster?

I did some benchmarks the other day, so just want to share results with you.

Don’t create unnecessary objects, use merge!

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

RESULT (ruby 2.3.0):

                 user     system      total        real
merge!       0.010000   0.000000   0.010000 (  0.007616)
merge       18.380000   0.740000  19.120000 ( 19.143505)

Be careful with calculation within iterators

Try to avoid nested methods. In n_func we do only one iteration over an array without any hard calculation within the iterator

require 'benchmark'

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

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

def n2_func(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("O(n)") { n_func(array) }
  x.report("O(n)merge!") { merge_bang_func(array) }
  x.report("O(n2)") { n2_func(array) }
end

RESULT (ruby 2.3.0):

                 user     system      total        real
O(n)         0.000000   0.000000   0.000000 (  0.002110)
O(n)merge!   0.010000   0.000000   0.010000 (  0.007396)
O(n2)       19.000000   0.760000  19.760000 ( 19.794764)

Use DateTime instead of Time

Try to avoid active_support, it will slow down your calculations significantly.

require 'benchmark'
require 'date'
require 'active_support/core_ext/date/calculations'
require 'active_support/core_ext/time/calculations'

Benchmark.bm(17) do |bm|
  bm.report('DateTime:') do
    n1 = DateTime.now
    n2 = DateTime.now
    1_000_000.times { n1 < n2 }
  end
  bm.report('Time:    ') do
    n1 = Time.now
    n2 = Time.now
    1_000_000.times { n1 < n2 }
  end

  puts DateTime.parse('2012-10-10 10:10 +0300')
  bm.report('DateTime parse:') do
    1_000_000.times { DateTime.parse('2012-10-10 10:10 +0300') }
  end

  puts Time.parse('2012-10-10 10:10 +0300')
  puts Time.zone.parse('2012-10-10 10:10 +0300')
  Time.zone = 'EET'
  puts Time.zone.parse('2012-10-10 10:10 +0300')
  bm.report('Time:    parse:') do
    1_000_000.times { Time.zone.parse('2012-10-10 10:10 +0300') }
  end
end

RESULT (ruby 1.9.2-p330):

      user     system      total        real
DateTime:  4.980000   0.020000   5.000000 (  5.063963)
Time:      0.330000   0.000000   0.330000 (  0.335913)

Nowadays it is faster…

RESULT (ruby 2.3.0):

       user     system      total        real
DateTime:  0.150000   0.000000   0.150000 (  0.151570)
Time:      0.170000   0.000000   0.170000 (  0.169885)
DateTime parse: 20.710000   0.060000  20.770000 ( 20.779141)
Time     parse: Not implemented

After requiring active support

RESULT (ruby 2.3.0):

       user     system      total        real
DateTime:        0.500000   0.010000   0.510000 (  0.503089)
Time:            1.730000   0.000000   1.730000 (  1.749615)
DateTime parse: 25.490000   0.100000  25.590000 ( 25.613761)
Time:    parse:110.320000   0.260000 110.580000 (110.743532)

Avoid using += to concatenate strings in favor of << method

require 'benchmark'

N = 1_000
LENGTH = 10_000

Benchmark.bm(15) do |x|
  x.report("+=") do
    str1 = ""
    str2 = "s" * LENGTH
    N.times { str1 += str2 }
  end

  x.report("interpolation") do
    str1 = "s"
    str2 = "s" * LENGTH
    N.times { str1 = "#{str1}#{str2}" }
  end

  x.report("<<") do
    str1 = "s"
    str2 = "s" * LENGTH
    N.times { str1 << str2 }
  end
end
str1 = "first"
str2 = "second"
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

RESULT (ruby 2.3.0):

                      user     system      total        real
+=                1.310000   2.060000   3.370000 (  3.374704)
interpolation     0.780000   0.900000   1.680000 (  1.677559)
<<                0.000000   0.000000   0.000000 (  0.004387)

class_eval

Try to avoid this, but if you can not, this section for you. class_eval works slower but it’s preferred since methods generated, as generated methods work faster.

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

class_eval works slower but it’s preferred since methods generated with class_eval and a string of code work faster

RESULT (ruby 2.3.0):

                             user     system      total        real
class_eval with string  23.730000   0.510000  24.240000 ( 24.301654)
define_method            9.280000   0.210000   9.490000 (  9.510544)
string method            0.070000   0.000000   0.070000 (  0.071356)
dynamic method           0.120000   0.000000   0.120000 (  0.128129)

Use detect

require 'benchmark'

ARRAY = (1..100_000_000).to_a

Benchmark.bm(20) do |x|
  x.report("Enumerable#select.first") { ARRAY.select { |x| x.eql?(15) }.first }
  x.report("Enumerable#detect")   { ARRAY.detect { |x| x.eql?(15) } }
  x.report("Enumerable#select.first") { ARRAY.select { |x| x.eql?(15_000_000) }.first }
  x.report("Enumerable#detect")   { ARRAY.detect { |x| x.eql?(15_000_000) } }
end

RESULT (ruby 2.3.0):

                           user     system      total        real
Enumerable#select.first  9.480000   0.020000   9.500000 (  9.521282)
Enumerable#detect      0.000000   0.000000   0.000000 (  0.000019)

In the middle of the Array still fast enough.

                           user     system      total        real
Enumerable#select.first  9.670000   0.040000   9.710000 (  9.759445)
Enumerable#detect      1.730000   0.010000   1.740000 (  1.751693)

Do not use exceptions for a control flow

require 'benchmark'

class Obj
  def with_condition
    return unless respond_to?(:mythical_method)

    self.mythical_method
  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

RESULT (ruby 2.3.0):

                      user     system      total        real
rescue:          24.180000   0.150000  24.330000 ( 24.389499)
condition:        1.410000   0.000000   1.410000 (  1.415949)
rescue/condition 17.148936        Inf        NaN ( 17.224849)

Be simple, use Hash or Struct

require 'benchmark'
require 'ostruct'
require 'hashie'

N = 1_000_000

Benchmark.bm(25) do |x|
  x.report("Hash")         { N.times { { field_1: 1, field_2: 2 } } }
  s = Struct.new(:field_1, :field_2);
  x.report("Struct")       { N.times { s.new(1, 2) } }
  x.report("OpenStruct")   { N.times { OpenStruct.new(field_1: 1, field_2: 2) } }
  x.report("Hashie::Mash") { N.times { Hashie::Mash.new(field_1: 1, field_2: 2) } }
  x.report("Hash create/read")         { N.times { o = { field_1: 1, field_2: 2 }; o.values } }
  x.report("Struct create/read")       { N.times { o = s.new(1, 2); [o.field_1, o.field_2] } }
  x.report("OpenStruct create/read")   { N.times { o = OpenStruct.new(field_1: 1, field_2: 2); [o.field_1, o.field_2] } }
  x.report("Hashie::Mash create/read") { N.times { o = Hashie::Mash.new(field_1: 1, field_2: 2); [o.field_1, o.field_2] } }
end

RESULT (ruby 2.3.0):

                                user     system      total        real
Hash                        0.560000   0.020000   0.580000 (  0.585226)
Struct                      0.190000   0.000000   0.190000 (  0.183596)
OpenStruct                  1.600000   0.020000   1.620000 (  1.624675)
Hashie::Mash                3.950000   0.020000   3.970000 (  3.984425)
Hash create/read            0.620000   0.010000   0.630000 (  0.632089)
Struct create/read          0.270000   0.000000   0.270000 (  0.274975)
OpenStruct create/read      5.910000   0.010000   5.920000 (  5.925728)
Hashie::Mash create/read    6.050000   0.020000   6.070000 (  6.072480)

Parallel assignment is slower

require 'benchmark'

N = 10_000_000

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

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

RESULT (ruby 2.3.0):

                      user     system      total        real
parallel          2.660000   0.020000   2.680000 (  2.698620)
consequentially   0.750000   0.010000   0.760000 (  0.759135)

Source

git checkout git@github.com:amalkov/ruby-performance-tests.git

Thanks

Big thanks to Ruby Performance Tricks and Array argument vs splat arguments

Basically I decided to create some tests and rerun some other tests from those articles on newest ruby versions.