Trilha de aprendizado · Nível 14 · Tutorial 9

Reduzir o pico e a retenção de memória

Use medições para reduzir a quantidade de dados mantida ao mesmo tempo, eliminando cópias dispensáveis e referências que prolongam a vida de estruturas auxiliares.

  • Nível: Avançado
  • Duração: 22 min
  • 8 passos
Reduzir o pico e a retenção de memória

O que você vai percorrer

  1. Mapear o que permanece vivo em cada fase Identifique o pico de memória observando quais estruturas coexistem durante cada fase do processamento. 2 min
  2. Remover cópias sem mudar o contrato Elimine contêineres intermediários apenas quando a leitura, a mutação e o compartilhamento de referências permitirem. 3 min
  3. Reduzir a materialização no fluxo completo Compare o pico de memória de três fluxos equivalentes: materialização completa, gerador sobre uma fonte ainda materializada e processamento incremental desde a origem. 3 min
  4. Dimensionar lotes e buffers pelo orçamento Estime uma capacidade inicial de lote a partir de um orçamento de memória e valide-a com medições representativas. 3 min
  5. Distinguir pico temporário de retenção crescente Compare a memória atual e o pico em ciclos equivalentes para reconhecer sobreposição temporária, estabilização e retenção que merece investigação. 3 min
  6. Encurtar a vida das estruturas auxiliares Revise vínculos que mantêm lotes alcançáveis e conserve apenas o histórico exigido pelo contrato. 3 min
  7. Confirmar o ganho sem quebrar o contrato Teste os riscos específicos de uma otimização de memória antes de aceitar o ganho medido. 3 min
  8. Aplicação final: reduzir e comprovar Aplique uma versão inicial e uma versão ajustada do mesmo processamento, compare as medições e registre evidências de correção. 4 min

O que você vai aprender

  • Identificar estruturas que coexistem no momento de maior consumo de memória.
  • Comparar o pico de versões com cópias intermediárias, materialização completa e processamento incremental.
  • Ajustar lotes e retenção de resultados a um orçamento de memória.
  • Verificar redução de memória sem alterar resultados nem introduzir compartilhamento indevido de estado.

Antes de começar

  • Localizar alocações de memória com tracemalloc
  • Medir trechos de código com timeit
  • Controlar referências e cópias de coleções
  • Compor fluxos de transformação sob demanda
  • Processar arquivos em fluxos de consumo único
  • Selecionar casos de teste e verificar exceções

Passo 1 de 8

Mapear o que permanece vivo em cada fase

Identifique o pico de memória observando quais estruturas coexistem durante cada fase do processamento.

Pico é coexistência, não soma histórica

O que procurar na medição

O pico acontece na fase em que a maior quantidade de dados ainda está viva ao mesmo tempo. Ele não é a soma de tudo o que foi alocado desde o início: estruturas temporárias que já deixaram de existir não contribuem para o mesmo instante do pico.

Ao analisar um fluxo, pergunte em cada fase: quais objetos e contêineres ainda podem ser alcançados? Some mentalmente a entrada, os contêineres auxiliares e a saída que coexistem naquele ponto.

Linha do tempo de objetos vivos

A faixa mais carregada representa a fase que reúne entrada, coleção auxiliar e resumo antes que a auxiliar deixe de ser necessária.

Diagrama horizontal de quatro fases. A entrada permanece viva durante todo o processamento; uma coleção auxiliar aparece no meio; um resumo compacto aparece perto do fim; a maior sobreposição ocorre quando as três estruturas coexistem.

O pico vem da sobreposição temporal das estruturas, e não da quantidade total de alocações feitas.

Exemplo: registros, intermediário e resumo

Exemplo

Uma linha do tempo possível

Considere uma função que cria registros sintéticos, monta uma coleção auxiliar e por fim entrega apenas um resumo:

  1. Entrada criada: registros está viva.
  2. Auxiliar construída: registros e normalizados estão vivos.
  3. Resumo calculado: registros, normalizados e resumo estão vivos.
  4. Retorno: apenas resumo precisa permanecer vivo para quem chamou a função.

A fase 3 tende a ser o maior pico, mesmo que o resumo seja pequeno. A coleção auxiliar é memória de trabalho daquela fase; o resumo é memória exigida pelo resultado.

Dica

Não conte o mesmo objeto duas vezes

Se dois contêineres apontam para os mesmos registros, há dois contêineres e um único conjunto de objetos referenciados. Considere o custo dos dois contêineres, mas não trate os registros compartilhados como cópias completas.

Classifique o que está vivo no pico

Associe a estrutura ao seu papel

No momento em que registros, normalizados e resumo coexistem, associe cada estrutura à descrição mais adequada.

Toque em um item e depois no par correspondente.

Use a linha do tempo como hipótese

Da hipótese à evidência

Desenhe as fases antes de alterar o código. Marque quando cada estrutura nasce, quando deixa de ser necessária e qual fase concentra mais sobreposição. Em seguida, use a medição com tracemalloc já conhecida para verificar se o pico ocorre perto dessa fase.

