Entenda quanta memória seus objetos Python usam

Python é uma linguagem de programação fantástica. Também é conhecido por ser bastante lento, devido principalmente à sua enorme flexibilidade e recursos dinâmicos. Para muitas aplicações e domínios não é um problema devido aos seus requisitos e várias técnicas de otimização. É menos conhecido que os gráficos de objetos Python (dicionários aninhados de listas e tuplas e tipos primitivos) ocupam uma quantidade significativa de memória. Isso pode ser um fator limitante muito mais grave devido aos seus efeitos no cache, memória virtual, multilocação com outros programas e, em geral, esgotar mais rapidamente a memória disponível, que é um recurso escasso e caro.

Acontece que não é trivial descobrir quanta memória é realmente consumida. Neste artigo, mostrarei os meandros do gerenciamento de memória do objeto Python e mostrarei como medir a memória consumida com precisão.

Neste artigo, concentro-me exclusivamente no CPython — a principal implementação da linguagem de programação Python. Os experimentos e conclusões aqui não se aplicam a outras implementações Python como IronPython, Jython e PyPy.

Além disso, executei os números no Python 2.7 de 64 bits. No Python 3, os números às vezes são um pouco diferentes (especialmente para strings que são sempre Unicode), mas os conceitos são os mesmos.

Exploração prática do uso de memória Python

Primeiro, vamos explorar um pouco e ter uma noção concreta do uso real de memória dos objetos Python.

A função integrada sys.getsizeof()

O módulo sys da biblioteca padrão fornece a função getsizeof(). Essa função aceita um objeto (e padrão opcional), chama a função do objeto tamanho de() e retorna o resultado, para que você também possa tornar seus objetos inspecionáveis.

Medindo a memória de objetos Python

Vamos começar com alguns tipos numéricos:

Interessante. Um inteiro leva 24 bytes.

Hmm… um float leva 24 bytes também.

Uau. 80 bytes! Isso realmente faz você pensar se deseja representar um grande número de números reais como floats ou decimais.

Vamos passar para strings e coleções:

OK. Uma string vazia ocupa 37 bytes e cada caractere adicional adiciona outro byte. Isso diz muito sobre as vantagens de manter várias strings curtas onde você pagará a sobrecarga de 37 bytes para cada uma versus uma única string longa onde você pagará a sobrecarga apenas uma vez.

As strings Unicode se comportam de maneira semelhante, exceto que a sobrecarga é de 50 bytes e cada caractere adicional adiciona 2 bytes. Isso é algo a ser considerado se você usar bibliotecas que retornam strings Unicode, mas seu texto pode ser representado como strings simples.

A propósito, no Python 3, as strings são sempre Unicode e a sobrecarga é de 49 bytes (eles salvaram um byte em algum lugar). O objeto bytes tem uma sobrecarga de apenas 33 bytes. Se você tem um programa que processa muitas strings curtas na memória e se preocupa com o desempenho, considere o Python 3.

O que está acontecendo? Uma lista vazia leva 72 bytes, mas cada int adicional adiciona apenas 8 bytes, onde o tamanho de um int é de 24 bytes. Uma lista que contém uma string longa ocupa apenas 80 bytes.

A resposta é simples. A lista não contém os próprios objetos int. Ele contém apenas um ponteiro de 8 bytes (em versões de 64 bits do CPython) para o objeto int real. O que isso significa é que a função getsizeof() não retorna a memória real da lista e todos os objetos que ela contém, mas apenas a memória da lista e os ponteiros para seus objetos. Na próxima seção, apresentarei a função deep_getsizeof() que trata desse problema.

A história é semelhante para tuplas. A sobrecarga de uma tupla vazia é de 56 bytes versus os 72 de uma lista. Novamente, essa diferença de 16 bytes por sequência é um fruto fácil se você tiver uma estrutura de dados com muitas sequências pequenas e imutáveis.

Conjuntos e dicionários aparentemente não crescem quando você adiciona itens, mas observe a enorme sobrecarga.

A linha inferior é que os objetos Python têm uma enorme sobrecarga fixa. Se sua estrutura de dados é composta por um grande número de objetos de coleção como strings, listas e dicionários que contêm um pequeno número de itens cada, você paga um preço alto.

A função deep_getsizeof()

Agora que eu o assustei até a morte e também demonstrei que sys.getsizeof() só pode dizer quanta memória um objeto primitivo usa, vamos dar uma olhada em uma solução mais adequada. A função deep_getsizeof() detalha recursivamente e calcula o uso real de memória de um gráfico de objeto Python.

Existem vários aspectos interessantes para esta função. Ele leva em consideração objetos que são referenciados várias vezes e os conta apenas uma vez, acompanhando os ids dos objetos. A outra característica interessante da implementação é que ela tira o máximo proveito das classes base abstratas do módulo de coleções. Isso permite que a função manipule de forma muito concisa qualquer coleção que implemente as classes base Mapping ou Container, em vez de lidar diretamente com uma miríade de tipos de coleção, como: string, Unicode, bytes, list, tuple, dict, frozendict, OrderedDict, set, frozenset, etc. .

Vamos vê-lo em ação:

