virzen

virzen

Hi, I’m new here. I’m finally trying out Elixir after hearing and reading about it for quite some time.

I’m implementing simple algorithms first, and already at factorial I found something perplexing. I’ve implemented it in several versions, one of them being tail recursive. I expected that one to be the fastest, but to my surprise it wasn’t - body recursion was.

I’ve read Erlang's Tail Recursion is Not a Silver Bullet and http://erlang.org/doc/efficiency_guide/myths.html#myth--tail-recursive-functions-are-much-faster-----than-recursive-functions but these only talk about lists; when calculating simple value, like factorial does, tail recursion should be faster, as far as I understand.

I’m doing the benchmark using Benchee, in a script run with mix run benchmark.exs. Project is clean project created using mix new. Below is the code for the benchmark and the functions themselves.

Am I missing something? Are my implementations wrong? Is the benchmark wrong? Am I running it wrong? Is this “idiomatic code being optimized”?

# benchmark.exs
n = 10000
Benchee.run(%{
  "body" => fn -> Factorial.body(n) end,
  "tail" => fn -> Factorial.tail(n) end
})
# factorial.ex
defmodule Factorial do
  def body(n) do
    case n do
      0 -> 1
      1 -> 1
      n -> n * body(n - 1)
    end
  end

  def tail(n) do
    tail_internal(1, n)
  end
  defp tail_internal(acc, 0), do: acc
  defp tail_internal(acc, n) do
    tail_internal(acc * n, n - 1)
  end
end

One output is below, but the situation with tail recursion being 5 - 15% slower repeats consistently and goes down as the input goes up, presumably because there is less and less calls to the function (only 1 when n is 1 000 000).

Operating System: Linux
CPU Information: AMD Ryzen 5 3600 6-Core Processor
Number of Available Cores: 12
Available memory: 12.44 GB
Elixir 1.12.2
Erlang 24.0.5

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking body...
Benchmarking tail...

Name           ips        average  deviation         median         99th %
body        3.20 K      312.96 μs     ±7.01%      309.20 μs      383.20 μs
tail        2.79 K      359.05 μs     ±6.48%         355 μs      441.43 μs

Comparison: 
body        3.20 K
tail        2.79 K - 1.15x slower +46.09 μs

Showing Posts 1 to 10

LostKobrakai

LostKobrakai

It might be that cleaning up the stack between each individual call might be slower than just doing all the calculation while accumulating stacks and freeing them up in one batch at the end.

Tail call optimization is much more a thing of “allow programs to run, which cannot be run without it” and less “it’s better than body recursion”.

derek-zhou

derek-zhou

10000! is a pretty big number. Large integer calculation is slow, and will dominate the run time. The small saving in function calling overhead is insignificant.

If you change the multiply to add you can clearly see that tail recursion is faster.

dimitarvp

dimitarvp

mpope

mpope

This made me curious. My first guess would be that passing along the two arguments instead of one would have a larger amount of overhead so I tested with:

defmodule Mod do
  def fun1(n) do
    case n do
      0 -> 1
      n -> fun1(n - 1)
    end
  end

  def fun2(acc, 0), do: 1
  def fun2(acc, n) do
    fun2(acc, n - 1)
  end

  def fun3(0), do: 1
  def fun3(n), do: fun3(n - 1)
end

n = 10000
Benchee.run(%{
  "one arg" => fn -> Mod.fun1(n) end,
  "two args" => fn -> Mod.fun2(1000000000, n) end,
  "one arg pattern match" => fn -> Mod.fun3(n) end
})

But the results say otherwise:

Comparison:
two args                    95.19 K
one arg pattern match       83.46 K - 1.14x slower +1.48 μs
one arg                     80.19 K - 1.19x slower +1.97 μs

The two args were actually faster than both single argument test cases.

My next theory was that maybe doing so many multiplications at one time would have overhead.

defmodule Mod do
  def fun4(n) do
    n * n * n * n * n * n * n * n * n * n * n * n * n * n * n * n * n
  end

  def fun5(n) do
    case n do
      n when n >= 11843044313729355057238118681361701 -> n
      _ -> fun5(n * n)
    end
  end
end

n = 101
Benchee.run(%{
  "all at once" => fn -> Mod.fun4(n) end,
  "one at a time" => fn -> Mod.fun5(n) end
})

Resulted in

Name                    ips        average  deviation         median         99th %
one at a time        3.41 M      292.87 ns  ±8152.96%           0 ns        1000 ns
all at once          2.84 M      352.35 ns  ±2610.58%           0 ns        1000 ns

Comparison:
one at a time        3.41 M
all at once          2.84 M - 1.20x slower +59.48 ns

So maybe that is it?

virzen

virzen OP

Very interesting. That would be related to the implementation of the * then I guess?

virzen

virzen OP

This is true, which would also point to multiplication as the source of the difference. Very interesting.

mpope

mpope

I know erlc has the -S option to view the instructions. I’m not sure if elixirc has something similar to take a deeper look.

derek-zhou

derek-zhou

No. This is related to the fact that infinite accurate integer arithmetic is slow.

virzen

virzen OP

Ok, but it happens in both cases. Both implementation have the same amount of multiplications, just in different order. Although with that line of reasoning, changing the operation to addition shouldn’t matter, but it does…

derek-zhou

derek-zhou

Remember, elixir automatically promotes integers to infinite precision on a as needed basis. Changing to addition eliminates all large numbers, so everything fit inside standard word size, (64 bit in most computers)

— All posts loaded —

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New
psy-q
I’m trying to set up Emacs with elixir-ls via lsp-mode and credo via Flycheck. This should mostly be preconfigured as Flycheck picks up c...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews