Pular para o conteúdo
Fantástico Mundo de Jon
RSS

Rodei o BitNet da Microsoft em duas CPUs: o 6x some contra o que já está no disco

Eu clonei o bitnet.cpp da Microsoft. O README promete 6,17x em CPU. Nas duas máquinas da casa o 4,2x contra f16 apareceu; contra o Q4 que o Ollama já entrega, cai para 1,3x. E o modelo vem com a ativação errada.

Atualizado em

Com pressa? Peça o TL;DR ao Claude — ele lê a página e resume.

Se você já pediu um código no ChatGPT, no Grok ou no Claude Code, a conta é a mesma. O modelo responde rápido porque está numa placa de vídeo que não é sua, num prédio que não é o seu. O texto sai de casa e volta pronto.

Tem gente tentando inverter essa conta: rodar o modelo no processador da mesa, sem placa de vídeo, sem mandar o arquivo para os Estados Unidos. A Microsoft tem um projeto para isso. Chama bitnet.cpp. Quarenta mil estrelas no GitHub, um paper no arXiv, e um número no topo do README: até 6,17x mais rápido em CPUs x86.

Seis vezes. Não vinte por cento. Seis.

A pergunta que quase ninguém faz, e que o próprio paper responde se você abrir a seção 4.1.2, é: seis vezes mais rápido do que o quê?

Eu clonei, compilei e medi em duas máquinas desta casa. O 6,17x não apareceu. Apareceu um 4,2x contra um adversário que ninguém usa, um 1,3x contra o arquivo que já está no seu disco, e um modelo estrela rodando com a função de ativação errada desde o dia em que eu baixei o repositório.

Antes, o que é cada peça

Cinco nomes que vão aparecer o post inteiro, sem mistério.

BitNet é a ideia. Os “pesos” do modelo, os números que ele aprendeu, deixam de ser números quebrados e viram três valores: -1, 0 e +1. Sem número quebrado, multiplicar vira somar e subtrair. A conta de energia fica bonita. A conclusão que as pessoas tiram é que um modelo grande passa a caber num computador modesto.

bitnet.cpp é o programa da Microsoft que põe essa ideia para rodar. É ele que o README está vendendo.

llama.cpp é o motor que quase todo mundo já tem. O Ollama, por baixo, é llama.cpp. Se você roda modelo local nesta casa ou na sua, é por aqui.

Q4_K_M é a compressão que o Ollama entrega por padrão. Quatro bits por peso, mais ou menos. É o arquivo que já está no disco de quem roda modelo local.

F16 é o modelo sem comprimir. Cinco gigas para um modelo de 2 bilhões de parâmetros. Ninguém roda isso em CPU. É o adversário que a Microsoft escolheu para o 6,17x.

Tem dois relógios, e as pessoas misturam os dois.

Prefill é o modelo lendo o seu pedido. Decode é ele escrevendo a resposta, pedacinho por pedacinho. O famoso tok/s do YouTube de hardware quase sempre é decode. Chat é quase todo decode. RAG, aquilo de colar um documento e perguntar, é quase todo prefill. O número que decide se vale a pena clonar o bitnet.cpp é o outro.

O 6,17x foi medido contra um adversário que ninguém usa

Antes de mostrar o que eu medi, vale olhar o número da Microsoft. A resposta está no próprio paper.

A comparação do bitnet.cpp é contra o llama.cpp em Float16. Está na seção 4.1.2 do arXiv 2502.11880: “we chose llama.cpp Float16 as the baseline”.

Ou seja: o baseline é um modelo de 2 bilhões de parâmetros em precisão cheia, ocupando 5 GB, rodando em CPU. Ninguém faz isso. As pessoas rodam Q4_K_M. A pergunta que interessa não é “quanto ganha contra o f16”. É “quanto ganha contra o arquivo que já está no disco”.

O arquivo de 1 bit não tem 1 bit

O primeiro susto veio antes de qualquer cronômetro, só de olhar o tamanho do arquivo.

O GGUF oficial, o pacote que você baixa para rodar, tem 1.187.801.280 bytes para 2,41 bilhões de parâmetros. Isso dá 3,94 bits por peso, não 1,58. Fui abrir os tensores, os blocos de números dentro do arquivo, para entender onde o dinheiro foi parar.

Anatomia do ggml-model-i2_s.gguf

