Caffe代码阅读
轉(zhuǎn)載自:
Caffe代碼閱讀——層次結構 - 無痛的機器學習 - 知乎專欄 ?https://zhuanlan.zhihu.com/p/21796890
Caffe源碼閱讀——Net組裝 - 無痛的機器學習 - 知乎專欄 ?https://zhuanlan.zhihu.com/p/21875025
Caffe代碼閱讀——Solver - 無痛的機器學習 - 知乎專欄 ?https://zhuanlan.zhihu.com/p/21800004
1.Caffe代碼閱讀——層次結構
作者:馮超鏈接:https://zhuanlan.zhihu.com/p/21796890
來源:知乎
著作權歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權,非商業(yè)轉(zhuǎn)載請注明出處。
Caffe是一款優(yōu)秀的深度神經(jīng)網(wǎng)絡的開源軟件,下面我們來聊聊它的源代碼以及它的實現(xiàn)。Caffe的代碼整體上可讀性很好,架構比較清晰,閱讀代碼并不算是一件很困難的事情。不過在閱讀代碼之前還是要回答兩個問題:
閱讀代碼大體上來說有下面幾個目的:
我們的目標是擴展代碼。Caffe中主要的擴展點就是Layer和Solver,當然其他的部分也可以擴展,只不過要改動的代碼會多一些。
當確定了上面第一個問題,下面就是第二個問題了。讀代碼要讀到什么程度?一般來說,我覺得閱讀代碼這件事情可以用一個Logistic型的函數(shù)來表示:
這個圖上,橫軸是閱讀代碼花費的時間,縱軸是閱讀代碼帶來的效果。對于代碼量比較大的項目,一開始閱讀肯定是蒙的,需要花一定的時間梳理清楚各個文件,各個模塊之間的關系。隨著結構關系逐漸清晰,讀者開始領會代碼中所表達的含義,閱讀代碼的效果直線上升。然而當我們把代碼主線和重要支線弄懂后,再讀一些小支線的收益就不會太大。所以根據(jù)閱讀代碼的性價比和Caffe代碼自身的特點,我們只會將主線和一些重要支線閱讀完,估計也就是整體代碼量的一半。
Caffe代碼的主線結構抽象
不同于其他的一些框架,Caffe沒有采用符號計算的模式進行編寫,整體上的架構以系統(tǒng)級的抽象為主。所謂的抽象,就是逐層地封裝一些細節(jié)問題,讓上層的代碼變得更加清晰。那么就讓我們來順著Caffe的抽象層級看看Caffe的主線結構:
SyncedMem:這個類的主要功能是封裝CPU和GPU的數(shù)據(jù)交互操作。一般來說,數(shù)據(jù)的流動形式都是:硬盤->CPU內(nèi)存->GPU內(nèi)存->CPU內(nèi)存->(硬盤),所以在寫代碼的過程中經(jīng)常會寫CPU/GPU之間數(shù)據(jù)傳輸?shù)拇a,同時還要維護CPU和GPU兩個處理端的內(nèi)存指針。這些事情處理起來不會很難,但是會很繁瑣。因此SyncedMem的出現(xiàn)就是把CPU/GPU的數(shù)據(jù)傳輸操作封裝起來,只需要調(diào)用簡單的接口就可以獲得兩個處理端同步后的數(shù)據(jù)。
Blob:這個類做了兩個封裝:一個是操作數(shù)據(jù)的封裝。在這里使用Blob,我們可以操縱高維的數(shù)據(jù),可以快速訪問其中的數(shù)據(jù),變換數(shù)據(jù)的維度等等;另一個是對原始數(shù)據(jù)和更新量的封裝。每一個Blob中都有data和diff兩個數(shù)據(jù)指針,data用于存儲原始數(shù)據(jù),diff用于存儲反。向傳播的梯度更新值。Blob使用了SyncedMem,這樣也得到了不同處理端訪問的便利。這樣Blob就基本實現(xiàn)了整個Caffe數(shù)據(jù)部分結構的封裝,在Net類中可以看到所有的前后向數(shù)據(jù)和參數(shù)都用Blob來表示就足夠了。
數(shù)據(jù)的抽象到這個就可以了,接下來是層級的抽象。前面我們也分析過,神經(jīng)網(wǎng)絡的前后向計算可以做到層與層之間完全獨立,那么每個層只要依照一定的接口規(guī)則實現(xiàn),就可以確保整個網(wǎng)絡的正確性。
Layer:Caffe實現(xiàn)了一個基礎的層級類Layer,對于一些特殊種類還會有自己的抽象類(比如base_conv_layer),這些類主要采用了模板的設計模式(Template),也就是說一些必須的代碼在基類寫好,一些具體的內(nèi)容在子類中實現(xiàn)。比方說在Layer的Setup中,函數(shù)中包括Setup的幾個步驟,其中的一些步驟由基類完成,一些步驟由子類完成。還有十分重要的Forward和Backward,基類實現(xiàn)了其中需要的一些邏輯,但是真正的運算部分則交給了子類。這樣當我們需要實現(xiàn)一個新的層時,我們不需要管理瑣碎的事物,只要關系好層的初始化和前后向即可。
Net:Net將數(shù)據(jù)和層組合起來做進一步的封裝,對外暴露了初始化和前后向的接口,使得整體看上去和一個層的功能類似,但內(nèi)部的組合可以是多種多樣。同時值得一提的是,每一層的輸入輸出數(shù)據(jù)統(tǒng)一保存在Net中,同時每個層內(nèi)的參數(shù)指針也保存在Net中,不同的層可以通過WeightShare共享相同的參數(shù),所以我們可以通過配置實現(xiàn)多個神經(jīng)網(wǎng)絡層之間共享參數(shù)的功能,這也增強了我們對網(wǎng)絡結構的想象力。
Solver:有了Net我們實際上就可以進行網(wǎng)絡的前向后向計算了,但是關于網(wǎng)絡的學習訓練的功能還有些缺乏,于是在此之上,Solver類進一步封裝了訓練和預測相關的一些功能。與此同時,它還開放了兩類接口:一個是更新參數(shù)的接口,繼承Solver可以實現(xiàn)不同的參數(shù)更新方法,如大家喜聞樂見的Momentum,Nesterov,Adagrad等。這樣使得不同的優(yōu)化算法能夠應用其中。另外一個是訓練過程中每一輪特定狀態(tài)下的可注入的一些回調(diào)函數(shù),在代碼中這個回調(diào)點的直接使用者就是多卡訓練算法。
IO:有了上面的東西就夠了?還不夠,我們還需要輸入數(shù)據(jù)和參數(shù),正所謂巧婦難為無米之炊,沒有數(shù)據(jù)都是白搭。DataReader和DataTransformer幫助準備輸入數(shù)據(jù),Filler對參數(shù)進行初始化。一些Snapshot方法幫助模型的持久化,這樣模型和數(shù)據(jù)的IO問題也解決了。
多卡:對于單GPU訓練來說,基本的層次關系到這里也就結束了,如果要進行多GPU訓練,那么上層還會有InternalThread和P2PSync兩個類,這兩個類屬于最上層的類了,而他們所調(diào)用的也只有Solver和一些參數(shù)類。
其實到這里,Caffe的主線也就基本走完了。我們可以畫一張圖把Caffe的整體層次關系展示出來:
如果對這張圖和圖中的一些細節(jié)比較清楚的話,那么你對Caffe的了解應該已經(jīng)不錯了。后面關于Caffe源碼分析的文章就可以不看了。如果沒有,那么我們還是可以繼續(xù)關注一下。當然如果想真正理解這張圖中所表達的含義,還是要真正地讀一下代碼,去理解一些細節(jié)。但是有些細節(jié)這里就不做詳細的分析了,下一回我們會站在Layer的角度去看一個Layer在訓練過程的全部經(jīng)歷。
2.
Caffe源碼閱讀——Net組裝
最近忙著看TI沒有及時寫文章,今天趕緊補一篇……
Net是Caffe代碼中一個比較核心的類,往下看它封裝了所有的Layer,構建起了整個神經(jīng)網(wǎng)絡;往上看它對外提供了前向后向計算,以及核心數(shù)據(jù)結構的訪問結構,使得再上層的Solver可以利用Net比較輕松地實現(xiàn)Train和Test的策略。當然,正是因為它的重要性,組裝Net是一個比較復雜的部分。這一回我們就來看看Net的內(nèi)容。當然,說在前面,看Net組裝的代碼有兩個目的:
首先,為了使問題不那么復雜,我們先從訓練模型時輸出的log看看Net組裝的幾個關鍵步驟,然后再把這個過程慢慢展開,了解組裝的所有細節(jié)。
Log眼中的Net組裝
為了更好地展示Net組裝的一些細節(jié),我們在這里選取了一個實際例子,就是Caffe的examples里面的siamese model。關于這個model的細節(jié)這里就不多說了,感興趣的可以去看官方或者非官方的文檔,這里只提一點:這個網(wǎng)絡除了包含其他正常網(wǎng)絡中的一些特性之外,還具有網(wǎng)絡參數(shù)復用的特點,在后面的分析中我們會用到。
下面我們要看的就是Net組裝的Log。這段Log一般都是大家在訓練網(wǎng)絡時一閃而過的大段Log,當然如果它沒有一閃而過而是停下來了,有可能是你的網(wǎng)絡定義有問題爆出了錯誤。這段Log內(nèi)容比較多,總體來說就是Train階段和Test階段的兩個網(wǎng)絡組裝起來。我們重點關注其中的幾個片段,來大概了解Net組裝的一些核心內(nèi)容,也是那些比較值得打印出來的內(nèi)容。
首先是一個正常的卷積層conv1,Log如下所示(以下代碼的行號可能會有不同,但位置是相近的):
layer_factory.hpp:77] Creating layer conv1 net.cpp:92] Creating Layer conv1 net.cpp:428] conv1 <- data net.cpp:402] conv1 -> conv1 net.cpp:144] Setting up conv1 net.cpp:151] Top shape: 64 20 24 24 (737280) net.cpp:159] Memory required for data: 3752192這其中第一行是創(chuàng)建這個Layer實例的代碼,具體的創(chuàng)建過程在layer_factory里面。為了方便創(chuàng)建Layer,Caffe采用了工廠方法的設計模式,只要提供Layer的名字(在配置文件中參數(shù)叫type),就可以根據(jù)名字和對應參數(shù)實例化一個Layer。這部分的細節(jié)只要認真看一下就會明白。
第3,4行顯示了創(chuàng)建當前層的bottom和top數(shù)據(jù)的過程。這里涉及到net.cpp中的AppendBottom和AppendTop兩個方法,因為每一個bottom blob和top blob都有名字,這里就將他們之間的關系輸出在了這里。
第5行看上去沒什么干貨,但是它代表了Layer的Setup函數(shù)已經(jīng)調(diào)用完成(或者Layer被share)。Layer的Setup函數(shù)是Layer初始化的關鍵函數(shù),這里面涉及到以下幾個具體的操作:
CheckBlobCounts(bottom, top); LayerSetUp(bottom, top); Reshape(bottom, top); SetLossWeights(top);總結地說,這四句完成了:
好了回到上面的log。接下來的那一句告訴了我們top層應該輸出的維度。這里輸出了維度就是為了讓不放心的朋友算一下,看看和你想的是否一樣。當然,輸出這句log的循環(huán)不是只做了這件事,它的主要工作就是設置top blob的loss_weight。
最后一句計算了該層top blob所占用的內(nèi)存??梢钥闯鼋刂恋竭@一層,內(nèi)存消耗大約是3M多,還不算大。
好,這就是一個最典型的Layer的初始化,下面這個ReLU層就稍微有些不同了:
layer_factory.hpp:77] Creating layer relu1 net.cpp:92] Creating Layer relu1 net.cpp:428] relu1 <- ip1 net.cpp:389] relu1 -> ip1 (in-place) net.cpp:144] Setting up relu1 net.cpp:151] Top shape: 64 500 (32000) net.cpp:159] Memory required for data: 5769472這里面最不同的就是第4行結尾的(in-place),這說明relu的bottom blob和top blob是同一個數(shù)據(jù),這和我們在網(wǎng)絡中的定義是一樣的。in-place的好處就是減少內(nèi)存的操作,但是這里在統(tǒng)計內(nèi)存消耗時并沒有考慮in-place帶來的節(jié)省。
接下來就是共享網(wǎng)絡的conv1_p了:
layer_factory.hpp:77] Creating layer conv1_p net.cpp:92] Creating Layer conv1_p net.cpp:428] conv1_p <- data_p net.cpp:402] conv1_p -> conv1_p net.cpp:144] Setting up conv1_p net.cpp:151] Top shape: 64 20 24 24 (737280) net.cpp:159] Memory required for data: 8721664 net.cpp:488] Sharing parameters 'conv1_w' owned by layer 'conv1', param index 0 net.cpp:488] Sharing parameters 'conv1_b' owned by layer 'conv1', param index 1這一段最有特點的是最后兩句“Sharing”,因為siamese model中擁有參數(shù)完全相同的兩個網(wǎng)絡,所以在構建時候,第二個網(wǎng)絡檢測到參數(shù)名字已經(jīng)存在,說明該層的參數(shù)和其他層共享,于是在這里打印出來告訴用戶這一點。當然,這一句之前沒有打印出來的內(nèi)容告訴了我們,實際上Net類中還負責了參數(shù)相關的初始化。這部分的內(nèi)容實際上還挺多,除了參數(shù)共享,還有對參數(shù)learning rate,weight decay的設定。
最后是最特別的一層:loss層
net.cpp:92] Creating Layer loss net.cpp:428] loss <- feat net.cpp:428] loss <- feat_p net.cpp:428] loss <- sim net.cpp:402] loss -> loss net.cpp:144] Setting up loss net.cpp:151] Top shape: (1) net.cpp:154] with loss weight 1 net.cpp:159] Memory required for data: 10742020這一層看上去沒有什么特別,該有的和前面一樣,但是唯一不同的就是它的倒數(shù)第二行,這說明這一層是有l(wèi)oss weight的。至于有l(wèi)oss weight有什么用,以后我們會詳細說這個事情。這里簡單說一下,有l(wèi)oss weight表示這個blob會被用于計算loss。
前面的log主要解決了網(wǎng)絡的組裝和前向的一些計算,從log中,我們可以看出Net完成了以下的事情:
從上面的過程也可以看出,整個網(wǎng)絡中所有的流動性變量(bottom blob,top blob)都保存在Net中,同時對于各層的參數(shù),根據(jù)各層的共享關系做了標記。這樣好處是集中管理了網(wǎng)絡中的數(shù)據(jù),方便對數(shù)據(jù)進行操作。
再往下面,我們可以截取一小段log來:
net.cpp:220] pool1 needs backward computation. net.cpp:220] conv1 needs backward computation. net.cpp:222] slice_pair does not need backward computation. net.cpp:222] pair_data does not need backward computation. net.cpp:264] This network produces output loss net.cpp:277] Network initialization done.接下來是統(tǒng)計一個層次是否需要進行反向傳播的計算。一般來說我們的層是都需要計算的,但是也會有一些層不需要計算,比方說數(shù)據(jù)層,就像上面的log那樣,還有就是一些希望固定的層,這個一般在finetune網(wǎng)絡的時候用的上。因為反向計算一般比前向計算慢,如果有不需要計算的Layer,直接跳過計算是可以節(jié)省時間的。
最后是整個網(wǎng)絡產(chǎn)生的輸出,這個輸出會在訓練迭代中顯示出來的。
了解了這些,我們就對Net裝載有了大概的了解,再去看它的代碼就會輕松些。
最后,關于Net類中所有的成員變量與它們之間的關系,我們可以用下面的一張圖來理解就好:
把Net的初始化理解后,其實Net以下的架構方面的問題就不多了。下面我再看看Net以上的東西,Solver以及Caffe里“簡單”的多卡訓練。
3.
Caffe代碼閱讀——Solver
前面我們聊了Net組裝的內(nèi)容,接下來我們來看看Solver的內(nèi)容。Solver主體有兩部分:初始化和訓練。初始化內(nèi)容相對比較簡單,這里就不說了;下面我們來說說訓練中的幾個關鍵函數(shù)。核心函數(shù):Step
真正的訓練在Step函數(shù)內(nèi),這里有多卡訓練的關鍵回調(diào)函數(shù):on_start()和on_gradient_ready(),具體的調(diào)用方法我們后面再說,在這兩個回調(diào)函數(shù)中間有兩個重要的過程:ForwardBackward和UpdateSmoothedLoss。在on_gradient_ready之后有一個關鍵函數(shù)ApplyUpdate(),這里面的代碼在Sgd_solver中。下面我們詳細看一下。
ForwardBackward
這里主要調(diào)用了Net中的代碼,主要完成了前向后向的計算,前向用于計算模型的最終輸出和Loss,后向用于計算每一層網(wǎng)絡和參數(shù)的梯度。對于前向后向的具體內(nèi)容這里需要詳細敘述了,唯一值得一提的是前向的Loss計算,這部分代碼實際上實在Layer里面,具體涉及到loss_weight這個參數(shù)相關的初始化和loss()的判斷,同時還有Loss_Layer在Setup函數(shù)中的初始化。
UpdateSmoothedLoss
這個函數(shù)主要做Loss的平滑。由于Caffe的訓練方式是SGD,我們無法把所有的數(shù)據(jù)同時放入模型進行訓練,那么部分數(shù)據(jù)產(chǎn)生的Loss就可能會和全樣本的平均Loss不同,在必要時候?qū)oss和歷史過程中更新的Loss求平均就可以減少Loss的震蕩問題。代碼中的平滑方法比較簡單,大家一看便知。
下面就是ApplyUpdate函數(shù),這個函數(shù)真正完成了參數(shù)更新的任務。Caffe的參數(shù)更新只利用了模型的梯度信息,沒有利用二階信息。下面就詳細介紹下更新參數(shù)的幾個過程:
- GetLearningRate
- ClipGradients
- Normalize
- Regularize
- ComputeUpdateValue
GetLearningRate
learning rate的故事我們前面已經(jīng)聊過了,在CNN訓練中這確實是個大問題。Caffe為了讓learning rate的設計更靈活,提供了一系列的learning rate方案:
- fixed:lr永遠不變
- step:
- exp:
- inv:
- multistep:直接寫iter在某個范圍內(nèi)時lr應該是多少
- poly:
- sigmoid:
這些方案各有優(yōu)劣,選擇自己順手的就好。
ClipGradients
這一步主要是對梯度值做一個限制,如果梯度值過大,那么這里就會對梯度做一個修剪,對所有的參數(shù)乘以一個縮放因子,使得所有參數(shù)的平方和不超過參數(shù)中設定的梯度總值。這個功能感覺上像是對全局函數(shù)設置了一個Trust Region,可以防止更新的量過大二導致梯度發(fā)散。我認為這一步的想法是很好的,但是實際操作中可能會有問題。實際中可能只有部分參數(shù)的梯度比較大,而其他參數(shù)的梯度本身比較小,那么對所有的參數(shù)乘以相同的因子會讓一些本來比較小的參數(shù)變得更小,這樣會帶來一些不公平。
Normalize
這一步同樣考慮了一些單一Batch不足以完成訓練的問題,通過限制每個Batch的更新量來控制更新總量,代碼比較簡單。
Regularize
到這一步終于要計算正則項的梯度了。Caffe提供兩種正則方法——L2和L1,其中L2采用了標準的梯度下降方法,L1采用了sub-gradient的計算方法。L2的優(yōu)化計算比較簡單,沒有什么好說的,但是L1的計算還是有點值得玩味的地方的。這里采用的sub-gradient方法其實本身沒有什么問題,不過Lasso的優(yōu)化還可以有其他的方法,這個問題以后可以再細聊。
ComputeUpdateValue
到這里,我們終于來到了梯度計算的最后一站,這時候我們終于完成了對梯度的計算,下面該考慮lr和梯度結合起來如何計算最終的梯度優(yōu)化值了。sgd方法主要采用momentum加梯度的優(yōu)化方法。關于momentum的優(yōu)勢我們前面已經(jīng)聊過了。除此之外,Caffe還提供了一系列的梯度計算方法,這些優(yōu)化方法各有特點,以后我們可以慢慢來看。
當計算完這一步,我們就可以調(diào)用Blob中的Update把每個參數(shù)的data和diff進行相加,計算出最終的結果。這樣,整個優(yōu)化過程就完成了。至于剩下的一些內(nèi)容都不是核心過程,就略去不看了。
如果我們采用單卡訓練的策略,那么閱讀代碼到這里也差不多了。不過多卡訓練對于大規(guī)模的訓練任務來說是必不可少的,所以我們接下來趁熱打鐵地看看Caffe的多卡訓練。
多卡訓練算法
Caffe的多卡訓練算法總體思路是數(shù)據(jù)并行,我們用不同的GPU處理不同的數(shù)據(jù),然后將所有的梯度更新匯總。由于Solver在訓練中給了兩個回調(diào)函數(shù),多卡訓練也主要利用了這兩個回調(diào)函數(shù)進行:
其中第2步由每一個CPU線程和自己的GPU并行完成,第4步由匯總的CPU和自己的GPU完成,剩下的1,3兩步主要是完成數(shù)據(jù)傳輸?shù)娜蝿?#xff0c;也是多卡計算中主要完成的部分。
Caffe采用樹型結構進行參數(shù)傳遞,其中一個CPU線程和GPU作為樹型結構的根,其他的則作為根下面的節(jié)點。為了更快地傳輸GPU數(shù)據(jù),樹型結構的構建要考慮GPU之間是否相近,比方說兩個GPU之間是否可以進行P2P的直傳。在前面的翻譯博客中我們已經(jīng)聊過GPU之間數(shù)據(jù)傳輸?shù)膯栴}了,這里的樹形結構也主要以此做考慮。
我們假設4塊GPU的拓撲結構如下:
nvidia-smi topo -mGPU0 GPU1 GPU2 GPU3 GPU0 X PHB SOC SOC GPU1 PHB X SOC SOC GPU2 SOC SOC X PHB GPU3 SOC SOC PHB X
那么我們構造出的樹型結構如下所示,數(shù)據(jù)傳輸也是按照這樣的結構傳輸:
這樣1,3的數(shù)據(jù)傳遞就解決了,具體的過程請詳細閱讀代碼,這里就不敘述了。
對Caffe代碼的基本介紹就到這里了,我們對代碼的整體結構有了比較清晰的認識,下面我們將分析模型中各個部分的特性。
總結
- 上一篇: Android Studio如何发布AP
- 下一篇: 利用TinyXML读取VOC2012数据