Elixir vs Ruby Showdown - Part One

We’ve taken a huge interest in Elixir here at the Littlelines office this year. I gave a 3.5 hour into to Elixir workshop at RailsConf in April, and have been busy building Phoenix, an Elixir Web Framework. Earlier this week, I put together Linguist, an Elixir internationalization library and was shocked at how little code it required after taking a look at the Ruby implementation. By using Elixir’s metaprogramming facilities, I was able to define function heads that pattern match on each I18n key. This approach simply generates a function per I18n key, whose function body returns the translation with any required interpolation. Let’s see it in action.

tl;dr The Elixir implementation is 73x faster than Ruby’s i18n gem

edit Joel Vanderwerf put together a Ruby implementation in response to this post that runs in 3.5s, making the Elixir implementaiton 2.18x as fast. gist

defmodule I18n do
  use Linguist.Compiler, locales: [en: [
    foo: "bar",
    flash: [
      notice: [
        alert: "Alert!",
        hello: "hello %{first} %{last}",
      ]
    ]
  ]]
end

iex> I18n.t("en", "flash.notice.alert")
"Alert!"
iex> I18n.t("en", "flash.notice.hello", first: "chris", last: "mccord")
"hello chris mccord"

By calling use Linguist.Compiler, the above code would expand at compile time to:

defmodule I18n do
  def t("en", "foo") do
    t("en", "foo", [])
  end
  def t("en", "foo", bindings) do
    "bar"
  end

  def t("en", "flash.notice.alert") do
    t("en", "flash.notice.alert", [])
  end
  def t("en", "flash.notice.alert", bindings) do
    "Alert!"
  end

  def t("en", "flash.notice.hello") do
    t("en", "flash.notice.hello", [])
  end
  def t("en", "flash.notice.hello", bindings) do
    ((("hello " <> Dict.fetch!(bindings, :first)) <> " ") <> Dict.fetch!(bindings, :last)) <> ""
  end
end

Notice in the last function definition, the interpolation is handled entirely by string contcatenation instead of relying on regex splitting/replacement at runtime. This level of optimization isn’t possible in our Ruby code.

Ruby’s implementation requires a complex algorithm to split the I18n keys into a Hash to allow performant lookup at runtime. Since our Elixir implementation just produces function definitions, we let the Erlang Virtual Machine’s highly optimized pattern matching engine take over to lookup the I18n value. The result is strikingly less code for equivalent functionality. Not only do we get less code, we also get a 77x speed improvement over Ruby 2.1.0. Here’s a few benchmarks we ran to see how the Elixir implementation compared to Ruby:

Elixir:

defmodule Benchmark do

  defmodule I18n do
    use Linguist.Compiler, locales: [
      en: [
        foo: "bar",
        flash: [
          notice: [
            alert: "Alert!",
            hello: "hello %{first} %{last}",
            bye: "bye now, %{name}!"
          ]
        ],
        users: [
          title: "Users",
          profiles: [
            title: "Profiles",
          ]
        ]
      ]
    ]]
  end

  def measure(func) do
    func
    |> :timer.tc
    |> elem(0)
    |> Kernel./(1_000_000)
  end

  def run do
    measure fn ->
      Enum.each 1..1_000_000, fn _->
        I18n.t("en", "foo")
        I18n.t("en", "users.profiles.title")
        I18n.t("en", "flash.notice.hello", first: "chris", last: "mccord")
        I18n.t("en", "flash.notice.hello", first: "john", last: "doe")
      end
    end
  end
end

iex> Benchmark.run
1.62

Ruby:

en:
  foo: "bar"
  flash:
    notice:
      alert: "Alert!"
      hello: "hello %{first} %{last}"
      bye: "bye now %{name}!"
  users:
    title: "Users"
    profiles:
      title: "Profiles"
class Benchmarker

  def self.run
    Benchmark.measure do
      1_000_000.times do |i|
        I18n.t("foo")
        I18n.t("users.profiles.title")
        I18n.t("flash.notice.hello", first: "chris", last: "mccord")
        I18n.t("flash.notice.hello", first: "john", last: "doe")
      end
    end.real
  end
end

irb> Benchmarker.run
118.58

Benchmark Results *

  • Elixir (0.14.1) Average across 10 runs: 1.63s
  • Ruby (MRI 2.1.0) Average across 10 runs: 118.62s

That’s a 77x speed improvement for Elixir over Ruby, with the same top-level API! With careful use of metaprogramming, we were able to produce a clean implementation with compile-time optimized lookup. Elixir provides metapgramming abilities well beyond what we can dream up as Rubyists. Here’s a few resources to learn more:

*never trust benchmark results, always measure yourself

Chris