Quantização GGUF no llama.cpp: 6 erros silenciosos
Quantização GGUF no llama.cpp virou aquela dupla de comandos que a gente copia de um canto de README e nunca mais olha. O problema mora exatamente ali, nesse trecho que ninguém audita. O arquivo abre, o loader não reclama de nada, e o modelo devolve o seu próprio prompt como resposta. Ou ignora o system prompt. Ou cospe um JSON quebrado numa tool call. Aí começa o desespero: troca de versão, baixa outro checkpoint, xinga o modelo de ruim em português. E o culpado quase sempre está três etapas antes, enterrado na metadata que a conversão gravou.
Minha posição: converter e quantizar é um pipeline com quatro pontos de falha silenciosa. Template de chat, contexto e rope, escolha da família de quant, offload de camadas. E a métrica que a comunidade usa pra decidir o Q (perplexidade em WikiText-2, aquela publicada nos PRs de k-quants) não antecipa a degradação que quem roda pt-BR, código e tool-calling sente na pele.
O erro mais mal documentado da quantização GGUF no llama.cpp: o chat template não viaja sozinho
O convert_hf_to_gguf.py copia pro GGUF o que estiver em tokenizer_config.json. E tokenizer.chat_template é uma dessas chaves. Quando o repositório do modelo não tem essa chave, ou tem num arquivo auxiliar que o script não lê, o GGUF nasce sem template. Embutir o arquivo com --chat-template-file resolve. Omitir produz um arquivo que parece íntegro e não é.
Sem template, o runtime não sabe onde inserir os tokens especiais (<|im_start|>, [INST], <|eot_id|>, depende da família). O prompt sai cru. O modelo faz a única coisa que sabe fazer: continuar texto. Ele continua o seu prompt. É esse o relato que aparece direto nas discussões do repositório, sempre com alguém jurando que o modelo entrou em loop.
Duas ferramentas matam a dúvida em segundos: llama-gguf, o dump de metadata que vem no próprio llama.cpp, e qualquer leitor GGUF em Python. Você quer ver general.architecture, tokenizer.ggml.model, um tokenizer.chat_template não vazio e general.name. Ah, e tem uma pegadinha: o Ollama guarda o template no Modelfile dele, não necessariamente no GGUF. Isso explica por que o mesmo arquivo se comporta diferente no llama-server e no ollama run.
Converter direto pra Q4 parece economia de passo e é onde você perde calibração
O atalho tentador é mandar safetensors → Q4_K_M de uma vez. Ele não existe no caminho oficial. O convert_hf_to_gguf.py escreve F32, F16, BF16 e Q8_0, e nada além. A quantização agressiva é trabalho do llama-quantize, que precisa de um GGUF de maior precisão como entrada.
Tem um motivo além do técnico pra essa etapa intermediária: a importance matrix. O llama-imatrix lê um corpus de calibração e gera um .dat dizendo ao quantizador quais pesos importam mais para aquele texto. Os I-quants (IQ3_XXS, IQ4_XS e companhia) praticamente exigem isso. Sem imatrix, um I-quant do mesmo tamanho pode sair pior que um K-quant equivalente. Isso está nas discussões de k-quants e nos guias de quantização do kaitchup.
O detalhe que quase ninguém aplica: o corpus de calibração pode ser o seu. Se o modelo vai responder em português, chamar função com nome em snake_case e devolver JSON, um calib.txt com 200 a 500 trechos desse tipo muda o resultado mais do que subir de Q4 pra Q5. Custa alguns minutos de GPU e um --chunks na casa das centenas. Se a GPU local engasga com o modelo em f16, alugar uma por hora em nuvem (RunPod, Vast.ai, Lambda) sai mais barato que o tempo perdido debugando depois.
Contexto e rope scaling são decisões da conversão, não do runtime
O GGUF guarda <arch>.context_length, normalmente herdado do max_position_embeddings do config.json, junto com rope.freq_base. Isso é o que o modelo foi treinado pra aguentar. Você pode sobrescrever no runtime com -c e as flags de rope scaling, e o llama.cpp obedece. Sem avisar que aquele valor é maior do que o modelo viu no treino. O resultado não é erro: é texto coerente nas primeiras centenas de tokens e degradação progressiva depois.
Outro mal-entendido recorrente: “não cabe o contexto”. Contexto é KV cache, não peso. A conta é aritmética simples e vale fazer com os números do seu modelo. Pegue a quantidade de camadas, os KV heads e o head_dim do config, multiplique pelos bytes por elemento em fp16 (2, no caso) e você tem o custo por token em cada camada. Digamos 32 camadas, 8 KV heads e head_dim 128: dá algo perto de 128 KB por token, em contas redondas. Multiplique pelo contexto que você pretende usar e veja o total. Flash attention ligado e --cache-type-k q8_0 cortam boa parte disso, mas o padrão não perdoa. Quando alguém diz “não cabe o contexto”, quase nunca é VRAM de peso. É cache.
Quantização GGUF no llama.cpp: Q4_K_M não é ponto doce universal, o corpus do benchmark não é o seu
A página de k-quants do llama.cpp documenta o bits-por-peso real de cada família: Q8_0 fica em torno de 8,5 bpw, Q4_K em torno de 4,5 bpw. Repare que Q4_K não é 4 bpw. O sufixo _S, _M, _L existe porque, dentro do mesmo arquivo, os tensores recebem tipos diferentes, e o grau de mistura é que define o tamanho final. Não é número mágico de “4 bits por peso”, é média ponderada por tensor.
A tabela de perplexidade do PR de k-quants mede a perda média em WikiText-2, corpus majoritariamente em inglês. Bom sinal de degradação geral, péssimo sinal de degradação onde dói pra nós. Três motivos concretos:
- Acentuação e tokenização multilíngue. Palavras com ã, ç e õ são UTF-8 multibyte e, em tokenizadores com fallback de bytes, viram sequências de tokens mais longas e raras. Token raro é exatamente o que a quantização agressiva machuca primeiro. E o corpus do benchmark quase não tem disso.
- Código e nomes de função. Identificadores como
get_user_by_emailsão sequências longas e específicas. O erro não aparece como texto estranho, aparece comoget_user_by_emial. - Tool calling. JSON com schema rígido depende de acertar chave, aspas e vírgula em sequência. Uma degradação que quase não mexe na perplexidade média arruína a taxa de parse.
Nada disso invalida os benchmarks. Só significa que eles não respondem a pergunta que você está fazendo. E o llama-bench, que vem no repositório, mede tokens/s, não qualidade. Perplexidade e velocidade medem outra coisa.
-ngl 99 não é otimização, é aposta (e a conta que ninguém faz)
-ngl é quantas camadas vão pra GPU. Jogar 99 (o “tudo”) é o padrão que todo mundo copia. Numa placa de entrada, com um modelo que já ocupa quase toda a VRAM, o peso fica com a maior parte da memória, o KV cache come um pedaço, o buffer de computação leva outro tanto e o desktop fica com o resto. O que acontece então não é um erro limpo. Ou o llama.cpp reduz o offload sozinho, ou o driver começa a paginar.
Nos dois casos o sintoma é o mesmo: throughput despenca. Cada token passa a atravessar PCIe várias vezes, e o gargalo deixa de ser cálculo e vira tráfego de barramento. A conclusão apressada é “esse modelo é lento”. A causa é configuração.
O que ajuda: calibrar -ngl por medição, não por fé; --no-kv-offload quando o cache é o que estoura, deixando ele em RAM; e llama-bench comparando alguns valores de -ngl antes de escolher. Em muitos cenários, um punhado de camadas a menos na GPU entrega mais tokens/s do que todas elas. Quando o assunto é rodar modelo grande de verdade, a conversa deixa de ser VRAM e vira CPU com AVX-512 e memória rápida. Aí vale ler o material da Arm sobre inferência distribuída com llama.cpp em Graviton4, que trata conversão e quantização como etapa formal de pipeline, não como comando solto.
Atualizou o llama.cpp, mudou o modelo: o drift de versão entre converter e quantizar
O llama.cpp se move rápido, e o nome dos scripts muda junto. O convert.py virou convert_hf_to_gguf.py, e quem tinha script de CI antigo descobriu isso do jeito ruim. Mais relevante ainda: a implementação por trás de um mesmo tipo de quant já mudou mais de uma vez no projeto, e o GGUF tem um campo de versão de formato no header.
O cenário perigoso é o silencioso. Converter com um build, quantizar com outro e rodar com um terceiro. Você não recebe erro quando o resultado é apenas diferente. Recebe erro explícito no caso oposto, quando o quantizador novo grava um tipo que o runtime antigo não conhece (“unknown type”). O silencioso é pior.
A correção é chata e funciona: fixe um commit ou uma release b#### e use o mesmo build nas três etapas. Docker com tag fixa, git checkout <sha> antes de compilar, e anota a versão no card do modelo que você publicar. Quem baixar seu GGUF daqui a seis meses vai precisar saber com o que ele foi gerado.
O checklist que eu rodo antes de queimar hora de GPU
Nada aqui é benchmark meu. É leitura da documentação, do código e das discussões do repositório. A sequência, na ordem:
- Metadata primeiro.
llama-gguf arquivo.ggufe confirageneral.architecture,tokenizer.ggml.model,<arch>.context_lengthe setokenizer.chat_templateestá presente. - Template aplicado. Mande um system prompt e olhe o bruto no log do
llama-server, não a resposta formatada. - Tokenizer. Compare a contagem de tokens de uma frase em pt-BR entre o tokenizer original (HF) e o do GGUF. Divergência aqui explica muita coisa depois.
- Batch de domínio. Dez prompts: acentuação, um nome de função, um schema JSON, uma instrução longa. Rode no f16 e no quantizado e compare lado a lado.
- Velocidade.
llama-benchcom pp512 e tg128, em dois ou três valores de-ngl. Número medido, não impressão. - Se degradou, recalibre. Gere nova imatrix com texto do seu domínio e requantize no mesmo tamanho antes de partir pra um Q maior.
O posicionamento
Eu não confio em tabela de perplexidade de corpus inglês pra decidir quantização de um modelo que responde em português e chama função. A quantização decide se o modelo cabe. Template, metadata e versão do llama.cpp decidem se ele funciona. Tratar conversão como etapa de engenharia, com entrada, saída, versão fixada e verificação, custa vinte minutos. Tratar como download custa uma semana culpando o modelo errado.
Fontes consultadas
- GitHub (ggml-org/llama.cpp), k-quants · Pull Request #1684 (ikawrakow), 2023, link
- GitHub (ggml-org/llama.cpp), tools/quantize/README.md, tipos de quantização e bits-per-weight, link
- GitHub (ggml-org/llama.cpp), tools/imatrix/README.md, Importance Matrix, link
- GitHub (ollama/ollama), docs/modelfile.mdx, Ollama Modelfile Format (TEMPLATE), link
- The Kaitchup (Substack), Choosing a GGUF Model: K-Quants, I-Quants, and Legacy Formats, link
- GitHub (ggml-org/llama.cpp), Perplexity (Quality of Generation) Scores · Discussion #406, 22/03/2023, link