Nos próximos passos, você avaliará como reduzir estruturas que coexistem — sem assumir que toda estrutura auxiliar ou toda cópia pode desaparecer.

Localize a sobreposição

No exemplo, qual fase tende a produzir o maior pico? Cite as estruturas vivas e diferencie a que precisa permanecer após o retorno das que só participam do processamento.

Escreva pelo menos 80 caracteres (0/80).

Passo 2 de 8

Remover cópias sem mudar o contrato

Elimine contêineres intermediários apenas quando a leitura, a mutação e o compartilhamento de referências permitirem.

Uma cópia rasa ainda cria outro contêiner

O custo está nas referências e na coexistência

Uma fatia como registros[:] e list(registros) cria um novo contêiner e copia suas referências. Os elementos internos não são duplicados, mas há trabalho e memória adicionais proporcionais ao número de posições copiadas.

No pico, origem e cópia coexistem. Portanto, não conclua que a cópia é barata só porque os objetos internos são compartilhados.

Origem e cópia rasa coexistem

A cópia possui outro conjunto de posições, mas aponta para os mesmos dicionários da origem.

Diagrama de duas listas distintas, origem e cópia rasa, ambas apontando para os mesmos três objetos de registro; os dois contêineres coexistem.

Dois contêineres, os mesmos elementos: a cópia rasa aumenta o pico sem clonar cada registro.

Dica

Pergunta antes de remover

Para cada cópia, pergunte: ela protege a entrada contra mutação, cria resultados independentes ou apenas prepara uma etapa que só lê os dados? Só o último caso sugere uma cópia possivelmente redundante.

Refatore a etapa que só lê

Antes: cópia usada apenas para leitura

A função não altera registros nem a lista copiada.

python
def ids_ativos(registros: list[dict[str, object]]) -> list[int]:
    trabalho = registros[:]  # novo contêiner com as mesmas referências
    return [registro["id"] for registro in trabalho if registro["ativo"]]

Depois: leia diretamente a entrada

O resultado continua sendo uma nova lista; apenas o contêiner intermediário foi removido.

python
def ids_ativos(registros: list[dict[str, object]]) -> list[int]:
    return [registro["id"] for registro in registros if registro["ativo"]]

Atenção

Não troque cópia por vínculo sem revisar a mutação

trabalho = registros não copia nada: os dois nomes apontam para a mesma lista. Se uma etapa fizer trabalho.sort(), pop() ou outra mutação, a entrada também será alterada. Nesse contrato, a cópia pode ser necessária; alternativamente, use uma operação que produza uma saída nova quando isso atender ao comportamento esperado.

Decida pelo contrato, não pela aparência

Qual refatoração é segura?

A função deve preservar a lista nomes e retornar uma nova lista com os nomes normalizados. Qual implementação elimina uma cópia redundante sem quebrar esse contrato?

Passo 3 de 8

Reduzir a materialização no fluxo completo

Compare o pico de memória de três fluxos equivalentes: materialização completa, gerador sobre uma fonte ainda materializada e processamento incremental desde a origem.

O fluxo inteiro define o pico

Não avalie só a transformação

Um gerador pode remover listas intermediárias, mas isso não torna automaticamente o processamento incremental. Para entender o pico, observe fonte + transformações + consumidor no mesmo instante.

Considere três versões com o mesmo resumo final:

  1. Materialização completa: entrada, seleção e transformação ficam em coleções distintas.
  2. Gerador sobre lista: as coleções intermediárias somem, mas a lista de entrada continua viva enquanto é percorrida.
  3. Incremental desde a origem: os registros são produzidos sob demanda e o consumidor mantém apenas o resumo exigido pelo contrato.

Também examine o consumidor: se ele fizer list(fluxo), criará uma nova coleção com todos os resultados e poderá recuperar grande parte do pico que o gerador evitou.

Três formas de reter dados

A largura dos blocos representa aproximadamente a quantidade de dados vivos em cada estratégia.

Diagrama comparando três fluxos: vários contêineres grandes coexistindo, uma lista grande alimentando um fluxo fino e um fluxo fino que termina em um resumo pequeno.

Eliminar intermediários ajuda, mas o maior ganho ocorre quando a fonte também é produzida sob demanda e a saída não é acumulada sem necessidade.

Dica

Contrato antes da otimização

Só descarte resultados quando o contrato pedir um resumo, uma contagem ou outro resultado compacto. Se quem chama a função precisa receber todos os registros transformados, a coleção final é necessária — embora a fonte e os intermediários ainda possam ser revistos.

Meça três versões equivalentes

Prática no seu computador

Crie um arquivo comparar_fluxos.py, copie o código completo abaixo e execute-o. Cada medição recria uma fonte equivalente, inicia o rastreamento antes da criação dessa fonte e consome todo o fluxo dentro da fronteira medida.

comparar_fluxos.py

As três funções retornam o mesmo resumo, mas mantêm quantidades diferentes de dados vivas.

python
import tracemalloc

QUANTIDADE = 30_000


