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
gccoraz 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.