Всегда гарантированный порядок оценки " seq "(со странным поведением "pseq" дополнительно)

документация говорит следующее:

примечание о порядке оценки: выражение seq a b не гарантируем, что a будет оцениваться перед b. Единственная гарантия, данная seq это как a и b будет оцениваться перед seq возвращает значение. В частности, это означает, что b может быть оценено перед a. Если вам нужно гарантировать определенный порядок оценки, вы должны использовать функция pseq из пакета" parallel".

Итак, у меня есть ленивая версия sum функции с аккумулятором:

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = go (x + acc) xs

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

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = let acc' = x + acc
                    in acc' `seq` go acc' xs

и я вижу огромное увеличение производительности! Но интересно, насколько она надежна? Мне повезло? Потому что GHC может сначала оценить рекурсивный вызов (согласно документации) и все еще накапливать thunks. Он похоже, мне нужно использовать pseq обеспечить acc' всегда оценивается перед рекурсивным вызовом. Но с pseq я вижу снижение производительности по сравнению с seq версия. Числа на моей машине (для вычисления sum [1 .. 10^7]:

  • наивный: 2.6s
  • seq: 0.2s
  • pseq: 0.5s

я использую GHC-8.2.2, и я компилирую с .

после того, как я попытался скомпилировать с stack ghc -- -O File.hs разрыв производительности команды между seq и pseq ушел. Теперь они оба бегут в 0.2s.

Итак, моя реализация демонстрирует свойства, которые я хочу? Или у GHC есть какая-то причуда реализации? Почему это pseq медленнее? Существует ли какой-то пример, где seq a b имеет разные результаты в зависимости от порядка оценки (тот же код, но разные флаги компилятора / разные компиляторы / и т. д.)?

3 ответов


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

короткий ответ: хотя оба должны генерировать почти одинаково выполняющийся код на практике (по крайней мере, когда включены соответствующие флаги оптимизации), примитив seq, а не pseq, это правильный выбор для вашей ситуации. Используя pseq не-идиоматические, запутанным и потенциально контрпродуктивно с точки зрения производительности, и ваша причина его использования основана на ошибочном понимании того, что означает его гарантия порядка оценки и что она подразумевает в отношении производительности. Хотя нет никаких гарантий производительности для разных наборов флагов компилятора (тем более для других компиляторов), если вы когда-либо сталкивались с ситуацией, когда seq версия вышеуказанного кода работает значительно медленнее, чем pseq версия используя " качество продукции" флаги оптимизации с компилятором GHC, вы должны считать его ошибкой GHC и подать отчет об ошибке.

длинный ответ, конечно, длиннее...

во-первых, давайте уточним, что seq и pseq are семантически идентичны в том смысле, что они оба удовлетворяют уравнениям:

seq _|_ b = _|_
seq a b = b -- if a is not _|_
pseq _|_ b = _|_
pseq a b = b -- if a is not _|_

это действительно единственное, что любой из них гарантирует семантически, и так как определение языка Haskell (как указано в Haskell report say) только делает-в лучшем случае-семантические гарантии и не имеет дело с производительностью или реализацией, нет причин выбирать между одним или другим по причинам гарантированной производительности в разных компиляторах или флагах компилятора.

кроме того, в вашем конкретном seq - на основе версии функции sum, это не слишком сложно увидеть, что нет никакой ситуации, в которой seq вызывается с неопределенным первым аргументом, но определенным вторым аргумент (при условии использования стандартного числового типа), поэтому вы даже не используя семантические свойства seq. Вы можете переопределить seq as seq a b = b и имеют точно такую же семантику. Конечно, вы знаете это-вот почему ваша первая версия не использовала seq. Вместо этого, вы используете seq для случайного побочного эффекта производительности, поэтому мы вышли из области семантических гарантий и вернулись в область конкретной реализации компилятора GHC и производительности характеристики (где на самом деле нет гарантии говорить).

во-вторых, это подводит нас к назначение of seq. Он редко используется для его семантических свойств, потому что эти свойства не очень полезны. Кому нужны вычисления?--22--> вернуться b за исключением что он не должен завершаться, если какое-то несвязанное выражение a не удалось завершить? (Исключения - каламбур не предназначен -- будут такие вещи, как обработка исключений, где вы могли бы использовать seq или deepSeq который основан на seq чтобы принудительно оценить неконтролируемое или контролируемое выражение перед началом оценки другого выражения.)

