微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

Julia 匿名函数和性能

如何解决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)。到现在为止还挺好;我确实得到了性能提升。
  • 但是,当使用 @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 的评论:

  1. 在 Julia 中,匿名函数在编译后与命名函数一样快。
  2. 您观察到的差异是由编译时间引起的。
  3. 当您使用 BenchmarkTools.jl (@btime) 时,时间总是在编译后测量。如果您只使用 @time,则计算时间包括编译。实际上,您会在输出中获得此信息(您在其中获得了编译时间的百分比)。
  4. 同样,如果您将整个表达式放入函数中,它只会被编译一次,而如果您在顶级范围内对其进行评估,则每次运行时都会对其进行编译。

结论是:

  1. 如果您的代码的计算成本确实很高,那么编译时间就没有关系(因为它是一次性恒定成本),因此如果您要执行大量计算,则不必担心。
  2. 但是,如果您的计算成本较低,则编译时间会很明显,因为每次在顶级作用域中引入新的匿名函数时,都必须对其进行编译。

让我给你一个典型的例子来展示这个问题,希望能帮助你更好地理解这个问题:

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 中不可见,因为 @timestrip 的成本与编译成本相形见绌,因此您根本无法看到差异,但实际上这两种情况都是一样的。

,

Bogumił Kamiński 的回答非常好。我写这个只是为了评论你的解决方案。

请注意,您可以使用标准库中的 DelimitedFiles module 将此类文件读入矩阵。像这样:

using DelimitedFiles
readdlm(filename,' ',Float64; comments=true,comment_char='#')

但是您可能会发现这 比您的代码慢,因为它将数据读入列主矩阵而不是基于行的向量向量。哪个更好取决于您的需求。 (当然,有许多包可以将分隔文件读入各种结构。)

关于您的解决方案,我建议进行一些小的更改以提高性能和内存使用:

  • readlinesfilter 都会分配您没有保留的新向量。要避免这些内存分配,请使用 iterator interfaceeachline 提供的 Iterators.filter
  • 同样,索引 [1:end-1] 会创建一个不必要的向量。使用 view 或方便的 @view 宏来避免分配。

此外,我认为在这段代码中坚持使用 map 或数组推导式而不是将两者混合使用更清晰。

以下代码合并了这些更改(使用 mapdo 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 举报,一经查实,本站将立刻删除。