Почему этот простой тест настолько медленный?

Этот код был взят из книги "Haskell Road to Logic, Math and Programming". Он реализует сито алгоритма эратосфенов и решает проблему Project 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

На самом деле он работает еще медленнее, чем наивный первичный тест. Может ли кто-нибудь объяснить это поведение?

Я подозреваю, что он имеет какое-то отношение к итерации каждого элемента в списке в функции метки.

Спасибо.

Ответ 1

Вы строите квадратичное число необоснованных thunks, используя этот алгоритм. Алгоритм полагается на лень так сильно, что это также является причиной того, что он не масштабируется.

Пройдите через то, как это работает, что, надеюсь, должно сделать проблему очевидной. Для упрощения предположим, что мы хотим print элементы primes ad infinitum, т.е. Мы хотим оценивать каждую ячейку в списке один за другим. primes определяется как:

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

Так как 2 не равно 0, применяется второе определение sieve, а 2 добавляется в список простых чисел, а остальная часть списка - неоцененный thunk (я использую tail вместо соответствия шаблону n : xs в sieve для xs, поэтому tail на самом деле не вызывается и не добавляет никаких накладных расходов в код ниже; mark - фактически единственная функция thunked):

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

Теперь нам нужен второй элемент primes. Итак, мы просматриваем код (упражнение для читателя) и заканчиваем:

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

Повторяем ту же процедуру, мы хотим оценить следующее простое...

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

Это начинает выглядеть как LISP, но я отвлекаюсь... Начав видеть проблему? Для каждого элемента в списке primes необходимо оценить все больший объем стеков приложений mark. Другими словами, для каждого элемента в списке должна быть проверка того, отмечен ли этот элемент любым из предыдущих простых чисел, путем оценки каждого приложения mark в стеке. Итак, для n~=2000000, время выполнения Haskell должно вызывать функции, приводящие к стеку вызовов с глубиной около... Я не знаю, 137900 (let n = 2e6 in n / log n дает нижнюю границу)? Что-то вроде того. Вероятно, это является причиной замедления; возможно vacuum может рассказать вам больше (у меня нет компьютера с Haskell и графическим интерфейсом прямо сейчас).

Причина, по которой сито Эратосфена работает на таких языках, как C, таково:

  • Вы не используете бесконечный список.
  • Из-за (1) вы можете пометить весь массив, прежде чем продолжить следующий n, в результате чего накладные расходы стека вызовов вообще не будут.

Ответ 2

Это не только шумы, которые делают его ужасно медленным, этот алгоритм также будет очень медленным, если он реализован в 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)) для реального сита Эратосфена).

Консоль nesting ¹

упомянутая dflemstr, усугубляет проблему², но даже без этого алгоритм будет хуже, чем у Тернера. Я одновременно потрясен и очарован.

¹ "Вложение" может быть неправильным. Хотя каждый из mark thunks доступен только через одно над ним, они ничего не ссылаются ни на что из вмещающего объема 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, который заставляет mark 2 2 thunk, что заставляет [8 .. ] thunk и заменяет голову на 0 и обертывает хвост в mark 2 1 thunk. Это пузырится до sieve, который отбрасывает 0, а затем заставляет следующий стек thunks.

Итак, для каждого числа от p_k + 1 до p_(k+1) (включительно) совпадение шаблона в sieve заставляет стек/цепочку k thunks формы mark p r. Каждый из них принимает (y:ys), полученный от закрытого thunk ([y ..] для самого внутреннего mark 2 r), и обертывает хвост ys в новый кусок, оставляя y неизменным или заменяя его 0, создавая таким образом новый стек/цепочку thunks в том, что будет хвостом списка, достигающим sieve.

Для каждого найденного простого, sieve добавляет еще один mark p r thunk сверху, поэтому в конце, когда найдено первое число больше 2000000, и takeWhile (< 2000000) заканчивается, будет 148933 уровней thunks.

Укладка трюков здесь не влияет на сложность, она просто влияет на постоянные факторы. В ситуации, с которой мы имеем дело, лениво сгенерированный бесконечный неизменный список, мало что можно сделать, чтобы сократить время, затрачиваемое на перенос управления с одного потока на другой. Если бы мы имели дело с конечным изменчивым списком или массивом, который не генерируется лениво, как на языке, таком как C или Java, было бы намного лучше, если бы каждый mark p завершил свою полную работу (это было бы просто for с меньшими накладными расходами, чем вызов функции/передача управления), прежде чем рассматривать следующий номер, поэтому никогда не будет более одного активного и менее управляемого маркера.

Ответ 3

