Гарантия оптимизации хвоста - кодировка шлейфа в Haskell

Итак, короткая версия моего вопроса: как мы должны кодировать циклы в Haskell вообще? В Haskell нет гарантии оптимизации хвоста, шаблоны ударов даже не являются частью стандарта (правильно?), И парадигма fold/unfold не гарантируется работать во всех ситуациях. Здесь случай в точке были только баг-шаблонами, которые помогли мне заставить его работать в постоянном пространстве (даже при использовании $! не помогло.. хотя тестирование было выполнено на Ideone.com, который использует ghc-6.8.2).

Это в основном о вложенном цикле, который в list-парадигме можно указать как

prod (sum,concat) . unzip $ 
    [ (c, [r | t]) | k<-[0..kmax], j<-[0..jmax], let (c,r,t)=...]
prod (f,g) x = (f.fst $ x, g.snd $ x)

Или в псевдокоде:

let list_store = [] in
for k from 0 to kmax
    for j from 0 to jmax
        if test(k,j) 
            list_store += [entry(k,j)]
        count += local_count(k,j)
result = (count, list_store)

До тех пор, пока я не добавлю к нему шаблон привязки, я получил либо выпадение памяти, либо даже переполнение стека. Но шаблоны ударов не являются частью стандарта, не так ли? Итак, вопрос в том, как кодировать выше, в стандартном Haskell, для работы в постоянном пространстве?

Вот тестовый код. Расчет является поддельным, но проблемы одинаковы. EDIT: foldr -форменный код:

testR m n = foldr f (0,[]) 
               [ (c, [(i,j) | (i+j) == d ])
                 | i<- [0..m], j<-[0..n], 
                   let c = if (rem j 3) == 0 then 2 else 1 ]
  where d = m + n - 3
    f (!c1, [])     (!c, h) = (c1+c,h) 
    f (!c1, (x:_))  (!c, h) = (c1+c,x:h)

Попытка запуска print $ testR 1000 1000 создает переполнение стека. Переход на foldl возможен только при использовании шаблонов bang в f, но он строит список в обратном порядке. Я хотел бы построить его лениво и в правильном порядке. Может ли это быть сделано с любым типом fold, для идиоматического решения?

РЕДАКТИРОВАТЬ:, чтобы подытожить ответ, который я получил от @ehird: нечего бояться, используя шаблон взлома. Хотя он и не в стандартном Haskell, он легко кодируется в нем как f ... c ... = case (seq c False) of {True -> undefined; _ -> ...}. Урок состоит в том, что только совпадение шаблона заставляет значение, а seq делает НЕ что-то само собой, а скорее упорядочивает, когда seq x y принудительно - по совпадению с шаблоном - x будет тоже, и y будет ответом. Вопреки тому, что я мог понять из онлайн-отчета, $! делает НЕ принудительно что-то само собой, хотя называется "строгим оператором приложения".

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

Спасибо вам всем за вашу помощь.

Ответ 1

Шаблоны взлома - это просто сахар для seq - всякий раз, когда вы видите let !x = y in z, который может быть переведен в let x = y in x `seq` z. seq является стандартным, поэтому нет проблем с переводами программ, которые используют шаблоны ударов в переносимую форму.

Верно, что Haskell не дает никаких гарантий относительно производительности - отчет даже не определяет порядок оценки (только он должен быть нестрогим), не говоря уже о существовании или поведении стека времени выполнения. Однако, хотя в отчете не указан конкретный метод реализации, вы можете, безусловно, оптимизировать его.

Например, call-by-need (и, следовательно, совместное использование) используется на практике всеми реализациями Haskell, и имеет жизненно важное значение для оптимизации кода Haskell для использования и скорости памяти. Действительно, чистый трюк memoisation 1 (как полагается на совместное использование (без него он просто замедлит работу).

Эта базовая структура позволяет нам, например, увидеть, что переполнение стека вызвано созданием слишком больших трюков. Поскольку вы не опубликовали весь свой код, я не могу сказать вам, как его переписать без шаблонов, но я подозреваю, что [ (c, [r | t]) | ... ] должен стать [ c `seq` r `seq` t `seq` (c, [r | t]) | ... ]. Разумеется, диаграммы ударных более удобны; почему они являются таким распространенным распространением! (С другой стороны, вам, вероятно, не нужно вытеснять всех из них, зная, что заставить полностью зависит от конкретной структуры кода, и дико добавляя шаблоны ударов ко всему, как правило, просто замедляет работу.)

В самом деле, "хвостовая рекурсия" сама по себе не означает, что все в Haskell: если ваши параметры аккумулятора не являются строгими, вы переполните стек, когда позже попытаетесь заставить их, и действительно, благодаря лени, многие нерекурсивные программы не переполняют стек; печать repeat 1 никогда не будет переполнять стек, хотя определение - repeat x = x : repeat x - явно имеет рекурсию в не-хвостом положении. Это связано с тем, что (:) ленив во втором аргументе; если вы пройдете по списку, у вас будет постоянное использование пространства, поскольку repeat x thunks будут принудительно, а предыдущие ячейки cons будут выбрасываться сборщиком мусора.

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

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

Что касается пасты, к которой вы привязаны, id $! x не работает, чтобы заставить что-либо, так как это то же самое, что и x `seq` id x, что совпадает с x `seq` x, что совпадает с x. В принципе, всякий раз, когда x `seq` y принудительно, x принудительно, а результат y. Вы не можете использовать seq, чтобы просто заставить вещи в произвольных точках; вы используете его для того, чтобы заставить фортуны зависеть от других thunks.

В этом случае проблема заключается в том, что вы создаете большой бит в c, поэтому вы, вероятно, захотите сделать auxk и auxj принудительно; простым методом было бы добавить предложение, подобное auxj _ _ c _ | seq c False = undefined, в начало определения. (guard всегда проверяется, заставляя c оцениваться, но всегда приводит к False, поэтому правая сторона никогда не оценивается.)

Лично я бы предложил сохранить шаблон удара, который у вас есть в финальной версии, так как он более читабельен, но f c _ | seq c False = undefined будет работать так же хорошо.

1 См. Элегантная memoization с помощью функциональных заметок memo и data-memocombinators библиотека.

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

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

Ответ 2

ОК, пусть работает с нуля здесь.

У вас есть список записей

entries = [(k,j) | j <- [0..jmax], k <- [0..kmax]]

И на основе этих индексов у вас есть тесты и подсчеты

tests m n = map (\(k,j) -> j + k == m + n - 3) entries
counts = map (\(_,j) -> if (rem j 3) == 0 then 2 else 1) entries

Теперь вы хотите создать две вещи: "общий" счет и список записей, которые "проходят" тест. Проблема, конечно, в том, что вы хотите генерировать последнее лениво, в то время как первое (чтобы избежать взрыва стека) должно строго оцениваться.

Если вы оцениваете эти две вещи отдельно, вы должны либо: 1) предотвратить совместное использование entries (сгенерировать его дважды, один раз для каждого вычисления) или 2) сохранить весь список entries в памяти. Если вы оцениваете их вместе, то вы должны либо 1) оценить строго, либо 2) иметь много пространства стека для огромного thunk, созданного для подсчета. Вариант № 2 для обоих случаев довольно плох. Ваше императивное решение касается этой проблемы просто путем оценки одновременно и строго. Для решения в Haskell вы можете выбрать вариант №1 для отдельной или одновременной оценки. Или вы можете показать нам свой "настоящий" код, и, возможно, мы сможем помочь вам найти способ изменить ваши зависимости данных; может оказаться, вам не нужен общий счет, или что-то в этом роде.