def criar_registro(indice: int) -> dict[str, object]:
    return {
        "id": indice,
        "ativo": indice % 3 == 0,
        "texto": f"registro-{indice:06d}-" + ("x" * 240),
    }


def criar_lista(quantidade: int):
    return [criar_registro(indice) for indice in range(quantidade)]


def gerar_registros(quantidade: int):
    for indice in range(quantidade):
        yield criar_registro(indice)


def normalizar(registro: dict[str, object]) -> dict[str, int]:
    return {
        "id": int(registro["id"]),
        "tamanho": len(str(registro["texto"])),
    }


def resumir(registros) -> dict[str, int]:
    quantidade = 0
    total_caracteres = 0
    for registro in registros:
        quantidade += 1
        total_caracteres += registro["tamanho"]
    return {
        "quantidade": quantidade,
        "total_caracteres": total_caracteres,
    }


# Fonte, seleção e saída transformada coexistem.
def versao_materializada(quantidade: int) -> dict[str, int]:
    entrada = criar_lista(quantidade)
    selecionados = [registro for registro in entrada if registro["ativo"]]
    normalizados = [normalizar(registro) for registro in selecionados]
    return resumir(normalizados)


# Não há listas intermediárias, mas entrada permanece viva.
def versao_gerador_sobre_lista(quantidade: int) -> dict[str, int]:
    entrada = criar_lista(quantidade)
    normalizados = (
        normalizar(registro)
        for registro in entrada
        if registro["ativo"]
    )
    return resumir(normalizados)


# A própria fonte é incremental; só o resumo é retido.
def versao_totalmente_incremental(quantidade: int) -> dict[str, int]:
    normalizados = (
        normalizar(registro)
        for registro in gerar_registros(quantidade)
        if registro["ativo"]
    )
    return resumir(normalizados)


def medir(nome: str, funcao, quantidade: int) -> dict[str, int]:
    tracemalloc.start()
    try:
        # O rastreamento já está ativo; a fonte será criada dentro da função.
        tracemalloc.reset_peak()
        resultado = funcao(quantidade)
        atual, pico = tracemalloc.get_traced_memory()
    finally:
        tracemalloc.stop()

    print(f"{nome:28} atual={atual / 1024 / 1024:6.2f} MiB  "
          f"pico={pico / 1024 / 1024:6.2f} MiB  resumo={resultado}")
    return resultado


resultados = [
    medir("materializada", versao_materializada, QUANTIDADE),
    medir("gerador sobre lista", versao_gerador_sobre_lista, QUANTIDADE),
    medir("totalmente incremental", versao_totalmente_incremental, QUANTIDADE),
]

assert resultados[0] == resultados[1] == resultados[2]
print("Resumos equivalentes.")

Atenção

Não compare fontes diferentes

Não passe uma lista pronta para apenas uma das versões nem crie a lista antes de iniciar o tracemalloc. Isso excluiria justamente o custo da entrada. Cada versão deve recriar dados equivalentes dentro da própria medição.

Interprete o que mudou

Explique sua medição

Após executar o script, relate: qual versão teve o maior e o menor pico? Explique por que a versão com gerador sobre lista não elimina o custo da entrada. Depois diga o que mudaria se resumir(normalizados) fosse trocado por list(normalizados).

Escreva pelo menos 180 caracteres (0/180).

Passo 4 de 8

Dimensionar lotes e buffers pelo orçamento

Estime uma capacidade inicial de lote a partir de um orçamento de memória e valide-a com medições representativas.

Divida o orçamento antes de escolher o lote

O orçamento é compartilhado

O tamanho do lote não pode ocupar todo o orçamento. Separe a memória rastreada disponível em quatro partes:

  • memória fixa: estruturas que já existem antes do lote;
  • resultados retidos: apenas o que o contrato exige manter;
  • lote em processamento: entrada, transformações e saída temporária que coexistem;
  • margem de segurança: variações de dados e sobrecargas não previstas.

Uma capacidade inicial pode ser estimada por:

capacidade = floor((orçamento - fixa - retida - margem) / custo_efetivo_por_registro)

O custo efetivo deve representar o pico por registro no fluxo real, não somente o tamanho superficial do objeto de entrada.

Componentes que dividem o orçamento

Observe que o lote ativo inclui estruturas simultâneas da transformação.

Diagrama de uma barra de orçamento de memória dividida entre memória fixa, resumo retido, lote ativo e margem de segurança; o lote ativo contém entrada, estrutura temporária e saída temporária coexistindo.

O espaço destinado ao lote é o que sobra depois das parcelas fixas, retidas e da margem.

Exemplo

Cálculo inicial

Considere um orçamento rastreado de 12 MiB, com 2 MiB de memória fixa, 1 MiB de resultados retidos e 1 MiB de margem. Restam 8 MiB para o lote.

Se uma medição representativa indicar 4 KiB por registro no pico do lote, a capacidade inicial é:

8 MiB / 4 KiB = 2048 registros

Esse valor é uma hipótese para testar, não uma garantia para qualquer distribuição de registros.

Meça o custo efetivo de um lote representativo