Parte Parâmetros Bytes Bits por peso
Pesos ternários (I2_S) 2.084.044.800 521.017.920 2,000
Tabela de embeddings (F16) 328.335.360 656.670.720 16,000
Normalizações (F32) 440.320 1.761.280 32,000
Arquivo inteiro 2.412.820.480 1.187.801.280 3,938
Leitura direta dos tensores de microsoft/BitNet-b1.58-2B-4T-gguf com o gguf-py do próprio repositório, descontando os 8.351.360 bytes de cabeçalho. Aritmética sobre o arquivo, não medição de execução. O gguf-py não conhece o tipo I2_S e reporta 1 byte por peso, então o valor da linha ternária vem por diferença.

Duas coisas saem daí, e as duas contradizem a propaganda.

O peso ternário, aquele de três valores, custa exatamente 2 bits, não 1,58. O 1,58 é log₂(3), a conta teórica de três símbolos. Para chegar perto disso seria preciso empacotar cinco valores em oito bits, que é o que o formato TQ1_0 do llama.cpp faz. O formato I2_S da Microsoft não faz: guarda quatro pesos por byte, dois bits cada, e deixa o quarto estado sem uso.

E tem uma tabela enorme no começo do modelo, o vocabulário. Cada palavra possível vira um vetor. Essa tabela não é ternária. Come 55,3% do arquivo sozinha. O modelo usa o tokenizador do Llama 3, com 128.256 entradas. Em F16 isso são 656 MB que a técnica do 1 bit não tocou. Quanto menor o modelo ternário, maior a fatia do vocabulário, e é por isso que a economia prometida não escala para baixo.

Onde eu medi

Duas máquinas, de propósito com instruções vetoriais diferentes. Uma é o desktop onde eu trabalho. A outra é um mini PC que serve de servidor de infra aqui em casa e não tem GPU nenhuma, que é exatamente o cenário para o qual o BitNet foi desenhado.

As duas máquinas

titan
Intel i7-12700 · 8P+4E, 20 threads · AVX2 + AVX-VNNI, sem AVX-512 · 31 GiB · Ubuntu 24.04
aegis
AMD Ryzen 7 7840HS · 8 núcleos, 16 threads · AVX-512 completo, com VNNI · 58 GiB · openSUSE Leap 16
bitnet.cpp
commit 0b341e5, de 27/07/2026 · submódulo llama.cpp 390c3077
Compilador
clang 22.1.8 · cmake 4.4.2 (via micromamba, sem root)
Modelo
microsoft/BitNet-b1.58-2B-4T-gguf · ggml-model-i2_s.gguf
Método
llama-bench -p 128 -n 32 -r 3, varrendo 4/8/12/16/20 threads

Um detalhe de método que muda o resultado: não use o e2e_benchmark.py do repositório para medir prefill. Ele chama o llama-bench com -b 1, e com batch 1 o pedido de 512 tokens entra um token de cada vez. O prefill deixa de ser matriz por matriz e vira matriz por vetor, o que derruba o número para a velocidade de decode. Chamei o llama-bench direto, com o batch padrão.

Todos os formatos abaixo saíram do mesmo checkpoint. Converti os pesos bf16 oficiais para GGUF f16 e quantizei a partir dali. Arquitetura, tokenizador e valores dos pesos são idênticos em todas as linhas. A única coisa que muda é o kernel, o pedaço de código que faz a conta.

O ganho depende de contra quem você compara

titan · Intel i7-12700

Formato Arquivo Decode tok/s Prefill tok/s
I2_S (bitnet.cpp) 1,10 GiB 49,2 345,8
TQ2_0 (llama.cpp) 0,75 GiB 68,0 207,3
TQ1_0 (llama.cpp) 0,66 GiB 49,1 87,5
Q4_K_M 1,41 GiB 32,4 145,8
Q8_0 2,39 GiB 21,7 82,0
F16 5,11 GiB 11,6 101,1
Melhor resultado de cada formato na varredura de 4/8/12/20 threads, média de 3 repetições do llama-bench (pp128 e tg32). Mesmo checkpoint em todos os formatos. Governor em powersave, frequência não fixada — os números absolutos subiriam com o governor em performance, as razões entre eles não.

aegis · AMD Ryzen 7 7840HS

Formato Arquivo Decode tok/s Prefill tok/s
I2_S (bitnet.cpp) 1,10 GiB 43,8 580,1
TQ2_0 (llama.cpp) 0,75 GiB 64,0 244,7
TQ1_0 (llama.cpp) 0,66 GiB 65,2 118,9
Q4_K_M 1,41 GiB 34,9 259,8
Q8_0 2,39 GiB 21,4 180,7
F16 5,11 GiB 10,4 177,6
Mesmo método e mesmos arquivos da tabela anterior, varredura de 4/8/12/16 threads. O mini PC tem AVX-512 com VNNI; o desktop não tem AVX-512.

Olha a tabela e pergunta: o I2_S ganhou de quem?

Quanto o I2_S ganha, no decode, conforme o adversário

Comparação titan aegis
I2_S sobre F16 — o número do README 4,24x 4,20x
I2_S sobre Q4_K_M — o que você usa hoje 1,52x 1,26x
TQ2_0 sobre I2_S — llama.cpp contra o oficial 1,38x 1,46x
Razão entre os melhores resultados de decode de cada formato nas duas tabelas acima. Aritmética sobre as medições, não medição nova.

O 4,2x contra f16 é real e saiu praticamente igual nas duas máquinas. Não cheguei aos 6,17x, e nem esperava: aquele número saiu de modelos sintéticos, não treinados, num i7 de 13ª geração, e o próprio paper diz isso em letra miúda.

Olha a segunda linha. Contra o Q4_K_M, o arquivo que já está no seu disco, o ganho cai para algo entre 1,26x e 1,52x. É bom. Fica na faixa de ajuste, não de categoria. E vem com um preço que nenhum benchmark mostra, do qual eu falo mais abaixo.

O llama.cpp que você já tem ganha no chat

Quando o TQ2_0 ganhou do I2_S, refiz a medição para ter certeza.

O llama.cpp mainline tem tipos ternários próprios desde 2024, TQ1_0 e TQ2_0, que não têm nada a ver com o bitnet.cpp. Quantizei o mesmo checkpoint para TQ2_0 só por curiosidade, e ele decodificou 1,38x mais rápido que o I2_S na titan e 1,46x mais rápido no aegis. Num arquivo 32% menor, porque o TQ2_0 também comprime aquela tabela de vocabulário que o I2_S deixa em F16.

O bitnet.cpp não perde em tudo. No prefill ele é dominante, e por larga margem: 345 contra 207 tok/s na titan, 580 contra 244 no aegis. É o kernel dele processando o pedido, que é onde a multiplicação de matriz por matriz aparece e onde o truque do ternário rende mais.

Traduzindo: o kernel da Microsoft é melhor para engolir o pedido, o do llama.cpp é melhor para cuspir tokens. Se a sua carga é RAG, classificação, qualquer coisa que lê muito e escreve pouco, o bitnet.cpp compensa. Se é chat, o llama.cpp comum já te serve melhor, e você nem precisa clonar nada.

Vale olhar onde o mini PC ganhou do desktop. Os 580 tok/s de prefill do Ryzen contra os 345 do Intel não são relógio nem núcleo. São o AVX-512 com VNNI, um jeito do processador fazer a mesma conta em vários números ao mesmo tempo. O i7-12700 não tem, porque a Intel desligou isso em toda a 12ª geração. Para carga ternária em CPU, essa instrução vale mais que a marca no processador.

O modelo vem com a função errada

Aí a bancada saiu de velocidade e foi parar em qualidade.

Função de ativação é o tempero do meio da rede. O modelo aprendeu com um. O código aplica outro. A velocidade nem muda, porque as duas funções custam a mesma coisa para o processador. A qualidade muda, e muda feio.

O config.json do BitNet b1.58 2B declara "hidden_act": "relu2". O modelo foi treinado com ReLU ao quadrado no feed-forward, uma escolha incomum e deliberada. Só que o grafo do llama.cpp embutido no bitnet.cpp, no arquivo src/models/bitnet.cpp, linha 133, faz isto:

grep -n 'LLM_FFN_SILU' 3rdparty/llama.cpp/src/models/bitnet.cpp

133: LLM_FFN_SILU, LLM_FFN_PAR, il);

saída

SiLU, não relu². O grafo está aplicando uma função que o modelo não aprendeu.

Não quis acreditar em leitura de código, então medi. Rodei perplexidade em wikitext-2. Perplexidade é um jeito de medir se o modelo está “surpreso” com o texto: quanto menor o número, mais o texto parece ter sido treinado nele. 17 é o número da Microsoft. Rodei com o repositório como ele vem, troquei LLM_FFN_SILU por LLM_FFN_RELU_SQR, recompilei, e rodei de novo. Mesmo modelo, mesmo arquivo de texto, mesmos 100 blocos, mesma máquina.

Perplexidade em wikitext-2, uma linha de diferença

