strace logo
Lista de Conteúdos

Linux não é um SO opaco. Ele não ser opaco significa que é fácil ver o que acontece por trás dos processos. O que permite identificar um problema ou pelo menos saber se você realmente tem um problema.

Iniciei meu aprendizado em MlOps (Machine Learning Operations). Embora tenha pouca experiência foi fácil aceitar que MlOps envolve um workflow extremamente intricado com muitos possíveis pontos de falhas. Tais pontos podem não estar relacionados com os operadores. Portanto, saber identificar se existe uma falha e o que está causando ela é de suma importância. Isso vai desde compreender o comportamento de um processo criado pelos próprios operadores ou o que o pip/conda e demais dependências externas estão aprontando debaixo dos panos.

O primeiro passo para entender um problema com um processo é analisar o output (a saída na sessão do seu terminal). Contudo, algumas vezes isso não te fornece a informação suficiente. Neste texto vou discorrer do básico sobre como debugar processos usando o strace e lsof. Iremos criar alguns exemplos patológicos usando python para simular problemas que podemos encontrar e como eles são dissecados pelo strace e o lsof.

Conceitos

“Everything is a file.” mantra UNIX

everthing_is_a_file

Quando você pensa em arquivo você talvez relacione com um CSV, uma planilha ou imagem. Mas na abordagem UNIX de fazer SO o conceito de arquivo aparece em todos os lugares. Por exemplo, até conexões de rede são associadas a um arquivo. Em casos que um elemento em si não é um arquivo tal elemento tem a ele associado um descritor de arquivo (file descriptor). Como isso se relaciona com debugar processos? **Se tudo é um arquivo analisar um processo pode ser feito com o mesmo conjunto de ferramentas e conceitos que usamos para listar, compreender e comunicar com arquivo inclusive com a mesma API. ** Aqui abordaremos uma ferramenta para listagem de arquivos, o lsof.

LSOF

A ferramenta lsof é um comando que pode ser usado para listar os file descriptors abertos e os processos que foram responsáveis por tal ação. Desta maneira você pode listar os file descriptors de um usuário que estão associados a uma porta via conexão ou processo. O nome desse comando é um acrônimo para list open files.

O exemplo mais simples de uso jogando os resultados para um arquivo é esse

meuusuario:/$ lsof > lsof_tudo.txt

O comando acima irá criar uma tabela (imensa) dentro de lsof_tudo.txt

Essa tabela será mais ou menos assim

COMMAND     PID   TID TASKCMD               USER   FD      TYPE             DEVICE  SIZE/OFF       NODE NAME
systemd       1                             root  cwd   unknown                                         /proc/1/cwd (readlink: Permission denied)
systemd       1                             root  rtd   unknown                                         /proc/1/root (readlink: Permission denied)
systemd       1                             root  txt   unknown                                         /proc/1/exe (readlink: Permission denied)

Se você olhar com cuidado verá que aparecem linhas de diferentes usuários. As primeiras são do root e uma das colunas mostra que você não tem permissão para ler os file descriptors desse usuário, ainda bem! Para pedir apenas a listagem do seu usuário faça

meuusuario:/$ lsof -u meuusuario > lsof_meu.txt

O arquivo ainda é enorme, mas abra ele com seu editor de texto. Tente procurar nomes de arquivos e processos que você esta usando agora.

Temos muitas colunas no output, você pode ver o significado detalhado de cada uma digitando man lsof . Mas eu acho mais interessante você focar nas seguintes colunas:

  • COMAND
    • O nome do comando associado ao processo que abriu o arquivo
  • PID
    • Um número que identifica unicamente o processo. Você pode usar esse número para matar o processo usando pkill, usar ele no strace etc.
  • TID
    • Se o arquivo foi aberto por uma thread de um processo. Quando não tem nada nessa coluna significa que a ação foi feita por um processo.
  • USER
    • O usuário responsável pelo processo que efetuou a ação.
  • TYPE
    • Essa coluna é bem útil. Tal coluna te diz o tipo de nó associado ao arquivo. Por exemplo, se o arquivo for associado com protocolos você vera aqui coisas do tipo: IPV4, IPV6. Se for um arquivo normal haverá na coluna o identificador **REG. **Existem algumas dezenas de possibilidades de valores para essa coluna, eu nunca lembro o que elas significam, mas é fácil consultar online ou no man.
  • NODE
    • O identificador do nó do arquivo. No caso desse arquivo envolver protocolos de internet haverá coisas como TCP, UDP
  • NAME
    • Também bastante útil. Ele muda bastante dependendo do que o arquivo se refere. Pode ser o endereço do servidor ( www.google.com, localhost:5000) assim como o endereço do arquivo.

