Issue #4917 has been updated by Jay Feldblum.


This minor performance issue becomes a huge problem when NilClass#method_missing is defined, such as in ActiveSupport (https://github.com/rails/rails/blob/master/activesupport/lib/active_support/whiny_nil.rb), which is a popular choice when developing a Rails application.

Using the following line in IRB (1.9.2-p180) for measurement:

    require 'ruby-prof'
    @times = 1_000_000
    @proc = proc { Array(nil) }
    RubyProf::FlatPrinter.new(RubyProf.profile{@times.times(&@proc)}).print

If NilClass#method_missing is not defined, then performance is good:

     %self     total     self     wait    child    calls  name
     39.57      1.69     1.02     0.00     0.67  1000000  Kernel#Array
     34.59      2.59     0.89     0.00     1.69        1  Integer#times
     25.84      0.67     0.67     0.00     0.00  1000000  NilClass#to_a
      0.00      2.59     0.00     0.00     2.59        1  Object#irb_binding

If NilClass#method_missing is defined, such as with

    class NilClass
      def method_missing(name, *args)
      end
    end

then the time taken doubles:

     %self     total     self     wait    child    calls  name
     54.72      3.11     2.20     0.00     0.91  1000000  Kernel#Array
     22.63      4.02     0.91     0.00     3.11        1  Integer#times
     12.16      0.49     0.49     0.00     0.00  1000000  NilClass#to_a
     10.48      0.42     0.42     0.00     0.00  1000000  NilClass#method_missing
      0.00      4.02     0.00     0.00     4.02        1  Object#irb_binding

If NilClass#method_missing is defined, and just calls super:

    class NilClass
      def method_missing(name, *args)
        super
      end
    end

Then the time taken is 30x:

     %self     total     self     wait    child    calls  name
     81.17     46.09    38.52     0.00     7.57  1000000  Kernel#Array
      3.15      3.92     1.50     0.00     2.43  1000000  NoMethodError#initialize
      2.89     47.46     1.37     0.00    46.09        1  Integer#times
      2.65      1.26     1.26     0.00     0.00  1000000  Exception#initialize
      2.46      2.43     1.17     0.00     1.26  1000000  NameError#initialize
      2.33      1.10     1.10     0.00     0.00  1000000  Exception#set_backtrace
      1.19      0.57     0.57     0.00     0.00  1000000  Exception#backtrace
      1.10      0.52     0.52     0.00     0.00  1000000  <Class::BasicObject>#allocate
      1.09      0.52     0.52     0.00     0.00  1000000  NilClass#to_a
      1.00      0.48     0.48     0.00     0.00  1000000  Exception#exception
      0.97      0.46     0.46     0.00     0.00  1000000  Kernel#respond_to_missing?
      0.00     47.46     0.00     0.00    47.46        1  Object#irb_binding

Let alone if NilClass#method_missing is defined as in ActiveSupport to print the error.

Likewise the following progression:

    require 'ruby-prof'
    @times = 1_000_000
    @proc = proc { a, b = nil }
    RubyProf::FlatPrinter.new(RubyProf.profile{@times.times(&@proc)}).print

Without defining NilClass#method_missing:


     %self     total     self     wait    child    calls  name
    100.00      0.80     0.80     0.00     0.00        1  Integer#times
      0.00      0.80     0.00     0.00     0.80        1  Object#irb_binding

With defining NilClass#method_missing to do nothing:

    class NilClass
      def method_missing(name, *args)
      end
    end

then the time taken triples:

     %self     total     self     wait    child    calls  name
     80.87      2.23     1.80     0.00     0.43        1  Integer#times
     19.13      0.43     0.43     0.00     0.00  1000000  NilClass#method_missing
      0.00      2.23     0.00     0.00     2.23        1  Object#irb_binding

With defining NilClass#method_missing to call super:

    class NilClass
      def method_missing(name, *args)
        super
      end
    end

