为什么 Elixir 的 Enum.any?减缓?

Why Elixir's Enum.any? slow?

我运行下面的命令

MIX_ENV=prod  mix profile.fprof --no-start -e "Math.prime_seq 501"

对于以下代码

  def prime_seq(n) do
    prime_seq(n, 1, 3, [2,3,5,7,11,13,17,19,23])
  end

  def prime_seq(n, c, p, cache) when c < n do
    is_it = cache |> Enum.any?(fn n -> rem(p, n) == 0 end)
    if not is_it do
      prime_seq(n, c+1, p+2, cache ++ [p])
    else
      if(is_prime(p)) do
        prime_seq(n, c+1, p+2, cache ++ [p])
      else
        prime_seq(n, c, p+2, cache)
      end
    end
  end

  def prime_seq(n, c, p, _) when c == n do
    p-2
  end

结果:

为什么 Enum.do_any? 花费了太多时间?

是的,这是一个愚蠢的算法寻找第 n 个素数,还有更好的算法。但重点是,Enum.any? 比使用专门函数遍历列表慢。

我相信 anom 函数是 rem(p,n),CMIIW

更新: 我用我的 Enum.any? 删除了 divisible?

 def divisible?(n, [h|t]) do
    if rem(n, h) == 0 do
      true
    else
      divisible?(n, t)
    end
  end

  def divisible?(n, []) do
    false
  end
  ..... 
  def prime_seq(n, c, p, cache) when c < n do
    #is_it = cache |> Enum.any?(fn n -> rem(p, n) == 0 end)
    is_it = divisible?(p, cache)
    if not is_it do
      prime_seq(n, c+1, p+2, cache ++ [p])
    else
      if(is_prime(p)) do
        prime_seq(n, c+1, p+2, cache ++ [p])
      else
        prime_seq(n, c, p+2, cache)
      end
    end
  end
  .....

结果:

所以..通过简单的修改我可以让它快 3 倍并且迭代次数是一样的。

注:这是我学习灵药时的玩具项目。请多多包涵。

is_prime函数:

def is_prime(n, i) when i < n do
    if rem(n, i) == 0 do
      false
    else
      is_prime(n, i+1)
    end
  end
  def is_prime(n, i) when i >= n do
    true
  end
  def is_prime(n) do
    cond do
    n <= 1 -> false
    n <= 3 -> true
    rem(n, 2) == 0 or rem(n, 3) == 0 -> false
    true -> is_prime(n, 3)
    end
  end

查看 source code of Enum.do_any?,我们可以看到它所做的只是遍历列表并调用提供的 lambda。

分析结果似乎表明大部分时间都花在了 lambda 之外,即迭代上,这在一定程度上让我感到困惑。

无论如何,这些结果的解释是,大部分时间花在了这一行:

is_it = cache |> Enum.any?(fn n -> rem(p, n) == 0 end)

另一个有用的信息是代码对 501 的输入大小进行了 135k 次迭代。这很好地表明算法复杂度至少是多项式的。

基于此,我建议考虑一些算法更改,例如 Eratosthenes 筛法。不幸的是,我无法弄清楚这段代码是什么 return,所以我无法提供替代解决方案。

如果您需要极致性能,Elixir/Erlang 可能不是合适的工具。 Erlang VM 的性能对于 CPU 绑定的任务来说不是很好。相反,您可能想要调用高度优化的外部库,例如 primesieve. You can learn how to write a so-called port in order to call native code in this recent Elixir Sips episode(免费)。

为了回答为什么 Enum.do_any?divisible? 慢的问题,我为非分析版本创建了一个简单的测试用例:

  1. cache |> Enum.any?(fn n -> rem(p, n) == 0 end)
  2. divisible?(p, cache)
  3. divisible_anon?(cache, fn n -> rem(p, n) == 0 end)

测试代码为:

 def divisible_anon?([h|t], fun) do
    if fun.(h) do
      true
    else
      divisible_anon?(t, fun)
    end
  end

  def divisible_anon?([], _) do
    false
  end

  def divisible?(n, [h|t]) do
    if rem(n, h) == 0 do
      true
    else
      divisible?(n, t)
    end
  end

  def divisible?(n, []) do
    false
  end

测量:

  def measure(function) do
    function
    |> :timer.tc
    |> elem(0)
    |> Kernel./(1_000_000)
  end

Math.prime_seq 501 的结果:

  1. 0.009852 秒
  2. 0.008797 秒
  3. 0.012836 秒

Math.prime_seq 10001 的结果:

  1. 2.686799 秒
  2. 1.395422 秒
  3. 2.687937 秒

结论,在@sasajuric 的帮助下:"Anonymous function call is expensive"

既然有人问我,我就把它作为答案而不是评论。

问题不在于匿名函数是 "expensive",而是代码除了遍历列表外几乎什么都不做。

BEAM 调度程序使用缩减来执行其功能切片。它稍微复杂一点,但每个函数调用都算作一次缩减。当您使用匿名函数时,您正在增加减少的时间成本(即,对实际函数的查找被添加到 减少。)通常这种额外成本可以忽略不计,但是当你这样做时 数百万次,它加起来。

BEAM 调度程序为每个进程提供 2000 次缩减,然后将时间片放入 一个新的过程。

您创建了一个病态的边缘案例,将值零与另一个值进行比较。如果您将零与它看起来 "expensive" 的任何东西进行比较,那么绝对值的大小无关紧要。

正确的结论是,扩展速度快于 O(n) 的递归算法对每次递归中完成的工作量极为敏感。您应该惊讶于这完全有效,而不是它很慢。

如果我今天有时间,我将尝试使用 :erlang.statistics(:exact_reductions) 获取各种情况下的减少计数。

我使用这段代码获得了一些基本指标。

defmodule Counter do

 def count(function,arg) do
  {_ , count } = :erlang.process_info(self,:reductions)
  function.(arg)
  {_ , new_count } = :erlang.process_info(self,:reductions)
  new_count - count
 end

end

此代码计算减少量的方式并不完美,它确实应该 运行 自己的过程。

这些是我得到的结果,首先是 Enum.any?版本。

iex(4)> Counter.count(&Math.prime_seq/1, 10)
283
iex(5)> Counter.count(&Math.prime_seq/1, 100)
14086
iex(6)> Counter.count(&Math.prime_seq/1, 1000)
1105114
iex(7)> Counter.count(&Math.prime_seq/1, 10000)
103654258
iex(8)> Counter.count(&Math.prime_seq/1, 100000)
10068833898

请注意,所有这些减少都发生在单个调度线程中,我的 8 核笔记本电脑在该测试期间几乎不忙。很明显这是一个 O(n**2) 的归约算法。现在有了整除功能

iex(1)> Counter.count(&Math.prime_seq_div/1, 10)
283
iex(2)> Counter.count(&Math.prime_seq_div/1, 100)
14062
iex(3)> Counter.count(&Math.prime_seq_div/1, 1000)
1105485
iex(4)> Counter.count(&Math.prime_seq_div/1, 10000)
103655170
iex(5)> Counter.count(&Math.prime_seq_div/1, 100000)
10068870615

老实说,我预计这些数字会更小。事实上,它们并没有让我得出关于正在发生的事情的另一个结论,不是更多的减少,而是每次减少更多的时间。

Enum.any?是依赖Enumerable(使用reducees)实现的:http://blog.plataformatec.com.br/2015/05/introducing-reducees/

泛化与专业化相反。因此,虽然我们可以使用任何数据类型,但它不会像您自己实现列表变体那样快。然而,这并不意味着我们一定会变慢,我们可以轻松地将这些操作内联到 Enum 中,从而完全降低成本。