Хорошо, вы определенно правы, это медленнее, чем наивная реализация. Я взял это из Википедии и сравнил его с вашим кодом с 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)

Я думаю, что, возможно, я сделал небольшую ошибку до конца там, так как похоже, что он вот-вот превратится в 0 и удален, но механизм ясен. Этот код просто создает набор счетчиков, подсчитывающих до каждого шага, испуская следующий штрих в нужный момент и передавая его по списку. Это эквивалентно простому проверке деления на каждое предыдущее правое, как в наивной реализации, с дополнительными накладными расходами на прохождение 0s или простых чисел между thunks.

Здесь может быть какая-то дальнейшая утонченность, которую мне не хватает. Существует очень подробное описание "Сито Эратосфена" в Хаскелле и различные оптимизации здесь.

Ответ 4

короткий ответ: счетное сито происходит медленнее, чем сито Turner (a.k.a. "наивное" ), потому что оно эмулирует прямой доступ к ОЗУ с последовательным подсчетом, что заставляет его проходить по потокам, которые не были достигнуты между этапами маркировки. Это иронично, потому что подсчет делает его "подлинным" ситом Эратосфена, в отличие от сита деления Тёрнера. Фактически удаление кратных, например, ситча Тернера, испортило бы подсчет.

Оба алгоритма чрезвычайно медленны, потому что они запускают кратковременную работу по устранению слишком рано из каждого найденного штриха вместо его квадрата, создавая при этом слишком много ненужных этапов обработки потока (будь то фильтрация или маркировка) - O(n), а не просто ~ 2*sqrt n/log n, при получении простых значений до n по значению. Не требуется проверять кратность 7, пока на входе не появится 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

Решетка Turner использует nomult p = filter ((/=0).(`rem`p)) вместо записей mark _ p, но в остальном выглядит одинаково:

[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 

Каждый такой преобразователь может быть реализован как кадр замыкания (a.k.a. "thunk" ) или генератор с изменяемым состоянием, что неважно. Выход каждого такого производителя поступает непосредственно в качестве своего преемника в цепочке. Здесь нет неоцененных громов, каждый вынужден своим потребителем производить свой следующий выход.

Итак, чтобы ответить на ваш вопрос,

Я подозреваю, что он имеет какое-то отношение к итерации каждого элемента в списке в функции метки.

да, точно. Они оба запускают не отложенные схемы в противном случае.


Таким образом, код можно улучшить, отложив начало маркировки потока:

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

sieve (x:xs) [email protected] ~(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 можно найти, добавив 6, снова и снова.) И вместо этого, вместо того, чтобы маркировать, мы можем вырвать цифры сразу, получив себя добросовестным сито Эратосфена (даже если оно не является наиболее эффективным):

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

sieve (x:xs) [email protected] ~(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 [email protected](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 простых пробых, быстро-n-грязное тестирование, скомпилированное в GHCi, производящие до 100k - 150k простых чисел, ухудшаясь до O(k^1.3) примерно на 0,5 мила простых чисел.


Итак, какие ускорения достигнуты этим? Сравнивая OP-код с ситтером Turner Википедии,

primes = sieve [2..] :: [Int]
  where
    sieve (x:xs) = x : sieve [y | y <- xs, rem y x /= 0]

наблюдалось ускорение W/OP 8x при 2k (т.е. производство 2000 простых чисел). Но в 4k это было ускорение 15x. Похоже, что сито Тёрнера работает примерно с O(k^1.9 .. 2.3) эмпирической сложностью при изготовлении простых чисел k = 1000 .. 6000, а счетное сито в O(k^2.3 .. 2.6) для того же диапазона.

Для двух версий здесь, в этом ответе, v1/W был дополнительным 20x быстрее на 4k, а 43x на 8k. v2/v1 был 5.2x при 20k, 5.8x при 40k и 6.5x быстрее при создании 80 000 простых чисел.

(для сравнения, код приоритетной очереди Мелиссы О'Нил работает примерно с O(k^1.2) эмпирической сложностью, в выражении k.) Он, конечно, масштабируется намного лучше кода здесь).


Вот сит определения Эратосфена:

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

Ключом к Эффекту Эффекта Эратосфена является прямая генерация кратных простых чисел путем подсчета с приращением (дважды) основного значения от каждого штриха; и их прямое устранение, стало возможным благодаря объединению ценности и адреса, как в алгоритмах целочисленной сортировки (возможно только с изменяемыми массивами). Не имеет значения, должно ли оно производить заранее заданное количество простых чисел или работать бесконечно, потому что оно всегда может работать по сегментам.