Проблема производительности многопоточного параллелизма с последовательностью Фибоначчи в Юлии (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)

происходит то, что загрузка ОЗУ / ЦП переходит с 3,2 ГБ / 6% до 15 ГБ / 25% без вывода данных (по крайней мере, в течение 1 минуты, после чего я решил прекратить сеанс julia)

Что я делаю неправильно?

ecjb
источник

Ответы:

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-fibonacci-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
источник
Речь никогда не шла о быстром вычислении чисел Фибоначчи. Дело было в том, «почему многопоточность не улучшает эту наивную реализацию?».
Мейсон
Для меня следующий логичный вопрос: как сделать это быстро. Так что кто-то, читающий это, может увидеть мое решение и извлечь из него урок, возможно.
xiaodai