"""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)