Capítulo 29, Avançado
Memória por dentro: strides, contiguidade e ordem
Um array é um bloco de bytes mais uma descrição de como lê-lo. Quando você entende essa descrição, entende por que fatiar é de graça, por que transpor é de graça, e por que algumas operações são mais lentas do que deveriam.
Código deste capítulo: avancado/cap29_memoria_strides.py
O array é um bloco mais uma lista de `strides`
Os dados ficam em um bloco contínuo de memória. O que dá forma a ele são os strides: quantos bytes pular para avançar uma posição em cada eixo. Um array de 3 por 4 de int64 (8 bytes por elemento) em ordem de linhas (ordem C, o padrão) avança 8 bytes para a próxima coluna e 32 (4 colunas × 8) para a próxima linha. A transposta não move um byte: só troca os strides:
import numpy as np
a = np.arange(12, dtype=np.int64).reshape(3, 4)
print(a.strides, a.flags["C_CONTIGUOUS"], a.flags["F_CONTIGUOUS"])
t = a.T
print(t.strides, t.flags["C_CONTIGUOUS"], t.flags["F_CONTIGUOUS"], np.shares_memory(a, t))
(32, 8) True False
(8, 32) False True True
A transposta é contígua em ordem Fortran (F, a ordem de colunas usada em Fortran e em MATLAB): os elementos da mesma coluna estão lado a lado. Os dois arrays compartilham a memória, e é por isso que a.T custa quase nada, mesmo em uma matriz de gigabytes.
Fatiar só muda os strides
Uma fatia com passo é uma nova leitura do mesmo bloco, com outros strides. Nenhum dado se move:
print(a[:, ::2].strides, a[::2].strides, a[:, 0].strides)
(32, 16) (64, 8) (32,)
Pegar uma linha em cada duas dobra o stride do eixo das linhas (de 32 para 64), e pegar uma coluna em cada duas dobra o das colunas (de 8 para 16). E a coluna 0 isolada tem um stride de 32 bytes: para percorrê-la, o NumPy salta pela memória, e esse salto tem um custo.
Por que a contiguidade importa
O processador lê a memória em linhas de cache (blocos de 64 bytes). Percorrer elementos vizinhos aproveita cada linha de cache inteira. Percorrer com saltos grandes desperdiça: a cada elemento, uma linha de cache nova. Compare somar a mesma quantidade de elementos, contíguos e espaçados:
import timeit
x = np.ones(16_000_000, dtype=np.float32)
contiguo = x[:1_000_000]
espacado = x[::16]
print(contiguo.size == espacado.size)
t_contiguo = min(timeit.repeat(lambda: contiguo.sum(), number=5, repeat=5))
t_espacado = min(timeit.repeat(lambda: espacado.sum(), number=5, repeat=5))
print("o contíguo foi mais rápido:", t_contiguo < t_espacado)
True
o contíguo foi mais rápido: True
São os mesmos 1 milhão de elementos, mas o espaçado precisa percorrer 16 vezes mais memória. Quando uma operação é inesperadamente lenta, os strides são o primeiro suspeito, e np.ascontiguousarray(x) faz uma cópia contígua quando vale a pena.
| Flag | Significa |
|---|---|
C_CONTIGUOUS | Linhas contínuas na memória (padrão) |
F_CONTIGUOUS | Colunas contínuas na memória |
OWNDATA | Este array é dono da sua memória (não é uma visão) |
WRITEABLE | Pode ser alterado (broadcast_to e janelas são somente leitura) |
Reinterpretar bytes com `view`
O view com outro dtype não converte os valores: lê os mesmos bytes de outro jeito. É útil para inspecionar a representação interna de números e para processar dados binários:
inteiros = np.array([1, 256, 65536], dtype=np.int32)
print(inteiros.view(np.uint8))
bits = np.array([1.0], dtype=np.float32).view(np.uint32)[0]
print(hex(int(bits)))
[1 0 0 0 0 1 0 0 0 0 1 0]
0x3f800000
O resultado do primeiro view mostra que o NumPy guarda os inteiros em ordem little-endian nesta máquina (o byte menos significativo primeiro). O 0x3f800000 é a representação binária do número 1,0 em float32, segundo o padrão IEEE 754.
`as_strided`: poder com perigo
A função as_strided constrói qualquer visão que você descrever com shape e strides. Com ela você monta janelas deslizantes à mão. É a base do que o sliding_window_view do capítulo 28 faz com segurança:
from numpy.lib.stride_tricks import as_strided
y = np.arange(6)
janelas = as_strided(y, shape=(4, 3), strides=(y.strides[0], y.strides[0]))
print(janelas)
[[0 1 2]
[1 2 3]
[2 3 4]
[3 4 5]]
Não use as_strided sem necessidade
O
as_stridednão verifica nada. Uma forma ou um stride que ultrapasse o bloco de memória lê (ou escreve) bytes que não pertencem ao array, sem erro e sem aviso. Isso corrompe dados ou derruba o programa de forma imprevisível. Prefira sempre osliding_window_view, que calcula tudo para você, e reserve oas_stridedpara quem tem uma razão forte e testa com muito cuidado.
Exercício 1
Descrever a memória
Escreva passos_em_bytes(a) que devolva os strides de um array e e_visao(a) que diga se ele não é dono dos seus dados. Teste com np.zeros((2, 3), dtype=np.int16) e uma fatia dele.