6

在 Haskell 中,我在Rosetta Code 页面上找到了三个简单的 Eratosthenes 筛子实现。

现在我的问题是,在什么情况下应该使用哪一个?

纠正我最初的推理也会有帮助:

我假设 List one 对于 Haskeller 来说是最惯用和最容易阅读的。但是,它正确吗?我想知道它是否遇到与我后来学到的另一个基于列表的筛子相同的问题,但实际上并没有实现算法:(
编辑:这里显示的是我知道有问题的基于列表的筛子,而不是来自 RosettaCode 的那个,我粘贴在底部)

primes = sieve [2..] where
         sieve (p:x) = p : sieve [ n | n <- x, n `mod` p > 0 ]

在性能方面,不可变数组似乎是赢家。上限m为 时2000000,时间约为:

  • 列表 1.3s
  • 阵列 0.6s
  • 可变数组 1.8s

所以我会选择 Array 来提高性能。

当然,可变数组也很容易推理,因为我有更命令式的语言背景。不过,如果我在 Haskell 中编码,我不确定为什么我会选择这个,因为它比其他的慢而且不习惯。

此处复制代码以供参考:

列表:

primesTo m = 2 : eratos [3,5..m] where
eratos (p : xs) | p*p>m = p : xs
                | True  = p : eratos (xs `minus` [p*p, p*p+2*p..])

minus a@(x:xs) b@(y:ys) = case compare x y of
     LT -> x : minus  xs b
     EQ ->     minus  xs ys
     GT ->     minus  a  ys
minus a        b        = a 

不可变数组:

import Data.Array.Unboxed

primesToA m = sieve 3 (array (3,m) [(i,odd i) | i<-[3..m]] :: UArray Int Bool)
  where
    sieve p a 
      | p*p > m   = 2 : [i | (i,True) <- assocs a]
      | a!p       = sieve (p+2) $ a//[(i,False) | i <- [p*p, p*p+2*p..m]]
      | otherwise = sieve (p+2) a  

可变数组:

import Control.Monad (forM_, when)
import Control.Monad.ST
import Data.Array.ST
import Data.Array.Unboxed

primeSieve :: Integer -> UArray Integer Bool
primeSieve top = runSTUArray $ do
    a <- newArray (2,top) True           -- :: ST s (STUArray s Integer Bool)
    let r = ceiling . sqrt $ fromInteger top
    forM_ [2..r] $ \i -> do
      ai <- readArray a i
      when ai $ do
        forM_ [i*i,i*i+i..top] $ \j -> do
          writeArray a j False
    return a

-- Return primes from sieve as list:  
primesTo :: Integer -> [Integer]
primesTo top = [p | (p,True) <- assocs $ primeSieve top]

编辑

  • 我在顶部展示了特纳的筛子,但这不是我要与其他两个比较的基于列表的示例。我只是想知道基于列表的示例是否存在与特纳相同的“不是正确的埃拉托色尼筛法”问题。
  • 看来性能比较是不公平的,因为可变数组示例也经历了偶数并使用Integer而不是Int......
4

3 回答 3

15

primes = sieve [2..] where
         sieve (p:x) = p : sieve [ n | n <- x, n `mod` p > 0 ]

不是筛子。这是非常低效的审判部门。不要用那个!

我很好奇你是怎么得到你的时间的,特纳“筛子”不可能在几秒钟内产生不超过 2,000,000 个的素数。让它找到 200,000 的素数

MUT     time    6.38s  (  6.39s elapsed)
GC      time    9.19s  (  9.20s elapsed)
EXIT    time    0.00s  (  0.00s elapsed)
Total   time   15.57s  ( 15.59s elapsed)

在我的机器上(64 位 Linux,ghc-7.6.1,使用 -O2 编译)。该算法的复杂度O(N² / log² N)几乎是二次的。让它前进到 2,000,000 大约需要 20 分钟。

您对阵列版本的时间也很可疑,尽管方向相反。你测量解释代码了吗?

筛选到 2,000,000,经过优化编译,可变数组代码的运行时间为 0.35 秒,不可变数组代码的运行时间为 0.12 秒。

