並行編程實戰記錄----多線程與MPI多進程

         工作半年以來,大部分時間都在做RNN的研究,尤其是通過lstm(long-short term memory)構建識別模型。我專注的是使用rnnlib工具開展模型的訓練工作,以搭建有效的識別模型。Rnnlib(http://sourceforge.net/projects/rnnl/)由Alex Graves提供,是解決序列識別問題的RNN工具包,尤其是對隱含層提供了lstm的算法實現。在Alex Graves的博士論文中(Supervised Sequence Labelling with Recurrent Neural Networks),他提出了CTC(Connectionisttemporal classification)算法,克服了傳統識別算法中序列數據需要預先分割的缺陷。在rnnlib中,CTC也得到了實現。

         當識別問題的類別非常多時,隱含層到輸出層的連接邊數目大幅增加,這也使得反向更新權重時需要耗費更多的時間。實際上,這是一個計算密集型的任務。典型地,在我們的訓練中,訓練樣本爲十萬級別的規模。在雙CPU--Xeon(R) CPU E5(2.60GHz,8核16線程)、x64的服務器平臺下,一次迭代需要花費18h左右的時間。而這裏訓練一個模型需要至少100多次的迭代,因此這個訓練效率是難以忍受的。實際上,在rnnlib源代碼中,作者實現的是一個單線程版本。於是,我開始了漫長的並行編程實戰,通過將rnnlib並行化以提高計算的效率。在這裏,我將摸索的過程進行了總結。

         首先對模型訓練的過程進行一下簡單的描述。每次迭代都是一樣的過程,根據所有訓練樣本前向計算輸出、CTC計算誤差項、反向更新權重。在每次迭代中,可以採取一種近似於隨機梯度SGD的方式,也就是設置一個batchsize,每批batchsize個樣本更新一次模型,而不是對所有樣本計算一次更新。

 

1.     多線程

1.1  多線程框架

         在這裏,我們寫好了多線程的接口框架,使用了線程池的方式。主線程不停讀取任務並保存在線程池的任務列表中。線程池中的線程不斷從任務列表中讀出任務並執行。在讀出任務時,需要使用鎖實現互斥訪問,以保證安全性。此外,主線程存儲任務與其他線程讀出任務也需要通過鎖實現任務列表訪問的互斥性。當任務列表中的任務全部讀出後,通過條件變量使線程陷入等待,而新存儲的任務會通過notify信號量重新激活線程。

         當batchsize個樣本讀入完畢後,通過條件變量試主線程陷入等待而不繼續讀入任務。直至所有其他線程將手頭的任務執行完畢,通過判斷等待線程數與總線程數相等而激活主線程,完成一次同步操作。由主線程將本批計算得到的誤差derivative用於計算權重weights。然後繼續以上循環,執行下一批batchsize,直至所有訓練樣本執行完畢。

以上多線程接口可移植性比較好,直接將任務替換爲rnnlib中相應的計算函數(包括前向計算輸出、CTC計算誤差項)即可。在我們的服務器平臺下,雙CPU共包含16核32線程,所以將多線程程序中的線程數設爲32。通過實驗發現,此時一次迭代所需時間下降到約8h4m。顯然,運行時間不會以32倍提高,因爲線程調度也需要花費時間。

1.2  進一步優化:分塊讀取樣本數據

         通過閱讀rnnlib源代碼,發現DataList在處理sequence時會以NetcdfDataset的結構一次性讀入整個nc文件。如果nc文件比較大,那麼一次性讀入將會佔用較大內存空間,可能影響CPU的運行速度。而nc文件中的訓練樣本是按順序依次處理的,因此靠後的訓練樣本只會空佔內存。所以,我們將nc文件進行了分塊,將訓練樣本按序平均分配到子nc文件中。在運行時,保證同一時刻內存中只有兩個子nc文件,一個是當前正處理的子nc文件,另一個是下一個將要處理的子nc文件。當前子nc文件處理完畢後,直接開始處理下一個子nc文件,同時釋放前一個子nc所佔用的內存,並準備好下一個將要處理的子nc文件,將其讀入內存。具體來說,通過兩個NetcdfDataset指針即可實現。

         這樣,佔用內存的訓練樣本大大減少,而比較靠後的訓練樣本則停留在磁盤空間中,在需要時調入內存即可。爲了節省時間,可以專門開一個線程用於讀取下一個子nc文件,使得當前子nc文件的處理與下一子nc文件的讀取同時進行。但是要通過條件變量做好邏輯控制,保證在處理下一子nc文件之前,該子nc文件讀入內存已完畢。通過這些優化,運行效率又一次得到了提高。通過實驗發現,當將訓練樣本分成60個部分時,一次迭代所需時間下降最多,耗時約6h50m。

2.     MPI多進程

         以上單次迭代所需時間仍然較多。實際上以上分析已經發現,在一個batchsize中,所有sequence是完全可以並行的。所以,我們想到通過多機並行進一步提高運行效率。在不同機器中,程序分屬於不同進程,它們並不能共享內存,我們有必要先了解一下線程與進程的概念與區別。

2.1  線程與進程的區別

         進程是一個具有獨立功能的程序關於某個數據集合的一次運行活動,是系統進行資源分配和調度的一個獨立單位。進程是一個動態的概念,是一個活動的實體。它不只是程序的代碼,還包括當前的活動,通過程序計數器的值和處理寄存器的內容來表示。進程是一個“執行中的程序”。程序是一個沒有生命的實體,只有處理器賦予程序生命時,它才能成爲一個活動的實體,我們稱其爲進程。

         通常在一個進程中可以包含若干個線程,它們可以利用進程所擁有的資源。在引入線程的操作系統中,通常都是把進程作爲分配資源的基本單位,而把線程作爲獨立運行和獨立調度的基本單位。由於線程比進程更小,基本上不擁有系統資源,故對它的調度所付出的開銷就會小得多,能更高效的提高系統內多個程序間併發執行的程度。

         進程和線程的區別歸納:1.地址空間和其它資源:進程間相互獨立,同一進程的各線程間共享。某進程內的線程在其它進程不可見;2.通信:進程間通信IPC,線程間可以直接讀寫進程數據段(如全局變量)來進行通信(需要進程同步和互斥手段的輔助,以保證數據的一致性);3.調度和切換:線程上下文切換比進程上下文切換要快得多。

         對於單核機器,通過多線程可以減少I/O等阻塞時間,有利於人機交互。此時的多線程叫做“併發”,通過單核分時間輪轉調度來實現。看似並行,實則單核一次仍然只處理一個線程,只是切換快速難以察覺而已。而對於計算密集型任務,單個任務已經將單核接近佔滿,因此多線程難以提高效率。

         在多核機器上,Linux2.6和WindowsNT4.0以上已經可以將不同線程交給不同的核心去處理,因此多線程可以提高計算密集型任務的處理效率。並行計算可以看成是多進程,每個進程交給一個核(或者超線程技術下的虛擬內核)去執行,這種方式是真正意義上的並行。需要注意的是,描述CPU性能中的幾核幾線程與多線程編程中的線程是兩個不同的概念,前者指的是虛擬內核。

2.2  MPI協議及MPICH框架

         在這裏,我們選擇使用MPI來實現多機並行。MPI(MessagePassing Interface)是一個跨語言的通訊協議,用於編寫並行計算機,支持點對點和廣播。MPI只是一個協議,有不同的實現版本。這裏採用MPICH(http://www.mpich.org/)進行多機並行的編程,比較有用的參考博客文章有http://blog.csdn.net/morewindows/article/details/6823436。MPI並行環境配置的參考博客文章http://blog.sina.com.cn/s/blog_7f40d1c10101f18x.html。要注意安裝過程中的passphrase在各臺server上必須設置爲一樣的口令,否則將連接不上。

         在MPICH執行前,將程序的可執行文件及所需的數據文件部署到每臺服務器上。在主機上通過cmd運行mpiexec.exe –hosts n serverip_1 procnum_1 serverip_2 procnum_2 …serverip_n procnum_n –noprompt yourprogram.exe開始執行。MPICH會根據-hosts的設定爲每個進程分配rank_id,每個進程會根據自己的rank_id去執行程序中的不同部分。通過MPI中的通信函數可以實現進程之間的數據傳輸。

         我們並行框架如下圖所示:

 

         我們把訓練程序和訓練樣本提前部署到服務器上,每個進程處理訓練樣本中的一部分。每一個batchsize平均分配給所有的進程部處理,待所有進程均處理完畢之後,由主進程收集誤差數據並用於更新權重。更新後的權重廣播到所有進程,再開始下一個batchsize。注意,這裏有一個同步操作,即等待所有進程處理完畢,才能由主進程開始權重更新。這個同步操作是很好理解的,但也不可缺少。部署好之後,我們用兩臺服務器進行了實驗。這裏我們的服務器有32個線程(包括物理的和虛擬的,注意與多線程編程中的線程不是一個概念),所以將兩臺服務器的線程數都設爲32。需要注意的是,在第一臺服務器上只有31個線程是用來計算sequence的,因爲要犧牲一個進程作爲主進程。這樣,一次迭代所需的時間約爲6h。

2.3  進一步優化:樣本數據預排序

         可以發現,多機並行實驗結果不夠理想,並不明顯優於單機的多線程。這是爲什麼呢?通過打印調試,我們發現前述同步操作花費了較多的時間。即便我們將進程間的通信不斷進行優化,實驗結果也沒有提升。所以,我們基本可以確定效率被同步操作耽誤了。

         仔細思考,不難發現樣本越長(時間點越多),前向計算輸出和CTC計算前向變量、後向變量都需要更多的時間。而同步操作所需時間是由耗時最長的那一個進程決定的。極端情況下,每次batchsize時,都有一個進程處理較長的樣本,哪怕是其他的樣本都很短。因此處理短樣本的進程在處理完之後將陷入等待,而每次batchsize的耗時都會很長。

         而我們這裏採用的優化辦法就是對樣本進行預處理,使得每次batchsize中的樣本的長度都大致相當,這樣就可以減少等待時間。實現的方式是根據timestep長度對樣本進行預排序,然後按序逐一分配給所有進程。在實際操作時,比如兩臺服務器共有32*2-1=63個從進程,那麼我們將預排序後的訓練樣本逐一分配給63個section,然後利用這63個section分別製作nc文件,然後提前部署到服務器上,由進程各自處理屬於自己的那一個nc文件。可以發現,經過這樣的處理之後,63個nc文件的大小幾乎沒有差異。

         可惜的是,經過這樣的優化處理之後,多機並行的效率並沒有多大改觀,反而下降到7h7m。但是,通過觀察打印輸出,我們發現前800個batchsize都執行的非常快,基本上都在20s以內,但最後的200多個batchsize由於都是大樣本,一個需要一兩分鐘,嚴重拖慢了效率。也就是說,一半以上的時間都用來處理最後這20%的樣本了。經過思考,我們決定對每個section內部的樣本序列進行隨機打亂,以避免某些batchsize過大。實驗證明我們的嘗試是正確的,一次迭代所需時間下降到4h40m左右。需要說明的是,雖然最後又將各個section內部的樣本順序打亂,但最開始的完全排序是有必要的,它可以使各個section的樣本規模大致相當。因此,我們最後的樣本預處理策略是:先對所有的樣本進行升序排序,然後逐一分配到各個section,然後在各個section內分別打亂順序,然後製作nc文件,用於MPI多進程處理。最後,有理由相信,通過更多服務器的多機並行,運行效率會得到進一步的提升。

3.     總結

         通過這一個多月的學習與摸索,我對多線程和多機並行有了更深的理解。通過實戰,我發現多機並行編程是一個很有意思的事情,與算法研究是兩個不同類型的事情,需要考慮的實際情況更復雜,有助於加深對計算機體系的認識。而這其中碰到問題分析問題解決問題的過程讓我受益匪淺。MPI只是一個比較底層的並行協議,後續,我將繼續學習hadoop和spark。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章