sexta-feira, 29 de setembro de 2017

Inteiros de tamanho fixo com e sem sinal

Operações aritméticas em Python puro funcionam de maneira bem diferente de funções aritméticas usando Numpy, Cython ou Numba. Diferentemente de linguagens de baixo nível como C, variáveis inteiras em Python não tem relação com tipos de dados usados pelo processador para realizar aritmética. Logo, um inteiro Python pode conter valores arbitrariamente longos e não existe diferenciação entre variáveis com ou sem sinal. No caso de variáveis decimais, a precisão sempre é dupla, equivalendo a uma variável tipo double em C. Em geral, isto torna a escrita de programas mais simples (e possivelmente mais segura) ao evitar erros de overflow e underflow, principalmente com variáveis que representados índices, contadores ou tamanhos de memória.

O exemplo abaixo ilustra este fenômeno ao criar um escalar com Numpy usando um inteiro com 8 bits sem sinal.

import numpy as np

purepy_int = 255
np_int8 = np.zeros(1, np.uint8)
np_int8[0] = 255
print(purepy_int, np_int8[0])
purepy_int += 1
np_int8 += 1
print('Após +1', purepy_int, np_int8[0])
255 255
Após +1 256 0

Apesar de mais simples, a abordagem usada em Python puro é mais lenta. Na verdade, ela é MUITO mais lenta do que utilizar tipos de dados ligados a CPU e este é um dos principais motivos da existência de bibliotecas como Numba e Cython. Neste texto irei explorar um pouco de como inteiros são representados na CPU e quais cuidados devem ser observados ao programar usando tipos de dados nativos ao invés de inteiros em Python puro.

Representação de números em diferentes bases

Computadores trabalham com uma representação de tamanho fixo para números inteiros (e decimais) baseada no número de dígitos necessários para representá-lo. Diferentemente de nossa álgebra cotidiana, em que possuimos 10 algarismos (base decimal), CPUs usam apenas 2 algarismos (base binária).

Não importa a base utilizada, a contagem é feita da mesma maneira que fazemos usando a base decimal. Porém, usamos somente os algarismos disponíveis na base que estamos usando. Veja abaixo uma sequência de números na base binária (2) e seu equivalente na base decimal.

  • 0 => 0
  • 1 => 1
  • 2 => 10
  • 3 => 11
  • 4 => 100
  • 5 => 101

Apesar da base ser diferente, a adição funciona da mesma maneira. Sempre que os algarismos disponíveis em um dígito se esgotam (chegam até \(b\)), adicionamos \(1\) ao digito à esquerda e atribuimos \(0\) ao dígito atual. Porém, cada dígito representa uma potência de \(b\) ao invés de uma potência de \(10\). No exemplo acima, quando passamos de \(11\) (3) para \(100\) (4) fazemos esta operação duas vezes e saímos de \(1 \times 2^0 + 1 \times 2^1 = 3\) para \(0 \times 2^0 + 0 \times 2^1 + 1 \times 2^2 = 4\).

Podemos também usar bases com mais algarismos que 10, como a hexadecimal (base 16, contém números 0...9 e letras A..F). Veja abaixo uma sequência de números em hexadecimal. É usual adicionar o prefixo 0x ao escrever números em hexadecimal. As mesmas propriedades citadas acima para números binários são válidas para números hexadecimais.

  • 0x9 => 9
  • 0xA => 10
  • 0xB => 11
  • 0xF => 15
  • 0x10 => 16

A conversão de números em uma base \(b\) para a base decimal é bastante simples. Sejam \(n_0, \dots, n_m\) os dígitos de um número representado na base \(b\) (ou seja, \(0 \leq n_i \leq b-1\)) tal que \(n_0\) é o dígito mais à direita e \(n_m\) é o dígito mais à esquerda. Este número pode ser convertido para o número \(n^{(10)}\) em base decimal usando a seguinte expressão.

\[\begin{equation} n^{(10)} = \sum_{i=0}^m b^i n_i \end{equation}\]

Como temos \(b\) algarismos, um número em base \(b\) com \(m\) dígitos pode representar \(b^m\) números diferentes. Contando com o zero e assumindo que os números são positivos, o intervalo representado é de números entre \(0\) e \(b^m - 1\).

Vejamos um exemplo com números binários. Seja \(n^{(2)} = 10010011\) um número binário. Sua representação em base decimal é dada por

\[\begin{equation} n^{(10)} = 1 \times 2^0 + 1 \times 2^1 + 0 \times 2^2 + 0 \times 2^3 + 1 \times 2^4 + 0 \times 2^5 + 0 \times 2^6 + 1 \times 2^7 = \textbf{147} \end{equation}\]

A conversão de um número em base decimal para uma base \(b\) é feita realizando sucessivas divisões por \(b\) e utilizando o resto de cada divisão como um dígito. Veja o exemplo abaixo, em que convertemos o número decimal \(147\) para a base binária.

  • 147 / 2 = 73, \(n_0 = 1 = (147 \% 2)\)
  • 73 / 2 = 36, \(n_1 = 1 = (73 \% 2)\)
  • 36 / 2 = 18, \(n_2 = 0 = (36 \% 2)\)
  • 18 / 2 = 9, \(n_3 = 0 = (18 \% 2)\)
  • 9 / 2 = 4, \(n_4 = 1 = (9 \% 2)\)
  • 4 / 2 = 2, \(n_5 = 0 = (4 \% 2)\)
  • 2 / 2 = 1, \(n_6 = 0 = (2 \% 2)\)
  • 1 / 2 = 0, \(n_7 = 1 = (1 \% 2)\)

A conversão entre bases diferentes pode ser feita primeiro convertendo para decimal e depois convertendo o decimal resultante para a base final.

Inteiros com tamanho fixo

O tamanho nativo do inteiro de cada processador é chamado de word size. Em processadores 64 bits (como todos os Intel e AMD modernos), o word size possui 64 bits (8 bytes). Logo, os registradores do processador (áreas de memória que o processador usa para armazenar parâmetros para as instruções) possuem tamanho de 64bits. Alé disto, este tamanho também é usado para representar endereços de memória. Como os processadores intel modernos são retro-compatíveis, eles também possuem instruções que tratam inteiros de 8, 16 e 32 bits. Logo, temos suporte nativo a inteiros usando todos estes tamanhos. Note, porém, que os números que representamos até agora são sempre positivos, porém em aplicações em geral precisamos usar também números negativos. A maneira que isto é feito em binário é usando uma técnica chamada "complemento de 2" (two's complement).

Antes de tudo, uma solução simples para este problema poderia ser usar o último dígito (mais a esquerda) como um indicador de sinal. Isto traz dois problemas: (i) existem duas representações para o 0 (-0 e +0) (ii) teríamos que usar algoritmos de adição diferentes para inteiros com e sem sinal.

A técnica de two's complement utiliza o bit mais significativo do inteiro como uma potência de 2 negativa. A representação decimal de um número binário com \(m\) dígitos \(n_0, \dots, n_{m-1}\), com \(n_{m-1}\) sendo o bit mais significativo (à esquerda) é

\[\begin{equation} n^{(10)} = -n_{m-1} 2^{m-1} + \sum_{i=0}^{m-2} n_i 2^i \end{equation}\]

Binários em two's complement com m dígitos podem representar inteiros entre \(-2^{m-1}\) e \(2^{m-1} - 1\). Claramente, é necessário sempre saber se o número que estamos representando é um inteiro unsigned (sem sinal) ou signed (com sinal). As operações nativas da CPU podem aplicar algoritmos diferentes no caso de inteiros com ou sem sinal. Mais importante que isto: não armazenamos na memória se o inteiro tem ou não sinal. Esta interpretação depende exclusivamente do programador.

Seja \(m=4\) (inteiros de 4 bits). O valor binário 1001 é interpretado como um inteiro sem sinal de valor 9 e como o inteiro com sinal -7. Se não formos cuidadosos podemos, por acidente, interpretar inteiros com sinal como sem sinal (e vice-versa) e introduzir bugs em nossos programas.

Aritmética com inteiros de tamanho fixo

Um problema da aritmética com inteiros de tamanho fixo é que operações entre dois inteiros sem sinal de tamanho \(m\) podem ocupar mais de \(m\) bits. Ou seja, pode ser que o resultado não caiba em nenhum registrador da CPU. A CPU possui uma śerie de flags que podem ser ativadas ao realizar operações aritméticas e que indicam se um erro ocorreu durante a operação. As flags mais comuns para operações aritméticas estão listadas abaixo.

  • CF(Carry Flag) - indica se a adição/subtração sem sinal gerou um resultado maior que o tamanho do registrador (ou menor que 0);
  • SF(Sign Flag) - sinal do resultado da operação (para inteiros com sinal);
  • OF(Overflow flag) - 1 se o resultado da operação não cabe no registrador de origem (para inteiros com sinal).

As operações de adição (ADD) e subtração (SUB) não fazem diferenciação entre inteiros com ou sem sinal. É responsabilidade do programador checar as flags corretas conforme o tipo de inteiro armazenado. Veja abaixo alguns exemplos de operações e suas interpretações com e sem sinal. Note que operações entre números com sinal diferente nunca podem dar overflow!

Op 1 op 2 Operação Resultado Com sinal Sem sinal CF OF SF
0b11111111 0b00000111 - 0b11111000 255-7=248 -1-7=-8 0 0 1
0b11111111 0b00000111 + 0b00000110 255+7=6 -1+7=6 1 0 0
0b11000000 0b10001000 + 0b01001000 192+136=72 -64+(-120)=72 1 1 0
0b01000001 0b00111111 + 0b10000000 65+63=128 65+63=-128 0 1 1

As operações de multiplicação e divisão possuem variantes para inteiros sem sinal (MUL/DIV) e com sinal (IMUL/IDIV). No caso da multiplicação, as flags OF e CF são setadas se o resultado não couber no tamanho dos registradores usados como operandos. As instruções de divisão não setam flags, mas podem lançar exceções caso ocorra divisão por 0, por exemplo. O funcionamento destas instruções é semelhante ao apresentado acima para adição/subtração.


Espero que este texto tenha ajudado a mostrar um pouco de como o processador trata números inteiros. Este conhecimento é muito útil não só quando programamos em linguagens com tipos inteiros de tamanho fixo mas também com bibliotecas numéricas como o Numpy.

Como sempre, dúvidas, comentários e críticas podem ser colocados na seção Comentários abaixo. Só lembre-se sempre de ser gentil ;)

