En Internet hay una gran cantidad de manuales y ejemplos, sobre la base de los cuales ustedes, queridos lectores, podrán "sin mucha dificultad" y con costos de tiempo "mínimos" escribir código que pueda distinguir a los gatos de los perros en una foto. ¿Y por qué perder tiempo en este artículo?
El principal inconveniente, en mi opinión, de todos estos ejemplos son las posibilidades limitadas. Tomó un ejemplo, incluso con la red neuronal básica que ofrece el autor, lo lanzó, tal vez incluso funcionó, y ¿qué sigue? ¿Cómo hacer que este código simple comience a funcionar en un servidor de producción? ¿Cómo actualizarlo y mantenerlo? Aquí es donde comienza la diversión. No pude encontrar una descripción completa del proceso desde el momento "Bueno, el ingeniero de ML entrenó la red neuronal" hasta que "finalmente la implementamos en producción". Y decidí cerrar esta brecha.
No hablaré sobre cómo enseñarle a la red neuronal nuevas cosas divertidas que te complacerán y te ayudarán a ganar un montón de billetes crujientes. Este es un gran tema para un artículo separado. Como ejemplo, usaré una red neuronal que se puede descargar libremente. La tarea principal que me propuse es dar una descripción completa del proceso de introducción de una red neuronal en funcionamiento.
Inmediatamente respondo la pregunta "¿Por qué no en Python?": Utilizamos Scala para soluciones de producción debido a la escritura más conveniente y estable de código multiproceso.
Contenido
1. Declaración del problema2. Tecnologías utilizadas3. Preparando un contenedor básico4. Estructura del proyecto.5. Carga de la red neuronal6. Implementación de la API REST7. Prueba8. Ensamblar un microservicio basado en una imagen básica9. Inicio de un microservicio en un servidor de producción con una GPUConclusiónReferencias1. Declaración del problema
Supongamos que tenemos una gran base de datos de fotos con diferentes objetos, y necesitamos hacer un microservicio que reciba una imagen en una solicitud HTTP POST y responda en formato JSON. La respuesta debe contener el número de objetos encontrados y sus clases, el grado de probabilidad de que este sea exactamente el objeto de la clase declarada y las coordenadas de los rectángulos que cubren los límites de cada objeto.
2. Tecnologías utilizadas
- Scala 2.12.7 + conjunto mínimo de bibliotecas adicionales, Sbt 1.2.6 con el complemento Sbt-pack 0.12 para construir códigos fuente.
- MXNet 1.3.1 (la última versión estable en el momento de la escritura), compilada para Scala 2.12.
- Servidor con tarjetas gráficas Nvidia.
- Cuda 9.0 y Cudnn 7 instalados en el servidor.
- Java 8 para ejecutar código compilado.
- Docker para facilitar el montaje, la entrega y el lanzamiento del microservicio en el servidor.
3. Preparando un contenedor básico
Para nuestro microservicio, necesitará una imagen básica de Docker en la que se instalará la cantidad mínima de dependencias necesarias para ejecutar. Para el montaje, utilizaremos la imagen con Sbt instalado adicionalmente. Sí, crearemos las propias fuentes no en el entorno local, sino en el contenedor Docker. Esto facilitará la transición hacia el ensamblaje a través de CI, por ejemplo, a través de gitlab CI.
Estructura de la carpeta:
\ | ----- 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
Esta imagen se utilizará para el lanzamiento final del microservicio. Se basa en la imagen oficial de Nvidia con CUDA 9.0 y CUDNN 7. preinstalados. La documentación para MXNet 1.3.1 afirma que funciona con CUDA 8.0, pero, como la práctica ha demostrado, todo funciona bien con la versión 9.0, e incluso un poco más rápido.
Además, instalaremos Java 8, MXNet 1.3.1 (lo crearemos en Scala 2.12), OpenCV 3.4.3 y la utilidad Linux para configurar la zona horaria en esta imagen.
# 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 / /
Los scripts timeZone.sh java8.sh y opencv.sh son bastante triviales, por lo que no me detendré en ellos en detalle, se presentan a continuación.
timeZone.sh
java8.sh
opencv.sh
Instalar MXNet no es tan simple. El hecho es que todos los ensamblajes de esta biblioteca para Scala se realizan sobre la base de la versión del compilador 2.11, y esto está justificado, ya que la biblioteca incluye un módulo para trabajar con Spark, que, a su vez, está escrito en Scala 2.11. Teniendo en cuenta que usamos Scala 2.12.7 en el desarrollo, las bibliotecas compiladas no son adecuadas para nosotros, y no podemos pasar a la versión 2.11. * No podemos, debido a la gran cantidad de código ya escrito en la nueva versión de Scala. Que hacer Diviértase mucho recolectando MXNet de la fuente para nuestra versión de Scala. A continuación, daré un script para construir e instalar MXNet 1.3.1 para Scala 2.12. * Y comentaré los puntos principales.
mxnet_2_12.sh
La parte más interesante comienza con esta línea:
ln -s ${CUDA_STUBS_DIR}/libcuda.so ${CUDA_STUBS_DIR}/libcuda.so.1 && \
Si ejecuta el ensamblaje MXNet como se indica en las instrucciones, obtendremos un error. El compilador no puede encontrar la biblioteca libcuda.so.1, por lo que enlazaremos desde la biblioteca libcuda.so a libcuda.so.1. Es posible que esto no le moleste, cuando lo inicie en un servidor de producción, reemplazaremos esta biblioteca por una local. También tenga en cuenta que la ruta a las bibliotecas CUDA desde la variable de entorno
CUDA_STUBS_DIR
se ha agregado a
LD_LIBRARY_PATH
. Si esto no se hace, entonces el ensamblaje también fallará.
En estas líneas, reemplazamos la versión de Scala 2.11 con 2.12 en todos los archivos necesarios usando una expresión regular, que se seleccionó experimentalmente, porque no es suficiente reemplazar 2.11 en todas partes con 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} && \
Y luego se comenta la dependencia del módulo para trabajar con Spark. Si esto no se hace, la biblioteca no se ensamblará.
Luego, ejecute el ensamblaje, como se indica en las instrucciones, copie la biblioteca ensamblada en una carpeta compartida y elimine cualquier basura que Maven haya bombeado durante el proceso de compilación (si esto no se hace, la imagen final crecerá en aproximadamente 3-4 GB, lo que puede causar que sus DevOps s nervioso).
Recopilamos la imagen, estando en el directorio raíz del proyecto (ver. Estructura de carpetas):
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 .
Permíteme recordarte por si acaso el punto al final dice que estamos haciendo el ensamblaje dentro del contexto del directorio actual.
Ahora es el momento de hablar sobre la imagen de construcción.
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
Es simple, no necesitamos Scala y Sbt para iniciar nuestro microservicio, por lo que no tiene sentido arrastrarlos a la imagen base para el lanzamiento. Por lo tanto, crearemos una imagen separada que se usará solo para el ensamblaje. Los scripts scala.sh y sbt.sh son bastante triviales y no me detendré en ellos en detalle.
scala.sh
sbt.sh
Recopilamos la imagen, estando en el directorio raíz del proyecto (ver. Estructura de carpetas):
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 .
Al final del artículo hay enlaces al repositorio con todos estos archivos.
4. Estructura del proyecto.
Una vez que haya terminado de prepararse para el montaje del proyecto, hagamos lo que decidió dedicar tiempo a este artículo.
El proyecto de nuestro microservicio tendrá la siguiente estructura:
\ | ----- 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
Esta es la estructura estándar de un proyecto Scala, con la excepción de las dependencias y modelos de directorios.
El directorio de dependencias contiene la biblioteca MXNet para Scala. Se puede obtener de dos maneras:
- construya MXNet en la máquina donde va a desarrollar (tenga en cuenta que la biblioteca no es multiplataforma; si la construye en Linux, no funcionará en Mac OS),
- o sáquelo de la imagen de Docker que creamos anteriormente. Si decide construir MXNet en un entorno local, el script mxnet_2.12.sh lo ayudará.
Puede extraer bibliotecas de la imagen de Docker de esta manera:
El directorio de modelos contiene los archivos de una red neuronal entrenada, puede descargarlos libremente de la siguiente manera:
Más brevemente sobre archivos que no son de particular interés, pero que juegan un papel en el proyecto.
project / build.properties
# Sbt, sbt.version = 1.2.6
proyecto / plugins.sbt
src / main / resources / cat_and_dog.jpg
Una imagen tan maravillosa, en la que nuestra red neuronal buscará un gato y un perro.

