frumos

frumos

Elixir language performance tuning for 1 quadrillion records per month

Hello Elixir community,

I would like to share with you some results of my first 2 months of Elixir learning and ask important question about Elixir language performance as such.

I’ve made some POC application and was able to run it on production-like host (AWS c5.9xl - 96 cores, 70Gb of RAM) and I got about 3-3.5 time worse tps comparing to our current prod metrics. To troubleshoot I just decided make some very rudimentary tests of the main logic which is key in our business - the logic is very simple:

  • iterate over files (compressed JSONs or plain CSVs) and
  • line by line make some map transformation and
  • perform aggregation by applying a sum function for the decimal value on string key

Simple enough, however the only feature of our business is the volume - it is 1 quadrillion (10^15) records per month what I need to account which is ~380 * 10^6 tps. We have such applications which are handling this volume in prod ATM.

For Elixir capability evaluation I took 2 files a plain csv what I need to aggregate (reduce by sum function) - the small (~1000 records) and big (11.7 million records)

Please see snippet of this file

HeaderLength=8
DatasetName=xxxBillingHourly
CreationTimestamp=1561942800000
StartRangeMarker=2019-07-01-01-00-00
EndRangeMarker=2019-07-01-02-00-00
FieldSpec=field-1,field-2,field-3,field-4,field-5,field-6,field-7,field-8,field-9,startTime,endTime,value
KeySpec=field-1,field-2,field-3,field-4,field-5,field-6,field-7,field-8,field-9,startTime,endTime
DataSources=someS3bucket

007931482,abcStore,abcStore,GetPar,GetPar,,us-west-1,,,2018-07-01 01:00:00,2018-07-01 02:00:00,10
016299379,abcEC2,abcEC2,Hours,Gateway,,,arn:aws:someARN,,2018-07-01 01:00:00,2018-07-01 02:00:00,10
018870929,abcLambda,abcLambda,Second,Invoke,,,arn:aws:some-function,,2018-07-01 01:00:00,2018-07-01 02:00:00,59.35000000000002086
.
.

and used Elixir and Java programs to compare with each other (Java is current language we use).

Bellow a listing of two programs which are doing the same things.

Java

    @Test
    public void konaAggregate() throws IOException {        
        
        long start = System.currentTimeMillis();
        System.out.println("Start");
        
        String fName = "/home/temp/1/real_kona";
        Path totalFilePath = Paths.get(fName + "_java_aggregated");
        
        Stream<Entry<String, BigDecimal>> stream = Files
        .lines(Paths.get(fName))
        .skip(9)
        .map(l -> {
            String[] parts = l.split(",");
            return Tuple.of(String.join(",", parts[0], parts[1], parts[2], parts[3], parts[4]), new BigDecimal(parts[11]));
        })
        .collect(
                Collectors.groupingBy(
                        Tuple2::_1,
                        TreeMap::new,
                        Collectors.mapping(Tuple2::_2, Collectors.reducing(BigDecimal.ZERO, BigDecimal::add))
                        )
                )
        .entrySet()
        .stream();
        
        try (BufferedWriter writer = Files.newBufferedWriter(totalFilePath)) {
            stream.forEach(line -> {
                String record = line.getKey() + "," + line.getValue().toPlainString();
                try {
                    writer.write(record);
                    writer.newLine();
                } catch (IOException e) {
                    // ignore
                }
            });
        }
        long end = System.currentTimeMillis();        
        System.out.println("Total time: " + (end - start));
    }

Elixir

def aggregate_kona() do

  IO.puts("Start")
  kona =  "/home/temp/1/real_kona"

  output_path = kona <> "_elixir_aggregated"

  start = :os.system_time(:millisecond)

  kona
  |> File.stream!
  |> Stream.map(&String.split(&1, "\n"))
  |> Stream.drop(9)
  |> Stream.map(&(&1 |> hd))
  |> Stream.map(&parse_granular(&1))
  |> Enum.reduce(
    %{},
    fn %{record_key: key, record_value: value}, acc ->
      Map.update(acc, key, value, &Decimal.add(&1, value))
    end
  )
  |> Stream.map(&((elem(&1, 0) <> "," <> Decimal.to_string(elem(&1, 1))) <> "\n"))
  |> Stream.into(File.stream!(output_path, [:write, :utf8]))
  |> Stream.run()

  stop = :os.system_time(:millisecond)
  IO.puts("Total: #{stop - start}")
  end
  
  def parse_granular(record) do
    [p,
      pr,
      cpc,
      ut,
      op,
      _,
      _,
      _,
      _,
      _,
      _,
      value] =
      record |> String.split(",")

    %{record_key: p <> "," <> pr <> "," <> cpc <> "," <> ut <> "," <> op, record_value: Decimal.new(value)}

  end