sexta-feira, 16 de junho de 2017

Relatórios e textos técnicos usando Pweave

Todos os textos do blog são gerados usando uma ferramenta chamada pweave, que permite escrever textos alternando markdown (ou HTML, Latex e reST) e código em Python. Apesar de ser parecido com o Jupyter Notebook, o foco do Pweave é na criação de relatórios e não na execução de código ao mesmo tempo que o texto é editado. Quando um documento é compilado pelo Pweave seu código é executado e um documento de saída é criado com os resultados. Isso facilita muito a edição dos textos, pois podemos usar nosso editor favorito ao invés da interface web do Jupyter (ela não me agrada, mas isto é bem pessoal...).

A instalação do Pweave pode ser feita via conda

conda config --add channels mpastell
conda install pweave

ou fazendo download do código a partir do PyPI. A página inicial do Pweave cite um problema com a instalaçõa via pip e Python 3. Não sei se este problema ainda existe, então pode valer a pena investigar.

Pweave pode ser utilizado de duas maneiras: convertendo um texto em markdown com código Python (usando o comando pweave) ou convertendo um script Python com comentários contendo texto em markdown (usando o comando pypublish). Ambos suportam as praticamente as mesmas funcionalidades, mas dependendo do uso um pode ser mais conveniente que o outro. Eu prefiro o primeiro modo para escrever os textos do blog.

Opções de entrada

Documentos são compostos por dois tipos de trechos (chunks): documentação e código. Trechos de documentação não são processados pelo Pweave e são copiados diretamente para a saída. Trechos de código (code chunks) são executados pelo Pweave e sua saída é colada no documento de resultado. É possível adicionar opções que controlam como a saída é processada. As que mais uso são:

  • fig=True: cola figuras do matplotlib no documento;
  • caption='': legenda de figuras;
  • source='': carregar o código deste chunk de um arquivo;
  • evaluate=False: somente mostrar o código, sem avaliar;
  • engine='python'|'shell': executar código em Python ou comandos no terminal.

Todas as opções de configuração de code chunks podem ser consultadas aqui.

Opções de saída

A transformação dos trechos de código no documento de saída depende do formato utilizado. Veja abaixo uma lista dos formatos mais comuns:

  • markdown: Produz texto em markdown na saída (em formato pandoc);
  • md2html: Produz documento HTML a partir de uma entrada em markdown (usando o módulo markdown);
  • pandoc2html: Markdown para HTML usando pandoc;
  • pandoc2latex Markdown para Latex usando pandoc;
  • rest: Documento em Restructured Text.

Mais formatos de saída em Latex podem ser vistos na documentação oficial). Também são suportados outros formatos mais obscuros.

Um ponto importante é que os formatos que exportam HTML suportam equações escritas no formato Latex usando MathJax. Logo, é possível criar textos bastante técnicos e exportar diretamente para HTML (além de Latex,é claro). Um exemplo é o texto que fiz sobre Regressão Logística.

Usando Pypublish

O comando pypublish recebe um arquivo em Python como entrada e devolve um documento formatado. Toda linha de comentário começando com #' é incluída como markdown no documento formatado. O restante do código é executado. Opções para a visualização do código podem ser definidas usando #+. Veja abaixo um exemplo de script que pode ser publicado usando pypublish.

