Pular para o conteúdo

    Capítulo 32, Avançado

    Desempenho: temporários, in-place e dtypes

    O NumPy já é rápido. O que o torna lento, quando acontece, é quase sempre memória: arrays temporários demais, tipos grandes demais, percursos fora de ordem.

    Código deste capítulo: avancado/cap32_desempenho_numpy.py

    O custo dos temporários

    Cada operação aritmética cria um array novo para o resultado. Em uma expressão com várias operações, o NumPy cria vários temporários, cada um do tamanho do array original. Para arrays grandes, isso significa memória gasta e páginas novas sendo alocadas. A alternativa é escrever no mesmo buffer, com out= e operadores in-place (+=, -=):

    avancado/cap32_desempenho_numpy.pylinhas 10 a 35
    import time
    import tracemalloc
    
    import numpy as np
    
    rng = np.random.default_rng(0)
    a = rng.random(2_000_000)
    b = rng.random(2_000_000)
    
    
    def com_temporarios(a, b):
        return (a * 2 + b * 3 - 1) ** 2
    
    
    def sem_temporarios(a, b, t1, t2):
        np.multiply(a, 2, out=t1)
        np.multiply(b, 3, out=t2)
        t1 += t2
        t1 -= 1
        np.square(t1, out=t1)
        return t1
    
    
    t1 = np.empty_like(a)
    t2 = np.empty_like(a)
    print(np.allclose(com_temporarios(a, b), sem_temporarios(a, b, t1, t2)))
    
    Saída
    True
    

    O resultado é o mesmo. A diferença está na memória: a versão com buffers reutilizados não aloca arrays novos a cada passo. Dá para medir o pico de memória de cada uma:

    avancado/cap32_desempenho_numpy.pylinhas 37 a 48
    def pico_de_memoria(funcao):
        tracemalloc.start()
        funcao()
        _, pico = tracemalloc.get_traced_memory()
        tracemalloc.stop()
        return pico
    
    
    pico_com = pico_de_memoria(lambda: com_temporarios(a, b))
    pico_sem = pico_de_memoria(lambda: sem_temporarios(a, b, t1, t2))
    print("o pico é menor sem temporários:", pico_sem < pico_com)
    print("e muito menor, menos de 10% do outro:", pico_sem < 0.1 * pico_com)
    
    Saída
    o pico é menor sem temporários: True
    e muito menor, menos de 10% do outro: True
    

    Esse padrão (alocar os buffers uma vez, fora de um laço, e reutilizá-los) é o que bibliotecas de alto desempenho fazem. Para código que roda uma vez, a expressão simples é mais legível e basta. Eu só uso out= quando a medição mostra que a memória ou as alocações importam.

    Escolher o dtype certo

    O tamanho do tipo define quanta memória o array ocupa, e quanto precisa ser lido da memória a cada operação. Para a maioria dos problemas de aprendizado de máquina, float32 basta, e usa metade:

    avancado/cap32_desempenho_numpy.pylinhas 53 a 55
    a32 = a.astype(np.float32)
    print(a32.nbytes == a.nbytes // 2)
    print(np.allclose(a32, a, atol=1e-6))
    
    Saída
    True
    True
    

    O custo é a precisão (capítulo 7). Não troque o tipo sem saber quanta precisão o seu problema exige.

    Iterar um array em Python é lento

    Percorrer um array com for cria um objeto escalar do NumPy a cada passo, e isso é caro. Quando um laço Python é inevitável, converter para lista antes com .tolist() o deixa mais rápido. E funções Python como o sum sobre um array são muito mais lentas do que o método do próprio array:

    avancado/cap32_desempenho_numpy.pylinhas 60 a 71
    def medir(funcao, repeticoes=3):
        melhor = float("inf")
        for _ in range(repeticoes):
            inicio = time.perf_counter()
            funcao()
            melhor = min(melhor, time.perf_counter() - inicio)
        return melhor
    
    
    x = np.arange(100_000)
    print("iterar a lista é mais rápido:", medir(lambda: [v for v in x.tolist()]) < medir(lambda: [v for v in x]))
    print("sum do Python é pelo menos 10 vezes mais lento:", medir(lambda: sum(x)) > 10 * medir(lambda: x.sum()))
    
    Saída
    iterar a lista é mais rápido: True
    sum do Python é pelo menos 10 vezes mais lento: True
    

    O que mais existe

    TécnicaGanhoObservação
    out= e in-placeMenos alocação e menos memóriaMenos legível, use onde a medição pedir
    float32 em vez de float64Metade da memória e da leituraPerde precisão
    Percorrer na ordem da memóriaAproveita o cacheCapítulo 29
    Operações em blocosCabe na memória e no cacheCapítulo 31
    Bibliotecas de álgebra linear@ usa BLAS multi-threadJá vem com o NumPy
    numexpr, numba, CythonFundem operações ou compilam laçosBibliotecas separadas, para quando o gargalo é comprovado

    A ordem que eu sigo

    Primeiro, o algoritmo (a vetorização certa). Depois, a memória (tipos e temporários). Só então ferramentas externas de compilação. E, entre uma etapa e outra, medir (capítulo 18): o gargalo quase nunca está onde a intuição aponta.

    Exercício 1

    Softmax estável e vetorizado

    Escreva softmax(x) para uma matriz, aplicando a cada linha. Subtraia o máximo da linha antes do exp para evitar overflow, e confira que funciona com valores enormes.