вместо seq a b предназначен для принудительной оценки a к слабой головной нормальной форме перед возвращением результата b предотвратить накопление thunks. Идея в том, если у вас есть выражение b что создает thunk, который потенциально может накапливаться поверх другого недооцененного thunk, представленного a, вы можете предотвратить это накопление с помощью seq a b. "Гарантия" слабая: GHC гарантирует, что он понимает, что вы не хотите a чтобы остаться недооцененным thunk, когда seq a b's значение потребовано. Технически, это не гарантирует, что a будет "оценено перед" b, что бы это ни значило, но тебе не нужна эта гарантия. когда вы беспокойство о том, что без этой гарантии GHC может сначала оценить рекурсивный вызов и все еще накапливать удары, так же смешно, как беспокоиться о том, что pseq a b может оценить свой первый аргумент, а затем подождать 15 минут (просто чтобы убедиться, что первый аргумент был оценен!), прежде чем оценивать его второй.

это ситуация, когда вы должны доверять GHC, чтобы сделать правильную вещь. Вам может показаться, что единственный способ реализовать преимущество производительности seq a b для a быть оцененным к WHNF перед оценкой b начинается, но возможно, что есть оптимизации в той или иной ситуации, которые технически начинают оценивать b (или даже полностью оценить b в WHNF) при отъезде a unevaluated в течение короткого времени, чтобы улучшить производительность, сохраняя при этом семантику seq a b. Используя pseq вместо этого вы можете запретить GHC делать такие оптимизации. (В sum ситуации, программы, есть несомненно, нет такой оптимизации, но в более сложном использовании seq, там может быть.)

в-третьих, важно понимать, что pseq на самом деле for. Это было впервые описано в Марлоу 2009 в контексте параллельного программирования. Предположим, мы хотим распараллелить два дорогостоящих вычисления foo и bar а затем объединить (скажем, добавить) их результаты:

foo `par` (bar `seq` foo+bar)  -- parens redundant but included for clarity

намерение здесь в том, что -- когда это требуется значение выражения - оно создает искру для вычисления foo параллельно, а затем, через seq выражение лица, приступает к оценке bar к WHNF (т. е., это числовое значение, скажем), прежде чем окончательно оценить foo+bar который будет ждать искры для foo перед добавлением и возвратом результатов.

здесь возможно, что GHC распознает это для определенного числового типа (1) foo+bar автоматически не завершается, если bar делает, удовлетворяющих формальная семантическая гарантия seq; и (2) оценке foo+bar к WHNF автоматически принудит оценку bar к WHNF предотвращая любое накопление thunk и таким образом удовлетворяя неофициальную гарантию реализации seq. В этой ситуации GHC может свободно оптимизировать seq отсюда выход:

foo `par` foo+bar

особенно если он чувствует, что было бы более эффективным, чтобы начать оценку foo+bar перед окончанием оценки bar to WHNF.

что GHC не достаточно умен, чтобы понять, что-если оценка foo на foo+bar начнется перед foo Искра запланирована, искра будет шипеть, и никакое параллельное выполнение не произойдет.

это действительно только в этом случае, когда вам нужно явно отложить требование значения искрящегося выражения, чтобы позволить ему быть запланированным до того, как основной поток "догонит" , что вам нужна дополнительная гарантия pseq и готовы иметь GHC forgo дополнительные возможности оптимизации, разрешенные более слабой гарантией seq:

foo `par` (bar `pseq` foo+bar)

здесь pseq предотвратит GHC от внедрения любой оптимизации, которая может позволить foo+bar чтобы начать оценку (потенциально шипящий foo spark) перед bar находится в WHNF (который, мы надеемся, позволяет достаточно времени для Искры быть запланированным).

результат таков, если вы используете pseq для всего, кроме параллельного программирования, ты неправильно его используешь. (Ну, может быть, есть некоторые странные ситуации, но...) Если все, что вы хотите сделать, это заставить строгую оценку и / или оценку thunk улучшить производительность в непараллельном коде, используя seq (или $!, который определяется в терминах seq или строгие типы данных Haskell, которые определены в терминах $!) - это правильный подход.

(или, если верить бенчмаркам @Kindaro, возможно, беспощадный бенчмаркинг с конкретными версиями компилятора и флаги-это правильный подход.)


я вижу только такую разницу с оптимизацией выключен. С ghc -O и pseq и seq выполнить то же самое.

спокойная семантика seq разрешить преобразования, приводящие к более медленному коду. Я не могу представить ситуацию, в которой это действительно происходит. Мы просто предполагаем, что GHC делает правильную вещь. К сожалению, у нас нет способа выразить это поведение в терминах семантики высокого уровня для Haskell.

почему pseq медленнее?

pseq x y = x `seq` lazy y

pseq таким образом реализуется с помощью seq. Наблюдаемые накладные расходы обусловлены дополнительным косвенным вызовом pseq.

