darknet 源碼閱讀(一) - 解析網絡配置文件 cfg

系列目錄

darknet 源碼閱讀(零) - Entry Point
darknet 源碼閱讀(一) - 解析網絡配置文件 cfg
darknet 源碼閱讀(二) - 加載訓練樣本數據
darknet 源碼閱讀(三) - 訓練網絡
darknet 源碼閱讀(番外篇一) - 卷積層


本文的重點是分析 darknet 是如何解析配置文件的. 和 Caffe 不同的是, darknet 並沒有使用類似 protobuf 的配置文件, 而是自己定義了一套解析配置文件參數的方法.

解析配置文件的核心是 parse_network_cfg() 函數, 該函數定義在 darknet/src/parser.c 文件, 博文中的所有文件路徑的根目錄爲 darknet 工程所在目錄.

源碼下載地址: https://github.com/pjreddie/darknet


1. 初識配置文件 .cfg


如果你使用過 darknet 進行訓練或測試一個模型, 那麼在命令行需要用戶指定你使用的網絡配置文件, 配置文件一般以 .cfg 爲後綴. 例如使用 darknet 訓練網絡時, 可以使用以下命令:

./darknet detector train cfg/coco.data cfg/yolov3.cfg darknet53.conv.74 -gpus 0,1,2,3

其中 cfg/yolov3.cfg 就是網絡對應的配置文件, 其內容大概是下面這個樣子:

[net]
# Testing
batch=1
subdivisions=1
width=416
height=416
channels=3
...

learning_rate=0.001

[convolutional]
batch_normalize=1
filters=32
size=3
stride=1
pad=1
activation=leaky
...

[shortcut]
from=-3
activation=linear
...

[yolo]
mask = 0,1,2
anchors = 10,13,  16,30,  33,23,  30,61,  62,45,  59,119,  116,90,  156,198,  373,326
classes=80
...

根據各個參數的作用可以將網絡配置文件中的配置項分爲兩類:

  1. 與訓練直接相關的項, 以 [net] 行開頭的段. 其中包含的參數有: batch_size, width,height,channel,momentum,decay,angle,saturation, exposure,hue,learning_rate,burn_in,max_batches,policy,steps,scales;

  2. 不同類型的層的配置參數. 如 [convolutional], [short_cut], [yolo], [route], [upsample] 層等.

對於"與訓練直接相關的項", 下面就幾個重要的參數進行介紹; 而對於"不同類型的層的配置參數", 後面會有專門的文章介紹.

1. batch, subdivisions

在網絡解析函數中, batch 是做過處理的:

net->batch /= net->subdivisions;

也就是說, batch_size 在 darknet 內部又被均分爲 net->subdivisions 份, 成爲更小的 batch_size. 但是這些小的 batch_size 最終又被彙總, 因此 darknet 中的 batch_size = net->batch / net->subdivisions * net->subdivisions. 至於爲什麼要這麼做, 在後續的文章中會提到. 傳送門: darknet 源碼閱讀(三) - 訓練網絡.

另外, 接下來計算訓練圖片數目的時候是這樣的:

int imgs = net->batch * net->subdivisions * ngpus;

這樣可以保證 imgs 可以被 subdivisions 整除, 因此, 通常建議將這個值設置爲 8 的倍數.

從這裏也可以看出另外一個事實: 在每次迭代過程中每個 GPU 或 CPU 都會訓練 batch * subdivisions 張圖片樣本.

這裏稍微有點跑題, 言歸正傳. 那麼 darknet 中是如何解析並保存這些參數的?


2. 基本數據結構


darknet 使用 C 語言實現, 這就決定了其中大多數保存數據的結構都會選擇鏈表這種簡單高效的數據結構.

爲了解析網絡配置參數, darknet 中定義了三個關鍵的數據結構類型. list 類型變量保存所有的網絡參數, section 類型變量保存的是網絡中每一層的網絡類型和參數, 其中的參數又是使用 list 類型來表示. kvp 鍵值對類型用來保存解析後的參數變量和參數值.

  1. list 類型定義在 darknet/include/darknet.h 文件中, 具體定義如下:
typedef struct node{
    void *val;
    struct node *next;
    struct node *prev;
} node;

typedef struct list{
    int size;     // list 的所有節點個數   
    node *front;  // list 的首節點
    node *back;   // list 的普通節點  
} list;
  1. section 類型定義在 darknet/src/parser.c 文件中, 具體定義如下:
typedef struct{
    char *type;    // section 的類型
    list *options; // section 的參數信息
}section;
  1. kvp - 鍵值對類型定義在 darknet/src/option_list.h 文件中, 具體定義如下:
typedef struct{
    char *key;
    char *val;
    int used;  // the parameter is not used in darknet
} kvp;

