Como a biblioteca de operadores integrados do LiteRT só é compatível com um número limitado de operadores do TensorFlow, nem todos os modelos podem ser convertidos. Para mais detalhes, consulte compatibilidade de operadores.
Para permitir a conversão, os usuários podem fornecer a própria implementação personalizada de um operador do TensorFlow não compatível no LiteRT, conhecido como operador personalizado. Se você quiser combinar uma série de operadores do TensorFlow não compatíveis (ou compatíveis) em um único operador personalizado otimizado e fundido, consulte fusão de operadores.
O uso de operadores personalizados consiste em quatro etapas.
Crie um modelo do TensorFlow. Verifique se o modelo salvo (ou a definição de gráfico) se refere ao operador LiteRT com o nome correto.
Converta para um modelo LiteRT. Defina o atributo de conversor LiteRT correto para converter o modelo com sucesso.
Crie e registre o operador. Isso é para que o tempo de execução do LiteRT saiba como mapear seu operador e parâmetros no gráfico para código executável em C/C++.
Teste e crie um perfil do seu operador. Se você quiser testar apenas seu operador personalizado, crie um modelo com ele e use o programa benchmark_model.
Vamos analisar um exemplo de ponta a ponta de execução de um modelo com um operador
personalizado tf.atan
(chamado de Atan
.Consulte Criar um modelo do TensorFlow), que
é compatível com o TensorFlow, mas não com o LiteRT.
O operador do TensorFlow Text é um exemplo de operador personalizado. Consulte o tutorial Converter TF Text para LiteRT (em inglês) para ver um exemplo de código.
Exemplo: operador Atan
personalizado
Vamos analisar um exemplo de suporte a um operador do TensorFlow que
a LiteRT não tem. Suponha que estamos usando o operador Atan
e criando um modelo muito simples para uma função y = atan(x + offset)
, em que offset
pode ser treinado.
Criar um modelo do TensorFlow
O snippet de código a seguir treina um modelo simples do TensorFlow. Esse modelo contém apenas um operador personalizado chamado Atan
, que é uma função y = atan(x +
offset)
, em que offset
pode ser treinado.
import tensorflow as tf
# Define training dataset and variables
x = [-8, 0.5, 2, 2.2, 201]
y = [-1.4288993, 0.98279375, 1.2490457, 1.2679114, 1.5658458]
offset = tf.Variable(0.0)
# Define a simple model which just contains a custom operator named `Atan`
@tf.function(input_signature=[tf.TensorSpec.from_tensor(tf.constant(x))])
def atan(x):
return tf.atan(x + offset, name="Atan")
# Train model
optimizer = tf.optimizers.Adam(0.01)
def train(x, y):
with tf.GradientTape() as t:
predicted_y = atan(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: 0.99999905
Neste ponto, se você tentar gerar um modelo LiteRT com as flags de conversor padrão, vai receber a seguinte mensagem de erro:
Error:
error: 'tf.Atan' op is neither a custom op nor a flex op.
Converter para um modelo LiteRT
Crie um modelo LiteRT com operadores personalizados definindo o atributo do conversor
allow_custom_ops
, conforme mostrado abaixo:
converter = tf.lite.TFLiteConverter.from_concrete_functions([atan.get_concrete_function()], atan) converter.allow_custom_ops = True tflite_model = converter.convert()
Neste ponto, se você executar com o interpretador padrão usando comandos como a seguir:
interpreter = tf.lite.Interpreter(model_content=tflite_model)
interpreter.allocate_tensors()
Você ainda vai receber o erro:
Encountered unresolved custom op: Atan.
Crie e registre o operador.
#include "third_party/tensorflow/lite/c/c_api.h"
#include "third_party/tensorflow/lite/c/c_api_opaque.h"
Os operadores personalizados do LiteRT são definidos usando uma API simples de C puro que
consiste em um tipo opaco (TfLiteOperator
) e funções relacionadas.
TfLiteOperator
é um tipo opaco:
typedef struct TfLiteOperator TfLiteOperator;
TfLiteOperator
armazena a identidade e a implementação do operador.
O operador é diferente dos operandos, que são armazenados nos nós do gráfico LiteRT para nós que chamam o operador.
As instâncias desse tipo são construídas com chamadas para
TfLiteOperatorCreate
e podem ser destruídas chamando
TfLiteOperatorDelete
.
A identidade do operador é definida pelos parâmetros da função construtora TfLiteOperatorCreate
:
TfLiteOperator*
TfLiteOperatorCreate(
TfLiteBuiltinOperator builtin_code, // Normally `TfLiteBuiltinCustom`.
const char* custom_name, // The name of the custom op.
int version // Normally `1` for the first version of a custom op.
);
A implementação do operador pode definir "métodos" com as seguintes assinaturas.
Todos esses métodos são opcionais, mas, para que um operador seja avaliado com sucesso, a implementação dele precisa definir e definir (usando as funções setter) pelo menos os métodos Prepare
e Invoke
.
// Initializes the op from serialized data.
void* Init(TfLiteOpaqueContext* context, const char* buffer, size_t length);
// Deallocates the op.
// The pointer `buffer` is the data previously returned by an Init invocation.
void Free(TfLiteOpaqueContext* context, void* buffer);
// Called when the inputs that this node depends on have been resized.
TfLiteStatus Prepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node);
// Called when the node is executed. (Should read node inputs and write to
// node outputs).
TfLiteStatus Invoke(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node);
// Retrieves the async kernel.
TfLiteAsyncKernel AsyncKernel(TfLiteOpaqueContext* context,
TfLiteOpaqueNode* node);
A função names (ou prefixos de namespace, para C++) na sua implementação de operação não precisa corresponder aos nomes de função no snippet de código acima, já que a API de operações personalizadas do TF Lite só usará os endereços delas. Recomendamos que você declare essas funções em um namespace anônimo ou como funções estáticas.
No entanto, é recomendável incluir o nome do operador como um namespace ou prefixo nesses nomes de função:
C++
namespace my_namespace::my_custom_op { void* Init(TfLiteOpaqueContext* context, const char* buffer, size_t length) { ... } // ... plus definitions of Free, Prepare, and Invoke ... }
C
void* MyCustomOpInit(TfLiteOpaqueContext* context, const char* buffer, size_t length) { ... } // ... plus definitions of MyCustomOpFree, MyCustomOpPrepare, and // MyCustomOpInvoke.
Como essa é uma API em C, esses "métodos" são implementados como ponteiros de função em C no tipo TfLiteOperator
, que são definidos transmitindo os endereços das funções de implementação para as funções setter correspondentes TfLiteOperatorSet
MethodName:
void TfLiteOperatorSetInit(
TfLiteOperator* operator,
void* (*init)(TfLiteOpaqueContext* context, const char* buffer,
size_t length));
void TfLiteOperatorSetFree(
TfLiteOperator* operator,
void (*free)(TfLiteOpaqueContext* context, void* data));
void TfLiteOperatorSetPrepare(
TfLiteOperator* operator,
TfLiteStatus (*prepare)(TfLiteOpaqueContext* context,
TfLiteOpaqueNode* node));
void TfLiteOperatorSetInvoke(
TfLiteOperator* operator,
TfLiteStatus (*invoke)(TfLiteOpaqueContext* context,
TfLiteOpaqueNode* node));
void TfLiteOperatorSetAsyncKernel(
TfLiteOperator* operator,
struct TfLiteAsyncKernel* (*async_kernel)(TfLiteOpaqueContext* context,
TfLiteOpaqueNode* node));
Consulte
common.h
para mais detalhes sobre TfLiteContext
e TfLiteNode
. TfLiteContext
oferece recursos de relatórios de erros e acesso a objetos globais, incluindo todos os tensores.
O TfLiteNode
permite que as implementações de operadores acessem as entradas e saídas.
Quando o intérprete carrega um modelo, ele chama o método Init()
uma vez para cada
nó no gráfico. Um determinado Init()
será chamado mais de uma vez se a operação for usada várias vezes no gráfico. Para operações personalizadas, um buffer de configuração será fornecido, contendo um flexbuffer que mapeia nomes de parâmetros para os valores deles. O buffer está vazio para operações integradas porque o interpretador já analisou os parâmetros da operação. As implementações do kernel que exigem estado precisam inicializá-lo
aqui e transferir a propriedade para o autor da chamada. Para cada chamada de Init()
, haverá uma chamada correspondente para Free()
, permitindo que as implementações descartem o buffer que pode ter sido alocado em Init()
.
Sempre que os tensores de entrada forem redimensionados, o intérprete vai passar pelo
gráfico notificando as implementações da mudança. Isso dá a eles a chance de
redimensionar o buffer interno, verificar a validade de formas e tipos de entrada e
recalcular as formas de saída. Tudo isso é feito com o método Prepare()
, e
as implementações podem acessar o estado usando
TfLiteOpaqueNodeGetUserData(node)
.
Por fim, cada vez que a inferência é executada, o intérprete percorre o gráfico chamando o método Invoke()
. Aqui também o estado está disponível como TfLiteOpaqueNodeGetUserData(node)
.
As operações personalizadas podem ser implementadas definindo essas funções de "método" e, em seguida,
definindo uma função que retorna uma instância de TfLiteOperator
construída chamando TfLiteOperatorCreate
e, em seguida, os métodos
setter relevantes:
C++
namespace my_namespace::my_custom_op { namespace { void* Init(TfLiteOpaqueContext* context, const char* buffer, size_t length) { ... } void Free(TfLiteOpaqueContext* context, void* buffer) { ... } TfLiteStatus Prepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { ... } TfLiteStatus Invoke(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) {... } }; const TfLiteOperator* MyCustomOperator() { // Singleton instance, intentionally never destroyed. static const TfLiteOperator* my_custom_op = ()[] { TfLiteOperator* r = TfLiteOperatorCreate( kTfLiteBuiltinCustom, "MyCustomOp", /*version=*/ 1); TfLiteOperatorSetInit(r, Init); TfLiteOperatorSetFree(r, Free); TfLiteOperatorSetPrepare(r, Prepare); TfLiteOperatorSetInvoke(r, Eval); return r; }; return my_custom_op; } } // namespace my_namespace
C
static void* MyCustomOpInit(TfLiteOpaqueContext* context, const char* buffer, size_t length) { ... } static void MyCustomOpFree(TfLiteOpaqueContext* context, void* buffer) { ... } static TfLiteStatus MyCustomOpPrepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { ... } static TfLiteStatus MyCustomOpInvoke(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) {... } static TfLiteOperator* MyCustomOpCreate() { const TfLiteOperator* r = TfLiteOperatorCreate( kTfLiteBuiltinCustom, "MyCustomOp", /*version=*/ 1); TfLiteOperatorSetInit(r, MyCustomOpInit); TfLiteOperatorSetFree(r, MyCustomOpFree); TfLiteOperatorSetPrepare(r, MyCustomOpPrepare); TfLiteOperatorSetInvoke(r, MyCustomOpEval); return r; } const TfLiteOperator* MyCustomOperator() { // Singleton instance, intentionally never destroyed. static const TfLiteOperator* my_custom_op = MyCustomOpCreate(); return my_custom_op; }
O registro não é automático. É necessário fazer uma chamada explícita para a função
MyCustomOperator
(confira os detalhes abaixo). Embora o BuiltinOpResolver
padrão (disponível na meta :builtin_ops
) cuide do registro de builtins, as operações personalizadas precisam ser coletadas em bibliotecas personalizadas separadas.
Como definir o kernel no ambiente de execução do LiteRT
Para usar a operação no LiteRT, basta definir duas funções (Prepare
e Eval
) e uma terceira para construir um TfLiteOperator
:
C++
namespace atan_op { namespace { TfLiteStatus AtanPrepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { TF_LITE_OPAQUE_ENSURE_EQ(context, TfLiteOpaqueNodeNumInputs(node), 1); TF_LITE_OPAQUE_ENSURE_EQ(context, TfLiteOpaqueNodeNumOutputs(node), 1); const TfLiteOpaqueTensor* input = TfLiteOpaqueNodeGetInput(context, node, 0); TfLiteOpaqueTensor* output = TfLiteOpaqueNodeGetOutput(context, node, 0); int num_dims = TfLiteOpaqueTensorNumDimensions(input); TfLiteIntArray* output_size = TfLiteIntArrayCreate(num_dims); for (int i=0; i < num_dims; ++i) { output_size->data[i] = input->dims->data[i]; } return TfLiteOpaqueContextResizeTensor(context, output, output_size); } TfLiteStatus AtanEval(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { const TfLiteOpaqueTensor* input = TfLiteOpaqueNodeGetInput(context, node, 0); TfLiteOpaqueTensor* output = TfLiteOpaqueNodeGetOutput(context, node, 0); float* input_data = static_cast<float*>(TfLiteOpaqueTensorData(input)); float* output_data = static_cast<float*>(TfLiteOpaqueTensorData(output)); size_t count = 1; int num_dims = TfLiteOpaqueTensorNumDimensions(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] = atan(input_data[i]); } return kTfLiteOk; } } // anonymous namespace const TfLiteOperator* AtanOperator() { // Singleton instance, intentionally never destroyed. static const TfLiteOperator* atan_op = ()[] { auto* r = TfLiteOperatorCreate( kTfLiteBuiltinCustom, "ATAN", /*version=*/ 1); TfLiteOperatorSetPrepare(r, Prepare); TfLiteOperatorSetInvoke(r, Eval); return r; }; return atan_op; } } // namespace atan_op
C
static TfLiteStatus AtanPrepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { TF_LITE_OPAQUE_ENSURE_EQ(context, TfLiteOpaqueNodeNumInputs(node), 1); TF_LITE_OPAQUE_ENSURE_EQ(context, TfLiteOpaqueNodeNumOutputs(node), 1); const TfLiteOpaqueTensor* input = TfLiteOpaqueNodeGetInput(context, node, 0); TfLiteOpaqueTensor* output = TfLiteOpaqueNodeGetOutput(context, node, 0); int num_dims = TfLiteOpaqueTensorNumDimensions(input); TfLiteIntArray* output_size = TfLiteIntArrayCreate(num_dims); for (int i = 0; i < num_dims; ++i) { output_size->data[i] = input->dims->data[i]; } return TfLiteOpaqueContextResizeTensor(context, output, output_size); } static TfLiteStatus AtanEval(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { const TfLiteOpaqueTensor* input = TfLiteOpaqueNodeGetInput(context, node, 0); TfLiteOpaqueTensor* output = TfLiteOpaqueNodeGetOutput(context, node, 0); float* input_data = static_cast<float*>(TfLiteOpaqueTensorData(input)); float* output_data = static_cast<float*>(TfLiteOpaqueTensorData(output)); size_t count = 1; int num_dims = TfLiteOpaqueTensorNumDimensions(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] = atan(input_data[i]); } return kTfLiteOk; } static const TfLiteOperator* AtanOpCreate() { TfLiteOperator* r = TfLiteOperatorCreate( kTfLiteBuiltinCustom, "ATAN", /*version=*/ 1); TfLiteOperatorSetPrepare(r, Prepare); TfLiteOperatorSetInvoke(r, Eval); return r; } const TfLiteOperator* AtanOperator() { // Singleton instance, intentionally never destroyed. static const TfLiteOperator* atan_op = AtanOpCreate(); return atan_op; }
Ao inicializar o OpResolver
, adicione a operação personalizada ao resolver. Consulte um exemplo abaixo. Isso vai registrar o operador com o LiteRT para que ele possa usar a nova implementação.
Registre o operador com a biblioteca do kernel
Agora precisamos registrar o operador na biblioteca do kernel. Isso é feito com um OpResolver
. Nos bastidores, o intérprete carrega uma biblioteca de kernels que são atribuídos para executar cada um dos operadores no modelo.
Embora a biblioteca padrão contenha apenas kernels integrados, é possível substituí-la/aumentá-la com operadores de biblioteca personalizados.
A classe OpResolver
, que traduz códigos e nomes de operadores em código
real, é definida assim:
class OpResolver {
public:
virtual TfLiteRegistration* FindOp(tflite::BuiltinOperator op) const = 0;
virtual TfLiteRegistration* FindOp(const char* op) const = 0;
...
};
Para oferecer compatibilidade com versões anteriores, essa classe usa o tipo concreto mais antigo
TfLiteRegistration
em vez do tipo opaco TfLiteOperator
,
mas a struct TfLiteRegistration
contém um campo registration_external
do
tipo TfLiteOperator*
.
As classes MutableOpResolver
e BuiltinOpResolver
são derivadas de
OpResolver
:
class MutableOpResolver : public OpResolver {
public:
MutableOpResolver(); // Constructs an initially empty op resolver.
void AddAll(const MutableOpResolver& other);
...
};
class BuiltinOpResolver : public MutableOpResolver {
public:
BuiltinOpResolver(); // Constructs an op resolver with all the builtin ops.
};
O uso normal (sem operações personalizadas) exige que você use BuiltinOpResolver
e escreva:
tflite::ops::builtin::BuiltinOpResolver resolver;
Para adicionar a operação personalizada criada acima, use um MutableOpResolver
e chame tflite::AddOp
antes de transmitir o resolver para o InterpreterBuilder
:
tflite::ops::builtin::MutableOpResolver resolver;
resolver.AddAll(tflite::ops::builtin::BuiltinOpResolver());
tflite::AddOp(&resolver, AtanOpRegistration());
Se o conjunto de operações integradas for considerado muito grande, um novo OpResolver
poderá ser gerado por código com base em um determinado subconjunto de operações, possivelmente apenas as contidas em um determinado modelo. Isso é o equivalente ao registro seletivo do TensorFlow. Uma versão simples dele está disponível no diretório tools
.
Se você quiser definir seus operadores personalizados em Java, precisará criar sua própria camada JNI personalizada e compilar seu próprio AAR neste código JNI. Da mesma forma, se você quiser definir esses operadores disponíveis em Python, coloque os registros no código do wrapper Python.
Um processo semelhante ao acima pode ser seguido para oferecer suporte a um conjunto de operações em vez de um único operador. Basta adicionar quantos operadores AddCustom
forem necessários. Além disso, MutableOpResolver
também permite substituir
implementações de builtins usando AddBuiltin
.
Testar e criar perfil do operador
Para criar um perfil da sua operação com a ferramenta de comparativo de mercado do LiteRT, use a
ferramenta de modelo de comparativo de mercado
para o LiteRT. Para fins de teste, adicione a chamada AddCustom
apropriada (como mostrado acima) a register.cc para que seu build local do LiteRT reconheça a operação personalizada.
Práticas recomendadas
Otimize as alocações e desalocações de memória com cuidado. Alocar memória em
Prepare
é mais eficiente do que emInvoke
, e alocar memória antes de um loop é melhor do que em cada iteração. Use dados de tensores temporários em vez de fazer malloc (consulte o item 2). Use ponteiros/referências em vez de copiar o máximo possível.Se uma estrutura de dados persistir durante toda a operação, recomendamos pré-alocar a memória usando tensores temporários. Talvez seja necessário usar uma struct OpData para referenciar os índices de tensor em outras funções. Confira o exemplo no kernel para convolução. Confira abaixo um exemplo de snippet de código.
struct MyOpData { int temp_tensor_index; ... }; void* Init(TfLiteOpaqueContext* context, const char* buffer, size_t length) { auto* op_data = new MyOpData{}; ... return op_data; } void Free(TfLiteOpaqueContext* context, void* buffer) { ... delete reinterpret_cast<MyOpData*>(buffer); } TfLiteStatus Prepare(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { ... auto* op_data = reinterpret_cast<MyOpData*>(TfLiteOpaqueNodeGetUserData(node)); const int num_temporaries = 1; int temporary_tensor_indices[num_temporaries]; TfLiteOpaqueTensorBuilder* builder = TfLiteOpaqueTensorBuilderCreate(); TfLiteOpaqueTensorBuilderSetType(builder, kTfLiteFloat32); TfLiteOpaqueTensorBuilderSetAllocationType(builder, kTfLiteArenaRw); TfLiteOpaqueContextAddTensor(context, builder, &temporary_tensor_indices[0]); TfLiteOpaqueTensorBuilderDelete(builder); TfLiteOpaqueNodeSetTemporaries(node, temporary_tensor_indices, num_temporaries); op_data->temp_tensor_index = temporary_tensor_indices[0]; ... return kTfLiteOk; } TfLiteStatus Invoke(TfLiteOpaqueContext* context, TfLiteOpaqueNode* node) { ... auto* op_data = reinterpret_cast<MyOpData*>( TfLiteOpaqueNodeGetUserData(node)); TfLiteOpaqueTensor* temp_tensor = TfLiteOpaqueContextGetOpaqueTensor(context, op_data->temp_tensor_index); TF_LITE_OPAQUE_ENSURE(context, TfLiteTensorType(temp_tensor) == kTfLiteFloat32); TF_LITE_OPAQUE_ENSURE(context, TfLiteTensorGetAllocationType(temp_Tensor) == kTfLiteArenaRw); void *temp_data = TfLiteTensorData(temp_tensor); TF_LITE_OPAQUE_ENSURE(context, temp_data != nullptr); ... return kTfLiteOk; }
Se não custar muita memória desperdiçada, prefira usar uma matriz estática de tamanho fixo (ou um
std::vector
pré-alocado emResize
) em vez de usar umstd::vector
alocado dinamicamente em cada iteração de execução.Evite instanciar modelos de contêiner da biblioteca padrão que ainda não existem, porque eles afetam o tamanho do binário. Por exemplo, se você precisar de um
std::map
na sua operação que não exista em outros kernels, usar umstd::vector
com mapeamento de indexação direta pode funcionar e manter o tamanho binário pequeno. Veja o que outros kernels usam para ter insights (ou pergunte).Verifique o ponteiro para a memória retornada por
malloc
. Se esse ponteiro fornullptr
, nenhuma operação poderá ser realizada usando esse ponteiro. Se vocêmalloc
em uma função e tiver uma saída de erro, desalocar a memória antes de sair.Use
TF_LITE_OPAQUE_ENSURE(context, condition)
para verificar uma condição específica. Seu código não pode deixar a memória pendente quandoTF_LITE_OPAQUE_ENSURE
é usado. Ou seja, essas macros precisam ser usadas antes que qualquer recurso seja alocado e cause um vazamento.