даже если они в конечном итоге оптимизируются, это не обязательно может быть хорошей идеей использовать pseq вместо seq. Несмотря на строгую семантику заказ подразумевает желаемого эффекта (то go не накапливает thunk), он может отключить некоторые дополнительные оптимизации: возможно, оценка x и оценка y можно разложить на низкоуровневые операции, некоторые из которых мы не хотели бы пересекать pseq граница.

существует ли пример, когда seq a b имеет разные результаты в зависимости от порядка оценки (тот же код, но разные флаги компилятора/разные компиляторы/и т. д.)?

это может вызвать либо "a" или "b".

seq (error "a") (error "b")

я думаю, что есть обоснование, объясненное в статье о исключения в Haskell, семантика для неточных исключений.


Edit:


относительно вопроса " почему pseq медленнее", у меня теория.

    • давайте перефразируем acc' `seq` go acc' xs as strict (go (strict acc') xs).
    • аналогично, acc' `pseq` go acc' xs перефразируется как lazy (go (strict acc') xs).
    • теперь давайте перефразируем go acc (x:xs) = let ... in ... to go acc (x:xs) = strict (go (x + acc) xs) в случае seq.
    • и go acc (x:xs) = lazy (go (x + acc) xs) в случае pseq.

теперь, легко видеть, что, в случае pseq, go получает назначенный ленивый удар, который будет быть оценены в будущем. В определении sum, go никогда не появляется слева от pseq, и, таким образом, во время бега sum, evaulation совсем не будет принудительным. Более того, это происходит для каждого рекурсивного вызова go, так что удары накапливаются.

go выделяет линейную память в pseq случае, но не в случае seq. Вы можете посмотрите сами, если вы запустите следующие команды оболочки:

for file in SumNaive.hs SumPseq.hs SumSeq.hs 
do
    stack ghc                \
        --library-profiling  \
        --package parallel   \
        --                   \
        $file                \
        -main-is ${file%.hs} \
        -o ${file%.hs}       \
        -prof                \
        -fprof-auto
done

for file in SumNaive.hs SumSeq.hs SumPseq.hs
do
    time ./${file%.hs} +RTS -P
done

-- и сравните распределение памяти go центр затрат.

COST CENTRE             ...  ticks     bytes
SumNaive.prof:sum.go    ...    782 559999984
SumPseq.prof:sum.go     ...    669 800000016
SumSeq.prof:sum.go      ...    161         0

postscriptum

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

SumNaive.hs

module SumNaive where

import Prelude hiding (sum)

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = go (x + acc) xs

main = print $ sum [1..10^7]

SumSeq.hs

module SumSeq where

import Prelude hiding (sum)

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = let acc' = x + acc
                    in acc' `seq` go acc' xs

main = print $ sum [1..10^7]

SumPseq.hs

module SumPseq where

import Prelude hiding (sum)
import Control.Parallel (pseq)

sum :: Num a => [a] -> a
sum = go 0
  where
    go acc []     = acc
    go acc (x:xs) = let acc' = x + acc
                    in acc' `pseq` go acc' xs

main = print $ sum [1..10^7]

время без оптимизаций:

./SumNaive +RTS -P  4.72s user 0.53s system 99% cpu 5.254 total
./SumSeq +RTS -P  0.84s user 0.00s system 99% cpu 0.843 total
./SumPseq +RTS -P  2.19s user 0.22s system 99% cpu 2.408 total

времени -O:

./SumNaive +RTS -P  0.58s user 0.00s system 99% cpu 0.584 total
./SumSeq +RTS -P  0.60s user 0.00s system 99% cpu 0.605 total
./SumPseq +RTS -P  1.91s user 0.24s system 99% cpu 2.147 total

времени -O2:

./SumNaive +RTS -P  0.57s user 0.00s system 99% cpu 0.570 total
./SumSeq +RTS -P  0.61s user 0.01s system 99% cpu 0.621 total
./SumPseq +RTS -P  1.92s user 0.22s system 99% cpu 2.137 total

видно, что:

  • наивный вариант имеет низкую производительность без оптимизации, но отлично производительности -O или -O2 -- в той мере, в какой он превосходит все остальные.

  • seq variant имеет хорошую производительность, которая очень мало улучшена за счет оптимизации, так что с любым -O или -O2 наивный вариант превосходит его.

  • pseq вариант имеет стабильно низкую производительность, примерно в два раза лучше, чем наивный вариант без оптимизации, и в четыре раза хуже, чем другие с любым -O или -O2. Оптимизация влияет на него примерно так же, как seq вариант.