technusm1

technusm1

Question regarding improving fizzbuzz IO performance

Hi everyone, posting for the first time here, so please bear with me.

I came across a HN thread that mentioned about a code-golf problem for high-throughput FizzBuzz. Link here: fastest code - High throughput Fizz Buzz - Code Golf Stack Exchange.

The naive single-threaded implementation in C gets about 70 MiB/s throughput on my Mac machine.
Here’s the C program for reference:

#include <stdio.h>

int main() {
    for (int i = 1; i < 1000000000; i++) {
        if ((i % 3 == 0) && (i % 5 == 0)) {
            printf("FizzBuzz\n");
        } else if (i % 3 == 0) {
            printf("Fizz\n");
        } else if (i % 5 == 0) {
            printf("Buzz\n");
        } else {
            printf("%d\n", i);
        }
    }
}

It is my understanding from learning Elixir so far that if your work is CPU intensive, pure Elixir is not a good choice. But since this is a simple problem (and mostly IO oriented as I’ve come to believe), I thought of testing the waters a bit. I came up with the approach of dividing the range 1…1000000000 into #{System.schedulers_online} chunks and using Task.async_stream to process each chunk in its own task.

I made the following Fizzbuzz module:

defmodule Fizzbuzz do
  def fizzbuzz_with_io(enumerable) do
    # I am printing here, because its my understanding it takes time to move data from one process to another.
    Stream.map(enumerable, &reply/1)
    |> Stream.chunk_every(5000)
    |> Enum.into(IO.stream())
  end

  def fizzbuzz_no_io(enumerable) do
    Stream.map(enumerable, &reply/1) |> Stream.run()
  end

  def reply(n) do
    case {rem(n, 3), rem(n, 5)} do
      {0, 0} -> "FizzBuzz\n"
      {0, _} -> "Fizz\n"
      {_, 0} -> "Buzz\n"
      {_, _} -> "#{n}\n"
    end
  end
end

My main CLI driver code (I acknowledge that the below code is not 100% correct and there are some numbers I might be missing in my quest to divide work, but it is OK enough to roughly illustrate the problem):

defmodule Fizzbuzz.Cli do
  def main([lower, upper]) do
    {lower, upper} = {String.to_integer(lower), String.to_integer(upper)}
    chunk_size = div(upper - lower, System.schedulers_online)
    input_enumerable = get_input_ranges(lower, upper, chunk_size)
    IO.inspect(input_enumerable)
    input_enumerable
    |> Task.async_stream(Fizzbuzz, :fizzbuzz, [], timeout: :infinity, max_concurrency: 64)
    |> Stream.run()
  end

  def main(_), do: IO.puts("Usage: fizzbuzz 1 10000")

  defp get_input_ranges(lower, upper, chunk_size) do
    if chunk_size >= 10 do
      if lower >= upper, do: [], else: [lower..min(lower+chunk_size, upper) | get_input_ranges(min(lower+chunk_size, upper) + 1, upper, chunk_size)]
    else
      [lower..upper]
    end
  end
end

Compiling above with MIX_ENV=prod mix escript.build, I have observed the following:

  • If I merely want to do the computation (with no IO at all using :fizzbuzz_no_io), the program finishes in 31 seconds on my machine. Its much faster than 3 minutes and 5 seconds of single threaded execution in Elixir, but its still slower than single-threaded C’s 7 seconds.
  • If I use :fizzbuzz_with_io, the program running time exceeds more than 5 minutes and I get a throughput of 2.5-2.7 MiB/s. Here, C gives a throughput of 70 MiB/s on my machine and a total running time of 1 minute and 43 seconds.

UPDATE-1:

  • I have updated the driver code to handle all cases properly.
  • I have updated fizzbuzz_with_io function to use Stream chunking, which has improved throughput to around 70 MiB/s on my system. That’s 35x improvement in performance. It was just a hunch that drove me to use this, since I thought adding chunking might add small chunks to IO.stream incrementally, rather than overloading it with everything at once. Could somebody please explain it better?
  • There are throughputs that still go in order of _ GiB/s. So, is there any further scope for improvement?

So, here’s my question: How do I improve the throughput of the Elixir version of Fizzbuzz program?
Thanks :smiley:

#io

Most Liked

ityonemo

ityonemo

Yep! One feature of the BEAM is that you can create a node, let’s say on your laptop, and connect into another node, let’s say in prod, using Erlang distribution and use the connection to debug what’s going on the remote node. Then let’s say you execute some code remotely that prints something to “stdio”. It’s useless if that IO gets sent to stdout on the remote node, if you’re lucky it gets logged but more likely it goes straight to /dev/null.

So Erlang will know that the code is being run remotely, assign that code’s group leader to your group leader on your laptop, and so IO calls to “stdout” in functions on the remote node will be forwarded to stdout on your laptop. To a first approximation, anyways. If you trigger code in a persistent genservers, that won’t hold true, for example, because that process holds on to its original group leader.

al2o3cr

al2o3cr

Two related gotchas with IO.stream:

  • it’s represented by a “file server” process, so “chunking” into bigger pieces helps by reducing the number of messages sent to that process (chunks vs individual lines)

  • it can still only print one message at a time, no matter how many workers are sending messages

You could likely boost performance by building bigger iolists and sending those to IO in one go.

mpope

mpope

Does IO.binwrite send a message to a process? Using raw file access means that no process communication happens, which could be saving time. maybe try this raw stdout access without flattening the binary, because iirc when you set the state in handle_info that isn’t a message pass to itself. I think state transitions are optimized.

ityonemo

ityonemo

While Erlang VM is pretty good with io in general, stdout is not very performant. The logic here is that when you need stdout you’re likely to be in anger,and even more likely, needing to capture stdout on another node across the distributed cluster. Also, io events should be atomic. We don’t want a situation where one process is writing some content to io and another process scoots in, blats something out, rudely interrupting the content you cared about.

Default Std io in Erlang goes through something called the group leader which is a single highly bottlenecked process that has lots of capabilities for interacting in concurrent systems in a sane fashion. It’s effectively handling lots of coordination. So expecting stdio to be performant in elixir is not a good idea. Why not write to a file or socket instead?

ityonemo

ityonemo

Yes and all of that infrastructure is costly. In the end, printing hello world quickly is far less important than having a robust debugging experience when you want up be doing debugging the least

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
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
Werner
Hi, I’m using Ubuntu 18.04 and after updating to OTP-24.0 yesterday i have this warning when I run “mix local.hex”: 14:57:30.512 [warn] ...
New
lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
New
kostonstyle
Hi all I want to have a unix time, from the current time plus 1 hour. DateTime.now + 1 hour How to get it in elixir? Thanks
New
hpopp
To simplify some tasks at work, I wrote and published this package yesterday. It’s a simple macro that enables Access behaviour on struct...
New
Mooodi
Given a string, how can I get access to its character by index? Enum.at("my_string", 2) doesn't work. Or rather, not char, but a substr...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
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

Other popular topics Top

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
openscript
Hello! Sorry for this astonishing simple question, but I’m really stuck. I try to set up the intellij-elixir plugin, but I don’t know ho...
New
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 35421 110
New
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
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
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
AstonJ
by Lance Halvorsen Elixir and Phoenix are generating tremendous excitement as an unbeatable platform for building modern web application...
460 27162 124
New

We're in Beta

About us Mission Statement