O lsof tem muitos argumentos possíveis, veremos alguns utilizando alguns casos que eu acho interessante e que acontecem.

System Calls e strace

O system call é o mecanismo de comunicação entre processos e o kernel do seu SO. Tal mecanismo permite que um processo requisite recursos do kernel disponibilizados pelo seu hardware. Para ler um arquivo armazenado em seu hardwre é necessário que ocorra antes um system call. Portanto, tendo uma maneira de interceptar essas chamadas entre um processo e o kernel temos como compreender o que tal processo está fazendo. Um comando que permite essa interceptação é o strace.

$ man strace

Se o strace não estiver disponível instale

$ apt install strace

O strace pode ser executado de duas formas. A primeira é usando o comando a ser interceptado como argumento do strace

$ strace ARGS COMANDO_A_SER_INTERCEPTADO

a segunda, bastante útil, é interceptando um processo já iniciado usando o PID de tal processo,

$ strace ARGS -p PID_DO_PROCESSO

Para descobrir o PID de um processo use o htop ou o seguinte comando ps aux | grep -i '[n]ome_do_processo'.

Veja um exemplo simples do strace e seu output

$ strace -t ls

O resultado será algo do tipo

18:02:23 execve("/usr/bin/ls", ["ls"], 0x7fffa727a418 /* 54 vars */) = 0
18:02:23 brk(NULL)                      = 0x55ebef60c000
18:02:23 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
18:02:23 openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
...

Cada linha representa uma system call e o seu respectivo resultado. O argumento -t diz para imprimir na primeira coluna o instante de tempo que o system call foi chamado.

De forma resumida o formato das linhas segue o padrão:

Nome da SYS CALL(Argumentos usados na SYS CALL) = O resultado

O output é difícil se não humanamente impossível de compreender tudo sem um guia externo. Um guia possível é o comando man. O comando abaixo mostra a documentação do sys call openat

$ man 2 openat

O openat é o sys call que requisita a abertura de um arquivo, o resultado na última linha ( O_RDONLY|O_CLOEXEC) = 3) significa que a chamada do sistema foi bem sucedida. Caso fosse -1 alguma coisa teria dado errado quando o processo requisitou o recurso.

Investigando problemas

Veremos aqui problemas e falhas relacionados a arquivos regulares e conexões de rede. Contudo podemos usar as mesmas tecnicas para outros tipos de problemas.

Identificando problemas de conexão

O conda está travado? O pip tá baixando os pacotes do servidor ou existe algum servidor engasgando? Para onde minhas requisições estão indo? Antes de tentar iniciar um modo verboso e ter que matar seu processo você pode usar o lsof para responder essas perguntas. Para começar nosso tutorial e realizar as simulações instale o flask e requests

$ python -m pip install requests flask

Crie o arquivo server_mlops.py

# server_mlops.py
import time
import flask

app = flask.Flask(__name__)

@app.route('/')
def hello_world():
    sleep_time = flask.request.args.get('sleep', default=10, type=int)
    print('sleep_time:', sleep_time)
    time.sleep(sleep_time)
    return 'Hello World!'

if __name__ == '__main__':
    app.run()

Inicie duas sessões no terminal. Na primeira inicie o servidor

$ python server_mlops.py

Na segunda execute

$ ps aux | grep -i '[s]erver_mlops.py'

você vera um output do tipo

devmess+ 19321 18.0  0.3  29716 24792 pts/5    S+   14:27   0:00 python server_mlops.py

O número na frente do seu username (19321) é o PID do processo.

O meu serviço está on?

