Weź udział w sympozjum Women in ML 7 grudnia Zarejestruj się teraz

Operatorzy celni

Zadbaj o dobrą organizację dzięki kolekcji Zapisuj i kategoryzuj treści zgodnie ze swoimi preferencjami.

Ponieważ wbudowana biblioteka operatorów TensorFlow Lite obsługuje tylko ograniczoną liczbę operatorów TensorFlow, nie każdy model jest konwertowalny. Aby uzyskać szczegółowe informacje, patrz Kompatybilność operatora .

Aby umożliwić konwersję, użytkownicy mogą zapewnić własną niestandardową implementację nieobsługiwanego operatora TensorFlow w TensorFlow Lite, znanego jako operator niestandardowy. Jeśli zamiast tego chcesz połączyć szereg nieobsługiwanych (lub obsługiwanych) operatorów TensorFlow w jeden połączony zoptymalizowany operator niestandardowy, zapoznaj się z łączeniem operatorów .

Korzystanie z operatorów niestandardowych składa się z czterech kroków.

Przejdźmy przez kompletny przykład uruchomienia modelu z niestandardowym operatorem tf.sin (o nazwie Sin , patrz #create_a_tensorflow_model), który jest obsługiwany w TensorFlow, ale nieobsługiwany w TensorFlow Lite.

Operator tekstowy TensorFlow jest przykładem operatora niestandardowego. Zobacz samouczek Konwertuj tekst TF na TF Lite, aby zapoznać się z przykładem kodu.

Przykład: operator Sin niestandardowego

Przeanalizujmy przykład obsługi operatora TensorFlow, którego TensorFlow Lite nie posiada. Załóżmy, że używamy operatora Sin i budujemy bardzo prosty model dla funkcji y = sin(x + offset) , gdzie offset można wytrenować.

Utwórz model TensorFlow

Poniższy fragment kodu trenuje prosty model TensorFlow. Ten model zawiera tylko niestandardowy operator o nazwie Sin , który jest funkcją y = sin(x + offset) , gdzie offset można wytrenować.

import tensorflow as tf

# Define training dataset and variables
x = [-8, 0.5, 2, 2.2, 201]
y = [-0.6569866 ,  0.99749499,  0.14112001, -0.05837414,  0.80641841]
offset = tf.Variable(0.0)

# Define a simple model which just contains a custom operator named `Sin`
@tf.function
def sin(x):
  return tf.sin(x + offset, name="Sin")

# Train model
optimizer = tf.optimizers.Adam(0.01)
def train(x, y):
    with tf.GradientTape() as t:
      predicted_y = sin(x)
      loss = tf.reduce_sum(tf.square(predicted_y - y))
    grads = t.gradient(loss, [offset])
    optimizer.apply_gradients(zip(grads, [offset]))

for i in range(1000):
    train(x, y)

print("The actual offset is: 1.0")
print("The predicted offset is:", offset.numpy())
The actual offset is: 1.0
The predicted offset is: 1.0000001

W tym momencie, jeśli spróbujesz wygenerować model TensorFlow Lite z domyślnymi flagami konwertera, otrzymasz następujący komunikat o błędzie:

Error:
Some of the operators in the model are not supported by the standard TensorFlow
Lite runtime...... Here is
a list of operators for which you will need custom implementations: Sin.

Konwersja do modelu TensorFlow Lite

Utwórz model TensorFlow Lite z operatorami niestandardowymi, ustawiając atrybut konwertera allow_custom_ops , jak pokazano poniżej:

converter = tf.lite.TFLiteConverter.from_concrete_functions([sin.get_concrete_function(x)], sin)
converter.allow_custom_ops = True
tflite_model = converter.convert()

W tym momencie, jeśli uruchomisz go z domyślnym interpreterem, otrzymasz następujące komunikaty o błędach:

Error:
Didn't find custom operator for name 'Sin'
Registration failed.

Utwórz i zarejestruj operatora.

Wszystkie operatory TensorFlow Lite (zarówno niestandardowe, jak i wbudowane) są definiowane za pomocą prostego interfejsu w czystym C, który składa się z czterech funkcji:

typedef struct {
  void* (*init)(TfLiteContext* context, const char* buffer, size_t length);
  void (*free)(TfLiteContext* context, void* buffer);
  TfLiteStatus (*prepare)(TfLiteContext* context, TfLiteNode* node);
  TfLiteStatus (*invoke)(TfLiteContext* context, TfLiteNode* node);
} TfLiteRegistration;

Zapoznaj się z common.h , aby uzyskać szczegółowe informacje na temat TfLiteContext i TfLiteNode . Pierwsza zapewnia funkcje raportowania błędów i dostęp do obiektów globalnych, w tym wszystkich tensorów. Ta ostatnia umożliwia implementacjom dostęp do swoich wejść i wyjść.

Kiedy interpreter ładuje model, wywołuje init() raz dla każdego węzła na wykresie. Dana init() zostanie wywołana więcej niż jeden raz, jeśli op zostanie użyte w grafie wielokrotnie. Dla niestandardowych operacji zostanie dostarczony bufor konfiguracyjny, zawierający flexbuffer, który mapuje nazwy parametrów na ich wartości. Bufor jest pusty dla operacji wbudowanych, ponieważ interpreter już przeanalizował parametry operacji. Implementacje jądra, które wymagają stanu, powinny zainicjować go tutaj i przenieść własność na wywołującego. Każdemu wywołaniu init() będzie odpowiednie wywołanie free() , co pozwoli implementacjom na pozbycie się bufora, który mogły przydzielić w init() .

Za każdym razem, gdy tensory wejściowe są zmieniane, interpreter przejdzie przez graf, powiadamiając implementacje o zmianie. Daje im to możliwość zmiany rozmiaru wewnętrznego bufora, sprawdzenia poprawności kształtów i typów wejściowych oraz ponownego obliczenia kształtów wyjściowych. Wszystko to odbywa się za pomocą metody Prepare prepare() , a implementacje mogą uzyskać dostęp do swojego stanu za pomocą node->user_data .

Wreszcie, za każdym razem, gdy uruchamiane jest wnioskowanie, interpreter przechodzi przez graf wywołując invoke() , a tutaj stan jest dostępny jako node->user_data .

Własne operacje można zaimplementować w dokładnie taki sam sposób, jak wbudowane operacje, definiując te cztery funkcje i globalną funkcję rejestracji, która zwykle wygląda tak:

namespace tflite {
namespace ops {
namespace custom {
  TfLiteRegistration* Register_MY_CUSTOM_OP() {
    static TfLiteRegistration r = {my_custom_op::Init,
                                   my_custom_op::Free,
                                   my_custom_op::Prepare,
                                   my_custom_op::Eval};
    return &r;
  }
}  // namespace custom
}  // namespace ops
}  // namespace tflite

Zauważ, że rejestracja nie jest automatyczna i należy wykonać wyraźne wywołanie Register_MY_CUSTOM_OP . Podczas gdy standardowy BuiltinOpResolver (dostępny z celu :builtin_ops ) zajmuje się rejestracją wbudowanych, niestandardowe operacje będą musiały być zebrane w oddzielnych niestandardowych bibliotekach.

Definiowanie jądra w środowisku wykonawczym TensorFlow Lite

Wszystko, co musimy zrobić, aby użyć op w TensorFlow Lite, to zdefiniować dwie funkcje ( TfLiteRegistration i Eval ) i skonstruować Prepare :

TfLiteStatus SinPrepare(TfLiteContext* context, TfLiteNode* node) {
  using namespace tflite;
  TF_LITE_ENSURE_EQ(context, NumInputs(node), 1);
  TF_LITE_ENSURE_EQ(context, NumOutputs(node), 1);

  const TfLiteTensor* input = GetInput(context, node, 0);
  TfLiteTensor* output = GetOutput(context, node, 0);

  int num_dims = NumDimensions(input);

  TfLiteIntArray* output_size = TfLiteIntArrayCreate(num_dims);
  for (int i=0; i<num_dims; ++i) {
    output_size->data[i] = input->dims->data[i];
  }

  return context->ResizeTensor(context, output, output_size);
}

TfLiteStatus SinEval(TfLiteContext* context, TfLiteNode* node) {
  using namespace tflite;
  const TfLiteTensor* input = GetInput(context, node,0);
  TfLiteTensor* output = GetOutput(context, node,0);

  float* input_data = input->data.f;
  float* output_data = output->data.f;

  size_t count = 1;
  int num_dims = NumDimensions(input);
  for (int i = 0; i < num_dims; ++i) {
    count *= input->dims->data[i];
  }

  for (size_t i=0; i<count; ++i) {
    output_data[i] = sin(input_data[i]);
  }
  return kTfLiteOk;
}

TfLiteRegistration* Register_SIN() {
  static TfLiteRegistration r = {nullptr, nullptr, SinPrepare, SinEval};
  return &r;
}

Podczas inicjowania OpResolver , dodaj niestandardową operację do resolvera (patrz przykład poniżej). Spowoduje to zarejestrowanie operatora w Tensorflow Lite, dzięki czemu TensorFlow Lite będzie mógł korzystać z nowej implementacji. Zauważ, że ostatnie dwa argumenty w TfLiteRegistration odpowiadają SinPrepare i SinEval , które zdefiniowałeś dla operacji niestandardowej. Jeśli użyto funkcji SinInit i SinFree do inicjalizacji zmiennych używanych w operacji i odpowiednio do zwolnienia miejsca, zostaną one dodane do pierwszych dwóch argumentów TfLiteRegistration ; w tym przykładzie te argumenty są ustawione na nullptr .

Zarejestruj operatora w bibliotece jądra

Teraz musimy zarejestrować operatora w bibliotece jądra. Odbywa się to za pomocą OpResolver . Za kulisami interpreter załaduje bibliotekę jąder, które zostaną przypisane do wykonania każdego z operatorów w modelu. Chociaż domyślna biblioteka zawiera tylko wbudowane jądra, możliwe jest zastąpienie/rozszerzenie jej niestandardowymi operatorami operacyjnymi biblioteki.

Klasa OpResolver , która tłumaczy kody i nazwy operatorów na rzeczywisty kod, jest zdefiniowana w następujący sposób:

class OpResolver {
  virtual TfLiteRegistration* FindOp(tflite::BuiltinOperator op) const = 0;
  virtual TfLiteRegistration* FindOp(const char* op) const = 0;
  virtual void AddBuiltin(tflite::BuiltinOperator op, TfLiteRegistration* registration) = 0;
  virtual void AddCustom(const char* op, TfLiteRegistration* registration) = 0;
};

Regularne używanie wymaga użycia BuiltinOpResolver i pisania:

tflite::ops::builtin::BuiltinOpResolver resolver;

Aby dodać niestandardową operację utworzoną powyżej, wywołujesz AddOp (przed przekazaniem resolvera do InterpreterBuilder ):

resolver.AddCustom("Sin", Register_SIN());

Jeśli zestaw wbudowanych operacji zostanie uznany za zbyt duży, nowy OpResolver może zostać wygenerowany na podstawie danego podzbioru operacji, prawdopodobnie tylko tych zawartych w danym modelu. Jest to odpowiednik selektywnej rejestracji TensorFlow (a prosta jej wersja jest dostępna w katalogu tools ).

Jeśli chcesz zdefiniować swoje niestandardowe operatory w Javie, musisz obecnie zbudować własną niestandardową warstwę JNI i skompilować własny AAR w tym kodzie jni . Podobnie, jeśli chcesz zdefiniować te operatory dostępne w Pythonie, możesz umieścić swoje rejestracje w opakowującym kodzie Pythona .

Zauważ, że podobny proces jak powyżej można zastosować do obsługi zestawu operacji zamiast pojedynczego operatora. Po prostu dodaj tyle operatorów AddCustom , ile potrzebujesz. Ponadto BuiltinOpResolver umożliwia również nadpisanie implementacji wbudowanych za pomocą AddBuiltin .

Przetestuj i sprofiluj swojego operatora

Aby sprofilować swoją operację za pomocą narzędzia do testów porównawczych TensorFlow Lite, możesz użyć narzędzia do testów porównawczych dla TensorFlow Lite. Do celów testowych możesz powiadomić swoją lokalną wersję TensorFlow Lite o swojej niestandardowej operacji, dodając odpowiednie wywołanie AddCustom (jak pokazano powyżej) do register.cc

Najlepsze praktyki

  1. Ostrożnie optymalizuj alokacje pamięci i cofanie alokacji. Alokowanie pamięci w Prepare jest bardziej wydajne niż w Invoke , a przydzielanie pamięci przed pętlą jest lepsze niż w każdej iteracji. Używaj tymczasowych danych tensorowych zamiast nadużywać siebie (patrz punkt 2). Używaj wskaźników/odniesień zamiast kopiowania jak najwięcej.

  2. Jeśli struktura danych będzie się utrzymywać podczas całej operacji, zalecamy wstępne przydzielenie pamięci za pomocą tymczasowych tensorów. Może być konieczne użycie struktury OpData, aby odwołać się do indeksów tensorów w innych funkcjach. Zobacz przykład w jądrze dla splotu . Przykładowy fragment kodu znajduje się poniżej

    auto* op_data = reinterpret_cast<OpData*>(node->user_data);
    TfLiteIntArrayFree(node->temporaries);
    node->temporaries = TfLiteIntArrayCreate(1);
    node->temporaries->data[0] = op_data->temp_tensor_index;
    TfLiteTensor* temp_tensor = &context->tensors[op_data->temp_tensor_index];
    temp_tensor->type =  kTfLiteFloat32;
    temp_tensor->allocation_type = kTfLiteArenaRw;
    
  3. Jeśli nie kosztuje to zbyt dużo zmarnowanej pamięci, wolą używać statycznej tablicy o stałym rozmiarze (lub wstępnie przydzielonego std::vector w Resize ) zamiast używać dynamicznie przydzielanego std::vector w każdej iteracji wykonania.

  4. Unikaj tworzenia wystąpień szablonów kontenerów biblioteki standardowej, które jeszcze nie istnieją, ponieważ mają one wpływ na rozmiar binarny. Na przykład, jeśli potrzebujesz std::map w swojej operacji, która nie istnieje w innych jądrach, użycie std::vector z bezpośrednim mapowaniem indeksowania może działać przy zachowaniu małego rozmiaru binarnego. Zobacz, jakich innych jąder używają, aby uzyskać wgląd (lub zapytaj).

  5. Sprawdź wskaźnik do pamięci zwróconej przez malloc . Jeśli ten wskaźnik jest nullptr , nie należy wykonywać żadnych operacji przy użyciu tego wskaźnika. Jeśli malloc w funkcji i masz błąd wyjścia, cofnij alokację pamięci przed zakończeniem.

  6. Użyj TF_LITE_ENSURE(context, condition) aby sprawdzić określony warunek. Twój kod nie może pozostawiać zawieszonej pamięci, gdy używane jest TF_LITE_ENSURE , tj. te makra powinny być używane przed przydzieleniem jakichkolwiek zasobów, które wyciekną.