朱莉娅(1.3)中具有斐波那契数列的多线程并行性能问题


14

我正在尝试Julia 1.3使用以下硬件的多线程功能:

Model Name: MacBook Pro
Processor Name: Intel Core i7
Processor Speed:    2.8 GHz
Number of Processors:   1
Total Number of Cores:  4
L2 Cache (per Core):    256 KB
L3 Cache:   6 MB
Hyper-Threading Technology: Enabled
Memory: 16 GB

运行以下脚本时:

function F(n)
if n < 2
    return n
    else
        return F(n-1)+F(n-2)
    end
end
@time F(43)

它给我以下输出

2.229305 seconds (2.00 k allocations: 103.924 KiB)
433494437

但是,当运行下面的代码从Julia页面复制的关于多线程的代码时

import Base.Threads.@spawn

function fib(n::Int)
    if n < 2
        return n
    end
    t = @spawn fib(n - 2)
    return fib(n - 1) + fetch(t)
end

fib(43)

发生的是,RAM / CPU的使用率从3.2GB / 6%跃升至15GB / 25%,没有任何输出(至少1分钟,此后我决定终止julia session)

我究竟做错了什么?

Answers:


19

好问题。

Fibonacci函数的这种多线程实现并不比单线程版本快。该功能仅在博客文章中显示为新线程功能如何工作的玩具示例,强调了它允许在不同功能中产生许多线程,并且调度程序将找出最佳工作负载。

问题在于,@spawn它的开销很小1µs,因此,如果生成一个线程来执行一个少于的任务,则1µs可能会损害性能。的递归定义fib(n)具有阶数1.6180^n[1]的指数时间复杂度,因此在调用时fib(43),会生成一些阶数1.6180^43线程。如果每个人都需要1µs生成,则仅花费约16分钟即可生成并安排所需的线程,这甚至不考虑执行实际计算和重新合并/同步线程所花费的时间,甚至更多时间。

像这样的事情,在您为计算的每个步骤生成线程时,只有与@spawn开销相比,计算的每个步骤花费很长时间,才有意义。

请注意,目前正在努力减少的开销@spawn,但由于多核有机硅芯片的物理特性,我怀疑它对于上述fib实现是否足够快。


如果您对如何将线程fib函数修改为真正有益的方法感到好奇,那么最简单的方法是仅fib在我们认为1µs运行时间比运行时间长的情况下才产生线程。在我的机器上(在16个物理内核上运行),我得到了

function F(n)
    if n < 2
        return n
    else
        return F(n-1)+F(n-2)
    end
end


julia> @btime F(23);
  122.920 μs (0 allocations: 0 bytes)

因此,这比产生线程的成本高出两个数量级。这似乎是一个很好的起点:

function fib(n::Int)
    if n < 2
        return n
    elseif n > 23
        t = @spawn fib(n - 2)
        return fib(n - 1) + fetch(t)
    else
        return fib(n-1) + fib(n-2)
    end
end

现在,如果我使用BenchmarkTools.jl [2]遵循正确的基准测试方法,我会发现

julia> using BenchmarkTools

julia> @btime fib(43)
  971.842 ms (1496518 allocations: 33.64 MiB)
433494437

julia> @btime F(43)
  1.866 s (0 allocations: 0 bytes)
433494437

@Anush在评论中提出:这似乎是使用16核速度提高2的一个因素。是否有可能使速度接近16倍?

是的。上面函数的问题是函数主体大于F,具有很多条件,函数/线程生成等等。我请你比较@code_llvm F(10) @code_llvm fib(10)。这意味着fibjulia难以优化。这种额外的开销对于小n案件来说是与众不同的。

julia> @btime F(20);
  28.844 μs (0 allocations: 0 bytes)

julia> @btime fib(20);
  242.208 μs (20 allocations: 320 bytes)

不好了!所有从未被触及的额外代码n < 23使我们的速度降低了一个数量级!不过有一个简单的解决方法:何时n < 23,不要递归到fib,而是调用单线程F

function fib(n::Int)
    if n > 23
       t = @spawn fib(n - 2)
       return fib(n - 1) + fetch(t)
    else
       return F(n)
    end
end

julia> @btime fib(43)
  138.876 ms (185594 allocations: 13.64 MiB)
433494437

这使结果更接近我们对这么多线程的期望。

[1] https://www.geeksforgeeks.org/time-complexity-recursive-fibonacci-program/

[2] BenchmarkTools.jl中的BenchmarkTools @btime宏将多次运行函数,从而跳过了编译时间和平均结果。


1
看来使用16核的速度提高了2倍。是否有可能使速度接近16倍?
Anush

使用更大的基本情况。顺便说一句,这也是FFTW等多线程程序在后台运行的有效方式!
克里斯·拉卡卡斯

较大的基本情况无济于事。诀窍是fibjulia最难优化F,因此我们只使用F代替fibfor n< 23。我用更深入的解释和示例编辑了答案。
梅森

太奇怪了,实际上我使用博客文章示例获得了更好的结果……
tpdsantos

@tpdsantos Threads.nthreads()对您的输出是什么?我怀疑您可能只有一个线程运行了julia。
梅森

0

An

作为手动使用记忆和多线程的示例

_fib(::Val{1}, _,  _) = 1
_fib(::Val{2}, _, _) = 1

import Base.Threads.@spawn
_fib(x::Val{n}, d = zeros(Int, n), channel = Channel{Bool}(1)) where n = begin
  # lock the channel
  put!(channel, true)
  if d[n] != 0
    res = d[n]
    take!(channel)
  else
    take!(channel) # unlock channel so I can compute stuff
    #t = @spawn _fib(Val(n-2), d, channel)
    t1 =  _fib(Val(n-2), d, channel)
    t2 =  _fib(Val(n-1), d, channel)
    res = fetch(t1) + fetch(t2)

    put!(channel, true) # lock channel
    d[n] = res
    take!(channel) # unlock channel
  end
  return res
end

fib(n) = _fib(Val(n), zeros(Int, n), Channel{Bool}(1))


fib(1)
fib(2)
fib(3)
fib(4)
@time fib(43)


using BenchmarkTools
@benchmark fib(43)

但是速度的提高来自记忆化,而不是多线程。这里的教训是我们应该在多线程之前考虑更好的算法。


问题从来不在于快速计算斐波那契数。关键是“为什么多线程不能改善这种天真的实现?”。
梅森

对我来说,下一个逻辑问题是:如何使其快速。因此,也许有人阅读此书可以看到我的解决方案并从中学习。
xiaodai
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.