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

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

Примечание по порядку оценки: выражение 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 -- File.hs.

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

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

Ответы

Ответ 1

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

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

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

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

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

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

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

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

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

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

В-третьих, важно понять, для чего действительно pseq. Он впервые был описан в Marlow 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 в WHNF.

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

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

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

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

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

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

Ответ 2

Я вижу только такую ​​разницу, при которой оптимизация отключена. С помощью 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 имеет разные результаты в зависимости от порядка оценки (тот же код, но разные флаги компилятора/разные компиляторы /etc.)?

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

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

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

Ответ 3

Изменить: Моя теория, сорванная, как наблюдаемые мной тайминги, на самом деле сильно искажена влиянием самого профилирования; с профилированием данные противоречат теории. Более того, тайминги сильно различаются между версиями GHC. Я собираю лучшие наблюдения даже сейчас, и я дополнительно отредактирую этот ответ, когда приеду в решающий момент.


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

    • Повторим фразу acc' `seq` go acc' xs как strict (go (strict acc') xs).
    • Аналогично, acc' `pseq` go acc' xs переформулируется как lazy (go (strict acc') xs).
    • Теперь перефразируем go acc (x:xs) = let ... in ... до 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

постскриптум

Так как, по-видимому, существует разногласие в вопросе о том, какие оптимизации фактически играют в какой-то эффект, я помещаю свой точный исходный код и 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 вариант имеет хорошую производительность, которая очень мало улучшается за счет оптимизации, так что с помощью -O или -O2 вариант Наивы превосходит его.

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