Elixir Performance – Using IO Data Lists

After watching Johanna Larsson's ElixirConf 2019 talk, High Performance String Processing Scripts in Elixir, I was inspired to work on some of my side projects to make them use IO lists instead of plain strings.

Starting Point

My side projects revolve around text based games, namely MUDs, so I have a lot of string processing built into them. I always knew that IO lists should be quicker than interpolating into strings, but never got around to using it. After watching Johanna's talk though, I decided to give it a shot and upgrade my newer codebase Spigot to use IO lists.

Spigot is an engine/test game I started after realizing I didn't really like the layout ExVenture has for processing commands and rendering back text. I took a Phoenix-like approach for Spigot, so it has specific View modules to contain all of the text and rendering code. View modules render plain strings or EEx that is resolved into strings, so this was the main set of code that I needed to fix.

Enter Macros

My first attempt at using IO lists was creating a new macro that looked like a string sigil but handled interpolation by breaking up individual strings and variables into a list. This turned out to be pretty easy except for one gotcha, you need to unescape the macro version of a string if it contains things like a newline. Below is the full sigil that returns IO lists instead of a single interpolated string:

@doc """
Creates ~i to create IO lists that look like standard interpolation
"""
defmacro sigil_i({:<<>>, _, text}, _) do
  Enum.map(text, &sigil_i_unwrap/1)
end

defp sigil_i_unwrap({:"::", _, interpolation}) do
  [text | _] = interpolation
  {_, _, text} = text
  text
end

defp sigil_i_unwrap(text) when is_binary(text) do
  :elixir_interpolation.unescape_chars(text)
end

The next step was upgrading my EEx engine to return IO lists instead of strings as the default engine does. For this, I looked to Phoenix as templates in Phoenix return IO lists for speed in controllers.

This was pretty simple and mostly a copy paste plus removal of some extra features that Phoenix uses or keeps track of. The referenced module in the updated sigil code to handle EEx parsing is on GitHub.

@doc """
Creates ~E which runs through EEx templating
"""
defmacro sigil_E({:<<>>, _, [expr]}, _opts) do
  EEx.compile_string(expr, 
    line: __CALLER__.line + 1,
    engine: Engine.View.EExEngine,
    trim: true
  )
end

Returning IO lists

Once text is being rendered as IO lists, the only spot I had to update was the TCP connection code. You can easily return IO lists in the place of strings and Erlang will handle this as if it was a string. This was working except for the fact that I was already expecting a list of lines of text or events to be sent to the process handling the TCP connection.

The only real change here was defaulting to expecting a list by wrapping anything the process was supposed to send back and assuming the data contained within would be an event struct or an IO list.

See affected lines in these GitHub links.

Results

Once Spigot was rendering IO lists instead of strings, I did some bench marks to see how much faster it potentially was at rendering text. The results surprised me, as I didn't expect even the very simple interpolation to be that much slower! Below are the two versions and the benchmark from Benchee. See the full code and results in this Gist.

# render a simple IO list
def render("iodata", %{name: name}) do
  ~i(Hello, #{name})
end

# render a simple string
def render("string", %{name: name}) do
  ~s(Hello, #{name})
end
Name         ips      average  deviation       median         99th %
iodata    6.20 M    161.33 ns  ±4226.50%       0 ns        1000 ns
string    3.68 M    271.40 ns ±11207.73%       0 ns        1000 ns

Comparison:
iodata        6.20 M
string        3.68 M - 1.68x slower +110.08 ns

With these results in hand, I am looking forward to continuing with Spigot and making it an engine I can later on drop into ExVenture and potentially push past my previous limit of 7,000 players on a single machine. With any luck I'll be able to keep my 1.6x result and pass 10,000 players!

Photo by Susan Yin on Unsplash