O argumento -a pede que o lsof use todos os argumentos de filtragem com o operador AND isto é, todas as condições devem ser válidas. O argumento -i pede para que ele filtre apenas arquivos associados a conexões e o argumento -p 19321 pede que use apena o processo com o PID 19321.

$ lsof -a -i -p 19321

Você vera um output mais ou menos assim

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python 19321 devmessias 4u IPv4 16108218 0t0 TCP localhost:5000 (LISTEN)

Está tudo ok com o seu serviço. Tente remover um dos argumentos (remova o -a por exemplo) ou usar eles isolados, veja como o output muda.

O pip ou um cliente qualquer está engasgado esperando uma resposta de alguém?

Esse tipo de problema pode acontecer quando estamos gerenciando uma dependência, requisitando algum tipo de dado de um servidor e em inúmeros outros casos em que não temos acesso a máquina que executa o serviço. Portanto, precisamos analisar do nosso lado se o processo está travado por alguma falha nossa.

Crie o arquivo client_mlops.py

#!/usr/bin/env python
#client_mlops.py

import requests
import argparse

parser = argparse.ArgumentParser()
parser.add_argument(
    '--sleep', type=int, help='time to sleep', default=0)
args = parser.parse_args()

print('Ask for localhost:5000 to sleep for {} seconds'.format(args.sleep))
r = requests.get('http://localhost:5000', params={'sleep': int(args.sleep)})
print(r.text)

No código acima temos o argumento sleep que pedira para o server_mlops.py esperar alguns segundos antes de enviar a resposta.

Simularemos um problema de um servidor preguiçoso. Pedindo que ele durma por 20 segundos. Se você matou o processo do servidor inicie ele novamente.

Execute o client_mlops.py com o strace

$ strace -e poll,select,connect,recvfrom,sendto python client_mlops.py --sleep=20

aqui estamos pedindo para que o strace nos mostre apenas chamadas do tipo poll,select,connect,recvfrom e sendto.

O output será algo do tipo

