Sytuacja kobiet w IT w 2024 roku
27.03.20208 min
Amirul Abdullah

Amirul AbdullahHPC Software Developer & ConsultantGBG Plc

Dostarczanie modelu TensorFlow jako pliku wykonywalnego C/C++

Oto coś dla upartych i lubiących wyzwania: sprawdź, jak można poradzić sobie z użyciem modelu wytrenowanego w Tensorflow w C/C++ i czy warto to w ogóle robić w taki sposób.

Dostarczanie modelu TensorFlow jako pliku wykonywalnego C/C++

Jak zrobić, żeby model wytrenowany w Pythonie dostarczyć do uruchomienia na kliencie jako kod C/C++, który nie wymaga środowiska Pythona i przy okazji opakować wszystko w binarki?


Rozwiązaniem jest tutaj użycie API Tensorflow w C lub C++. W tym artykule przyjrzymy się jedynie, jak używać API C (nie C++/tensorflowlite), który działa tylko w procesorze. Oto środowisko, którego będę używać w całym artykule:

  • System operacyjny: LInux (przetestowany na nowym Ubuntu 19.10/OpenSuse Tumbleweed)
  • Najnowsze GCC
  • Tensorflow z Githuba (master branch 2.1)
  • Brak GPU


Chciałbym również podziękować Vladowi Dovgalecsowi i jego artykułowi, ponieważ jego samouczek bardzo mi pomógł. Sprawdź moje repozytorium, aby zobaczyć cały kod.

Struktura tutorialu

To będzie długi artykuł: oto, co zrobimy, krok po kroku:

  1. Sklonujemy kod źródłowy Tensorflow i skompilujemy go, aby otrzymać nagłówki/binarki API C.
  2. Zbudujemy najprostszy model przy użyciu Pythona oraz Tensorflow i wyeksportujemy go do modelu tf, który API C będzie w stanie odczytać.
  3. Stworzymy prosty kod w C i skompilujemy go gcc oraz uruchomimy jak normalny plik wykonywalny. 


Zaczynajmy!

Startujemy z API Tensorflow C

O ile mi wiadomo, istnieją 2 sposoby na otrzymanie nagłówka API w C.

  • Pobranie wstępnie skompilowanego API Tensorflow w C ze strony internetowej (zwykle nie ma tam aktualnych plików binarnych) LUB
  • Sklonowanie i skompilowanie z kodu źródłowego (długi proces, ale jeśli coś nie działa, jest opcja na debugowanie problemu)


Pokażę więc, jak skompilować kod i korzystać z jego plików binarnych.

Krok pierwszy: sklonuj projekty API

Utwórz folder i sklonuj projekt

$ git clone https://github.com/tensorflow/tensorflow.git


Krok drugi: zainstaluj Bazel i Numpy

Do kompilacji potrzebny Ci będzie Bazel. Zainstaluj go w swoim środowisku.

Ubuntu:

$ sudo apt update && sudo apt install bazel-1.2.1


OpenSuse:

$ sudo zypper install bazel


Bez względu na to, jakiej platformy używasz, upewnij się, że wersja Bazel to 1.2.1, ponieważ właśnie tego używa Tensorflow 2.1. Może się to zmienić w przyszłości.

Następnie musimy zainstalować pakiet Numpy Pythona (dlaczego w ogóle potrzebujemy pakietu Pythona, aby zbudować API C??). Sposób zainstalowania pakietu nie jest istotny, ważne, żeby można było podać referencję do niego w czasie kompilacji. Ja jednak wolę zainstalować go za pośrednictwem Minicondy i mieć osobne środowisko wirtualne dla kompilacji. Oto, jak to zrobię:

Instalacja Minicondy:

$ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh 
$ sudo chmod 777 Miniconda3-latest-Linux-x86_64.sh
$ ./Miniconda3-latest-Linux-x86_64.sh


Tworzenie nowego środowiska z Numpy o nazwie tf-build:

$ conda create -n tf-build python=3.7 numpy


Użyjemy tego środowiska później.