Calibre com o fluxo que importa

Meça um lote que crie as mesmas estruturas que coexistem na transformação real. Registros com campos maiores, saídas expandidas ou coleções auxiliares podem elevar o custo por registro. Compare um lote vazio com um lote representativo para reduzir o efeito da parcela fixa observada.

Calibração local com tracemalloc

Execute localmente e ajuste amostra e a forma de transformar para se aproximarem da sua carga.

python
import tracemalloc

MIB = 1024 * 1024


def criar_registros(quantidade: int) -> list[dict[str, object]]:
    return [
        {
            "id": indice,
            "categoria": f"grupo-{indice % 20}",
            "payload": "x" * (300 + (indice % 5) * 200),
        }
        for indice in range(quantidade)
    ]


def transformar(registros: list[dict[str, object]]) -> int:
    # A lista transformada é uma estrutura temporária que coexiste com a entrada.
    saida = [
        f"{registro['id']}|{registro['categoria']}|{registro['payload']}"
        for registro in registros
    ]
    return sum(len(item) for item in saida)  # resumo compacto


def pico_do_lote(quantidade: int) -> int:
    tracemalloc.start()
    registros = criar_registros(quantidade)
    transformar(registros)
    _, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return pico


amostra = 1_000
pico_vazio = pico_do_lote(0)
pico_amostra = pico_do_lote(amostra)
custo_por_registro = (pico_amostra - pico_vazio) / amostra

print(f"Pico vazio: {pico_vazio / MIB:.2f} MiB")
print(f"Pico da amostra: {pico_amostra / MIB:.2f} MiB")
print(f"Custo efetivo estimado: {custo_por_registro:.0f} bytes/registro")

Dica

Não use sys.getsizeof como orçamento completo

O tamanho superficial de um registro não inclui automaticamente os objetos alcançados por ele, a capacidade dos contêineres nem a saída temporária. Use a medição do fluxo representativo para obter uma estimativa operacional.

Calcule uma capacidade inicial

Orçamento para o lote

Com orçamento de 12 MiB, memória fixa de 2 MiB, resultados retidos de 1 MiB, margem de 1 MiB e custo efetivo de 4 KiB por registro, a capacidade inicial é de ____ registros.

Teste capacidades próximas com a carga completa

Valide antes de adotar

Teste a capacidade calculada e valores próximos com fontes equivalentes recriadas a cada execução. Cada lote deve ser consumido antes de pedir o próximo; neste exemplo, o contrato permite reter somente um total numérico. Se seu contrato exigir guardar resultados individuais, inclua essa retenção no orçamento.

Comparação local de tamanhos de lote

O pico rastreado é um indicador para esta carga e este interpretador; ele não é um limite rígido da memória residente do processo.

python
import tracemalloc
from itertools import islice

MIB = 1024 * 1024
ORCAMENTO = 12 * MIB


def registros_sinteticos(quantidade: int):
    for indice in range(quantidade):
        yield {
            "id": indice,
            "categoria": f"grupo-{indice % 20}",
            "payload": "x" * (300 + (indice % 5) * 200),
        }


def em_lotes(fonte, capacidade: int):
    while lote := list(islice(fonte, capacidade)):
        yield lote


def resumir_lote(lote: list[dict[str, object]]) -> int:
    # Esta saída temporária pode ser maior que a entrada do lote.
    codificados = [
        f"{registro['id']}|{registro['categoria']}|{registro['payload']}"
        for registro in lote
    ]
    return sum(len(item) for item in codificados)


def medir(capacidade: int, quantidade_total: int = 10_000) -> tuple[int, int]:
    tracemalloc.start()
    total = 0

    for lote in em_lotes(registros_sinteticos(quantidade_total), capacidade):
        total += resumir_lote(lote)  # consome o resumo antes do próximo lote
        del lote

    atual, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return total, pico


for capacidade in (1536, 2048, 2560):
    total, pico = medir(capacidade)
    situacao = "dentro" if pico <= ORCAMENTO else "acima"
    print(
        f"lote={capacidade:4d} | total={total} | "
        f"pico={pico / MIB:.2f} MiB | {situacao} do orçamento"
    )

Registre sua validação

Execute o código com sua carga sintética ou adapte-o a uma carga local representativa. Qual capacidade respeitou o orçamento rastreado? Relate os picos medidos e diga se registros ou resultados de tamanhos variados exigiriam uma margem maior.

Escreva pelo menos 80 caracteres (0/80).

Passo 5 de 8

Distinguir pico temporário de retenção crescente

Compare a memória atual e o pico em ciclos equivalentes para reconhecer sobreposição temporária, estabilização e retenção que merece investigação.

Leia o padrão entre ciclos

Pico não é retenção

Em ciclos de trabalho equivalentes, observe dois valores: a memória atual depois que a saída esperada foi consumida e o pico atingido durante aquele ciclo.

Um pico alto que volta a um patamar semelhante pode indicar apenas estruturas que coexistiram temporariamente. Já a memória atual que aumenta em pontos equivalentes de vários ciclos é um sinal para investigar o que continua sendo mantido.

