Operatori personalizzati

Mantieni tutto organizzato con le raccolte Salva e classifica i contenuti in base alle tue preferenze.

Poiché la libreria degli operatori incorporata di TensorFlow Lite supporta solo un numero limitato di operatori TensorFlow, non tutti i modelli sono convertibili. Per i dettagli, fare riferimento a Compatibilità con l'operatore .

Per consentire la conversione, gli utenti possono fornire la propria implementazione personalizzata di un operatore TensorFlow non supportato in TensorFlow Lite, noto come operatore personalizzato. Se invece si desidera combinare una serie di operatori TensorFlow non supportati (o supportati) in un unico operatore personalizzato ottimizzato con fusione, fare riferimento a operator fusing .

L'utilizzo di operatori personalizzati consiste in quattro passaggi.

Esaminiamo un esempio end-to-end di esecuzione di un modello con un operatore personalizzato tf.sin (denominato Sin , fare riferimento a #create_a_tensorflow_model) che è supportato in TensorFlow, ma non in TensorFlow Lite.

L'operatore TensorFlow Text è un esempio di operatore personalizzato. Vedere il tutorial Converti testo TF in TF Lite per un esempio di codice.

Esempio: operatore Custom Sin

Esaminiamo un esempio di supporto di un operatore TensorFlow che TensorFlow Lite non ha. Supponiamo di utilizzare l'operatore Sin e di costruire un modello molto semplice per una funzione y = sin(x + offset) , in cui l' offset è addestrabile.

Crea un modello TensorFlow

Il seguente frammento di codice esegue il training di un semplice modello TensorFlow. Questo modello contiene solo un operatore personalizzato chiamato Sin , che è una funzione y = sin(x + offset) , dove offset è addestrabile.

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

A questo punto, se si tenta di generare un modello TensorFlow Lite con i flag di conversione predefiniti, verrà visualizzato il seguente messaggio di errore:

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.

Converti in un modello TensorFlow Lite

Crea un modello TensorFlow Lite con operatori personalizzati, impostando l'attributo del convertitore allow_custom_ops come mostrato di seguito:

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

A questo punto, se lo esegui con l'interprete predefinito, otterrai i seguenti messaggi di errore:

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

Crea e registra l'operatore.

Tutti gli operatori TensorFlow Lite (sia personalizzati che integrati) sono definiti utilizzando una semplice interfaccia in puro C che consiste in quattro funzioni:

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;

Fare riferimento a common.h per i dettagli su TfLiteContext e TfLiteNode . Il primo fornisce funzionalità di segnalazione degli errori e accesso a oggetti globali, inclusi tutti i tensori. Quest'ultimo consente alle implementazioni di accedere ai propri input e output.

Quando l'interprete carica un modello, chiama init() una volta per ogni nodo nel grafico. Un dato init() verrà chiamato più di una volta se l'op viene utilizzato più volte nel grafico. Per le operazioni personalizzate verrà fornito un buffer di configurazione, contenente un flexbuffer che mappa i nomi dei parametri ai loro valori. Il buffer è vuoto per le operazioni integrate perché l'interprete ha già analizzato i parametri delle operazioni. Le implementazioni del kernel che richiedono lo stato devono inizializzarlo qui e trasferire la proprietà al chiamante. Per ogni chiamata init() , ci sarà una chiamata corrispondente a free() , consentendo alle implementazioni di eliminare il buffer che potrebbero aver allocato in init() .

Ogni volta che i tensori di input vengono ridimensionati, l'interprete esaminerà il grafico notificando le implementazioni della modifica. Questo dà loro la possibilità di ridimensionare il buffer interno, controllare la validità delle forme e dei tipi di input e ricalcolare le forme di output. Tutto questo viene fatto tramite prepare() e le implementazioni possono accedere al loro stato usando node->user_data .

Infine, ogni volta che viene eseguita l'inferenza, l'interprete attraversa il grafico chiamando invoke() , e anche qui lo stato è disponibile come node->user_data .

Le operazioni personalizzate possono essere implementate esattamente allo stesso modo delle operazioni integrate, definendo queste quattro funzioni e una funzione di registrazione globale che di solito assomiglia a questa:

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

Si noti che la registrazione non è automatica e dovrebbe essere effettuata una chiamata esplicita a Register_MY_CUSTOM_OP . Mentre lo standard BuiltinOpResolver (disponibile dalla destinazione :builtin_ops ) si occupa della registrazione dei builtin, le operazioni personalizzate dovranno essere raccolte in librerie personalizzate separate.

Definizione del kernel nel runtime TensorFlow Lite

Tutto quello che dobbiamo fare per usare l'op in TensorFlow Lite è definire due funzioni ( Prepare e TfLiteRegistration Eval

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;
}

