String manipulation micro optimisation using IO lists in Elixir
Replace string concatenation and interpolation with IO lists to improve performance.
Elixir
Why IO lists
Nathan Long recently published Elixir and IO Lists, Part 1: Building Output Efficiently where he described using IO lists to make your own code more efficient.
He gives an example of using Enum.map
to build an IO list.
The repeated strings are created only once in memory in this example. Building the same string using interpolation ("<li>#{user.name}</li>"
) or concatenation ("<li>" <> user.name <> "</li>"
) would require additional string allocations and copying of data. It would be far less memory efficient, creating unnecessary work for the garbage collector.
Usage
Immediately I thought of a use for IO lists in one of my own open-source Elixir projects. I’m building an EventStore using PostgreSQL for persistence. Building the SQL statement to insert a batch of events uses both string concatenation and interpolation. As there is a lot of repetition in the result string it’s an ideal candidate for using an IO list.
Verifying Postgrex supports querying with IO lists
Postgrex is used as the driver to interface with PostgreSQL for the EventStore. The insert SQL statement is passed to the Postgrex.query/4
function. Documentation for query/4 indicates that the statement
argument is expected to be iodata
. So it natively supports IO lists.
I verify that querying PostgreSQL using a SQL statement provided as a string or an IO list produces the expected output by running an iex
Elixir interactive shell.
Implementation
The change to the EventStore was limited to the single EventStore.Sql.Statements.create_events/1
function. Originally the SQL statement was built by mapping the number of events to insert using string interpolation and concatenation, as show below.
This was modified to use nested IO lists to build the SQL statement. Nesting to append to a list is an O(1) operation and doesn’t require copying any data.
Use IO lists to build insert events SQL statement
Running the suite of unit tests confirmed that the change was good.
Benchmarking
The EventStore has a benchmark test suite that uses the Benchfella microbenchmarking tool for Elixir. Benchfella allows you to define a bench
test and compare measurements between multiple runs. It can also plot the results of multiple runs as a graph.
Existing benchmark tests covered the main use cases for the EventStore: appending events to a stream, and reading events from a stream. The benchmark suite included separate tests for a single, 10 or 100 concurrent readers/writers.
An example benchmark test to append 100 events (generated before the test run) to the EventStore is shown below.
I measured the before and after timings of the EventStore benchmark suite to ensure that the change actually improved performance.
Running the benchmark suite
To ensure a clean environment I drop and recreate the PostgreSQL database before running the benchmark tests.
MIX_ENV=bench mix do es.reset, app.start, bench
Benchmark before results
## AppendEventsBench
benchmark name iterations average time
append events, single writer 100 15787.46 µs/op
append events, 10 concurrent writers 10 137400.10 µs/op
append events, 100 concurrent writers 1 1495547.00 µs/op
## ReadEventsBench
benchmark name iterations average time
read events, single reader 1000 2151.71 µs/op
read events, 10 concurrent readers 100 20172.47 µs/op
read events, 100 concurrent readers 10 202203.20 µs/op
Benchmark after results
## AppendEventsBench
benchmark name iterations average time
append events, single writer 100 12239.92 µs/op
append events, 10 concurrent writers 10 108868.50 µs/op
append events, 100 concurrent writers 1 1116299.00 µs/op
## ReadEventsBench
benchmark name iterations average time
read events, single reader 1000 1928.22 µs/op
read events, 10 concurrent readers 100 20102.93 µs/op
read events, 100 concurrent readers 10 201094.80 µs/op
Benchmark comparison
Benchfella provides a tool to compare the last two runs. It displays the performance gain, or loss, between the two runs for each benchmark test.
$ MIX_ENV=bench mix bench.cmp
bench/snapshots/2016-10-28_12-33-28.snapshot vs
bench/snapshots/2016-10-28_12-34-12.snapshot
## AppendEventsBench
append events, 100 concurrent writers 0.75
append events, single writer 0.78
append events, 10 concurrent writers 0.79
## ReadEventsBench
read events, single reader 0.9
read events, 100 concurrent readers 0.99
read events, 10 concurrent readers 1.0
This demonstrates that using IO lists was a performance gain. It is between 20 - 25% quicker to append events to the database after the refactoring. Reading events is unaffected.
Comparison graph
Generating a graph of the comparison results is simple with Benchfella.
$ MIX_ENV=bench mix bench.graph
Wrote bench/graphs/index.html
Linear scale
Logarithmic scale
Conclusion
Using an IO list for string manipulation can be a performance gain. As Nathan concluded in his Elixir and IO Lists article.
Caveats aside, here’s some simple advice: if you’re going to build up some output and then write it to a file, write it to a socket, or return it from a plug, don’t concatenate strings. Use an IO list.