В других ответах уже обсуждалось значение seq
и его отношение к pseq
. Но, похоже, существует некоторая путаница в отношении того, каковы последствия предупреждений seq
.
Технически говоря, это правда, что a `seq` b
не гарантирует, что a
будет оценен до b
. Это может показаться тревожным: как это могло бы служить своей цели, если бы это было так? Давайте рассмотрим пример, который Джон привел в их ответ:
foldl' :: (a -> b -> a) -> a -> [b] -> a
foldl' f acc [] = acc
foldl' f acc (x : xs)
= acc' `seq` foldl' f acc' xs
where
acc' = f acc x
Конечно, мы заботимся о том, чтобы acc'
оценивался здесь перед рекурсивным вызовом. Если это не так, вся цель foldl'
потеряна! Так почему бы не использовать здесь pseq
? И действительно ли все это полезно?
К счастью, на самом деле ситуация не так ужасна. seq
действительно здесь. GHC никогда не выберет компиляцию foldl'
таким образом, чтобы он оценивал рекурсивный вызов перед вычислением acc'
, поэтому желаемое поведение сохраняется. Разница между seq
и pseq
скорее заключается в том, какая гибкость у оптимизатора для принятия другого решения, когда он думает, что у него есть для этого особенно веские причины.
Понимание строгости seq
и pseq
Чтобы понять, что это значит, мы должны научиться думать немного как оптимизатор GHC. На практике единственная конкретная разница между seq
и pseq
заключается в том, как они влияют на анализатор строгости:
seq
считается строгим в обоих своих аргументах. То есть в таком определении функции, как
f a b c = (a `seq` b) + c
f
будет считаться строгим по всем трем аргументам.
pseq
аналогичен seq
, но считается строгим только в его первом аргументе, а не во втором. Это означает, что в определении функции вроде
g a b c = (a `pseq` b) + c
g
будет считаться строгим в a
и c
, но не b
.
Что это значит? Что ж, давайте сначала определим, что значит для функции, прежде всего, «быть строгой в одном из своих аргументов». Идея состоит в том, что если функция является строгой в отношении одного из своих аргументов, то вызов этой функции гарантированно оценит этот аргумент. Это имеет несколько значений:
Предположим, у нас есть функция foo :: Int -> Int
, которая имеет строгий аргумент, и предположим, что у нас есть вызов foo
, который выглядит следующим образом:
foo (x + y)
Наивный компилятор Haskell создаст преобразователь для выражения x + y
и передаст полученный преобразователь в foo
. Но мы знаем, что оценка foo
обязательно вызовет преобразование, поэтому от этой лени мы ничего не выиграем. Было бы лучше сразу оценить x + y
, а затем передать результат foo
, чтобы сохранить ненужное выделение преобразователя.
Поскольку мы знаем, что нет причин передавать преобразование в foo
, мы получаем возможность внести дополнительную оптимизацию. Например, оптимизатор может выбрать внутреннюю перезапись foo
, чтобы взять распакованный Int#
вместо Int
, избегая не только конструкции преобразователя для x + y
, но и вообще избегая упаковки результирующего значения. Это позволяет передавать результат x + y
напрямую в стек, а не в кучу.
Как видите, анализ строгости очень важен для создания эффективного компилятора Haskell, поскольку он позволяет ему принимать гораздо более разумные решения, среди прочего, о том, как компилировать вызовы функций. По этой причине мы обычно хотим, чтобы анализ строгости находил как можно больше возможностей для активной оценки вещей, позволяя нам сэкономить на бесполезном распределении кучи.
Имея это в виду, давайте вернемся к нашим примерам f
и g
выше. Давайте подумаем, какой строгости мы интуитивно ожидаем от этих функций:
Напомним, что тело f
- это (a `seq` b) + c
. Даже если мы полностью проигнорируем специальные свойства seq
, мы знаем, что в конечном итоге он оценивает свой второй аргумент. Это означает, что f
должен быть по крайней мере таким же строгим, как если бы его тело было просто b + c
(с a
полностью неиспользованным).
Мы знаем, что оценка b + c
должна фундаментально оценивать как b
, так и c
, поэтому f
должен, по крайней мере, быть строгим как в b
, так и в c
. Строго ли это в a
- вопрос более интересный. Если бы seq
был на самом деле просто flip const
, этого не было бы, поскольку a
не использовался бы, но, конечно, весь смысл seq
состоит в том, чтобы ввести искусственную строгость, поэтому на самом деле f
также считается строгим в a
.
К счастью, строгость f
, о которой я говорил выше, полностью соответствует нашей интуиции о том, какой строгостью она должна быть. f
строг во всех своих аргументах, как и следовало ожидать.
Интуитивно понятно, что все вышеперечисленные аргументы в пользу f
также должны применяться к g
. Единственное отличие состоит в замене seq
на pseq
, и мы знаем, что pseq
обеспечивает более сильную гарантию порядка оценки, чем seq
, поэтому мы ожидаем, что g
будет не менее строгим, чем f
… который так сказать, также строг во всех своих аргументах.
Однако, что примечательно, это не строгость, которую GHC устанавливает для g
. GHC считает g
строгим в a
и c
, но не b
, хотя по нашему определению строгости, приведенному выше, g
явно является строгим в b
: b
должен быть оценен для g
для получения результата! Как мы увидим, именно это несоответствие делает pseq
таким глубоко волшебным и почему это вообще плохая идея.
Последствия строгости
Теперь мы увидели, что seq
ведет к строгости, которую мы ожидаем, а pseq
- нет, но не сразу понятно, что это означает. Чтобы проиллюстрировать это, рассмотрим возможный сайт звонков, где используется f
:
f a (b + 1) c
Мы знаем, что f
строго во всех своих аргументах, поэтому по тем же рассуждениям, которые мы использовали выше, GHC должен быстро оценить b + 1
и передать свой результат f
, избегая преобразований.
На первый взгляд может показаться, что все хорошо, но подождите: что, если a
- это преобразователь? Несмотря на то, что f
также является строгим в a
, это всего лишь простая переменная - возможно, она была передана в качестве аргумента откуда-то еще - и у GHC нет никаких причин для принудительного применения a
здесь, если f
собирается принудительно это сделать. Единственная причина, по которой мы форсируем b + 1
, - это избавить новый преобразователь от создания, но мы ничего не сохраняем, кроме форсирования уже созданного a
на сайте вызова. Это означает, что a
на самом деле может быть передан как неоцененный преобразователь.
Это своего рода проблема, потому что в теле f
мы написали a `seq` b
, запрашивая оценку a
до b
. Но по нашим рассуждениям выше, GHC просто пошли дальше и сначала оценили b
! Если нам действительно, действительно нужно убедиться, что b
не будет оцениваться до тех пор, пока не пройдет a
, этот тип нетерпеливой оценки не может быть разрешен.
Конечно, именно поэтому pseq
считается ленивым во втором аргументе, хотя на самом деле это не так. Если мы заменим f
на g
, то GHC послушно выделит новый преобразователь для b + 1
и передаст его в кучу, гарантируя, что он не будет оценен слишком рано. Это, конечно, означает большее выделение кучи, отсутствие распаковки и (что хуже всего) отсутствие распространения информации о строгости дальше по цепочке вызовов, создавая потенциально каскадные пессимизации. Но вот чего мы просили: любой ценой не оценивать b
слишком рано!
Надеюсь, это иллюстрирует, почему pseq
соблазнительно, но в конечном итоге контрпродуктивно, если вы действительно не знаете, что делаете. Конечно, вы гарантируете оценку, которую ищете… но какой ценой?
Выводы
Надеюсь, приведенное выше объяснение ясно показало, как seq
и pseq
имеют преимущества и недостатки:
seq
отлично работает с анализатором строгости, выявляя гораздо больше потенциальных оптимизаций, но эти оптимизации могут нарушить ожидаемый нами порядок оценки.
pseq
любой ценой сохраняет желаемый порядок оценки, но делает это только путем откровенной лжи анализатору строгости, чтобы он не подходил к делу, резко ослабляя его способность помогать оптимизатору делать хорошие вещи.
Как мы узнаем, какие компромиссы выбрать? Хотя теперь мы можем понять, почему seq
может иногда не оценивать свой первый аргумент перед вторым, у нас больше нет оснований полагать, что это нормально, чтобы это произошло.
Чтобы развеять ваши страхи, давайте сделаем шаг назад и задумаемся, что же здесь происходит на самом деле. Обратите внимание, что GHC никогда фактически не компилирует a `seq` b
выражение таким образом, что a
не может быть вычислен до b
. Учитывая такое выражение, как a `seq` (b + c)
, GHC никогда не нанесет вам тайного удара в спину и не оценит b + c
перед тем, как оценить a
. Скорее, то, что он делает, гораздо более тонкое: оно может косвенно привести к индивидуальной оценке b
и c
перед вычислением общего b + c
выражения, поскольку анализатор строгости заметит, что общее выражение все еще строго в обоих b
и c
.
Как все это сочетается друг с другом, невероятно сложно, и это может вскружить вам голову, так что, возможно, вы не найдете предыдущий абзац таким успокаивающим. Но чтобы сделать эту мысль более конкретной, вернемся к примеру foldl'
в начале этого ответа. Напомним, что он содержит такое выражение:
acc' `seq` foldl' f acc' xs
Чтобы избежать взрыва преобразователя, нам нужно оценить acc'
перед рекурсивным вызовом foldl'
. Но, учитывая приведенные выше рассуждения, так будет всегда! Разница, которую делает seq
здесь относительно pseq
, опять же, актуальна только для анализа строгости: она позволяет GHC сделать вывод, что это выражение также является строгим в f
и xs
, а не только в acc'
, который в этой ситуации практически не меняется в все:
Общая функция foldl'
по-прежнему не считается строгой в f
, поскольку в первом случае функции (где xs
равно []
) f
не используется, поэтому для некоторых шаблонов вызовов foldl'
является ленивым в f
.
foldl'
может считаться строгим в xs
, но здесь это совершенно неинтересно, поскольку xs
является лишь частью одного из аргументов foldl'
, и эта информация о строгости никак не влияет на строгость foldl'
.
Итак, если здесь нет никакой разницы, почему бы не использовать pseq
? Что ж, предположим, что foldl'
встроен некоторое конечное число раз на сайте вызова, поскольку, возможно, форма его второго аргумента частично известна. Информация о строгости, предоставляемая seq
, может затем вызвать несколько дополнительных оптимизаций на сайте вызова, что приведет к цепочке выгодных оптимизаций. Если бы использовалось pseq
, эти оптимизации были бы скрыты, и GHC дал бы худший код.
Таким образом, реальный вывод здесь состоит в том, что, хотя seq
может иногда не оценивать свой первый аргумент раньше второго, это верно только технически, способ, которым это происходит, неуловимый, и маловероятно, что это нарушит вашу программу. Это не должно вызывать особого удивления: seq
- это инструмент, который авторы GHC ожидают от программистов, которые будут использовать в этой ситуации, поэтому было бы довольно грубо с их стороны заставить его делать неправильные вещи! seq
- идиоматический инструмент для этой работы, а не pseq
, поэтому используйте seq
.
Когда же тогда вы используете pseq
? Только тогда, когда вы действительно действительно заботитесь об очень конкретном порядке оценки, что обычно происходит только по одной из двух причин: вы используете параллелизм на основе par
или вы используете unsafePerformIO
и заботитесь о порядке побочных эффектов. Если вы не делаете ничего из этого, не используйте pseq
. Если все, что вас волнует, - это варианты использования, такие как foldl'
, когда вы просто хотите избежать ненужного накопления переходов, используйте seq
. Это то, для чего он нужен.
person
Alexis King
schedule
06.04.2021