“DCI” in Ruby is completely broken
Rubyists are people who generally value elegance over performance. “CPU time is cheaper than developer time!” is a mantra Rubyists have repeated for years. Performance has almost always taken a second seat to producing beautiful code, to the point that Rubyists chose what used to be (but is no longer) the slowest programming language on earth in order to get things done.
You can file me under the “somewhat agree” category, or otherwise I’d be using languages with a better performance track record like Java, Scala, or even Clojure. That said, I like Ruby, and for my purposes it has been fast enough. I also like to make light of those who would sacrifice elegance for speed, at least in Ruby, calling out those who would do silly stuff “FOR SPEED!” while compromising code clarity (otherwise known as roflscaling).
The past year though, there’s been an idea creeping through the Ruby community whose performance implications are so pathological I think it needs to die now. That idea is “DCI”, which stands for Data, Context, and Interaction. The DCI Architecture paper makes the following claims:
Imagine that we might use something like delegation or mix-ins or Aspects. (In fact each of these approaches has at least minor problems and we’ll use something else instead, but the solution is nonetheless reminiscent of all of these existing techniques.)
…
In many ways DCI reflects a mix-in style strategy, though mix-ins themselves lack the dynamics that we find in Context semantics.
DCI, as envisioned by its creators, is something distinct from mix-ins or delegation, however it seems in Ruby these are the two ways that people have chosen to implement it. This makes me believe that the way Rubyists are attempting to implement DCI does not live up to the original idea, but that’s a subject for a different blog post.
Far and away, the main pattern we see described as “DCI” in Ruby works by using a mix-in on an individual instance. This pattern is what I will be referring to from now on as “DCI”:
class ThisBlogContext
def initialize(rubyist)
rubyist.extend(Fool)
end
end
Let’s look at what’s happening here. First, we’re mixing the “Fool” module into the metaclass of the “rubyist” object. Since this is an instance-specific modification, unless “rubyist” already has an instance-specific metaclass, the Ruby VM needs to allocate a new one which will exist for the lifecycle of this object. Okay, so we’re allocating more memory than we otherwise would. That doesn’t seem too bad, does it?
Well, unfortunately there’s something far more sinister going on here inside the depths of any Ruby VM you happen to be using. The main thing we’re doing here is modifying the class hierarchy at runtime. Depending on when this is occurring, this can have very, very bad non-local effects. But before I get into that, let’s look at some benchmarks:
NOTE: you will need the benchmark-ips gem for this snippet
require 'rubygems'
require 'benchmark/ips'
class ExampleClass
def foo; 42; end
end
module ExampleMixin
def foo; 43; end
end
Benchmark.ips do |bm|
bm.report("without dci") { ExampleClass.new.foo }
bm.report("with dci") do
obj = ExampleClass.new
obj.extend(ExampleMixin)
obj.foo
end
end
And the results:
Using DCI is about an order of magnitude slower (or in the case of Rubinius, four orders of magnitude slower) than simply instantiating an object. Okay, so DCI is slow, right? Big deal, plenty of things are slow. But should we really care? The actual bottleneck here is going to be talking to the database or something, right? Actually, something far more sinister is going on here…
What if I were to tell you that the performance impact you’re seeing here wasn’t just localized to the little snippet we’re microbenchmarking, but is in fact having non-local effects that are causing similar performance degradations throughout your Ruby application? Scared now?
This is exactly what’s happening. All Ruby VMs use method caches to improve dispatch speed. They can, for example, cache which method to use based on the types flowing through a particular call site. These caches remain valid so long as we don’t see new types and the class hierarchy doesn’t change.
Unfortunately, what this approach to DCI is doing by using an instance-specific mixin is making modifications to the class hierarchy at runtime, and by doing so, it’s busting method caches throughout your application. By busting these caches everywhere, the performance effects you see aren’t localized just to where you’re using DCI. You’re taking a pathological performance hit every time you use obj.extend(Mixin)
.
This becomes especially problematic if you’re performing these sorts of runtime mixins every time you handle a request (or worse, multiple times per request). By doing so, you are preventing these caches from ever filling, and forcing the VM to dispatch methods in the most pathological way possible every time you use this feature.
Ruby gives you a lot of expressive power, but with great power comes great responsibility. My advice to you is to completely avoid using any of Ruby’s dynamic features which alter the class hierarchy after your application has loaded. They’re great to use when loading your application, but once your app has been loaded, you should really avoid doing anything that creates instance-specific metaclasses. This isn’t just limited to extend
on objects but also includes things like doing def
within a def
, def obj.method
, class << obj
then making modifications, or define_method
.
What’s a better approach to doing something like DCI that doesn’t blow your method cache? Delegation. Evan Light blogged over a year ago about DCI that respects the method cache using SimpleDelegator. In addition to respecting your method cache, I personally also find this approach a lot cleaner.