Let me give you results:

For small file which is 1K records there is no issues and both are (Elixir is faster) latency is 50 milliseconds

But for the big file (11.7 million records and ~2Gb size)

Java gives 16 seconds latency and is able to aggregate file
Elixir was running for ~20 minutes with no produced result and I just terminated an iex session

To get somewhere, I made flow in Elixir even simpler, I commented a reduce part of the flow and made a run. It gave me latency ~115 seconds. Java version for such work coped for ~13 seconds.

So the question to Elixir community, could you review please my task, implementation whether I have any obvious mistakes in Elixir part and also why Elixir’s performance is such that I simply can not accept it?

Thank you.

Most Liked

frumos

frumos

Good evening everybody, sorry for delayed update, I had extra hectic working week so put my POC on hold, but today I finally competed first part and got more or less working version.
In short results are just tremendous - I did not expect such a great outcome. And outcome is as following:

In Elixir app:

  • each hour, I download ~400 CSV-like files from S3, total size ~70-75Gb. Total # of records to aggregate is ~0.55-0,7 billions. Files are in different size from 0.5Mb to 2,2Gb
  • after downloading each files is processed with aggregation function what we discussed here already

the average latency is ~17-18 minutes for the whole work, which is almost 2 times better than I did with Java prototype 1 year ago!!!

The CPU usage is ~50%, the memory usage ~50% which is slightly higher than in Java but totally acceptable (please see screenshot bellow).

I performed 3 test runs for different hours. The test platform is my cloud devbox C5.9XL (36 cores, 25Gb net, 72Gb RAM)

This is first functional step in the POC (and I still need to work a lot) but first results exceed all my expectation!

Thanks to everybody who helped to reach me such results.

jola

jola

So… a couple of things. First, File.stream! is already splitting the input into lines by default, but you then Stream.map(&String.split(&1, "\n")) again on each line, which does nothing.

If you need to do a large number of updates on a Map, consider switching to ETS. It will perform much better with large amounts of data, since it has constant time access and isn’t garbage collected.

There are some other things I’d try that might help too, like replacing this &((elem(&1, 0) <> "," <> Decimal.to_string(elem(&1, 1))) <> "\n") with fn {key, value} -> key <> "," <> Decimal.to_string(elem(&1, 1)) <> "\n" end.

You’re also building a lot of strings, which in eg the JVM is automatically optimized. The BEAM doesn’t optimize that automatically, but there’s this concept of “iolists” that let you manually optimize it.

I’d suggest taking a look at a similar thread posted before that has a lot of tips Erlang/Elixir string performance - can this be improved?

I also wrote an article about this which isn’t completely up to date but collects most of the improvements from the thread https://blog.jola.dev/elixir-string-processing-optimization and it has examples of eg using ETS instead of Map and using iolists.

21
Post #2
jola

jola

Based on my experience optimizing a similar code snippet (spent lots of hours on it, measuring each part).

  • Streaming into lines is very slow compared to reading the entire string directly. Writing a stream is also slower than writing a complete string. Keeping unicode support is slower than not.
  • Splitting is fast when you can split 1 big thing, but slow when you split many small things.
  • Passing a large map across functions is not something that takes time. As long as it stays within the process nothing gets moved.
  • Putting values in large map is very slow (depending on your definition of large). The time it takes grows with the size of the map (unlike ETS which is constant time).
  • Overhead of stream is small, but noticeable with a large number of items (like this case). Memory is the deciding factor. If you can spare it, don’t use Stream.

Another thing, causing lots of GC by constantly growing the heap. With a similar use case script I was able to speed it up considerably by setting the initial process heap size to a very large number, which avoids growing the heap a bunch of times. GC in general is costly, if you can avoid it, do. But that’s up to your memory limits.

ps come to my talk at CodeElixir LDN or ElixirConf US where I will be talking about this thing exactly

massimo

massimo

a few performance tricks to use when dealing with Strings in Elixir (or Erlang)

  • don’t use string concatenation (<>) use io lists
  • don’t use records maps, they are slow, use lists or tuples
  • take advantage of the standard library, don’t overthink the solution: in this case Integer.parse is more than enough to convert a string to an integer
  • use Erlang’s binary module. For example compiled patterns are usually much faster than just string patterns.

