Всегда гарантированный порядок оценки " 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
, так что удары накапливаются.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
вариант.