为什么这个主要测试如此缓慢?

时间:2012-07-30 08:47:44

标签: haskell primes sieve-of-eratosthenes

此代码取自“Haskell之路逻辑,数学和编程”一书。它实现了eratosthenes算法的筛选并解决了项目Euler问题10。

sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
  where
    mark :: [Integer] -> Integer -> Integer -> [Integer]
    mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
                    | otherwise = y : (mark ys (k+1) m)

primes :: [Integer]
primes = sieve [2..]

-- Project Euler #10
main = print $ sum $ takeWhile (< 2000000) primes

实际上它比天真的主要测试运行得更慢。 有人可以解释这种行为吗?

我怀疑它与迭代标记函数中列表中的每个元素有关。

感谢。

4 个答案:

答案 0 :(得分:11)

您正在使用此算法构建二次数的未评估的thunk。该算法严重依赖于懒惰,这也是它无法扩展的原因。

让我们来看看它是如何运作的,希望能让问题显而易见。简单地说,我们想要print无限primes的元素,即我们想要一个接一个地评估列表中的每个单元格。 primes定义为:

primes :: [Integer]
primes = sieve [2..]

由于2不是0,因此sieve的第二个定义适用,并且2被添加到素数列表中,列表的其余部分是未评估的thunk(我使用tail代替对于n : xssieve中的模式匹配xs,因此tail实际上并未被调用,并且不会在下面的代码中添加任何开销; {{1实际上是唯一的thunked函数):

mark

现在我们想要第二个primes = 2 : sieve (mark (tail [2..]) 1 2) 元素。因此,我们遍历代码(为读者练习)并最终得到:

primes

同样的程序,我们想评估下一个素数......

primes = 2 : 3 : sieve (mark (tail (mark (tail [2..]) 1 2)) 1 3)

这开始看起来像LISP,但我离题了...开始看到问题了吗?对于primes = 2 : 3 : 5 : sieve (mark (tail (tail (mark (tail (mark (tail [2..]) 1 2)) 1 3))) 1 5) 列表中的每个元素,必须评估越来越多的primes个应用程序堆栈。换句话说,对于列表中的每个元素,必须通过评估堆栈中的每个mark应用程序来检查该元素是否由任何前面的素数标记。因此,对于mark,Haskell运行时必须调用函数,导致调用堆栈的深度约为......我不知道,137900(n~=2000000给出了下限)?这样的事情。这可能是导致减速的原因;也许vacuum可以告诉你更多(我现在没有配备Haskell和GUI的计算机)。

Eratosthenes的筛子在C语言中工作的原因是:

  1. 您没有使用无限列表。
  2. 由于(1),您可以在继续下一个let n = 2e6 in n / log n之前标记整个数组,从而导致根本没有调用堆栈开销。

答案 1 :(得分:8)

不仅是使得它非常慢的thunk,如果在有限位数组中用C实现,该算法也会非常慢。

sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
  where
    mark :: [Integer] -> Integer -> Integer -> [Integer]
    mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
                    | otherwise = y : (mark ys (k+1) m)

对于每个素数p,此算法会检查从p+1到限制的所有数字是否为p的倍数。它并没有像特纳的筛子那样划分,而是通过将计数器与素数进行比较。现在,比较两个数字比分割要快得多,但为此付出的代价是现在每个素数n检查每个数字< n,而不仅仅是n的素数。最小的素数因素。

结果是该算法的复杂性为特纳筛的O(N ^ 2 / log N)对O((N / log N)^ 2)(和O(N * log(log N))为真正的Eratosthenes筛选。)

嵌套¹堆叠的thunks mentioned by dflemstr会加剧问题²,但即使没有这个,算法也会比Turner更差。我同时感到震惊和着迷。


¹“嵌套”可能不正确。虽然每个mark thunks只能通过它上面的那个来访问,但它们不会引用封闭thunk范围内的任何内容。

²但是,在thunk的大小或深度上都没有任何二次方,而且thunk的表现相当不错。为了说明,我们假装mark是用反向参数顺序定义的。然后,当发现7是素数时,情况就是

sieve (mark 5 2 (mark 3 1 (mark 2 1 [7 .. ])))
~> sieve (mark 5 2 (mark 3 1 (7 : mark 2 2 [8 .. ])))
~> sieve (mark 5 2 (7 : mark 3 2 (mark 2 2 [8 .. ])))
~> sieve (7 : mark 5 3 (mark 3 2 (mark 2 2 [8 .. ])))
~> 7 : sieve (mark 7 1 (mark 5 3 (mark 3 2 (mark 2 2 [8 .. ]))))

sieve的下一个模式匹配强制mark 7 1 thunk,它强制mark 5 3 thunk,强制mark 3 2 thunk,强制{{1} thunk,它强制mark 2 2 thunk并用0替换头部,并将尾部包裹在[8 .. ] thunk中。这会冒泡到mark 2 1,这会丢弃0,然后强制下一堆thunk。

因此,对于从sievep_k + 1(包括)的每个数字,p_(k+1)中的模式匹配会强制sieve形式k thunk的堆栈/链1}}。其中每个都会从封闭的thunk(mark p r获取最内层(y:ys))中获取[y ..]并将尾部mark 2 r包裹在新的thunk中,或者离开{{1} }未更改或将其替换为0,从而在到达ys的列表尾部建立一个新的堆栈/ thunk链。

对于每个找到的素数,y在顶部添加另一个sieve thunk,所以最后,当找到大于2000000的第一个素数并且sieve结束时,将会有148933 thunk的水平。

这里的堆叠不会影响复杂性,只会影响常数因素。在我们正在处理的情况下,一个懒惰生成的无限不可变列表,没有太多可以做的事情来减少将控制从一个thunk转移到下一个thunk所花费的时间。如果我们处理的是一个有限的可变列表或不是懒惰生成的数组,就像在C或Java这样的语言中那样,那么让每个mark p r完成它的完整工作会更好(那将是一个在检查下一个数字之前,简单takeWhile (< 2000000)循环的开销比函数调用/控制转移少,因此永远不会有多个标记处于活动状态且控制传递较少。

答案 2 :(得分:5)

好的,你肯定是对的,它比天真的实现慢。我从维基百科那里拿了这个,然后用GHCI将它与你的代码进行比较:

-- from Wikipedia
sieveW [] = [] 
sieveW (x:xs) = x : sieveW remaining 
  where 
    remaining = [y | y <- xs, y `mod` x /= 0]

-- your code
sieve :: [Integer] -> [Integer]
sieve (0 : xs) = sieve xs
sieve (n : xs) = n : sieve (mark xs 1 n)
  where
    mark :: [Integer] -> Integer -> Integer -> [Integer]
    mark (y:ys) k m | k == m = 0 : (mark ys 1 m)
                    | otherwise = y : (mark ys (k+1) m)

跑步给出

[1 of 1] Compiling Main             ( prime.hs, interpreted )
Ok, modules loaded: Main.
*Main> :set +s
*Main> sum $ take 2000 (sieveW [2..])
16274627
(1.54 secs, 351594604 bytes)
*Main> sum $ take 2000 (sieve [2..])
16274627
(12.33 secs, 2903337856 bytes)

为了尝试了解正在发生的事情以及mark代码的确切运作方式,我尝试手动扩展代码:

  sieve [2..]
= sieve 2 : [3..]
= 2 : sieve (mark [3..] 1 2)
= 2 : sieve (3 : (mark [4..] 2 2))
= 2 : 3 : sieve (mark (mark [4..] 2 2) 1 3)
= 2 : 3 : sieve (mark (0 : (mark [5..] 1 2)) 1 3)
= 2 : 3 : sieve (0 : (mark (mark [5..] 1 2) 1 3))
= 2 : 3 : sieve (mark (mark [5..] 1 2) 1 3)
= 2 : 3 : sieve (mark (5 : (mark [6..] 2 2)) 1 3)
= 2 : 3 : sieve (5 : mark (mark [6..] 2 2) 2 3)
= 2 : 3 : 5 : sieve (mark (mark (mark [6..] 2 2) 2 3) 1 5)
= 2 : 3 : 5 : sieve (mark (mark (0 : (mark [7..] 1 2)) 2 3) 1 5)
= 2 : 3 : 5 : sieve (mark (0 : (mark (mark [7..] 1 2) 3 3)) 1 5)
= 2 : 3 : 5 : sieve (0 : (mark (mark (mark [7..] 1 2) 3 3)) 2 5))
= 2 : 3 : 5 : sieve (mark (mark (mark [7..] 1 2) 3 3)) 2 5)
= 2 : 3 : 5 : sieve (mark (mark (7 : (mark [8..] 2 2)) 3 3)) 2 5)

