Почему это вызывает утечку памяти в библиотеке Haskell Conduit?

У меня есть конвейер pipeline, обрабатывающий длинный файл. Я хочу печатать отчет о проделанной работе для пользователя каждые 1000 записей, поэтому написал следующее:

-- | Every n records, perform the IO action.
-- Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = skipN n 1
   where
      skipN c t = do
         mv <- await
         case mv of
            Nothing -> return ()
            Just v ->
               if c <= 1
                  then do
                     liftIO $ act t v
                     yield v
                     skipN n (succ t)
                  else do
                     yield v
                     skipN (pred c) (succ t)

Независимо от того, какое действие я вызываю, это приводит к утечке памяти, даже если я просто приказываю ему напечатать точку.

Насколько я могу судить, функция является хвостовой рекурсивной, и оба счетчика регулярно форсируются (я пытался ввести «seq c» и «seq t», но безрезультатно). Есть подсказка?

Если я введу "awaitForever", который печатает отчет для каждой записи, тогда он будет работать нормально.

Обновление 1: это происходит только при компиляции с -O2. Профилирование указывает, что текущая память выделяется в рекурсивной функции «skipN» и сохраняется «СИСТЕМОЙ» (что бы это ни значило).

Обновление 2: мне удалось вылечить его, по крайней мере, в контексте моей текущей программы. Я заменил указанную выше функцию на это. Обратите внимание, что «proc» имеет тип «Int -> Int -> Maybe i -> m ()»: чтобы использовать его, вы вызываете «await» и передаете ему результат. По какой-то причине замена await и yield решила проблему. Итак, теперь он ожидает следующего ввода, прежде чем выдаст предыдущий результат.

