Почему Elixir медленнее всех Ruby и Go решает Project Euler #5?

Обновление: Эликсир работает не так медленно, как мой алгоритм. Мои алгоритмы не были даже сравнением яблок с яблоками. См. Ответы Романа ниже для эквивалентных алгоритмов Ruby и Go. Также благодаря Хосе мой медленный алгоритм можно значительно ускорить, просто добавив префикс MIX_ENV=prod. Я обновил статистику в вопросе.

Первоначальный вопрос: я работаю над задачами Project Euler на нескольких языках, просто чтобы посмотреть, насколько производительны и быстры языки. В задаче №5 нас просят найти наименьшее положительное число, которое без остатка делится на все числа. от 1 до 20.

Я реализовал решение на нескольких языках. Вот статистика:

  1. Go 1.4.2 : 0.58s
  2. Рубин 2.2 МРТ: 6,7 с
  3. Эликсир 1.0.5 (мой первый алгоритм): 57 с.
  4. Эликсир 1.0.5 (мой первый алгоритм с префиксом MIX_ENV=prod): 7,4 с.
  5. Эликсир 1.0.5 (алгоритм, эквивалентный Роману Го): 0,7 с.
  6. Эликсир 1.0.5 (эквивалентный алгоритм Roman's Ruby): 1,8 с.

Почему производительность Эликсира такая низкая? Я пытался использовать одни и те же оптимизации на всех языках. Предупреждение: я новичок в FP и Эликсире.

Могу ли я что-нибудь сделать, чтобы улучшить производительность в Эликсире? Если вы использовали какие-либо инструменты профилирования для поиска лучшего решения, не могли бы вы включить их в ответ?

In Go:

func problem005() int {
  i := 20
outer:
  for {
    for j := 20; j > 0; j-- {
      if i%j != 0 {
        i = i + 20
        continue outer
      }
    }
    return i
  }
  panic("Should have found a solution by now")
}

В Руби:

def self.problem005
  divisors = (1..20).to_a.reverse

  number = 20 # we iterate over multiples of 20

  until divisors.all? { |divisor| number % divisor == 0 } do
    number += 20
  end

  return number
end

В Эликсире:

def problem005 do 
  divisible_all? = fn num ->
    Enum.all?((20..2), &(rem(num, &1) == 0))
  end

  Stream.iterate(20, &(&1 + 20))
  |> Stream.filter(divisible_all?)
  |> Enum.fetch! 0
end

person Abs    schedule 07.09.2015    source источник
comment
Я не могу объяснить ваш эликсир, но почти наверняка есть лучшие способы решить эту проблему, например. попробуйте построить число, а не просто отсканировать его.   -  person Rup    schedule 07.09.2015
comment
Спасибо, Руп. Я попробую это предложение. Однако мой вопрос здесь связан исключительно с выполнением аналогичной логики на 3 разных языках.   -  person Abs    schedule 07.09.2015
comment
Если вы проводите бенчмаркинг кода Elixir, убедитесь, что вы сравните его с MIX_ENV=prod, чтобы Elixir компилировал ваш проект максимально эффективно. В противном случае вы получите неоптимальную производительность.   -  person José Valim    schedule 07.09.2015


Ответы (5)


Мой первый ответ был о реализации того же алгоритма, который вы реализовали в Ruby. Теперь вот версия вашего алгоритма в Go на Эликсире:

defmodule Euler do
  @max_divider 20
  def problem005 do 
    problem005(20, @max_divider)
  end

  defp problem005(number, divider) when divider > 1 do
    if rem(number, divider) != 0 do
      problem005(number+20, @max_divider)
    else
      problem005(number, divider-1)
    end
  end
  defp problem005(number, _), do: number
end

На моем ноутбуке это занимает около 0,73 с. Эти алгоритмы разные, поэтому я уверен, что Ruby здесь тоже мог бы сыграть лучше.

Я думаю, здесь действует общее правило: если у вас есть код на Эликсире, который имеет производительность около 80% по сравнению с кодом на Go или выше, это нормально. В других случаях, скорее всего, у вас есть алгоритмическая ошибка в вашем коде Elixir.

Обновление о Ruby:

В качестве бонуса вот эквивалентный алгоритм Go в Ruby:

def problem_005
  divisor = max_divisor = 20
  number = 20 # we iterate over multiples of 20

  while divisor > 1 do
    if number % divisor == 0 
      divisor -= 1
    else
      number += 20
      divisor = max_divisor
    end
  end

  number
end

Он работает в 4,5 раза быстрее, поэтому я думаю, что на вашем компьютере он может показать ~ 1,5 с.

person Roman Smirnov    schedule 07.09.2015
comment
Еще раз спасибо за ответ! В моей системе это занимает ~ 0,7 с. - person Abs; 08.09.2015
comment
Превосходно! Рад был вам помочь :-) - person Roman Smirnov; 08.09.2015

