並行化實現基於BP神經網絡的手寫體數字識別

並行化實現基於BP神經網絡的手寫體數字識別

手寫體數字識別可以堪稱是神經網絡學習的“Hello World” ,我今天要說的是如何實現BP神經網絡算法的並行化,我們仍然是以手寫體數字識別爲例,會給出實現原理與不同參數的實例分析。
並行的實現是基於MPICHOpenMP兩種,運行環境是Linux

環境搭建

MPI

MPI的環境搭建說簡單也簡單,說難也難,CSDN上有各種教程,大部分都是對的,我只提一下我在搭建環境的時候遇到的問題以及要注意的地方。
1.如果只在一臺機器上跑代碼的話,就相對簡單一些,只需要下載壓縮包,解壓,編譯安裝就好了,這裏要注意的是安裝的時候,MPI依賴於gcc、g++、Fortran等編譯工具,我們大多數人的機器上應該只有C/C++的編譯工具,如果我們不打算在Fortran上使用MPI的話,可以選擇在安裝的時候禁用掉Fortran,好像是在安裝命令的末尾加上–disable Fortran就可以了。
2.如果是想搭集羣,首先要把MPI安裝在相同的目錄例如:/usr/local/mpich,然後還要注意要使用相同的用戶名進行ssh免密登錄的配置。這些東西都有很完整的教程,我在這裏就不進行詳細說了。

OpenMP

如果你的編譯鏈工具版本夠新的話,編譯OpenMP只需要加上一句-fopenmp即可

BP神經網絡算法基礎

1. 算法框架


BP神經網絡的過程主要分爲兩個階段,第一階段是信號的前向傳播,從輸入層經過隱含層,最後到達輸出層;第二階段是誤差的反向傳播,從輸出層到隱含層,最後到輸入層,依次調節隱含層到輸出層的權重和偏置,輸入層到隱含層的權重和偏置。

2.樣本訓練

正向傳播

對每一層遍歷每一個神經細胞,做如下操作:

  1. 獲取第n個神經細胞的輸入權重數組
  2. 遍歷輸入權重數組每一個輸入權重,累加該權重和相應輸入的乘積
  3. 將累加後的值通過激活函數,得到當前神經細胞的最終輸出
  4. 該輸出作爲下一層的輸入,對下一層重複上述操作,直到輸出層輸出爲止
    激活函數: sigmoid(S型)函數

反向訓練

1.首先輸入期望輸出,同輸出層的輸出進行計算得到輸出誤差數組
2.然後對包括輸出層的每一層: 遍歷當前層的神經細胞,得到該神經細胞的輸出,同時利用反向傳播激活函數計算反向傳播回來的誤差, 進行調整權重矩陣。
激活函數: sigmoid(S型)函數的導函數

算法參考博客:
https://blog.csdn.net/xuanwolanxue/article/details/71565934
https://blog.csdn.net/qq_41645895/article/details/85265148
https://blog.csdn.net/u014303046/article/details/7820001

3. 數據處理

1. 訓練數據集

簡介

樣本來源於美國提供的MNIST數據集一共包含7萬個樣本

數據集包含四個二進制文件:
train-images-idx3-ubyte: training set images #訓練集圖片
train-labels-idx1-ubyte: training set labels #訓練集標籤
t10k-images-idx3-ubyte: test set images #測試集圖片
t10k-labels-idx1-ubyte: test set labels #測試集標籤
訓練集有60000個訓練樣本,測試集有10000個樣本

文件的格式如下
TRAINING SET IMAGE FILE (train-images-idx3-ubyte):
[offset] [type] [value] [description]
0000 32 bit integer 0x00000803(2051) magic number #文件頭魔數
0004 32 bit integer 60000 number of images #圖像個數
0008 32 bit integer 28 number of rows #圖像寬度
0012 32 bit integer 28 number of columns #圖像高度
0016 unsigned byte ?? pixel #圖像像素值
0017 unsigned byte ?? pixel
………
xxxx unsigned byte ?? pixel
數據來源:http://yann.lecun.com/exdb/mnist/

讀取數據

數據集的讀取單獨設置一個類,每次讀數據傳進一個index參數,index表示要讀取文件中第幾張圖片,這就需要用到C++文件流的seekg()函數來索引到正確的位置,seekg()函數有兩種形式的重載,我們採用的單參數重載,參數是相對於文件初始的偏移量。
偏移量 = 文件頭大小 + (index - 1) * 圖片大小

