Alternativa para ML-Agents: integração de redes neurais em um projeto Unity usando a API PyTorch C ++





Explicarei brevemente o que acontecerá neste artigo:



  • Mostrarei como usar a API PyTorch C ++ para integrar uma rede neural em um projeto no mecanismo Unity;
  • Não vou descrever o projeto em detalhes, não importa para este artigo;
  • Eu uso um modelo de rede neural pronto, transformando seu rastreamento em um binário que será carregado em tempo de execução;
  • Vou mostrar que essa abordagem facilita muito a implantação de projetos complexos (por exemplo, não há problemas com a sincronização dos ambientes Unity e Python).


Bem-vindo ao mundo real



As técnicas de aprendizado de máquina, incluindo redes neurais, ainda são muito confortáveis ​​em ambientes experimentais, e o lançamento de tais projetos no mundo real costuma ser difícil. Vou falar um pouco sobre essas dificuldades, descrever as limitações de como sair delas e também dar uma solução passo a passo para o problema de integração de uma rede neural em um projeto do Unity. 



Em outras palavras, preciso transformar um projeto de pesquisa em PyTorch em uma solução pronta que possa funcionar com o motor Unity em condições de combate.



Existem várias maneiras de integrar uma rede neural no Unity. Eu sugiro usar a API C ++ para PyTorch (chamada libtorch) para criar uma biblioteca nativa compartilhada que pode então ser conectada ao Unity como um plugin. Existem outras abordagens (por exemplo, usando ML-Agents ), que em certos casos podem ser mais simples e eficazes. Mas a vantagem da minha abordagem é que ela oferece mais flexibilidade e mais potência. 



Digamos que você tenha algum modelo exótico e apenas queira usar o código PyTorch existente (que foi escrito sem a intenção de se comunicar com o Unity); ou sua equipe está desenvolvendo seu próprio modelo e não quer se distrair com pensamentos de unidade. Em ambos os casos, o código do modelo pode ser tão complexo quanto você deseja e usar todos os recursos do PyTorch. E se de repente se tratar de integração, a API C ++ entrará em ação e envolverá tudo em uma biblioteca sem a menor alteração no código PyTorch original do modelo.



