[multithreading] Julia의 피보나치 시퀀스에서 멀티 스레드 병렬 처리 성능 문제 (1.3)

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 분 동안 줄리아 세션을 종료하기로 결정한 후)

내가 무엇을 잘못하고 있지?



답변

좋은 질문입니다.

피보나치 함수의이 멀티 스레드 구현은 단일 스레드 버전보다 빠르지 않습니다 . 이 기능은 블로그 게시물에서 새로운 스레딩 기능의 작동 방식에 대한 장난감 예제로만 보여졌으며 다른 기능으로 많은 수의 스레드를 생성 할 수 있으며 스케줄러가 최적의 작업 부하를 계산할 수 있음을 강조했습니다.

문제는 @spawn주변의 사소한 오버 헤드 가 있다는 것입니다 1µs. 따라서보다 적은 작업을 수행하기 위해 스레드를 생성하면 1µs성능이 저하 될 수 있습니다. 의 재귀 적 정의는 fib(n)순서의 지수 시간 복잡도를 가지 1.6180^n므로 [1]을 호출 fib(43)하면 주문 1.6180^43스레드가 생성 됩니다. 각각 1µs생성하는 데 필요한 스레드를 생성하고 예약하는 데 약 16 분이 소요되며 실제 계산을 수행하고 스레드를 다시 병합 / 동기화하는 데 걸리는 시간도 고려하지 않습니다. 더 많은 시간.

계산의 각 단계마다 스레드를 생성하는 것과 같은 것은 @spawn오버 헤드에 비해 계산의 각 단계가 오래 걸리는 경우에만 의미가 있습니다.

의 오버 헤드를 줄이는 작업이 @spawn있지만 멀티 코어 실리콘 칩의 물리학으로 인해 위의 fib구현에 충분히 빠르지 않을 것입니다.


스레드 fib함수를 실제로 유용하게 수정하는 방법에 대해 궁금한 경우 가장 쉬운 방법 fib1µ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)

스레드 생성 비용보다 2 배나 큰 크기입니다. 사용하기 좋은 컷오프처럼 보입니다.

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). 이것은 fib줄리아가 최적화하기가 훨씬 어렵다는 것을 의미합니다 . 이 여분의 오버 헤드는 작은 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매크로는 컴파일 시간과 평균 결과를 건너 뛰고 함수를 여러 번 실행합니다.


답변

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

그러나 속도는 memmiozation에서 나 왔으며 멀티 스레딩은 그리 많지 않았습니다. 여기서 교훈은 멀티 스레딩 전에 더 나은 알고리즘을 생각해야한다는 것입니다.


답변