bool dataLoader::readIndex(int* label, int pos) {
	if (mLabelFile.is_open() && !mLabelFile.eof()) {
		mLabelFile.seekg(mLableStartPos + pos*mLabelLen);
		mLabelFile.read((char*)label, mLabelLen);
		return mLabelFile.gcount() == mLabelLen;
	}
	return false;
}

bool dataLoader::readImage(char imageBuf[], int pos) {
	if (mImageFile.is_open() && !mImageFile.eof()) {
		mImageFile.seekg(mImageStartPos + pos*mImageLen);
		mImageFile.read(imageBuf, mImageLen);

		return mImageFile.gcount() == mImageLen;
	}
	return false;
}
//label 用於保存標籤 imagebuf保存圖片像素 pos表示需要讀取數據集中第幾張圖片
bool dataLoader::read(int* label, char imageBuf[], int pos) {
	if (readIndex(label, pos)) {
		return readImage(imageBuf, pos);
	}

	return false;
}

2. 算法相關參數聲明

樣本參數:
每個圖片樣本從二進制文件中讀取的格式是unsigned char [28 * 28] (圖片大小爲28 * 28)
然後對樣本的像素矩陣進行歸一化處理,轉成double數組:像素值大於128置1否則置0

inline void preProcessInputData(const unsigned char src[], double out[], int size) {
    for (int i = 0; i < size; i++) {
        out[i] = (src[i] >= 128) ? 1.0 : 0.0;
    }
}

權重參數:
神經網絡的每一層都有一個二維的權重數組,在隱藏層每一個神經細胞都會有28*28個輸入,每個細胞對應一個輸出,所以輸出層的每個神經細胞對應有隱藏層細胞總數個的輸入。權重的初始化是由隨機數生成。

// 隨機整數數[x, y]
inline int RandInt(int x, int y)
{ 
	return rand() % (y - x + 1) + x; 
}

// 隨機浮點數(0, 1)
inline double RandFloat()
{ 
	return (rand()) / (RAND_MAX + 1.0); 
}

// 隨機布爾值
inline bool RandBool()
{
	return RandInt(0, 1) ? true : false;
}

// 隨機浮點數(-1, 1)
inline double RandomClamped()
{ 
	return rand() % 1000 * 0.001 - 0.5;
}


// 高斯分佈
inline double RandGauss()
{
	static int	  iset = 0;
	static double gset = 0;
	double fac = 0, rsq = 0, v1 = 0, v2 = 0;

	if (iset == 0)
	{
		do
		{
			v1 = 2.0*RandFloat() - 1.0;
			v2 = 2.0*RandFloat() - 1.0;
			rsq = v1*v1 + v2*v2;
		} while (rsq >= 1.0 || rsq == 0.0);

		fac = sqrt(-2.0*log(rsq) / rsq);
		gset = v1*fac;
		iset = 1;
		return v2*fac;
	}
	else
	{
		iset = 0;
		return gset;
	}
}

4. 並行機制

MPI–數據並行

數據並行的方法適用於MPI,假設默認隱藏層的神經細胞數量爲100,那麼權重矩陣的大小爲28 * 28 *100,這個大小並不是很適用於MPI進行矩陣運算並行,在這種小計算量的地方採用並行,有很大機率會由於過大的通信開銷導致程序運行變慢,所以我們採用訓練樣本並行的方法。
採用訓練樣本並行需要慎重的考慮執行的進程數和粒度大小的設置,因爲權重數組的更新是依賴於之前訓練過的樣本的,所以採用樣本並行可能會導致識別率的降低。
1.基於模型的配置隨機初始化網絡模型參數
2.將當前這組參數分發到各個工作節點
3.在每個工作節點,用數據集的一部分數據進行訓練
4.將各個工作節點的參數的均值作爲全局參數值
5.若還有訓練數據沒有參與訓練,則繼續從第二步開始
在這裏插入圖片描述
因此MPI的數據並行就是一個不斷分傳樣本->分進程計算權值->回傳權值->主進程計算新權值->所有進程統一權值的過程。