Quando si inizializza OpResolver , aggiungere l'operazione personalizzata nel risolutore (vedi sotto per un esempio). Ciò registrerà l'operatore con Tensorflow Lite in modo che TensorFlow Lite possa utilizzare la nuova implementazione. Si noti che gli ultimi due argomenti in TfLiteRegistration corrispondono alle funzioni SinPrepare e SinEval definite per l'op personalizzata. Se hai usato le funzioni SinInit e SinFree per inizializzare le variabili usate nell'op e per liberare spazio, rispettivamente, allora verrebbero aggiunte ai primi due argomenti di TfLiteRegistration ; questi argomenti sono impostati su nullptr in questo esempio.

Registra l'operatore con la libreria del kernel

Ora dobbiamo registrare l'operatore con la libreria del kernel. Questo viene fatto con un OpResolver . Dietro le quinte, l'interprete caricherà una libreria di kernel che verrà assegnata per eseguire ciascuno degli operatori nel modello. Sebbene la libreria predefinita contenga solo kernel integrati, è possibile sostituirla/aumentarla con operatori operativi di libreria personalizzati.

La classe OpResolver , che traduce i codici e i nomi degli operatori in codice effettivo, è definita in questo modo:

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;
};

L'utilizzo regolare richiede l'utilizzo di BuiltinOpResolver e la scrittura di:

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

Per aggiungere l'operazione personalizzata creata sopra, chiami AddOp (prima di passare il risolutore a InterpreterBuilder ):

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

Se l'insieme di operazioni integrate è ritenuto troppo grande, è possibile generare un nuovo codice OpResolver in base a un determinato sottoinsieme di operazioni, possibilmente solo quelle contenute in un determinato modello. Questo è l'equivalente della registrazione selettiva di TensorFlow (e una versione semplice è disponibile nella directory degli tools ).

Se desideri definire i tuoi operatori personalizzati in Java, al momento dovresti creare il tuo livello JNI personalizzato e compilare il tuo AAR in questo codice jni . Allo stesso modo, se desideri definire questi operatori disponibili in Python, puoi inserire le tue registrazioni nel codice wrapper Python .

Si noti che un processo simile come sopra può essere seguito per supportare un insieme di operazioni anziché un singolo operatore. Basta aggiungere tutti gli operatori AddCustom di cui hai bisogno. Inoltre, BuiltinOpResolver consente anche di sovrascrivere le implementazioni dei built-in utilizzando AddBuiltin .

Testa e profila il tuo operatore

Per profilare la tua operazione con lo strumento benchmark TensorFlow Lite, puoi utilizzare lo strumento modello benchmark per TensorFlow Lite. A scopo di test, puoi rendere la tua build locale di TensorFlow Lite consapevole della tua operazione personalizzata aggiungendo la chiamata AddCustom appropriata (come mostrato sopra) a register.cc

Migliori pratiche

  1. Ottimizza con cautela le allocazioni e le delocazioni di memoria. L'allocazione della memoria in Prepare è più efficiente rispetto a Invoke e l'allocazione della memoria prima di un ciclo è migliore che in ogni iterazione. Usa i dati dei tensori temporanei invece di eseguire il mallo (vedi punto 2). Usa puntatori/riferimenti invece di copiare il più possibile.

  2. Se una struttura dati persiste durante l'intera operazione, si consiglia di preallocare la memoria utilizzando tensori temporanei. Potrebbe essere necessario utilizzare la struttura OpData per fare riferimento agli indici del tensore in altre funzioni. Vedere l'esempio nel kernel per la convoluzione . Di seguito è riportato un frammento di codice di esempio

    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. Se non costa troppa memoria sprecata, preferisci usare un array statico a dimensione fissa (o uno std::vector preallocato in Resize ) piuttosto che usare uno std::vector allocato dinamicamente ogni iterazione dell'esecuzione.

  4. Evita di creare istanze di modelli di contenitori di librerie standard che non esistono già, poiché influiscono sulla dimensione binaria. Ad esempio, se hai bisogno di uno std::map nella tua operazione che non esiste in altri kernel, l'uso di uno std::vector con mappatura di indicizzazione diretta potrebbe funzionare mantenendo piccola la dimensione del binario. Guarda cosa usano gli altri kernel per ottenere informazioni (o chiedere).

  5. Controllare il puntatore alla memoria restituita da malloc . Se questo puntatore è nullptr , non è necessario eseguire alcuna operazione utilizzando quel puntatore. Se si malloc in una funzione e si verifica un errore di uscita, deallocare la memoria prima di uscire.

  6. Utilizzare TF_LITE_ENSURE(context, condition) per verificare una condizione specifica. Il codice non deve lasciare la memoria sospesa quando viene utilizzato TF_LITE_ENSURE , ovvero queste macro devono essere utilizzate prima che vengano allocate le risorse che perderanno.