Sur Internet, il existe un grand nombre de manuels et d'exemples, sur la base desquels, chers lecteurs, vous pourrez écrire du code «sans trop de difficulté» et avec un temps «minimal» permettant de distinguer les chats des chiens sur une photo. Et pourquoi alors perdre du temps sur cet article?
À mon avis, le principal inconvénient de tous ces exemples réside dans les possibilités limitées. Vous avez pris un exemple - même avec le réseau neuronal de base que l'auteur propose - l'a lancé, peut-être même que cela a fonctionné, et ensuite? Comment faire fonctionner ce code simple sur un serveur de production? Comment le mettre à jour et le maintenir? C'est là que le plaisir commence. Je n'ai pas pu trouver une description complète du processus à partir du moment «Eh bien, l'ingénieur ML a formé le réseau neuronal» à «finalement nous l'avons déployé en production». Et j'ai décidé de combler cet écart.
Je ne parlerai pas de la façon d'enseigner au réseau neuronal de nouvelles choses amusantes qui vous plairont et vous aideront à gagner un tas de billets croustillants. C'est un excellent sujet pour un article séparé. À titre d'exemple, j'utiliserai un réseau de neurones qui peut être téléchargé gratuitement. La tâche principale que je me suis fixée est de donner une description complète du processus d'introduction d'un réseau neuronal en fonctionnement.
Je réponds immédiatement à la question «Pourquoi pas en Python?»: Nous utilisons Scala pour les solutions de production en raison de l'écriture plus pratique et stable du code multi-thread.
Table des matières
1. Énoncé du problème2. Technologies utilisées3. Préparation d'un conteneur docker de base4. Structure du projet5. Chargement du réseau neuronal6. Implémentation de l'API REST7. Test8. Assemblage d'un microservice basé sur une image de base9. Démarrage d'un microservice sur un serveur de production avec un GPUConclusionLes références1. Énoncé du problème
Supposons que nous ayons une grande base de données de photos avec différents objets et que nous devons créer un microservice qui recevra une image dans une demande HTTP POST et répondra au format JSON. La réponse doit contenir le nombre d'objets trouvés et leurs classes, le degré de probabilité qu'il s'agisse exactement de l'objet de la classe déclarée et les coordonnées des rectangles couvrant les limites de chaque objet.
2. Technologies utilisées
- Scala 2.12.7 + ensemble minimum de bibliothèques supplémentaires, Sbt 1.2.6 avec le plugin Sbt-pack 0.12 pour construire des codes sources.
- MXNet 1.3.1 (la dernière version stable au moment de la rédaction), compilé pour Scala 2.12.
- Serveur avec cartes graphiques Nvidia.
- Cuda 9.0 et Cudnn 7 installés sur le serveur.
- Java 8 pour exécuter du code compilé.
- Docker pour faciliter l'assemblage, la livraison et le lancement du microservice sur le serveur.
3. Préparation d'un conteneur docker de base
Pour notre microservice, vous aurez besoin d'une image Docker de base dans laquelle le nombre minimum de dépendances nécessaires à l'exécution sera installé. Pour l'assemblage, nous utiliserons l'image avec Sbt installé en plus. Oui, nous construirons les sources elles-mêmes non pas dans l'environnement local, mais dans le conteneur Docker. Cela facilitera la transition vers l'assemblage via CI, par exemple, via gitlab CI.
Structure des dossiers:
\ | ----- install | | ----- java8.sh | | ----- mxnet_2_12.sh | | ----- opencv.sh | | ----- sbt.sh | | ----- scala.sh | | ----- timeZone.sh | ----- scala-mxnet-cuda-cudnn | ----- Dockerfile.2.12-1.3.1-9-7-builder | ----- Dockerfile.2.12-1.3.1-9-7-runtime
Dockerfile.2.12-1.3.1-9-7-runtime
Cette image sera utilisée pour le lancement final du microservice. Il est basé sur l'image officielle de Nvidia avec CUDA 9.0 et CUDNN 7. préinstallés. La documentation de MXNet 1.3.1 prétend fonctionner avec CUDA 8.0, mais, comme la pratique l'a montré, tout fonctionne bien avec la version 9.0, et même un peu plus vite.
De plus, nous installerons Java 8, MXNet 1.3.1 (nous le construirons sous Scala 2.12), OpenCV 3.4.3 et l'utilitaire Linux pour définir le fuseau horaire dans cette image.
# Nvidia cuda 9.0 cudnn 7 FROM nvidia/cuda:9.0-cudnn7-devel AS builder # ENV MXNET_VERSION 1.3.1 ENV MXNET_BUILD_OPT "USE_OPENCV=1 USE_BLAS=openblas USE_CUDA=1 USE_CUDA_PATH=/usr/local/cuda USE_CUDNN=1" ENV CUDA_STUBS_DIR "/usr/local/cuda-9.0/targets/x86_64-linux/lib/stubs" ENV OPEN_CV_VERSION 3.4.3 ENV OPEN_CV_INSTALL_PREFIX /usr/local ENV JAVA_HOME /usr/lib/jvm/java-8-oracle/ ENV TIME_ZONE Europe/Moscow # COPY install /install RUN chmod +x -R /install/* # RUN apt-get update WORKDIR /install RUN ./timeZone.sh ${TIME_ZONE} RUN ./java8.sh RUN ./mxnet_2_12.sh ${MXNET_VERSION} "${MXNET_BUILD_OPT}" ${CUDA_STUBS_DIR} RUN ./opencv.sh ${OPEN_CV_VERSION} ${OPEN_CV_INSTALL_PREFIX} # RUN apt-get autoclean -y && \ rm -rf /var/cache/* /install # FROM nvidia/cuda:9.0-cudnn7-devel COPY --from=builder --chown=root:root / /
Les scripts timeZone.sh java8.sh et opencv.sh sont assez triviaux, donc je ne m'attarderai pas sur eux en détail, ils sont présentés ci-dessous.
timeZone.sh
java8.sh
opencv.sh
L'installation de MXNet n'est pas si simple. Le fait est que tous les assemblages de cette bibliothèque pour Scala sont faits sur la base de la version 2.11 du compilateur, et cela est justifié, car la bibliothèque comprend un module pour travailler avec Spark, qui, à son tour, est écrit en Scala 2.11. Étant donné que nous utilisons Scala 2.12.7 en développement, les bibliothèques compilées ne nous conviennent pas et nous ne pouvons pas passer à la version 2.11. * Nous ne pouvons pas, en raison de la grande quantité de code déjà écrit sur la nouvelle version de Scala. Que faire Obtenez beaucoup de plaisir à collecter MXNet à partir des sources pour notre version de Scala. Ci-dessous, je donnerai un script pour construire et installer MXNet 1.3.1 pour Scala 2.12. * Et commenter les points principaux.
mxnet_2_12.sh
La partie la plus intéressante commence par cette ligne:
ln -s ${CUDA_STUBS_DIR}/libcuda.so ${CUDA_STUBS_DIR}/libcuda.so.1 && \
Si vous exécutez l'assemblage MXNet comme dans les instructions, nous obtiendrons une erreur. Le compilateur ne peut pas trouver la bibliothèque libcuda.so.1, nous lierons donc la bibliothèque libcuda.so à libcuda.so.1. Cela peut ne pas vous déranger, lorsque vous le démarrez sur un serveur de production, nous remplacerons cette bibliothèque par une locale. Notez également que le chemin d'accès aux bibliothèques CUDA à partir de la variable d'environnement
CUDA_STUBS_DIR
a été ajouté à
LD_LIBRARY_PATH
. Si cela n'est pas fait, l'assemblage échouera également.
Dans ces lignes, nous remplaçons la version de Scala 2.11 par 2.12 dans tous les fichiers nécessaires en utilisant une expression régulière, qui a été sélectionnée expérimentalement, car il ne suffit pas de remplacer simplement 2.11 partout par 2.12:
sed -rim 's/([a-zA-Z])_2.11/\1_2.12/g' $(find scala-package -name pom.xml) && \ sed -im 's/SCALA_VERSION_PROFILE := scala-2.11/SCALA_VERSION_PROFILE := scala-2.12/g' Makefile && \ sed -im 's/<module>spark<\/module>/<\!--<module>spark<\/module>-->/g' scala-package/pom.xml && \ make scalapkg ${MXNET_BUILD_OPT} && \
Et puis la dépendance du module pour travailler avec Spark est commentée. Si cela n'est pas fait, la bibliothèque ne sera pas assemblée.
Ensuite, exécutez l'assembly, comme indiqué dans les instructions, copiez la bibliothèque assemblée dans un dossier partagé et supprimez toutes les ordures que Maven a pompées pendant le processus de construction (si cela n'est pas fait, l'image finale augmentera d'environ 3-4 Go, ce qui peut entraîner votre DevOps s nerveux).
Nous collectons l'image, étant dans le répertoire racine du projet (voir. Structure des dossiers):
your@pc$ docker build -f Dockerfile.2.12-1.3.1-9-7-runtime -t entony/scala-mxnet-cuda-cudnn:2.12-1.3.1-9-7-runtime .
Permettez-moi de vous rappeler juste au cas où le point à la fin indique que nous faisons l'assemblage dans le contexte du répertoire actuel.
Il est maintenant temps de parler de l'image de construction.
Dockerfile.2.12-1.3.1-9-7-builder
# runtime-, FROM entony/scala-mxnet-cuda-cudnn:2.12-1.3.1-9-7-runtime # ENV SCALA_VERSION 2.12.7 ENV SBT_VERSION 1.2.6 # COPY install /install RUN chmod +x -R /install/* # RUN apt-get update && \ cd /install && \ ./scala.sh ${SCALA_VERSION} && \ ./sbt.sh ${SBT_VERSION} # RUN rm -rf /install
C'est simple, nous n'avons pas besoin de Scala et Sbt pour démarrer notre microservice, il est donc inutile de les faire glisser dans l'image de base pour le lancement. Par conséquent, nous allons créer une image distincte qui sera utilisée uniquement pour l'assemblage. Les scripts scala.sh et sbt.sh sont assez triviaux et je ne m'attarderai pas sur eux en détail.
scala.sh
sbt.sh
Nous collectons l'image, étant dans le répertoire racine du projet (voir. Structure des dossiers):
your@pc$ docker build -f Dockerfile.2.12-1.3.1-9-7-builder -t entony/scala-mxnet-cuda-cudnn:2.12-1.3.1-9-7-builder .
À la fin de l'article, il y a des liens vers le référentiel avec tous ces fichiers.
4. Structure du projet
Après avoir terminé la préparation de l'assemblage du projet, faisons ce que vous avez décidé de consacrer du temps à cet article.
Le projet de notre microservice aura la structure suivante:
\ | ----- dependencies | | ----- mxnet-full_2.12-linux-x86_64-gpu-1.3.1-SNAPSHOT.jar | ----- models | | ----- resnet50_ssd_model-0000.params | | ----- resnet50_ssd_model-symbol.json | | ----- synset.txt | ----- project | | ----- build.properties | | ----- plugins.sbt | ----- src | | ----- main | | | ----- resources | | | | ----- cat_and_dog.jpg | | | ----- scala | | | | ----- simple.predictor | | | | ----- Config | | | | ----- Model | | | | ----- Server | | | | ----- Run | | ----- test | | | ----- scala | | | | ----- simple.predictor | | | | ----- ServerTest | ----- build.sbt | ----- Dockerfile
Il s'agit de la structure standard d'un projet Scala, à l'exception des dépendances et des modèles de répertoires.
Le répertoire des dépendances contient la bibliothèque MXNet pour Scala. Il peut être obtenu de deux manières:
- construire MXNet sur la machine sur laquelle vous allez développer (notez que la bibliothèque n'est pas multi-plateforme; si vous la construisez sur Linux, elle ne fonctionnera pas sur Mac OS),
- ou retirez-le de l'image Docker que nous avons créée plus tôt. Si vous décidez de créer MXNet dans un environnement local, le script mxnet_2.12.sh vous aidera.
Vous pouvez extraire des bibliothèques de l'image Docker comme ceci:
Le répertoire des modèles contient les fichiers d'un réseau neuronal formé, vous pouvez les télécharger librement comme suit:
Plus brièvement sur les fichiers qui ne présentent pas d'intérêt particulier, mais qui jouent un rôle dans le projet.
project / build.properties
# Sbt, sbt.version = 1.2.6
project / plugins.sbt
src / main / resources / cat_and_dog.jpg
Une si belle image, dans laquelle notre réseau de neurones recherchera un chat et un chien.

build.sbt
enablePlugins(PackPlugin) name := "simple-predictor" version := "0.1" scalaVersion := "2.12.7" unmanagedBase := baseDirectory.value / "dependencies"
simple.predictor.Config
Cet objet stocke des variables globales dont la valeur est lue à partir des variables d'environnement ou définie par défaut.
package simple.predictor import org.apache.mxnet.Context import scala.util.Try object Config {
simple.predictor.Run
L'objet Run est le point d'entrée de l'application.
package simple.predictor import java.net.InetSocketAddress
5. Chargement du réseau neuronal
Le réseau neuronal est chargé dans le constructeur de la classe
simple.predictor.Model
.
simple.predictor.Model
package simple.predictor import java.awt.image.BufferedImage import org.apache.mxnet._ import org.apache.mxnet.infer.ObjectDetector import simple.predictor.Model.Prediction class Model(prefix: String, epoch: Int, imageEdge: Int, threshold: Float, context: Context) {
Dans la section
vous dites que dans un réseau de neurones, cela fonctionnera avec
NDArray
avec une dimension de 1 x 3 x 512 x 512, où 1 est le nombre d'images qui seront contenues dans NDArray, 3 est le nombre de couleurs et 512 x 512 - la taille de l'image (la valeur de
imageEdge = 12
est définie dans l'objet
simple.predict.Config
, c'est la taille latérale de l'image utilisée pour entraîner le réseau neuronal). Toutes ces descriptions de données sont transmises à l'
ObjectDetector
.
Une autre section intéressante est le
.
Après avoir exécuté l'image à travers le réseau neuronal, le résultat est du type
Seq[Seq[(String, Array[Float])]]
. La première collection ne contient qu'un seul résultat (le format des données est déterminé par un réseau neuronal spécifique), puis chaque élément de la collection suivante est un tuple de deux éléments:
- nom de classe ("chat", "chien", ...),
- un tableau de cinq nombres à virgule flottante: le premier est la probabilité, le second est le coefficient pour calculer la coordonnée
x
, le troisième est le coefficient pour calculer la coordonnée y
, le quatrième est le coefficient pour calculer la largeur du rectangle, et le cinquième est le coefficient pour calculer la hauteur du rectangle.
Pour obtenir les valeurs réelles des coordonnées et des dimensions du rectangle, vous devez multiplier la largeur et la hauteur d'origine de l'image par les coefficients correspondants.
Je me permets une petite digression sur le sujet de
NDArray
. Il s'agit d'un tableau multidimensionnel que MXNet crée dans un contexte donné (CPU ou GPU). Lors de la création d'un NDArray, un objet C ++ est formé, un objet avec lequel les opérations sont effectuées très rapidement (et s'il est créé dans un contexte GPU, il est presque instantané), mais vous devez payer pour une telle vitesse. Par conséquent (au moins dans la version MXNet 1.3.1), vous devez gérer indépendamment la mémoire allouée à
NDArray
, et n'oubliez pas de décharger ces objets de la mémoire après avoir fini de travailler avec eux. Sinon, il y aura une fuite de mémoire importante et assez rapide, ce qui n'est pas très pratique à surveiller, car les programmes de profilage JVM ne le voient pas. Le problème de mémoire est aggravé si vous travaillez dans un contexte GPU, car les cartes vidéo n'ont pas une grande quantité de mémoire et l'application se bloque rapidement en mémoire.
Comment résoudre un problème de fuite de mémoire?
Dans l'exemple ci-dessus, dans la ligne
model.imageObjectDetect(image).head map toPrediction(image.getWidth, image.getHeight) filter (_.probability > threshold)
, la méthode
imageObjectDetect
est utilisée pour exécuter l'image via le réseau de neurones, qui reçoit une entrée
BufferedImage
. Toutes les conversions vers et depuis
NDArray
sont effectuées à l'intérieur de la méthode, et vous n'avez pas besoin de penser aux problèmes de désallocation de mémoire. D'un autre côté, avant de convertir
BufferedImage
en
NDArray
est effectué à une taille de 512 x 512 et l'image est normalisée à l'aide des méthodes d'un objet de type
BufferedImage
. Cela se produit un peu plus longtemps que lors de l'utilisation d'OpenCV, par exemple, mais cela résout le problème de la libération de mémoire après l'utilisation de
NDArray
.
Vous pouvez, bien sûr, utiliser OpenCV et contrôler la mémoire vous-même, pour cela, il vous suffit d'appeler la méthode d'
dispose
de
dispose
, mais pour une raison quelconque, vous avez oublié de le mentionner dans la documentation MXNet officielle de Scala.
MXNet a également un moyen peu pratique de contrôler la fuite de mémoire qui se produit en raison de
NDArray
. Pour ce faire, exécutez l'application avec le paramètre JVM
Dmxnet.traceLeakedObjects=true
. Si MXNet remarque un
NDArray
qui n'est pas utilisé mais se bloque en mémoire, vous obtiendrez une exception indiquant dans quelle ligne de code le
NDArray
malheureux
NDArray
.
Mon conseil: travaillez directement avec NDArray, surveillez attentivement la mémoire et écrivez vous-même la normalisation, après avoir spécifié quel algorithme l'ingénieur ML a fait lors de la formation d'un réseau de neurones, sinon les résultats seront complètement différents.
ObjectDetector
possède une méthode
objectDetectWithNDArray
à laquelle vous pouvez passer un
NDArray
. Pour implémenter une approche plus universelle du chargement d'un réseau de neurones, je recommande d'utiliser l'objet
org.apache.mxnet.module.Module
. Voici un exemple d'utilisation.
import org.apache.mxnet._ import org.apache.mxnet.io.NDArrayIter
6. Implémentation de l'API REST
La classe
simple.predictor.Server
est responsable de l'implémentation de l'API REST. Le serveur est basé sur le serveur Java inclus dans Java.
simple.predictor.Server
package simple.predictor import java.net.InetSocketAddress import com.sun.net.httpserver.{HttpExchange, HttpServer} import javax.imageio.ImageIO import org.json4s.DefaultFormats import org.json4s.native.Serialization class Server(address: InetSocketAddress, entryPoint: String, model: Model) {
7. Test
Pour vérifier, démarrez le serveur et envoyez une image de test src / main / resources / cat_and_dog.jpg. Nous analyserons le JSON reçu du serveur, vérifierons combien et quels objets le réseau neuronal a trouvés dans l'image et encerclons les objets dans l'image.
simple.predictor.ServerTest
package simple.predictor import java.awt.{BasicStroke, Color, Font} import java.awt.image.BufferedImage import java.io.{ByteArrayOutputStream, File} import java.net.InetSocketAddress import javax.imageio.ImageIO import org.scalatest.{FlatSpec, Matchers} import scalaj.http.Http import org.json4s.{DefaultFormats, Formats} import org.json4s.native.JsonMethods.parse import simple.predictor.Config._ import simple.predictor.Model.Prediction import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global class ServerTest extends FlatSpec with Matchers { implicit val formats: Formats = DefaultFormats "Service" should "find a cat and a dog on photo" in {
, .

8.
, . Docker , .
Dockerfile
# Sbt FROM entony/scala-mxnet-cuda-cudnn:2.12-1.3.1-9-7-builder AS builder # RUN mkdir /tmp/source /tmp/source/dependencies COPY project /tmp/source/project COPY src /tmp/source/src COPY build.sbt /tmp/source/build.sbt # MXNet, RUN ln -s /usr/local/share/mxnet/scala/linux-x86_64-gpu/mxnet-full_2.12-linux-x86_64-gpu-1.3.1-SNAPSHOT.jar /tmp/source/dependencies/mxnet-full_2.12-linux-x86_64-gpu-1.3.1-SNAPSHOT.jar && \ cd /tmp/source/ && sbt pack # FROM entony/scala-mxnet-cuda-cudnn:2.12-1.3.1-9-7-runtime # LD Cuda Java ENV LD_LIBRARY_PATH /usr/local/cuda-9.0/targets/x86_64-linux/lib/stubs:/usr/local/share/OpenCV/java # /opt/app/models ENV MODEL_PREFIX "/opt/app/models/resnet50_ssd_model" # RUN mkdir -p /opt/app COPY --from=builder --chown=root:root /tmp/source/target/pack /opt/app COPY models /opt/app/models # ENTRYPOINT /opt/app/bin/simple-predictor
9. production- GPU
, docker hub, Nvidia, 8080 Docker, Cuda 9.0 Cudnn 7.
Docker-
--device
Cuda-
-v
.
MODEL_CONTEXT_GPU
GPU-,
MXNET_CUDNN_AUTOTUNE_DEFAULT
( , , , ).
:
Conclusion
MXNet , - . , , , production.
, , MXNet , Python production Scala, Java ++.
, .
, . . Merci de votre attention.
Les références