Оптимизация рекурсивной функции с метапрограммированием в Julia - PullRequest
0 голосов
/ 06 июля 2018

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

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

Рассмотрим следующую модифицированную функцию Фибоначчи, в которой я хочу вычислить ряд Фибоначчи до n и умножить его на число p.

Простая рекурсивная реализация будет

function fib(n::Integer, p::Real)
    if n <= 1
        return 1 * p
    else
        return n * fib(n-1, p)
    end
end

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

function fib_expr(n::Integer, p::Symbol)
    if n <= 1
        return :(1 * $p)
    else
        return :($n * $(fib_expr(n-1, p)))
    end
end

который, например возвращает что-то вроде

julia> ex = fib_expr(3, :myp)
:(3 * (2 * (1myp)))

Таким образом, я получаю выражение, которое полностью развернуто и зависит от значения, присвоенного символу myp. Таким образом, я больше не вижу рекурсии, в основном я метапрограммируем : я создал функцию, которая создает другую «функцию» (в этом случае мы называем это выражением). Теперь я могу установить myp = 0.5 и вызвать eval(ex), чтобы вычислить результат. Однако это на медленнее , чем при первом подходе.

Что я могу сделать, так это сгенерировать параметрическую функцию следующим образом

@generated function fib_gen{n}(::Type{Val{n}}, p::Real)
    return fib_expr(n, :p)
end

И, волшебным образом, вызов fib_gen(Val{3}, 0.5) делает дело, и это невероятно быстро. Итак, что происходит?

Насколько я понимаю, при первом вызове fib_gen(Val{3}, 0.5) параметрическая функция fib_gen{Val{3}}(...) компилируется, и ее содержимым является полностью расширенное выражение, полученное через fib_expr(3, :p), то есть 3*2*1*p с p, замененным на вход значение. Причина, по которой он так быстр, заключается в том, что fib_gen - это просто серия умножений, тогда как исходный fib должен распределять в стеке каждый рекурсивный вызов, делая его медленнее, Я прав?

Чтобы привести некоторые цифры, вот мой короткий тест using BenchmarkTools.

julia> @benchmark fib(10, 0.5)
...
mean time: 26.373 ns
...

julia> p = 0.5
0.5

julia> @benchmark eval(fib_expr(10, :p))
...
mean time: 177.906 μs
...

julia> @benchmark fib_gen(Val{10}, 0.5)
...
mean time: 2.046 ns
...

У меня много вопросов:

  • Почему второй случай такой медленный?
  • Что именно означает и означает ::Type{Val{n}}? (Я скопировал это из ответа выше)
  • Из-за JIT-компилятора иногда я теряюсь в том, что происходит в во время компиляции и в во время выполнения , как это имеет место здесь ...

Кроме того, я попытался объединить fib_expr и fib_gen в одну функцию в соответствии с

@generated function fib_tot{n}(::Type{Val{n}}, p::Real)
    if n <= 1
        return :(1 * p)
    else
        return :(n * fib_tot(Val{n-1}, p))
    end
end

что медленно

julia> @benchmark fib_tot(Val{10}, 0.5)
...
mean time: 4.601 μs
...

Что я здесь не так делаю? Можно ли даже объединить fib_expr и fib_gen в одной функции?

Я понимаю, что это скорее монография, а не вопрос, однако, хотя я и читаю раздел metaprogramming несколько раз, мне трудно разобраться во всем, в частности, с помощью прикладного примера, такого как этот.

Ответы [ 2 ]

0 голосов
/ 06 июля 2018

Прежде всего, я присоединяюсь к комментариям: ваш вопрос очень хорошо написан и конструктивен.


Я воспроизвел ваши результаты с помощью Julia 0.7-beta.

  • Разница между @generated fib_tot (один фрагмент кода) и fib_gen (который вызывает fib_expr)

С моей версией julia результаты совпадают:

julia> @btime fib_tot(Val{10},0.5)
  0.042 ns (0 allocations: 0 bytes)
1.8144e6

julia> @btime fib_gen(Val{10},0.5)
  0.042 ns (0 allocations: 0 bytes)
