NCNN 模型推理詳解及實戰

嵌入式視覺發表於2023-03-28

一,依賴庫知識速學

aarch64

aarch64,也被稱為 ARM64,是一種基於 ARMv8-A 架構的 64 位指令集體系結構。它是 ARM 體系結構的最新版本,旨在提供更好的效能和能效比。與先前的 32ARM 架構相比,aarch64 具有更大的定址空間、更多的暫存器和更好的浮點效能。

在 Linux 系統終端下輸入以下命令,檢視 cpu 架構。

uname -m # 我的英特爾伺服器輸出 x86_64,m1 pro 蘋果電腦輸出 arm64

OpenMP

OpenMP(Open Multi-Processing)是一種基於共享記憶體的並行程式設計 API,用於編寫多執行緒並行程式。使用 OpenMP,程式設計師可以透過在程式中插入指令來指示程式中的並行性。這些指令是以 #pragma 開頭的編譯指示符,告訴編譯器如何並行化程式碼。

#include <stdio.h>
#include <omp.h>

int main() {
    int i;
    #pragma omp parallel for
    for(i = 0; i < 10; i++) {
        printf("Thread %d executing iteration %d\n", omp_get_thread_num(), i);
    }
    return 0;
}

AVX512

AVX 全稱是 Advanced Vector Extension,高階向量擴充套件,用於處理 N 維資料的,例如 8 維及以下的 64 位雙精度浮點向量或 16 維及以下的單精度浮點向量。

AVX512SIMD 指令(單指令多資料),x86 架構上最早的 SIMD 指令是 128bit 的 SSE,然後是 256bit 的 AVX/AVX2,最後是現在 512bit 的 AVX512。

submodule

github submodule(子模組)允許你將一個 Git 倉庫作為另一個 Git 倉庫的子目錄。 它能讓你將另一個倉庫克隆到自己的專案中,同時還保持提交的獨立。

apt upgrade

  • apt update:只檢查,不更新(已安裝的軟體包是否有可用的更新,給出彙總報告)。
  • apt upgrade:更新已安裝的軟體包。

二,硬體基礎知識速學

2.1,記憶體

RAM(隨機訪問儲存)的一些關鍵特性是頻寬(bandwidth)和延遲(latency)。

2.2,CPU

中央處理器(central processing unit,CPU)是任何計算機的核心,其由許多關鍵元件組成:

  • 處理器核心 (processor cores): 用於執行機器程式碼的。
  • 匯流排(bus): 用於連線不同元件(注意,匯流排會因為處理器型號、 各代產品和供應商之間的特定拓撲結構有明顯不同)
  • 快取(cache): 一般是三級緩(L1/L2/L3 cache),相比主記憶體實現更高的讀取頻寬和更低的延遲記憶體訪問。

現代 CPU 都包含向量處理單元,都提供了 SIMD 指令,可以在單個指令中同時處理多個資料,從而支援高效能線性代數和卷積運算。這些 SIMD 指令有不同的名稱: 在 ARM 上叫做 NEON,在 x86 上被稱 為AVX2156。

一個典型的 Intel Skylake 消費級四核 CPU,其核心架構如下圖所示。

cpu 核心架構

三,ncnn 推理模型

3.1,shufflenetv2 模型推理解析

這裡以分類網路 shufflenetv2 為例,分析如何使用 ncnn 框架模型推理。先原始碼在 ncnn/examples/shufflenetv2.cpp檔案中,程式主要分為兩個函式,分別是 detect_shufflenetv2()print_topk()。前者用於執行圖片分類網路,後者用於輸出前 N 個分類結果。程式碼流程總結如下:

  1. detect_shufflenetv2 函式中,主要使用了 ncnn::Net 類進行模型載入和推理,主要流程如下:

    • 載入模型引數和模型二進位制檔案。
    • 將輸入圖片 cv::Mat 格式轉換為 ncnn::Mat 格式,同時進行 resize 和歸一化操作。
    • 建立 ncnn::Extractor 物件,並設定輸入和輸出。
    • 進行推理計算,得到分類輸出結果。
    • 對輸出結果進行 softmax 操作。
    • 將輸出結果轉換為 vector 型別的資料,儲存到 cls_scores 中。
  2. 呼叫 print_topk 函式輸出 cls_scores 的前 topk 個類別及其得分,具體實現步驟如下:

    • 定義一個向量 std::vector<std::pair<float, int>> vec,其元素型別為 <float, int>,其中第一個元素為分類得分,第二個元素為該分類的索引。
    • 遍歷分類模型輸出結果 cls_scores,將其與索引值組成一個 <float, int> 型別的元素,放入向量 vec 中。
    • 使用 std::partial_sort() 函式,將向量 vec 進行部分排序,按照得分從大到小的順序排列。
    • 遍歷排好序的向量 vec,輸出前 topk 個元素的索引和得分值。
  3. 最後主函式 main 中先呼叫 cv::imread 函式完成影像的讀取操作,而後呼叫 detect_shufflenetv2print_topk 函式,完成 shufflenetv2 網路推理和圖片分類結果機率值輸出的操作。

print_topk 函式程式碼及其註釋如下:

// 定義函式,輸入為一個向量 cls_scores 和需要輸出的 topk 數量
static int print_topk(const std::vector<float>& cls_scores, int topk)
{
    // 1,定義一個向量 vec,其元素型別為 <float, int>,用於儲存分類得分和索引值
    int size = cls_scores.size();
    std::vector<std::pair<float, int> > vec;
    vec.resize(size);

    // 2,遍歷分類得分,將其與索引值組成 <float, int> 元素,並存入向量 vec 中
    for (int i = 0; i < size; i++)
    {
        vec[i] = std::make_pair(cls_scores[i], i);
    }

    // 3,使用 std::partial_sort() 函式,將向量 vec 進行部分排序,按照得分從大到小的順序排列
    std::partial_sort(vec.begin(), vec.begin() + topk, vec.end(),
                      std::greater<std::pair<float, int> >());

    // 4,遍歷排好序的向量 vec,輸出前 topk 個元素的索引和得分值
    for (int i = 0; i < topk; i++)
    {
        float score = vec[i].first;
        int index = vec[i].second;
        fprintf(stderr, "%d = %f\n", index, score);
    }

    return 0;
}

值得注意的是,雖然呼叫 print_topk 函式得到了最高得分及其類別索引,但還需要將類別索引轉換為類別字串。這通常需要預先定義一個包含所有類別字串的向量 class_names,並將其與類別索引一一對應。另外, class_names 的定義需與模型訓練時的類別標籤一致,否則會出現類別不匹配的情況。

最後,實際跑下 sample 看下執行結果,這裡模型用的是 imagenet 訓練的 shufflenetv2 模型,然後用編譯好的 shufflenetv2 程式去跑測試圖片,輸入圖片和程式執行結果如下:

dog

/ncnn/build/examples# ./shufflenetv2 demo.jpeg
270 = 0.455700
279 = 0.303561
174 = 0.057936

輸入影像的類別索引是 270,參考文章ImageNet 2012 1000分類名稱和編號,可知該類別是 dog(狗)。

3.2,網路推理過程解析

下面再看下網路推理程式碼的整體流程解析:

1,首先需要 Net 物件,然後使用 load_paramload_bin 兩個介面載入模型結構引數和模型權重引數檔案:

// 為了方便閱讀,和官方程式碼比有所刪減
ncnn::Net shufflenetv2;
shufflenetv2.load_param("shufflenet_v2_x0.5.param")
shufflenetv2.load_model("shufflenet_v2_x0.5.bin")

2,定義好 Net 物件後,可以呼叫相應的 create_extractor 介面建立 Extractor,Extractor 物件是完成影像資料輸入和模型推理的類,雖然它也是對 Net 的相關介面做了封裝。

ncnn::Extractor ex = shufflenetv2.create_extractor();
ex.input("data", in);
ncnn::Mat out;
ex.extract("fc", out); // 提取網路輸出結果到 out 矩陣中

3,模型推理結果後處理,對網路推理結果執行 softmax 操作得到機率矩陣,而後轉換為 vector 型別的資料。

// 對輸出結果矩陣進行 softmax 操作
// manually call softmax on the fc output
// convert result into probability
// skip if your model already has softmax operation
{
    ncnn::Layer* softmax = ncnn::create_layer("Softmax");

    ncnn::ParamDict pd;
    softmax->load_param(pd);

    softmax->forward_inplace(out, shufflenetv2.opt);

    delete softmax;
}

// 將softmax輸出結果轉換為 vector<float> 型別的資料,儲存到 cls_scores 中
out = out.reshape(out.w * out.h * out.c);

cls_scores.resize(out.w);
for (int j = 0; j < out.w; j++)
{
    cls_scores[j] = out[j];
}

這裡之所以需要手動呼叫 softmax 層,是因為官方提供的 shufflenetv2 模型結構檔案的最後一層是 fc 層,沒有 softmax 層。

shufflenetv2_param

值得注意的是,ncnn::Mat 型別預設採用的是 NCHW (通道在前,即 Number-Channel-Height-Width)的格式。在常見的分類任務中,ncnn 網路輸出的一般是一個大小為 [1, 1, num_classes] 的張量,其中第三個維度的大小為類別數,上述程式碼即 out.w 表示類別數量,而 out.h 和 out.c 都為 1。

3.3,模型推理過程總結

1,模型推理過程可總結為下述步驟:

  1. 輸入資料準備:輸入資料可以是影像、文字或其他形式的資料。在ncnn中,輸入資料通常被轉化為多維張量,其中第一維是資料的數量,其餘維度表示資料的形狀和尺寸。
  2. 載入模型引數和模型權重檔案:透過 Net 類的 load_paramload_bin 兩個介面實現。
  3. 模型前向計算:從模型的輸入層開始,逐層計算模型的輸出。每個層接收上一層的輸出作為輸入,並執行特定的運算元,比如:卷積、池化、全連線等。在逐層計算過程中,模型各層的引數和權重資料也被用於更新模型的輸出。最終,模型的輸出被傳遞到模型的輸出層。
  4. 輸出資料解析:模型的輸出資料通常被轉化為外部應用程式可用的格式。例如,在影像分類任務中,模型的輸出可以是一個機率向量,表示輸入影像屬於每個類別的機率分佈。在ncnn中,輸出資料可以轉化為多維張量或其他形式的資料。

2,ncnn 載入/解析模型引數和權重檔案的步驟還是很複雜的,可總結如下:

  1. 讀取二進位制引數和權重檔案,並儲存為位元組陣列。
  2. 解析位元組陣列中的頭部資訊,包括檔案版本號、模型結構資訊等。
  3. 解析層級資訊,包括每個層的名稱、型別、輸入輸出維度等資訊,並儲存在 blobs 中,Blob 類由:網路層 name、依賴層索引:producer 和 consumer,及上一層和下一網路層索引、網路層 shape 組成。
  4. 解析每個層的引數和權重資料,將其儲存為矩陣或向量。

參考資料

  1. Git submodule使用指南(一)

相關文章