Je vais vous expliquer brièvement ce qui va se passer dans cet article :
- Je vais vous montrer comment utiliser l'API PyTorch C++ pour intégrer un réseau de neurones dans un projet sur le moteur Unity ;
- Je ne vais pas décrire le projet en détail, cela n'a pas d'importance pour cet article ;
- J'utilise un modèle de réseau de neurones prêt à l'emploi, transformant son traçage en un binaire qui sera chargé au moment de l'exécution ;
- Je montrerai que cette approche facilite grandement le déploiement de projets complexes (par exemple, il n'y a pas de problèmes de synchronisation des environnements Unity et Python).
Bienvenue dans le monde réel
Les techniques d'apprentissage automatique, y compris les réseaux de neurones, sont encore très à l'aise dans les environnements expérimentaux, et lancer de tels projets dans le monde réel est souvent difficile. Je vais parler un peu de ces difficultés, décrire les limites sur la façon d'en sortir, et aussi donner une solution étape par étape au problème de l'intégration d'un réseau de neurones dans un projet Unity.
En d'autres termes, je dois transformer un projet de recherche dans PyTorch en une solution prête à l'emploi pouvant fonctionner avec le moteur Unity dans des conditions de combat.
Il existe plusieurs façons d'intégrer un réseau de neurones dans Unity. Je suggère d'utiliser l'API C ++ pour PyTorch (appelée libtorch) pour créer une bibliothèque partagée native qui peut ensuite être connectée à Unity en tant que plugin. Il existe d'autres approches (par exemple, l'utilisation de ML-Agents ), qui dans certains cas peuvent être plus simples et plus efficaces. Mais l'avantage de mon approche est qu'elle offre plus de flexibilité et plus de puissance.
Supposons que vous ayez un modèle exotique et que vous souhaitiez simplement utiliser le code PyTorch existant (qui a été écrit sans intention de communiquer avec Unity); ou votre équipe développe son propre modèle et ne veut pas être distraite par des pensées d'Unité. Dans les deux cas, le code du modèle peut être aussi complexe que vous le souhaitez et utiliser toutes les fonctionnalités de PyTorch. Et s'il s'agit soudainement d'intégration, l'API C++ entrera en jeu et encapsulera le tout dans une bibliothèque sans la moindre modification du code PyTorch d'origine du modèle.
Mon approche se résume donc à quatre étapes clés :
- Mise en place de l'environnement.
- Préparation d'une librairie native (C++).
- Importation de fonctions depuis la connexion bibliothèque/plugin (Unity/C#).
- Sauvegarde/déploiement du modèle.
IMPORTANT : puisque j'ai réalisé le projet sous Linux, certaines commandes et paramètres sont basés sur ce système d'exploitation ; mais je ne pense pas que quoi que ce soit ici doive trop dépendre d'elle. Par conséquent, il est peu probable que la préparation de la bibliothèque pour Windows cause des difficultés.
Mise en place de l'environnement
Avant d'installer libtorch, assurez-vous d'avoir
- Cfaire
Et si vous souhaitez utiliser un GPU, il vous faut :
- Boîte à outils CUDA (au moment d'écrire ces lignes, la version 10.1 était pertinente) ;
- bibliothèque CUDNN
Des difficultés peuvent survenir avec CUDA, car le pilote, les bibliothèques et autres kakis doivent être amis les uns avec les autres. Et vous devez expédier ces bibliothèques avec votre projet Unity pour que tout fonctionne immédiatement. C'est donc la partie la plus inconfortable pour moi. Si vous ne prévoyez pas d'utiliser GPU et CUDA, sachez que les calculs ralentiront de 50 à 100 fois. Et même si l'utilisateur a un GPU assez faible, c'est mieux avec que sans. Même si votre réseau de neurones est allumé assez rarement, ces rares allumages entraîneront un retard qui gênera l'utilisateur. C'est peut-être différent dans votre cas, mais... avez-vous besoin de ce risque ?
Une fois que vous avez installé le logiciel ci-dessus, il est temps de télécharger et d'installer (localement) libtorch. Il n'est pas nécessaire de l'installer pour tous les utilisateurs : vous pouvez simplement le placer dans votre répertoire de projet et vous y référer au démarrage de CMake.
Préparer une bibliothèque native
L'étape suivante consiste à configurer CMake. J'ai pris l'exemple de la documentation PyTorch comme base et je l' ai modifié pour qu'après la construction, nous obtenions la bibliothèque, pas le fichier exécutable. Placez ce fichier dans le répertoire racine de votre projet de bibliothèque natif.
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)
Le code source de la bibliothèque sera situé dans networks.cpp .
Cette approche a une autre fonctionnalité intéressante : nous n'avons pas encore à penser au réseau de neurones que nous voulons utiliser avec Unity. La raison (un peu en avance sur moi-même) est qu'à tout moment, nous pouvons exécuter le réseau en Python, en obtenir une trace et dire simplement à libtorch d'"appliquer cette trace à ces entrées". Par conséquent, nous pouvons dire que notre bibliothèque native sert simplement une sorte de boîte noire, fonctionnant avec des E/S.
Mais si vous souhaitez compliquer la tâche et, par exemple, implémenter l'entraînement réseau directement pendant l'exécution de l'environnement Unity, vous devez alors écrire l'architecture réseau et l'algorithme d'entraînement en C++. Cependant, cela sort du cadre de cet article, donc pour plus d'informations, je vous renvoie à la section appropriée de la documentation PyTorch et du référentiel d' exemples de code .
Quoi qu'il en soit, dans network.cpp, nous devons définir une fonction externe pour initialiser le réseau (démarrage à partir du disque) et une fonction externe qui démarre le réseau avec les données d'entrée et renvoie les résultats.
réseaux.cpp
#include <torch/script.h>
#include <vector>
#include <memory>
extern «C»
{
// This is going to store the loaded network
torch::jit::script::Module network;
Pour appeler nos fonctions de bibliothèque directement depuis Unity, nous devons transmettre des informations sur leurs points d'entrée. Sous Linux, j'utilise __attribute __ ((visibility ("default"))) pour cela. Sous Windows, il existe un spécificateur __declspec (dllexport) pour cela , mais pour être honnête, je n'ai pas testé si cela fonctionne là-bas . Commençons donc par la fonction de chargement d'une trace de réseau neuronal à partir du disque. Le fichier se trouve dans un chemin relatif - il se trouve à la racine du projet Unity, pas dans Assets / . Donc sois prudent. Vous pouvez également simplement transmettre le nom de fichier depuis 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
}
Passons maintenant à la fonction qui alimente le réseau en données d'entrée. Écrivons du code C++ qui utilise des pointeurs (gérés par Unity) pour boucler les données dans les deux sens. Dans cet exemple, je suppose que mon réseau a des entrées et des sorties fixes, et j'empêche Unity de changer cela. Ici, par exemple, je prendrai Tensor {1,3,64,64} et Tensor {1,5,64,64} (par exemple, un tel réseau est nécessaire pour segmenter les pixels des images RVB en 5 groupes) .
En général, vous devrez passer des informations sur la dimension et la quantité de données pour éviter les débordements de buffer.
Pour convertir les données au format avec lequel libtorch fonctionne, nous utilisons la fonction torch :: from_blob... Il prend un tableau de nombres à virgule flottante et une description de tenseur (avec des dimensions) et renvoie le tenseur généré.
Les réseaux de neurones peuvent prendre plusieurs arguments d'entrée (par exemple, call forward () prend x, y, z comme entrée). Pour gérer cela, tous les tenseurs d'entrée sont enveloppés dans un vecteur de la torche :: jit :: bibliothèque de modèles standard IValue (même s'il n'y a qu'un seul argument).
Pour obtenir des données d'un tenseur, le moyen le plus simple est de les traiter élément par élément, mais si cela ralentit la vitesse de traitement, vous pouvez utiliser Tensor :: accessor pour optimiser le processus de lecture des données . Bien que personnellement je n'en ai pas eu besoin.
En conséquence, le code simple suivant est obtenu pour mon réseau de neurones :
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>();
}
}
Pour compiler le code, suivez les instructions de la documentation , créez un sous - répertoire build/ et exécutez les commandes suivantes :
cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch <strong>..</strong> cmake --build <strong>.</strong> --config Release
Si tout se passe bien, des fichiers libnetworks.so ou networks.dll seront générés que vous pourrez placer dans Assets / Plugins / de votre projet Unity.
Connexion du plugin à Unity
Pour importer des fonctions de la bibliothèque, utilisez DllImport . La première fonction dont nous avons besoin est InitNetwork (). Lors de la connexion d'un plugin, Unity l'appellera :
using System.Runtime.InteropServices;
public class Startup : MonoBehaviour
{
...
[DllImport(«networks»)]
private static extern void InitNetwork();
void Start()
{
...
InitNetwork();
...
}
}
Pour que le moteur Unity (C#) puisse communiquer avec la librairie (C++), je vais lui confier tout le travail de gestion mémoire :
- J'allouerai de la mémoire pour les tableaux de la taille requise du côté Unity ;
- passer l'adresse du premier élément du tableau à la fonction ApplyNetwork (il doit également être importé avant cela);
- laissez simplement l'arithmétique d'adresse C++ accéder à cette mémoire lorsque les données sont reçues ou envoyées.
En code de bibliothèque (C++), je dois éviter toute allocation ou désallocation de mémoire. D'un autre côté, si je passe l'adresse du premier élément du tableau de Unity à la fonction ApplyNetwork, je dois enregistrer ce pointeur (et le morceau de mémoire correspondant) jusqu'à ce que le réseau de neurones ait fini de traiter les données.
Heureusement, ma bibliothèque native fait le travail simple de distiller les données, donc c'était assez facile à suivre. Mais si vous souhaitez paralléliser les processus afin que le réseau de neurones apprenne et traite simultanément les données pour l'utilisateur, vous devrez rechercher une sorte de solution.
[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
...
}
Enregistrement du modèle
L'article touche à sa fin et nous avons encore discuté du réseau de neurones que j'ai choisi pour mon projet. Il s'agit d'un simple réseau de neurones convolutifs qui peut être utilisé pour segmenter des images. Je n'ai pas inclus la collecte de données et la formation dans le modèle : ma tâche est de parler d'intégration avec Unity, et non de problèmes de traçage de réseaux de neurones complexes. Ne me blâmez pas.
Si vous êtes curieux, il est un bon exemple complexe ici qui donne un aperçu des cas particuliers et les problèmes potentiels. L'un des principaux problèmes est que le traçage ne fonctionne pas correctement pour tous les types de données. La documentation explique comment résoudre le problème en utilisant des annotations et une compilation explicite.
Voici à quoi pourrait ressembler le code Python de notre modèle simple :
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 z
, , , , .
() :
network = Net().cuda()
example = torch.rand(1, 3, 32, 32).cuda()
traced_network = torch.jit.trace(network, example)
traced_network.save(«network_trace.pt»)
Élargir le modèle
Nous avons créé une bibliothèque statique, mais cela ne suffit pas pour le déploiement : des bibliothèques supplémentaires doivent être incluses dans le projet. Malheureusement, je ne suis pas sûr à 100% des bibliothèques à inclure. J'ai choisi libtorch, libc10, libc10_cuda, libnvToolsExt et libcudart . Au total, ils ajoutent 2 Go à la taille d'origine du projet.
LibTorch vs ML-Agents
Je pense que pour de nombreux projets, en particulier dans la recherche et le prototypage, ML-Agents, un plugin spécialement conçu pour Unity, vaut vraiment la peine d'être choisi. Mais lorsque les projets deviennent plus complexes, vous devez jouer la sécurité - au cas où quelque chose tournerait mal. Et cela arrive assez souvent...
Il y a quelques semaines, je viens d'utiliser des ML-Agents pour communiquer entre un jeu de démonstration dans Unity et quelques réseaux de neurones écrits en Python. Selon la logique du jeu, Unity appellerait l'un de ces réseaux avec différents ensembles de données.
J'ai dû creuser profondément dans l'API Python pour ML-Agents. Certaines des opérations que j'ai utilisées dans mes réseaux de neurones, telles que le pliage 1d et la transposition, n'étaient pas prises en charge dans Barracuda (il s'agit de la bibliothèque de traçage actuellement utilisée par ML-Agents).
Le problème que j'ai rencontré était que ML-Agents collecte les "demandes" des agents pendant un certain intervalle de temps, puis les envoie pour évaluation, par exemple, à un bloc-notes Jupyter. Cependant, certains de mes réseaux de neurones dépendaient de la sortie de mes autres réseaux. Et pour avoir une estimation de toute la chaîne de mes réseaux de neurones, il faudrait que j'attende un peu, obtenir le résultat, faire une autre demande, attendre, obtenir le résultat, et ainsi de suite à chaque fois que je fais une demande. De plus, l'ordre dans lequel ces réseaux ont été mis en service ne dépendait pas trivialement de l'entrée de l'utilisateur. Cela signifiait que je ne pouvais pas simplement exécuter des réseaux de neurones de manière séquentielle.
De plus, dans certains cas, la quantité de données que je devais envoyer devait varier. Et ML-Agents est plus conçu pour une dimension fixe pour chaque agent (il semble qu'il puisse être modifié à la volée, mais je suis sceptique à ce sujet).
Je pourrais faire quelque chose comme calculer la séquence d'appel des réseaux de neurones à la demande, en envoyant l'entrée appropriée à l'API Python. Mais à cause de cela, mon code, à la fois du côté Unity et du côté Python, deviendrait trop complexe, voire redondant. Par conséquent, j'ai décidé d'étudier l'approche en utilisant libtorch, et c'était juste.
Si auparavant quelqu'un m'avait demandé de construire un modèle prédictif GPT-2 ou MAML dans un projet Unity, je lui conseillerais d'essayer de s'en passer. La mise en œuvre d'une telle tâche à l'aide de ML-Agents est trop compliquée. Mais maintenant, je peux trouver ou développer n'importe quel modèle avec PyTorch, puis l'envelopper dans une bibliothèque native qui se connecte à Unity comme un plugin normal.
Les serveurs cloud de Macleod sont rapides et sécurisés.
Inscrivez-vous en utilisant le lien ci-dessus ou en cliquant sur la bannière et bénéficiez d'une remise de 10 % pour le premier mois de location d'un serveur de n'importe quelle configuration !