Três curvas, três interpretações

Compare sempre o mesmo ponto do ciclo: depois de processar e consumir a saída prevista pelo contrato.

Três gráficos de memória por ciclos. O primeiro mostra picos repetidos que retornam à mesma linha de base; o segundo sobe nos primeiros ciclos e se estabiliza; o terceiro cresce continuamente ao fim de cada ciclo.

Picos temporários, aquecimento que estabiliza e crescimento persistente têm leituras diferentes.

Atenção

Sinal, não veredito

Crescimento entre ciclos não prova sozinho um vazamento. Pode haver inicialização tardia, estruturas reutilizadas ou retenção deliberada pelo contrato. O padrão indica onde procurar: coleções e atributos persistentes que podem estar acumulando dados de ciclos anteriores.

Meça sem guardar a carga

Aquecimento e pontos comparáveis

Faça um ciclo de aquecimento com a mesma carga antes de registrar a série. Depois, execute ciclos equivalentes e registre somente números compactos. A saída deve ser totalmente consumida dentro de cada ciclo; caso contrário, você estará comparando fronteiras diferentes.

Série compacta de medições

Execute este exemplo no seu computador. Ele cria uma carga equivalente em cada ciclo, consome o resultado como uma soma e guarda apenas tuplas numéricas de métricas.

python
import tracemalloc


def processar(quantidade: int):
    registros = [
        {"id": i, "valor": i % 97, "texto": f"registro-{i}"}
        for i in range(quantidade)
    ]
    return (registro["valor"] * 2 for registro in registros)


def executar_ciclo(quantidade: int) -> int:
    # sum consome toda a saída; nenhum lote é devolvido ao chamador.
    return sum(processar(quantidade))


quantidade = 30_000
ciclos_medidos = 6
medidas = []  # apenas (ciclo, atual, pico), não os registros

tracemalloc.start()

# Aquecimento: mesmo trabalho, mas fora da série comparada.
executar_ciclo(quantidade)
tracemalloc.reset_peak()

for ciclo in range(1, ciclos_medidos + 1):
    resultado = executar_ciclo(quantidade)
    atual, pico = tracemalloc.get_traced_memory()
    medidas.append((ciclo, atual, pico))
    print(f"ciclo={ciclo} resultado={resultado} atual={atual:,} pico={pico:,}")
    tracemalloc.reset_peak()

tracemalloc.stop()

Classifique antes de concluir

Associe a série à interpretação

Relacione cada padrão de memória atual após o consumo da saída à interpretação mais adequada.

Toque em um item e depois no par correspondente.

Dica

Proteja a observação

Não adicione os lotes, saídas completas ou objetos de diagnóstico à lista de medições. Guarde contadores, tamanhos e valores de memória; do contrário, a instrumentação pode criar exatamente a retenção que você quer detectar.

Passo 6 de 8

Encurtar a vida das estruturas auxiliares

Revise vínculos que mantêm lotes alcançáveis e conserve apenas o histórico exigido pelo contrato.

Siga os vínculos, não apenas os nomes

Um objeto só deixa de ser necessário quando não há vínculos úteis até ele

Depois de processar um lote, examine quem ainda aponta para ele: um nome local, uma lista persistente, um atributo de objeto ou outra estrutura auxiliar. Um nome local pode desaparecer no fim da função, mas um atributo como self.ultimo_lote mantém o lote alcançável enquanto o processador existir.

A pergunta prática é: o histórico precisa do lote inteiro ou apenas de um resumo? Se quantidade, total e identificador bastam, guardar todos os registros é retenção desnecessária.

Um vínculo persistente prolonga a vida do lote

Diagrama de referências em que um processador de longa duração aponta para um atributo ultimo_lote, que aponta para um lote grande; o mesmo processador também aponta para um histórico pequeno de resumos.

Mesmo após o fim da função, o atributo do processador ainda alcança o lote completo.

Atenção

del remove um vínculo, não força a liberação

del lote remove apenas o nome local. Se self.ultimo_lote, uma lista ou outro atributo ainda referenciar o mesmo objeto, ele continua alcançável. Quando não restam vínculos, Python pode recuperar o objeto, mas não há um momento exato garantido em toda implementação. Além disso, no CPython a memória pode ficar disponível para reutilização interna sem que a memória residente informada pelo sistema operacional caia imediatamente.

Guarde o resumo, não o lote

Troque retenção acidental por histórico deliberado

A versão inicial mantém o lote inteiro em um atributo de longa duração, embora o histórico só precise de métricas. A versão ajustada limita o que persiste ao resumo previsto pelo contrato. O lote permanece apenas no escopo da chamada enquanto é processado.

Antes e depois da revisão de referências

python
class Processador:
    def __init__(self) -> None:
        self.historico: list[dict[str, int]] = []
        self.ultimo_lote: list[dict[str, int]] | None = None

    def processar_antes(self, lote: list[dict[str, int]]) -> dict[str, int]:
        self.ultimo_lote = lote  # mantém todos os registros após o retorno
        resumo = {
            "quantidade": len(lote),
            "total": sum(registro["valor"] for registro in lote),
        }
        self.historico.append(resumo)
        return resumo


