5-Minute Read

Ruby meta programming performance

In 2015, Pragtob wrote a blog post about Ruby meta programming. Well, you may wonder why I’m talking about a blog that wrote five years ago while the computer science is progressing at ever-increasing speed. I searched for it, because in work we were hit by this problem.

In work we use Cocoapods to manage dependency. In a project with few pods you won’t notice the speed of Cocoapods. But what if, let’s say, 600+ pods? It can take half a minute just for Cocoapods to generate XCode Project files! We managed to narrow down the performance issue to this very Ruby file. On the surface, it’s pretty harmless. There is a helper method that can generate other methods using meta programming, saving the hassle to manually write things like sorting results, memorizing… However, there are also some methods that merely return a constant string but also use this helper. Methods defined using define_method have small overhead, but when the method get called tens of thousands of times, the overhead is big enough to use some monkey patching to optimize it. I monkey patched those methods that use define_method but simply return a constant string. Re-define those methods with plain def resulted in a 5s optimization. 5s may not sound like much, but in programming world, it’s like 3 years!

Normally, stories end here. However, I decided to dig a little deeper. You see, I can squeeze 5s from just re-define methods with def. Can’t Ruby interpreter do something similar in the first place? I peeked into Ruby interpreter source code and found out that methods defined with plain def and methods that defined with define_method indeed treated differently. For def, source code is here. And the define_method source code is here. The major difference is the type of def functions are VM_METHOD_TYPE_CFUNC, while the type of define_method is VM_METHOD_TYPE_BMETHOD. Unfortunately, I failed to discover why VM_METHOD_TYPE_CFUNC is faster than VM_METHOD_TYPE_BMETHOD, nor did I know why they’re of different type in the first place. A quick search on the net doesn’t help anything as well. So I just gave up on this matter. However, I did know more about Ruby through the process. I now know the default class is kernel, everything you defined outside any class goes into kernel. And the class type is actually… a class too! The reason why symbols are Ruby is unique is that rb_intern converts string to internal ID type, same string converted to same ID. And Ruby maintains a central function table to determine which function you’re calling, function visibility and the class it belonged to are stored with the function itself.


Well, if you’re interested the overhead in Ruby 3.0, 5 years after Pragtob’s post. Here is the code to test it. You’ll need benchmark-ips gem to run it.

require 'benchmark/ips'
require 'set'
class BuildSettings
  def self.define_method_cocoapods(method_name, build_setting: false, &implementation)
    (@build_settings_names ||= Set.new) << method_name.to_s.upcase if build_setting
    raw_method_name = :"_raw_#{method_name}"
    define_method(raw_method_name, &implementation)
    define_method(method_name) do
      retval = send(raw_method_name)

  define_method_cocoapods :meta_fn, build_setting: true do
    'A simple string'

  def direct_fn
    'A simple string'

  define_method_cocoapods :meta_complex_fn, build_setting: true do
    10.times do
      foo = 'String'

  def direct_complex_fn
    10.times do
      foo = 'String'
Benchmark.ips do |x|
  build_settings = BuildSettings.new
  x.report('direct') { build_settings.direct_fn }
  x.report('meta') { build_settings.meta_fn }
  x.report('complex meta') { build_settings.meta_complex_fn }
  x.report('complex direct') { build_settings.direct_complex_fn }

And on my computer, the result is:

Warming up --------------------------------------
              direct     1.020M i/100ms
                meta   436.276k i/100ms
        complex meta   118.988k i/100ms
      complex direct   143.041k i/100ms
Calculating -------------------------------------
              direct     10.511M (± 3.3%) i/s -     53.022M in   5.050267s
                meta      4.372M (± 3.0%) i/s -     22.250M in   5.093786s
        complex meta      1.196M (± 2.6%) i/s -      6.068M in   5.079384s
      complex direct      1.458M (± 2.1%) i/s -      7.295M in   5.005728s

              direct: 10511119.1 i/s
                meta:  4372098.4 i/s - 2.40x  (± 0.00) slower
      complex direct:  1457994.7 i/s - 7.21x  (± 0.00) slower
        complex meta:  1195505.1 i/s - 8.79x  (± 0.00) slower

Not much has progressed since 2015?

Not quite so, we didn’t have TruffleRuby back in 2015! And on TruffleRuby define_method and def have same performance! Run this test through TruffleRuby produced this output:

Warming up --------------------------------------
              direct   317.071M i/100ms
                meta   334.676M i/100ms
        complex meta   328.865M i/100ms
      complex direct   331.058M i/100ms
Calculating -------------------------------------
              direct      3.306B (± 4.9%) i/s -     16.488B in   5.001178s
                meta      3.305B (± 3.2%) i/s -     16.734B in   5.068132s
        complex meta      3.267B (± 3.6%) i/s -     16.443B in   5.040465s
      complex direct      3.314B (± 3.3%) i/s -     16.553B in   5.000735s

      complex direct: 3313971061.1 i/s
              direct: 3306325701.2 i/s - same-ish: difference falls within error
                meta: 3305189666.5 i/s - same-ish: difference falls within error
        complex meta: 3266924543.9 i/s - same-ish: difference falls within error

And it’s multitude faster than CRuby too! However to my dismay, Cocoapods doesn’t run well enough with TruffleRuby. I guess we’ll never have good things.

For those of you want to find exactly why define_method is slower on CRuby, here are the lines I found relevant during my research:

If you know the reason, please share it over the internet.

Recent Posts



A young developer who loves Linux.