Эффективная работа с битами в реализации LFSR

хотя у меня есть хорошая реализация LSFR C, я думал, что попробую то же самое в Haskell - просто посмотреть, как это происходит. То, что я придумал, до сих пор на два порядка медленнее, чем реализация C, что вызывает вопрос:как можно улучшить представление? очевидно, что операции с битами являются узким местом, и профилировщик подтверждает это.

вот базовый код Haskell с использованием списков и Data.Bits:

import           Control.Monad      (when)
import           Data.Bits          (Bits, shift, testBit, xor, (.&.), (.|.))
import           System.Environment (getArgs)
import           System.Exit        (exitFailure, exitSuccess)

tap :: [[Int]]
tap = [
    [],            [],            [],            [3, 2],
    [4, 3],        [5, 3],        [6, 5],        [7, 6],
    [8, 6, 5, 4],  [9, 5],        [10, 7],       [11, 9],
    [12, 6, 4, 1], [13, 4, 3, 1], [14, 5, 3, 1], [15, 14],
    [16,15,13,4],  [17, 14],      [18, 11],      [19, 6, 2, 1],
    [20, 17],      [21, 19],      [22, 21],      [23, 18],
    [24,23,22,17], [25, 22],      [26, 6, 2, 1], [27, 5, 2, 1],
    [28, 25],      [29, 27],      [30, 6, 4, 1], [31, 28],
    [32,22,2,1],   [33,20],       [34,27,2,1],   [35,33],
    [36,25],       [37,5,4,3,2,1],[38,6,5,1],    [39,35],
    [40,38,21,19], [41,38],       [42,41,20,19], [43,42,38,37],
    [44,43,18,17], [45,44,42,41], [46,45,26,25], [47,42],
    [48,47,21,20], [49,40],       [50,49,24,23], [51,50,36,35],
    [52,49],       [53,52,38,37], [54,53,18,17], [55,31],
    [56,55,35,34], [57,50],       [58,39],       [59,58,38,37],
    [60,59],       [61,60,46,45], [62,61,6,5],   [63,62]        ]

xor' :: [Bool] -> Bool
xor' = foldr xor False

mask ::  (Num a, Bits a) => Int -> a
mask len = shift 1 len - 1

advance :: Int -> [Int] -> Int -> Int
advance len tap lfsr
    | d0        = shifted
    | otherwise = shifted .|. 1
    where
        shifted = shift lfsr 1 .&. mask len
        d0 = xor' $ map (testBit lfsr) tap'
        tap' = map (subtract 1) tap

main :: IO ()
main = do
    args <- getArgs
    when (null args) $ fail "Usage: lsfr <number-of-bits>"
    let len = read $ head args
    when (len < 8) $ fail "No need for LFSR"
    let out = last $ take (shift 1 len) $ iterate (advance len (tap!!len)) 0
    if out == 0 then do
        putStr "OKn"
        exitSuccess
    else do
        putStr "FAILn"
        exitFailure

в основном он проверяет, является ли LSFR определена в tap :: [[Int]] на любой бит-длина максимальной длины. (Точнее, он просто проверяет, достигает ли LSFR начального состояния (нуля) после 2n итераций.)

согласно профилировщику самая дорогая линия бит обратной связи d0 = xor' $ map (testBit lfsr) tap'.

что я пробовал до сих пор:

  • использовать Data.Array: попытка оставлена, потому что нет foldl/r
  • использовать Data.Vector: Немного быстрее, чем базовый

параметры компилятора, которые я использую: -O2, LTS Haskell 8.12 (GHC-8.0.2).

справочную программу на C++ можно найти на gist.github.com.

код Haskell не может быть ожидаемым (?) чтобы работать так же быстро, как код C, но два порядка величины слишком много, должен быть лучший способ сделать бит-скрипку.

