Julia高性能計算實踐記錄(一)

洋洋灑灑寫了5篇博客之後,嘗試把理論用於實際,遇到了一些問題。本文是對實戰中遇到問題的記錄和思考。爲了方便理解,把代碼做了簡化,原理不變。不定時更新,每次更新的日期和內容排在最前面,太長了會分爲多篇文章。

爲敘述簡便,把@everywhere廣播的對象簡稱爲”廣播對象“,例如:廣播變量、廣播函數等。讀者應該已經理解”共享“和”廣播“之間的區別。另外,@time第一次運行時會比較慢,要多運行幾遍。

Jun 24, 2019

今天測試後發現,如果在並行化的循環體中引用了結構體數組,耗時會增大兩個數量級。結論是並行程序裏儘量不要用結構體數組。

Jun 23, 2019

我想做這樣一件事:創建一個共享數組W,在一個函數f中並行修改W。代碼如下:

using Distributed
using SharedArrays

addprocs(4-nprocs())
println("Running ",nprocs()," processes")

t = 2. ; nx=1000; ny=1000
W = SharedArray{Float64}((nx,ny),init=A->(A=zeros(nx,ny)))


function f()
    @time @sync @distributed  for i=1:nx
        for j=1:ny
            W[i,j] = W[i,j] + t
        end
    end
end

f()

rmprocs(2,3,4)

逐行解釋這段代碼——

addprocs(4-nprocs())

默認nprocs()=1,所以是開啓3個遠程Worker。此時打開任務管理器,會看到:
在這裏插入圖片描述
第一個是常駐的終端,不參與運算。第二個是主進程,不論是否並行,都會在有計算任務時啓動。剩下三個是遠程Worker。

t = 2. ; nx=1000; ny=1000

聲明三個變量,第一個加了小數點,令Julia自動識別爲浮點數。

W = SharedArray{Float64}((nx,ny),init=A->(A=zeros(nx,ny)))

聲明一個共享數組並初始化爲零,默認存儲在主進程上,所以在後面的並行計算中,盯着任務管理器會看到主進程完成得比三個遠程Worker更快些。如圖:
同步

function f()
    @time @sync @distributed  for i=1:nx
        for j=1:ny
            W[i,j] = W[i,j] + t
        end
    end
end

這個函數有好幾點需要解釋:

  • f可以不帶參數,此時所有變量和數組都自動繼承到函數的局部域中。若帶有參數,則該參數會遵循正常的參數傳遞方式,其餘仍自動繼承。例如修改爲:
function f(nx)
   for i=1:nx
       for j=1:ny
           W[i,j] = W[i,j] + t
       end
   end
end

W1  =  f(nx)
W2  =  f(nx+1)

會看到W1正常運行,而W2報錯。

  • 回到原先的代碼中。
    @distributed已經介紹過了。Julia的多層for循環可以簡寫爲for i=1:m, j=1:n, k=1:p形式,但@distributed只能識別最外面一層,所以必須把最外層和裏面幾層拆開,寫成:
@distributed for i=1:m
   for j=1:n, k=1:p
   	   <Expr>
   end
end

由於Julia對數組是按列讀取的,也即,遍歷第一個指標i的速度顯著快於其他指標,所以最好把指標i放在最內側,並行時優先分割其他指標而保持指標i的完整性。修改爲:

function f()
    @time @sync @distributed  for j=1:ny
        for i=1:nx
            W[i,j] = W[i,j] + t
        end
    end
end

f()

原代碼消耗0.316919 seconds (185.12 k allocations: 9.060 MiB),修改後消耗0.262387 seconds (185.10 k allocations: 9.058 MiB),可見內存消耗幾乎一致,但速度更快了。

  • 在設計並行程序時,會很自然地想到是否要使用廣播變量。上述例子表明:位於@distributed結構的三個不同位置的變量nx, ny, t都不需要廣播,所以在@distributed裏不被多進程修改的變量不需要廣播。那麼假如要修改變量,應不應當廣播呢?我們來看下面的例子:
function f()
    @time @sync @distributed  for j=1:ny
        for i=1:nx
            @everywhere t += 1
            W[i,j] = W[i,j] + t
        end
    end
end

f()

假如去掉其中的@everywhere就會報錯,證明廣播是可行的。然而,你會發現計算時間大幅延長,因爲@everywhere是一個遠程調用命令,反覆執行它是很耗時間的。如果你把它挪到前面,像這樣:

@everywhere t = 2
function f()
    @time @sync @distributed  for j=1:ny
        for i=1:nx
            t += 1
            W[i,j] = W[i,j] + t
        end
    end
end

f()

系統會報錯。那麼到底怎樣纔是合理的方法?答案是把t改爲參數,像這樣:

function f(t)
    @time @sync @distributed  for j=1:ny
        for i=1:nx
            t += 1
            W[i,j] = W[i,j] + t
        end
    end
end

f(t)

會看到計算時間增加得很少。這種做法不需要廣播。但是!!!當你打印W時會發現計算結果變了。舉例來說,假如你希望一致地修改t後加到W上,以nx=3; ny=2的情況爲例,得到的W是:

julia> W
3×2 SharedArray{Float64,2}:
 3.0  3.0
 3.0  3.0
 3.0  3.0

如果用上面的傳參方法會得到:

julia> W
3×2 SharedArray{Float64,2}:
 3.0  3.0
 4.0  4.0
 5.0  5.0

表明t的修改沿着指標i的維度被疊加了。而用@everywhere t+=1方法會得到:

julia> W
3×2 SharedArray{Float64,2}:
 4.0  4.0
 6.0  6.0
 8.0  8.0

這就更誇張了,t的修改每次在i方向疊加時還在j方向上也疊加了一遍。問題出在哪兒呢?很明顯,是表達式的位置有問題,改成下面這樣:

function f2(t)
    @time @sync @distributed  for j=1:ny
        t+=1
        for i=1:nx
            W[i,j] = W[i,j] + t
        end
    end
end

獲得了正確的結果。結論是:@distributed結構的最外層循環是分割開的,互相獨立,變量修改不會疊加。但裏層循環依舊是按照一般程序的循環規則,會把修改進行疊加。使用@distributed時要記牢這一點。

  • @sync是爲了確保各進程全部完成任務後才繼續往下走(下一個語句是@time)。如果不加@sync,會看到@time迅速返回了一個結果0.012252 seconds (8.00 k allocations: 421.867 KiB),此時任務管理器裏各進程還在計算中,可見@time返回的是隻是發起遠程調用的消耗。把nxny設定得更大些會看得更明顯。如果在一部分進程尚未完成計算的情況下打印W,會看到它的一部分元素沒有改變。由於共享數組默認存儲在主進程上,一般主進程會率先完成,隨後其餘進程幾乎同時完成,如圖:
    非同步
rmprocs(2,3,4)

最後,必須像這樣關閉多餘的進程,否則再次執行代碼時系統會爲已有的進程增加新的內存,導致佔用內存增加一倍,多運行幾次就喫不消了。關閉遠程進程後如下圖:
關閉
但記得不要試圖關閉主進程,那樣系統會拒絕執行rmprocs()命令。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章