class ProcessadorAjustado:
    def __init__(self) -> None:
        self.historico: list[dict[str, int]] = []

    def processar(self, lote: list[dict[str, int]]) -> dict[str, int]:
        resumo = {
            "quantidade": len(lote),
            "total": sum(registro["valor"] for registro in lote),
        }
        self.historico.append(resumo)  # persiste somente o contrato de histórico
        return resumo

Localize a referência que ainda retém o lote

Qual vínculo impede a liberação?

Após del lote, qual diagrama mostra por que o lote completo continua vivo e qual alteração é apropriada quando o histórico exige apenas resumos compactos?

Passo 7 de 8

Confirmar o ganho sem quebrar o contrato

Teste os riscos específicos de uma otimização de memória antes de aceitar o ganho medido.

O ganho só vale se o contrato continuar verdadeiro

Verifique o que a otimização pode ter alterado

Depois de reduzir cópias, materializar menos dados ou limitar lotes, não compare apenas o número do pico. Verifique: resultado equivalente, entrada sem mutação quando essa é a regra, saídas mutáveis independentes, casos-limite e a propagação de erros.

Em fluxos sob demanda, uma falha de um registro pode aparecer somente quando aquele ponto do fluxo é consumido. Isso é aceitável se esse já for o comportamento previsto pelo contrato.

Mapa das verificações

Diagrama mostrando uma fonte de registros preservada, lotes processados com resultados independentes e uma seta de erro que chega ao consumidor durante o consumo.

Uma validação completa cobre valores, estado compartilhado e comportamento durante o consumo.

Dica

Isole as evidências

Testes de correção podem guardar uma saída de referência; a medição de memória não deve. Meça cada versão em uma execução própria, consumindo a saída e retendo apenas uma métrica compacta, como contagem e soma.

Teste valores, entrada e identidade

Implementação incremental sob teste

Salve esta função em um módulo, por exemplo, processamento.py.

python
def resumir_em_lotes(registros, tamanho_lote):
    """Produz lotes de resumos sem alterar os registros de origem."""
    if tamanho_lote <= 0:
        raise ValueError("tamanho_lote deve ser positivo")

    lote = []
    for registro in registros:
        # KeyError é preservado se um registro não tiver "valor".
        resumo = {
            "id": registro["id"],
            "dobro": registro["valor"] * 2,
        }
        lote.append(resumo)

        if len(lote) == tamanho_lote:
            yield lote
            lote = []

    if lote:
        yield lote

Testes direcionados com pytest

Crie um arquivo teste_processamento.py ao lado do módulo e execute pytest -q. Estes testes exercitam inclusive lote vazio, último lote incompleto, registro grande e dado inválido.

python
import pytest
from processamento import resumir_em_lotes


def consumir(registros, tamanho_lote):
    return [item for lote in resumir_em_lotes(registros, tamanho_lote)
            for item in lote]


def test_resultado_e_entrada_preservada():
    origem = [{"id": 1, "valor": 3}, {"id": 2, "valor": 5}]
    antes = [registro.copy() for registro in origem]

    assert consumir(origem, 2) == [
        {"id": 1, "dobro": 6},
        {"id": 2, "dobro": 10},
    ]
    assert origem == antes


def test_saidas_mutaveis_nao_compartilham_estado():
    saida = consumir([{"id": 1, "valor": 7}, {"id": 2, "valor": 9}], 1)

    saida[0]["dobro"] = 999
    assert saida[1]["dobro"] == 18
    assert saida[0] is not saida[1]


def test_vazio_e_lote_incompleto():
    assert list(resumir_em_lotes([], 3)) == []
    assert list(resumir_em_lotes([{"id": 1, "valor": 4}], 3)) == [
        [{"id": 1, "dobro": 8}]
    ]


def test_registro_grande_e_dado_invalido():
    grande = {"id": 1, "valor": 10**1000}
    assert consumir([grande], 1)[0]["dobro"] == 2 * 10**1000

    fluxo = resumir_em_lotes([{"id": 1, "valor": 2}, {"id": 2}], 1)
    assert next(fluxo) == [{"id": 1, "dobro": 4}]
    with pytest.raises(KeyError):
        next(fluxo)


def test_tamanho_invalido():
    with pytest.raises(ValueError):
        list(resumir_em_lotes([], 0))

Não confunda igualdade com independência

Exemplo

Uma otimização aparentemente correta, mas defeituosa

Este padrão é perigoso:

resumo = {}
for registro in registros:
    resumo["id"] = registro["id"]
    resumo["dobro"] = registro["valor"] * 2
    lote.append(resumo)

Ao final, cada posição de lote aponta para o mesmo dicionário. Os valores podem parecer corretos em uma inspeção rápida — todos refletem o último registro —, mas alterar um resultado altera todos. Criar um novo dicionário a cada iteração, como na implementação anterior, preserva a independência.