#' % título
#'% author
#'% data

#'este texto é um exemplo em markdown para pweave.

print('hello!')
var = 7

#' O valor de var é <%= var %>!.

#' Abaixo um gráfico (com legenda!). O código usado para gerar o
gráfico não aparece no resultado final.

#+ caption='Gráfico com legenda.',echo=False
import numpy as np
import matplotlib.pyplot as plt
plt.plot(np.random.rand(10), '-')
plt.show()

#' E o texto acaba aqui.

O comando para publicar o script é

pypublish -f (html ou pdf) arquivo.py

Para saída em pdf é necessário ter instalado pandoc e pdflatex.

Usando pweave

O comando pweave recebe um arquivo texto com trechos em Python e o converte para um documento processado contendo os trechos de código e seu resultado. Figuras são embutidas direto na página. O código incluido nos code chunks é executado na mesma seção (ou seja, tudo que foi escrito em um trecho continua valendo nos próximos).

% título
% author
% data

este texto é um exemplo em markdown para pweave.

<<>>=
print('hello!')
var = 7
@

O valor de var é <%= var %>!.

Abaixo um gráfico (com legenda!). O código usado para gerar o gráfico
não aparece no resultado final.

<<caption='Gráfico com legenda.',echo=False>>=
import numpy as np
import matplotlib.pyplot as plt
plt.plot(np.random.rand(10), '-')
plt.show()
@

E o texto acaba aqui.

O comando usado para processar o arquivo acima é:

pweave -f (formato incluso na lista acima) arquivo

Lembre-se de que o arquivo de entrada deverá ser compatível com o formato escolhido. Um outro exemplo pode ser este texto mesmo, que foi compilado usando Pweave. Seu código fonte pode ser visto aqui.


O resultado gerado pelos exemplos acima pode ser visto aqui. Apesar de ambos comandos gerarem a mesma saída eles tem usos diferentes. Quando desejamos fazer um texto que contém exemplos e gráficos gerados usando Python é melhor usar o comando pweave. Por outro lado, se temos um código já pronto que precisa ser usado em outros scripts e queremos documentá-lo sem alterar sua funcionalidade podemos usar o pypublish.

Espero que este texto tenha sido útil. Tenho visto o Pweave como uma excelente alternativa ao Jupyter Notebook para a criação de textos técnicos, com a grande vantagem de que posso usar meu editor de texto favorito ao invés da interface Web do Jupyter. Qualquer dúvida ou comentário é só comentar. Se gostou, compartilhe e ajude o blog a ficar mais conhecido ;)

sexta-feira, 9 de junho de 2017

Uma estratégia para começar a terminar os projetos que começamos

Como muitos outros programadores, eu tenho uma grande vontade de criar programas e aplicativos. Assim, vivo arranjando desculpas para começar algum projeto novo ou estudar uma nova linguagem. Passo um tempo considerável fazendo pesquisas sobre o assunto, procurando tecnologias "adequadas" (ou seja, que me agradem) e pensando como este programa seria. O problema é que dificilmente estes projetos alcançam uma primeira versão usável. Na verdade, muitas vezes eles não são nem iniciados e sempre acabam substituídos por outras ideias ou novidades. Mais ou menos assim.

Neste texto tentarei elaborar algumas ideias de como tento direcionar esta energia criativa de modo mais efetivo. Antes de tudo, eu ainda sofro deste problema e frequentemente me vejo pesquisando a fundo coisas que só seriam úteis em um eventual projeto que eu sei que não completarei. É verdade que isto me dá uma cultura geral (tecnológica) interessante, mas o custo benefício é bem baixo. São horas demais gastas em resultados de menos, por assim dizer. Porém, a técnica que descrevo neste texto me ajudou a transformar parte deste tempo gasto em algum resultado palpável.

Para mim, a melhor maneira que encontrei de direcionar essa vontade e energia criativa foi exercitá-la todos os dias ao invés de deixar tudo acumular e passar horas pesquisando. Como gosto também de escrever e, resolvi direcionar essa energia para escrever neste blog. Todos os dias escrevo por 30 minutos, de preferência de manhã, e procuro ter sempre um post pronto toda sexta-feira. Se tenho algo bom antes eu agendo para a próxima sexta e continuo a escrever. Se o assunto é extenso divido em várias partes e publico uma por vez. Já são 9 semanas com textos publicados regularmente e hoje estou no meu 17 dia de escrita seguido sem interrupção.

