Odpowiedz już dziś na lokalne wydarzenie TensorFlow Everywhere!
Ta strona została przetłumaczona przez Cloud Translation API.
Switch to English

Operatorzy niestandardowi

Ponieważ wbudowana biblioteka operatorów TensorFlow Lite obsługuje tylko ograniczoną liczbę operatorów TensorFlow, nie każdy model można konwertować. Aby uzyskać szczegółowe informacje, patrz zgodność 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ć serię nieobsługiwanych (lub wspieranych) operatorów TensorFlow w jednym topionego zoptymalizowanej operatora niestandardowego, patrz stapiania operatora .

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

Przyjrzyjmy się kompleksowemu przykładowi uruchamiania modelu z niestandardowym operatorem tf.sin (o nazwie Sin , patrz #create_a_tensorflow_model), który jest obsługiwany w TensorFlow, ale nie jest obsługiwany w TensorFlow Lite.

Przykład: niestandardowy operator Sin

Przyjrzyjmy się przykładowi obsługi operatora TensorFlow, którego TensorFlow Lite nie ma. Załóżmy, że używamy operatora Sin i budujemy bardzo prosty model dla funkcji y = sin(x + offset) , gdzie offset można trenować.

Utwórz model TensorFlow

Poniższy fragment kodu szkoli prosty model TensorFlow. Ten model zawiera po prostu niestandardowego operatora o nazwie Sin , który jest funkcją y = sin(x + offset) , gdzie offset można trenować.

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, zostanie wyświetlony 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.

Konwertuj na model TensorFlow Lite

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

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

W tym momencie, jeśli uruchomisz go za pomocą domyślnego interpretera, 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ą zdefiniowane za pomocą prostego interfejsu w czystym języku 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;

Patrz common.h Szczegółowe informacje na temat TfLiteContext i TfLiteNode . Pierwsza z nich 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 ich danych wejściowych i wyjściowych.

Kiedy interpreter ładuje model, wywołuje init() raz dla każdego węzła na wykresie. Dana init() zostanie wywołana więcej niż raz, jeśli operacja zostanie użyta wiele razy na wykresie. W przypadku operacji niestandardowych zostanie dostarczony bufor konfiguracji zawierający bufor flexbuffer, który odwzorowuje nazwy parametrów na ich wartości. Bufor jest pusty dla wbudowanych operacji, ponieważ interpreter już przeanalizował parametry operacji. Implementacje jądra, które wymagają stanu, powinny zainicjować go tutaj i przekazać własność wywołującemu. Dla każdego wywołania init() będzie odpowiadało wywołanie free() , pozwalając implementacjom na pozbycie się bufora, który mogły zaalokować w init() .

Za każdym razem, gdy rozmiar tensorów wejściowych zostanie zmieniony, interpreter przejdzie przez wykres, powiadamiając implementacje o zmianie. Daje im to szansę na zmianę rozmiaru wewnętrznego bufora, sprawdzenie poprawności kształtów i typów wejściowych oraz ponowne obliczenie kształtów wyjściowych. Odbywa się to za pomocą prepare() , a implementacje mogą uzyskać dostęp do swojego stanu za pomocą node->user_data .

Na koniec, za każdym razem, gdy przebiega wnioskowanie, interpreter przechodzi przez wykres wywołujący invoke() i tutaj również stan jest dostępny jako node->user_data .

Operacje niestandardowe można zaimplementować w taki sam sposób, jak operacje wbudowane, definiując te cztery funkcje i funkcję rejestracji globalnej, która zwykle wygląda następująco:

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

Należy pamiętać, że rejestracja nie jest automatyczna i należy wykonać jawne 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ć gromadzone w oddzielnych bibliotekach niestandardowych.

Definiowanie jądra w środowisku wykonawczym TensorFlow Lite

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

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ą OpResolver 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. Zwróć uwagę, że dwa ostatnie argumenty w TfLiteRegistration odpowiadają SinPrepare i SinEval które zdefiniowano dla niestandardowego op. Jeśli SinFree funkcji SinInit i SinFree do zainicjowania zmiennych używanych odpowiednio w op i zwolnienia miejsca, zostaną one dodane do pierwszych dwóch argumentów TfLiteRegistration ; w tym przykładzie te argumenty mają wartość nullptr .

Zarejestruj operatora w bibliotece jądra

Teraz musimy zarejestrować operatora w bibliotece jądra. Odbywa się to za pomocą OpResolver . W tle interpreter załaduje bibliotekę jąder, które zostaną przypisane do wykonywania 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.

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 narzędzia BuiltinOpResolver i napisania:

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

Aby dodać niestandardową AddOp 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 mógłby zostać wygenerowany na podstawie danego podzbioru operacji, być może tylko tych zawartych w danym modelu. Jest to odpowiednik selektywnej rejestracji TensorFlow (a jego prosta wersja jest dostępna w katalogu tools ).

Jeśli chcesz zdefiniować własne 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 kodzie opakowania Pythona .

Należy zauważyć, ż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ż przesłonięcie implementacji wbudowanych przy użyciu metody 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 modelowania wzorcowego dla TensorFlow Lite. Do celów testowych możesz powiadomić lokalną kompilację TensorFlow Lite o niestandardowych AddCustom dodając odpowiednie wywołanie AddCustom (jak pokazano powyżej) do register.cc

Najlepsze praktyki

  1. Ostrożnie optymalizuj alokacje pamięci i jej usuwanie. Alokowanie pamięci w Prepare jest bardziej wydajne niż w Invoke , a alokowanie pamięci przed pętlą jest lepsze niż w każdej iteracji. Skorzystaj z tymczasowych danych tensorów, zamiast zajmować się samoczynnie (patrz punkt 2). Używaj wskaźników / referencji zamiast kopiować 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 tensora w innych funkcjach. Zobacz przykład w jądrze dla konwolucji . 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, preferuj używanie statycznej tablicy o stałym rozmiarze (lub wstępnie przydzielonego std::vector w Resize ) zamiast używania dynamicznie przydzielanego std::vector każdej iteracji wykonywania.

  4. Unikaj tworzenia wystąpień szablonów kontenerów bibliotek standardowych, które jeszcze nie istnieją, ponieważ mają wpływ na rozmiar binarny. Na przykład, jeśli potrzebujesz w swojej operacji std::map , 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 ma wartość nullptr , żadne operacje nie powinny być wykonywane przy użyciu tego wskaźnika. Jeśli malloc w funkcji i masz błąd wyjścia, zwolnij pamięć przed zakończeniem.

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