Yehuda Katz is a member of the Ember.js, Ruby on Rails and jQuery Core Teams; his 9-to-5 home is at the startup he founded, Tilde Inc.. There he works on Skylight, the smart profiler for Rails, and does Ember.js consulting. He is best known for his open source work, which also includes Thor and Handlebars. He travels the world doing open source evangelism and web standards work.

Status Update — A Fresh Look at Callbacks

After I finished the first stage of the ActionView refactor I was working on (I’m currently working on some small issues with Josh and getting ready to merge it into the official Rails3 branch), I decided to take a fresh look at the performance numbers. As expected, by initial refactor did not really improve performance (it wasn’t intended to — I mainly just moved things around and cleaned up the code path), but one thing that stuck out at me was how expensive the callback system is.

This is the case in both Rails and Merb, although Merb’s smaller callback feature set makes it somewhat faster than Rails’ at present (but not significantly so; both systems use the same basic approach). So I decided to spend some time today trying to make the callbacks faster. The first thing I noticed was that the ActiveSupport callbacks are fairly limited (for instance, missing around filters), and that ActionPack uses a small part of the ActiveSupport system and then layers on scads of additional functionality.

After spending a few hours trying to improve the performance of the callbacks, I remembered that Carl had considered an interesting approach for the Merb callbacks that involved dispensing with iteration in favor of a single compiled method that inlined the filters. In his original experiments (which supported before, after, and around filters, but not conditions), he was able to make filters run about an order of magnitude faster than Merb’s system.

Effectively, the approach boils down to:

before_filter :foo
after_filter :bar
around_filter :baz

def _run_hookz
  foo
  baz do
    yield
    baz
  end
end 

This completely removes the need for iteration at runtime, and compiles down the hooks into a single, speedy method. What complicated matters a bit was:

  • Rails’ support for conditions (also supported in Merb, but not in Carl’s original prototype)
  • Rails’ support for various filter types, including strings (which get evalled), procs, blocks, and arbitrary objects

After playing around a bit, I managed to support conditional filters as well as all the supported Rails filter types using the speedier callback system.I compared the ActionPack filtering system to the old ActiveSupport callbacks to the new callbacks. According to my benchmarks, the new system is 15-20x faster than the old one. Here are the actual benchmarks for 100,000 iterations (2 before filters, 1 after filter with a proc conditions).

      JRuby Results |
---------------------
actionpack    2.406 |
old           1.798 |
new           0.089 |
  MRI (1.8) Results |
---------------------
actionpack    4.190 |
old           3.063 |
new           0.276 |
  MRI (1.9) Results |
---------------------
actionpack    2.617 |
old           2.137 |
new           0.157 |

Keep in mind that I haven’t retrofitted the ActionPack filters to use the new callback system yet; I had to make ActiveSupport::Callbacks support around_filters first, but that there shouldn’t be any reason it won’t work. Also, adding around_filter support to ActiveSupport::Callbacks means that Test::Unit will now have around filters for free as part of the retrofit (this should affect ActiveRecord as well, for anyone who needs around filters in AR’s hooks).

Also, and this is a big caveat: this is most certainly not an optimization that is likely to significantly help most apps where the overhead is in database access or rendering. However, one of my personal goals for the work I’m doing is to reduce the need for things like Rack Metal by reducing the overhead of rendering a request through the full stack. Filters are used throughout the render path and we shockingly using 10-20% of the 4ms a render :text => “Hello world” was taking up. I was surprised that the action itself was only about 7% of the default render path.

So again, shaving off under 1ms is not likely to help many apps, but it is a step in the direction of making it possible to build high-performance APIs and service tiers on top of the full Rails request cycle. You can follow along this callback work on my optimize_callbacks branch.

P.S. Merb 1.0.8 should be released tomorrow. It’ll include a bunch of pending fixes (including a fix for PIDs being overwritten in production when using merb -i)

12 Responses to “Status Update — A Fresh Look at Callbacks”

“So again, shaving off under 1ms is not likely to help many apps, but it is a step in the direction of making it possible to build high-performance APIs and service tiers on top of the full Rails request cycle.”

milliseconds also add up pretty quickly, if today you shaved off 3-4ms and made the internals cleaner, that’s an overall huge gain.

– Matt

Matt:

The entire Rails request cycle is around 4ms. This may have shaved off 1/2ms, which isn’t going to be that big a deal for any app, but like I said, a few more of these optz and the full Rails request cycle will moot Metal. That’s the idea here :)

okay, this might sound like a stupid question, but as I’m a novice I dare to ask: will we be able to use before/after/around_filters for ActiveRecord::Base classes too? More than once I was desperately searching for solutions to use before filters for various getters and setters, including collections (e.g. triggering a complex calculation when either the items in a collection of an object or various attributes are changed) – aka something other than the current AR callbacks such as before_save.

Oh, and yes, I know there are association callbacks, but they are very limited (as e.g. there’s no way to call a method only once no matter how many objects you’ve added and removed from the association).

I recently got under-the-hood with Extlib::Hook. It does a very similar thing, I think, to what you discribe. It turns blocks into methods, then rewrites the filtered method to call the filter methods in-line. What is different in what you have done?

I know that DataMapper uses Extlib for for its filters, because it was a bug from datamapper that I was trying to solve. Doesn’t Merb use the same library for its filters?

Do you have any sense of how the Extlib version might compare to what you have done?

Thanks again for all the work!

@antares trader: the Extlib version doesn’t support around filters or conditionals.

A side effect of this might be to even more obfuscate debugging sadly. Walking through the callback chain has always been a pain, but now we’ll have one more opaque “eval”.

Maybe a solution could be to store the string for the method (http://github.com/wycats/rails/commit/95799d8f704db186c518330c386627ed726e1ab7#L2R104) when debugging so that it can be printed out.

Nice insight into the refactoring process :)

On a side note, in ‘_run_hookz’, shouldn’t the ‘baz’ after yielding be ‘bar’ ?

Gaspard: This initial refactoring uses fairly opaque names for everything, because it was convenient that way. Once everything works with all the various callbacks in the system, I plan to go back and figure out how to make it easier to follow (by using much better names and also making it possible to see the output).

Any chance this speedup can be introduced to ActiveRecord, specifically after_initialize and after_find? Both of those are just unbelievably poor performers.

Leave a Reply

Archives

Categories

Meta