如何解决Julia 匿名函数和性能
我正在移植这个 Python 代码...
with open(filename,'r') as f:
results = [np.array(line.strip().split(' ')[:-1],float)
for line in filter(lambda l: l[0] != '#',f.readlines())]
...给朱莉娅。我想出了:
results = [map(ss -> parse(Float64,ss),split(s," ")[1:end-1])
for s in filter(s -> s[1] !== '#',readlines(filename))];
这次移植的主要原因是潜在的性能提升,所以我在 Jupyter notebook 中对这两个片段进行了计时:
- 使用
%%timeit
...- Python:
12.8 ms ± 44.7 µs per loop (mean ± std. dev. of 7 runs,100 loops each)
- Julia:
@benchmark
返回(除其他外)mean time: 8.250 ms (2.62% GC)
。到现在为止还挺好;我确实得到了性能提升。
- Python:
- 但是,当使用
@time
时:- 我得到了一些与
0.103095 seconds (130.44 k allocations: 11.771 MiB,91.58% compilation time)
相关的内容。从 this thread 我推断这可能是由我的 -> 函数声明引起的。
- 我得到了一些与
确实,如果我将代码替换为:
filt = s -> s[1] !== '#';
pars = ss -> parse(Float64,ss);
res = [map(pars," ")[1:end-1])
for s in filter(filt,readlines(filename))];
而且只有最后一行,我得到了更令人鼓舞的0.073007 seconds (60.58 k allocations: 7.988 MiB,88.33% compilation time)
;欢呼!然而,它有点违背了匿名函数的目的(至少在我的理解中是这样),并可能导致一堆 f1、f2、f3,......在列表理解之外为我的 Python lambda 函数命名不会似乎会影响 Python 的运行时。
我的问题是:为了获得正常的性能,我应该系统地命名我的 Julia 函数吗?请注意,这个特定的代码段将在大约 30k 个文件的循环中调用。 (基本上,我正在做的是读取由空格分隔的浮点数和注释行混合而成的文件;每个浮点数行可以有不同的长度,我对行的最后一个元素不感兴趣。对我的解决方案的任何评论都是赞赏。)
(旁注:用 s
包裹 strip
完全搞砸了 @benchmark
,平均值增加了 10 毫秒,但似乎不会影响 @time
。任何原因?)
按照 DNF 的建议将所有内容放入一个函数中可以解决我的“必须命名我的匿名函数”的问题。使用 Vincent Yu's 公式之一:
function results(filename::String)::Vector{Vector{Float64}}
[[parse(Float64,s) for s in @view split(line,' ')[1:end-1]]
for line in Iterators.filter(!startswith('#'),eachline(filename))]
end
@benchmark results(FN)
BenchmarkTools.Trial:
memory estimate: 3.74 MiB
allocs estimate: 1465
--------------
minimum time: 7.108 ms (0.00% GC)
median time: 7.458 ms (0.00% GC)
mean time: 7.580 ms (1.58% GC)
maximum time: 9.538 ms (14.84% GC)
--------------
samples: 659
evals/sample: 1
在此函数上调用的@time 在第一次编译运行后返回等效结果。我对此很满意。
然而,这是我对 strip 的持续问题:
function results_strip(filename::String)::Vector{Vector{Float64}}
[[parse(Float64,s) for s in @view split(strip(line),eachline(filename))]
end
@benchmark results_strip(FN)
BenchmarkTools.Trial:
memory estimate: 3.74 MiB
allocs estimate: 1465
--------------
minimum time: 15.155 ms (0.00% GC)
median time: 15.742 ms (0.00% GC)
mean time: 15.885 ms (0.75% GC)
maximum time: 19.089 ms (10.02% GC)
--------------
samples: 315
evals/sample: 1
中位数时间翻倍。如果我只看条带:
function only_strip(filename::String)
[strip(line) for line in Iterators.filter(!startswith('#'),eachline(filename))]
end
@benchmark only_strip(FN)
BenchmarkTools.Trial:
memory estimate: 1.11 MiB
allocs estimate: 475
--------------
minimum time: 223.868 μs (0.00% GC)
median time: 258.227 μs (0.00% GC)
mean time: 325.389 μs (9.41% GC)
maximum time: 56.024 ms (75.09% GC)
--------------
samples: 10000
evals/sample: 1
数字只是不加起来。是否存在类型不匹配,我应该将结果转换为其他内容吗?
解决方法
为了(希望)清楚地总结 Colin T Bowers 和 DNF 的评论:
- 在 Julia 中,匿名函数在编译后与命名函数一样快。
- 您观察到的差异是由编译时间引起的。
- 当您使用 BenchmarkTools.jl (
@btime
) 时,时间总是在编译后测量。如果您只使用@time
,则计算时间包括编译。实际上,您会在输出中获得此信息(您在其中获得了编译时间的百分比)。 - 同样,如果您将整个表达式放入函数中,它只会被编译一次,而如果您在顶级范围内对其进行评估,则每次运行时都会对其进行编译。
结论是:
- 如果您的代码的计算成本确实很高,那么编译时间就没有关系(因为它是一次性恒定成本),因此如果您要执行大量计算,则不必担心。
- 但是,如果您的计算成本较低,则编译时间会很明显,因为每次在顶级作用域中引入新的匿名函数时,都必须对其进行编译。
让我给你一个典型的例子来展示这个问题,希望能帮助你更好地理解这个问题:
julia> x = rand(10^6);
julia> @time count(v -> v < 0.5,x) # a lot of compilation as everything needs to be compiled
0.033077 seconds (18.34 k allocations: 1.047 MiB,110.16% compilation time)
499921
julia> @time count(v -> v < 0.5,x) # v -> v < 0.5 is a new function - it has to be compiled
0.013155 seconds (5.85 k allocations: 322.655 KiB,95.92% compilation time)
499921
julia> @time count(v -> v < 0.5,x) # v -> v < 0.5 is a new function - it has to be compiled
0.017371 seconds (5.85 k allocations: 322.702 KiB,95.37% compilation time)
499921
julia> f(x) = x < 0.5
f (generic function with 1 method)
julia> @time count(f,x) # f is a new function - it has to be compiled
0.011609 seconds (5.82 k allocations: 321.351 KiB,95.85% compilation time)
499921
julia> @time count(f,x) # f was already compiled - we are fast
0.000596 seconds (2 allocations: 32 bytes)
499921
julia> @time count(f,x) # f was already compiled - we are fast
0.000621 seconds (2 allocations: 32 bytes)
499921
julia> @time count(<(0.5),x) # <(0.5) is a new callable - it has to be compiled
0.013751 seconds (7.71 k allocations: 456.232 KiB,96.03% compilation time)
499921
julia> @time count(<(0.5),x) # <(0.5) is callable already compiled - we are fast
0.000504 seconds (2 allocations: 32 bytes)
499921
julia> @time count(<(0.5),x) # <(0.5) is callable already compiled - we are fast
0.000616 seconds (2 allocations: 32 bytes)
重点是每次编写 v -> v > 0.5
都是一个新函数,即使您使用了完全相同的定义 - 如果您引入它,Julia 必须创建一个新的匿名函数在全球范围内。在这里很容易看到:
julia> v -> v > 0.5
#7 (generic function with 1 method)
julia> v -> v > 0.5
#9 (generic function with 1 method)
(注意数字增加 - 这是一个不同的功能)
现在看看>(0.5)
:
julia> >(0.5)
(::Base.Fix2{typeof(>),Float64}) (generic function with 1 method)
julia> >(0.5)
(::Base.Fix2{typeof(>),Float64}) (generic function with 1 method)
每次调用都是相同的 - 所以它只需要编译一次。
最后,如果你把东西包装在一个函数中,正如 DNF 解释的那样:
julia> test() = v -> v > 0.5
test (generic function with 1 method)
julia> test()
#11 (generic function with 1 method)
julia> test()
#11 (generic function with 1 method)
正如你所看到的,匿名函数是在命名函数中定义的,编译器每次都知道它是同一个匿名函数,所以数量不会增加(它只需要编译一次 - 第一次 {{1 }} 被调用)。
关于 test
问题。差异在 strip
中可见,但在 @btime
中不可见,因为 @time
中 strip
的成本与编译成本相形见绌,因此您根本无法看到差异,但实际上这两种情况都是一样的。
Bogumił Kamiński 的回答非常好。我写这个只是为了评论你的解决方案。
请注意,您可以使用标准库中的 DelimitedFiles
module 将此类文件读入矩阵。像这样:
using DelimitedFiles
readdlm(filename,' ',Float64; comments=true,comment_char='#')
但是您可能会发现这 比您的代码慢,因为它将数据读入列主矩阵而不是基于行的向量向量。哪个更好取决于您的需求。 (当然,有许多包可以将分隔文件读入各种结构。)
关于您的解决方案,我建议进行一些小的更改以提高性能和内存使用:
-
readlines
和filter
都会分配您没有保留的新向量。要避免这些内存分配,请使用 iterator interface 和eachline
提供的Iterators.filter
。 - 同样,索引
[1:end-1]
会创建一个不必要的向量。使用view
或方便的@view
宏来避免分配。
此外,我认为在这段代码中坚持使用 map
或数组推导式而不是将两者混合使用更清晰。
以下代码合并了这些更改(使用 map
和 do
notation)。在我的测试案例中,这将速度提高了大约 15%,内存使用率提高了大约 30%:
results = map(Iterators.filter(!startswith('#'),eachline(filename))) do line
map(@view split(line,' ')[1:end-1]) do s
parse(Float64,s)
end
end
如果您更喜欢数组推导式而不是 map
,以下是相同的:
results = [
[
parse(Float64,s)
for s in @view split(line,' ')[1:end-1]
]
for line in Iterators.filter(!startswith('#'),eachline(filename))
]
正如您在评论中指出的,我们可以使用 broadcasting 来消除显式的内部循环,从而得到更简洁的代码:
results = map(Iterators.filter(!startswith('#'),eachline(filename))) do line
parse.(Float64,@view split(line,' ')[1:end-1])
end
results = [
parse.(Float64,' ')[1:end-1])
for line in Iterators.filter(!startswith('#'),eachline(filename))
]
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。