在Julia

时间:2018-07-06 10:45:46

标签: recursion optimization julia metaprogramming

按照this answer的方法,我试图了解在metaprogramming概念内Julia究竟发生了什么以及表达式和生成的函数是如何工作的。

目标是使用表达式和生成的函数优化递归函数(对于具体示例,您可以查看上面提供的链接中回答的问题)。

请考虑以下修改的fibonacci函数,在该函数中,我要计算直至n的斐波那契数列,然后将其乘以数字p

简单,递归的实现将是

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

第一步,我可以定义一个返回 expression 而不是计算值的函数

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的值。这样,我不再看到递归了,基本上我是 metaprogramming :我创建了一个函数,该函数创建了另一个“函数”(在这种情况下,我们称其为表达式)。 我现在可以设置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必须在堆栈上分配每个递归调用,这会使其变慢, am我正确吗?

要给出一些数字,这是我的简短基准测试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编译器,有时我迷失在编译时运行时发生的情况,就像这里的情况一样... < / li>

此外,我尝试根据{p>将fib_exprfib_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_exprfib_gen合并到一个函数中吗?

我意识到这更像是一本专着而不是一个问题,但是,尽管我几次阅读了metaprogramming部分,但我还是很难掌握所有内容,尤其是通过这样的应用示例一个。

2 个答案:

答案 0 :(得分:1)

回应的专着:

元编程基础

首先从“普通”宏开始会更容易。我会放松一下您使用的定义:

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函数。它们将在运行时以 type 进行分派,然后像定义函数体的宏一样工作。

首先关于类型调度。如果有

f(x) = x + 1

并有一个函数调用f(1),大约会发生以下情况:

  1. 确定参数的类型(Int
  2. 查阅函数的方法表以找到最佳的匹配方法
  3. 如果方法主体是针对特定的Int参数类型(如果之前尚未完成的话)
  4. 根据具体参数评估编译后的方法

如果我们随后输入f(1.0),则将再次发生相同的情况,并且将基于相同的函数主体为Float64编译新的专用方法。

值类型和单例类型

现在,朱莉娅具有独特的功能,您可以将数字用作类型。这意味着上面概述的调度过程也将在以下功能上起作用:

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

有点棘手。请记住,类型本身就是Julia中的值:Int isa Type

这里,Val{N}中的每一个N就是所谓的 singleton类型,它具有一个实例,即Val{N}(),与{{1} }是具有许多实例Int0-11,....

的类型。

-2也是一个单例类型,其唯一实例为Type{T} 类型。 TInt,而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)的缩写。)

集成生成函数和值类型

最后是生成的函数。它们是对上述分发模式的略微修改:

  1. 确定参数的类型(x
  2. 查阅函数的方法表以找到最佳的匹配方法
  3. 方法主体被视为宏,并以Int参数类型作为参数调用(如果以前没有做过)。结果表达式被编译为方法。
  4. 根据具体参数评估编译后的方法

此模式允许更改分派函数的每种类型的实现。

对于我们的具体设置,我们希望分派代表斐波那契数列参数的Int类型:

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替换为输入值。

我希望整个故事也能回答您列出的所有三个问题:

  1. 使用p的实现每次都会复制递归,再加上编译的开销
  2. eval是一种将数字提升为类型的技巧,Val是仅包含Type{T}的单例类型的技巧-但我希望这些示例足够有用
  3. 编译时间不在执行之前,这是由于JIT所致–它是每次方法第一次被编译时,因为它被调用了。

答案 1 :(得分:1)

首先,我要发表评论:您的问题写得很好并且很有建设性。


我已使用Julia 0.7-beta复制了您的结果。

  • @生成的 fib_tot(一段代码) fib_gen(称为fib_expr)之间的区别

在我的茱莉亚版本中,结果是相同的:

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

有时将一个功能分成多个部分see official doc:performance tips可能很有用,但是在您的特殊情况下,我看不出为什么这可能有用。在编译时,Julia具有优化fib_tot所需的一切。有一个分支if n<=1,但是由于Type{Val{n}}的技巧,n在“编译时”是已知的,应该删除该分支,而不会在生成的(专用)代码中出现问题。

  • Type{Val{n}}技巧

要专门化功能,Julia推断是根据参数类型执行的,而不是根据参数值执行的。

例如,没有为每个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, parametric methods了解更多详细信息。


我的Julia版本:

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)