Another Cool Reason Ruby is Slow: implicit super

Posted by -

Edit (3/24/2011 4:38pm): Replaced the farcical (@count ||= 0) += 1 with the 2-line version of @count ||= 0; @count += 1.

Background

One is always learning new reasons why Ruby is slow. We know a lot of the easy reasons, like dynamic dispatch overhead, lack of type information, and the overall everything-is-dynamicness. More interestingly are the subtle features that we often miss or overlook that are just doomed to be slower, in order to offer more flexibility. Ruby's designers have often chosen the more flexible option.

Example: Default Arguments

The easy example has a direct contrast with Python: default arguments must be evaluated every time they are required. This program, in Ruby:

  def count
    @count ||= 0
    @count += 1
  end
  def foo(x = count())
    p x
  end
  3.times { foo }

Outputs:

1
2
3

In python, this code (not at all idiomatic, I know):

  global amt
  amt = 0
  def count():
    global amt
    amt += 1
    return amt

  def foo(x = count()):
    print x

  foo()
  foo()
  foo()

Outputs:

1
1
1

Ruby has a new flexibility here, and Python is more limited, but Python only has to evaluate its default argument once. The restriction on default arguments leads to a near-trivial performance boost. It's on the Ruby implementors to try to recover the lost speed in default arguments. One of the goals of Laser is to determine what variables are constants and what methods are pure functions, which could help implementors optimize certain default arguments.

Implicit Super

Anyway, the main focus of this post is a new oddity like this I've come across: modifying arguments then calling implicit super.

  class A
    def foo(*args)
      p args
    end
  end
  class B < A
    def foo(a, b=3, *args, z)
      a = 10
      b = 20
      args = args[0..-2]
      super
    end
  end

  B.new.foo('a', 'b', 'c', 'd', 'e')

What gets printed? Why, it's obvious:

[10, 20, 'c', 'e']

If you modify the arguments to a method and then call super, those arguments have to be evaluated, re-splatted (if there is a rest argument), and then the superclass method can be called. In the above example, the rest argument was modified and the arity of the super call is different then the arity of the current call.

Here's another time when the Ruby designers chose a more flexible, possibly questionable implementation requirement that makes optimization harder. If implicit super used the arguments to the method as they were passed in, an implementation could cache the arguments on method entry and pass them right along at the call to super.

Laser can statically distinguish between implicit super calls with potentially modified arguments and implicit super calls that will always use the same arguments as were passed to the enclosing method. With a control flow graph, one merely examines all predecessors to the basic block ending at the call to super, and if any of those blocks contains an assignment to an argument, or an argument has a mutating method call performed on it, then the arguments cannot be cached on method entry.

I will be posting, later in the week, an online utility here at carbonica that will show the CFG for input code. It will be buggy and not work on all inputs, but I will provide interesting examples that it does work for.

Super-fine details!

Another subtlety to note: Ruby follows the principle of least surprise by always looking up the value of the actual argument binding. Different bindings with the same name do not affect super, so the following foo and foo_switch methods are identical:

  class B
    def foo(a, b=3, *args, z)
      super
    end

    def foo_switch(a, b=3, *args, z)
      [:j, 'k', @l].tap { |args| super }
    end
  end

Enjoy this article? Then feel free to:


Comments