double trainEpoch(dataLoader& src, NetWork& bpnn, int imageSize, int numImages) {
   //for mpi
   
   int task_count = 0;
   int rank = 0;
   int tag = 0;
   MPI_Status status;
   //for train
   double net_target[NUM_NET_OUT];
   char* temp = new char[imageSize];
   double* net_train = new double[imageSize];
   
   //get mpi message
   MPI_Comm_size(MPI_COMM_WORLD, &task_count);  //get num of ranks
   MPI_Comm_rank(MPI_COMM_WORLD, &rank);        //get current rank number
   --task_count;                                //the num of ranks used for training
   double comun_time = 0.0;
   for (int i = 0; i < numImages;) {
   	
       int row1 = bpnn.mNeuronLayers[0]->mNumNeurons;
       int row2 = bpnn.mNeuronLayers[1]->mNumNeurons;
       int col1 = bpnn.mNeuronLayers[0]->mNumInputsPerNeuron + 1;
       int col2 = bpnn.mNeuronLayers[1]->mNumInputsPerNeuron + 1;
       double weights1[row1][col1];
       double weights2[row2][col2];

       double new_weights1[row1][col1];
       double new_weights2[row2][col2];
       
       if(rank != 0){
           int sample_num = 0;
           if(i + task_count * SIZE > numImages){
               sample_num = (numImages - i) / task_count;
               if(rank <= ((numImages - i) % task_count))
                   sample_num++;
           }
           else{
               sample_num = SIZE;
           }
           for(int loop = 0; loop < sample_num; loop++){
               int label = 0;
               memset(net_target, 0, NUM_NET_OUT * sizeof(double));
               if (src.read(&label, temp, i + ((rank-1) * sample_num) + loop)) {
                   net_target[label] = 1.0;
                   preProcessInputData((unsigned char*)temp, net_train, imageSize);
                   bpnn.training(net_train, net_target);
               }
               else {
                   cout << "讀取訓練數據失敗" << endl;
                   break;
               }
           }
       }
       if(rank != 0){
           for(int loop = 0; loop < row1; loop++){
               for(int loop1 = 0; loop1 < col1; loop1++)
                   weights1[loop][loop1] = bpnn.mNeuronLayers[0]->mWeights[loop][loop1];
           }
           for(int loop = 0; loop < row2; loop++){
               for(int loop1 = 0; loop1 < col2; loop1++)
                   weights2[loop][loop1] = bpnn.mNeuronLayers[1]->mWeights[loop][loop1];
           }
           for(int loop = 0; loop < row1; loop++){
               MPI_Send(weights1[loop], col1, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD);    
           }
           MPI_Send(weights2, row2*col2, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD);
       }
           
       MPI_Barrier(MPI_COMM_WORLD);
           
       if(rank == 0){//father rank
       	double cur_time = MPI_Wtime();
           for(int loop = 0; loop < row1; loop++){
               for(int loop1 = 0; loop1 < col1; loop1++)
                   new_weights1[loop][loop1] = 0;
           }
           for(int loop = 0; loop < row2; loop++){
               for(int loop1 = 0; loop1 < col2; loop1++)
                   new_weights2[loop][loop1] = 0;
           }

           for(int j = 1; j <= task_count; j++){//recv and calculate the new weights
               for(int loop = 0; loop < row1; loop++)
                   MPI_Recv(weights1[loop], row1*col1, MPI_DOUBLE, j, tag, MPI_COMM_WORLD, &status);
               MPI_Recv(weights2, row2*col2, MPI_DOUBLE, j, tag, MPI_COMM_WORLD, &status);
               for(int loop = 0; loop < row1; loop++){
                   for(int loop1 = 0; loop1 < col1; loop1++)
                       new_weights1[loop][loop1] += weights1[loop][loop1];
               }
               for(int loop = 0; loop < row2; loop++){
                   for(int loop1 = 0; loop1 < col2; loop1++)
                       new_weights2[loop][loop1] += weights2[loop][loop1];
               }
           }

           for(int loop = 0; loop < row1; loop++){
               for(int loop1 = 0; loop1 < col1; loop1++)
                   new_weights1[loop][loop1] /= task_count;
           }
           for(int loop = 0; loop < row2; loop++){
               for(int loop1 = 0; loop1 < col2; loop1++)
                   new_weights2[loop][loop1] /= task_count;
           }
       	
           for(int j = 1; j <= task_count; j++){
               for(int loop = 0; loop < row1; loop++)
                   MPI_Send(new_weights1[loop], col1, MPI_DOUBLE, j, tag, MPI_COMM_WORLD);
               MPI_Send(new_weights2, row2*col2, MPI_DOUBLE, j, tag, MPI_COMM_WORLD);
           }
           for(int loop = 0; loop < row1; loop++){
               for(int loop1 = 0; loop1 < col1; loop1++)
                   bpnn.mNeuronLayers[0]->mWeights[loop][loop1] = new_weights1[loop][loop1];
           }
           for(int loop = 0; loop < row2; loop++){
               for(int loop1 = 0; loop1 < col2; loop1++)
                   bpnn.mNeuronLayers[1]->mWeights[loop][loop1] = new_weights2[loop][loop1];
           }
           cout << "已學習:" << i << "\r";
           cur_time = MPI_Wtime() - cur_time;
           comun_time += cur_time;
       }
       if(rank !=0){
               //get new weights
           for(int loop = 0; loop < row1; loop++)
           MPI_Recv(weights1, col1, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD, &status);
           
           MPI_Recv(weights2, row2*col2, MPI_DOUBLE, 0, tag, MPI_COMM_WORLD, &status);
           for(int loop = 0; loop < row1; loop++){
               for(int loop1 = 0; loop1 < col1; loop1++)
                   bpnn.mNeuronLayers[0]->mWeights[loop][loop1] = weights1[loop][loop1];
           }
           for(int loop = 0; loop < row2; loop++){
               for(int loop1 = 0; loop1 < col2; loop1++)
                   bpnn.mNeuronLayers[1]->mWeights[loop][loop1] = weights2[loop][loop1];
           }
              
       }
       MPI_Barrier(MPI_COMM_WORLD);
       i += task_count*SIZE;
       
   }
   if(rank == 0)
   	cout << " comun_time=" << comun_time << endl;

   delete []net_train;
   delete []temp;

   return bpnn.getError();

}
//

