Why ruby's version of `tap` is faster than the one implemented in C?

Viewed 119

Please take a look at this pr https://github.com/ruby/ruby/pull/3281

Rewriting the C version to simple ruby:

  def tap
    yield(self)
    self
  end

made it work faster.

The popular opinion is code written in C is always faster. This method is very simple in both versions, so why is that? What are the mechanisms at work here?

I'm reasing about Ruby's JIT and AFAIU it includes a few extra steps more than the C code would: enter image description here Namely Tokenization and Parsing. Where is this magic coming from?

2 Answers

kernel.rb isn’t a “normal” Ruby file. This is a new feature in the Ruby implementation that allows the devs to write certain parts in Ruby.

The issue introducing it gives more details about the reasons for this feature, but one of them is performance: “There are several features which are slower in C than written in Ruby”. The examples there are exception handling and keyword arguments, but it seems that tap is also faster in C (possibly due to handling blocks in Ruby versus C).

Part of what this feature does is to compile the Ruby code to bytecode at build time and include it in the resulting binary. This means the first three stages in your diagram (tokenize, parse and compile) only happen once when Ruby is built. Running Ruby only involves evaluating the precompiled bytecode.

You might also want to look at the commit where this was added.

NOTE: this is opinion based... a half informed conjuncture.

It's interesting to note that when the logic is in a single language (either Ruby or C), then some compile time (or JIT) optimizations become available, while bridging the two languages makes theses optimizations impossible.

For example, the Ruby .tap method creates a block and passes an object to that block using yield. But if we know the whole logic (during JIT), we could optimize out the extra function calls and perhaps optimize away the creation of a block object...

...however, if two different compilers need to compile the code at different times (compiling the tap function when Ruby is compiled and compiling the block with the JIT), then these optimizations become impossible.

Related