如何閱讀一個前向推理框架?以NCNN為例

極市導讀
?自NCNN開源以來,如何去繁從簡的閱讀一個深度學習推理框架十分重要,這篇文章記錄了作者是如何閱讀NCNN框架的,希望對一些不知道如何下手的讀者有一點啟發(fā)。?>>年度盤點:極市計算機視覺資源匯總,頂會論文、技術視頻、數(shù)據(jù)集等(限時開放下載)
0x00. 想法來源
CNN從15年的ResNet在ImageNet比賽中大放異彩,到今天各種層出不窮的網(wǎng)絡結構被提出以解決生活中碰到的各種問題。然而,在CNN長期發(fā)展過程中,也伴隨著很多的挑戰(zhàn),比如如何調(diào)整算法使得在特定場景或者說數(shù)據(jù)集上取得最好的精度,如何將學術界出色的算法落地到工業(yè)界,如何設計出在邊緣端或者有限硬件條件下的定制化CNN等。前兩天看到騰訊優(yōu)圖的文章:騰訊優(yōu)圖開源這三年 ,里面提到了NCNN背后的故事,十分感動和佩服,然后我也是白嫖了很多NCNN的算法實現(xiàn)以及一些調(diào)優(yōu)技巧。所以為了讓很多不太了解NCNN的人能更好的理解騰訊優(yōu)圖這個"從0到1"的深度學習框架,我將結合我自己擅長的東西來介紹「我眼中的NCNN它是什么樣的」?
0x01. 如何使用NCNN
這篇文章的重點不是如何跑起來NCNN的各種Demo,也不是如何使用NCNN來部署自己的業(yè)務網(wǎng)絡,這部分沒有什么比官方wiki介紹得更加清楚的資料了。所以這部分我只是簡要匯總一些資料,以及說明一些我認為非常重要的東西。
官方wiki指路:https://github.com/Tencent/ncnn/wiki
在NCNN中新建一個自定義層教程:https://github.com/Ewenwan/MVision/blob/master/CNN/HighPerformanceComputing/example/ncnn_%E6%96%B0%E5%BB%BA%E5%B1%82.md
NCNN下載編譯以及使用:https://github.com/Ewenwan/MVision/blob/master/CNN/HighPerformanceComputing/example/readme.md
0x02. 運行流程解析
要了解一個深度學習框架,首先得搞清楚這個框架是如何通過讀取一張圖片然后獲得的我們想要的輸出結果,這個運行流程究竟是長什么樣的?我們看一下NCNN官方wiki中提供一個示例代碼:
int main(){// opencv讀取輸入圖片cv::Mat img = cv::imread("image.ppm", CV_LOAD_IMAGE_GRAYSCALE);int w = img.cols;int h = img.rows;// 減均值以及縮放操作,最后輸入數(shù)據(jù)的值域為[-1,1]ncnn::Mat in = ncnn::Mat::from_pixels_resize(img.data, ncnn::Mat::PIXEL_GRAY, w, h, 60, 60);float mean[1] = { 128.f };float norm[1] = { 1/128.f };in.substract_mean_normalize(mean, norm);// 構建NCNN的net,并加載轉換好的模型ncnn::Net net;net.load_param("model.param");net.load_model("model.bin");// 創(chuàng)建網(wǎng)絡提取器,設置網(wǎng)絡輸入,線程數(shù),light模式等等ncnn::Extractor ex = net.create_extractor();ex.set_light_mode(true);ex.set_num_threads(4);ex.input("data", in);// 調(diào)用extract接口,完成網(wǎng)絡推理,獲得輸出結果ncnn::Mat feat;ex.extract("output", feat);return 0;
0x02.00 圖像預處理ncnn::Mat
可以看到NCNN對于我們給定的一個網(wǎng)絡(首先轉換為NCNN的param和bin文件)和輸入,首先執(zhí)行圖像預處理,這是基于「ncnn::Mat」這個數(shù)據(jù)結構完成的。
其中,from_pixels_resize() 這個函數(shù)的作用是生成目標尺寸大小的網(wǎng)絡輸入Mat,它的實現(xiàn)在https://github.com/Tencent/ncnn/blob/b93775a27273618501a15a235355738cda102a38/src/mat_pixel.cpp#L2543。它的內(nèi)部實際上是「根據(jù)傳入的輸入圖像的通道數(shù)」完成resize_bilinear_c1/c2/c3/4 即一通道/二通道/三通道/四通道 圖像變形算法,可以看到使用的是雙線性插值算法。這些操作的實現(xiàn)在https://github.com/Tencent/ncnn/blob/master/src/mat_pixel_resize.cpp#L27。然后經(jīng)過Resize之后,需要將像素圖像轉換成ncnn::Mat。這里調(diào)用的是Mat::from_pixels()這個函數(shù),它將我們Resize操作之后獲得的像素圖像數(shù)據(jù)(即float*數(shù)據(jù))根據(jù)特定的輸入類型賦值給ncnn::Mat。
接下來,我們講講substract_mean_normalize()這個函數(shù),它實現(xiàn)了減均值和歸一化操作,它的實現(xiàn)在:https://github.com/Tencent/ncnn/blob/master/src/mat.cpp#L34。具體來說,這個函數(shù)根據(jù)均值參數(shù)和歸一化參數(shù)的有無分成這幾種情況:
「有均值參數(shù)」
「創(chuàng)建 偏置層」 ncnn::create_layer(ncnn::LayerType::Bias); 載入層參數(shù) op->load_param(pd); 3通道 「載入層權重數(shù)據(jù)」 op->load_model(ncnn::ModelBinFromMatArray(weights)); -均值參數(shù) 「運行層」 op->forward_inplace(*this); 「有歸一化參數(shù)」
「創(chuàng)建 尺度層」 ncnn::create_layer(ncnn::LayerType::Scale); 載入層參數(shù) op->load_param(pd); 3通道 「載入層權重數(shù)據(jù)」 op->load_model(ncnn::ModelBinFromMatArray(weights)); 尺度參數(shù) 「運行層」 op->forward_inplace(*this); 「有均值和歸一化參數(shù)」
「創(chuàng)建 尺度層」 ncnn::create_layer(ncnn::LayerType::Scale); 載入層參數(shù) op->load_param(pd); 3通道 「載入層權重數(shù)據(jù)」 op->load_model(ncnn::ModelBinFromMatArray(weights)); -均值參數(shù) 和 尺度參數(shù) 「運行層」 op->forward_inplace(*this);
可以看到NCNN的均值和歸一化操作,是直接利用了它的Bias Layer和Scale Layer來實現(xiàn)的,也就是說NCNN中的每個層都可以單獨拿出來運行我們自己數(shù)據(jù),更加方便我們白嫖 。
0x02.01 模型解析ncnn::Net
param 解析
完成了圖像預處理之后,新增了一個ncnn::Net,然后調(diào)用Net::load_param來載入網(wǎng)絡參數(shù)文件 *.proto, 這部分的實現(xiàn)在https://github.com/Tencent/ncnn/blob/master/src/net.cpp#L115。在講解這個函數(shù)在的過程之前,我們先來一起分析一下NCNN的param文件,舉例如下:
7767517 # 文件頭 魔數(shù)75 83 # 層數(shù)量 輸入輸出blob數(shù)量# 下面有75行Input data 0 1 data 0=227 1=227 2=3Convolution conv1 1 1 data conv1 0=64 1=3 2=1 3=2 4=0 5=1 6=1728ReLU relu_conv1 1 1 conv1 conv1_relu_conv1 0=0.000000Pooling pool1 1 1 conv1_relu_conv1 pool1 0=0 1=3 2=2 3=0 4=0Convolution fire2/squeeze1x1 1 1 pool1 fire2/squeeze1x1 0=16 1=1 2=1 3=1 4=0 5=1 6=1024...層名字 輸入blob數(shù)量 輸出blob數(shù)量 輸入blob名字 輸出blob名字 參數(shù)字典參數(shù)字典,每一層的意義不一樣:Input data 0 1 data 0=227 1=227 2=3 圖像寬度×圖像高度×通道數(shù)量Convolution ... 0=64 1=3 2=1 3=2 4=0 5=1 6=1728num_output() ; 1卷積核尺寸 kernel_size(); 2空洞卷積參數(shù) dilation(); 3卷積步長 stride();5卷積偏置有無bias_term(); 6卷積核參數(shù)數(shù)量 weight_blob.data_size();C_OUT * C_in * W_h * W_w = 64*3*3*3 = 1728Pooling 0=0 1=3 2=2 3=0 4=0:最大值、均值、隨機 1池化核大小 kernel_size(); 2池化核步長 stride();pad(); 4是否為全局池化 global_pooling();ReLU 0=0.000000 下限閾值 negative_slope();ReLU6 0=0.000000 1=6.000000 上下限綜合示例:0=1 1=2.5 -23303=2,2.0,3.0: -23300- 23300 = 3 表示該參數(shù)在參數(shù)數(shù)組中的index后面的第一個參數(shù)表示數(shù)組元素數(shù)量,2表示包含兩個元素
然后官方的wiki中提供了所有網(wǎng)絡層的詳細參數(shù)設置,地址為:https://github.com/Tencent/ncnn/wiki/operation-param-weight-table
了解了Param的基本含義之后,我們可以來看一下Net::load_param這個函數(shù)是在做什么了。
從函數(shù)實現(xiàn),我們知道,首先會遍歷param文件中的所有網(wǎng)絡層,然后根據(jù)當前層的類型調(diào)用create_layer()/ net::create_custom_layer()來創(chuàng)建網(wǎng)絡層,然后讀取輸入Blobs和輸出Blobs和當前層綁定,再調(diào)用paramDict::load_param(fp)解析當前層的特定參數(shù)(參數(shù)字典),按照id=參數(shù)/參數(shù)數(shù)組來解析。最后,當前層調(diào)用layer->load_param(pd)載入解析得到的層特殊參數(shù)即獲得當前層特有的參數(shù)。
核心代碼解析如下:
// 參數(shù)讀取 程序// 讀取字符串格式的 參數(shù)文件int ParamDict::load_param(FILE* fp){clear();// 0=100 1=1.250000 -23303=5,0.1,0.2,0.4,0.8,1.0// parse each key=value pairint id = 0;while (fscanf(fp, "%d=", &id) == 1)// 讀取 等號前面的 key========={bool is_array = id <= -23300;if (is_array){id = -id - 23300;// 數(shù)組 關鍵字 -23300 得到該參數(shù)在參數(shù)數(shù)組中的 index}// 是以 -23300 開頭表示的數(shù)組===========if (is_array){int len = 0;int nscan = fscanf(fp, "%d", &len);// 后面的第一個參數(shù)表示數(shù)組元素數(shù)量,5表示包含兩個元素if (nscan != 1){fprintf(stderr, "ParamDict read array length fail\n");return -1;}params[id].v.create(len);for (int j = 0; j < len; j++){char vstr[16];nscan = fscanf(fp, ",%15[^,\n ]", vstr);//按格式解析字符串============if (nscan != 1){fprintf(stderr, "ParamDict read array element fail\n");return -1;}bool is_float = vstr_is_float(vstr);// 檢查該字段是否為 浮點數(shù)的字符串if (is_float){float* ptr = params[id].v;nscan = sscanf(vstr, "%f", &ptr[j]);// 轉換成浮點數(shù)后存入?yún)?shù)字典中}else{int* ptr = params[id].v;nscan = sscanf(vstr, "%d", &ptr[j]);// 轉換成 整數(shù)后 存入字典中}if (nscan != 1){fprintf(stderr, "ParamDict parse array element fail\n");return -1;}}}// 普通關鍵字=========================else{char vstr[16];int nscan = fscanf(fp, "%15s", vstr);// 獲取等號后面的 字符串if (nscan != 1){fprintf(stderr, "ParamDict read value fail\n");return -1;}bool is_float = vstr_is_float(vstr);// 判斷是否為浮點數(shù)if (is_float)nscan = sscanf(vstr, "%f", ¶ms[id].f); // 讀入為浮點數(shù)elsenscan = sscanf(vstr, "%d", ¶ms[id].i);// 讀入為整數(shù)if (nscan != 1){fprintf(stderr, "ParamDict parse value fail\n");return -1;}}params[id].loaded = 1;// 設置該 參數(shù)以及載入}return 0;}// 讀取 二進制格式的 參數(shù)文件===================int ParamDict::load_param_bin(FILE* fp){clear();// binary 0// binary 100// binary 1// binary 1.250000// binary 3 | array_bit// binary 5// binary 0.1// binary 0.2// binary 0.4// binary 0.8// binary 1.0// binary -233(EOP)int id = 0;fread(&id, sizeof(int), 1, fp);// 讀入一個整數(shù)長度的 indexwhile (id != -233)// 結尾{bool is_array = id <= -23300;if (is_array){id = -id - 23300;// 數(shù)組關鍵字對應的 index}// 是數(shù)組數(shù)據(jù)=======if (is_array){int len = 0;fread(&len, sizeof(int), 1, fp);// 數(shù)組元素數(shù)量params[id].v.create(len);float* ptr = params[id].v;fread(ptr, sizeof(float), len, fp);// 按浮點數(shù)長度*數(shù)組長度 讀取每一個數(shù)組元素====}// 是普通數(shù)據(jù)=======else{fread(¶ms[id].f, sizeof(float), 1, fp);// 按浮點數(shù)長度讀取 該普通字段對應的元素}params[id].loaded = 1;fread(&id, sizeof(int), 1, fp);// 讀取 下一個 index}return 0;}
bin 解析
解析完param文件,接下來需要對bin文件進行解析,這部分的實現(xiàn)在:https://github.com/Tencent/ncnn/blob/master/src/net.cpp#L672。這里執(zhí)行的主要的操作如下:
創(chuàng)建 ModelBinFromStdio 對象 提供載入?yún)?shù)的接口函數(shù) ModelBinFromStdio::load()根據(jù) 權重數(shù)據(jù)開始的一個四字節(jié)數(shù)據(jù)類型參數(shù)(float32/float16/int8等) 和 指定的參數(shù)數(shù)量 讀取數(shù)據(jù)到 Mat 并返回Mat, 這個函數(shù)的實現(xiàn)在https://github.com/Tencent/ncnn/blob/master/src/modelbin.cpp#L50。根據(jù)load_param 獲取到的網(wǎng)絡層信息 遍歷每一層 載入每一層的模型數(shù)據(jù) layer->load_model() 每一層特有函數(shù)。 部分層需要 根據(jù)層實際參數(shù) 調(diào)整運行流水線 layer->create_pipeline 例如卷積層和全連接層 量化的網(wǎng)絡需要融合 Net::fuse_network()
bin文件的結構如下:
+---------+---------+---------+---------+---------+---------+weight1 | weight2 | weight3 | weight4 | ....... | weightN |+---------+---------+---------+---------+---------+---------+^ ^ ^0x0 0x80 0x140 0x1C0每個權重占 32bit。weight buffer(optional 可選)data](optional 可選)flag : unsigned int, little-endian, indicating the weight storage type,0 => float32,0x01306B47 => float16,=> int8, 如果層實現(xiàn)顯式強制存儲類型,則可以省略raw data : 原始權重數(shù)據(jù)、little endian、float32數(shù)據(jù)或float16數(shù)據(jù)或量化表和索引,具體取決于存儲類型標志padding : 32位對齊的填充空間,如果已經(jīng)對齊,則可以省略。
感覺bin解析這部分了解一下就好,如果感興趣可以自己去看看源碼。
0x02.03 網(wǎng)絡運行 ncnn::Extractor
至此,我們將網(wǎng)絡的結構和權重信息都放到了ncnn::Net這個結構中,接下來我們就可以新建網(wǎng)絡提取器 Extractor Net::create_extractor,它給我們提供了設置網(wǎng)絡輸入(Extractor::input),獲取網(wǎng)絡輸出(Extractor::extract),設置網(wǎng)絡運行線程參數(shù)(Extractor::set_num_threads)等接口。接下來,我們只需要調(diào)用Extractor::extract運行網(wǎng)絡(net)的前向傳播函數(shù)net->forward_layer就可以獲得最后的結果了。
另外,ncnn::Extractor還可以設置一個輕模式省內(nèi)存 即set_light_mode(true),原理是net中每個layer都會產(chǎn)生blob,除了最后的結果和多分支中間結果,大部分blob都不值得保留,開啟輕模式可以在運算后自動回收,省下內(nèi)存。但需要注意的是,一旦開啟這個模式,我們就不能獲得中間層的特征值了,因為中間層的內(nèi)存在獲得最終結果之前都被回收掉了。例如:「某網(wǎng)絡結構為 A -> B -> C,在輕模式下,向ncnn索要C結果時,A結果會在運算B時自動回收,而B結果會在運算C時自動回收,最后只保留C結果,后面再需要C結果會直接獲得,滿足大多數(shù)深度網(wǎng)絡的使用方式」。
最后,我們需要明確一下,我們剛才是先創(chuàng)建了ncnn::net,然后我們調(diào)用的ncnn::Extractor作為運算實例,因此運算實例是不受net限制的。換句話說,雖然我們只有一個net,但我們可以開多個ncnn::Extractor,這些實例都是單獨完成特定網(wǎng)絡的推理,互不影響。
這樣我們就大致了解了NCNN的運行流程了,更多的細節(jié)可以關注NCNN源碼。
0x03. NCNN源碼目錄分析
這一節(jié),我們來分析一下NCNN源碼目錄以便更好的理解整個工程。src的目錄結構如下:
/src 目錄:
./src/layer下是所有的layer定義代碼 ./src/layer/arm是arm下的計算加速的layer ./src/layer/x86是x86下的計算加速的layer。 ./src/layer/mips是mips下的計算加速的layer。 ./src/layer/.h + ./src/layer/.cpp 是各種layer的基礎實現(xiàn),無加速。 目錄頂層下是一些基礎代碼,如宏定義,平臺檢測,mat數(shù)據(jù)結構,layer定義,blob定義,net定義等。 http://platform.h.in 平臺檢測 benchmark.h + benchmark.cpp 測試各個模型的執(zhí)行速度 allocator.h + allocator.cpp 內(nèi)存池管理,內(nèi)存對齊 paramdict.h + paramdict.cpp 層參數(shù)解析 讀取二進制格式、字符串格式、密文格式的參數(shù)文件 opencv.h opencv.cpp opencv 風格的數(shù)據(jù)結構 的 mini實現(xiàn),包含大小結構體 Size,矩陣框結構體 Rect_ 交集 并集運算符重載,點結構體 Point_,矩陣結構體 Mat 深拷貝 淺拷貝 獲取指定矩形框中的roi 讀取圖像 寫圖像 雙線性插值算法改變大小等等 mat.h mat.cpp 三維矩陣數(shù)據(jù)結構, 在層間傳播的就是Mat數(shù)據(jù),Blob數(shù)據(jù)是工具人,另外包含 substract_mean_normalize(),去均值并歸一化;half2float(),float16 的 data 轉換成 float32 的 data; copy_make_border(), 矩陣周圍填充; resize_bilinear_image(),雙線性插值等函數(shù)。 net.h net.cpp ncnn框架接口,包含注冊 用戶定義的新層Net::register_custom_layer(); 網(wǎng)絡載入 模型參數(shù) Net::load_param(); 載入 模型權重 Net::load_model(); 網(wǎng)絡blob 輸入 Net::input(); 網(wǎng)絡前向傳播Net::forward_layer();被Extractor::extract() 執(zhí)行;創(chuàng)建網(wǎng)絡模型提取器 Net::create_extractor(); 模型提取器提取某一層輸出Extractor::extract()等函數(shù)。 ...
源碼目錄除了這些還有很多文件,介于篇幅原因就不再枚舉了,感興趣的可以自行查看源碼。由于我只對x86和arm端的指令集加速熟悉一些,所以這里再枚舉一下src/layers下面的NCNN支持的層的目錄:
├── absval.cpp // 絕對值層
├── absval.h
├── argmax.cpp // 最大值層
├── argmax.h
├── arm ============================ arm平臺下的層
│ ├── absval_arm.cpp // 絕對值層
│ ├── absval_arm.h
│ ├── batchnorm_arm.cpp // 批歸一化 去均值除方差
│ ├── batchnorm_arm.h
│ ├── bias_arm.cpp // 偏置
│ ├── bias_arm.h
│ ├── convolution_1x1.h // 1*1 float32 卷積
│ ├── convolution_1x1_int8.h // 1*1 int8 卷積
│ ├── convolution_2x2.h // 2*2 float32 卷積
│ ├── convolution_3x3.h // 3*3 float32 卷積
│ ├── convolution_3x3_int8.h // 3*3 int8 卷積
│ ├── convolution_4x4.h // 4*4 float32 卷積
│ ├── convolution_5x5.h // 5*5 float32 卷積
│ ├── convolution_7x7.h // 7*7 float32 卷積
│ ├── convolution_arm.cpp // 卷積層
│ ├── convolution_arm.h
│ ├── convolutiondepthwise_3x3.h // 3*3 逐通道 float32 卷積
│ ├── convolutiondepthwise_3x3_int8.h // 3*3 逐通道 int8 卷積
│ ├── convolutiondepthwise_arm.cpp // 逐通道卷積
│ ├── convolutiondepthwise_arm.h
│ ├── deconvolution_3x3.h // 3*3 反卷積
│ ├── deconvolution_4x4.h // 4*4 反卷積
│ ├── deconvolution_arm.cpp // 反卷積
│ ├── deconvolution_arm.h
│ ├── deconvolutiondepthwise_arm.cpp // 反逐通道卷積
│ ├── deconvolutiondepthwise_arm.h
│ ├── dequantize_arm.cpp // 反量化
│ ├── dequantize_arm.h
│ ├── eltwise_arm.cpp // 逐元素操作,product(點乘), sum(相加減) 和 max(取大值)
│ ├── eltwise_arm.h
│ ├── innerproduct_arm.cpp // 即 fully_connected (fc)layer, 全連接層
│ ├── innerproduct_arm.h
│ ├── lrn_arm.cpp // Local Response Normalization,即局部響應歸一化層
│ ├── lrn_arm.h
│ ├── neon_mathfun.h // neon 數(shù)學函數(shù)庫
│ ├── pooling_2x2.h // 2*2 池化層
│ ├── pooling_3x3.h // 3*3 池化層
│ ├── pooling_arm.cpp // 池化層
│ ├── pooling_arm.h
│ ├── prelu_arm.cpp // (a*x,x) 前置relu激活層
│ ├── prelu_arm.h
│ ├── quantize_arm.cpp // 量化層
│ ├── quantize_arm.h
│ ├── relu_arm.cpp // relu 層 (0,x)
│ ├── relu_arm.h
│ ├── scale_arm.cpp // BN層后的 平移和縮放層 scale
│ ├── scale_arm.h
│ ├── sigmoid_arm.cpp // sigmod 負指數(shù)倒數(shù)歸一化 激活層 1/(1 + e^(-zi))
│ ├── sigmoid_arm.h
│ ├── softmax_arm.cpp // softmax 指數(shù)求和歸一化 激活層 e^(zi) / sum(e^(zi))
│ └── softmax_arm.h
|
|
|================================ 普通平臺 待優(yōu)化=============
├── batchnorm.cpp // 批歸一化 去均值除方差
├── batchnorm.h
├── bias.cpp // 偏置
├── bias.h
├── binaryop.cpp // 二元操作: add,sub, div, mul,mod等
├── binaryop.h
├── bnll.cpp // binomial normal log likelihood的簡稱 f(x)=log(1 + exp(x)) 激活層
├── bnll.h
├── clip.cpp // 截斷=====
├── clip.h
├── concat.cpp // 通道疊加
├── concat.h
├── convolution.cpp // 普通卷積層
├── convolutiondepthwise.cpp // 逐通道卷積
├── convolutiondepthwise.h
├── convolution.h
├── crop.cpp // 剪裁層
├── crop.h
├── deconvolution.cpp // 反卷積
├── deconvolutiondepthwise.cpp// 反逐通道卷積
├── deconvolutiondepthwise.h
├── deconvolution.h
├── dequantize.cpp // 反量化
├── dequantize.h
├── detectionoutput.cpp // ssd 的檢測輸出層================================
├── detectionoutput.h
├── dropout.cpp // 隨機失活層 在訓練時由于舍棄了一些神經(jīng)元,因此在測試時需要在激勵的結果中乘上因子p進行縮放.
├── dropout.h
├── eltwise.cpp // 逐元素操作, product(點乘), sum(相加減) 和 max(取大值)
├── eltwise.h
├── elu.cpp // 指數(shù)線性單元relu激活層 Prelu : (a*x, x) ----> Erelu : (a*(e^x - 1), x)
├── elu.h
├── embed.cpp // 嵌入層,用在網(wǎng)絡的開始層將你的輸入轉換成向量
├── embed.h
├── expanddims.cpp // 增加維度
├── expanddims.h
├── exp.cpp // 指數(shù)映射
├── exp.h
├── flatten.cpp // 攤平層
├── flatten.h
├── innerproduct.cpp // 全連接層
├── innerproduct.h
├── input.cpp // 數(shù)據(jù)輸入層
├── input.h
├── instancenorm.cpp // 單樣本 標準化 規(guī)范化
├── instancenorm.h
├── interp.cpp // 插值層 上下采樣等
├── interp.h
├── log.cpp // 對數(shù)層
├── log.h
├── lrn.cpp // Local Response Normalization,即局部響應歸一化層
├── lrn.h // 對局部神經(jīng)元的活動創(chuàng)建競爭機制,使得其中響應比較大的值變得相對更大,
| // 并抑制其他反饋較小的神經(jīng)元,增強了模型的泛化能力
├── lstm.cpp
├── lstm.h // lstm 長短詞記憶層
├── memorydata.cpp // 內(nèi)存數(shù)據(jù)層
├── memorydata.h
├── mvn.cpp
├── mvn.h
├── normalize.cpp // 歸一化
├── normalize.h
├── padding.cpp // 填充,警戒線
├── padding.h
├── permute.cpp // ssd 特有層 交換通道順序 [bantch_num, channels, h, w] ---> [bantch_num, h, w, channels]]=========
├── permute.h
├── pooling.cpp // 池化層
├── pooling.h
├── power.cpp // 平移縮放乘方 : (shift + scale * x) ^ power
├── power.h
├── prelu.cpp // Prelu (a*x,x)
├── prelu.h
├── priorbox.cpp // ssd 獨有的層 建議框生成層 L1 loss 擬合============================
├── priorbox.h
├── proposal.cpp // faster rcnn 獨有的層 建議框生成,將rpn網(wǎng)絡的輸出轉換成建議框========
├── proposal.h
├── quantize.cpp // 量化層
├── quantize.h
├── reduction.cpp // 將輸入的特征圖按照給定的維度進行求和或求平均
├── reduction.h
├── relu.cpp // relu 激活層:(0,x)
├── relu.h
├── reorg.cpp // yolov2 獨有的層, 一拆四層,一個大矩陣,下采樣到四個小矩陣=================
├── reorg.h
├── reshape.cpp // 變形層:在不改變數(shù)據(jù)的情況下,改變輸入的維度
├── reshape.h
├── rnn.cpp // rnn 循環(huán)神經(jīng)網(wǎng)絡
├── rnn.h
├── roipooling.cpp // faster Rcnn 獨有的層, ROI池化層:輸入m*n 均勻劃分成 a*b個格子后池化,得到固定長度的特征向量 ==========
├── roipooling.h
├── scale.cpp // bn 層之后的 平移縮放層
├── scale.h
├── shufflechannel.cpp // ShuffleNet 獨有的層,通道打亂,通道混合層=================================
├── shufflechannel.h
├── sigmoid.cpp // 負指數(shù)倒數(shù)歸一化層 1/(1 + e^(-zi))
├── sigmoid.h
├── slice.cpp // concat的反向操作, 通道分開層,適用于多任務網(wǎng)絡
├── slice.h
├── softmax.cpp // 指數(shù)求和歸一化層 e^(zi) / sum(e^(zi))
├── softmax.h
├── split.cpp // 將blob復制幾份,分別給不同的layer,這些上層layer共享這個blob。
├── split.h
├── spp.cpp // 空間金字塔池化層 1+4+16=21 SPP-NET 獨有===================================
├── spp.h
├── squeeze.cpp // squeezeNet獨有層, Fire Module, 一層conv層變成兩層:squeeze層+expand層, 1*1卷積---> 1*1 + 3*3=======
├── squeeze.h
├── tanh.cpp // 雙曲正切激活函數(shù) (e^(zi) - e^(-zi)) / (e^(zi) + e^(-zi))
├── tanh.h
├── threshold.cpp // 閾值函數(shù)層
├── threshold.h
├── tile.cpp // 將blob的某個維度,擴大n倍。比如原來是1234,擴大兩倍變成11223344。
├── tile.h
├── unaryop.cpp // 一元操作: abs, sqrt, exp, sin, cos,conj(共軛)等
├── unaryop.h
|
|==============================x86下特殊的優(yōu)化層=====
├── x86
│ ├── avx_mathfun.h // x86 數(shù)學函數(shù)
│ ├── convolution_1x1.h // 1*1 float32 卷積
│ ├── convolution_1x1_int8.h // 1×1 int8 卷積
│ ├── convolution_3x3.h // 3*3 float32 卷積
│ ├── convolution_3x3_int8.h // 3×3 int8 卷積
│ ├── convolution_5x5.h // 5*5 float32 卷積
│ ├── convolutiondepthwise_3x3.h // 3*3 float32 逐通道卷積
│ ├── convolutiondepthwise_3x3_int8.h // 3*3 int8 逐通道卷積
│ ├── convolutiondepthwise_x86.cpp // 逐通道卷積
│ ├── convolutiondepthwise_x86.h
│ ├── convolution_x86.cpp // 卷積
│ ├── convolution_x86.h
│ └── sse_mathfun.h // sse優(yōu)化 數(shù)學函數(shù)
├── yolodetectionoutput.cpp // yolo-v2 目標檢測輸出層=========================================
└── yolodetectionoutput.h當然還有一些支持的層沒有列舉到,具體以源碼為準。
0x04. NCNN是如何加速的?
之所以要單獨列出這部分,是因為NCNN作為一個前向推理框架,推理速度肯定是尤其重要的。所以這一節(jié)我就來科普一下NCNN為了提升網(wǎng)絡的運行速度做了哪些關鍵優(yōu)化。我們需要明確一點,當代CNN的計算量主要集中在卷積操作上,只要卷積層的速度優(yōu)化到位,那么整個網(wǎng)絡的運行速度就能獲得極大提升。所以,我們這里先以卷積層為例來講講NCNN是如何優(yōu)化的。
在講解之前,先貼出我前面很長一段時間學習的一些優(yōu)化策略和復現(xiàn)相關的文章鏈接,因為這些思路至少一半來自于NCNN,所以先把鏈接匯總在這里,供需要的小伙伴獲取。
一份樸實無華的移動端盒子濾波算法優(yōu)化筆記 基于NCNN的3x3可分離卷積再思考盒子濾波 詳解Im2Col+Pack+Sgemm策略更好的優(yōu)化卷積運算 詳解卷積中的Winograd加速算法 道阻且長_再探矩陣乘法優(yōu)化
NCNN中對卷積的加速過程(以Arm側為例)在我看來有:
無優(yōu)化 即用即取+共用行 Im2Col+GEMM WinoGrad SIMD 內(nèi)聯(lián)匯編 針對特定架構如A53和A55提供更好的指令排布方式,不斷提高硬件利用率
后面又加入了Pack策略,更好的改善訪存,進一步提升速度。
不得不說,NCNN的底層優(yōu)化做得還是比較細致的,所以大家一定要去白嫖 啊。這里列舉的是Arm的優(yōu)化策略,如果是x86或者其它平臺以實際代碼為準。
下面貼一個帶注釋的ARM neon優(yōu)化絕對值層的例子作為結束吧,首先絕對值層的普通C++版本如下:
// 絕對值層特性: 單輸入,單輸出,可直接對輸入進行修改int AbsVal::forward_inplace(Mat& bottom_top_blob, const Option& opt) const{int w = bottom_top_blob.w; // 矩陣寬度int h = bottom_top_blob.h; // 矩陣高度int channels = bottom_top_blob.c;// 通道數(shù)int size = w * h;// 一個通道的元素數(shù)量for (int q=0; q// 每個 通道 {float* ptr = bottom_top_blob.channel(q);// 當前通道數(shù)據(jù)的起始指針for (int i=0; i// 遍歷每個值 {if (ptr[i] < 0)ptr[i] = -ptr[i];// 小于零取相反數(shù),大于零保持原樣// ptr[i] = ptr[i] > 0 ? ptr[i] : -ptr[i];}}return 0;}
ARM neon優(yōu)化版本如下:
// arm 內(nèi)聯(lián)匯編// asm(// 代碼列表// : 輸出運算符列表 "r" 表示同用寄存器 "m" 表示內(nèi)存地址 "I" 立即數(shù)// : 輸入運算符列表 "=r" 修飾符 = 表示只寫,無修飾符表示只讀,+修飾符表示可讀可寫,&修飾符表示只作為輸出// : 被更改資源列表// );// __asm__ __volatile__();// __volatile__或volatile 是可選的,假如用了它,則是向GCC 聲明不答應對該內(nèi)聯(lián)匯編優(yōu)化,// 否則當 使用了優(yōu)化選項(-O)進行編譯時,GCC 將會根據(jù)自己的判定決定是否將這個內(nèi)聯(lián)匯編表達式中的指令優(yōu)化掉。// 換行符和制表符的使用可以使得指令列表看起來變得美觀。int AbsVal_arm::forward_inplace(Mat& bottom_top_blob, const Option& opt) const{int w = bottom_top_blob.w; // 矩陣寬度int h = bottom_top_blob.h; // 矩陣高度int channels = bottom_top_blob.c;// 通道數(shù)int size = w * h;// 一個通道的元素數(shù)量for (int q=0; q{float* ptr = bottom_top_blob.channel(q);int nn = size >> 2; // 128位的寄存器,一次可以操作 4個float,剩余不夠4個的,最后面直接c語言執(zhí)行int remain = size - (nn << 2);// 4*32 =128字節(jié)對其后 剩余的 float32個數(shù), 剩余不夠4個的數(shù)量int remain = size;/*從內(nèi)存中載入:v7:帶了前綴v的就是v7 32bit指令的標志;ld1表示是順序讀取,還可以取ld2就是跳一個讀取,ld3、ld4就是跳3、4個位置讀取,這在RGB分解的時候賊方便;后綴是f32表示單精度浮點,還可以是s32、s16表示有符號的32、16位整型值。這里Q寄存器是用q表示,q5對應d10、d11可以分開單獨訪問(注:v8就沒這么方便了。)大括號里面最多只有兩個Q寄存器。"vld1.f32 {q10}, [%3]! \n""vld1.s16 {q0, q1}, [%2]! \n"v8:ARMV8(64位cpu) NEON寄存器 用 v來表示 v1.8b v2.8h v3.4s v4.2d后綴為8b/16b/4h/8h/2s/4s/2d)大括號內(nèi)最多支持4個V寄存器;"ld1 {v0.4s, v1.4s, v2.4s, v3.4s}, [%2], #64 \n" // 4s表示float32"ld1 {v0.8h, v1.8h}, [%2], #32 \n""ld1 {v0.4h, v1.4h}, [%2], #32 \n" // 4h 表示int16*/// ARMv8-A 是首款64 位架構的ARM 處理器,是移動手機端使用的CPUif (nn > 0){asm volatile("0: \n" // 0: 作為標志,局部標簽"prfm pldl1keep, [%1, #128] \n" // 預取 128個字節(jié) 4*32 = 128"ld1 {v0.4s}, [%1] \n" // 載入 ptr 指針對應的值,連續(xù)4個"fabs v0.4s, v0.4s \n" // ptr 指針對應的值 連續(xù)4個,使用fabs函數(shù) 進行絕對值操作 4s表示浮點數(shù)"subs %w0, %w0, #1 \n" // %0 引用 參數(shù) nn 操作次數(shù)每次 -1 #1表示1"st1 {v0.4s}, [%1], #16 \n" // %1 引用 參數(shù) ptr 指針 向前移動 4*4=16字節(jié)"bne 0b \n" // 如果非0,則向后跳轉到 0標志處執(zhí)行: "=r"(nn), // %0 操作次數(shù)"=r"(ptr) // %1: "0"(nn), // %0 引用 參數(shù) nn"1"(ptr) // %1 引用 參數(shù) ptr: "cc", "memory", "v0" /* 可能變化的部分 memory內(nèi)存可能變化*/);}// 32位 架構處理器=========if (nn > 0){asm volatile("0: \n" // 0: 作為標志,局部標簽"vld1.f32 {d0-d1}, [%1] \n" // 載入 ptr處的值 q0寄存器 = d0 = d1"vabs.f32 q0, q0 \n" // abs 絕對值運算"subs %0, #1 \n" // %0 引用 參數(shù) nn 操作次數(shù)每次 -1 #1表示1"vst1.f32 {d0-d1}, [%1]! \n" // %1 引用 參數(shù) ptr 指針 向前移動 4*4=16字節(jié)"bne 0b \n" // 如果非0,則向后跳轉到 0標志處執(zhí)行: "=r"(nn), // %0"=r"(ptr) // %1: "0"(nn),"1"(ptr): "cc", "memory", "q0" /* 可能變化的部分 memory內(nèi)存可能變化*/);}for (; remain>0; remain--) // 剩余不夠4個的直接c語言執(zhí)行{*ptr = *ptr > 0 ? *ptr : -*ptr;ptr++;}}return 0;}
0x05. 結語
介紹到這里就要結束了,這篇文章只是以我自己的視角看了一遍NCNN,如果有什么錯誤或者筆誤歡迎評論區(qū)指出。在NCNN之后各家廠商紛紛推出了自己的開源前向推理框架,例如MNN,OpenAILab的Tengine,阿里的tengine,曠視的MegEngine,華為Bolt等等,希望各個CVer都能多多支持國產(chǎn)端側推理框架。
0x06. 友情鏈接
https://github.com/Tencent/ncnn https://github.com/MegEngine/MegEngine https://github.com/alibaba/tengine https://github.com/OAID/Tengine https://github.com/alibaba/MNN https://github.com/Ewenwan/MVision
推薦閱讀