then the time taken multiplies 50x:

     %self     total     self     wait    child    calls  name
     76.30     43.80    37.04     0.00     6.76  1000000  BasicObject#method_missing
      6.36     48.55     3.09     0.00    45.46        1  Integer#times
      3.03      3.94     1.47     0.00     2.47  1000000  NoMethodError#initialize
      2.64      1.28     1.28     0.00     0.00  1000000  Exception#initialize
      2.44      2.47     1.19     0.00     1.28  1000000  NameError#initialize
      2.35      1.14     1.14     0.00     0.00  1000000  Exception#set_backtrace
      2.32     44.92     1.13     0.00    43.80  1000000  NilClass#method_missing
      1.20      0.58     0.58     0.00     0.00  1000000  Exception#backtrace
      1.18      0.57     0.57     0.00     0.00  1000000  <Class::BasicObject>#allocate
      1.11      0.54     0.54     0.00     0.00  1000000  Kernel#respond_to_missing?
      1.06      0.52     0.52     0.00     0.00  1000000  Exception#exception
      0.00     48.55     0.00     0.00    48.55        1  Object#irb_binding

Let alone if NilClass#method_missing is defined as in ActiveSupport to print the error.

So all calls to Kernel#Array and all uses of multiple return values, when the argument is nil or the right-hand side is nil, can cause a large slowdown in Rails development mode.

I am seeing this in particular in Sass (https://github.com/nex3/sass/blob/3.1.1/lib/sass/importers/filesystem.rb#L130), where the call in that line gets called often and usually will return nil. This causes a very noticeable slowdown when developing, where NilClass#method_missing is defined to print the error.

A very quick-and-dirty solution to this problem is simply to not let Ruby try to call method_missing in these cases:

    class NilClass
      alias to_ary to_a
    end

This causes Ruby to call to_ary (which is an alias for to_a) rather than try to call to_ary, fallback to calling method_missing (which is very slow in Rails development), and subsequently call to_a.

When I use this hack, then performance of `Array(nil)` and `a, b = nil` returns to being very fast. That is why I showed it. I am using this hack to make Rails development faster, but it is a hack.

Ruby should check if the argument is nil and should return [], just as NilClass#to_a does, in the definition of Kernel#Array and in whatever code implements `a, b = nil`. Ruby should not try to call to_ary on nil because that relies, for its performance, on NilClass#method_missing being undefined.

----------------------------------------
Feature #4917: NilClass#to_ary
http://redmine.ruby-lang.org/issues/4917

Author: Jay Feldblum
Status: Rejected
Priority: Normal
Assignee: 
Category: 
Target version: 


As a performance improvement in certain cases, nil should respond to to_ary.

Kernel#Array, when passed nil, first tries to send to_ary to nil (which actually ends up calling method_missing on nil) and then tries to send to_a to nil which finally succeeds. When Kernel#Array is used often, for example in library/gem code, this can have a noticeable, if relatively small, negative impact on the overall application performance.

    $ irb

    > RUBY_VERSION
     => "1.9.2"

    > require 'benchmark'
    > def bench(times) Benchmark.bmbm{|x| x.report{times.times(&Proc.new)}} end

    # Let's zero the scale....
    > bench(10_000_000) { }

    # The "before" benchmark....
    > bench(10_000_000) { Array(nil) }

    # An optimization....
    > NilClass.class_eval { alias to_ary to_a }

    # The "after" benchmark....
    > bench(10_000_000) { Array(nil) }
    # Much faster!

    # Let's see how many times method_missing actually gets called....
    > NilClass.class_eval { undef to_ary }
    > class NilClass
    >   alias method_missing_without_hit method_missing
    >   def method_missing(name, *args, &block)
    >     $method_missing_hits += 1
    >     method_missing_without_hit(name, *args, &block)
    >   end
    > end

    > $method_missing_hits = 0
    > bench(100_000) { Array(nil) }
    # Very slow!
    > $method_missing_hits
     => 200005

    > NilClass.class_eval { alias to_ary to_a }
    > $method_missing_hits = 0
    > bench(100_000) { Array(nil) }
    # Instantaneous!
    > $method_missing_hits
     => 0



-- 
http://redmine.ruby-lang.org