-- | Every n records, perform the monadic action. 
-- Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = await >>= proc 1 n
   where
      proc c t = seq c $ seq t $ maybe (return ()) $ \v ->
         if c <= 1
            then {-# SCC "progress.then" #-} do
               liftIO $ act t v
               v1 <- await
               yield v
               proc n (succ t) v1
            else {-# SCC "progress.else" #-} do
               v1 <- await
               yield v
               proc (pred c) (succ t) v1

Поэтому, если у вас есть утечка памяти в Conduit, попробуйте поменять местами yield и await actions.


person Paul Johnson    schedule 16.07.2014    source источник
comment
На самом деле это не хвостовая рекурсия, последний вызов - не к skipN, а к (>>) (yield v) (skipN x y). Это распространенная ошибка при написании рекурсивных подпрограмм с использованием монад. Я не уверен, сможет ли GHC оптимизировать это правильно, не глядя на дамп ядра, но я предполагаю, что вы на самом деле не используете хвостовую рекурсивную функцию.   -  person bheklilr    schedule 16.07.2014
comment
Также вы можете добавить отчет об утечке памяти?   -  person Sibi    schedule 16.07.2014
comment
Я не профилировал его: я просто запустил программу и наблюдал, как растет память процесса.   -  person Paul Johnson    schedule 16.07.2014
comment
@bheklilr, не могли бы вы объяснить, почему m>>n может не вызывать n в хвостовой позиции?   -  person dfeuer    schedule 16.07.2014
comment
@dfeuer По той же причине, почему sum (x:xs) = x + sum xs не является хвостовой рекурсией, последняя вызываемая функция не sum, а (+), поскольку она эквивалентна sum (x:xs) = (+) x xs. Вот почему мы часто пишем рекурсивные функции, используя вспомогательную функцию с аргументом аккумулятора, или просто используем folds, если ситуация достаточно проста, например, sum = go 0 where { go a [] = a; go a (x:xs) a = go (x + a) xs } или sum = foldl' (+) 0. Поскольку в нотации do используются десахары >> и >>=, это означает, что последний вызов в стеке относится к одному из них, а не к его второму аргументу.   -  person bheklilr    schedule 16.07.2014
comment
@bheklilr, (+) необходимо проверить свой второй аргумент, прежде чем он сможет избавиться от своего первого. Я ничего не знаю о каналах, но в IO, m >>= f переводится в код, который выглядит как 1. Выполнить m (без хвоста). Результат выложите в r. 2. Выполните f r (хвост). К моменту проверки f от m не остается ничего, кроме результата, который он произвел, и (что более важно) >>= не должен ничего делать с f r; его работа уже сделана. Очевидно, что это не так для всех монад; может что-то про проводники его ломает?   -  person dfeuer    schedule 16.07.2014
comment
Я думаю, вы все слишком много внимания уделяете хвостовой рекурсии. Ни pipes, ни conduit не должны быть хвостовой рекурсией для работы в постоянном пространстве. Хвостовая рекурсивная дискуссия - просто отвлекающий маневр.   -  person Gabriel Gonzalez    schedule 16.07.2014
comment
@dfeuer Очевидно, что это не относится ко всем монадам. Тогда почему вы ожидаете, что GHC его оптимизирует? Для IO могут быть сделаны особые оптимизации по сравнению с пользовательскими монадами, поскольку IO - очень особенная монада низкого уровня, но для пользовательских монад, я полагаю, может быть важно рассмотреть такие вещи, как это.   -  person bheklilr    schedule 16.07.2014
comment
@GabrielGonzalez Я не говорю, что проблема здесь в хвостовой рекурсии, я просто хотел указать OP, что обессахаренная функция не является хвостовой рекурсивной функцией, поскольку он заявил об этом в своем исходном вопросе.   -  person bheklilr    schedule 16.07.2014
comment
Я подозреваю, что бхеклилр на правильном пути. Conduit работает с продолжениями, поэтому порядок оценки намного сложнее определить, чем в IO. Мне придется потратить некоторое время на изучение исходного кода.   -  person Paul Johnson    schedule 16.07.2014
comment
Было бы очень полезно, если бы вы могли опубликовать полный исполняемый код, то есть включая пример использования progress, который демонстрирует утечку памяти.   -  person Tom Ellis    schedule 17.07.2014
comment
Вы понимаете, что t никогда не будет принудительным, если act это не заставит? Печать точки этого не сделает ...   -  person Tom Ellis    schedule 17.07.2014
comment
@TomEllis, я бы упомянул это, если бы OP еще не упомянул попытку принудительно принудительно это сделать.   -  person dfeuer    schedule 17.07.2014
comment
@dfeuer: Пока мы не увидим попытку, мы не можем быть уверены, что он пытался заставить ее правильно!   -  person Tom Ellis    schedule 17.07.2014
comment
На первый взгляд, мне кажется, что у вас слишком много лени. Если ваша функция отчета ленива в аргументе 't', вы продолжите накапливать thunks в 't'. Добавьте образцы челки и попробуйте заменить skipN c t на skipN !c !t и посмотрите, поможет ли это.   -  person ozataman    schedule 17.07.2014
comment
@TomEllis, однако, большая часть Int арифметических операций совпадает, а начальный t аргумент для skipN локально известен как 1, поэтому компилятор может безопасно сделать его строгим в t. Я не уверен, так это или нет, и для этого, безусловно, потребуется оптимизация.   -  person dfeuer    schedule 17.07.2014
comment
Для меня это звучит так, как будто у вас произошла утечка из аккумулятора, и вы не принудили аккумулятор правильно. Нам нужно увидеть код, который вы использовали для принудительной установки аккумулятора.   -  person Gabriel Gonzalez    schedule 17.07.2014
comment
Я использовал действие, которое выводило аккумулятор «t», и я пытался сказать skipN c t = seq t $ seq c $ do .... Ни то, ни другое не имело никакого значения. Я почти уверен, что не просто накапливаю панки на «c» и «t».   -  person Paul Johnson    schedule 17.07.2014
comment
@PaulJohnson: Написав код в ответе ниже с большим количеством принуждения, я склонен согласиться с вами. Я не понимаю, почему все еще протекает.   -  person Tom Ellis    schedule 17.07.2014


Ответы (3)


Это не anwser, это некий полный код, который я взломал для тестирования. Я вообще не знаю канала, так что это может быть не лучший код канала. Я форсировал все, что, кажется, нужно форсировать, но все равно протекает.

{-# LANGUAGE BangPatterns #-}

import Data.Conduit
import Data.Conduit.List
import Control.Monad.IO.Class

-- | Every n records, perform the IO action.
--   Used for progress reports to the user.
progress :: (MonadIO m) => Int -> (Int -> i -> IO ()) -> Conduit i m i
progress n act = skipN n 1
   where
      skipN !c !t = do
         mv <- await
         case mv of
            Nothing -> return ()
            Just !v ->
               if (c :: Int) <= 1
                  then do
                     liftIO $ act t v
                     yield v
                     skipN n (succ t)
                  else do
                     yield v
                     skipN (pred c) (succ t)

main :: IO ()
main = unfold (\b -> b `seq` Just (b, b+1)) 1
       $= progress 100000 (\_ b -> print b)
       $$ fold (\_ _ -> ()) ()

С другой стороны,

main = unfold (\b -> b `seq` Just (b, b+1)) 1 $$ fold (\_ _ -> ()) ()

не протекает, поэтому что-то в progress действительно кажется проблемой. Я не понимаю что.

РЕДАКТИРОВАТЬ: утечка происходит только с ghci! Если я скомпилирую двоичный файл и запустил его, утечки не будет (я должен был проверить это раньше ...)

person Tom Ellis    schedule 16.07.2014
comment
Спасибо. Я планировал написать что-то подобное сегодня. - person Paul Johnson; 17.07.2014
comment
Это не форсирует "t", так что вы все еще можете накапливать thunks. Я должен попробовать поиграть с ним сегодня вечером. - person Paul Johnson; 17.07.2014
comment
@PaulJohnson, этот образец взрыва в skipN !c !t, похоже, заставляет t. Нет необходимости форсировать c (хотя, вероятно, это хорошая идея для скорости), потому что это достаточно часто форсируется if. - person dfeuer; 17.07.2014
comment
Пожалуйста, посмотрите мой ответ ниже. Я думаю, что решение Тома не вызывает утечки памяти, а вместо этого что-то происходит с print. - person Michael Snoyman; 17.07.2014

Я думаю, что ответ Тома правильный, я начинаю это как отдельный ответ, так как он, вероятно, приведет к новому обсуждению (и потому что он слишком длинный для простого комментария). В моем тестировании замена print b в примере Тома на return () позволяет избавиться от утечки памяти. Это заставило меня подумать, что проблема на самом деле в print, а не в conduit. Чтобы проверить эту теорию, я написал простую вспомогательную функцию на C (помещенную в helper.c):

#include <stdio.h>

void helper(int c)
{
    printf("%d\n", c);
}

Затем я импортировал эту функцию в код Haskell:

foreign import ccall "helper" helper :: Int -> IO ()

и я заменил вызов print вызовом helper. Выходные данные программы идентичны, но я не показываю утечки и максимальное размещение 32 КБ против 62 КБ (я также изменил код, чтобы останавливаться на 10 м записях для лучшего сравнения).

Я наблюдаю подобное поведение, когда полностью вырезаю канал, например:

main :: IO ()
main = forM_ [1..10000000] $ \i ->
    when (i `mod` 100000 == 0) (helper i)

Однако я не уверен, что это действительно ошибка в print или Handle. Мое тестирование никогда не показало, что утечка достигает какого-либо существенного использования памяти, так что вполне возможно, что размер буфера приближается к пределу. Мне пришлось бы провести больше исследований, чтобы лучше понять это, но я хотел сначала посмотреть, согласуется ли этот анализ с тем, что видят другие.

person Michael Snoyman    schedule 17.07.2014
comment
Раньше я тестировал свой код только в ghci, компилировать его не стал. Сделав последнее, я заметил, что в скомпилированной версии нет никакой утечки (даже в -O0). Так что, возможно, в ghci есть ошибка? (Я на 7.6). - person Tom Ellis; 17.07.2014
comment
Кстати, в ghci утечка памяти огромна. Он быстро съедает 50% моей 4 ГБ памяти. - person Tom Ellis; 17.07.2014
comment
@PaulJohnson: Вы видите утечку места в скомпилированной версии? - person Tom Ellis; 17.07.2014
comment
Да, утечка была в скомпилированной версии. - person Paul Johnson; 19.07.2014
comment
Я пытаюсь создать образец программы, воспроизводящей утечку, но мне не очень везет. - person Paul Johnson; 19.07.2014
comment
В ПОРЯДКЕ. Цвет меня совсем запутал. Я удалил свой ~ / .ghc (то есть все локально установленные пакеты) и переустановил все с помощью cabal install -p (что позволяет профилировать библиотеку), чтобы я мог попытаться профилировать проблему. Утечка памяти ушла. - person Paul Johnson; 19.07.2014
comment
Путаница устранена: утечка происходит при включении оптимизации. - person Paul Johnson; 20.07.2014
comment
@PaulJohnson У вас есть отдельная программа, которая полностью демонстрирует утечку памяти? Это бы немного помогло. - person Michael Snoyman; 20.07.2014
comment
Я пытался разработать один, но не могу воспроизвести проблему нигде, кроме оригинала. Я буду продолжать. - person Paul Johnson; 21.07.2014

Я знаю, что прошло два года, но подозреваю, что полная лень поднимает часть тела await до момента, предшествующего await, и это вызывает утечку пространства. Это похоже на случай из раздела "Расширение доступа" в m y блоге пост по этой теме.

person edsko    schedule 30.09.2016