Ayuda a proteger la Gran Barrera de Coral con TensorFlow en Kaggle Únete Challenge

Operadores personalizados

Dado que la biblioteca de operadores integrada de TensorFlow Lite solo admite una cantidad limitada de operadores de TensorFlow, no todos los modelos son convertibles. Para más detalles, referirse a la compatibilidad operador .

Para permitir la conversión, los usuarios pueden proporcionar su propia implementación personalizada de un operador de TensorFlow no compatible en TensorFlow Lite, conocido como operador personalizado. Si por el contrario, desea combinar una serie de operadores TensorFlow no compatibles (o compatibles) en una sola operadora personalizada optimizado fusionado, consulte la fusión del operador .

El uso de operadores personalizados consta de cuatro pasos.

Vamos a caminar a través de un ejemplo de extremo a extremo de la ejecución de un modelo con una operadora personalizada tf.sin (nombrado como Sin , consulte #create_a_tensorflow_model) que se apoya en TensorFlow, pero sin apoyo en TensorFlow Lite.

Ejemplo: Custom Sin operador

Veamos un ejemplo de compatibilidad con un operador de TensorFlow que TensorFlow Lite no tiene. Supongamos que estamos utilizando la Sin operador y que estamos construyendo un modelo muy simple para una función y = sin(x + offset) , donde offset es entrenable.

Crea un modelo de TensorFlow

El siguiente fragmento de código entrena un modelo simple de TensorFlow. Este modelo solo contiene un operador personalizado denominado Sin , que es una función y = sin(x + offset) , donde offset es entrenable.

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

En este punto, si intenta generar un modelo de TensorFlow Lite con las marcas de convertidor predeterminadas, obtendrá el siguiente mensaje de error:

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.

Convertir a un modelo de TensorFlow Lite

Crear un modelo TensorFlow Lite con operadores personalizados, estableciendo el atributo convertidor allow_custom_ops como se muestra a continuación:

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

En este punto, si lo ejecuta con el intérprete predeterminado, obtendrá los siguientes mensajes de error:

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

Crea y registra el operador.

Todos los operadores de TensorFlow Lite (tanto personalizados como integrados) se definen mediante una interfaz simple de C puro que consta de cuatro funciones:

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;

Consulte la common.h para obtener detalles sobre TfLiteContext y TfLiteNode . El primero proporciona funciones de notificación de errores y acceso a objetos globales, incluidos todos los tensores. Este último permite que las implementaciones accedan a sus entradas y salidas.

Cuando se carga el intérprete un modelo, que llama init() una vez para cada nodo en el gráfico. A dado init() será llamado más de una vez si el op se utiliza múltiples veces en el gráfico. Para operaciones personalizadas, se proporcionará un búfer de configuración, que contiene un búfer flexible que asigna los nombres de los parámetros a sus valores. El búfer está vacío para operaciones integradas porque el intérprete ya ha analizado los parámetros de operación. Las implementaciones de kernel que requieren estado deben inicializarlo aquí y transferir la propiedad al llamador. Para cada uno init() llamada, habrá una llamada correspondiente a free() , lo que permite implementaciones para disponer de la memoria intermedia que podría haber asignado en init() .

Siempre que se cambie el tamaño de los tensores de entrada, el intérprete revisará el gráfico notificando las implementaciones del cambio. Esto les da la oportunidad de cambiar el tamaño de su búfer interno, verificar la validez de las formas y tipos de entrada y volver a calcular las formas de salida. Todo esto se hace a través prepare() , y las implementaciones pueden acceder a su estado usando node->user_data .

Por último, cada vez que se ejecuta la inferencia, el intérprete atraviesa el llamado gráfico invoke() , y también en este caso el estado está disponible como node->user_data .

Las operaciones personalizadas se pueden implementar exactamente de la misma manera que las operaciones integradas, definiendo esas cuatro funciones y una función de registro global que generalmente se ve así:

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

Tenga en cuenta que la inscripción no es automática y una llamada explícita a Register_MY_CUSTOM_OP debe hacerse. Si bien la norma BuiltinOpResolver (disponible a partir del :builtin_ops objetivo) se encarga del registro de órdenes internas, ops personalizados tendrán que ser recogidos en las bibliotecas personalizadas separadas.

Definición del kernel en el tiempo de ejecución de TensorFlow Lite

Todo lo que tenemos que hacer para utilizar el op en TensorFlow Lite es definir dos funciones ( Prepare y Eval ), y construir un 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;
}

