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
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
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 Elixir String Processing Optimization | jola.dev and it has examples of eg using ETS instead of Map and using iolists.
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
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #hex
- #security











