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

歡迎關(guān)注GiantPandaCV, 在這里你將看到獨(dú)家的深度學(xué)習(xí)分享,堅(jiān)持原創(chuàng),每天分享我們學(xué)習(xí)到的新鮮知識(shí)。( ? ?ω?? )?
有對(duì)文章相關(guān)的問(wèn)題,或者想要加入交流群,歡迎添加BBuf微信:
為了方便讀者獲取資料以及我們公眾號(hào)的作者發(fā)布一些Github工程的更新,我們成立了一個(gè)QQ群,二維碼如下,感興趣可以加入。
