我正在嘗試了解 Julia 中的多執行緒行為,我注意到以下兩個代碼塊在 Julia v1.6.3 中的行為有所不同(我在某些 script.jl 中的 Atom 中運行 Julia):
acc = 0
Threads.@threads for i in 1:1000
global acc
println(Threads.threadid(), ",", acc)
acc = 1
end
acc
和
acc = 0
Threads.@threads for i in 1:1000
global acc
acc = 1
end
acc
注意唯一的區別是我在后面的例子中去掉了 "println(Threads.threadid(), ",", acc)" 。結果,每次運行第一個塊都會給我 1000,而第二個塊會給我一些 <1000 的數字(由于競爭條件)。
我對 Julia 的并行計算(或一般的并行計算)完全陌生,所以希望能解釋一下這里發生的事情以及為什么單個列印行會改變代碼塊的行為。
uj5u.com熱心網友回復:
您有多個執行緒同時改變狀態acc,最終會出現競爭條件。
但是,println與加法運算相比,它花費的時間相對較長,并且println會及時發生,因此對于小回圈,您很有可能觀察到“正確”的結果。但是,您的兩個回圈都不正確。
當許多執行緒改變完全相同的共享狀態時,您需要引入鎖定或使用原子變數。
- 對于快速、短的運行回圈,使用
SpinLock如下:
julia> acc = 0;
julia> u = Threads.SpinLock();
julia> Threads.@threads for i in 1:1000
global acc
Threads.lock(u) do
acc = 1
end
end
julia> acc
1000
- 第二種選擇
ReentrantLock通常更適合較長運行的回圈(切換時間比 a 長得多SpinLock),在回圈步驟中具有異構時間(它不像 那樣需要 CPU 時間“旋轉”SpinLock):
julia> acc = 0
0
julia> u = ReentrantLock();
julia> Threads.@threads for i in 1:1000
global acc
Threads.lock(u) do
acc = 1
end
end
julia> acc
1000
- 如果您正在改變原始值(如您的情況),原子操作將是最快的(請注意我如何從 an 獲取值
Atomic):
julia> acc2 = Threads.Atomic{Int}(0)
Base.Threads.Atomic{Int64}(0)
julia> Threads.@threads for i in 1:1000
global acc2
Threads.atomic_add!(acc2, 1)
end
julia> acc2[]
1000
uj5u.com熱心網友回復:
你可能知道這一點,但在現實生活中,所有這些都應該在一個函式中;如果您使用全域變數,那么您的性能將是災難性的,而對于一個函式,您只需使用單執行緒實作即可領先數英里。雖然“慢”編程語言的用戶通常會立即使用并行來提高性能,但使用 Julia 通常,您最好的方法是首先分析單執行緒實作的性能(使用分析器等工具)并修復您發現的任何問題。特別是對于 Julia 的新手來說,以這種方式使代碼速度提高十倍或一百倍的情況并不少見,在這種情況下,您可能會覺得這就是您所需要的。
事實上,有時單執行緒實作會更快,因為執行緒引入了它自己的開銷。我們可以在這里很容易地說明這一點。我將對上面的代碼做一個修改:不是在每次迭代時加 1,而是加i % 2,如果i是奇數則加1,如果是偶數則加 0 i。我這樣做是因為一旦你把它放在一個函式中,如果你所做的只是加 1,Julia 的編譯就足夠聰明,可以弄清楚你在做什么,并且只回傳答案而不實際運行回圈;我們想運行回圈,所以我們必須讓它更棘手一些,這樣編譯器就無法提前找出答案。
首先,讓我們嘗試上面最快的執行緒實作(我開始julia -t4使用Julia使用 4 個執行緒):
julia> acc2 = Threads.Atomic{Int}(0)
Base.Threads.Atomic{Int64}(0)
julia> @btime Threads.@threads for i in 1:1000
global acc2
Threads.atomic_add!(acc2, i % 2)
end
12.983 μs (21 allocations: 1.86 KiB)
julia> @btime Threads.@threads for i in 1:1000000
global acc2
Threads.atomic_add!(acc2, i % 2)
end
27.532 ms (22 allocations: 1.89 KiB)
這是快還是慢?讓我們先把它放在一個函式中,看看它是否有幫助:
julia> function lockadd(n)
acc = Threads.Atomic{Int}(0)
Threads.@threads for i = 1:n
Threads.atomic_add!(acc, i % 2)
end
return acc[]
end
lockadd (generic function with 1 method)
julia> @btime lockadd(1000)
9.737 μs (22 allocations: 1.88 KiB)
500
julia> @btime lockadd(1000000)
13.356 ms (22 allocations: 1.88 KiB)
500000
因此,通過將其放入函式中,我們獲得了 2 倍(在更大的作業上)。然而,更好的執行緒策略是無鎖執行緒:給每個執行緒自己的執行緒acc,然后accs在最后添加所有單獨的執行緒。
julia> function threadedadd(n)
accs = zeros(Int, Threads.nthreads())
Threads.@threads for i = 1:n
accs[Threads.threadid()] = i % 2
end
return sum(accs)
end
threadedadd (generic function with 1 method)
julia> using BenchmarkTools
julia> @btime threadedadd(1000)
2.967 μs (22 allocations: 1.97 KiB)
500
julia> @btime threadedadd(1000000)
56.852 μs (22 allocations: 1.97 KiB)
500000
對于更長的回圈,我們獲得了超過 200 倍的性能!這確實是一個非常好的加速。
但是,讓我們嘗試一個簡單的單執行緒實作:
julia> function addacc(n)
acc = 0
for i in 1:n
acc = i % 2
end
return acc
end
addacc (generic function with 1 method)
julia> @btime addacc(1000)
43.218 ns (0 allocations: 0 bytes)
500
julia> @btime addacc(1000000)
41.068 μs (0 allocations: 0 bytes)
500000
這比小型作業的執行緒實作快 70 倍,即使在較大的作業上也更快。為了完整起見,讓我們將其與使用全域狀態的相同代碼進行比較:
julia> @btime for i in 1:1000
global acc
acc = i % 2
end
20.158 μs (1000 allocations: 15.62 KiB)
julia> @btime for i in 1:1000000
global acc
acc = i % 2
end
20.455 ms (1000000 allocations: 15.26 MiB)
太可怕了。
There are, of course, cases where parallelism makes a difference, but it's typically for much more complicated tasks. You still shouldn't use it unless you've already optimized a single-threaded implementation.
So the two important morals of the story:
- read Julia's performance tips, analyze the performance of your code, and fix any bottlenecks
- reach for parallelism only after you've exhausted all single-threaded options.
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/325568.html
上一篇:[java篇]一口氣搞定例外處理
下一篇:這是升級標準庫鎖的有效方法嗎?
