【问题标题】:Multi-threaded parallelism performance problem with Fibonacci sequence in Julia (1.3)Julia (1.3) 中斐波那契数列的多线程并行性能问题
【发布时间】:2020-03-23 11:42:43
【问题描述】:

我正在使用以下硬件尝试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 page about multithreading复制的以下代码时

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 会话)

我做错了什么?

【问题讨论】:

    标签: multithreading julia fibonacci


    【解决方案1】:

    好问题。

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

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

    只有当计算的每个步骤与@spawn 开销相比,计算的每个步骤都需要很长时间时,才会为计算的每个步骤生成一个线程这样的事情才有意义。

    请注意,在减少 @spawn 的开销方面还有一些工作要做,但从多核硅芯片的物理特性来看,我怀疑它对于上述 fib 的实现是否足够快。


    如果您对我们如何修改线程化的fib 函数感到好奇,那么最简单的做法是只生成一个fib 线程,如果我们认为它需要比@987654335 长得多的时间@ 跑步。在我的机器上(在 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 在 cmets 中提问:这似乎是使用 16 核加速的 2 倍。是否有可能获得接近 16 倍的加速?

    是的。上述函数的问题在于,函数体比F 大,有很多条件、函数/线程产生等等。我邀请你比较@code_llvm F(10)@code_llvm fib(10)。这意味着 fib 对 julia 来说更难优化。这种额外的开销对于 n 的小案例来说是天壤之别。

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

    哦不!对于n &lt; 23,所有那些永远不会被触及的额外代码正在让我们慢一个数量级!不过有一个简单的解决方法:当n &lt; 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 宏将多次运行函数,跳过编译时间和平均结果。

    【讨论】:

    • 这似乎是使用 16 核加速的 2 倍。是否有可能获得接近 16 倍的加速?
    • 使用更大的基本情况。顺便说一句,这就是像 FFTW 这样的多线程程序在后台工作的效率!
    • 更大的基本情况没有帮助。诀窍是 fibF 更难对 julia 进行优化,所以我们只使用 F 而不是 fib 来代替 n&lt; 23。我用更深入的解释和示例编辑了我的答案。
    • 这很奇怪,我实际上使用博客文章示例得到了更好的结果......
    • @tpdsantos Threads.nthreads() 的输出对你来说是什么?我怀疑你可能让 julia 只运行一个线程。
    【解决方案2】:

    @Anush

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

    _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)
    
    

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

    【讨论】:

    • 问题从来不在于快速计算斐波那契数。重点是“为什么多线程不能改进这种幼稚的实现?”。
    • 对我来说,下一个合乎逻辑的问题是:如何让它变得更快。因此,阅读本文的人可能会看到我的解决方案并从中学习。
    猜你喜欢
    • 1970-01-01
    • 2020-03-09
    • 2010-11-24
    • 2017-03-06
    • 1970-01-01
    • 2015-06-05
    • 1970-01-01
    相关资源
    最近更新 更多