Al inicializar el OpResolver , añadir el op personalizado en el sistema de resolución (véase a continuación para un ejemplo). Esto registrará al operador con Tensorflow Lite para que TensorFlow Lite pueda usar la nueva implementación. Tenga en cuenta que los dos últimos argumentos en TfLiteRegistration corresponden a la SinPrepare y SinEval funciones que se definen para el op personalizado. Si utilizó SinInit y SinFree funciones para inicializar las variables utilizadas en la operación y para liberar espacio, respectivamente, después de que se añadirían a los dos primeros argumentos de TfLiteRegistration ; estos argumentos se establecen en nullptr en este ejemplo.

Registrar el operador con la biblioteca del kernel

Ahora necesitamos registrar el operador con la biblioteca del kernel. Esto se hace con un OpResolver . Detrás de escena, el intérprete cargará una biblioteca de núcleos que serán asignados para ejecutar cada uno de los operadores en el modelo. Si bien la biblioteca predeterminada solo contiene núcleos integrados, es posible reemplazarla / aumentarla con operadores de operaciones de biblioteca personalizados.

El OpResolver clase, lo que se traduce códigos y nombres de operador en el código actual, se define así:

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

El uso regular requiere el uso de la BuiltinOpResolver y escribir:

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

Para añadir el op personalizada creada anteriormente, se llama a AddOp (antes de emitir la resolución a la InterpreterBuilder ):

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

Si el conjunto de operaciones incorporadas se considera que es demasiado grande, un nuevo OpResolver podría ser basado en un subconjunto determinado de operaciones generado en código, posiblemente, sólo los contenidos en un modelo dado. Este es el equivalente de registro selectivo de TensorFlow (y una versión simple de la misma está disponible en el tools directorio).

Si desea definir sus operadores personalizados en Java, usted actualmente necesita para construir su propia capa JNI costumbre y compilar su propia AAR en este código JNI . Del mismo modo, si desea definir estos operadores disponibles en Python puede colocar sus registros en el código de contenedor de Python .

Tenga en cuenta que se puede seguir un proceso similar al anterior para admitir un conjunto de operaciones en lugar de un solo operador. Sólo tiene que añadir tantos AddCustom operadores como sea necesario. Además, BuiltinOpResolver también le permite anular las implementaciones de órdenes internas mediante el uso de la AddBuiltin .

Pruebe y perfile su operador

Para el perfil de su op con la herramienta de referencia TensorFlow Lite, se puede utilizar la herramienta de modelo de referencia para TensorFlow Lite. Para propósitos de prueba, puede hacer que su acumulación local del TensorFlow Lite consciente de su op personalizada mediante la adición de la adecuada AddCustom llamada (como se muestra arriba) para register.cc

Mejores prácticas

  1. Optimice las asignaciones y desasignaciones de memoria con precaución. La asignación de memoria en Prepare es más eficiente que en Invoke , y la asignación de memoria antes de un bucle es mejor que en cada iteración. Utilice datos de tensores temporales en lugar de mallocarse (consulte el punto 2). Utilice punteros / referencias en lugar de copiar tanto como sea posible.

  2. Si una estructura de datos persiste durante toda la operación, recomendamos preasignar la memoria utilizando tensores temporales. Es posible que deba usar la estructura OpData para hacer referencia a los índices de tensor en otras funciones. Vea el ejemplo en el kernel de convolución . A continuación se muestra un fragmento de código de muestra.

    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. Si no cuesta demasiado memoria desperdiciada, prefieren utilizar una matriz de tamaño fijo estática (o una pre-asignado std::vector de Resize ) en lugar de utilizar una asignación dinámica std::vector cada iteración de la ejecución.

  4. Evite crear instancias de plantillas de contenedor de biblioteca estándar que aún no existan, ya que afectan al tamaño binario. Por ejemplo, si necesita un std::map en su operación que no existe en otros núcleos, utilizando un std::vector con el mapeo de indexación directa podría funcionar mientras se mantiene el pequeño tamaño binario. Vea qué otros núcleos usan para obtener información (o preguntar).

  5. Compruebe el puntero a la memoria devuelto por malloc . Si este puntero es nullptr , no hay operaciones deben llevarse a cabo utilizando ese puntero. Si malloc en una función y tiene una salida de error, liberar memoria antes de salir.

  6. Uso TF_LITE_ENSURE(context, condition) para comprobar si hay una condición específica. El código no debe dejar de memoria suspendida cuando esté TF_LITE_ENSURE se utiliza, es decir, estas macros se deben utilizar antes de asignar los recursos que se escape.