Krok trzeci: zastosuj patch w kodzie źródłowym (WAŻNE!)

Kod źródłowy Tensorflow 2.1 zawiera błąd, który powoduje, że kompilacja nie powiedzie się. Sprawdź ten problem tutaj. Poprawka polega na zastosowaniu patcha, który jest w tym miejscu - do repozytorium dołączyłem plik, który można wykorzystać jako patch.

$ git apply p.patch


Być może w przyszłości nie będziemy się musieli tym martwić.

Krok czwarty: skompiluj kod

Zrób to przy pomocy tej dokumentacji oraz Readme z Githuba. Oto, jak skompilować ten kod. Najpierw musimy aktywować nasze conda env

$ conda activate tf-build 
$ bazel test -c opt tensorflow/tools/lib_package:libtensorflow_test
$ bazel build -c opt tensorflow/tools/lib_package:libtensorflow_test


Ponownie Cię ostrzegam: kompilacja w VM z Ubuntu na 6 rdzeniach zajmuje 2 godziny. Na słabszych maszynach może to zająć wieczność. Ale mam dla Was radę: odpalcie to na serwerze z mocnym procesorem i odpowiednią ilością RAM-u. 

Skopiujcie plik, który znajduje się w bazel-bin/tensorflow/tools/lib_package/libtensorflow.tar.gz i wklejcie to wybranego folderu. Rozpakujcie go w następujący sposób:

$ tar -C /usr/local -xzf libtensorflow.tar.gz


Ja rozpakowuję go w katalogu domowym zamiast w /usr/local, jak próbowałem wcześniej.

Prosty model w Pythonie

Tutaj stworzymy i zapiszemy model za pomocą klasy tf.keras.layers, abyśmy mogli go później załadować za pomocą API C. W tym celu potrzebujemy pythonowego TensorFlow do wygenerowania modelu. Zapoznajcie się z pełnym kodem na model.py w tym repozytorium.

Krok pierwszy: zainstaluj TensorFlow w conda

Będziemy również musieli stworzyć oddzielne środowisko conda. 

$ conda create -n tf python=3.7 tensorflow


Krok drugi: napisz model

Oto prosty model, w którym znajduje się niestandardowy tf.keras.layers.Model z pojedynczą warstwą dense. Jest on inicjowany za pomocą jedynek. Wynik tego modelu (z def call ()) będzie zatem podobny do tego, co dostarczyliśmy na wejście.

import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

class testModel(tf.keras.Model):
  def __init__(self):
    super(testModel, self).__init__()
    self.dense1 = tf.keras.layers.Dense(1, kernel_initializer='Ones', activation=tf.nn.relu)
  def call(self, inputs):
    return self.dense1(inputs)

input_data = np.asarray([[10]])
module = testModel()
module._set_inputs(input_data)
print(module(input_data))

# Export the model to a SavedModel
module.save('model', save_format='tf')

Prosty model z tensorflow

Od momentu wydania Tensorflow 2.0, Eager execution pozwala nam uruchomić model i przejść przez sesję bez budowania grafu. Jednak, aby zapisać model (przejdź do module.save ('model', save_format = 'tf')), trzeba wygenerować graf. Będziemy zatem musieli wywołać model co najmniej raz, aby utworzyć graf, a wywołanie print(module(input_data)) zmusi go do utworzenia grafu.

Następnie uruchom kod:

$ conda activate tf
$ python model.py


Otrzymasz:

tf.Tensor([[10.]], shape=(1, 1), dtype=float32)


Powinniście również zobaczyć folder o nazwie model

Krok trzeci: zweryfikuj zapisany model

Gdy zapiszemy model, utworzy on w sobie folder i kilka plików. Zasadniczo przechowuje on wagi i grafy modelu. Tensorflow ma narzędzie do przemieszczania się po tych plikach, abyśmy mogli dopasować input tensora do outputu tensora. Narzędzie to nazywa się save_model_cli i jest dostarczane razem z Tensorflow.

Musielibyśmy wyodrębnić nazwę grafu dla tensora wejściowego i tensora wyjściowego i użyć tych informacji podczas późniejszego wywoływania API C. Oto, jak to zrobimy:

$ saved_model_cli show --dir <path_to_saved_model_folder>


Przez uruchomienie i zastąpienie tego odpowiednią ścieżką, powinniście otrzymać taki wynik:

The given SavedModel contains the following tag-sets: 
serve


Użyjemy tag-set aby jeszcze bardziej zagłębić się w graf. Oto, jak to zrobimy:

$ saved_model_cli show --dir <path_to_saved_model_folder> --tag_set serve


Output powinien być taki:

The given SavedModel MetaGraphDef contains SignatureDefs with the following keys:
SignatureDef key: "__saved_model_init_op"
SignatureDef key: "serving_default"


Używamy klucza serving _default w poleceniu, aby wydrukować węzeł tensora:

$ saved_model_cli show --dir <path_to_saved_model_folder> --tag_set serve --signature_def serving_default


Wynik powinien być taki:

The given SavedModel SignatureDef contains the following input(s):
inputs['input_1'] tensor_info:
dtype: DT_INT64
shape: (-1, 1)
name: serving_default_input_1:0
The given SavedModel SignatureDef contains the following output(s):
outputs['output_1'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 1)
name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

Będziemy później potrzebować serving_default_input_1 oraz StatefulPartitionedCall, aby użyć ich w API C.

Tworzenie kodu C/C++

Trzecia część polega na napisaniu kodu C, który używa API C Tensorflow i importuje zapisany model Pythona. Tutaj znajdziecie pełny kod.

Nie istnieje właściwa dokumentacja API C, więc jeśli coś pójdzie nie tak, najlepiej będzie spojrzeć na nagłówek w kodzie źródłowym (możesz również debugować przy użyciu GDB i krok po kroku odkrywać, jak wszystko działa).

Krok pierwszy: napisz kod w C

Zaimportuj API C Tensorflow w pustym pliku cpp:

#include <stdlib.h>
#include <stdio.h>
#include "tensorflow/c/c_api.h"

void NoOpDeallocator(void* data, size_t a, void* b) {}
int main()
{
  
}

Pusty kod podstawowy w C

Zauważ, że zadeklarowaliśmy pustą funkcję NoOpDellocator, będziemy jej mogli później użyć. 

Następnie musimy załadować zapisany model oraz sesję przy użyciu API TF_LoadSessionFromSavedModel.

    //********* Read model
    TF_Graph* Graph = TF_NewGraph();
    TF_Status* Status = TF_NewStatus();
    TF_SessionOptions* SessionOpts = TF_NewSessionOptions();
    TF_Buffer* RunOpts = NULL;
    
    const char* saved_model_dir = "model/"; 
    const char* tags = "serve"; 
    
    int ntags = 1;
    TF_Session* Session = TF_LoadSessionFromSavedModel(SessionOpts, RunOpts, saved_model_dir, &tags, ntags, Graph, NULL, Status);
    
    if(TF_GetCode(Status) == TF_OK)
    {
        printf("TF_LoadSessionFromSavedModel OK\n");
    }
    else
    {
        printf("%s",TF_Message(Status));
    }

Ładowanie zapisanego modelu z przykładowym kodem cpp

Następnie pobieramy węzły z grafu poprzez ich nazwy. Pamiętasz, jak wcześniej szukaliśmy nazwy tensora za pomocą save_model_cli? Teraz użyjemy go ponownie podczas wywołania TF_GraphOperationByName(). W tym przykładzie serv_default_input_1 jest naszym inputem, a StatefulPartitionedCall outputem.

    //****** Get input tensor
    int NumInputs = 1;
    TF_Output* Input = malloc(sizeof(TF_Output) * NumInputs);
    TF_Output t0 = {TF_GraphOperationByName(Graph, "serving_default_input_1"), 0};
    
    if(t0.oper == NULL)
        printf("ERROR: Failed TF_GraphOperationByName serving_default_input_1\n");
    else
        printf("TF_GraphOperationByName serving_default_input_1 is OK\n");
    
    Input[0] = t0;
    
    //********* Get Output tensor
    int NumOutputs = 1;
    TF_Output* Output = malloc(sizeof(TF_Output) * NumOutputs);
    TF_Output t2 = {TF_GraphOperationByName(Graph, "StatefulPartitionedCall"), 0};
    
    if(t2.oper == NULL)
        printf("ERROR: Failed TF_GraphOperationByName StatefulPartitionedCall\n");
    else
      printf("TF_GraphOperationByName StatefulPartitionedCall is OK\n");
    
    Output[0] = t2;