Portanto, minha abordagem se resume a quatro etapas principais:



  1. Configurando o ambiente.
  2. Preparando uma biblioteca nativa (C ++).
  3. Importação de funções da conexão biblioteca / plugin (Unity / C #). 
  4. Salvando / implantando o modelo.





IMPORTANTE: como fiz o projeto trabalhando no Linux, alguns comandos e configurações são baseados neste SO; mas não acho que nada aqui deva depender muito dela. Portanto, é improvável que a preparação da biblioteca para Windows cause dificuldades.



Configurando o ambiente



Antes de instalar o libtorch, certifique-se de ter



  • CMake


E se quiser usar uma GPU, você precisa de:





Podem surgir dificuldades com CUDA, porque o driver, as bibliotecas e outros caquis devem ser amigos. E você tem que enviar essas bibliotecas com seu projeto do Unity para fazer tudo funcionar fora da caixa. Então essa é a parte mais desconfortável para mim. Se você não planeja usar GPU e CUDA, você deve saber: os cálculos ficarão mais lentos em 50-100 vezes. E mesmo se o usuário tiver uma GPU um tanto fraca, é melhor com ela do que sem ela. Mesmo que sua rede neural seja ativada raramente, essas ativações raras levarão a um atraso que irritará o usuário. Pode ser diferente no seu caso, mas ... você precisa desse risco?



Depois de instalar o software acima, é hora de baixar e instalar (localmente) o libtorch. Não é necessário instalá-lo para todos os usuários: você pode simplesmente colocá-lo no diretório do seu projeto e consultá-lo ao iniciar o CMake.



Preparando uma biblioteca nativa



A próxima etapa é configurar o CMake. Peguei o exemplo da documentação do PyTorch como base e o alterei para que, após a construção, obtivéssemos a biblioteca, não o arquivo executável. Coloque este arquivo no diretório raiz do seu projeto de biblioteca nativa.



CMakeLists.txt


cmake_minimum_required(VERSION 3.0 FATAL_ERROR)

project(networks)

find_package(Torch REQUIRED)

set(CMAKE_CXX_FLAGS «${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS}»)

add_library(networks SHARED networks.cpp)

target_link_libraries(networks «${TORCH_LIBRARIES}»)

set_property(TARGET networks PROPERTY CXX_STANDARD 14)

if (MSVC)

	file(GLOB TORCH_DLLS «${TORCH_INSTALL_PREFIX}/lib/*.dll»)

	add_custom_command(TARGET networks

		POST_BUILD

		COMMAND ${CMAKE_COMMAND} -E copy_if_different

		${TORCH_DLLS}

		$<TARGET_FILE_DIR:example-app>)

endif (MSVC)
      
      





O código-fonte da biblioteca estará localizado em networks.cpp



Essa abordagem tem outro recurso interessante: não precisamos pensar sobre qual rede neural queremos usar com o Unity ainda. O motivo (ficando um pouco à frente de mim) é que a qualquer momento podemos executar a rede em Python, obter um rastreamento dela e apenas dizer ao libtorch para "aplicar este rastreamento a essas entradas". Portanto, podemos dizer que nossa biblioteca nativa está simplesmente servindo uma espécie de caixa preta, trabalhando com E / S.



Mas se você quiser complicar a tarefa e, por exemplo, implementar o treinamento de rede diretamente enquanto o ambiente Unity está em execução, você deve escrever a arquitetura de rede e o algoritmo de treinamento em C ++. No entanto, isso está fora do escopo deste artigo, portanto, para obter mais informações, indico a seção relevante da documentação do PyTorch e o repositório de exemplos de código .



De qualquer forma, em network.cpp precisamos definir uma função externa para inicializar a rede (boot do disco) e uma função externa que inicia a rede com dados de entrada e retorna os resultados.



redes.cpp


#include <torch/script.h>

#include <vector>

#include <memory> 

extern «C»

{

// This is going to store the loaded network

torch::jit::script::Module network;
      
      





Para chamar nossas funções de biblioteca diretamente do Unity, precisamos passar informações sobre seus pontos de entrada. No Linux, eu uso __attribute __ ((visibilidade ("padrão"))) para isso. No Windows, há um especificador __declspec (dllexport) para isso , mas para ser honesto, não testei se funciona lá . Então, vamos começar com a função de carregar um rastreamento de rede neural do disco. O arquivo está em um caminho relativo - está na raiz do projeto Unity, não em Assets / . Por isso tem cuidado. Você também pode simplesmente passar o nome do arquivo do Unity.  






extern __attribute__((visibility(«default»))) void InitNetwork()

{
	network = torch::jit::load(«network_trace.pt»);

	network.to(at::kCUDA); // If we're doing this on GPU
}

      
      





Agora vamos passar para a função que alimenta a rede com dados de entrada. Vamos escrever o código C ++ que usa ponteiros (gerenciados pelo Unity) para fazer um loop de dados para frente e para trás. Neste exemplo, estou assumindo que minha rede tem entradas e saídas fixas e evito que o Unity mude isso. Aqui, por exemplo, pegarei Tensor {1,3,64,64} e Tensor {1,5,64,64} (por exemplo, essa rede é necessária para segmentar os pixels de imagens RGB em 5 grupos) .



Em geral, você terá que passar informações sobre a dimensão e a quantidade de dados para evitar estouros de buffer.



Para converter os dados para o formato com o qual libtorch trabalha, usamos a função torch :: from_blob... Ele pega uma matriz de números de ponto flutuante e uma descrição do tensor (com dimensões) e retorna o tensor gerado.



As redes neurais podem ter vários argumentos de entrada (por exemplo, call forward () leva x, y, z como entrada). Para lidar com isso, todos os tensores de entrada são agrupados em um vetor da biblioteca de modelos padrão torch :: jit :: IValue (mesmo se houver apenas um argumento).



Para obter dados de um tensor, a maneira mais fácil é processá-lo elemento por elemento, mas se isso diminuir a velocidade de processamento, você pode usar Tensor :: accessor para otimizar o processo de leitura de dados . Embora pessoalmente eu não precisasse disso.



Como resultado, o seguinte código simples é obtido para minha rede neural:



extern __attribute__((visibility(«default»))) void ApplyNetwork(float *data, float *output)

{

Tensor x = torch::from_blob(data, {1,3,64,64}).cuda();

std::vector<torch::jit::IValue> inputs;

inputs.push_back(x);

Tensor z = network.forward(inputs).toTensor();

for (int i=0;i<1*5*64*64;i++)

output[i] = z[0][i].item<float>();

}

}

      
      





Para compilar o código, siga as instruções na documentação , crie um build / subdiretório e execute os seguintes comandos:



cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch <strong>..</strong>

cmake --build <strong>.</strong> --config Release

      
      





Se tudo correr bem, serão gerados os arquivos libnetworks.so ou networks.dll que você pode colocar em Assets / Plugins / do seu projeto Unity.



Conectando o plugin ao Unity



Para importar funções da biblioteca, use DllImport . A primeira função de que precisamos é InitNetwork (). Ao conectar um plugin, o Unity o chamará de:



using System.Runtime.InteropServices;

public class Startup : MonoBehaviour

{

...

[DllImport(«networks»)]

private static extern void InitNetwork();

void Start()

{

...

InitNetwork();

...

}

}

      
      





Para que o motor Unity (C #) possa se comunicar com a biblioteca (C ++), vou confiar a ela todo o trabalho de gerenciamento de memória:



  • Alocarei memória para matrizes do tamanho necessário no lado do Unity;
  • passe o endereço do primeiro elemento do array para a função ApplyNetwork (também precisa ser importado antes disso);
  • apenas deixe que a aritmética de endereços C ++ acesse essa memória quando os dados forem recebidos ou enviados.


No código da biblioteca (C ++), tenho que evitar qualquer alocação ou desalocação de memória. Por outro lado, se eu passar o endereço do primeiro elemento do array do Unity para a função ApplyNetwork, tenho que salvar este ponteiro (e o pedaço de memória correspondente) até que a rede neural termine de processar os dados.



Felizmente, minha biblioteca nativa faz o trabalho simples de destilar os dados, por isso foi fácil de controlar. Mas se você quiser paralelizar os processos de modo que a rede neural aprenda e processe simultaneamente os dados para o usuário, você terá que procurar algum tipo de solução.



[DllImport(«networks»)]

private static extern void ApplyNetwork(ref float data, ref float output);

void SomeFunction() {

float[] input = new float[1*3*64*64];

float[] output = new float[1*5*64*64];

// Load input with whatever data you want

...

ApplyNetwork(ref input[0], ref output[0]);

// Do whatever you want with the output

...

}

      
      





Salvando o modelo



O artigo está chegando ao fim e ainda discutimos qual rede neural eu escolhi para meu projeto. É uma rede neural convolucional simples que pode ser usada para segmentar imagens. Não incluí coleta de dados e treinamento no modelo: minha tarefa é falar sobre integração com o Unity, e não sobre problemas com o rastreamento de redes neurais complexas. Não me culpe.



Se você estiver curioso, um exemplo bom e complexo aqui que descreve alguns casos especiais e problemas potenciais. Um dos principais problemas é que o rastreamento não funciona corretamente para todos os tipos de dados. A documentação explica como resolver o problema usando anotações e compilação explícita.



Esta é a aparência do código Python para nosso modelo simples:



import torch

import torch.nn as nn

import torch.nn.functional as F

class Net(nn.Module):

def __init__(self):

super().__init__()

self.c1 = nn.Conv2d(3,64,5,padding=2)

self.c2 = nn.Conv2d(64,5,5,padding=2)

def forward(self, x): z = F.leaky_relu(self.c1(x)) z = F.log_softmax(self.c2(z), dim=1)

return

   , , , ,  .

 ()       :

network = Net().cuda()

example = torch.rand(1, 3, 32, 32).cuda()

traced_network = torch.jit.trace(network, example)

traced_network.save(«network_trace.pt»)

      
      





Expandindo o modelo



Fizemos uma biblioteca estática, mas isso não é suficiente para implantação: bibliotecas adicionais precisam ser incluídas no projeto. Infelizmente, não estou 100% certo de quais bibliotecas devem ser incluídas. Eu escolhi libtorch, libc10, libc10_cuda, libnvToolsExt e libcudart . No total, eles adicionam 2 GB ao tamanho original do projeto. 



LibTorch vs ML-Agents



Acredito que para muitos projetos, especialmente em pesquisa e prototipagem, ML-Agents, um plugin construído especificamente para Unity, realmente vale a pena escolher. Mas quando os projetos ficam mais complexos, você precisa jogar pelo seguro - caso algo dê errado. E isso acontece com bastante frequência ...



Algumas semanas atrás, acabei de usar ML-Agents para me comunicar entre um jogo de demonstração no Unity e algumas redes neurais escritas em Python. Dependendo da lógica do jogo, o Unity chamaria uma dessas redes com conjuntos de dados diferentes.



Tive que me aprofundar na API Python para agentes de ML. Algumas das operações que usei em minhas redes neurais, como 1d fold e transpose, não eram suportadas no Barracuda (esta é a biblioteca de rastreamento usada atualmente pelos ML-Agents).



O problema que encontrei foi que os ML-Agents coletam "solicitações" dos agentes durante um determinado intervalo de tempo e, em seguida, os envia para avaliação, por exemplo, para um notebook Jupyter. No entanto, algumas de minhas redes neurais dependiam da saída de minhas outras redes. E para obter uma estimativa de toda a cadeia de minhas redes neurais, eu teria que esperar um pouco, obter o resultado, fazer outra solicitação, esperar, obter o resultado e assim por diante toda vez que fizer uma solicitação. Além disso, a ordem em que essas redes foram colocadas em operação não dependia de maneira trivial da entrada do usuário. Isso significava que eu não poderia simplesmente executar redes neurais em sequência. 



Além disso, em alguns casos, a quantidade de dados que precisei enviar teve que variar. E o ML-Agents é mais projetado para uma dimensão fixa para cada agente (parece que pode ser alterado na hora, mas sou cético quanto a isso).



Eu poderia fazer algo como calcular a sequência de chamadas de redes neurais sob demanda, enviando a entrada apropriada para a API Python. Mas por causa disso, meu código, tanto no lado do Unity quanto no lado do Python, se tornaria muito complexo, ou mesmo redundante. Portanto, decidi estudar a abordagem usando libtorch, e estava certo.



Se anteriormente alguém tivesse me pedido para construir um modelo preditivo GPT-2 ou MAML em um projeto do Unity, eu o aconselharia a tentar fazer sem ele. Implementar tal tarefa usando ML-Agents é muito complicado. Mas agora posso encontrar ou desenvolver qualquer modelo com PyTorch e envolvê-lo em uma biblioteca nativa que se conecta ao Unity como um plug-in regular.






Os servidores em nuvem da Macleod são rápidos e seguros.



Cadastre-se pelo link acima ou clicando no banner e ganhe 10% de desconto no primeiro mês de aluguel de um servidor de qualquer configuração!






All Articles