Build Perplexidade Desvio
Como o repositório vem hoje (SiLU) 98,80 ± 2,26
Trocando uma linha para relu² 17,85 ± 0,34
Referência publicada pela Microsoft 17,11 ± 0,13
llama-perplexity, wiki.test.raw do wikitext-2-raw, contexto 512, 8 threads, 100 blocos, na titan. As duas primeiras linhas são medição minha com binários recompilados a partir do mesmo fonte, trocando só a linha 133. A terceira é o valor publicado em src/README.md do repositório, medido sobre o arquivo inteiro.

O modelo fica 5,5 vezes pior do que deveria. E o valor corrigido bate com a referência da própria Microsoft, o que confirma que a única coisa errada era aquela linha.

Só que essa troca serve na minha bancada e não dá para mandar como patch. A classe llama_model_bitnet_b158, que é a do modelo da Microsoft, herda o grafo de llama_model_bitnet, que é a arquitetura antiga do 1bitLLM. E aquela usa SiLU de verdade. Trocar a linha conserta o modelo novo e quebra o velho, que passa a rodar com a ativação errada na direção contrária.

Um conserto que possa subir para o projeto precisa distinguir os dois: ou o b158 ganha grafo próprio, ou a ativação passa a ser lida do metadado do GGUF. É o desenho que um mantenedor do llama.cpp já pediu num dos pull requests fechados. Enquanto ninguém faz, quem clona escolhe qual dos dois modelos vai rodar errado.

Depois de medir, fui procurar. O problema já é conhecido: está na issue #602 desde 8 de agosto, e existem dois pull requests que o corrigem no llama.cpp mainline. Os dois foram fechados pelos próprios autores, sem serem rejeitados por ninguém. O bug continua no repositório.

O caminho documentado para gerar o f16 não roda

Para montar as tabelas eu precisava do mesmo modelo em f16. O repositório documenta como fazer. O conversor aceita --outtype f16. No clone de hoje, isso não termina.

O arquivo 3rdparty/llama.cpp/gguf-py/gguf/constants.py tem dois dicionários, um que dá nome às arquiteturas de modelo e outro que dá nome aos projetores de visão. As duas linhas que registram o BitNet foram coladas no segundo:

# 3rdparty/llama.cpp/gguf-py/gguf/constants.py, linhas 1131-1143
VISION_PROJECTOR_TYPE_NAMES: dict[VISION_PROJECTOR_TYPE, str] = {
    VISION_PROJECTOR_TYPE.MLP:       "mlp",
    # ...
    VISION_PROJECTOR_TYPE.STEP3VL:   "step3vl",
    MODEL_ARCH.BITNET:         "bitnet",     # <- estas duas
    MODEL_ARCH.BITNET_25:      "bitnet-25",  # <- estão no dicionário errado
}

O resultado é que MODEL_ARCH.BITNET_25 é a única arquitetura do projeto inteiro sem nome no dicionário certo, e o conversor morre com KeyError antes de escrever o primeiro tensor. Corrigi movendo a linha para o dicionário correto, e descobri a segunda camada: o nome "bitnet-25" não existe no runtime em C++, que só conhece "bitnet" e "bitnet-b1.58". O conversor produzia um arquivo que o próprio projeto não abre.

Conferi que os 332 tensores gerados são idênticos aos do GGUF oficial e apontei o BITNET_25 para "bitnet-b1.58". Com isso o conversor fechou o arquivo e o runtime abriu.

O efeito prático: o caminho documentado para gerar o baseline f16 está quebrado. A comparação de velocidade que o README usa para alegar 6,17x não é reproduzível por quem clona o repositório hoje. O único artefato que funciona é o GGUF pré-cozido que a Microsoft publica.

Se você quiser refazer isso

Deixei as três correções e o roteiro inteiro em dois forks, porque um patch que você não consegue aplicar não serve de nada:

  • jordaobass/llama.cpp — os três patches: a ativação, o registro de arquitetura e o src1_cont do macOS
  • jordaobass/BitNet — o submódulo já apontando para lá, mais o BANCADA.md com o passo a passo e os JSON brutos do llama-bench das duas máquinas

Clonar com --recursive e seguir o BANCADA.md deve reproduzir as tabelas acima, inclusive o passo do baseline f16 que quebra no repositório oficial. Nada ali precisa de root: o cmake e o clang vêm por micromamba, porque nenhuma das minhas duas máquinas tinha compilador instalado.

O que os 1,3x custam