Um ponto essencial desta rotina é o tempo que reservo para escrever e o horário que o faço. Se eu deixo para escrever de noite existem grandes chances que eu estarei muito cansado ou que algo aconteça e eu não tenha tempo. Se eu simplesmente começar em qualquer momento do dia posso negligenciar outras tarefas mais importantes. Ou pode acontecer o contrário e eu nunca escrever pois outras tarefas são mais urgentes. Em geral, escrever se torna frustrante pois é uma tarefa que eu sei que gostaria de fazer mas acabo não conseguindo realizar a contento.

Essa estratégia está dividida em três componentes igualmente importantes: foco, hábito e objetivo.

Para exercitar o foco eu sempre escrevo usando um cronômetro. Assim que eu começo inicio a contagem e termino assim que ele alcançar os 30 minutos. O senso de urgência de ter pouco tempo para esta atividade aumenta minha concentração e faz com que este tempo seja reservado somente para a escrita. O objetivo é aprender a ligar o foco e dedicar um tempo para fazer uma atividade com 100% de concentração. É assustador o quanto conseguimos ser produtivos ao usar 100% da nossa atenção em uma só tarefa. Isto pode ser relaxante também. Muitas vezes estou menos cansado ao terminar do que antes de escrever.

O hábito é igualmente importante nesta estratégia. A regularidade evita que muita energia criativa acumule e eu resolva iniciar novos projetos e pesquisas. Gosto muito da seguinte citação, atribuída a William Falkner.

"I Only Write When Inspiration Strikes. Fortunately It Strikes at Nine Every Morning"

Além disto, a regularidade transforma uma atividade em um hábito. Da mesma maneira que todos os dias escovo os dentes ou tomo banho mais ou menos nos mesmos horário, tenho o hábito de escrever e não é necessário grande esforço para começar.

O último ponto importante é o objetivo. Ter foco e regularidade pode até ajudar a controlar nossa energia criativa e evitar que ela nos domine, mas colocar um objetivo transforma essa energia criativa em um benefício concreto para nós mesmos. Se, por exemplo, decido passar um tempo pesquisando sobre uma linguagem de programação ou tecnologia diferente, posso transformar esta busca em um tutorial ou um screencast. Ao final da busca tenho, além do conhecimento adquirido, um material escrito que demonstra meu conhecimento no assunto. Este resultado obtido pode ter o formato de contribuições para um software livre ou palestras. No meu caso, o objetivo é publicar um texto novo a cada sexta-feira. Este é o nono texto que publico seguido e me sinto mais motivado do que quando comecei.

Quando juntamos os três componentes, foco, hábito e objetivo, conseguimos domar nossa energia criativa e direcioná-la para nosso benefício. Apesar de ser somente uma pequena quantidade de tempo (30 minutos por dia), exercitar estes 3 componentes pode trazer benefícios para o restante da vida. Recomendo a todos os leitores (no plural?) fazer um teste de 30 dias e ver como este exercício afeta sua produtividade. Se gostou, comente aqui embaixo. Se não gostou pode comentar também, mas seja educado ;)

Até mais.

sexta-feira, 2 de junho de 2017

Usando o profilehooks para medir tempo de execução em Python

Melhorias de performance em código Python já foram assunto de diversos textos neste blog. Já escrevi sobre utilização de [Cython] e [Numba], fiz uma comparação dessas duas tecnologias com Numpy e recentemente publiquei um texto sobre medição de consumo de memória. Neste texto irei tratar novamente de otimização de programas em Python, focando novamente em um exemplo de TRIOSlib (a biblioteca que desenvolvi durante meu doutorado). Uma das operações feitas é a contagem de pontos diferentes de zero em imagens. Já examinamos as diferenças entre Python puro, Cython e Numba neste operação anteriormente e chegamos à conclusão que o desempenho era melhor utilizando Numba e Cython, mas que mesmo os códigos usando Numpy já ofereciam grandes ganhos de desempenho.

Iremos otimizar a função num_samples(win) da classe Imageset (trios.imageset.Imageset). O código da função está abaixo. O código completo do arquivo pode ser visto aqui.

import os.path
import numpy as np
import trios.shortcuts.persistence as p
import collections

class Imageset:

    # .....

    def num_samples(self, win):
        total = 0
        for (_, _, m) in p.load_imageset(self, win):
            total += len(np.nonzero(m)[0])
        return total

    # .....

Na TRIOS um Imageset é uma lista de triplas contendo caminhos para imagens. A função num_samples(win) conta quantos pontos brancos existem na terceira imagem da tripla. A função load_imageset() carrega do disco as imagens gravadas em uma tripla e as retorna como matrizes do Numpy. Atualmente estamos usando Numpy para fazer a contagem de pontos.

