Há um grande número de manuais e exemplos na Internet, com base nos quais vocês, queridos leitores, poderão “sem muita dificuldade” e com custos de tempo “mínimos” escrever código que pode distinguir gatos de cães em uma foto. E por que perder tempo com este artigo?
A principal, na minha opinião, a desvantagem de todos esses exemplos são as possibilidades limitadas. Você pegou um exemplo - mesmo com a rede neural básica que o autor oferece - lançou, talvez até funcionou, e o que vem depois? Como fazer esse código simples começar a funcionar em um servidor de produção? Como atualizá-lo e mantê-lo? É aqui que a diversão começa. Não consegui encontrar uma descrição completa do processo a partir do momento "Bem, o engenheiro de ML treinou a rede neural" para "finalmente a lançamos em produção". E eu decidi fechar essa lacuna.
Não falarei sobre como ensinar à rede neural novas coisas engraçadas que irão agradá-lo e ajudá-lo a ganhar um monte de notas crocantes. Este é um ótimo tópico para um artigo separado. Como exemplo, usarei uma rede neural que pode ser baixada gratuitamente. A principal tarefa que me propus é fornecer uma descrição completa do processo de introdução de uma rede neural em operação.
Respondo imediatamente à pergunta “Por que não no Python?”: Usamos o Scala para soluções de produção devido à escrita mais conveniente e estável do código multithread.
Conteúdo
1. Declaração do problema2. Tecnologias utilizadas3. Preparando um Contêiner de Docker Básico4. Estrutura do projeto5. Carregamento de rede neural6. Implementação da API REST7. Teste8. Montagem de um microsserviço com base em uma imagem básica9. Iniciando um microsserviço em um servidor de produção com uma GPUConclusãoReferências1. Declaração do problema
Suponha que tenhamos um grande banco de dados de fotos com objetos diferentes e precisamos criar um microsserviço que receba uma imagem em uma solicitação HTTP POST e responda no formato JSON. A resposta deve conter o número de objetos encontrados e suas classes, o grau de probabilidade de que esse seja exatamente o objeto da classe declarada e as coordenadas dos retângulos que cobrem os limites de cada objeto.
2. Tecnologias utilizadas
- Scala 2.12.7 + conjunto mínimo de bibliotecas adicionais, Sbt 1.2.6 com o plug-in Sbt-pack 0.12 para criar códigos-fonte.
- MXNet 1.3.1 (a última versão estável no momento da redação), compilada para o Scala 2.12.
- Servidor com placas gráficas Nvidia.
- Cuda 9.0 e Cudnn 7 instalados no servidor.
- Java 8 para executar o código compilado.
- Docker para facilitar a montagem, entrega e lançamento do microsserviço no servidor.
3. Preparando um Contêiner de Docker Básico
Para o nosso microsserviço, você precisará de uma imagem básica do Docker, na qual o número mínimo de dependências necessárias para executar será instalado. Para montagem, usaremos a imagem com o Sbt instalado adicionalmente. Sim, criaremos as próprias fontes não no ambiente local, mas no contêiner do Docker. Isso facilitará a transição adicional para a montagem através do IC, por exemplo, através do CI do gitlab.
Estrutura de pastas:
\ | ----- 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 imagem será usada para o lançamento final do microsserviço. Ele é baseado na imagem oficial da Nvidia com CUDA 9.0 e CUDNN 7. pré-instalados. A documentação do MXNet 1.3.1 afirma funcionar com o CUDA 8.0, mas, como a prática demonstrou, tudo funciona bem com a versão 9.0 e até um pouco mais rápido.
Além disso, instalaremos o Java 8, o MXNet 1.3.1 (o construiremos sob o Scala 2.12), o OpenCV 3.4.3 e o utilitário Linux para definir o fuso horário nesta imagem.
# 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 / /
Os scripts timeZone.sh java8.sh e opencv.sh são bastante triviais, por isso não vou me debruçar sobre eles em detalhes, eles são apresentados abaixo.
timeZone.sh
java8.sh
opencv.sh
Instalar o MXNet não é tão simples. O fato é que todos os assemblies desta biblioteca para o Scala são feitos com base na versão 2.11 do compilador, e isso é justificado, pois a biblioteca inclui um módulo para trabalhar com o Spark, que, por sua vez, está escrito no Scala 2.11. Considerando que usamos o Scala 2.12.7 no desenvolvimento, as bibliotecas compiladas não são adequadas para nós e não podemos ir para a versão 2.11. * Não podemos, devido à grande quantidade de código já escrita na nova versão do Scala. O que fazer Divirta-se muito coletando o MXNet da fonte para a nossa versão do Scala. Abaixo darei um script para construir e instalar o MXNet 1.3.1 para Scala 2.12. * E comente os pontos principais.
mxnet_2_12.sh
A parte mais interessante começa com esta linha:
ln -s ${CUDA_STUBS_DIR}/libcuda.so ${CUDA_STUBS_DIR}/libcuda.so.1 && \
Se você executar o conjunto MXNet como nas instruções, obteremos um erro. O compilador não pode encontrar a biblioteca libcuda.so.1, portanto, vincularemos a partir da biblioteca libcuda.so ao libcuda.so.1. Isso pode não incomodá-lo. Quando você o inicia em um servidor de produção, substituiremos esta biblioteca por uma local. Observe também que o caminho para as bibliotecas CUDA da variável de ambiente
CUDA_STUBS_DIR
foi adicionado ao
LD_LIBRARY_PATH
. Se isso não for feito, a montagem também falhará.
Nestas linhas, substituímos a versão do Scala 2.11 pela 2.12 em todos os arquivos necessários, usando uma expressão regular, que foi selecionada experimentalmente, porque não é suficiente substituir simplesmente o 2.11 em todos os lugares pelo 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} && \
E então a dependência do módulo para trabalhar com o Spark é comentada. Se isso não for feito, a biblioteca não será montada.
Em seguida, execute o assembly, conforme indicado nas instruções, copie a biblioteca montada em uma pasta compartilhada e remova todo o lixo que o Maven lançou durante o processo de compilação (se isso não for feito, a imagem final aumentará em cerca de 3-4 GB, o que pode causar os DevOps está nervoso).
Coletamos a imagem, estando no diretório raiz do projeto (consulte. Estrutura de pastas):
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 .
Deixe-me lembrá-lo para o caso de o ponto no final indicar que estamos fazendo a montagem no contexto do diretório atual.
Agora é hora de falar sobre a imagem de compilação.
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
É simples, não precisamos do Scala e do Sbt para iniciar nosso microsserviço, portanto não faz sentido arrastá-los para a imagem base para o lançamento. Portanto, criaremos uma imagem separada que será usada apenas para montagem. Os scripts scala.sh e sbt.sh são bastante triviais e não vou me aprofundar neles em detalhes.
scala.sh
sbt.sh
Coletamos a imagem, estando no diretório raiz do projeto (consulte. Estrutura de pastas):
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 .
No final do artigo, existem links para o repositório com todos esses arquivos.
4. Estrutura do projeto
Depois de terminar de preparar a montagem do projeto, vamos fazer o que você decidiu dedicar tempo para este artigo.
O projeto do nosso microsserviço terá a seguinte estrutura:
\ | ----- 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
Essa é a estrutura padrão de um projeto Scala, com exceção das dependências e modelos de diretórios.
O diretório de dependências contém a biblioteca MXNet para Scala. Pode ser obtido de duas maneiras:
- construa o MXNet na máquina em que você estará desenvolvendo (observe que a biblioteca não é multiplataforma; se você construí-la no Linux, ela não funcionará no Mac OS),
- ou retire-o da imagem do Docker que criamos anteriormente. Se você decidir construir o MXNet em um ambiente local, o script mxnet_2.12.sh o ajudará.
Você pode extrair bibliotecas da imagem do Docker assim:
O diretório de modelos contém os arquivos de uma rede neural treinada, você pode baixá-los livremente da seguinte maneira:
Brevemente sobre arquivos que não são de interesse particular, mas que desempenham um papel no projeto.
projeto / build.properties
# Sbt, sbt.version = 1.2.6
project / plugins.sbt
src / main / resources / cat_and_dog.jpg
Uma imagem tão maravilhosa, em que nossa rede neural procurará um gato e um cachorro.

