Repository navigation
Description
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:
- 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 poridnão reaproveita nada. A forma canônica cresce cerca de 8 vezes por chamável. - DAG com compartilhamento, sem ciclo: cada nó tem
aebapontando 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 ojson.dumpsde_run_signatureo 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
sha256dela. 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