這裏需要注意的是MPI_Recv和MPI_Send緩衝區大小有限制,當時我發送一個大小爲7w+個double大小的數組就發生了一直阻塞的情況,後來借鑑了別人的經驗,將數組分開多次發送就解決了,但是可能加大了通信的總開銷

OpenMP–計算並行

當時我在考慮應該在哪裏使用OpenMP來改進算法時,我的思路一直侷限在樣本並行上,我忽略了MPI與OpenMP的區別,後來我突然想起來,當時OpenMP的最經典應用就是用在矩陣運算上,而我們在本算法中,大量的計算開銷都是產生於矩陣的運算。
因此使用OpenMP更改算法就變得簡單了起來,只要找到矩陣運算的部分,加上合適的原語即可。

5. 可擴展性設計

在算法中,會有一些可以自由調整的參數,爲了在進行不同維度的算法效果分析時的方便,我們把可變的參數放到一個文件中,在程序執行的開始,使用文件中的數據來初始化本次執行的一些可變參數。
文件包含:
訓練樣本量 #input_size 學習率 #learning_rate
隱含層神經元數量 #number 並行粒度 #para_size
在這裏插入圖片描述

6.結果分析

測試機器硬件型號:cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定參數設置:
測試樣本:1w 學習率:0.5 訓練週期:1 隱藏層細胞:100 粒度:10 訓練樣本6w
串行程序: 時間開銷27.9s 正確率94.01%
8線程–OpenMP: 時間開銷7.9s 正確率93.86%
單節點8進程–MPI: 時間開銷18.1s 正確率91.2%

並行參數調整分析

1. 不同輸入樣本量

在這裏插入圖片描述
測試機器硬件型號:
cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定參數設置:測試樣本:1w 學習率:0.5 訓練週期:1 進程數:8 隱藏層細胞:100 粒度:10
在OpenMP中,當我們輸入維度隨倍數遞增,我們的時間開銷基本上也隨倍數遞增,但是錯誤率隨有提升,但是提升的效果不是十分顯著,趨勢已經趨於平緩。
在這裏插入圖片描述
在MPI中,當輸入的維度隨倍數遞增,不論是總開銷還是進程之間的通信開銷依舊基本上隨倍數遞增,但是通信開銷的佔比幾乎不變,與OpenMP相似,錯誤率的下降呈逐漸緩慢的趨勢。

2.不同粒度

在這裏插入圖片描述
測試機器硬件型號:
cups:8核 Intel® Core™ i7-4720HQ @ 2.60GHz
固定參數設置:
測試樣本:1w 訓練樣本:6w學習率:0.5 訓練週期:1 進程數:8 隱藏層細胞:100

當我們改變MPI並行粒度的大小的時候,如圖6,我們可以看到正確率是一個先下降後上升的過程。當粒度處於10-50之間的時候,由於權重數組的更新對於樣本之間存在很強的依賴性,隨着粒度的增大,我們回傳參數的次數變小,導致了正確率的降低。當粒度再次增大,我們每一個進程分到的樣本量變大,這時粒度的增大彌補了進程之間權重數組更新不同步的缺陷,正確率回升。
在時間開銷上,計算量的變化基本不大,但是粒度增大,進程之間的通信量變少,導致了通信開銷減小,也就造成了總開銷變低。