connect(4, {sa_family=AF_INET, sin_port=htons(5000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
connect(4, {sa_family=AF_INET6, sin6_port=htons(5000), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
connect(4, {sa_family=AF_INET6, sin6_port=htons(5000), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = -1 ECONNREFUSED (Connection refused)
connect(4, {sa_family=AF_INET, sin_port=htons(5000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
sendto(4, "GET /?sleep=10 HTTP/1.1\r\nHost: l"..., 154, 0, NULL, 0) = 154
recvfrom(4, 

Note que temos uma SYS_CALL engasgada, recvfrom (se você quiser obter mais informações sobre uma SYS_CALL digite man 2 recvfrom) . Quem tá engasagando é o servidor e não o cliente.

Você pode também usar o lsof para checar se você está com esse tipo de problema. Para isso, execute o cliente em uma sessão separada

$ python client_mlops.py --sleep=100

pegue o PID com ps aux | grep -i '[c]lient_mlops.py' e execute o lsof

lsof -a -i -p 19321

O resultado será algo do tipo

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python 31551 devmessias 4u IPv4 16622065 0t0 TCP localhost:57314->localhost:5000 (ESTABLISHED)

Note que uma conexão foi estabelecida (coluna NAME). Se o serviço estivesse enviado a resposta não teríamos obtido nada na saída do lsof.

Problemas com arquivos

Vamos simular alguns problemas com arquivos regulares: csv, txt, bin, jpg etc. Copie um csv para pasta /tmp/, ou execute o comando abaixo para criar um txt dummy contendo o manual do comando strace.

$ man strace > /tmp/arquivo.csv 

Quais processos estão usando esse arquivo?

O objetivo aqui é saber quais processos estão acessando um arquivo. Isto é útil quando queremos identificar processos que já deveriam ter “fechado” o arquivo ou inentificar acessos indenvidos. Também pode ser útil para descobrir qual processo está criando um arquivo gigantesco no seu sistema para que você possa dar um kill.

Crie o script a seguir em uma pasta.

#!/usr/bin/env python
# file_open.py
import time

f = open('/tmp/arquivo.csv', 'r')
input('Press Enter to continue...')

Depois abra duas sessões no terminal e rode o comando python file_open.py. Agora basta listar os processos que estão com arquivo.csv abertos

$ lsof /tmp/arquivo.csv

O output será algo do tipo

COMMAND   PID       USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
python  15411 devmessias    3r   REG    8,2        0 2911031 /tmp/arquivo.csv
python  20777 devmessias    3r   REG    8,2        0 2911031 /tmp/arquivo.csv

Temos dois processos distintos (dois PID) utilizando nosso arquivo.

Deletei o csv e agora?

Suponha uma situação em que acidentalmente um arquivo foi apagado. Contudo, existe um processo que ainda está fazendo uso de tal recurso.

Crie um arquivo qualquer, aqui vou chamar de acidente.txt

Abra uma sessão no terminal e execute o comando a seguir. Não feche a sessão!

$ python -c 'f=open("acidente.txt", "r");input("...")'

Simularemos o acidente que outro processo remove o arquivo. Execute os comandos abaixo

$ rm acidente.txt
$ ls acidente.txt

Nosso arquivo foi embora :(

ls: cannot access 'acidente.txt': No such file or directory

Mas não se preocupe! Uma coisa legal do linux: todos os processos do sistema tem a eles associados um diretório dentro da pasta /proc (everthing is a file). E o que tem nesses diretórios ? Muitas coisas, incluindo o file descriptor do acidente.txt. Utilizado pelo nosso processo python. Para encontrar esse file descriptor usaremos o lsof

$ lsof -u nomedeusuario | grep 'acidente.txt'

No meu caso obtive o seguinte output

python    22465 devmessias    3r      REG                8,2     37599   14288174 caminho/acidente.txt (deleted)

Então o PID é 22465 e o número que descreve o arquivo (file descriptor) é 3 (o que vem antes do r no output acima). Para obter uma cópia do acidente.txt deletado basta chamar um simples cp

$ cp /proc/22465/fd/3 recuperado.txt

Abra o arquivo recuperado.txt e veja que tudo está no seu devido lugar. Não é mágica, procure por process pseudo-filesystem na web ou digite man proc .

Erros silenciosos: arquivo não existente ou permissão

Em alguns casos você pode ter um processo criado por uma dependência externa que tenta acessar um arquivo com permissão errada ou mesmo não existente. Criaremos essas duas situações com o script file_404.py.

#!/usr/bin/env python
# file_404.py
import time

try:
    f = open('/tmp/arquivo_404.csv', 'r')
except FileNotFoundError:
    pass

try:
    # um arquivo que vc nao tem permissao, crie como sudo e mude com chmod 700
    f = open('/tmp/arquivo_permission.csv', 'r')
except PermissionError:
    pass

input('Press Enter to continue...')

Execute ele com python file_404.py veja que nenhum problema é informado.

Para traquear as chamadas do sistema do tipo arquivo feitas por python file_404.py basta digitar o comando abaixo no terminal

$ strace -f -e trace=file python file_404.py

o argumento -f diz para o strace monitorar também qualquer processo filho criado. Em python, isso seria por exemplo os processos criados por os.fork.

A saída do exemplo será algo do tipo

lstat("SEU DIRETORIO/file_404.py", {st_mode=S_IFREG|0644, st_size=242, ...}) = 0
openat(AT_FDCWD, "file_404.py", O_RDONLY) = 3
openat(AT_FDCWD, "/tmp/arquivo_404.csv", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/tmp/arquivo_permission.csv", O_RDONLY|O_CLOEXEC) = -1 EACCES (Permission denied)

Note que temos no output informações que não queremos investigar, mas nas últimas linhas os erros de permissão e ausência de arquivo apareceram.

Uma maneira de filtrar o resultado e tornar sua vida mais fácil é usar o awk redirecionado a saída do strace com o pipe |.

$ strace -f -e trace=file python file_404.py 2>&1 | awk '/^open/ && /= -1/ {print}'

O comando acima diz para mostrar apenas as linhas que começam com a string open e em alguma parte da linha tenha o padrão = -1.

O comando com awk concatenado produzirá um output mais limpo, veja só

openat(AT_FDCWD, "/home/devmessias/anaconda3/pyvenv.cfg", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/tmp/arquivo_404.csv", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/tmp/arquivo_permission.csv", O_RDONLY|O_CLOEXEC) = -1 EACCES (Permission denied)

Esse processo está salvando algo que não deveria? Onde?

Talvez você queira monitorar o que uma dependência externa anda fazendo no seu sistema de arquivos. Outro problema que pode ocorrer é caso você delete um arquivo usado por uma dependência, contudo tal dependência fez um cache em algum lugar antes de você efetuar a remoção. O que te impede de ressetar a dependência.

Usando o mesmo comando anterior é possível buscar onde esses caches e arquivos estão

$ strace -f -e trace=file comando 2>&1 | awk '/^open/{print}'

se você quiser pegar apenas as chamadas que não retornaram em falha digite

$ strace -f -e trace=file comando 2>&1 | awk '/^open/ && !/= -1/ {print}'

Extras envolvendo arquivos (/proc/) e strace

Usando problemas comuns envolvendo arquivos e conexões conversamos um pouco sobre o strace e lsof. Conceitos como SYS CALL e a pasta /proc/ também foram mencioandos. Darei alguns exemplos de algumas outras questões que podemos responder usando esses outros elementos.

Gerando um sumário de SYS CALL

Você pode sumarizar todas as sys call feitas por um processo usando o argumento -c. Isso pode te ajudar a economizar tempo numa pre-análise.

O comando abaixo retorna as sys calls efetuadas pelo comando make sync-env

$ strace -c -e trace=!\wait4 make sync-env

outro argumento que foi alterado aqui é o operador !\ que diz para o strace ignorar as sys call do tipo wait4. O ouput será algo do tipo:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 14,54    0,000209           6        33        13 openat
 13,01    0,000187          17        11           vfork
 12,32    0,000177           7        25           mmap
  8,49    0,000122           3        31           close
  8,42    0,000121           5        21           rt_sigprocmask
  8,14    0,000117           6        17           read
  6,89    0,000099           5        19        11 stat
  5,85    0,000084           3        23           fstat
  2,85    0,000041           8         5           mprotect
  2,64    0,000038           9         4           write
  2,51    0,000036           2        16           fcntl
  2,02    0,000029           3         9           rt_sigaction
  1,95    0,000028          14         2           readlink
  1,95    0,000028          14         2           getdents64
  1,25    0,000018           4         4           brk
  1,25    0,000018          18         1         1 access
  1,25    0,000018           3         5           pipe
  1,11    0,000016           4         4           ioctl
  0,84    0,000012           6         2           getcwd
  0,70    0,000010          10         1           munmap
  0,49    0,000007           7         1           lstat
  0,49    0,000007           7         1           execve
  0,49    0,000007           3         2           prlimit64
  0,35    0,000005           5         1           chdir
  0,21    0,000003           3         1           arch_prctl
------ ----------- ----------- --------- --------- ----------------
100.00    0,001437                   241        25 total

A coluna time diz que make sync-env gastou $14$% do tempo (com exceção do wait4) em sys calls do tipo openat e $13$ das $33$ chamadas não foram bem sucedidas.

O processo foi iniciado com as variáveis de ambiente corretas?

Os próximos exemplos envolvem situações em que um processo foi iniciado, mas você quer verificar algumas informações sobre o mesmo sem que seja necessário matar e reiniciar processo. Imagine fazer isso em produção? Ou com um modelo de ML que já gastou muitos R$ para chegar no estágio atual.

Vamos continuar com o nosso server_mlops.py. Suponha que o processo foi iniciado usando uma variável de ambiente extra, ANSWER.

$ ANSWER=42 python server_mlops.py

Após o inicio do processo como saber com quais variáveis de ambiente ele está usando? Essa variáveis setam por exemplo bibliotecas de otimização(BLAS, LAPACK), env’s python etc.

Como dito em um exemplo anterior, a pasta /proc contêm arquivos representado o estado dos processos em execução. Supondo que o PID do processo é 4031 você pode acessar as variáveis de ambiente do mesmo através de cat /proc/4031/environ. Mas o output é meio feio, vamos usar tr para trocar os caracteres nulos \0 por quebras de linhas, \n.

$ tr '\0' '\n' < /proc/4031/environ

Você terá um output do tipo

ANSWER=42
SHELL=/bin/bash
LANGUAGE=en_US
JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64/bin/java
...more stuff

Se você quiser filtrar apenas linhas que comecem com a string CONDA faça

$ tr '\0' '\n' < /proc/4031/environ 2>&1 | awk '/^CONDA/ {print}'

o output no meu caso foi algo do tipo

CONDA_EXE=/home/devmessias/anaconda3/bin/conda
CONDA_PREFIX=/home/devmessias/anaconda3
CONDA_PROMPT_MODIFIER=(base) 
CONDA_SHLVL=1
CONDA_PYTHON_EXE=/home/devmessias/anaconda3/bin/python
CONDA_DEFAULT_ENV=base

Esqueci de redirecionar os outputs do processo para um arquivo. O que fazer?

Suponha que você iniciou um processo e não redirecionou os outputs para um arquivo de texto por esquecimento ou por subestimar problemas. Se reiniciar o processo não é uma opção você está com problemas. Felizmente é possível usar o strace para interceptar os outputs e salva-los em um arquivo externo.

A SYS CALL responsável por requisitar a escrita no stdin, stdout e stderr é a write . Veja o manual dessa chamada

$ man 2 write
NAME
       write - write to a file descriptor
SYNOPSIS
       #include <unistd.h>
       ssize_t write(int fd, const void *buf, size_t count);

O primeiro argumento é um inteiro que representa o file descriptor. Sendo que fd=1 implica que a chamada escreverá no stdout e fd=2 no stderr . Portanto, não existe nenhum segredo aqui. Se você quiser capturar os outputs basta filtrar as SYS CALL do tipo write e file descriptor 1 ou 2 e envia-las para o arquivo desejado. Temos que tomar cuidado só com as algumas coisas aqui. No manual do strace (man strace) você vera que por padrão ele printa apenas $32$ caracteres em uma string. Portanto, precisamos aumentar o limite com o argumento -s. Também é interessante traquear os forks. No caso do server_mlops.py por exemplo, qualquer print dentro de um método não será executado na main, então o -f é obrigatório.

O comando para redirecionar as saidas do stdout e stderr no arquivo out.txt pode ser colocado da seguinte maneira com o log dos tempos (-t) opicional.

$ strace -f -t -etrace=write -s 666 -p PID_DO_PROCESSO 2>&1 | grep --line-buffered -e 'write(2, ' -e 'write(1, ' >> out.txt

O código abaixo tem uma alteração no server_mlops.py , e execute ele assim como o client_mlops.py. Pegando o PID do serve_mlops você conseguirá explorar esse exemplo

# server_mlops.py
import time
import flask
import sys

app = flask.Flask(__name__)

@app.route('/')
def hello_world():
    sleep_time = flask.request.args.get('sleep', default=10, type=int)
    print('sleep_time:', sleep_time)
    for i in range(sleep_time):
        print(f'INFO: {i} of sleep_time \n asdf \t ')
        print(f'ERROR: Example msg {i}', file=sys.stderr) 
        time.sleep(1)
    return 'Hello World!'


if __name__ == '__main__':
    app.run()

Qual comando gerou o processo e onde é o seu working dir?

Essa pergunta talvez não seja tão difícil de responder se você tem o htop instalado. Mas supondo que você não lembra as informações sobre o comando que gerou o processo execute o comando abaixo

$ tr '\0' '\t' < /proc/PID_CLIENT_MLOPS/cmdline

o output será

python	client_mlops.py	--sleep	1000

Para descobrir o diretório do client_mlops.py basta executar

$ readlink /proc/PID_CLIENT_MLOPS/cwd

Agradecimentos & Sugestões

Achou um erro? Tem alguma sugestão ou dica? mande um email para devmessias@gmail.com.

Bruno Messias
Bruno Messias
Ph.D Candidate /Software Developer

Free-software enthusiast, researcher, and software developer. Currently, working in the field of Graphs, Complex Systems and Machine Learning.

comments powered by Disqus

Relacionados