Skip to content

Custo da assinatura do checkpoint cresce exponencialmente com chamáveis que se referenciam ou compartilham estado #295

Open

Description

@bdcdo

Origem: limite conhecido aceito no PR #267, que passou a assinar o código e o estado dos objetos chamáveis das condições.

O que acontece

A assinatura do checkpoint é calculada antes da primeira linha, sempre que há checkpoint_path. Quando a condição de field_condition é um objeto chamável cujo estado alcança outros objetos chamáveis, o custo dela cresce exponencialmente em dois formatos:

  1. Registro mútuo: cada regra guarda a lista de todas as regras (regra.todas = todas). Todo objeto está num ciclo, a forma de cada um depende do caminho por onde se chegou a ele, e a memoização por id não reaproveita nada. A forma canônica cresce cerca de 8 vezes por chamável.
  2. DAG com compartilhamento, sem ciclo: cada nó tem a e b apontando para o mesmo filho. A memoização evita refazer a descida (profundidade 20 caiu de cerca de 30 s para cerca de 3,7 s), mas a forma reaproveitada é o mesmo objeto Python inserido nos dois ramos, e o json.dumps de _run_signature o escreve por extenso em cada um. O texto assinado continua com 2^P cópias da folha, e o tempo e a memória dobram a cada nível.

Nos dois casos a execução parece travada antes da primeira linha, e o processo pode ser morto por falta de memória.

Script mínimo, contra ac3ccee7 (Python 3.11.14):

"""Registro mútuo de chamáveis: cada regra guarda a lista de todas as regras.

uso: python minimo.py registro N   (N regras)
     python minimo.py dag P        (DAG sem ciclo, profundidade P, controle)
     um terceiro argumento "sem-tamanho" mede só a assinatura
"""
import json
import sys
import time

from pydantic import BaseModel, Field

from dataframeit import field_condition
from dataframeit.core import _run_signature, _schema_form


class Regra:
    def __init__(self, alvo):
        self.alvo = alvo

    def __call__(self, dados):
        return dados.get("tipo") == self.alvo


def modelo(condicao):
    class Pedido(BaseModel):
        tipo: str
        desconto: float | None = Field(
            default=None,
            json_schema_extra=field_condition(condicao, depends_on="tipo"),
        )

    return Pedido


def registro(n):
    todas = []
    for i in range(n):
        regra = Regra(f"r{i}")
        regra.todas = todas
        todas.append(regra)
    return todas[0]


def dag(profundidade):
    no = Regra("folha")
    for _ in range(profundidade):
        pai = Regra("z")
        pai.a = no
        pai.b = no
        no = pai
    return no


tipo, n = sys.argv[1], int(sys.argv[2])
Pedido = modelo(registro(n) if tipo == "registro" else dag(n))
inicio = time.perf_counter()
assinatura = _run_signature(
    Pedido, "{texto}", provider="p", model="m", model_kwargs=None,
    search_config=None, text_column="texto", status_col="status",
)
tempo = time.perf_counter() - inicio
if sys.argv[3:] == ["sem-tamanho"]:
    print(f"{tipo} {n}: {tempo:.2f} s, {assinatura[:16]}")
else:
    tamanho = len(json.dumps(_schema_form(Pedido), sort_keys=True, default=repr))
    print(f"{tipo} {n}: {tempo:.2f} s, forma canônica {tamanho / 1e6:.1f} MB, {assinatura[:16]}")

Saída observada, cada medida com timeout 180 e teto de memória de 10 GB:

caso assinatura forma canônica em JSON pico de memória
registro, 6 chamáveis 0,01 s 0,5 MB
registro, 7 0,11 s 3,4 MB
registro, 8 0,93 s 27,1 MB
registro, 9 10,0 s 243,4 MB 1,67 GB
registro, 10 morto pelo teto de memória depois de 1 min 56 s acima de 10 GB
DAG, profundidade 20 3,7 s 520,1 MB 1,19 GB
DAG, profundidade 22 15,7 s 4,24 GB
DAG, profundidade 24 morto pelo teto de memória depois de 57 s acima de 10 GB
registro 9: 10.01 s, 0e7cb99a2495cd4e
	Maximum resident set size (kbytes): 1667568
registro 10: exit 137 (Maximum resident set size (kbytes): 12611088)
dag 20: 3.73 s, b541206440fae538
	Maximum resident set size (kbytes): 1193912
dag 22: 15.69 s, 0a297929501ce8aa
	Maximum resident set size (kbytes): 4241704
dag 24: exit 137 (Maximum resident set size (kbytes): 12609916)

O que se esperava

Custo da assinatura proporcional ao número de objetos alcançáveis, e não ao número de caminhos entre eles. O formato é raro (só se desce em objeto chamável ou dono de método ligado, e precisa de vários deles apontando uns para os outros), mas quem cai nele não tem pista de onde está o tempo.

Causa provável

Em _instance_state (src/dataframeit/core.py), o marcador de ciclo depende só do caminho atual (_Descent.depth), e a forma de um objeto que está num ciclo é refeita a cada caminho que chega a ele. A memoização em _Descent.forms só guarda a forma do objeto cuja subárvore não apontou para ele nem para cima dele, o que no registro mútuo não acontece com nenhum. No DAG, a forma memoizada é devolvida inteira e reinserida em cada ramo, e _run_signature serializa a árvore expandida com json.dumps.

Alternativa

A memoização por id da forma sem marcador de ciclo já está feita. O que falta:

  • Teto de nós visitados por assinatura: estourado, o resto da descida entra por um marcador (ou por um hash do que já foi visto), com aviso, em vez de continuar crescendo. A assinatura fica estável entre processos, e o custo, limitado.
  • Hash no lugar da forma reaproveitada: em vez de reinserir a forma memoizada, inserir o sha256 dela. Para que o mesmo objeto em dois ramos continue assinando como dois objetos iguais, o hash entra em toda subárvore sem ciclo, e não só na repetição. O texto assinado deixa de crescer com 2^P no DAG.

Qualquer das duas muda a assinatura dos modelos afetados, e o checkpoint gravado antes delas recomeça com aviso.

Limite registrado hoje

O PR #267 registrou o limite: o custo da assinatura cresce com o estado alcançável a partir dos chamáveis e é exponencial num registro mútuo grande (9 chamáveis, 10 s e 1,7 GB). A documentação de checkpoint (docs/guides/performance.md) não fala desse custo.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions