Выбор между продолжением стиля прохода и записью

При написании примеров для функций memoization и продолжения прохождения (CPS) на функциональном языке я в конечном итоге использовал пример Fibonacci для обоих. Тем не менее, Фибоначчи действительно не выигрывает от CPS, так как цикл все равно должен выполняться экспоненциально часто, тогда как с memoization его единственный O (n) первый раз и O (1) в следующее следующее время.

Объединение CPS и memoization имеет небольшое преимущество для Fibonacci, но есть ли примеры, где CPS - лучший способ, который препятствует выходу из стека и повышает производительность и где memoization не является решением?

Или: есть ли какие-либо ориентиры для выбора одного или другого?

Ответы

Ответ 1

В CPS

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

let rec fib = function
  | 0 | 1 -> 1
  | n -> fib (n-1) + fib (n-2)

let rec fib_cps n k = match n with
  | 0 | 1 -> k 1
  | n -> fib_cps (n-1) (fun a -> fib_cps (n-2) (fun b -> k (a+b)))

Предыдущий не-хвост-вызов fib (n-2), который выделил новый стек стека, превращается в хвост-вызов fib (n-2) (fun b -> k (a+b)), который выделяет замыкание fun b -> k (a+b) (в куче), чтобы передать его в качестве аргумента.

Это не асимптотически уменьшает использование памяти вашей программой (некоторые дополнительные оптимизации для домена могут, но это другая история). Вы просто торгуете пространством стека для пространства кучи, что интересно в системах, где пространство стека сильно ограничено ОС (не в случае с некоторыми реализациями ML, такими как SML/NJ, которые отслеживают свой стек вызовов в куче вместо с использованием системного стека) и потенциально снижается производительность из-за дополнительных затрат ГК и потенциальной локальности.

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

В Memoization

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

Для любой рекурсивной функции (или рекурсивной постановки задачи) вы можете наблюдать структуру пространства подзадач. Какие более простые экземпляры Fib(k) будут Fib(n) должны возвращать свой результат? Какие более простые экземпляры будут нуждаться в этих экземплярах?

В общем случае это пространство подзадачи - это граф (обычно ацикличный для целей прекращения): есть несколько узлов, которые имеют несколько родителей, то есть несколько различных проблем, для которых они являются подзадачами. Например, Fib(n-2) является подзадачей как Fib(n), так и Fib(n-2). Объем обмена node на этом графике зависит от конкретной задачи/рекурсивных функций. В случае с Фибоначчи все узлы разделяются между двумя родителями, поэтому есть много общего.

Прямой рекурсивный вызов без memoization не сможет наблюдать этот обмен. Структура вызовов рекурсивной функции - это дерево, а не граф, а общие подзадачи, такие как Fib(n-2), будут полностью посещены несколько раз (столько, сколько есть путей от стартового node к подзадаче node на графике). Memoization вводит общий доступ, позволяя некоторым субколлам возвращаться напрямую с "мы уже вычислили этот node, и вот результат". Для проблем, которые имеют много общего, это может привести к резкому сокращению (бесполезному) вычислению: Фибоначчи переходит от экспоненциальной к линейной сложности при введении memoization - обратите внимание на то, что есть другие способы записи функции без использования memoization, но различные структуры подколов, чтобы иметь линейную сложность.

let rec fib_pair = function
  | 0 -> (1,1)
  | n -> let (u,v) = fib_pair (n-1) in (v,u+v)

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

Имеет ли смысл сравнение?

Эти два, кажется, в основном независимы друг от друга.

Есть много проблем, когда memoization не применимо, потому что структура графа подзадачи не имеет совместного использования (это дерево). Напротив, преобразование CPS применимо ко всем рекурсивным функциям, но само по себе не приводит к преимуществам производительности (за исключением потенциальных постоянных факторов из-за конкретной реализации и системы времени исполнения, которые вы используете, хотя они, вероятно, будут делать код медленнее, а не быстрее).

Улучшение характеристик путем проверки контекста без хвоста

Существует метод оптимизации, связанный с CPS, который может улучшить производительность рекурсивных функций. Они состоят в том, чтобы смотреть на вычисления, "оставшиеся" после рекурсивных вызовов (что бы превратилось в функцию в прямом стиле CPS) и найти альтернативное, более эффективное представление для него, что не приводит к систематическому распределению закрытия. Рассмотрим, например:

let rec length = function
  | [] -> 0
  | _::t -> 1 + length t

let rec length_cps li k = match li with
  | [] -> k 0
  | _::t -> length_cps t (fun a -> k (a + 1))

Вы можете заметить, что контекст нерекурсивного вызова, а именно [_ + 1], имеет простую структуру: он добавляет целое число. Вместо представления этого как функции fun a -> k (a+1) вы можете просто сохранить добавленное целое, соответствующее нескольким приложениям этой функции, сделав k целое число вместо функции.

let rec length_acc li k = match li with
  | [] -> k + 0
  | _::t -> length_acc t (k + 1)

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

Завершите проверку порядка, в котором выполняются дополнения, покажет, что они теперь выполняются в другом направлении: мы сначала добавляем 1, соответствующий началу списка, в то время как версия cps добавляла их последние, Это обращение к порядку верно, потому что _ + 1 является ассоциативной операцией (если у вас есть несколько вложенных контекстов, foo + 1 + 1 + 1, то действительно, чтобы начать вычислять их либо изнутри, ((foo+1)+1)+1, либо снаружи, foo+(1+(1+1))), Приведенная выше оптимизация может использоваться для всех таких "ассоциативных" контекстов вокруг не-хвоста.

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

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

type length_cont =
  | Linit
  | Lcons of length_cont

let rec length_cps_defun li k = match li with
  | [] -> length_cont_eval 0 k
  | _::t -> length_cps_defun t (Lcons k)
and length_cont_eval acc = function
  | Linit -> acc
  | Lcons k -> length_cont_eval (acc+1) k

let length li = length_cps_defun li Linit

type fib_cont =
  | Finit
  | Fminus1 of int * fib_cont
  | Fminus2 of fib_cont * int

let rec fib_cps_defun n k = match n with
  | 0 | 1 -> fib_cont_eval 1 k
  | n -> fib_cps_defun (n-1) (Fminus1 (n, k))
and fib_cont_eval acc = function
  | Finit -> acc
  | Fminus1 (n, k) -> fib_cps_defun (n-2) (Fminus2 (k, acc))
  | Fminus2 (k, acc') -> fib_cont_eval (acc+acc') k

let fib n = fib_cps_defun n Finit

Ответ 2

Одним из преимуществ CPS является обработка ошибок. Если вам нужно сбой, вы просто вызываете свой метод отказа.

Я думаю, что самая большая ситуация заключается в том, что вы не говорите о вычислениях, где заметна заметная память. Если вы говорите об IO или других операциях, преимущества CPS существуют, но memoization не работает.

Что касается экземпляра, где CPS и memoization являются применимыми, а CPS лучше, я не уверен, так как считаю их различными функциональными возможностями.

Наконец, CPS немного уменьшилась в F #, поскольку хвостовая рекурсия делает всю операцию уже не относящейся к проблеме.