在 darknet 的網絡配置文件( 以 .cfg 結尾) 中, 以 ‘[’ 開頭的行被稱爲一個: section(段);

所有的網絡配置參數保存在 list 類型變量中, list 中有很多的 sections 節點, 每個 section 中又有一個保存層參數的小 list, 整體上呈現出一種大鏈掛小鏈的結構: 大鏈中的節點爲每個 section, 各個 section 包含的參數保存在小鏈中, 小鏈的節點值的數據類型爲 kvp 鍵值對.

在數據結構中, 爲了防止鏈表頭丟失, 我們通常的做法是直接使用一個 node 指針來表示鏈表頭. 但是在 darknet 中單獨把 node 類型又進行了一次封裝 - list 類型.

list 中的 front 表示鏈表首元素, front 成員只會在空鏈表插入第一個元素時進行賦值, 之後每次插入都只是操作 back 成員.


2. 解析並保存網絡參數到鏈表中


讀取配置文件由 read_cfg() 函數實現, 該函數的定義在 darknet/src/parser.c 文件中:

/**
 * 讀取神經網絡結構配置文件(.cfg文件)中的配置數據, 將每個神經網絡層參數讀取到每個
 * section 結構體 (每個 section 是 sections 的一個節點) 中, 而後全部插入到
 * list 結構體 sections 中並返回
 * 
 * \param: filename    C 風格字符數組, 神經網絡結構配置文件路徑
 * 
 * \return: list 結構體指針,包含從神經網絡結構配置文件中讀入的所有神經網絡層的參數
 */
list *read_cfg(char *filename)
{
    FILE *file = fopen(filename, "r");
    if(file == 0) file_error(filename);

    // 一個 section 表示配置文件中的一個字段,也就是網絡結構中的一層
    // 因此,一個 section 將讀取並存儲某一層的參數以及該層的 type
    char *line;
    int nu = 0;                   // 當前讀取行記號
    list *options = make_list();  // options 包含所有的神經網絡層參數
    section *current = 0;         // 當前讀取到的某一層 

    while((line= fgetl(file)) != 0){
        ++ nu;
        strip(line);  // 去除讀入行中含有的空格符
        switch(line[0]){
            // 以 '[' 開頭的行是一個新的 section , 其內容是層的 type 
            // 比如 [net], [maxpool], [convolutional] ...
            case '[':
                current = malloc(sizeof(section));
                list_insert(options, current);
                current->options = make_list();
                current->type = line;
                break;
            case '\0':  // 空行
            case '#':   // 註釋
            case ';':   // 空行
                free(line);  // 對於上述三種情況直接釋放內存即可
                break;
            // 剩下的才真正是網絡結構的數據,調用 read_option() 函數讀取
            // 返回 0 說明文件中的數據格式有問題,將會提示錯誤
            default:
                if(!read_option(line, current->options)){
                    fprintf(stderr, "Config file error line %d, could \
                        parse: %s\n", nu, line);
                    free(line);
                }
                break;
        }
    }
    fclose(file);
    return options;
}

每個 section 的所在行的開頭是 ‘[’ , ‘\0’ , ‘#’ 和 ‘;’ 符號開頭的行爲無效行, 除此之外的行爲 section 對應的參數行. 每一行都是一個等式, 類似鍵值對的形式.

可以看到, 如果某一行開頭是符號 ‘[’ , 說明讀到了一個新的 section: current, 然後第 30 行list_insert(options, current);` 將該新的 section 保存起來.

在讀取到下一個開頭符號爲 ‘[’ 的行之前的所有行都是該 section 的參數, 在第 42 行 read_option(line, current->options) 將讀取到的參數保存在 current 變量的 options 中. 注意, 這裏保存在 options 節點中的數據爲 kvp 鍵值對類型.

當然對於 kvp 類型的參數, 需要先將每一行中對應的鍵和值(用 ‘=’ 分割) 分離出來, 然後再構造一個 kvp 類型的變量作爲節點元素的數據.

這裏保存讀取到的參數使用的是 list_insert() 函數, 接下來分析這個函數.


3. 鏈表的插入操作


保存 section 和每個參數組成的鍵值對時使用的是 list_insert() 函數, 前面提到了參數保存的結構其實是大鏈( 節點爲 section )上邊串着很多小鏈( 每個 section 節點的各個參數).

list_insert() 函數實現了鏈表插入操作. 該函數定義在 darknet/src/list.c 文件中:

/*
 * \brief: 將 val 指針插入 list 結構體 l 中,這裏相當於是用 C 實現了 C++ 中的 
 *         list 的元素插入功能
 * 
 * \prama: l    鏈表指針
 *         val  鏈表節點的元素值
 * 
 * 流程: list 中保存的是 node 指針. 因此,需要用 node 結構體將 val 包裹起來後纔可以
 *       插入 list 指針 l 中
 * 
 * 注意: 此函數類似 C++ 的 insert() 插入方式;
 *      而 opion_insert() 函數類似 C++ map 的按值插入方式,比如 map[key]= value
 *      
 *      兩個函數操作對象都是 list 變量, 只是操作方式略有不同。
*/
void list_insert(list *l, void *val)
{
    node *new = malloc(sizeof(node));
    new->val = val;
    new->next = 0;

    // 如果 list 的 back 成員爲空(初始化爲 0), 說明 l 到目前爲止,還沒有存入數據  
    // 另外, 令 l 的 front 爲 new (此後 front 將不會再變,除非刪除) 
    if(!l->back){
        l->front = new;
        new->prev = 0;
    }else{
        l->back->next = new;
        new->prev = l->back;
    }
    l->back = new;

    ++l->size;
}

可以看到, 插入的數據都會被重新包裝在一個新的 node : 變量 new 中, 然後再將這個節點插入到鏈表 l 中.

網絡結構解析到鏈表中後還不能直接使用, 如果僅僅想使用某一個參數而不得不每次都遍歷整個鏈表, 這樣就會導致程序效率變低, 最好的辦法是將其保存到一個結構體變量中, 使用的時候按照成員進行訪問.


4. 將鏈表中的網絡參數保存到 network 結構體


注意區分"網絡參數"和"訓練得到的權值參數", 這是兩個不同的概念.

“網絡參數” 指的是描述網絡結構的參數, 並沒有構建實際的網絡;
“訓練得到的權值參數” 指的是已構建好的網絡中各層的所有節點的權重值集合.

  1. 首先看一下 network 結構體的定義, 在文件 darknet/include/darknet.h 中:
typedef struct network{
    int n;                      // 網絡的層數, 調用 make_network(int n) 時賦值
    int batch;                  // 一批訓練中的圖片張數, 和 subdivisions 參數相關
    size_t *seen;               // 目前已經讀入的圖片張數(網絡已經處理的圖片張數)
    int *t;              
    float epoch;                // 到目前爲止訓練了整個數據集的次數
    int subdivisions;           // TODO
    layer *layers;              // 存儲網絡中的所有層  
    float *output;  
    learning_rate_policy policy;// 學習率下降策略: TODO

    // 梯度下降法相關參數  
    float learning_rate;
    float momentum;
    float decay;
    float gamma;
    float scale;
    float power;
    int time_steps;
    int step;
    int max_batches;
    float *scales;
    int   *steps;
    int num_steps;
    int burn_in;

    int adam;
    float B1;
    float B2;
    float eps;

    int inputs;   // 輸入層節點個數 
    int outputs;  // 輸出層節點個數
    int truths;
    int notruth;
    int h, w, c;
    int max_crop;
    int min_crop;
    float max_ratio;
    float min_ratio;
    int center;
    float angle;
    float aspect;
    float exposure;
    float saturation;
    float hue;
    int random;

    // darknet 爲每個 GPU 維護一個相同的 network, 每個 network 以 gpu_index 區分
    int gpu_index; 
    tree *hierarchy;

    // 中間變量,用來暫存某層網絡的輸入(包含一個 batch 的輸入,比如某層網絡完成前向,將其輸出賦給該變量,作爲下一層的輸入,可以參看 network.c 中的forward_network() 與 backward_network() 兩個函數 )
    float *input;  
    // 中間變量,與上面的 input 對應,用來暫存 input 數據對應的標籤數據(真實數據)
    float *truth;
    // 中間變量,用來暫存某層網絡的敏感度圖(反向傳播處理當前層時,用來存儲上一層的敏感度圖,因爲當前層會計算部分上一層的敏感度圖,可以參看 network.c 中的 backward_network() 函數) 
    float *delta;
    // 網絡的工作空間, 指的是所有層中佔用運算空間最大的那個層的 workspace_size, 
    // 因爲實際上在 GPU 或 CPU 中某個時刻只有一個層在做前向或反向運算
    float *workspace;
    // 網絡是否處於訓練階段的標誌參數,如果是則值爲1. 這個參數一般用於訓練與測試階段有不
    // 同操作的情況,比如 dropout 層,在訓練階段才需要進行 forward_dropout_layer()
    // 函數, 測試階段則不需要進入到該函數.
    int train;
    int index; // 標誌參數,當前網絡的活躍層 
    float *cost;
    float clip;

#ifdef GPU
    float *input_gpu;
    float *truth_gpu;
    float *delta_gpu;
    float *output_gpu;
#endif

} network;

其中, 重要的成員變量都給出了註釋.

  1. 爲網絡結構體分配內存空間, 函數定義在 darknet/src/network.c 文件中.
network *make_network(int n)
{
    network *net = calloc(1, sizeof(network));
    net->n = n;
    net->layers = calloc(net->n, sizeof(layer));
    net->seen = calloc(1, sizeof(size_t));
    net->t    = calloc(1, sizeof(int));
    net->cost = calloc(1, sizeof(float));
    
    return net;
}

從 net 變量開始, 依次爲其中的指針變量分配內存. 由於第一個段 “[net]” 中存放的是和網絡並不直接相關的配置參數, 因此網絡中層的數目爲 sections->size - 1, 即:

network *net = make_network(sections->size - 1);
  1. 將鏈表中的網絡參數解析後保存到 network 結構體

第一節中提到: “根據各個參數的作用可以將網絡配置文件中的配置項分爲兩類”, 因此需要對這兩類配置參數分別處理.

配置文件的第一個段一定是"[net]" 段, 該段的參數解析由 parse_net_options() 函數完成, 函數定義在 darknet/src/parser.c 中.

之後的各段都是網絡中的層, 比如, 用來完成特定的特徵提取的卷積層, 用來降低訓練誤差的 shortcut 層和用來防止過擬合的 dropout 層等.

這些層都有特定的解析函數: 比如 parse_convolutional(), parse_shortcut() 和 parse_dropout() 等( 定義在 darknet/src/parser.c 中). 關於解析卷積層的詳細過程可參考這篇博客: darknet 源碼閱讀(番外篇一) - 卷積層. 每個解析函數返回一個填充好的層 l, 將這些層全部添加到 network 結構體的 layers 數組中. 即:

net->layers[count] = l;   // save the layer to nerwork  

另外需要注意的是:

if (l.workspace_size > workspace_size) 
    workspace_size = l.workspace_size;  

workspace_size 表示網絡的工作空間, 指的是所有層中佔用運算空間最大的那個層的 workspace_size. 因爲實際上在 GPU 或 CPU 中某個時刻只有一個層在做前向或反向運算.

注意: 填充層的參數時, 上一層的輸入會作爲下一層的輸出.

最後的收尾工作: 輸出層只能在網絡搭建完畢之後才能確定, 輸入層需要考慮 batch_size 的因素. truth 和輸入層的維度相同, 也就是 label 信息.

{
    ...
    layer out = get_network_output_layer(net);  // 通常情況下都是最後一層
    net->outputs = out.outputs;
    net->truths = out.outputs;   // 默認值
    if(net->layers[net->n-1].truths) 
        net->truths = net->layers[net->n-1].truths;
    net->output = out.output;
    net->input = calloc(net->inputs*net->batch, sizeof(float));
    net->truth = calloc(net->truths*net->batch, sizeof(float));
    ...
}

上面提到的涉及到網絡層的部分, 均會提供 GPU 版本, 其實最重要的也就下面這 4 個變量:

{
	...
#ifdef GPU
    net->output_gpu = out.output_gpu;
    net->input_gpu = cuda_make_array(net->input, net->inputs*net->batch);
    net->truth_gpu = cuda_make_array(net->truth, net->truths*net->batch);
#endif

    if(workspace_size){
#ifdef GPU
        if(gpu_index >= 0){
            net->workspace = cuda_make_array(0, (workspace_size-1)/sizeof(float)+1);
        }else {
            net->workspace = calloc(1, workspace_size);
        }
#else
        net->workspace = calloc(1, workspace_size);
#endif
    ...
}

至此, 網絡的解析結束, parse_network_cfg() 函數( 定義在 darknet/src/parser.c 中) 返回解析好的 network 類型的指針變量. 這個指針變量會伴隨訓練的整個過程, 因此一旦程序達到最大訓練次數而需要退出訓練時, 保存完最後一個模型後應該調用 free_network() 釋放內存, 然後程序結束.

for(i = 0; i < ngpus; ++i){
    free_network(nets[i]);
}

但是 darknet 原生態代碼中並沒有顯式調用 free_network() 釋放內存.

總結

可能有的人會問, 既然如此, 爲什麼不直接將配置文件讀取並解析到 network 結構體變量中, 而要使用一箇中間數據結構來緩存讀取到的文件呢?

如果不使用中間數據結構來緩存. 將讀取和解析流程串行進行的話, 如果配置文件較爲複雜, 就會長時間使文件處於打開狀態. 如果此時用戶更改了配置文件中的一些條目, 就會導致讀取和解析過程出現問題.

分開兩步進行可以先快速讀取文件信息到內存中組織好的結構中, 這時就可以關閉文件. 然後再慢慢的解析參數.

這種機制類似於操作系統中斷的底半部機制, 先處理重要的中斷信號, 然後在系統負荷較小時再處理中斷信號中攜帶的任務.

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