Masalah kinerja paralelisme multi-utas dengan urutan Fibonacci di Julia (1.3)

14

Saya mencoba fungsi multithread Julia 1.3dengan Perangkat Keras berikut:

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

Saat menjalankan skrip berikut:

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

itu memberi saya output berikut

2.229305 seconds (2.00 k allocations: 103.924 KiB)
433494437

Namun ketika menjalankan kode berikut disalin dari halaman Julia tentang 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)

apa yang terjadi adalah bahwa pemanfaatan RAM / CPU melonjak dari 3,2GB / 6% menjadi 15GB / 25% tanpa output (setidaknya 1 menit, setelah itu saya memutuskan untuk mematikan sesi julia)

Apa yang saya lakukan salah?

ECJB
sumber

Jawaban:

19

Pertanyaan yang bagus

Implementasi multitreaded dari fungsi Fibonacci ini adalah tidak lebih cepat dari versi single threaded. Fungsi itu hanya ditampilkan dalam posting blog sebagai contoh mainan tentang bagaimana kemampuan threading baru bekerja, menyoroti bahwa itu memungkinkan untuk memunculkan banyak banyak utas dalam fungsi yang berbeda dan penjadwal akan mencari tahu beban kerja yang optimal.

Masalahnya adalah bahwa @spawnada overhead non-sepele di sekitar 1µs, jadi jika Anda menelurkan utas untuk melakukan tugas yang membutuhkan waktu kurang dari 1µs, Anda mungkin akan merusak kinerja Anda. Definisi rekursif dari fib(n)kompleksitas waktu eksponensial 1.6180^n[1], jadi ketika Anda menelepon fib(43), Anda menelurkan sesuatu dari pesanan1.6180^43 utas . Jika masing-masing diperlukan 1µsuntuk menelurkan, itu akan memakan waktu sekitar 16 menit hanya untuk menelurkan dan menjadwalkan utas yang diperlukan, dan itu bahkan tidak memperhitungkan waktu yang diperlukan untuk melakukan perhitungan aktual dan menggabungkan kembali / menyinkronkan utas yang membutuhkan waktu genap lebih banyak waktu.

Hal-hal seperti ini di mana Anda menelurkan utas untuk setiap langkah perhitungan hanya masuk akal jika setiap langkah perhitungan membutuhkan waktu lama dibandingkan dengan @spawn overhead.

Perhatikan bahwa ada pekerjaan yang dilakukan untuk mengurangi overhead @spawn, tetapi oleh fisika chip silikon multicore, saya ragu itu bisa cukup cepat untuk fibimplementasi di atas .


Jika Anda penasaran tentang bagaimana kami dapat memodifikasi fibfungsi berulir agar benar-benar bermanfaat, hal yang paling mudah untuk dilakukan adalah dengan menelurkan sebuah fibutas jika kami pikir itu akan memakan waktu lebih lama daripada 1µsmenjalankannya. Di mesin saya (berjalan pada 16 core fisik), saya mengerti

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)

jadi itu adalah dua urutan besar yang baik atas biaya pemijahan utas. Itu sepertinya cutoff yang bagus untuk digunakan:

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

sekarang, jika saya mengikuti metodologi benchmark yang tepat dengan BenchmarkTools.jl [2] saya temukan

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 bertanya dalam komentar: Ini adalah faktor 2 percepatan menggunakan 16 core sepertinya. Apakah mungkin untuk mendapatkan sesuatu yang lebih dekat dengan faktor kecepatan 16?

Ya itu. Masalah dengan fungsi di atas adalah bahwa fungsi tubuh lebih besar dari pada F, dengan banyak persyaratan, fungsi / pemijahan benang dan semua itu. Saya mengundang Anda untuk membandingkan @code_llvm F(10) @code_llvm fib(10). Ini berarti bahwa fibjauh lebih sulit untuk mengoptimalkan julia. Overhead tambahan ini membuat perbedaan besar untuk ncase kecil .

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

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

Oh tidak! semua kode tambahan yang tidak pernah disentuh n < 23adalah memperlambat kita dengan urutan besarnya! Namun ada perbaikan yang mudah: kapan n < 23, jangan berulang fib, panggil single yang di-threaded 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

yang memberikan hasil lebih dekat dengan apa yang kita harapkan untuk begitu banyak utas.

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

[2] @btimeMakro BenchmarkTools dari BenchmarkTools.jl akan menjalankan fungsi beberapa kali, melewatkan waktu kompilasi dan hasil rata-rata.

Tukang batu
sumber
1
Ini adalah faktor 2 percepatan menggunakan 16 core tampaknya. Apakah mungkin untuk mendapatkan sesuatu yang lebih dekat dengan faktor kecepatan 16?
Anush
Gunakan kasing yang lebih besar. BTW, ini adalah seberapa efektif program multithread seperti FFTW bekerja di bawah tenda juga!
Chris Rackauckas
Kasing yang lebih besar tidak membantu. Kuncinya adalah bahwa fiblebih sulit untuk mengoptimalkan julia daripada F, jadi kami hanya menggunakan Fbukan fibuntuk n< 23. Saya mengedit jawaban saya dengan penjelasan dan contoh yang lebih mendalam.
Mason
Aneh, saya benar-benar mendapatkan hasil yang lebih baik menggunakan contoh posting blog ...
tpdsantos
@ tpdsantos Apa hasil Threads.nthreads()untuk Anda? Saya menduga Anda mungkin memiliki julia berjalan hanya dengan satu utas.
Mason
0

@Anush

Sebagai contoh menggunakan memoisasi dan multithreading secara manual

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

Tetapi percepatan datang dari memmiozation dan tidak banyak multithreading. Pelajaran di sini adalah kita harus memikirkan algoritma yang lebih baik sebelum multithreading.

Xiaodai
sumber
Pertanyaannya bukan tentang menghitung angka Fibonacci dengan cepat. Intinya adalah 'mengapa multithreading tidak meningkatkan implementasi naif ini?'.
Mason
Bagi saya, pertanyaan logis berikutnya adalah: bagaimana membuatnya cepat. Jadi seseorang yang membaca ini dapat melihat solusi saya dan belajar darinya, mungkin.
xiaodai