大数整数类型的性能

Performance of Integer type with large numbers

我正在尝试解决 AdventOfCode 2018 day 14。任务大致是通过在两个已经存在的数字的基础上迭代地附加一个或两个数字来创建一个有很多数字的数字。对于 Haskell,我认为 Integer 可能很适合代表巨大的数字。我认为我的程序正确,至少它似乎适用于 AoC 提供的样本。但是我注意到,当数字包含超过 10^4 个数字(附加程序中的recipeCount)时,程序的性能会急剧下降。当将数字增加到以下位数时,我观察到以下执行时间:

看起来像 O(n^2) 或更糟,不是吗?

这是为什么?据我所知,该程序只进行基本计算。

import Data.Bool (bool)

main :: IO ()
main = print solve

recipeCount :: Int
recipeCount = 10000

solve :: Integer
solve = loop 0 1 37 2
    where
        loop recipeA recipeB recipes recipesLength
            | recipesLength >= recipeCount + 10 = recipes `rem` (10 ^ 10)
            | otherwise =
                let recipeAScore = digitAt (recipesLength - 1 - recipeA) recipes
                    recipeBScore = digitAt (recipesLength - 1 - recipeB) recipes
                    recipeSum = fromIntegral $ recipeAScore + recipeBScore
                    recipeSumDigitCount = bool 2 1 $ recipeSum < 10
                    recipes' = recipes * (10 ^ recipeSumDigitCount) + recipeSum
                    recipesLength' = recipesLength + recipeSumDigitCount
                    recipeA' = (recipeA + recipeAScore + 1) `rem` recipesLength'
                    recipeB' = (recipeB + recipeBScore + 1) `rem` recipesLength'
                in loop recipeA' recipeB' recipes' recipesLength'

digitAt :: Int -> Integer -> Int
digitAt i number = fromIntegral $ number `quot` (10 ^ i) `rem` 10

P.S.: 因为我是 Haskell 的新手,我也非常感谢对程序本身(风格、算法等)的反馈。

编辑: 我找到了对我的程序的两个版本进行概要分析的选项。 两个版本均使用 ghc -O2 -rtsopts ./Program.hs 和 运行 以及 ./Program +RTS -sstderr.

编译

第一个整数版本在生成 50,000 个食谱时产生以下输出:

   2,435,108,280 bytes allocated in the heap
         886,656 bytes copied during GC
          44,672 bytes maximum residency (2 sample(s))
          29,056 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      1925 colls,     0 par    0.018s   0.017s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time   15.208s  ( 15.225s elapsed)
  GC      time    0.018s  (  0.017s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time   15.227s  ( 15.242s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    160,115,875 bytes per MUT second

  Productivity  99.9% of total user, 99.9% of total elapsed

具有可变数组的第二个版本在生成约 500,000 个食谱时产生以下输出:

      93,437,744 bytes allocated in the heap
          16,120 bytes copied during GC
         538,408 bytes maximum residency (2 sample(s))
          29,056 bytes maximum slop
               0 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        88 colls,     0 par    0.000s   0.000s     0.0000s    0.0000s
  Gen  1         2 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.021s  (  0.020s elapsed)
  GC      time    0.000s  (  0.000s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.021s  (  0.021s elapsed)

  %GC     time       0.0%  (0.0% elapsed)

  Alloc rate    4,552,375,284 bytes per MUT second

  Productivity  97.0% of total user, 97.2% of total elapsed

我认为首先使用 Integer 作为您的食谱列表是一个大危险信号。 Integers 存储号码,但您的问题不要求号码。它需要一个数字列表。一个 Integer,其优先级是一个数字,基本上是 "compressed":它是二进制的,而不是十进制的,并且试图从中提取一个十进制数字意味着你必须做时髦的,不平凡的数学,正如其他人所说。此外,纯度对您不利,因为每次向列表添加新数字时,您最终都会复制整个列表。问题大小约为 100,000-1,000,000 位数字(我得到的问题输入约为 800,000),即每次复制 Integer 大小约为 log_(2^8)(10^(10^5)) = ~41000 字节。这部分似乎也是二次方的。

我会推荐 "decompressing" 你的数字列表。您可以用 1 个字节表示一个数字(这确实浪费了很多 space!)

import Data.Word
type Digit = <a href="https://hackage.haskell.org/package/base-4.12.0.0/docs/Data-Word.html#t:Word8" rel="nofollow noreferrer">Word8</a>
addDigit :: Digit -> Digit -> (Digit, Digit)
addDigit = _yourJob

您可以使用数组将逻辑的主体实现为函数。是的,Haskell 确实有数组,从具有实际 O(1) 索引的连续内存块的意义上来说。只是我们喜欢找到 "more functional" 的方式来表达问题而不是数组。但是,如果您需要它们,它们总是在那里。

import Data.Array.Unboxed -- from the <a href="https://hackage.haskell.org/package/array-0.5.3.0" rel="nofollow noreferrer">array</a> package, which is a core library
makeRecipes ::
  -- | Elf 1's starting score
  Digit ->
  -- | Elf 2's starting score
  Digit ->
  -- | Number of recipes to make
  Int ->
  -- | Scores of the recipes made, indices running from 0 upwards
  <a href="https://hackage.haskell.org/package/array-0.5.3.0/docs/Data-Array-Unboxed.html#t:UArray" rel="nofollow noreferrer">UArray</a> Int Digit

数组最酷的地方在于你可以在 ST monad 中改变它们,同时得到一个纯粹的结果。因此,这个数组不会受到任何复制,索引它所涉及的数学运算也很少。

import Control.Monad.ST
import Data.Array.ST
makeRecipes elf1 elf2 need = <a href="https://hackage.haskell.org/package/array-0.5.3.0/docs/Data-Array-ST.html#v:runSTUArray" rel="nofollow noreferrer">runSTUArray</a> $ do
  arr <- <a href="https://hackage.haskell.org/package/array-0.5.3.0/docs/Data-Array-MArray.html#v:newArray_" rel="nofollow noreferrer">newArray_</a> (0, need)
  <a href="https://hackage.haskell.org/package/array-0.5.3.0/docs/Data-Array-MArray.html#v:writeArray" rel="nofollow noreferrer">writeArray</a> arr 0 elf1
  writeArray arr 1 elf2
  loop arr 0 1 2
  return arr
  where
    loop :: STUArray s Int Digit -> Int -> Int -> Int -> ST s ()
    loop arr loc1 loc2 done = _yourJob

loop 给出了数组,其中部分填充了 done 配方分数,以及两个精灵的位置 loc1, loc2 < done。它应该使用 addDigitreadArray 计算新食谱的分数,并使用 writeArray 将它们添加到数组的正确位置。如果数组已满,它应该终止(它没有 return 任何有用的东西)。否则,它应该继续计算精灵的新位置,然后递归。

然后您可以在 makeRecipes 之上编写一个小适配器来实际提取最后十个食谱,提供正确的输入等。当我填写程序中的所有空白时,我得到了一个运行时-O2 在我的输入(大约 800,000)上有 .07s,-O0 有大约 0.8s。输入似乎需要 O(n) 时间。