Teste de identidade

Se dois resultados mutáveis têm o mesmo conteúdo, um teste de igualdade (resultado_a == resultado_b) é suficiente para garantir que eles não compartilham estado.

Meça sem contaminar a comparação

Memória e tempo em execuções separadas

Execute este código após importar a função. Ele recria a fonte para cada execução, consome tudo e retém somente contagem e soma. Para comparar uma versão anterior e a ajustada, passe cada função separadamente para medir_memoria e medir_tempo.

python
import timeit
import tracemalloc
from processamento import resumir_em_lotes


def fonte(quantidade=20_000):
    for indice in range(quantidade):
        yield {"id": indice, "valor": indice}


def consumir_compacto(funcao):
    quantidade = 0
    total = 0
    for lote in funcao(fonte(), 200):
        for resumo in lote:
            quantidade += 1
            total += resumo["dobro"]
    return quantidade, total


def medir_memoria(funcao):
    tracemalloc.start()
    tracemalloc.reset_peak()
    resultado = consumir_compacto(funcao)
    atual, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return resultado, atual, pico


def medir_tempo(funcao):
    # Sem tracemalloc: a instrumentação alteraria a comparação de tempo.
    temporizador = timeit.Timer(lambda: consumir_compacto(funcao))
    return min(temporizador.repeat(repeat=5, number=3)) / 3

resultado, atual, pico = medir_memoria(resumir_em_lotes)
tempo = medir_tempo(resumir_em_lotes)
print(f"resultado={resultado}, atual={atual}, pico={pico}, tempo={tempo:.6f}s")

Registre sua evidência

Execute os testes e as medições para a versão anterior e a ajustada. Qual contrato você confirmou, qual foi o efeito no pico e o que ocorreu com o tempo?

Escreva pelo menos 80 caracteres (0/80).

Passo 8 de 8

Aplicação final: reduzir e comprovar

Aplique uma versão inicial e uma versão ajustada do mesmo processamento, compare as medições e registre evidências de correção.

Diagnóstico integrado: pico versus retenção

Localize os dois problemas

Nesta aplicação, a versão inicial cria uma lista completa de registros e também todas as fatias de lote. Durante o processamento, a fonte, as fatias e os resultados temporários podem coexistir: essa sobreposição eleva o pico.

Além disso, o atributo last_batch mantém o último lote processado depois que o ciclo termina. Isso é retenção: a memória atual após o ciclo pode permanecer acima do necessário, mesmo que o pico temporário já tenha passado.

A versão ajustada produz registros sob demanda, mantém apenas um lote de tamanho limitado e retorna somente um resumo compacto. Ela também não guarda o lote em um objeto persistente.

O que fica vivo em cada versão

Comparação em duas colunas: à esquerda, uma fonte completa, várias fatias de lote, um lote transformado e um atributo persistente coexistem; à direita, um gerador alimenta um único lote limitado que produz um resumo pequeno, sem referência persistente ao lote.

À esquerda, a sobreposição aumenta o pico e o último lote continua alcançável. À direita, há somente um lote de cada vez e o histórico é compacto.

Dica

Critério de sucesso

Compare fontes equivalentes criadas dentro da medição. Aceite a alteração quando os resumos e os testes de contrato continuarem corretos e o pico observado couber no orçamento definido para a carga. A memória residente do processo não precisa cair imediatamente para que a memória rastreada tenha sido reduzida.

Execute a comparação local

Experimento autocontido

Crie um arquivo, por exemplo comparar_memoria.py, cole o código abaixo e execute python comparar_memoria.py. Ele inicia o rastreamento antes de cada fonte, processa três ciclos equivalentes e mostra memória atual e pico em KiB.

comparar_memoria.py

python
from itertools import islice
import tracemalloc

N_REGISTROS = 40_000
TAMANHO_LOTE = 200
CICLOS = 3


def gerar_registros(quantidade: int):
    for indice in range(quantidade):
        yield {
            "id": indice,
            "valor": (indice * 17) % 1_000,
            "ativo": indice % 3 != 0,
        }


def transformar(registro: dict) -> int:
    # Leitura בלבד: não altera o registro recebido.
    return registro["valor"] * 2 if registro["ativo"] else 0


class ProcessadorInicial:
    def __init__(self, tamanho_lote: int):
        if tamanho_lote <= 0:
            raise ValueError("tamanho_lote deve ser positivo")
        self.tamanho_lote = tamanho_lote
        self.last_batch = None  # vínculo persistente desnecessário

    def processar(self, registros: list[dict]) -> dict:
        # Fonte completa + todas as fatias coexistem.
        lotes = [
            registros[inicio : inicio + self.tamanho_lote]
            for inicio in range(0, len(registros), self.tamanho_lote)
        ]
        total = 0
        quantidade = 0
        for lote in lotes:
            transformados = [transformar(registro) for registro in lote]
            total += sum(transformados)
            quantidade += len(transformados)
            self.last_batch = transformados
        return {"quantidade": quantidade, "total": total}


