Задача про виконання багатопотокової паралелізму з послідовністю Фібоначчі в Джулії (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

Однак при запуску наступного коду скопійовано зі сторінки Джулія про багатопотоковість

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)

що трапляється в тому, що використання оперативної пам’яті / процесора підскакує з 3,2 ГБ / 6% до 15 ГБ / 25% без будь-якого виходу (принаймні 1 хвилину, після чого я вирішив убити сесію Julia)

Що я роблю неправильно?

Відповіді:


19

Чудове запитання.

Ця багатопотокова реалізація функції Фібоначчі не швидша за однопоточну версію. Ця функція була показана лише в дописі в блозі як іграшковий приклад того, як працюють нові можливості нитки, підкреслюючи, що вона дозволяє нереститися безліччю ниток різних функцій, а планувальник визначить оптимальне навантаження.

Проблема полягає в тому, що @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 запитує в коментарях: Це коефіцієнт у 2 швидкості, використовуючи 16 ядер, здається. Чи можна наблизитись до коефіцієнта 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-fibach-program/

[2] @btimeМакрос BenchmarkTools від BenchmarkTools.jl буде виконувати функції кілька разів, пропускаючи час компіляції та середні результати.


1
Це коефіцієнт у 2 швидкості, використовуючи 16 ядер, здається. Чи можна наблизитись до коефіцієнта 16 швидкості?
Ануш

Використовуйте більший базовий корпус. До речі, ось так ефективно багатопотокові програми, такі як FFTW, працюють і під кришкою!
Кріс Раккаукас

Більший базовий випадок не допомагає. Хитрість полягає в тому, що fibце важче Джулію оптимізують ніж F, тому ми просто використовувати Fзамість fibдля n< 23. Я відредагував свою відповідь більш невловимим поясненням та прикладом.
Мейсон

Це дивно, я фактично отримав кращі результати, використовуючи приклад повідомлення в блозі ...
tpdsantos

@tpdsantos Що Threads.nthreads()для вас є результатом? Я підозрюю, що у вас може бути Джулія, яка працює лише однією ниткою.
Мейсон

0

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

Але пришвидшення прийшло від меммонізації та не стільки багатопоточності. Урок полягає в тому, що нам слід продумати алгоритми, перш ніж багатопотокові.


Питання ніколи не було в тому, щоб швидко обчислити числа Фібоначчі. Суть полягала в тому, «чому багатопоточна редакція не покращує цю наївну реалізацію?».
Мейсон

Для мене наступне логічне питання: як зробити це швидко. Тож хтось, читаючи це, може побачити моє рішення і навчитися на ньому, можливо.
xiaodai
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.