Update: результаты применения оптимизаций, предложенных в ответы

  • справочная программа C++ с входным сигналом 28, скомпилированная с LLVM 8.0.0, работает в 0.67 s на моей машине (то же самое с clang 3.7 немного медленнее, 0.68 s)
  • базовый код Haskell работает примерно на 100 раз медленнее (из-за неэффективности пространства не пытайтесь использовать его с входами больше 25)
  • с переписыванием @Thomas M. DuBuisson, все еще используя бэкэнд GHC по умолчанию, время выполнения идет вниз к 5.2 s
  • с переписыванием @Thomas M. DuBuisson, теперь используя бэкэнд LLVM (опция GHC -O2 -fllvm), время выполнения идет вниз до 1.7 с
    • использование опции GHC -O2 -fllvm -optlc -mcpu=native доводит это до 0.73 s
  • замена iterate С iterate' @cirdec не имеет значения, когда используется код Томаса (как с "родным" бэкэндом по умолчанию, так и с LLVM). Однако это тут сделайте разницу, когда базовый код используемый.

Итак, мы пришли от 100x до 8x до 1.09 x, т. е. только на 9% медленнее, чем C!

Примечание Бэкэнд LLVM для GHC 8.0.2 требует LLVM 3.7. На Mac OS X это означает установку этой версии с brew и затем symlinking opt и llc. См.7.10. GHC Backends.

3 ответов


Передние Дела

для начала я использую GHC 8.0.1 на Intel I5 ~2.5 GHz, linux x86-64.

Первый Черновик: О Нет! Время замедляется!

ваш начальный код с параметром 25 работает:

% ghc -O2 orig.hs && time ./orig 25
[1 of 1] Compiling Main             ( orig.hs, orig.o )
Linking orig ...
OK
./orig 25  7.25s user 0.50s system 99% cpu 7.748 total

таким образом, время бить 77ms - на два порядка лучше, чем этот код Haskell. Давайте нырять.

Выпуск 1: Шифти Код

я нашел пару странности с кодом. Сначала было использование shift в коде высокой эффективности. Shift поддерживает как левый, так и правый сдвиг, и для этого требуется ветвь. Давайте убьем это с более читаемыми способностями двух и таких (shift 1 x~>2^x и shift x 1~>2*x):

% ghc -O2 noShift.hs && time ./noShift 25
[1 of 1] Compiling Main             ( noShift.hs, noShift.o )
Linking noShift ...
OK
./noShift 25  0.64s user 0.00s system 99% cpu 0.637 total

(как вы отметили в комментариях: Да, это связано с расследованием. Возможно, какая-то странность предыдущего кода препятствовала запуску правила перезаписи и, как следствие, гораздо худшему коду результат)

Проблема 2: Списки Битов? Int операции сохранить день!

одно изменение, один порядок величины. Ура. Что еще? Ну, у вас есть этот неудобный список битовых местоположений, которые вы нажимаете, что просто кажется, что он умоляет о неэффективности и / или опирается на хрупкие оптимизации. На этом этапе я отмечу, что жесткое кодирование любого выбора из этого списка приводит к действительно хорошей производительности (например,testBit lsfr 24 `xor` testBit lsfr 21), но мы хотим более общем быстро решение.

я предлагаю вычислить маску все места крана затем сделать одну инструкцию поп-счет. Для этого нам нужен только один Int принят в advance вместо целого списка. Инструкция popcount требует хорошей генерации сборки, которая требует llvm и, вероятно,-optlc-mcpu=native или другой выбор набора инструкций, который не является пессимистичным.

этот шаг дает нам pc ниже. Я сложил в карауле-удаление advance об этом говорилось в комментариях:

let tp = sum $ map ((2^) . subtract 1) (tap !! len)
    pc lfsr = fromEnum (even (popCount (lfsr .&. tp)))
    mask = 2^len - 1
    advance' :: Int -> Int
    advance' lfsr = (2*lfsr .&. mask) .|. pc lfsr 
    out :: Int
    out = last $ take (2^len) $ iterate advance' 0

наша результирующая производительность:

% ghc -O2 so.hs -fforce-recomp -fllvm -optlc-mcpu=native && time ./so 25      
[1 of 1] Compiling Main             ( so.hs, so.o )
Linking so ...
OK
./so 25  0.06s user 0.00s system 96% cpu 0.067 total

это более двух порядков величины от начала до конца, поэтому, надеюсь, он соответствует вашему C. наконец, в развернутом коде на самом деле очень часто встречаются пакеты Haskell с привязками C, но это часто учебное упражнение, поэтому я надеюсь, что вам было весело.

Edit: теперь доступный код C++ принимает мою систему 0.10 (g++ -O3) и 0.12 (clang++ -O3 -march=native) секунд, так что кажется, мы немного превзошли нашу цель.


я подозреваю, что следующая строка строит большой список, как thunk в памяти, прежде чем оценивать его.

let out = last $ take (shift 1 len) $ iterate (advance len (tap!!len)) 0` is 
-rtsopts кроме -O2. Это позволяет запускать программу с помощью параметры RTS, inclusing +RTS -s который выводит небольшую память резюме.

Начальная Производительность

запуск вашей программы как lfsr 25 +RTS -s я получаю следующий вывод

OK
   5,420,148,768 bytes allocated in the heap
   6,705,977,216 bytes copied during GC
   1,567,511,384 bytes maximum residency (20 sample(s))
     357,862,432 bytes maximum slop
            3025 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     10343 colls,     0 par    2.453s   2.522s     0.0002s    0.0009s
  Gen  1        20 colls,     0 par    2.281s   3.065s     0.1533s    0.7128s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    1.438s  (  1.162s elapsed)
  GC      time    4.734s  (  5.587s elapsed)
  EXIT    time    0.016s  (  0.218s elapsed)
  Total   time    6.188s  (  6.967s elapsed)

  %GC     time      76.5%  (80.2% elapsed)

  Alloc rate    3,770,538,273 bytes per MUT second

  Productivity  23.5% of total user, 19.8% of total elapsed

это много памяти, используемой сразу. Очень вероятно, что где-то там есть большое здание.

пытается уменьшить размер thunk

я предположил, что thunk строится в iterate (advance ...). Если это так, мы можем попытаться уменьшить размер thunk, сделав advance более строгий в своем lsfr аргумент. Это не удалит позвоночник thunk (последовательные итерации), но это может уменьшить размер состояния, которое создается по мере оценки позвоночника.

BangPatterns - простой способ сделать функцию строгой в аргументе. f !x = .. сокращенно от f x = seq x $ ...

{-# LANGUAGE BangPatterns #-}

advance :: Int -> [Int] -> Int -> Int
advance len tap = go
  where
    go !lfsr
      | d0        = shifted
      | otherwise = shifted .|. 1
      where
        shifted = shift lfsr 1 .&. mask len
        d0 = xor' $ map (testBit lfsr) tap'
    tap' = map (subtract 1) tap

давайте посмотрим, что это меняет ...

>lfsr 25 +RTS -s
OK
   5,420,149,072 bytes allocated in the heap
   6,705,979,368 bytes copied during GC
   1,567,511,448 bytes maximum residency (20 sample(s))
     357,862,448 bytes maximum slop
            3025 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0     10343 colls,     0 par    2.688s   2.711s     0.0003s    0.0059s
  Gen  1        20 colls,     0 par    2.438s   3.252s     0.1626s    0.8013s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    1.328s  (  1.146s elapsed)
  GC      time    5.125s  (  5.963s elapsed)
  EXIT    time    0.000s  (  0.226s elapsed)
  Total   time    6.484s  (  7.335s elapsed)

  %GC     time      79.0%  (81.3% elapsed)

  Alloc rate    4,081,053,418 bytes per MUT second

  Productivity  21.0% of total user, 18.7% of total elapsed

ничего заметного.

исключения позвоночника

я думаю, это позвоночник это iterate (advance ...) это строится. В конце концов, для команды, которую я запускаю, список будет 2^25, или чуть более 33 миллионов пунктов. Сам список, вероятно, удаляется список fusion, но thunk для последнего элемента списка составляет более 33 миллионов приложений advance ...

чтобы решить эту проблему, нам нужна строгая версия iterate так что значение принудительно к Int перед нанесением advance функции снова. Это должно держать память до одного lfsr значение в то время, наряду с в настоящее время вычисляется применение advance.

к сожалению, нет строгой iterate на Data.List. Вот один, который не отказывается от списка fusion, который предоставляет другие важные (Я думаю) оптимизации производительности для этой проблемы.

{-# LANGUAGE BangPatterns #-}

import GHC.Base (build)

{-# NOINLINE [1] iterate' #-}
iterate' :: (a -> a) -> a -> [a]
iterate' f = go
  where go !x = x : go (f x)

{-# NOINLINE [0] iterateFB' #-}
iterateFB' :: (a -> b -> b) -> (a -> a) -> a -> b
iterateFB' c f = go
  where go !x = x `c` go (f x)

{-# RULES
"iterate'"    [~1] forall f x. iterate' f x = build (\c _n -> iterateFB' c f x)
"iterateFB'"  [1]              iterateFB' (:) = iterate'
 #-}

это просто iterate С GHC.List (вместе со всеми своими правилами переписывания), но сделанный строгим в аккумулированном аргумент.

оснащен строгой итерации,iterate', мы можем изменить хлопотно строки

let out = last $ take (shift 1 len) $ iterate' (advance len (tap!!len)) 0

я ожидаю, что это будет работать гораздо лучше. Давайте посмотрим ...

>lfsr 25 +RTS -s
OK
   3,758,156,184 bytes allocated in the heap
         297,976 bytes copied during GC
          43,800 bytes maximum residency (1 sample(s))
          21,736 bytes maximum slop
               1 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      7281 colls,     0 par    0.047s   0.008s     0.0000s    0.0000s
  Gen  1         1 colls,     0 par    0.000s   0.000s     0.0002s    0.0002s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.750s  (  0.783s elapsed)
  GC      time    0.047s  (  0.008s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.797s  (  0.792s elapsed)

  %GC     time       5.9%  (1.0% elapsed)

  Alloc rate    5,010,874,912 bytes per MUT second

  Productivity  94.1% of total user, 99.0% of total elapsed

это 0.00002 раз больше памяти и работает в 10 раз быстрее.

я не знаю, если это улучшит на ответ, что улучшает advance но все равно оставляет ленивый iterate advance' на месте. Было бы легко проверить; добавьте iterate' код к этому ответу и используйте iterate' на месте iterate в ответ.


  1. поднимает ли компилятор tap !! len выход из цикла? Я подозреваю, что это так, но перемещение его, чтобы гарантировать, что это не повредит:

    let tap1 = tap !! len
    let out = last $ take (shift 1 len) $ iterate (advance len tap1) 0    
    
  2. в комментариях вы говорите:"2^len нужен ровно один раз", но это неправильно. Вы делаете это каждый раз в advance. Так что вы можете попробовать

    advance len tap mask lfsr
        | d0        = shifted
        | otherwise = shifted .|. 1
        where
            shifted = shift lfsr 1 .&. mask
            d0 = xor' $ map (testBit lfsr) tap'
            tap' = map (subtract 1) tap
    
    -- in main
    let tap1 = tap !! len
    let numIterations = 2^len
    let mask = numIterations - 1
    let out = iterate (advance len tap1 mask) 0 !! (numIterations - 1)
    

    (компилятор не может оптимизировать last $ take ... to !! в целом, потому что они разные для конечных списков, но iterate всегда возвращает бесконечный один.)

  3. вы сравнили foldr С foldl, а foldl почти никогда не то, что вам нужно; С xor всегда нуждается в обоих Аргументах и является ассоциативным,foldl', скорее всего, будет правильным выбором (компилятор может оптимизировать его, но если есть какая-либо реальная разница между foldl и foldr и не просто случайная вариация, в этом случае она могла бы потерпеть неудачу).