Uma string de comprimento 7 leva 44 bytes (37 overhead + 7 bytes para cada caractere).

Uma lista vazia leva 72 bytes (apenas sobrecarga).

Uma lista que contém a string x ocupa 124 bytes (72 + 8 + 44).

Uma lista que contém a string x 5 vezes leva 156 bytes (72 + 5 * 8 + 44).

O último exemplo mostra que deep_getsizeof() conta referências ao mesmo objeto (a string x) apenas uma vez, mas o ponteiro de cada referência é contado.

Deleites ou Truques

Acontece que o CPython tem vários truques na manga, então os números que você obtém de deep_getsizeof() não representam totalmente o uso de memória de um programa Python.

Contagem de referência

Python gerencia a memória usando semântica de contagem de referência. Uma vez que um objeto não é mais referenciado, sua memória é desalocada. Mas enquanto houver uma referência, o objeto não será desalocado. Coisas como referências cíclicas podem te machucar bastante.

Objetos pequenos

O CPython gerencia pequenos objetos (menos de 256 bytes) em pools especiais em limites de 8 bytes. Existem pools para 1-8 bytes, 9-16 bytes e até 249-256 bytes. Quando um objeto de tamanho 10 é alocado, ele é alocado do pool de 16 bytes para objetos de 9 a 16 bytes de tamanho. Portanto, embora contenha apenas 10 bytes de dados, custará 16 bytes de memória. Se você alocar 1.000.000 objetos de tamanho 10, você realmente usará 16.000.000 bytes e não 10.000.000 bytes, como você pode supor. Essa sobrecarga de 60% obviamente não é trivial.

Inteiros

CPython mantém uma lista global de todos os inteiros no intervalo [-5, 256]. Essa estratégia de otimização faz sentido porque pequenos inteiros aparecem em todo lugar e, como cada inteiro ocupa 24 bytes, economiza muita memória para um programa típico.

Isso também significa que o CPython pré-aloca 266 * 24 = 6384 bytes para todos esses inteiros, mesmo que você não use a maioria deles. Você pode verificá-lo usando a função id() que fornece o ponteiro para o objeto real. Se você chamar id(x) multiple para qualquer x no intervalo [-5, 256], você obterá o mesmo resultado todas as vezes (para o mesmo inteiro). Mas se você tentar para inteiros fora desse intervalo, cada um será diferente (um novo objeto é criado dinamicamente a cada vez).

Aqui estão alguns exemplos dentro do intervalo:

Aqui estão alguns exemplos fora do intervalo:

Memória Python x Memória do sistema

CPython é meio possessivo. Em muitos casos, quando os objetos de memória em seu programa não são mais referenciados, eles são não devolvidos ao sistema (por exemplo, os pequenos objetos). Isso é bom para o seu programa se você alocar e desalocar muitos objetos (que pertencem ao mesmo pool de 8 bytes) porque o Python não precisa incomodar o sistema, o que é relativamente caro. Mas não é tão bom se o seu programa normalmente usa X bytes e sob alguma condição temporária usa 100 vezes mais (por exemplo, analisando e processando um arquivo de configuração grande apenas quando ele é iniciado).

Agora, essa memória 100X pode ficar presa inutilmente em seu programa, para nunca mais ser usada e impedindo o sistema de alocá-la a outros programas. A ironia é que, se você usar o módulo de processamento para executar várias instâncias de seu programa, limitará severamente o número de instâncias que pode executar em uma determinada máquina.

Perfilador de memória

Para medir e medir o uso real de memória do seu programa, você pode usar o módulo memory_profiler. Brinquei um pouco com isso e não tenho certeza se confio nos resultados. Usá-lo é muito simples. Você decora uma função (pode ser a função main(0) com @profiler decorador, e quando o programa é encerrado, o criador de perfil de memória imprime na saída padrão um relatório prático que mostra o total e as alterações na memória para cada linha. Aqui está um exemplo programa que executei sob o profiler:

Aqui está a saída:

Como você pode ver, há 22,9 MB de sobrecarga de memória. A razão pela qual a memória não aumenta ao adicionar números inteiros dentro e fora da [-5, 256] range e também ao adicionar a string é que um único objeto é usado em todos os casos. Não está claro por que o primeiro loop de alcance (100.000) na linha 8 adiciona 4,2 MB, enquanto o segundo na linha 10 adiciona apenas 0,4 MB e o terceiro loop na linha 12 adiciona 0,8 MB. Finalmente, ao excluir as listas a, b e c, -0,6 MB é liberado para a e c, mas para b é adicionado 0,2 MB. Não consigo fazer muito sentido com esses resultados.

Conclusão

CPython usa muita memória para seus objetos. Ele usa vários truques e otimizações para gerenciamento de memória. Ao acompanhar o uso de memória do seu objeto e estar ciente do modelo de gerenciamento de memória, você pode reduzir significativamente o consumo de memória do seu programa.

Aprenda Python

Aprenda Python com nosso guia tutorial completo sobre Python, seja você apenas começando ou um programador experiente procurando aprender novas habilidades.

Originally posted 2022-05-22 09:51:38.

Deixe uma resposta