build.sbt
enablePlugins(PackPlugin) name := "simple-predictor" version := "0.1" scalaVersion := "2.12.7" unmanagedBase := baseDirectory.value / "dependencies"
simple.predictor.Config
Este objeto almacena variables globales cuyo valor se lee de las variables de entorno o se establece de forma predeterminada.
package simple.predictor import org.apache.mxnet.Context import scala.util.Try object Config {
simple.predictor.Run
El objeto Ejecutar es el punto de entrada a la aplicación.
package simple.predictor import java.net.InetSocketAddress
5. Carga de la red neuronal
La red neuronal se carga en el constructor de la clase
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) {
En la sección
dice que en una red neuronal funcionará con
NDArray
con una dimensión de 1 x 3 x 512 x 512, donde 1 es el número de imágenes que se incluirán en NDArray, 3 es el número de colores y 512 x 512 - tamaño de imagen (el valor de
imageEdge = 12
se establece en el objeto
simple.predict.Config
, este es el tamaño lateral de la imagen utilizada para entrenar la red neuronal). Toda esta descripción de datos se pasa al
ObjectDetector
.
Otra sección interesante es el
.
Después de ejecutar la imagen a través de la red neuronal, el resultado es del tipo
Seq[Seq[(String, Array[Float])]]
. La primera colección contiene solo un resultado (el formato de datos está determinado por una red neuronal específica), luego cada elemento de la siguiente colección es una tupla de dos elementos:
- nombre de clase ("gato", "perro", ...),
- una matriz de cinco números de coma flotante: el primero es la probabilidad, el segundo es el coeficiente para calcular la coordenada
x
, el tercero es el coeficiente para calcular la coordenada y
, el cuarto es el coeficiente para calcular el ancho del rectángulo y el quinto es el coeficiente para calcular la altura del rectángulo.
Para obtener los valores reales de las coordenadas y las dimensiones del rectángulo, debe multiplicar el ancho y la altura originales de la imagen por los coeficientes correspondientes.
Me permito un poco de digresión sobre el tema de
NDArray
. Esta es una matriz multidimensional que MXNet crea en un contexto dado (CPU o GPU). Al crear un NDArray, se forma un objeto C ++, un objeto con el que las operaciones se realizan muy rápidamente (y si se crea en un contexto de GPU, es casi instantáneo), pero hay que pagar por esa velocidad. Como resultado (al menos en la versión MXNet 1.3.1) necesita administrar de forma independiente la memoria asignada para
NDArray
, y no olvide descargar estos objetos de la memoria después de que termine de trabajar con ellos. De lo contrario, habrá una pérdida de memoria significativa y bastante rápida, que no es muy conveniente de monitorear, ya que los programas para la creación de perfiles JVM no lo ven. El problema de memoria se agrava si trabaja en un contexto de GPU, ya que las tarjetas de video no tienen una gran cantidad de memoria y la aplicación se bloquea rápidamente.
¿Cómo resolver un problema de pérdida de memoria?
En el ejemplo anterior, en la línea
model.imageObjectDetect(image).head map toPrediction(image.getWidth, image.getHeight) filter (_.probability > threshold)
, el método
imageObjectDetect
se utiliza para ejecutar la imagen a través de la red neuronal, que recibe una entrada de
BufferedImage
. Todas las conversiones hacia y desde
NDArray
se realizan dentro del método, y no necesita pensar en problemas de desasignación de memoria. Por otro lado, antes de convertir
BufferedImage
a
NDArray
se realiza a un tamaño de 512 x 512 y la imagen se normaliza utilizando métodos de un objeto de tipo
BufferedImage
. Esto sucede un poco más que cuando se usa OpenCV, por ejemplo, pero resuelve el problema de liberar memoria después de usar
NDArray
.
Por supuesto, puede usar OpenCV y controlar la memoria usted mismo, para esto solo necesita llamar al método de
dispose
de
dispose
, pero por alguna razón olvidó mencionar esto en la documentación oficial de MXNet para Scala.
MXNet también tiene una forma no muy conveniente de controlar la pérdida de memoria que ocurre debido a
NDArray
. Para hacer esto, ejecute la aplicación con el parámetro JVM
Dmxnet.traceLeakedObjects=true
. Si MXNet nota un
NDArray
que no está en uso pero está colgado en la memoria, obtendrá una excepción que indica en qué línea de código está el
NDArray
desafortunado.
Mi consejo: trabaje directamente con NDArray, monitoree cuidadosamente la memoria y escriba la normalización usted mismo, habiendo especificado previamente qué algoritmo hizo el ingeniero de ML al entrenar una red neuronal, de lo contrario los resultados serán completamente diferentes.
ObjectDetector
tiene un método
objectDetectWithNDArray
al que puede pasar un
NDArray
. Para implementar un enfoque más universal para cargar una red neuronal, recomiendo usar el objeto
org.apache.mxnet.module.Module
. A continuación se muestra un ejemplo de uso.
import org.apache.mxnet._ import org.apache.mxnet.io.NDArrayIter
6. Implementación de la API REST
La clase
simple.predictor.Server
es responsable de implementar la API REST. El servidor se basa en el servidor Java incluido en 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. Prueba
Para verificar, inicie el servidor y envíe una imagen de prueba src / main / resources / cat_and_dog.jpg. Analizaremos el JSON recibido del servidor, verificaremos cuántos y qué objetos encontró la red neuronal en la imagen, y rodearemos los objetos en la imagen.
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
( , , , ).
:
Conclusión
MXNet , - . , , , production.
, , MXNet , Python production Scala, Java ++.
, .
, . . Gracias por su atencion
Referencias