按照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}}
的确切含义是什么? (我从上面链接的答案中复制了该内容)此外,我尝试根据{p>将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部分,但我还是很难掌握所有内容,尤其是通过这样的应用示例一个。
答案 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)
,大约会发生以下情况:
Int
)Int
参数类型(如果之前尚未完成的话)如果我们随后输入f(1.0)
,则将再次发生相同的情况,并且将基于相同的函数主体为Float64
编译新的专用方法。
现在,朱莉娅具有独特的功能,您可以将数字用作类型。这意味着上面概述的调度过程也将在以下功能上起作用:
g(::Type{Val{N}}) where N = N + 1
有点棘手。请记住,类型本身就是Julia中的值:Int isa Type
。
这里,Val{N}
中的每一个N
就是所谓的 singleton类型,它具有一个实例,即Val{N}()
,与{{1} }是具有许多实例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
)Int
参数类型作为参数调用(如果以前没有做过)。结果表达式被编译为方法。此模式允许更改分派函数的每种类型的实现。
对于我们的具体设置,我们希望分派代表斐波那契数列参数的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
替换为输入值。
我希望整个故事也能回答您列出的所有三个问题:
p
的实现每次都会复制递归,再加上编译的开销eval
是一种将数字提升为类型的技巧,Val
是仅包含Type{T}
的单例类型的技巧-但我希望这些示例足够有用答案 1 :(得分:1)
首先,我要发表评论:您的问题写得很好并且很有建设性。
我已使用Julia 0.7-beta复制了您的结果。
在我的茱莉亚版本中,结果是相同的:
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)