1.8144e6

Иногда разбиение функции на несколько частей см. Официальный документ: советы по производительности может быть полезно, однако в вашем специфическом случае я не понимаю, почему это может быть полезно. Во время компиляции у Джулии есть все, что нужно для оптимизации fib_tot. Существует ветвь if n<=1, однако n известен во время «компиляции» благодаря трюку Type{Val{n}}, и эту ветвь следует без проблем удалить в сгенерированном (специализированном) коде.

  • Трюк Type{Val{n}}

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

Например, скомпилированная версия foo(n::Int) = ... не создается для каждого значения n. Для достижения этой цели вы должны определить тип, который зависит от значения n. Именно так работает Type{Val{n}}: Val{n} - просто параметризованная пустая структура:

struct Val{T} end

Следовательно, каждый Val{1}, Val{2}, ... Val{100}, ... - это другой тип. Следовательно, если foo определяется как:

foo(::Type{Val{n}}) where {n} = ...

Каждый foo(Val{1}), foo(Val{2}), ... foo(Val{100}) будет запускать специализированную версию foo (поскольку аргумент type отличается).

  • eval(fib_expr(n, 1)) чехол

Это

julia> @btime eval(fib_expr(10, :p))
  401.651 μs (99 allocations: 6.45 KiB)
1.8144e6

медленно, потому что ваше выражение (пере) составляется каждый раз. Эту проблему можно избежать, если вместо этого использовать макрос (см. Ответ phg).

  • fib версия

.

julia> @btime fib(10,0.5)
  30.778 ns (0 allocations: 0 bytes)
1.8144e6

Существует только одна скомпилированная версия этой fib функции. Следовательно, он должен содержать все тесты ветвлений во время выполнения и т. Д. Это объясняет, насколько медленно.


Просто замечание по поводу:

  • foo{n}(::Type{Val{n}}) устарел синтаксис

Синтаксис foo{n}(::Type{Val{n}}) устарел, новый - foo(::Type{Val{n}}) where {n}. Вы можете прочитать Julia doc, параметрические методы для получения более подробной информации.


Моя версия Юлия:

julia> versioninfo()

Julia Version 0.7.0-beta.0
Commit f41b1ecaec (2018-06-24 01:32 UTC)
Platform Info:
  OS: Linux (x86_64-pc-linux-gnu)
  CPU: Intel(R) Xeon(R) CPU E5-2603 v3 @ 1.60GHz
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-6.0.0 (ORCJIT, haswell)
0 голосов
/ 06 июля 2018

Монография в ответ:

Основы метапрограммирования

Сначала будет проще начать с "нормальных" макросов. Я немного расслаблю определение, которое вы использовали:

function fib_expr(n::Integer, p)
    if n <= 1
        return :(1 * $p)
    else
        return :($n * $(fib_expr(n-1, p)))
    end
end

Это позволяет передавать для p не только символы, например целочисленные литералы или целые выражения. Учитывая это, мы можем определить макрос для той же функциональности:

macro fib_macro(n::Integer, p)
    fib_expr(n, p)
end

Теперь, если @fib_macro 45 1 используется где-либо в коде, во время компиляции он сначала будет заменен длинным вложенным выражением

:(45 * (44 * ... * (1 * 1)) ... )

и затем компилируется нормально - в константу.

Вот и все, что нужно с макросами, правда. Замена синтаксиса во время компиляции; и с помощью рекурсии это может быть сколь угодно длинное изменение между компиляцией и вычислением функций в выражениях. И для вещей, которые по существу постоянны, но утомительны, если писать иначе, это очень полезно: пример хорошего примера: Base.Math. @ Evalpoly .

Оценка во время выполнения?

Но проблема в том, что вы не можете проверять значения, которые известны только во время выполнения: вы не можете реализовать fib(n) = @fib_macro n 1, поскольку во время компиляции n является символом, представляющим параметр, а не числом, которое вы можете отправить на.

Следующим лучшим решением будет использование

fib_eval(n::Integer) = eval(fib_expr(n, 1))

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