class ProcessadorAjustado:
    def __init__(self, tamanho_lote: int):
        if tamanho_lote <= 0:
            raise ValueError("tamanho_lote deve ser positivo")
        self.tamanho_lote = tamanho_lote

    def processar(self, registros) -> dict:
        total = 0
        quantidade = 0
        fonte = iter(registros)
        while lote := list(islice(fonte, self.tamanho_lote)):
            # O lote é consumido antes de obter o próximo.
            for registro in lote:
                total += transformar(registro)
                quantidade += 1
        return {"quantidade": quantidade, "total": total}


def medir(processador, criar_fonte) -> tuple[dict, int, int]:
    tracemalloc.start()
    try:
        resultado = None
        for _ in range(CICLOS):
            # A fonte é recriada e consumida dentro da fronteira medida.
            resultado = processador.processar(criar_fonte())
        atual, pico = tracemalloc.get_traced_memory()
        return resultado, atual, pico
    finally:
        tracemalloc.stop()


def verificar_contrato() -> None:
    registros = list(gerar_registros(20))
    entrada_antes = [registro.copy() for registro in registros]

    inicial = ProcessadorInicial(4)
    ajustado = ProcessadorAjustado(4)
    resumo_inicial = inicial.processar(registros)
    resumo_ajustado = ajustado.processar(gerar_registros(20))

    assert resumo_inicial == resumo_ajustado
    assert registros == entrada_antes, "a entrada não pode ser alterada"
    assert resumo_inicial is not resumo_ajustado, "cada chamada retorna seu próprio resumo"

    for classe in (ProcessadorInicial, ProcessadorAjustado):
        try:
            classe(0)
        except ValueError:
            pass
        else:
            raise AssertionError("lote inválido deve continuar falhando")


verificar_contrato()

inicial = ProcessadorInicial(TAMANHO_LOTE)
ajustado = ProcessadorAjustado(TAMANHO_LOTE)

resumo_inicial, atual_inicial, pico_inicial = medir(
    inicial,
    lambda: list(gerar_registros(N_REGISTROS)),
)
resumo_ajustado, atual_ajustado, pico_ajustado = medir(
    ajustado,
    lambda: gerar_registros(N_REGISTROS),
)

assert resumo_inicial == resumo_ajustado
assert inicial.last_batch is not None
assert not hasattr(ajustado, "last_batch")

print("Testes de contrato: OK")
print(f"Resumo equivalente: {resumo_ajustado}")
print(f"Inicial  — atual: {atual_inicial / 1024:.1f} KiB | pico: {pico_inicial / 1024:.1f} KiB")
print(f"Ajustado — atual: {atual_ajustado / 1024:.1f} KiB | pico: {pico_ajustado / 1024:.1f} KiB")
print(f"Lote escolhido: {TAMANHO_LOTE} registros")

Registre sua evidência

Conclusão do seu experimento

Após executar o arquivo, registre: (1) quais estruturas causavam a sobreposição no pico, (2) qual referência prolongava a vida de um lote, (3) o tamanho de lote escolhido, (4) os valores de memória atual e pico antes e depois, e (5) quais verificações de contrato passaram. Se houver um orçamento local, diga se o pico ajustado ficou dentro dele.

Escreva pelo menos 180 caracteres (0/180).

Revisão final: decidir com evidências

Resumo

Checklist de redução segura

Use este roteiro ao otimizar um fluxo semelhante.

  • Meça a fronteira inteira: crie a fonte, transforme e consuma a saída dentro do rastreamento.
  • Atribua o pico a objetos vivos ao mesmo tempo; não o confunda com a soma histórica de alocações.
  • Remova cópias apenas quando o contrato não exige uma coleção independente.
  • Dimensione o lote com dados representativos e uma margem para contêineres e resultados que coexistem.
  • Depois de cada ciclo, investigue referências persistentes a lotes completos; mantenha somente o resumo ou histórico permitido.
  • Confirme resultados, preservação da entrada, independência exigida e erros esperados antes de aceitar o ganho.
  • Interprete a redução com base no pico e na memória rastreada observados; não prometa uma queda imediata da memória residente nem o mesmo ganho para toda carga.

Aplicação concluída

Parabéns! Você concluiu: Reduzir o pico e a retenção de memória

Você concluiu a aplicação final. Agora você pode justificar uma redução de memória com medições, identificar pico e retenção como problemas distintos e comprovar que a otimização preserva o contrato.

Baixe o Aplicativo agora para ter acesso a + de 5000 cursos gratuitos, exercícios, certificado e muito conteúdo sem pagar nada!

  • Cursos online 100% gratuitos do início ao fim

    Milhares de cursos online em vídeo, ebooks e áudiobooks.

  • Mais de 60 mil exercícios gratuitos

    Para testar seus conhecimentos no decorrer dos cursos online

  • Certificado Digital gratuito válido em todo o Brasil

    Gerado diretamente na galeria de fotos do seu celular e enviado ao seu e-mail

Aplicativo Cursa na tela de ebook, na tela de curso em vídeo e na tela de exercícios do curso, mais o certificado de conclusão de curso