File size: 29,424 Bytes
3b4fb97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46d88b9
 
 
3b4fb97
46d88b9
 
 
 
 
 
 
 
 
 
 
3b4fb97
 
d8402ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b4fb97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a0c9d8
 
 
 
 
3b4fb97
2a0c9d8
 
3b4fb97
2a0c9d8
 
 
3b4fb97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4ee86a
 
 
 
 
 
 
 
 
 
 
 
 
 
5de0eb7
 
 
 
 
b4ee86a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ab0dce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a0c9d8
7ab0dce
2a0c9d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ab0dce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b4fb97
5de0eb7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a72b37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5de0eb7
0a72b37
5de0eb7
 
 
 
 
 
0a72b37
 
5de0eb7
0a72b37
 
 
 
5de0eb7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47520c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89b8453
 
 
 
47520c2
 
 
 
a3b8ca7
 
 
 
 
 
 
 
 
 
 
 
 
89b8453
 
a3b8ca7
 
 
 
 
 
 
 
 
 
3b4fb97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5ed9ba
 
 
 
 
 
3b4fb97
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
"""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)