Метод отправки и компиляции

Так что нам нужен способ смешивать время выполнения и время компиляции. Введите @generated функции. Они будут выполняться во время выполнения для типа , а затем работать как макрос, определяющий тело функции.

Сначала о типе отправки. Если у нас есть

f(x) = x + 1

и вызов функции f(1), произойдет примерно следующее:

  1. Определен тип аргумента (Int)
  2. С таблицей методов функции обращаются, чтобы найти лучший метод соответствия
  3. Тело метода компилируется для определенного типа аргумента Int, если это не было сделано ранее
  4. Скомпилированный метод оценивается по конкретному аргументу

Если мы затем введем f(1.0), то же самое произойдет снова, с новым, другим специализированным методом, скомпилированным для Float64, основанным на том же теле функции.

Типы значений и одиночные типы

Теперь у Юлии есть особенность: вы можете использовать числа в качестве типов. Это означает, что описанный выше процесс отправки будет также работать со следующей функцией:

g(::Type{Val{N}}) where N = N + 1

Это немного сложно. Помните, что типы сами по себе являются значениями в Юлии: Int isa Type.

Здесь Val{N} - это для каждого N так называемого синглетонного типа , имеющего ровно один экземпляр, а именно Val{N}() - точно так же, как Int - это тип, имеющий много экземпляров 0, -1, 1, -2, ....

Type{T} также является одноэлементным типом, имеющим в качестве единственного экземпляра тип T. Int - это Type{Int}, а Val{3} - это Type{Val{3}} - фактически оба являются единственными значениями своего типа.

Итак, для каждого N существует тип Val{N}, являющийся единственным экземпляром Type{Val{N}}. Таким образом, g будет отправлено и скомпилировано для каждого N. Вот как мы можем отправлять числа как типы. Это уже позволяет оптимизировать:

julia> @code_llvm g(Val{1})

define i64 @julia_g_61158(i8**) #0 !dbg !5 {
top:
  ret i64 2
}

julia> @code_llvm f(1)

define i64 @julia_f_61076(i64) #0 !dbg !5 {
top:
  %1 = shl i64 %0, 2
  %2 = or i64 %1, 3
  %3 = mul i64 %2, %0
  %4 = add i64 %3, 2
  ret i64 %4
}

Но помните, что он требует компиляции для каждого нового N при первом вызове.

fkt(::T) - это просто сокращение от fkt(x::T), если вы не используете x в теле.)

Интеграция производящих функций и типов значений

Наконец к сгенерированным функциям. Они работают как небольшая модификация вышеуказанного шаблона отправки:

  1. Определен тип аргумента (Int)
  2. С таблицей методов функции обращаются, чтобы найти наилучший метод сопоставления
  3. Тело метода обрабатывается как макрос, и вызывается с типом аргумента Int в качестве параметра ,если это не было сделано раньше.Полученное выражение компилируется в метод.
  4. Скомпилированный метод оценивается по конкретному аргументу

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

Для нашей конкретной настройки мы хотим отправить типы Val, представляющие аргументы последовательности Фибоначчи:

@generated function fib_gen{n}(::Type{Val{n}}, p::Real)
    return fib_expr(n, :p)
end

Теперь вы видите, что ваше объяснение было совершенно правильным:

при первом вызове fib_gen(Val{3}, 0.5) параметрическая функция fib_gen{Val{3}}(...) компилируется, и ее содержимым является полностью расширенное выражение, полученное с помощью fib_expr(3, :p), т.е. 3*2*1*p с p, замененным на входзначение.

Я надеюсь, что вся история также ответила на все три перечисленных вами вопроса:

  1. Реализация, использующая eval, повторяет рекурсию каждый раз, плюсиздержки компиляции
  2. Val - это трюк для поднятия чисел к типам, а Type{T} синглтон-тип, содержащий только T -- но я надеюсь, что примеры были достаточно полезны
  3. Время компиляции не перед выполнением, из-за JIT - это каждый раз, когда метод компилируется в первый раз, потому что он вызывается.
...