Udud / patch_app.py
Erinaldordso's picture
revert: ubatch volta a 2048 (4096 mediu ~3% PIOR em A/B controlado); mantem guarda batch>=ubatch
89b8453 verified
Raw History Blame Contribute Delete
29.4 kB
"""Patches cirurgicos para app.py do Space Programa.
Motivo: a configuracao atual roda Qwen3-30B-A3B em CPU com FLASH_ATTN=0 e
sem validacao de teto de threads. Este patch aplica mudancas pontuais,
verificaveis e reversiveis por env var.
Cada patch e idempotente: rodar duas vezes nao duplica nada.
Falha alto se o ponto de ancoragem nao for encontrado exatamente uma vez,
para nunca aplicar uma mudanca silenciosamente errada.
Uso:
python patch_app.py /caminho/app.py # aplica
python patch_app.py /caminho/app.py --dry-run # so mostra o diff
"""
import re
import sys
import pathlib
import difflib
PATCHES = []
def patch(name, old, new):
PATCHES.append((name, old, new))
# --------------------------------------------------------------------------
# 1. Flash attention ligado por padrao.
# Medido no proprio README: o prefill NAO e linear (1028 tok a ~159 tok/s,
# 4580 tok a ~91 tok/s). Com contexto de agente real (4.5k+ tokens) o custo
# de atencao cresce ~L^2 e domina o TTFT. FA corta exatamente esse termo.
# Em CPU-only o ganho e menor que na GPU, mas e o eixo que o autor ja tinha
# identificado e deixado desligado.
patch(
"FLASH_ATTN default on",
'FLASH_ATTN = os.environ.get("FLASH_ATTN", "0") == "1"',
'FLASH_ATTN = os.environ.get("FLASH_ATTN", "1") == "1"',
)
# --------------------------------------------------------------------------
# 2. Teto de threads: nunca passar do numero de CPUs realmente visiveis.
# Bug real: THREADS=16 fixo. Se o host entregar menos vCPU, o llama.cpp
# cria threads em excesso e o decode PIORA (contencao de spinlock). O host
# do ZeroGPU ja variou; medido nesta sessao o servidor reportava menos
# nucleos do que 16 em alguns momentos.
#
# ATENCAO: este trecho roda no IMPORT, e `log()` so e definido MAIS ABAIXO
# no arquivo. Chamar log() aqui -> NameError e o Space nao sobe.
# Por isso usa print(), que sempre existe.
patch(
"clamp THREADS to os.cpu_count()",
'THREADS = int(os.environ.get("THREADS", "16"))',
'THREADS = int(os.environ.get("THREADS", "16"))\n'
'_VISIBLE_CPUS = os.cpu_count() or 16\n'
'if THREADS > _VISIBLE_CPUS:\n'
' # print, nao log(): log() ainda nao foi definido neste ponto.\n'
' print(f"[cfg] THREADS={THREADS} > cpus visiveis={_VISIBLE_CPUS}; "\n'
' f"reduzindo para evitar contencao", flush=True)\n'
' THREADS = _VISIBLE_CPUS',
)
# --------------------------------------------------------------------------
# 3. (REMOVIDO) --no-warmup.
# Hipotese levantada em producao: DESLIGAR o warmup do llama.cpp causa uma
# janela de ~2 min apos cada restart em que o tool calling NAO funciona.
#
# Evidencia (reproduzida em dois deploys independentes):
# - logo apos o autotune terminar, 0/6 e 0/10 chamadas retornam
# finish_reason=stop sem tool_calls
# - ~2 min depois, sem tocar em nada: 10/10, 20/20, 25/25
# - o proprio diag do boot reporta tool_calls=SIM no mesmo periodo,
# entao nao e problema de template/parser
# O warmup do llama.cpp faz um forward de dummy no load que inicializa
# buffers. Sem ele, as primeiras requisicoes reais pegam estado nao
# inicializado. O ganho de --no-warmup era so um boot um pouco mais curto;
# o custo e ~2 min de tool calling quebrado apos CADA restart. Nao compensa.
# Revertido. Se a janela persistir sem --no-warmup, a causa e outra.
# --------------------------------------------------------------------------
# 4. (REMOVIDO) --cache-reuse.
# Suspeito principal da instabilidade de tool calling observada em producao.
#
# Evidencia:
# - o tool calling falha em SURTOS: mesma prompt e mesma config deram
# 0/10, depois 4/4, depois 0/6, depois 10/10, 20/20, 25/25
# - /metrics mostrou cache muito usado: prompt_cached_total=5511 contra
# prompt_total=1103 (5x mais tokens servidos do cache que processados)
# - quando falha, o modelo NAO quebra: ele responde em prosa
# ("O comando ... geralmente esta na secao scripts"), ou seja, o contexto
# que ele ve leva a outra escolha -- sintoma de contexto desalinhado
#
# `--cache-reuse N` permite reaproveitar um prefixo PARCIAL (>= N tokens)
# fazendo context shifting. Se um request casa parcialmente com uma conversa
# DIFERENTE no cache, o shift pode injetar contexto errado.
# O ganho de 68x medido no cache NAO vem daqui: vem do `--cache-prompt`,
# que ja e ligado por padrao e reaproveita prefixo EXATO. Removendo
# --cache-reuse o ganho principal continua e o risco do shift parcial sai.
#
# Se a instabilidade persistir sem --cache-reuse, a causa e outra (modelo ou
# plataforma) e deve ser reportada como tal, nao como bug de config.
# --------------------------------------------------------------------------
# 5. Defaults de geracao para CODIGO.
# MEDIDO em /props: o servidor sobe com temperature=0.8, top_p=0.95, min_p=0.05.
# Esses sao os defaults do modelo para chat generico, nao para codigo.
# 0.8 de temperatura e alto para geracao de codigo: aumenta variacao e
# invencao de API. Backends de codigo serios rodam 0.1-0.2.
#
# VERIFICADO NO BINARIO PINADO (b10985, libllama-common.so.0.4.1):
# LLAMA_ARG_TOP_K existe -> usar env
# LLAMA_ARG_TEMP NAO EXISTE
# LLAMA_ARG_TOP_P NAO EXISTE
# LLAMA_ARG_MIN_P NAO EXISTE
# Sao 143 vars LLAMA_ARG_* no build; nao ha nenhuma para temperatura/top_p,
# porque sampling e parametro de request, nao default de ambiente.
# Uma versao anterior deste patch setava LLAMA_ARG_TEMP e era NO-OP silencioso:
# o valor que importava (temperatura) nunca era aplicado.
# Correcao: temperatura e top_p vao como ARGUMENTO do servidor (--temp/--top-p),
# que afeta o default do slot sem depender do cliente.
patch(
"code-friendly generation defaults (via --temp/--top-p)",
' if "--metrics" in help_txt:\n args += ["--metrics"]',
' # Defaults de geracao para CODIGO.\n'
' # Verificado no binario b10985: NAO existe LLAMA_ARG_TEMP nem\n'
' # LLAMA_ARG_TOP_P (so LLAMA_ARG_TOP_K). Por isso temperatura e top_p\n'
' # vao por flag, nao por env -- senao viram no-op silencioso.\n'
' # O cliente continua podendo sobrescrever mandando `temperature`.\n'
' #\n'
' # VALORES: recomendacao OFICIAL do Qwen para Qwen3-Coder-30B-A3B:\n'
' # temperature=0.7, top_p=0.8, top_k=20, repetition_penalty=1.05\n'
' # (antes era 0.2/0.95, que era correcao para o default generico 0.8 do\n'
' # Qwen3-30B-A3B. Para o Coder, o autor do modelo manda 0.7 -- seguir.)\n'
' if "--temp" in help_txt or "--temperature" in help_txt:\n'
' args += ["--temp", os.environ.get("GEN_TEMP", "0.7")]\n'
' log("geracao: --temp " + os.environ.get("GEN_TEMP", "0.7") + " (coder)")\n'
' if "--top-p" in help_txt:\n'
' args += ["--top-p", os.environ.get("GEN_TOP_P", "0.8")]\n'
' if "--repeat-penalty" in help_txt:\n'
' args += ["--repeat-penalty", os.environ.get("GEN_REP_PEN", "1.05")]\n'
' if "--min-p" in help_txt:\n'
' args += ["--min-p", os.environ.get("GEN_MIN_P", "0.0")]\n'
' if "--metrics" in help_txt:\n args += ["--metrics"]',
)
# --------------------------------------------------------------------------
# 5b. LLAMA_ARG_TOP_K: esta e a UNICA var de sampling que o build aceita,
# entao vai por env (verificado presente no binario).
patch(
"LLAMA_ARG_TOP_K via env",
'def server_env():\n env = dict(os.environ)',
'def server_env():\n'
' env = dict(os.environ)\n'
' # Unica var de sampling confirmada no build b10985. As de temperatura\n'
' # e top_p NAO existem -- ver patch 5 (vao por flag).\n'
' env.setdefault("LLAMA_ARG_TOP_K",\n'
' os.environ.get("GEN_TOP_K", "20"))\n',
)
# --------------------------------------------------------------------------
# 6. Concorrencia: PARALLEL=1 medido em /props (total_slots: 1).
# Um slot unico serializa tudo: num loop de agente cada tool call espera a
# anterior, e um segundo cliente fica na fila atras do primeiro.
#
# ATENCAO -- isto e deliberadamente 1 por padrao, NAO aumentado.
# Motivo: com PARALLEL=N o llama.cpp divide o --ctx-size entre os slots.
# Com CTX_SIZE=32768 e N=2, cada slot fica com 16k, o que QUEBRA casos de
# contexto longo (ja foi observado n_tokens_max=14464 neste Space).
# Subir PARALLEL exige subir CTX_SIZE junto. Por isso fica explicito e
# documentado, mas o default nao muda sozinho.
patch(
"document PARALLEL/ctx tradeoff",
'PARALLEL = int(os.environ.get("PARALLEL", "1"))',
'PARALLEL = int(os.environ.get("PARALLEL", "1"))\n'
'# Medido: total_slots=1. Um slot unico serializa agentes concorrentes.\n'
'# Subir PARALLEL divide CTX_SIZE entre os slots -- para manter 32k por\n'
'# slot com N=Parallel, defina CTX_SIZE = 32768 * N. Ver README.\n'
'# print, nao log(): log() ainda nao existe neste ponto do arquivo.\n'
'if PARALLEL > 1 and CTX_SIZE < 32768 * PARALLEL:\n'
' print(f"[cfg] AVISO: PARALLEL={PARALLEL} com CTX_SIZE={CTX_SIZE} "\n'
' f"da apenas {CTX_SIZE // PARALLEL} tokens por slot "\n'
' f"(era 32768). Use CTX_SIZE={32768 * PARALLEL} para manter.",\n'
' flush=True)',
)
# --------------------------------------------------------------------------
# 7. Cache de prompt em RAM.
# Default do llama.cpp: --cache-ram 8192 (8 GB). Verificado na doc oficial.
# Este host tem ~96 GB de RAM e o modelo ocupa 18.6 GB; o KV cache de 32k
# custa ~3.2 GB. Ou seja, sobram ~70 GB ociosos.
# Cache maior = mais prefixos de conversa mantidos em memoria. Num agente,
# que revisita o mesmo historico a cada turno, isso evita re-prefill.
# Nao custa nada em qualidade: e so RAM parada que passamos a usar.
patch(
"raise --cache-ram (default 8 GB -> 16 GB)",
' if "--metrics" in help_txt:\n args += ["--metrics"]',
' # Cache de prompt em RAM. Default do llama.cpp e 8192 MiB.\n'
' # Com ~96 GB de RAM e 18.6 GB de modelo, ha folga para manter\n'
' # muitos prefixos de conversa. CACHE_RAM=0 desliga, -1 = sem limite.\n'
' # 32768 MiB (era 16384): com CTX_SIZE=131072 o KV de UM contexto custa\n'
' # 12 GiB, entao 16 GB guardava praticamente uma conversa so. 32 GB\n'
' # guarda ~2-3 e ainda sobra RAM (modelo 17.3 + KV 12 + cache 32 = 61\n'
' # de 97 GB). Nao custa velocidade -- e RAM que estava parada.\n'
' cache_ram = os.environ.get("CACHE_RAM", "32768")\n'
' if "--cache-ram" in help_txt:\n'
' args += ["--cache-ram", cache_ram]\n'
' log("cache: --cache-ram " + cache_ram + " MiB")\n'
' if "--metrics" in help_txt:\n args += ["--metrics"]',
)
# --------------------------------------------------------------------------
# 8. (REMOVIDO) Speculative decoding por n-gram.
# Testado em producao e MEDIDO INERTE: com `--spec-type ngram-map-k4v` o
# servidor aceita a flag mas gera ZERO draft tokens
# (`llamacpp:spec_decode_num_draft_tokens_total` = 0), inclusive no caso
# ideal (prompt com o mesmo bloco de codigo repetido 12x e instrucao
# explicita de repetir o padrao 20 vezes). Decode ficou em 28.0 tok/s,
# identico a sem spec.
# O A/B do autotune confirma: prefill 89.22 -> 89.85 e decode 14.12 -> 14.16
# na config vencedora (dentro do ruido); nas outras configs piorou ~10%.
# Manter uma flag que nao faz nada e pior que nao ter: induz a acreditar
# que ha uma otimizacao ativa. Removido.
# --------------------------------------------------------------------------
# 9. Correcao do criterio de escolha do AUTOTUNE.
# BUG REAL observado em producao: o autotune escolheu tb=8 (prefill 63.16,
# TTFT 72.63s) em vez de tb=16 (prefill 89.85, TTFT 51.05s) -- ou seja,
# trocou um TTFT 21 SEGUNDOS PIOR por ~3 tok/s de decode.
#
# Causa: o portao de elegibilidade exigia decode >= 90% do melhor medido.
# best_decode = 17.20 (tb=8) -> corte = 15.48
# tb=16 fa=on tinha decode 14.16 -> REPROVADO e excluido da escolha,
# apesar de ter o melhor prefill de todos.
# Com decode variando ate ~27% entre execucoes identicas (medido: tb=8 deu
# 13.55 numa rodada e 17.20 na outra), um portao de 90% persegue ruido e
# pode excluir a melhor configuracao inteira.
#
# Correcao: baixar o portao para 60%. Ele existe so para nao escolher algo
# com decode catastrofico; 60% cumpre isso sem ser vitima do ruido.
# Para carga de agente, TTFT/prefill domina a experiencia -- 20s a mais de
# espera por turno pesa muito mais que 3 tok/s de decode.
patch(
"fix autotune eligibility gate (0.9 -> 0.6)",
' best_decode = max(r["decode_tok_s"] for r in rows)\n'
' elegiveis = [r for r in rows if r["decode_tok_s"] >= 0.9 * best_decode]',
' best_decode = max(r["decode_tok_s"] for r in rows)\n'
' # Portao 0.9 era vitima do ruido: decode varia ~27% entre rodadas\n'
' # identicas, e o portao chegou a excluir a config de MELHOR prefill\n'
' # (tb=16 fa=on, decode 14.16 < corte 15.48), fazendo o autotune\n'
' # escolher tb=8 com TTFT 21s pior. 0.6 ainda barra decode\n'
' # catastrofico sem perseguir ruido.\n'
' elegiveis = [r for r in rows if r["decode_tok_s"] >= 0.6 * best_decode]',
)
# --------------------------------------------------------------------------
# 10. Contexto: 32768 -> 40960 (o maximo NATIVO, sem perda de qualidade).
#
# Verificado no config.json do proprio modelo (Qwen/Qwen3-30B-A3B):
# "max_position_embeddings": 40960
# "rope_scaling": null
# Ou seja: as position embeddings vao ate 40960 e NAO ha rope scaling
# configurado. Entao 40960 e alcancavel sem YaRN e sem risco de qualidade.
# O servidor rodava em 32768 -- 25% abaixo do que o modelo entrega de graca.
#
# O Qwen avisa na model card: "32,768 nativamente e 131,072 com YaRN", e
# "se o contexto medio nao passar de 32,768, NAO recomendamos YaRN, pois
# pode degradar performance". Por isso 40960 e o default seguro: e o teto
# sem YaRN.
#
# Memoria (medido/calculado): KV f16 = 96 KiB por token
# 32768 -> 3.0 GiB | 40960 -> 3.75 GiB | 131072 -> 12 GiB
# Com 96 GB de RAM e 18.6 GB de modelo, memoria NAO e o limite.
# O limite e o PREFILL: a ~105 tok/s, 40960 tokens custam ~390s (6.5 min).
# O cache de prompt amortiza isso quando o prefixo se repete.
patch(
"raise CTX_SIZE to 131072 (native on Qwen3-Coder, no YaRN)",
'CTX_SIZE = int(os.environ.get("CTX_SIZE", "32768"))',
'# Qwen3-Coder-30B-A3B tem 262144 de contexto NATIVO (rope_scaling=null),\n'
'# entao 131072 nao precisa de YaRN e nao tem risco de qualidade.\n'
'#\n'
'# Por que 131072 e nao 262144 (o maximo nativo):\n'
'# CTX_SIZE e um TETO, nao um custo -- alocar 262144 nao deixa requisicoes\n'
'# curtas mais lentas (a atencao escala com o tamanho REAL da sequencia).\n'
'# O motivo de parar em 131072 e o PREFILL: medido ~91 tok/s com 4.6k\n'
'# tokens, e a taxa cai com o contexto. Em 131072 o prefill de um prompt\n'
'# cheio fica na casa de dezenas de minutos; em 262144, horas. 128K cobre\n'
'# repositorio inteiro e ja e um teto realista. Subir para 262144 e uma\n'
'# Variable (CTX_SIZE=262144) -- 24 GiB de KV, cabe nos 97 GB.\n'
'CTX_SIZE = int(os.environ.get("CTX_SIZE", "131072"))',
)
# --------------------------------------------------------------------------
# 12. Profile do Qwen3-Coder-30B-A3B-Instruct.
# O app trazia 5 profiles, e NENHUM deles e o modelo certo para este hardware.
# Pior: dois sao densos de 32B (qwen3-32b, qwen25-coder-32b), que neste
# container rodam a ~2.6 tok/s (dense le TODOS os pesos por token) -- ou seja,
# sao inutilizaveis, apesar do nome "coder".
#
# Qwen3-Coder-30B-A3B: 30.5B total / 3.3B ATIVOS, 48 camadas, 4 KV heads.
# Arquitetura identica ao qwen3-30b-a3b -> mesma velocidade (~25 tok/s) e
# mesmo KV (96 KiB/token), mas:
# - especializado em coding agentico (SWE-bench 51.6, Terminal-Bench 31.3)
# - 262144 de contexto NATIVO (vs 40960) -- 6.4x, sem YaRN
# - Q4_K_M = 17.28 GiB, MENOR que o modelo anterior
# Revision fixada (como os outros profiles) para o build ser reproduzivel.
patch(
"add qwen3-coder-30b-a3b profile",
' "alias": "qwen2.5-coder-7b-agent",\n },\n}',
' "alias": "qwen2.5-coder-7b-agent",\n'
' },\n'
' # MoE 30.5B total / 3.3B ativos -- mesma arquitetura do qwen3-30b-a3b\n'
' # (48 camadas, 4 KV heads), entao mesma velocidade e mesmo KV, mas\n'
' # especializado em coding agentico e com 262144 de contexto nativo.\n'
' "qwen3-coder-30b-a3b": {\n'
' "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",\n'
' "revision": "b17cb02dd882d5b6ab62fc777ad2995f19668350",\n'
' "file": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",\n'
' "size": 18556689568,\n'
' "alias": "qwen3-coder-30b-a3b-agent",\n'
' },\n'
'}',
)
# --------------------------------------------------------------------------
# 13. Default passa a ser o Coder.
# O profile existe (patch 12), mas o default ainda apontava para o modelo
# antigo. Trocar o default (em vez de depender de uma Variable) garante que
# o Space suba no modelo certo mesmo sem configuracao extra.
patch(
"default PROFILE -> qwen3-coder-30b-a3b",
'PROFILE = os.environ.get("PROFILE", "qwen3-30b-a3b")',
'PROFILE = os.environ.get("PROFILE", "qwen3-coder-30b-a3b")',
)
# --------------------------------------------------------------------------
# 11. YaRN opt-in para passar de 40960 (ate 131072).
# DESLIGADO por padrao, de proposito. O Qwen recomenda nao usar YaRN a menos
# que o contexto medio realmente passe de 32768, porque ele pode degradar a
# qualidade -- e num endpoint de CODIGO qualidade e o que importa.
#
# Para habilitar 131072 (4x de 32768, o maximo documentado pelo Qwen):
# ROPE_SCALE=4 (e CTX_SIZE=131072)
# Para 65536 (2x): ROPE_SCALE=2
patch(
"optional YaRN for >40960 context",
' if "--metrics" in help_txt:\n args += ["--metrics"]',
' # YaRN opt-in. Sem ROPE_SCALE definido, nada e passado -- o default\n'
' # fica no contexto nativo (40960) sem rope scaling.\n'
' rope_scale = os.environ.get("ROPE_SCALE", "")\n'
' if rope_scale and "--rope-scaling" in help_txt:\n'
' rope_type = os.environ.get("ROPE_SCALING", "yarn")\n'
' args += ["--rope-scaling", rope_type, "--rope-scale", rope_scale]\n'
' orig = os.environ.get("YARN_ORIG_CTX", "32768")\n'
' if "--yarn-orig-ctx" in help_txt:\n'
' args += ["--yarn-orig-ctx", orig]\n'
' log("rope: " + rope_type + " scale=" + rope_scale +\n'
' " orig_ctx=" + orig + " -- Qwen avisa que YaRN pode degradar")\n'
' if "--metrics" in help_txt:\n args += ["--metrics"]',
)
# --------------------------------------------------------------------------
# 14. Diagnostico de CPU: AMX e VNNI.
# O app checava so avx/avx2/fma/avx512f/avx512bw -- e ficava cego justamente
# na feature que mais importa para inferencia: **AMX**.
#
# Por que AMX importa: o build traz 14 backends de CPU compilados
# (libggml-cpu-*.so), incluindo `sapphirerapids` e `cooperlake`, que usam
# AMX (Advanced Matrix Extensions) para matmul INT8/BF16. Prefill e
# dominado por matmul, entao AMX e exatamente o eixo do gargalo.
# O Xeon Platinum 8559C e Emerald Rapids -- mesma familia de nucleo do
# Sapphire Rapids, que introduziu AMX. Se AMX estiver disponivel e o
# backend certo for carregado, o prefill ganha sem custo nenhum.
#
# `/proc/cpuinfo` expoe: amx_tile, amx_int8, amx_bf16, avx512_vnni,
# avx512_bf16. Reportar isso torna o ganho verificavel em vez de suposto.
patch(
"report AMX / VNNI / bf16 cpu flags",
' for f in ("avx", "avx2", "fma", "avx512f", "avx512bw"):',
' # AMX e VNNI sao o que decide a velocidade de PREFILL (matmul).\n'
' for f in ("avx", "avx2", "fma", "avx512f", "avx512bw",\n'
' "avx512_vnni", "avx512_bf16",\n'
' "amx_tile", "amx_int8", "amx_bf16"):',
)
# --------------------------------------------------------------------------
# 15. Logar o backend de CPU E o uso real de AMX.
#
# Pesquisa nos mantenedores do llama.cpp (issue #12003) revelou o que checar:
# - "amx works better for PREFILL (M is large)"; decode usa AVX512-VNNI
# de proposito, porque VNNI ganha quando M=1.
# - "The moe modules in llama.cpp needs different optimizations (group
# gemm), it is not a matter of using amx or not" -> para MoE o ganho de
# AMX e LIMITADO por limitacao do framework, nao da CPU.
# - O llama.cpp imprime QUANTO do modelo foi para o buffer AMX:
# "load_tensors: AMX model buffer size = X MiB"
# "load_tensors: tensor '...' (q4_K) cannot be used with ... AMX"
# Ou seja: da para SABER se AMX esta ativo e quanto, em vez de supor.
# - "system_info:" lista as flags como o llama.cpp as ve (AMX_INT8 = 1 ...).
#
# A versao anterior procurava so "ggml-cpu"/"load_backend" e nao pegava nada,
# porque as linhas uteis sao "load_tensors:" e "system_info:".
patch(
"log ggml cpu backend + real AMX usage",
' for line in txt.splitlines():\n'
' if "Chat format" in line or "chat_template" in line.lower():\n'
' log("diag> " + line.strip()[:200])',
' for line in txt.splitlines():\n'
' if "Chat format" in line or "chat_template" in line.lower():\n'
' log("diag> " + line.strip()[:200])\n'
' # backend carregado e, mais importante, quanto do modelo foi para\n'
' # o buffer AMX (linha "load_tensors: ... AMX").\n'
' low = line.lower()\n'
' if any(k in low for k in ("load_backend", "ggml-cpu",\n'
' "system_info", "load_tensors",\n'
' "amx", "buffer type")):\n'
' log("diag> " + line.strip()[:200])',
)
# --------------------------------------------------------------------------
# 16. Reportar topologia NUMA.
# O host tem 192 nucleos logicos (provavelmente 2 sockets). Se o container
# for espalhado por mais de um no NUMA, o acesso a memoria pode atravessar
# o interconect entre sockets -- o que pesa justamente em inferencia, que e
# limitada por banda. Saber quantos nos existem e se ha memoria por no e o
# que decide se vale usar --numa / --cpu-mask.
# Se houver 1 no so, NAO ha o que otimizar e paramos por aqui.
patch(
"report NUMA topology",
' if TEMPLATE_FILE.is_file():',
' # NUMA: so importa se houver mais de um no visivel ao container.\n'
' try:\n'
' import glob as _glob\n'
' nodes = sorted(_glob.glob("/sys/devices/system/node/node*"))\n'
' log("numa: " + str(len(nodes)) + " no(s) visivel(is)")\n'
' if len(nodes) > 1:\n'
' for n in nodes:\n'
' try:\n'
' cpul = pathlib.Path(n, "cpulist").read_text().strip()\n'
' log("numa> " + n.rsplit("/", 1)[-1] + " cpus=" + cpul)\n'
' except Exception:\n'
' pass\n'
' except Exception as exc:\n'
' log(f"numa: {exc}")\n'
' if TEMPLATE_FILE.is_file():',
)
# --------------------------------------------------------------------------
# 17. Modo NUMA (opt-in, medido antes de virar default).
# Medido no container: 2 nos NUMA (node0 cpus=0-47,96-143 | node1 48-95,144-191).
# Num host de 2 sockets, memoria alocada num no e lida pelo outro atravessa o
# interconect -- e inferencia e limitada por banda. O llama.cpp tem --numa
# justamente para isso (distribute = interleave a memoria entre os nos).
#
# Porem NAO e garantido que ajude: o container recebe 16 vCPU de 192 e pode
# ja estar restrito a um subconjunto de nos. Por isso entra como opt-in via
# env (NUMA_MODE), com default VAZIO = nao passa a flag = comportamento de
# antes. Depois de medir, decidimos se vira default.
# NUMA_MODE=distribute -> interleave (esperado melhor em banda)
# NUMA_MODE=isolate -> cada thread no seu no
patch(
"optional --numa mode",
' if "--metrics" in help_txt:\n args += ["--metrics"]',
' # NUMA opt-in: sem NUMA_MODE definido, nada muda em relacao a antes.\n'
' numa_mode = os.environ.get("NUMA_MODE", "")\n'
' if numa_mode and "--numa" in help_txt:\n'
' args += ["--numa", numa_mode]\n'
' log("numa: --numa " + numa_mode + " (opt-in)")\n'
' if "--metrics" in help_txt:\n args += ["--metrics"]',
)
# --------------------------------------------------------------------------
# 18. ubatch 2048 -> 4096 (experimento focado em PREFILL).
# O autotune media threads/threads_batch, mas mantinha ubatch FIXO em 2048 --
# entao esse eixo nunca foi realmente testado.
# Por que ubatch importa aqui: prefill processa o prompt em lotes de ubatch
# tokens. Lote maior = matmul maior = melhor eficiencia. E os mantenedores
# do llama.cpp apontaram que "the moe modules needs different optimizations
# (e.g. group gemm)" -- group gemm justamente melhora com lote maior, porque
# agrupa as matmuls dos especialistas ativados. Ou seja: ubatch e o knob que
# ataca a limitacao conhecida de MoE em CPU.
# 4096 ainda cabe folgado: ativacoes ~4096 x 2048 x 2B = 16 MB por tensor.
# Continua sobrescrevivel por env (UBATCH_SIZE), entao da para A/B sem deploy.
patch(
"ubatch: mantido 2048 (experimento revertido)",
'UBATCH_SIZE = int(os.environ.get("UBATCH_SIZE", "2048"))',
'# MANTIDO 2048. Testei 4096 e NAO ajudou -- ver bloco 18 acima.\n'
'# Fica explicito para ninguem "otimizar" de novo sem medir.\n'
'UBATCH_SIZE = int(os.environ.get("UBATCH_SIZE", "2048"))',
)
# --------------------------------------------------------------------------
# 19. Coerencia batch >= ubatch.
# BUG que eu mesmo introduzi no patch 18: subi UBATCH_SIZE para 4096 mas
# BATCH_SIZE continuou 2048. No llama.cpp o lote fisico (ubatch) NAO pode ser
# maior que o logico (batch) -- ele faz clamp de n_ubatch para n_batch. Ou
# seja: a mudanca podia estar sendo silenciosamente anulada, e a "melhora"
# medida vinha de diferenca de metodologia, nao da flag.
# Correcao dupla: (a) subir BATCH_SIZE para 4096, (b) guarda que forca
# batch >= ubatch e AVISA no log se alguem configurar ao contrario.
# O aviso e o ponto: configuracao incoerente tem de aparecer, nao ser
# engolida em silencio.
patch(
"guard: force batch >= ubatch and warn",
'UBATCH_SIZE = int(os.environ.get("UBATCH_SIZE", "2048"))',
'UBATCH_SIZE = int(os.environ.get("UBATCH_SIZE", "2048"))\n'
'# Guarda de coerencia: sem isso um UBATCH_SIZE maior que BATCH_SIZE e\n'
'# anulado em silencio pelo llama.cpp e a flag vira placebo.\n'
'if UBATCH_SIZE > BATCH_SIZE:\n'
' print(f"[cfg] UBATCH_SIZE={UBATCH_SIZE} > BATCH_SIZE={BATCH_SIZE}; "\n'
' f"subindo BATCH_SIZE para {UBATCH_SIZE} (llama.cpp faria clamp)",\n'
' flush=True)\n'
' BATCH_SIZE = UBATCH_SIZE',
)
def apply(path, dry_run=False):
src = pathlib.Path(path).read_text(encoding="utf-8")
original = src
report = []
for name, old, new in PATCHES:
# Idempotencia: se TODAS as linhas novas ja estao no arquivo, o patch
# ja foi aplicado. Comparar `new in src` nao serve quando o patch
# consiste em INSERIR um bloco antes de uma ancora que ja existe --
# nesse caso `new` nunca aparece literalmente como substring, porque
# o bloco inserido e a ancora ficam separados por linhas ja presentes.
# Por isso o teste e por linhas: as linhas adicionadas tem de existir.
added = [ln for ln in new.splitlines() if ln.strip()
and ln not in old.splitlines()]
if added and all(a in src for a in added):
report.append(f"[SKIP] {name}: ja aplicado")
continue
occurrences = src.count(old)
if occurrences == 0:
report.append(f"[FALHA] {name}: ancora nao encontrada")
print("\n".join(report))
return 1, None
if occurrences > 1:
report.append(f"[FALHA] {name}: ancora ambigua ({occurrences}x)")
print("\n".join(report))
return 1, None
src = src.replace(old, new, 1)
report.append(f"[OK] {name}")
if src == original:
report.append("nada a fazer (todos os patches ja aplicados)")
if dry_run:
diff = difflib.unified_diff(
original.splitlines(keepends=True),
src.splitlines(keepends=True),
fromfile="app.py (atual)", tofile="app.py (patched)", n=3,
)
print("\n".join(report))
print("\n".join(diff))
return 0, None
# newline="\n" explicito: sem isso o write_text traduz para CRLF no
# Windows, e o arquivo local fica com sha256 diferente do que foi
# publicado (o upload converte para LF). O conteudo e o mesmo, mas
# comparar hash local-vs-remoto vira falso alarme. Escrevendo LF desde
# aqui, local e publicado batem byte a byte.
pathlib.Path(path).write_text(src, encoding="utf-8", newline="\n")
print("\n".join(report))
return 0, src
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
target = sys.argv[1]
dry = "--dry-run" in sys.argv
code, _ = apply(target, dry_run=dry)
sys.exit(code)