我认为我可能在那里结束时犯了一个小错误,因为7看起来它将变成0并被删除,但机制很清楚。此代码仅创建一组计数器,计数到每个素数,在正确的时刻发出下一个素数并将其传递到列表中。这相当于在初始实现中仅检查每个先前素数的除法,并且在thunk之间传递0或素数的额外开销。

这里可能还有一些我想念的细微之处。在Haskell中对Eratosthenes的Sieve进行了非常详细的处理以及各种优化here

答案 3 :(得分:1)

简短回答:计数筛比Turner(又名“天真”)筛子慢,因为它通过顺序计数模拟直接RAM访问,这迫使它通过流 unieved < / em>标记阶段之间。这具有讽刺意味,因为计算使它成为Eratosthenes的“真正的”筛子,而不是特纳的试验筛子。实际上,就像特纳的筛子那样去除倍数会使计数陷入困境。

这两种算法都非常慢,因为它们从每个找到的素数而不是它的方块开始多次消除过早,从而创建了太多不需要的流处理阶段(无论是过滤还是标记) - {{它们中的1}},而不仅仅是O(n),用于生成价值最高~ 2*sqrt n/log n的素数。在输入中看到 n 之前,不需要检查 7 的倍数。

This answer解释了49如何被视为构建流处理“传感器”的管道,因为它正在工作:

sieve

特纳筛选使用[2..] ==> sieve --> 2 [3..] ==> mark 1 2 ==> sieve --> 3 [4..] ==> mark 2 2 ==> mark 1 3 ==> sieve [5..] ==> mark 1 2 ==> mark 2 3 ==> sieve --> 5 [6..] ==> mark 2 2 ==> mark 3 3 ==> mark 1 5 ==> sieve [7..] ==> mark 1 2 ==> mark 1 3 ==> mark 2 5 ==> sieve --> 7 [8..] ==> mark 2 2 ==> mark 2 3 ==> mark 3 5 ==> mark 1 7 ==> sieve [9..] ==> mark 1 2 ==> mark 3 3 ==> mark 4 5 ==> mark 2 7 ==> sieve [10..]==> mark 2 2 ==> mark 1 3 ==> mark 5 5 ==> mark 3 7 ==> sieve [11..]==> mark 1 2 ==> mark 2 3 ==> mark 1 5 ==> mark 4 7 ==> sieve --> 11 代替nomult p = filter ((/=0).(`rem`p))条目,但看起来相同:

mark _ p
每个这样的换能器可以实现为闭合框架(也称为“thunk”),或具有可变状态的发电机,这是不重要的。每个这样的生产者的输出直接作为输入进入其链中的后继者。这里没有没有被评估的价值,每个都被消费者强迫,以产生下一个产量。

所以,回答你的问题,

  

我怀疑它与迭代标记函数中列表中的每个元素有关。

是的,完全。他们都运行非推迟的计划。


通过推迟流标记的开始,可以改进代码:

[2..] ==> sieveT --> 2
[3..] ==> nomult 2 ==> sieveT --> 3
[4..] ==> nomult 2 ==> nomult 3 ==> sieveT 
[5..] ==> nomult 2 ==> nomult 3 ==> sieveT --> 5
[6..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> sieveT 
[7..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> sieveT --> 7
[8..] ==> nomult 2 ==> nomult 3 ==> nomult 5 ==> nomult 7 ==> sieveT 

现在,在primes = 2:3:filter (>0) (sieve [5,7..] (tail primes) 9) sieve (x:xs) ps@ ~(p:t) q | x < q = x:sieve xs ps q | x==q = sieve (mark xs 1 p) t (head t^2) where mark (y:ys) k p | k == p = 0 : (mark ys 1 p) -- mark each p-th number in supply | otherwise = y : (mark ys (k+1) p) 生成的O(k^1.5)素数中,它实际上高于k。但是,当我们可以按增量计算时,为什 9中的每个第3个奇数可以通过添加6一次又一次找到。) 然后我们可以杂草而不是标记立即取出数字,让自己成为Eratosthenes的真正筛子(即使不是最有效的筛子):

primes = 2:3:sieve [5,7..] (tail primes) 9

sieve (x:xs) ps@ ~(p:t) q 
   | x < q = x:sieve xs ps q
   | x==q  = sieve (weedOut xs (q+2*p) (2*p)) t (head t^2)
  where
    weedOut i@(y:ys) m s 
       | y < m = y:weedOut ys m s
       | y==m  = weedOut ys (m+s) s
       | y > m = weedOut i (m+s) s

这在O(k^1.2)生成的k以上的O(k^1.3)运行,快速n-dirty测试被编译加载到GHCi中,产生高达10万到150k的质数,在约0.5时恶化到primes = sieve [2..] :: [Int] where sieve (x:xs) = x : sieve [y | y <- xs, rem y x /= 0] mil primes。


那么实现了什么样的加速?比较OP代码和“维基百科”的特纳筛子,

8x

2k 的W / OP加速 15x (即产生2000个素数)。但是在 4k 时,它是 O(k^1.9 .. 2.3) 加速。特纳筛子似乎在产生k = 1000 .. 6000质数时约为O(k^2.3 .. 2.6)经验复杂度,而20x处的计数筛用于相同范围。

对于本答案中的两个版本,v1 / W在 4k 时更快 43x 5.2x 8k 。 v2 / v1在 20k 5.8x 6.5x 40k O(k^1.2) 可以更快地产生80,000个素数。

(为了比较,Melissa O'Neill的优先级队列代码在k经验复杂度下运行,在{{1}}质数产生。当然,它比这里的代码要好得多。


以下是Eratosthenes定义的筛子:

P = {3,5,...} \ U {{p p,p p + 2 * p ,. ..} | p在 P }

Eratosthenes效率筛选的关键是直接生成素数的倍数,通过从每个素数计算增量(两次)素数值;它们的直接消除,可以通过值和地址的混合来实现,就像整数排序算法一样(只有可变数组才有可能)。它是否必须产生预先设定的素数或无限期地工作是无关紧要的,因为它总是可以按片段工作。