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:
- Sklonujemy kod źródłowy Tensorflow i skompilujemy go, aby otrzymać nagłówki/binarki API C.
- Zbudujemy najprostszy model przy użyciu Pythona oraz Tensorflow i wyeksportujemy go do modelu tf, który API C będzie w stanie odczytać.
- 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.