Ruby Optimization with One Magic Comment

2018-02-28

Software performance optimization is simple: find a way to do less. Ruby has a reputation for being slow and, while that impression is a decade out of date, one of the leading offenders has been the garbage collector.

This leads to the question: can we speed up Ruby by creating less garbage? Absolutely!

A String Primer

Ruby has an unfortunate default semantic that all Strings are mutable:

string = "" string << "mike"

This allocates two Strings, "” and “mike”. The first, empty String is then mutated to contain “mike”. However String mutation is quite rare, more common is something like this:

HASH = { "mike" : 123 } def getmike HASH [ "mike" ] # unnecessary garbage here! end

Every invocation of getmike will allocate a new copy of “mike”, which is then immediately thrown away as garbage, but is required because Ruby just treats the String as a method argument which might be mutated inside Hash#[] . So wasteful!

Freeze!

Ruby introduced the freeze concept many years ago to minimize allocation. Calling freeze on an object tells Ruby to treat it as immutable. Now Ruby knows that it can treat “mike” as a constant:

def getmike HASH [ "mike" . freeze ] end

The problem? It makes the code uglier and needs to be called everywhere you declare a String.

Ruby 2.3 introduced a very nice option: each Ruby file can opt into Strings as immutable, meaning all Strings within that file will automatically freeze, with a simple magic comment at the top of the file. This will not allocate an extra String for “mike”.

# frozen_string_literal: true HASH = { "mike" : 123 } def getmike HASH [ "mike" ] end

The Real World

Years ago I added a lot of freeze calls to Sidekiq to minimize its impact on the garbage collector and maximize performance. Last week I removed all those calls and added a frozen_string_literal comment to all Ruby files.

To see the effect, I ran an experiment with frozen_string_literal using Sidekiq’s benchmark script by adding GC.disable and watching the RSS grow. Note how Ruby allows you to enable or disable the feature with a flag:

Disabled

$ RUBYOPT=--disable=frozen-string-literal bundle exec bin/sidekiqload Created 30000 jobs RSS: 105852 Pending: 25749 RSS: 178880 Pending: 21514 RSS: 252804 Pending: 17306 RSS: 326824 Pending: 12987 RSS: 399268 Pending: 8810 RSS: 472620 Pending: 4618 RSS: 547968 Pending: 319 RSS: 553568 Pending: 0 Done

Enabled

$ RUBYOPT=--enable=frozen-string-literal bundle exec bin/sidekiqload Created 30000 jobs RSS: 105824 Pending: 25687 RSS: 174948 Pending: 21700 RSS: 245448 Pending: 17669 RSS: 316848 Pending: 13559 RSS: 388544 Pending: 9447 RSS: 456704 Pending: 5288 RSS: 450552 Pending: 1160 RSS: 457536 Pending: 0 Done

frozen_string_literal reduces the generated garbage by ~100MB or ~20%! Free performance by adding a one line comment.

Conclusion

Gem authors: add # frozen_string_literal: true to the top of all Ruby files in a gem. It gives a free performance improvement to all your users as long as you don’t use String mutation.

Notes