build.sbt
enablePlugins(PackPlugin) name := "simple-predictor" version := "0.1" scalaVersion := "2.12.7" unmanagedBase := baseDirectory.value / "dependencies"
simple.predictor.Config
Este objeto armazena variáveis globais cujo valor é lido das variáveis de ambiente ou definido por padrão.
package simple.predictor import org.apache.mxnet.Context import scala.util.Try object Config {
simple.predictor.Run
O objeto Executar é o ponto de entrada para o aplicativo.
package simple.predictor import java.net.InetSocketAddress
5. Carregamento de rede neural
A rede neural é carregada no construtor da 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) {
Na seção
você diz que em uma rede neural funcionará com o
NDArray
com uma dimensão de 1 x 3 x 512 x 512, em que 1 é o número de imagens que estarão contidas no NDArray, 3 é o número de cores e 512 x 512 - tamanho da imagem (o valor de
imageEdge = 12
é definido no objeto
simple.predict.Config
, esse é o tamanho lateral da imagem usada para treinar a rede neural). Toda essa descrição de dados é passada para o
ObjectDetector
.
Outra seção interessante é o
.
Depois de executar a imagem pela rede neural, o resultado é do tipo
Seq[Seq[(String, Array[Float])]]
. A primeira coleção contém apenas um resultado (o formato dos dados é determinado por uma rede neural específica), então cada elemento da próxima coleção é uma tupla de dois elementos:
- nome da classe ("gato", "cachorro", ...),
- uma matriz de cinco números de ponto flutuante: o primeiro é a probabilidade, o segundo é o coeficiente para calcular a coordenada
x
, o terceiro é o coeficiente para calcular a coordenada y
, o quarto é o coeficiente para calcular a largura do retângulo e o quinto é o coeficiente para calcular a altura do retângulo.
Para obter os valores reais das coordenadas e dimensões do retângulo, é necessário multiplicar a largura e a altura originais da imagem pelos coeficientes correspondentes.
NDArray
me um pouco de digressão sobre o tópico
NDArray
. Essa é uma matriz multidimensional que o MXNet cria em um determinado contexto (CPU ou GPU). Ao criar um NDArray, um objeto C ++ é formado, um objeto com o qual as operações são executadas muito rapidamente (e se ele é criado em um contexto de GPU, é quase instantâneo), mas você precisa pagar por essa velocidade. Como resultado (pelo menos na versão MXNet 1.3.1), você precisa gerenciar independentemente a memória alocada para o
NDArray
e não se esqueça de descarregar esses objetos da memória após concluir o trabalho com eles. Caso contrário, haverá um vazamento de memória significativo e bastante rápido, o que não é muito conveniente para monitorar, pois os programas para criação de perfil da JVM não o veem. O problema de memória é agravado se você trabalhar em um contexto de GPU, pois as placas de vídeo não possuem uma grande quantidade de memória e o aplicativo falha rapidamente na memória.
Como resolver um problema de vazamento de memória?
No exemplo acima, no modelo de linha
model.imageObjectDetect(image).head map toPrediction(image.getWidth, image.getHeight) filter (_.probability > threshold)
, o método
imageObjectDetect
é usado para executar a imagem pela rede neural, que recebe uma entrada
BufferedImage
. Todas as conversões para e do
NDArray
são feitas dentro do método, e você não precisa pensar em problemas de desalocação de memória. Por outro lado, antes de converter
BufferedImage
em
NDArray
é realizado no tamanho de 512 x 512 e a imagem é normalizada usando métodos de um objeto do tipo
BufferedImage
. Isso acontece um pouco mais do que quando se usa o OpenCV, por exemplo, mas resolve o problema de liberar memória após o uso do
NDArray
.
Obviamente, você pode usar o OpenCV e controlar a memória por conta própria. Para isso, basta chamar o método de
NDArray
do
dispose
, mas, por algum motivo, esqueceu de mencionar isso na documentação oficial do MXNet para Scala.
O MXNet também possui uma maneira não muito conveniente de controlar o vazamento de memória que ocorre devido ao
NDArray
. Para fazer isso, execute o aplicativo com o parâmetro JVM
Dmxnet.traceLeakedObjects=true
. Se o MXNet perceber um
NDArray
que não está em uso, mas está
NDArray
na memória, você receberá uma exceção indicando em qual linha de código o infeliz
NDArray
.
Meu conselho: trabalhe diretamente com o NDArray, monitore cuidadosamente a memória e escreva a normalização, especificando previamente o algoritmo que o engenheiro de ML fez ao treinar uma rede neural, caso contrário, os resultados serão completamente diferentes. O
ObjectDetector
possui um método
objectDetectWithNDArray
para o qual você pode transmitir um
NDArray
. Para implementar uma abordagem mais universal para carregar uma rede neural, recomendo usar o objeto
org.apache.mxnet.module.Module
. Abaixo está um exemplo de uso.
import org.apache.mxnet._ import org.apache.mxnet.io.NDArrayIter
6. Implementação da API REST
A classe
simple.predictor.Server
é responsável pela implementação da API REST. O servidor é baseado no servidor Java incluído no 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. Teste
Para verificar, inicie o servidor e envie uma imagem de teste src / main / resources / cat_and_dog.jpg. Analisaremos o JSON recebido do servidor, verificaremos quantos e quais objetos a rede neural encontrou na imagem e circularemos os objetos na imagem.
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
( , , , ).
:
Conclusão
MXNet , - . , , , production.
, , MXNet , Python production Scala, Java ++.
, .
, . . Obrigado pela atenção.
Referências