Czytanie inputu

Następnie musimy lokalnie zaalokować nowy tensor za pomocą TF_NewTensor, ustawić wartość wejściową, a później uruchomić sesję. UWAGA: ndata to całkowity rozmiar Twoich danych w bajtach, a nie długość tablicy.

Input tensora ustawiamy na 20. Output powinien mieć taką samą wartość.

    //********* Allocate data for inputs & outputs
    TF_Tensor** InputValues  = (TF_Tensor**)malloc(sizeof(TF_Tensor*)*NumInputs);
    TF_Tensor** OutputValues = (TF_Tensor**)malloc(sizeof(TF_Tensor*)*NumOutputs);
    
    int ndims = 2;
    int64_t dims[] = {1,1};
    int64_t data[] = {20};
    
    int ndata = sizeof(int64_t); 
    TF_Tensor* int_tensor = TF_NewTensor(TF_INT64, dims, ndims, data, ndata, &NoOpDeallocator, 0);
    
    if (int_tensor != NULL)
        printf("TF_NewTensor is OK\n");
    else
      printf("ERROR: Failed TF_NewTensor\n");
    
    InputValues[0] = int_tensor;

Przydzielony input tensora

Następnie uruchamiamy model przez przywołanie API TF_SessionRun. Oto, jak to zrobimy:

    // Run the Session
    TF_SessionRun(Session, NULL, Input, InputValues, NumInputs, Output, OutputValues, NumOutputs, NULL, 0,NULL , Status);
    
    if(TF_GetCode(Status) == TF_OK)
      printf("Session is OK\n");
    else
      printf("%s",TF_Message(Status));

    // Free memory
    TF_DeleteGraph(Graph);
    TF_DeleteSession(Session, Status);
    TF_DeleteSessionOptions(SessionOpts);
    TF_DeleteStatus(Status);

Uruchamiane sesji

Na koniec, odzyskujemy wartość wyjściową z wyjścia tensora przy użyciu TF_TensorData, które wydobywa dane z obiektu tensora. Ponieważ jednak znamy rozmiaru wyjścia (czyli 1) może go bezpośrednio wydrukować. Możesz albo użyć TF_GraphGetTensorNumDims albo innego API, które jest dostępne w c_api.h albo w tf_tensor.h.

    void* buff = TF_TensorData(OutputValues[0]);
    float* offsets = buff;
    printf("Result Tensor :\n");
    printf("%f\n",offsets[0]);
    return 0;

Czytanie wyników sesji

Krok drugi: skompiluj kod

Skompiluj kod, jak pokazano poniżej:

gcc -I<path_of_tensorflow_api>/include/ -L<path_of_tensorflow_api>/lib main.c -ltensorflow -o main.out


Krok trzeci: uruchom kod

Przed uruchomieniem musisz się upewnić, że biblioteka C została wyeksportowana do Twojego środowiska.

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:<path_of_tensorflow_api>/lib


Uruchom kod

./main.out


Wynik powinien być, jak poniżej. Zauważ, że jego wartość wynosi 20 i jest dokładnie taka sama, jak wartość naszego inputu. Możesz zmienić model i zainicjować jądro o wartości 2, aby zobaczyć, czy zostanie to odzwierciedlone w innych wartościach. 

TF_LoadSessionFromSavedModel OK
TF_GraphOperationByName serving_default_input_1 is OK
TF_GraphOperationByName StatefulPartitionedCall is OK
TF_NewTensor is OK
Session is OK
Result Tensor :
20.000000


Koniec!



Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>