Falta dizer o que você paga pelos 1,3x.

O BitNet b1.58 2B tem 4.096 tokens de contexto. Contexto é a memória de curto prazo: o arquivo que ele leu, a pergunta, a resposta anterior. Está no config.json, não é limitação do programa. ChatGPT, Grok e Claude Code trabalham com dezenas ou centenas de milhares. Em 2026, 4096 deixa o modelo de fora de praticamente qualquer coisa agêntica, RAG sério ou leitura de documento.

Na comparação de qualidade da própria Microsoft, o modelo tira 54,19 de média contra 55,23 do Qwen2.5-1.5B. Ele perde, na tabela feita por quem o construiu. Ganha em GSM8K e raciocínio comum, perde feio em MMLU e em código.

E o teto do ecossistema é esse mesmo. Não existe modelo ternário nativo pré-treinado acima de 3B: o maior é o Falcon-E-3B, e a Microsoft nunca liberou os pesos de 7B ou 70B que aparecem nos papers. Tudo que existe acima disso é conversão depois do treino, e as duas melhores receitas, a da Intel e a da Prism ML, não publicaram o código de conversão.

Tentei no Mac e não cheguei no número

Faltava a terceira arquitetura, então liberei disco no MacBook (M4 Pro, 10P+4E, macOS 26.5) e fui rodar a mesma bancada. Não cheguei aos tokens por segundo. O caminho até lá rendeu mais do que o número teria rendido.

O build padrão não passa em macOS. Duas paradas, em sequência. A primeira é o linker recusando a libggml-base.dylib, porque ela referencia quantize_i2_s e dequantize_row_i2_s, que estão definidas em ggml-cpu. No Linux o ELF tolera símbolo indefinido em biblioteca compartilhada e ninguém percebe; o Mach-O do Mac não tolera. Passa com -Wl,-undefined,dynamic_lookup.

A segunda é código que não compila. O bloco do I2_S em ggml-cpu.c lê src1_cont, que é declarada dentro de um #if GGML_USE_LLAMAFILE na linha 1361, e o bloco do I2_S está fora de qualquer guarda:

clang -c ggml-cpu.c

ggml-cpu.c:1495:40: error: use of undeclared identifier ‘src1_cont’

saída

Onde essa macro não fica definida, o projeto simplesmente não compila. É uma linha para consertar, e virou o terceiro patch do fork.

O kernel que a Microsoft otimizou para ARM também não compila. Isso é o mais estranho, porque TL1 é o caminho oficial em ARM e o vídeo de demonstração do próprio README é um Apple M2. Ligando BITNET_ARM_TL1=ON, o bitnet-lut-kernels.h pede tensor->backend e GGML_BACKEND_TYPE_CPU, duas coisas que o ggml removeu da API. O gerador de kernels ficou para trás do llama.cpp que o próprio projeto carrega como submódulo.

E com Metal no build, o I2_S morre calado. Na configuração padrão do macOS, que inclui Metal, carregar o GGUF oficial derruba o processo com SIGSEGV e nenhuma mensagem. Desligando Metal e Accelerate, ele passa a rodar.

Parei por um motivo chato. A máquina não estava em condição de medir: havia uma VM da Virtualization.framework consumindo dois núcleos, vários simuladores de iOS ligados e load average em 387. O prefill que saiu dali, 9,26 tok/s, não me permite separar fallback escalar de disputa por CPU. Número que eu não sei ler não entra em tabela. Fica para quando eu tiver a máquina quieta.

Número de tok/s no ARM eu não tenho para publicar. O que eu tenho é o estado do código em agosto de 2026: o bitnet.cpp não compila em macOS sem dois remendos, e o kernel que ele anuncia para ARM não compila de jeito nenhum.

O que eu faria

Se você tem GPU, nada disso importa e o post acaba aqui. ChatGPT, Grok e Claude Code continuam na nuvem; o Qwen nesta placa continua na placa. BitNet de 2B com 4096 de contexto não entra nessa conversa.

Se você quer rodar em CPU, eu não clonaria o bitnet.cpp. Pegaria o llama.cpp que você já tem, quantizaria para TQ2_0 e seguiria. Decodifica mais rápido, o arquivo é menor, roda em mais backend e a ativação vem certa. Só vale compilar o bitnet.cpp se a sua carga for quase toda prefill, e aí vale bastante.

Antes de acreditar em 6,17x, pergunte contra o quê. Se a resposta for f16 em CPU, refaça a conta com o Q4_K_M que está no disco.