Diferentemente dos textos anteriores, a função que iremos otimizar neste texto não é somente a implementação de um algoritmo em memória. Imageset.num_samples contém também acessos ao disco, o que significa que uma parte do tempo de execução não depende da implementação da contagem de pontos. Um dos nossos objetivos é verificar se a diferença obtida pela otimização na contagem de pontos é significativa dado o tempo de leitura das imagens do disco.


O módulo cProfile já vem incluso em Python e pode ser usado para medir quanto tempo é gasto por cada função de um programa. Sua utilização não é complicada, mas se torna ainda mais fácil com o módulo profilehooks, que é um wrapper para o cProfile usando o decorador @profile. Basta decorar uma função e um relatório é automaticamente impresso na saída padrão. Também é possível passar várias opções para o decorador.

Nosso código de testes é o seguinte:

from trios import imageset
from profilehooks import profile
import numpy as np

if __name__ == '__main__':
    imgset =
imageset.Imageset.read('/media/igor/Data1/datasets/staffs/level1.set')
    profiled_num_samples = profile(imgset.num_samples, entries=10)
    win = np.ones((11, 11), np.uint8)

    print('num_samples:', profiled_num_samples(win))
num_samples: 5595040

*** PROFILER RESULTS ***
num_samples (/home/igor/anaconda3/lib/python3.5/site-packages/trios-2.0.7-py3.5-linux-x86_64.egg/trios/imageset.py:65)
function called 1 times

         51097 function calls (50830 primitive calls) in 3.032 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 322 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.003    0.003    3.032    3.032 imageset.py:65(num_samples)
       11    0.000    0.000    2.181    0.198 persistence.py:39(load_imageset)
       30    0.000    0.000    2.179    0.073 persistence.py:20(load_image)
       30    0.000    0.000    2.178    0.073 io.py:22(imread)
       30    0.000    0.000    2.178    0.073 pilutil.py:103(imread)
       30    0.000    0.000    2.148    0.072 pilutil.py:203(fromimage)
       30    0.057    0.002    2.148    0.072 {built-in method numpy.core.multiarray.array}
       30    0.001    0.000    2.090    0.070 Image.py:618(__array_interface__)
       30    0.005    0.000    2.089    0.070 Image.py:654(tobytes)
       30    0.008    0.000    1.968    0.066 ImageFile.py:120(load)

A execução da função num_samples com um conjunto de imagens contendo 10 imagens de alta resolução demorou 3 segundos, sendo que 2.2 segundos foram gastos na função load_imageset. Logo, por mais que otimizemos a contagem de pontos brancos, a execução demorará ao menos o tempo de ler as imagens do disco. Ou seja, mesmo para imagens em alta resolução uma pequena parte (cerca de 25%) do tempo é gasta contando os pontos brancos. Se possível, otimizar a leitura das imagens traria ganhos mais significativos.

A coluna ncalls mostra o número de vezes que a função daquela linha foi chamada, enquanto cumtime mostra o tempo cumulativo de execução (ou seja, o tempo da função executar por completo). Apesar de estarmos carregando somente 10 imagens p.load_image foi chamada 30 vezes! Examinando o código da função p.load_imageset vemos que ela retorna 3 imagens, mas só usamos a máscara. Logo, se lermos somente a máscara do disco poderemos economizar bastante tempo.

O novo código da função num_samples é

def num_samples(self, win):
    total = 0
    for (i, o, m) in self:
        if m is not None:
            m = p.load_mask_image(m, None, win)
            total += len(np.nonzero(m)[0])
        else:
            i = p.load_image(i)
            ww = win.shape[1]
            wh = win.shape[0]
            total += (i.shape[0] - wh+1)*(i.shape[1] - ww+1)
    return total
num_samples: 5595040

*** PROFILER RESULTS ***
num_samples2 (/home/igor/Dropbox/blog/otimizacao-trios/imageset.py:71)
function called 1 times

         22088 function calls (21821 primitive calls) in 1.618 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 321 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.003    0.003    1.618    1.618 imageset.py:71(num_samples2)
       10    0.000    0.000    0.857    0.086 fromnumeric.py:1490(nonzero)
       10    0.856    0.086    0.856    0.086 {method 'nonzero' of 'numpy.ndarray' objects}
       10    0.002    0.000    0.758    0.076 persistence.py:23(load_mask_image)
       10    0.000    0.000    0.756    0.076 persistence.py:20(load_image)
       10    0.000    0.000    0.756    0.076 io.py:22(imread)
       10    0.000    0.000    0.756    0.076 pilutil.py:103(imread)
       10    0.000    0.000    0.732    0.073 pilutil.py:203(fromimage)
       10    0.016    0.002    0.732    0.073 {built-in method numpy.core.multiarray.array}
       10    0.001    0.000    0.716    0.072 Image.py:618(__array_interface__)