3.不同進程/線程數

在這裏插入圖片描述
測試機器硬件型號:
cups:8核 Intel® Core™ [email protected]
固定參數設置:
測試樣本:1w 訓練樣本:6w學習率:0.5 訓練週期:1 粒度:10 隱藏層細胞:100
在OpenMP中,當我們開啓的線程數不斷增大時,時間的開銷是一個先減小後增大的過程,由於程序在一個8核的機器上執行,所以當開啓的線程數達到8的時候,時間開銷達到最小,再增大之後,就需要8個核共同協調完成多出來的8個線程,也就導致了時間開銷又再次加大。
在OpenMP中由於使用的是計算並行,所以調整線程的大小對正確率沒有影響,圖中的正確率的浮動在1%左右屬於正常現象,可能是由於初始化時隨機數的不同所導致。
在這裏插入圖片描述
測試機器硬件型號:
節點1: cups:8核 Intel® Core™ [email protected]
節點2: cpus:8核 Intel® Core™ [email protected]

固定參數設置:
測試樣本:1w 訓練樣本:6w學習率:0.5 訓練週期:1 粒度:10 隱藏層細胞:100

當我們在單臺機器節點1上進行開啓不同進程數的測試時,如圖8我們可以發現開啓4個進程和開啓8個進程的總開銷幾乎一致,但是8個進程的通信開銷佔比變大了,這也就意味着,開啓多個進程雖然能在計算上加速,但是通信開銷也會變大,當我們開啓更多的12個進程時,通信的開銷佔據了總開銷的一半還多。
當我們同樣開啓12個進程在兩個節點上執行的時候,時間的總開銷更是高達2200多秒,通信佔比更是高達98.83%。
在這裏插入圖片描述
由於權重參數的樣本依賴性,我們開啓的進程數越多,對正確率的影響也越大,錯誤率隨着進程數的增大不斷增加。
在這裏插入圖片描述

算法參數調整分析

1.不同訓練週期數

測試機器硬件型號:
cups:8核 Intel® Core™ [email protected]
固定參數設置:
測試樣本:1w 訓練樣本:6w學習率:0.5 線程/進程數:8 粒度:10 隱藏層細胞:100
在這裏插入圖片描述
在當我們不斷加大訓練週期的時候,不論採用哪種並行算法都是時間開銷呈線性遞增,識別的成功率也會有所增長。但是當訓練週期達到一定的值的時候,正確率的提升微乎其微,似乎達到了一個極限,這個時候就不是單憑加大訓練量就能繼續提高正確率的,需要我們去改善訓練所用的算法,例如改變其他參數、更換激活函數、使用卷積神經網絡等。

2.不同學習率

測試機器硬件型號:
cups:8核 Intel® Core™ [email protected]
固定參數設置:
測試樣本:1w訓練樣本:6w訓練週期:1 線程/進程數:8 粒度:10 隱藏層細胞:100
在這裏插入圖片描述
隨着學習率的增大,正確率的變化由平緩到線性急速降低,我們可以推測,最優的學習率大致在0-1之間,時間的變化在0.1秒之內,屬於正常變化,幾乎沒有太大影響。

3.不同隱藏層細胞數

在這裏插入圖片描述
隱藏層細胞數的增加提高了正確率,但是到後面會有所收斂,由於計算量的倍增,時間開銷同樣也會倍增。

小結

當計算量不足夠大,而且網絡通信開銷比較大的時候,使用OpenMP進行並行優化的效果要比MPI優化的效果更爲明顯。
同樣是在單節點上執行,由於OpenMP是多線程並行,大多數的數據是共享的,每一個線程的計算是獨立的、互不干擾的,因此在數據傳遞上就比MPI的多線程,數據不共享體現出了優勢,有效的減小了通信的開銷佔比。當然,我們在多節點執行的時候,其中一個節點使用了虛擬機,有可能也是程序執行時間被拖慢的原因之一。
這並不是說MPI與OpenMP相比就失去了優勢,這隻能說明OpenMP更適合於我們這次所選的課題。MPI的優勢在於,可以將多臺機器組建成一個集羣,而不是侷限於單臺機器。
這是本黑菜第一次寫博客,如果文章裏哪裏出現了學術上的問題,還請各位大佬及時指正,謝謝。

參考資料

https://blog.csdn.net/a493823882/article/details/78683445
https://blog.csdn.net/xbinworld/article/details/74781605

CSDN下載

https://download.csdn.net/download/qq_41645895/11068914

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