Попробуйте эту версию:

defmodule Euler do
  def problem005 do 
    problem005(20)
  end

  @divisors (20..2) |> Enum.to_list 
  defp problem005(number) do
    if Enum.all?(@divisors, &(rem(number, &1) == 0)) do
      number
    else
      problem005(number+20)
    end
  end
end

На моем ноутбуке это занимает около 1,4 секунды. Основной проблемой вашего решения является преобразование диапазона в список на каждой итерации. Это огромные накладные расходы. Кроме того, здесь нет необходимости создавать «бесконечный» поток. Вы не делали ничего подобного на других языках.

person Roman Smirnov    schedule 07.09.2015
comment
Спасибо, Роман! Ваш алгоритм занимает ~ 1,8 с в моей системе. Самым большим виновником действительно было преобразование диапазона в список. Однократное создание списка из диапазона сократило время выполнения с 57 до 2,7 с. - person Abs; 08.09.2015
comment
Стоит отметить, что MIX_ENV=prod значительно ускорит преобразование диапазона в список (потому что мы встраиваем их), и диапазоны в Эликсире 1.1 также будут быстрее. - person José Valim; 08.09.2015
comment
Спасибо, Хосе! Я действительно увидел значительное улучшение (с 57,0 до 7,4 с) при добавлении префикса MIX_ENV=prod к моему смущающе медленному алгоритму. Я обновил статистику в вопросе. - person Abs; 09.09.2015

Ваш код может быть в порядке, но от математики у меня болят зубы. Существует простое рекурсивное решение, которое прекрасно подходит для эликсира. Он также показывает, как вы можете просто выполнять рекурсию в elixir и не беспокоиться о проблемах с производительностью, которые рекурсия вызывает в других языках.

defmodule Euler_5 do
@moduledoc """
Solve the smallest number divisible by 1..X using Greatest Common Divisor.
"""

  def smallest(1), do: 1
  def smallest(2), do: 2

  def smallest(n) when n > 2 do
    next = smallest(n-1)
    case rem(next, n) do
      0 -> next
      _ -> next * div(n,gcd(next,n))
    end
  end

  def gcd(1,_n), do: 1

  def gcd(2,n) do
    case rem(n,2) do
      0 -> 2
      _ -> 1
    end
  end

  def gcd( m, n) do
    mod = rem(m,n)
    case mod do
      0 -> n
      _ -> gcd(n,mod)
    end
  end

end

Как бы то ни было, на моем компьютере это занимает 8 микросекунд.

iex> :timer.tc(Euler_5, :smallest, [20])
{8, 232792560}

Не совсем справедливое сравнение с другими языками, поскольку оно не включает время на загрузку виртуальной машины и выполнение операций ввода-вывода.

person Fred the Magic Wonder Dog    schedule 08.09.2015
comment
Спасибо, Фред. 8 микросекунд впечатляют! Я стараюсь не заглядывать в ваше решение, поэтому я могу сам попробовать подход GCD. Я включу статистику из вашего решения после того, как поработаю над своим собственным решением. - person Abs; 09.09.2015

Мне нравится это решение за его простоту:

#!/usr/bin/env elixir
defmodule Problem005 do
  defp gcd(x, 0), do: x
  defp gcd(x, y), do: gcd(y, rem(x, y))

  defp lcm(x, y) do
    x * y / gcd(x, y)
  end

  def solve do
    1..20
    |> Enum.reduce(fn(x, acc) -> round(lcm(x, acc)) end)
  end
end

IO.puts Problem005.solve

Это также очень быстро.

./problem005.exs  0.34s user 0.17s system 101% cpu 0.504 total

Что касается Ruby, это можно решить одной строкой:

#!/usr/bin/env ruby
puts (1..20).reduce { |acc, x| acc.lcm(x) }

(lcm -> http://ruby-doc.org/core-2.0.0/Integer.html#method-i-lcm)

person Frank Kair    schedule 12.09.2017
comment
и пойти дальше с рубином... (1..20).inject(&:lcm) - person joegiralt; 12.05.2018

Решение Фреда отличное. Это более неэффективно (32 микросекунды), но более ясно. Может быть, с мемомизацией он мог бы работать на порядок быстрее.

defmodule Euler5 do
  def smallest(n) when n > 0 do
    Enum.reduce(1..n, &(lcm(&1, &2)))
  end
  def smallest(n), do: n

  def lcm(x, y), do: div((x * y), gcd(x, y))

  def gcd(x, 0), do: x
  def gcd(x, y), do: gcd(y, rem(x, y))
end
person AA.    schedule 13.08.2016