Clojure comp не оптимизирует последующий вызов (может создать исключение StackOverflow)

#performance #clojure

#Производительность #clojure

Вопрос:

Я застрял в программе Clojure, обрабатывающей очень большой объем данных (данные изображения). Когда изображение было больше 128×128, программа завершала работу с исключением StackOverflow. Поскольку это работало для изображений меньшего размера, я знал, что это не бесконечный цикл.

Было много возможных причин высокого использования памяти, поэтому я потратил время на поиск. Убедившись, что я правильно использую отложенные последовательности, убедившись, что использую recur по мере необходимости и т.д. Поворотный момент наступил, когда я понял, что это:

 at clojure.core$comp$fn__5792.invoke(core.clj:2569)
at clojure.core$comp$fn__5792.invoke(core.clj:2569)
at clojure.core$comp$fn__5792.invoke(core.clj:2569)
  

ссылается на comp функцию.

Итак, я посмотрел, где я его использовал:

 (defn pipe [val funcs]
  ((apply comp funcs) val))

(pipe the-image-vec
  (map
    (fn [index] (fn [image-vec] ( ... )))
    (range image-size)))
  

Я выполнял операции для каждого пикселя, сопоставляя функцию с каждым пикселем для его обработки. Интересно, что comp , похоже, не извлекает выгоды из оптимизации конечных вызовов или выполняет какое-либо последовательное применение функций. Кажется, что он просто составлял вещи базовым способом, который при наличии 65 тысяч функций по понятным причинам переполняет стек. Вот исправленная версия:

 (defn pipe [val funcs]
  (cond
    (= (count funcs) 0) val
    :else               (recur ((first funcs) val) (rest funcs))))
  

recur гарантирует, что рекурсия оптимизируется для конечного вызова, предотвращая накопление стека.

Если кто-нибудь может объяснить, почему comp работает таким образом (или, скорее, не работает таким образом), я хотел бы быть просвещенным.

Комментарии:

1. В этом случае я бы выбрал reduce. Что-то вроде (уменьшить # (%2 %1) функции val)?

2. Я борюсь с вариантом использования — если каждый пиксель сопоставлен с потенциально другой функцией, это одно, но здесь вы применяете n функций к каждому из n пикселей? Какова технология обработки изображений?

3. Разве у вас не должно быть в вашем решении повторяющихся функций в обратном порядке? ((comp f g) val) будет выполнять f(g(val)), но ваш (pipe val [f g]) будет выполняться поэтапно как val = f (val), val= g(val); == g(f(val))?

Ответ №1:

Во-первых, более простой MCVE:

 (def fs (repeat 1e6 identity))
((apply comp fs)  99)

#<StackOverflowError...
  

Но почему это происходит? Если вы посмотрите на (сокращенный) comp источник:

 (defn comp
  ([f g] 
     (fn 
       ([x] (f (g x)))
  ([f g amp; fs]
     (reduce1 comp (list* f g fs))))
  

Вы можете видеть, что все это в основном состоит всего из 2 частей:

  • Перегрузка первого аргумента, которая выполняет основную работу по переносу каждого составного вызова функции в другую функцию.

  • Сокращение по сравнению с используемыми функциями comp .

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

Итак, почему TCO здесь не может помочь? Потому что, в отличие от большинства ошибок StackOverflowErrors, рекурсия здесь не проблема. Рекурсивные вызовы достигают только одного кадра в глубине в регистре variardic внизу. Проблема заключается в создании массивной функции, которую нельзя просто оптимизировать.

Почему вы смогли это «исправить»? Потому что у вас есть доступ к val , поэтому вы можете оценивать функции по ходу выполнения вместо создания одной функции для последующего вызова. comp была написана с использованием простой реализации, которая отлично работает в большинстве случаев, но не работает в крайних случаях, подобных представленному вами. Довольно тривиально написать специализированную версию, которая обрабатывает массивные коллекции, хотя:

 (defn safe-comp [amp; fs]
  (fn [value]
    (reduce (fn [acc f]
              (f acc))
            value
            (reverse fs))))
  

Конечно, обратите внимание, что это не обрабатывает несколько функций, как это делает базовая версия.

Честно говоря, за 3 с небольшим года использования Clojure я ни разу не написал (apply comp ...) . Хотя, безусловно, возможно, что вы столкнулись со случаем, с которым мне никогда не приходилось иметь дело, более вероятно, что вы используете неправильный инструмент для работы здесь. Когда этот код будет завершен, опубликуйте его в Code Review, и мы сможем предложить лучшие способы выполнения того, что вы пытаетесь сделать.

Комментарии:

1. «Проблема заключается в создании массивной функции, которую нельзя просто оптимизировать […] вместо создания одной функции для последующего вызова. очевидно, что comp не может этого сделать, поскольку он не принимает аргументы, которые будут применены, и должен возвращать недооцененную функцию. » … ему не нужно возвращать гигантскую вложенную часть и помещать все это в стек сразу (функция со списком символов для вызова переходов в памяти). Это нормально, что это так, поскольку типичное использование включает в себя подобное… не 65000 функций, но … не может?

2. @l.k Обновлено. Не уверен, что это было то, что вы имели в виду, но это работает.

3. Намного счастливее. Я никогда не боюсь «не могу» 🙂