现在,可变数组仍然比不可变数组慢三倍。

但是,这是一个不公平的比较。对于不可变数组,您使用Int了 s,而对于可变数组Integers。将可变数组代码更改为使用Ints - 因为它应该,因为在引擎盖下,数组是Int-indexed,所以 usingInteger是不必要的性能牺牲,什么都买不到 - 使可变数组代码在 0.15 秒内运行。接近可变数组代码,但不完全在那里。但是,您让可变数组做更多的工作,因为在不可变数组代码中您只消除奇素数的奇数倍数,但在可变数组代码中,您标记所有素数的所有倍数。更改可变数组代码以特别处理 2,并且仅消除奇数的奇数倍数也将其降低到 0.12 秒。

但是,您正在使用经过范围检查的数组索引,这很慢,而且由于在代码本身中检查了索引的有效性,因此没有必要。将其更改为 usingunsafeRead并将unsafeWrite不可变数组的时间缩短到 0.09 秒。

然后你有使用的问题

forM_ [x, y .. z]

使用 boxed Ints(幸运的是,GHC 消除了该列表)。自己写一个循环,这样就只Int#用了 unboxed s,时间降到 0.02 秒。

{-# LANGUAGE MonoLocalBinds #-}
import Control.Monad (forM_, when)
import Control.Monad.ST
import Data.Array.ST
import Data.Array.Unboxed
import Data.Array.Base

primeSieve :: Int -> UArray Int Bool
primeSieve top = runSTUArray $ do
    a <- newArray (0,top) True
    unsafeWrite a 0 False
    unsafeWrite a 1 False
    let r = ceiling . sqrt $ fromIntegral top
        mark step idx
            | top < idx = return ()
            | otherwise = do
                unsafeWrite a idx False
                mark step (idx+step)
        sift p
            | r < p     = return a
            | otherwise = do
                prim <- unsafeRead a p
                when prim $ mark (2*p) (p*p)
                sift (p+2)
    mark 2 4
    sift 3

-- Return primes from sieve as list:
primesTo :: Int -> [Int]
primesTo top = [p | (p,True) <- assocs $ primeSieve top]

main :: IO ()
main = print .last $ primesTo 2000000

所以,总结一下,对于埃拉托色尼筛,你应该使用一个数组——这并不奇怪,它的效率取决于能够在很短的恒定时间内从一个倍数步进到下一个倍数。

你会得到非常简单直接的不可变数组代码,并且该代码在不太高的限制下表现得很好(对于更高的限制它会变得相对更糟,因为数组仍然被复制和垃圾收集,但这还不错)。

当您需要更好的性能时,您需要可变数组。编写高效的可变数组代码并非易事,必须知道编译器如何翻译各种结构以选择正确的结构,有些人会认为这样的代码是单一的。但是您也可以使用提供相当有效实现的库(免责声明:我编写的),而不是自己编写。

于 2013-02-22T14:19:41.720 回答
3

可变数组在性能方面永远是赢家(你真的应该复制只在赔率上工作的版本作为最低限度;它应该是三个中最快的 - 也是因为它使用Int而不是Integer)。

对于列表树形合并增量筛的性能应该比你展示的要好。takeWhile (< limit)如果需要,您可以随时使用它。我认为它最清楚地传达了筛子的真实性质:

import Data.List (unfoldr)

primes :: [Int]         
primes = 2 : _Y ((3 :) . gaps 5 . _U . map (\p -> [p*p, p*p+2*p..]))

_Y g = g (_Y g)                                -- recursion 
_U ((x:xs):t) = (x :) . union xs . _U          -- ~= nub . sort . concat
                      . unfoldr (\(a:b:c) -> Just (union a b, c)) $ t

gaps k s@(x:xs) | k < x     = k : gaps (k+2) s   -- ~= [k,k+2..]\\s, when 
                | otherwise =     gaps (k+2) xs  --  k<=x && null(s\\[k,k+2..])

union a@(x:xs) b@(y:ys) = case compare x y of  -- ~= nub . sort .: (++)
         LT -> x : union  xs  b
         EQ -> x : union  xs ys
         GT -> y : union  a  ys

_U重新实现Data.List.Ordered.unionAll,并且为了gaps 5效率(minus [5,7..])而融合,使用minusunion来自同一个

当然,没有什么能比得上简洁Data.List.nubBy (((>1).).gcd) [2..](但它很慢)。

对于您的第一个新问题:不是。它确实通过计数来找到倍数,就像任何真正的筛子一样(尽管列表上的“减号”当然性能不佳;上面通过将线性减法链重新排列为树折叠加法的减法来改进了((((xs-a)-b)-c)- ... )一点xs-(a+((b+c)+...)))。

于 2013-02-22T14:17:38.677 回答
1

如前所述,使用可变数组具有最佳性能。以下代码源自此“TemplateHaskell”版本,该版本转换回更符合直接可变数组解决方案的内容,因为“TemplateHaskell”似乎没有任何区别,并进行了一些进一步的优化。我相信它比通常的可变未装箱数组版本更快,因为进一步优化,特别是由于使用了“unsafeRead”和“unsafeWrite”函数,避免了数组的范围检查,可能也在内部使用指针进行数组访问:

{-# OPTIONS -O2 -optc-O3 #-}
import Control.Monad
import Control.Monad.ST
import Data.Array.ST
import Data.Array.Unboxed
import Data.Array.Base

primesToUA :: Word32-> [Word32]
primesToUA top = do
    let sieveUA top = runSTUArray $ do    
        let m = ((fromIntegral top) - 3) `div` 2 :: Int
        buf <- newArray (0,m) True -- :: ST s (STUArray s Int Bool)
        let cullUA i = do
            let p = i + i + 3
                strt = p * (i + 1) + i
            let cull j = do
                if j > m then cullUA (i + 1)
                else do
                    unsafeWrite buf j False
                    cull (j + p)
            if strt > m then return ()
            else do
                e <- unsafeRead buf i
                if e then cull strt else cullUA (i + 1)
        cullUA 0; return buf
    if top > 1 then 2 : [2 * (fromIntegral i) + 3 | (i,True) <- assocs $ sieveUA top]
    else []

main = do 
    x <- read `fmap` getLine      --   1mln    2mln    10mln   100mln
    print (last (primesToUA x))   --   0.01    0.02     0.09     1.26  seconds

编辑:上面的代码已被更正,下面的时间被编辑以反映更正,以及将分页与非分页版本进行比较的评论已被编辑。

将其运行到指示的最高范围的时间如上述源代码底部的评论表所示,由ideone.com测量,并且比@WillNess 发布的答案快大约五倍,也是在ideone测量的.com. 这段代码将素数剔除到 200 万只需要很短的时间,而将素数剔除到一亿只需要 1.26 秒。在我的 i7 (3.5 GHz) 上以 0.44 秒运行到一亿时,这些时间快了大约 2.86 倍,运行到十亿需要 6.81 秒。前者的内存使用量刚刚超过 6 兆字节,后者的内存使用量为 60 兆字节,这是巨大(位压缩)数组使用的内存。该数组还解释了非线性性能,因为当数组大小超过 CPU 缓存大小时,每个复合数表示剔除的平均内存访问时间会变差。

编辑_添加: 页面分段筛更有效,因为当缓冲区大小保持小于 L1 或 L2 CPU 缓存时,它具有更好的内存访问效率,并且具有无界的优点,因此上限不必是预先指定的内存占用量要小得多,即基素数小于所用范围的平方根加上页面缓冲内存。以下代码已编写为分页实现,比非分页版本要快一些;它还提供了一个优点,即可以将代码顶部的输出范围规范从“Word32”更改为“Word64”,因此它不限于 32 位数字范围,只需要花费一点处理时间(对于 32 位编译代码)适用于任何通用范围。代码如下:

-- from http://www.haskell.org/haskellwiki/Prime_numbers#Using_ST_Array

{-# OPTIONS -O2 -optc-O3 #-}
import Data.Word
import Control.Monad
import Control.Monad.ST
import Data.Array.ST
import Data.Array.Unboxed
import Data.Array.Base

primesUA :: () -> [Word32]
primesUA () = do
    let pgSZBTS = 262144 * 8
    let sieveUA low bps = runSTUArray $ do
        let nxt = (fromIntegral low) + (fromIntegral pgSZBTS)
        buf <- newArray (0,pgSZBTS - 1) True -- :: ST s (STUArray s Int Bool)
        let cullUAbase i = do
            let p = i + i + 3
                strt = p * (i + 1) + i
            when (strt < pgSZBTS) $ do
                e <- unsafeRead buf i
                if e then do
                    let loop j = do
                        if j < pgSZBTS then do
                            unsafeWrite buf j False
                            loop (j + p)
                        else cullUAbase (i + 1)
                    loop strt
                else cullUAbase (i + 1)
        let cullUA ~(p:t) = do
            let bp = (fromIntegral p)
                i = (bp - 3) `div` 2
                s = bp * (i + 1) + i
            when (s < nxt) $ do
                let strt = do
                    if s >= low then fromIntegral (s - low)
                    else  do
                        let b = (low - s) `rem` bp
                        if b == 0 then 0 else fromIntegral (bp - b)
                let loop j = do
                    if j < pgSZBTS then do
                        unsafeWrite buf j False
                        loop (j + (fromIntegral p))
                    else cullUA t
                loop strt
        if low <= 0 then cullUAbase 0 else cullUA bps
        return buf
    let sieveList low bps = do
        [2 * ((fromIntegral i) + low) + 3 | (i,True) <- assocs $ sieveUA low bps]
    let sieve low bps = do
        (sieveList low bps) ++ sieve (low + (fromIntegral pgSZBTS)) bps
    let primes' = ((sieveList 0 []) ++ sieve (fromIntegral pgSZBTS) primes') :: [Word32]
    2 : sieve 0 primes'

main = do 
   x <- read `fmap` getLine      --   1mln    2mln    10mln   100mln
                                 --   0.02    0.03     0.13     1.13  seconds
   print (length (takeWhile ((>=) (fromIntegral x)) (primesUA ())))

由于需要以不同于后续页面的方式从第一页数组中剔除复合数表示,因此上面的代码比非分页的情况多出几行代码。该代码还具有修复程序,因此不会由于基本素数列表和输出列表现在不是同一个列表而导致内存泄漏(从而避免将整个列表保留在内存中)。

请注意,由于剔除缓冲区的大小恒定小于 L2 CPU 缓存,此代码需要接近线性时间(在筛选范围内),因为范围变得更大。内存使用量是非分页版本使用量的一小部分,1 亿只使用不到 600 KB,10 亿只使用超过 600 KB,略有增加只是基素数所需的额外空间小于平方根的范围列表。

ideone.com 上,此代码在大约 1.13 秒内生成一亿个素数,大约 12 秒到十亿个(32 位设置)。可能车轮分解和肯定的多核处理会使它在多核 CPU 上更快。在我的 i7 (3.5 GHz) 上,筛分到一亿只需要 0.44 秒,筛到十亿只需要 4.7 秒,随着范围的增加,性能大致呈线性。似乎在 ideone.com 上运行的 GHC 版本中存在某种非线性开销,对于 i7 不存在的较大范围有一些性能损失,这可能与不同的垃圾收集有关,如页面正在为每个新页面创建新的缓冲区。 END_EDIT_ADD

EDIT_ADD2:似乎上述页面分段代码的大部分处理时间都用于(惰性)列表处理,因此相应地重新编写了代码,并进行了如下几个改进:

  1. 实现了一个不使用列表处理并使用“popCount”表查找来一次计算 32 位字中“一个”位的数量的素数计数功能。这样,与实际筛分时间相比,找到结果的时间是微不足道的。

  2. 将基本素数存储为位打包页面段列表,这比存储素数列表更节省空间,并且根据需要将页面段转换为素数的时间并不是很大的计算开销。

  3. 调整素数段生成函数,使得对于初始零页段,它使用自己的位模式作为源页,从而使复合数剔除代码更短更简单。

然后代码变成如下:

{-# OPTIONS -O3 -rtsopts #-} -- -fllvm ide.com doesn't support LLVM

import Data.Word
import Data.Bits
import Control.Monad
import Control.Monad.ST
import Data.Array.ST (runSTUArray)
import Data.Array.Unboxed
import Data.Array.Base

pgSZBTS = (2^18) * 8 :: Int -- size of L2 data cache

type PrimeType = Word32
type Chunk = UArray PrimeType Bool

-- makes a new page chunk and culls it
--   if the base primes list provided is empty then
--   it uses the current array as source (for zero page base primes)
mkChnk :: Word32 -> [Chunk] -> Chunk
mkChnk low bschnks = runSTUArray $ do
  let nxt = (fromIntegral low) + (fromIntegral pgSZBTS)
  buf <- nxt `seq` newArray (fromIntegral low, fromIntegral nxt - 1) True
  let cull ~(p:ps) =
        let bp = (fromIntegral p)
            i = (bp - 3) `shiftR` 1
            s = bp * (i + 1) + i in
        let cullp j = do
              if j >= pgSZBTS then cull ps
              else do
                unsafeWrite buf j False
                cullp (j + (fromIntegral p)) in
        when (s < nxt) $ do
          let strt = do
                if s >= low then fromIntegral (s - low)
                else  do
                  let b = (low - s) `rem` bp
                  if b == 0 then 0 else fromIntegral (bp - b)
          cullp strt
  case bschnks of
    [] -> do bsbf <- unsafeFreezeSTUArray buf
             cull (listChnkPrms [bsbf])
    _ -> cull $ listChnkPrms bschnks
  return buf

-- creates a page chunk list starting at the lw value
chnksList :: Word32 -> [Chunk]
chnksList lw =
  mkChnk lw basePrmChnks : chnksList (lw + fromIntegral pgSZBTS)

-- converts a page chunk list to a list of primes
listChnkPrms :: [Chunk] -> [PrimeType]
listChnkPrms [] = []
listChnkPrms ~(hdchnk@(UArray lw _ rng _):tlchnks) =
  let nxtp i =
        if i >= rng then [] else
          if unsafeAt hdchnk i then
            (case ((lw + fromIntegral i) `shiftL` 1) + 3 of
              np -> np) : nxtp (i + 1)
          else nxtp (i + 1) in
  (hdchnk `seq` lw `seq` nxtp 0) ++ listChnkPrms tlchnks

-- the base page chunk list used to cull the higher order pages,
--   note that it has special treatment for the zero page.
--   It is more space efficient to store this as chunks rather than
--   as a list of primes or even a list of deltas (gaps), with the
--   extra processing to convert as needed not too much.
basePrmChnks :: [Chunk]
basePrmChnks = mkChnk 0 [] : chnksList (fromIntegral pgSZBTS)

-- the full list of primes could be accessed with the following function.
primes :: () -> [PrimeType]
primes () = 2 : (listChnkPrms $ chnksList 0)

-- a quite fast prime counting up to the given limit using
--   chunk processing to avoid innermost list processing loops.
countPrimesTo :: PrimeType -> Int
countPrimesTo limit =
  let lmtb = (limit - 3) `div` 2 in
  let sumChnks acc chnks@(chnk@(UArray lo hi rng _):chnks') =
        let cnt :: UArray PrimeType Word32 -> Int
            cnt bfw =
              case if lmtb < hi then fromIntegral (lmtb - lo) else rng of
                crng -> case crng `shiftR` 5 of
                  rngw -> 
                    let cnt' i ac =
                          ac `seq` if i >= rngw then
                            if (i `shiftL` 5) >= rng then ac else
                              case (-2) `shiftL` fromIntegral (lmtb .&. 31) of
                                msk -> msk `seq`
                                  case (unsafeAt bfw rngw) .&.
                                       (complement msk) of
                                    bts -> bts `seq` case popCount bts of
                                      c -> c `seq` case ac + c of nac -> nac
                          else case ac + (popCount $ unsafeAt bfw i) of
                                 nacc -> nacc `seq` cnt' (i + 1) (nacc)
                    in cnt' 0 0 in
        acc `seq` case runST $ do -- make UArray _ Bool into a UArray _ Word32
          stbuf <- unsafeThawSTUArray chnk
          stbufw <- castSTUArray stbuf
          bufw <- unsafeFreezeSTUArray stbufw
          return $ cnt bufw of
            c -> c `seq` case acc + c of
              nacc -> nacc `seq` if hi >= lmtb then nacc
                      else sumChnks nacc chnks' in
  if limit < 2 then 0 else if limit < 3 then 1 else
    lmtb `seq` sumChnks 1 (chnksList 0)

main = do 
  x <- read `fmap` getLine  --  1mln   2mln  10mln  100mln 1000mln
                            --  0.02   0.03   0.06    0.45    4.60   seconds
                            --  7328   7328   8352    8352    9424  Kilobytes
  -- this takes 14.34 seconds and 9424 Kilobytes to 3 billion on ideone.com,
  -- and 9.12 seconds for 3 billion on an i7-2700K (3.5 GHz).
  -- The above ratio of about 1.6 is much better than usual due to
  -- the extremely low memory use of the page segmented algorithm.
  -- It seems thaat the Windows Native Code Generator (NCG) from GHC
  --   is particularly poor, as the Linux 32-bit version takes
  --   less than two thirds of the time for exactly the same program...
  print $ countPrimesTo x
--  print $ length $ takeWhile ((>=) x) $ primes () -- the slow way to do this

代码中给出的时间和内存要求与在 ideone.com 上运行时观察到的一样,在我的 i7-2700K (3.5 GHz) 上运行一、二、十、一百、一千(十亿)和三千(三十亿)需要 0.02、0.03、0.05、0.30、3.0 和 9.1 秒) 百万范围,具有几乎恒定的内存占用量,随着基本素数的数量小于所需范围的平方根而缓慢增加。当使用 LLVM 编译器后端编译时,这些时间分别变为 0.01、0.02、0.02、0.12、1.35 和 4.15 秒,因为它更有效地使用了寄存器和机器指令;最后一个非常接近与使用 64 位编译器而不是使用 32 位编译器编译时相同的速度,因为寄存器的有效使用意味着额外寄存器的可用性没有太大区别。

正如代码中所评论的那样,由于没有受到内存访问瓶颈的限制,我的真实机器和 ideone.com 服务器上的性能之间的比率变得比内存浪费更多的算法要小得多,因此速度限制主要是比率CPU 时钟速度以及每个时钟周期的 CPU 处理效率。然而,正如那里所评论的那样,当在 Windows(32 位编译器)下运行时,GHC 本地代码生成器(NCG)存在一些奇怪的低效率,因为运行时间比在 Linux 下运行(作为 ideone. com 服务器使用)。AFAIK 他们都有相同的 Haskell GHC 版本的通用代码库,唯一的分歧在于使用的链接器(它也与 LLVM 后端一起使用,不受影响),因为 GHC NCG 不使用 GCC,而只使用 mingw32 汇编器,

请注意,当使用 LLVM 编译器后端编译此代码时,其速度与为高度优化的“C/C++”实现编写的相同算法的速度大致相同,这表明 Haskell 确实有能力开发非常紧密的循环编码。可以说,一旦习惯了单子和非严格代码的 Haskell 范例,Haskell 代码比等效的“C/C++”代码更具可读性和安全性。埃拉托色尼筛子执行速度的进一步改进纯粹是对所用实现的调整,而不是在 Haskell 和“C/C++”之间选择语言。

总结: 当然,这还不是 Haskell 版本的埃拉托色尼筛法的终极速度,因为我们还没有调整内存访问以更有效地使用快速 CPU L1 缓存,也没有显着降低除了消除赔率处理之外,使用极端车轮分解所需的复合剔除操作的总数。然而,这足以回答这个问题,即可变数组是解决此类紧密循环类型问题的最有效方法,与使用列表或不可变数组相比,潜在的速度增益约为 100 倍。 END_EDIT_ADD2

于 2014-01-02T13:48:06.070 回答