O consumo de tempo foi reduzido pela metade! Isto mostra a grande importância de medir qual função/parte de um script consome mais tempo. Podemos otimizar somente as partes que gastam a maior parte do tempo e identificar quais otimizações valem a pena. Além disto, agora a operação mais cara realmente é a contagem de pontos (linha 2, função nonzero em numeric.py). Mudando a contagem de pontos para a função np.count_nonzero obtemos a seguinte medição de tempo.

num_samples: 5595040

*** PROFILER RESULTS ***
num_samples3 (/home/igor/Dropbox/blog/otimizacao-trios/imageset.py:84)
function called 1 times

         22068 function calls (21801 primitive calls) in 1.073 seconds

   Ordered by: cumulative time, internal time, call count
   List reduced from 320 to 10 due to restriction <10>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.004    0.004    1.073    1.073 imageset.py:84(num_samples3)
       10    0.002    0.000    0.766    0.077 persistence.py:23(load_mask_image)
       10    0.000    0.000    0.765    0.076 persistence.py:20(load_image)
       10    0.000    0.000    0.765    0.076 io.py:22(imread)
       10    0.000    0.000    0.764    0.076 pilutil.py:103(imread)
       10    0.000    0.000    0.743    0.074 pilutil.py:203(fromimage)
       10    0.019    0.002    0.743    0.074 {built-in method numpy.core.multiarray.array}
       10    0.001    0.000    0.723    0.072 Image.py:618(__array_interface__)
       10    0.002    0.000    0.723    0.072 Image.py:654(tobytes)
       10    0.003    0.000    0.662    0.066 ImageFile.py:120(load)

Diminuimos a execução de 3 segundos no primeiro exemplo para somente 1 segundo. Executar as funções com @profile nos ajudou a encontrar onde a função gastava a maior quantidade de tempo e fizemos pequenas mudanças adicionais para ganhar ainda mais performance. É possível ir ainda mais longe, como pudemos ver no comparativo Numpy x Numba x Cython, porém o ganho de desempenho será pequeno para nosso caso de uso. É mais vantajoso manter o código mais enxuto e simples do que adicionar complexidade e dependências por um ganho pequeno. O código final da função é mostrado abaixo.

def num_samples3(self, win):
    total = 0
    for (i, o, m) in self:
        if m is not None:
            m = p.load_mask_image(m, None, win)
            total += np.count_nonzero(m)
        else:
            i = p.load_image(i)
            ww = win.shape[1]
            wh = win.shape[0]
            total += (i.shape[0] - wh+1)*(i.shape[1] - ww+1)
    return total

Espero que este texto tenha mostrado a importância da utilização de ferramentes de profiling para medição de tempo de execução em Python. Muitos scripts podem ser otimizados de maneira inteligente se nos concentrarmos somente nas partes que demoram a maior parte do tempo.

sexta-feira, 26 de maio de 2017

Medindo consumo de memória em Python

O gerenciamento de memória de programas em Python é feito inteiramente pela linguagem. Isto significa que, por vezes, pode ser difícil controlar o uso de memória e não costuma ser claro o quanto cada objeto ocupa na memória nem quando seu espaço é liberado. Descreverei neste post o uso do módulo memory_profiler, que contém ferramentas para medir o consumo de memória de scripts Python.

Antes de tudo, o memory_profiler pode ser instalado via pip usando o seguinte comando. A opção -U pode ser usada para instalar o módulo na área do usuário em sistemas Unix-like.

$ pip install memory_profiler

Para usar este módulo basta decorar a função que desejamos medir o consumo de memória com o decorador @profile e executar o programa usando

$ python -m memory_profiler arquivo.py

Este comando imprime um relatório linha a linha mostrando o consumo de memória. Quando objetos são criados o consumo de memória aumenta, enquanto quando eles saem de contexto o consumo pode diminuir. O ponto fraco deste modo de execução é que ele só mostra o consumo medido "entre" as linhas do programa. Ou seja, se uma função for chamada e, durante sua execução alocar e liberar uma grande quantidade de memória estes valores não são contabilizados.

Também é disponibilizado o comando mprof, que executa um script e mede o consumo total de memória em pequenos intervalos de tempo. mprof, porém, não discrima onde a memória está sendo usada.


Irei exemplificar o uso do mprof com dois exemplos tirados da TRIOSlib, a biblioteca que desenvolvi durante meu doutorado. O objetivo da biblioteca é usar aprendizado de máquina para tratar problemas de processamento de imagens (mais detalhes aqui).