On my Laptop (Lenovo P70, 8 cores, 64GB of RAM) your solutions takes approximately 6 seconds to process a little bit more than 1 million rows, while the following takes around 2.1 seconds

  def aggregate_kona2() do
    IO.puts("Start")
    kona = "./data"

    output_path = kona <> "_elixir_aggregated"
    
    pattern = :binary.compile_pattern(",")

    {time, _} =
      :timer.tc(fn ->
        kona
        |> File.stream!()
        |> Stream.drop(9)
        |> Stream.map(&parse_granular2(&1, pattern))
        |> Enum.reduce(
          %{},
          fn [key, value], acc ->
            Map.update(acc, key, value, &Kernel.+(&1, value))
          end
        )
        |> Stream.map(fn {k, v} -> [k, ",", to_string(v), "\n"] end)
        |> Stream.into(File.stream!(output_path, [:write, :utf8]))
        |> Stream.run()
      end)

    IO.puts("Total: #{time / 1_000_000}s")
  end

  def parse_granular2(record, pattern) do
    [p, pr, cpc, ut, op, _, _, _, _, _, _, value] = record |> String.split(pattern)

    {value, _} = Integer.parse(value)

    [
      [p, ",", pr, ",", cpc, ",", ut, ",", op],
      value
    ]
  end

EDIT

Here’s a version using Flow.

It’s not optimized, I’m sure tweaking the params a bit will yeld much better results.

For around 11 millions rows it take ~17secs (as usual for the BEAM, results scale linearly with the size of the input, for 1 million rows it take 1.55 secs, 1.55 * 11 = 17.05)

  def aggregate_kona_flow() do
    IO.puts("Start")
    kona = "./data12"

    output_path = kona <> "_elixir_aggregated_3"
    {:ok, output_file} = File.open(output_path, [:write, :utf8])

    pattern = :binary.compile_pattern(",")

    part_opts = [stages: System.schedulers_online()]
    read_ahead = 32768
    max_demand = 256

    {time, _} =
      :timer.tc(fn ->
        kona
        |> File.stream!(read_ahead: read_ahead)
        |> Stream.drop(9)
        |> Flow.from_enumerable(max_demand: max_demand)
        |> Flow.map(&parse_granular2(&1, pattern))
        |> Flow.partition(part_opts)
        |> Flow.reduce(fn -> %{} end, fn [key, value], acc ->
          Map.update(acc, key, value, &(&1 + value))
        end)
        |> Flow.each(fn {k, v} ->
          IO.write(output_file, [k, ",", to_string(v), "\n"])
        end)
        |> Flow.run()
      end)

    File.close(output_file)

    IO.puts("Total: #{time / 1_000_000}s")
  end

Schultzer

Schultzer

If you want maximum speed you have to stick with tuples, iolist and, integers. I wouldn’t use Decimal, it is based on structs which is a map, and maps are expansive, not as expensive as they used to be. I would recommend you to use :erts_debug.size/1 to determine how much memory your would use.
:erts_debug.size(%{}) == 4 :erts_debug.size({}) == 1

/edit

Another thing is, use pattern matching, if your data is consistent and you always expect the csv to be the same then you don’t have to split the lines to get your values, you can pick them right out of the string which would give you almost pointer arithmetic.

def getmyvalue(<<"StartRangeMarker=", year::binary-size(4), ?-, month::binary-sizer(2), ?-, day::binary-size(2), ?-, hours::binary-size(2), ?-, min::binary-size(2), ?-, sec::binary-size(2)>>) do
{:ok, year, month, day, hours, min, sec}
end

Where Next?

Popular in Questions Top

pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: https://hexdocs.pm/ecto/Ecto.Schema.html#module-...
New
_russellb
I want to try my hand at web scraping. What tools/libraries do I need to use. I’m hoping to turn this into something professional so don’...
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
chrisalley
ExUnit now has describe blocks which is a welcome addition coming from RSpec. In the docs, it states that nested hierarchies of describe ...
New
jc00ke
Expanding on this topic: https://forum.elixirforum.net/t/map-typespec-question/19217 Let’s say I have a map with required and optional k...
New

Other popular topics Top

Qqwy
Update: How to use the Blogs &amp; Podcasts section You can post links to your blog posts or podcasts either in one of the Official Blog...
3268 119930 1237
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 30048 115
New
peerreynders
Manning 2016 Halloween weekend sale via Deal of the Day Friday, October 28 - Half off all MEAPs - code WM102816LT Saturday, October 29 ...
326 29600 154
New
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
johnnyicon
Hi all, I've just started learning Elixir and Phoenix Framework, so please pardon my n00bness at this stage. I'm trying to use Postg...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New

We're in Beta

About us Mission Statement