Uma das tarefas que mais consome memória na TRIOSlib é a extração de características. Um padrão é extraído de cada ponto das imagens de treinamento. Conjuntos de treinamento contendo várias centenas de milhares de padrões são comuns, mesmo usando um pequeno número de imagens de treinamento.

A TRIOSlib implementa dois modos para extração de características. No primeiro uma matriz é alocada com uma linha por pixel das imagens de treinamento. Cada padrão observado é armazenado em uma linha e, se houverem repetições, o mesmo padrão é armazenado diversas vezes. Neste caso o consumo depende do número de pixels das imagens de treinamento. Nos referimos a este modo como modo Array.

No segundo modo a matriz é trocada por um dicionário cujas chaves são padrões observados nas imagens. Se houverem repetições não há consumo extra de memória. Neste caso o consumo de memória depende do número de padrões únicos nas imagens. Quanto maior a repetição de padrões menor será o consumo relativo ao primeiro modo. Nos referimos a este tipo de execução como modo Dicionário ou Dict.

Apesar do memory_profiler possuir um modo que mostra o consumo de memória linha a linha, para operações longas como o treinamento de operadores pode ser mais interessante usar o comando mprof. Apesar de não ser possível identificar qual estrutura está consumindo memória, podemos ver o quanto a memória cresce e diminui conforme o programa executa. Também conseguimos, por meio do decorador @profile, marcar o início e o fim da execução de funções no código.

Para executar um script usamos o comando mprof run arquivo.py. Podemos plotar um gráfico com a última execução usando mprof plot. Usaremos o código abaixo como exemplo.

import trios
import trios.feature_extractors
import trios.classifiers

import numpy as np
from sklearn.tree import DecisionTreeClassifier

import sys


@profile
def main(ordered=True):
    imgset =
trios.Imageset.read('/media/igor/Data1/datasets/staffs/level1.set')
    testset =
trios.Imageset.read('/media/igor/Data1/datasets/staffs/test-
small.set')
    win = np.ones((7, 7), np.uint8)

    wop = trios.WOperator(win,
trios.classifiers.SKClassifier(DecisionTreeClassifier(),
ordered=ordered), trios.feature_extractors.RAWFeatureExtractor(win))

    dataset = wop.extractor.extract_dataset(imgset, ordered=ordered)
    if ordered:
        print(dataset[1].shape[0])
    else:
        print(len(dataset))

    tr2 = profile(wop.classifier.train)
    tr2(dataset, {})
    wop.trained = True

    print(wop.eval(testset[:1]))

if __name__ == '__main__':
    main(sys.argv[1] == 'ordered')

O decorator @profile irá marcar no gráfico o início e fim das funções main e WOperator.Classifier.train. (Lembre-se que um decorador é simplesmente uma função que retorna outra função. Fazemos isto na linha 21 para obter uma função de treinamento com @profile.) A escolha do modo de extração de características é feita pelo parâmetro ordered, passado pela linha de comando. ordered=True implica no uso de uma matriz para os padrões. ordered=False usa um dicionário. Veja abaixo o consumo de memória medidos em uma execução usando os dois modos.

Fig 1: Modo Array

Fig 2: Modo Dict

O consumo máximo de memória do modo Array é cerca de 4 vezes maior do que o modo Dict! A principal diferença entre os dois modos de execução é a quantidade de memória usada pelo método train. Enquanto o modo Array executa a extração de características muito mais rápido que o modo Dict, o treinamento é muito mais custoso tanto em termos de tempo quanto em memória. Isto pode ser explicado pelo fato de que o modo Dict condensa todas as observações repetidas em uma só linha ao treinar. Isto resulta em uma matriz de treinamento com 137.453 linhas, contra 5.595.585 linhas quando usamos o modo Array. A redução no número de linhas é possível atribuindo ao peso de cada exemplo de treinamento único o número de repetições observadas nas imagens. Infelizmente a maioria dos classificadores do scikit-learn não oferece suporte a definir pesos para cada instância de treinamento. Classificadores baseados em árvores de decisão (encontrados no pacote sklearn.tree) possuem este suporte e, como podemos ver isto faz uma grande diferença ao trabalhar com dados em que há repetição nos padrões de entrada observados.


Neste texto exemplifiquei a utilização do memory_profiler para medição de consumo de memória em Python e do decorador @profile para marcar o início e o fim da execução de diversas funções. Como pudemos ver, utilizar estruturas adequadas pode diminuir significativamente o consumo de memória e permitir processar uma maior quantidade de dados em memória.