Ejecuté el BitNet de Microsoft en dos CPUs: el 6x se encoge contra el Q4 que ya usas
Cloné el bitnet.cpp de Microsoft. El README promete 6,17x en CPU. En las dos máquinas de casa el 4,2x frente a f16 apareció; frente al Q4 que Ollama ya entrega, cae a 1,3x. Y el modelo viene con la activación equivocada.
¿Con prisa? Pide el TL;DR a Claude — lee la página y la resume.
es Traducción automática por grok-4.6, revisada por el autor. Leer el original en portugués
Si alguna vez pediste un trozo de código a ChatGPT, a Grok o a Claude Code, la cuenta es la misma. El modelo responde rápido porque está en una GPU que no es tuya, en un edificio que no es el tuyo. El texto sale de casa y vuelve listo.
Hay gente intentando invertir esa cuenta: correr el modelo en el procesador del escritorio, sin placa de vídeo, sin mandar el archivo a Estados Unidos. Microsoft tiene un proyecto para eso. Se llama bitnet.cpp. Cuarenta mil estrellas en GitHub, un paper en arXiv y un número en lo alto del README: hasta 6,17x más rápido en CPUs x86.
Seis veces. No un veinte por ciento. Seis.
La pregunta que casi nadie hace, y que el propio paper responde si abres la sección 4.1.2, es: ¿seis veces más rápido que qué?
Lo cloné, lo compilé y lo medí en dos máquinas de esta casa. El 6,17x no apareció. Apareció un 4,2x contra un rival que nadie usa, un 1,3x contra el archivo que ya está en tu disco, y un modelo estrella ejecutándose con la función de activación equivocada desde el día en que bajé el repositorio.
Antes, qué es cada pieza
Cinco nombres que van a aparecer el post entero, sin misterio.
BitNet es la idea. Los “pesos” del modelo, los números que aprendió, dejan de ser números fraccionarios y pasan a ser tres valores: -1, 0 y +1. Sin número fraccionario, multiplicar se convierte en sumar y restar. La cuenta de energía queda bonita. La conclusión que la gente saca es que un modelo grande pasa a caber en una máquina modesta.
bitnet.cpp es el programa de Microsoft que pone esa idea a correr. Es lo que el README está vendiendo.
llama.cpp es el motor que casi todo el mundo ya tiene. Ollama, por debajo, es llama.cpp. Si corres un modelo local en esta casa o en la tuya, es por aquí.
Q4_K_M es la compresión que Ollama entrega por defecto. Cuatro bits por peso, más o menos. Es el archivo que ya está en el disco de quien corre modelos en local.
F16 es el modelo sin comprimir. Cinco gigas para un modelo de 2 mil millones de parámetros. Nadie corre eso en CPU. Es el rival que Microsoft eligió para el 6,17x.
Hay dos relojes, y la gente los mezcla.
Prefill es el modelo leyendo tu petición. Decode es él escribiendo la respuesta, trocito a trocito. El famoso tok/s del YouTube de hardware casi siempre es decode. El chat es casi todo decode. El RAG, eso de pegar un documento y preguntar, es casi todo prefill. El número que decide si vale la pena clonar bitnet.cpp es el otro.
El 6,17x se midió contra un rival que nadie usa
Antes de mostrar lo que medí, vale la pena abrir el número de Microsoft. La respuesta está en su propio paper.
La comparación de bitnet.cpp es contra llama.cpp en Float16. Está en la sección 4.1.2 de arXiv 2502.11880: “we chose llama.cpp Float16 as the baseline”.
O sea: el baseline es un modelo de 2 mil millones de parámetros en precisión completa, ocupando 5 GB, corriendo en CPU. Nadie hace eso. La gente corre Q4_K_M. La pregunta que importa no es “cuánto gana frente a f16”. Es “cuánto gana frente al archivo que ya está en el disco”.
El archivo de 1 bit no tiene 1 bit
El primer susto llegó antes de cualquier cronómetro, solo de mirar el tamaño del archivo.
El GGUF oficial, el paquete que bajas para ejecutarlo, tiene 1.187.801.280 bytes para 2,41 mil millones de parámetros. Eso da 3,94 bits por peso, no 1,58. Abrí los tensores, los bloques de números dentro del archivo, para entender adónde se fue el dinero.
Anatomía del ggml-model-i2_s.gguf
| Parte | Parámetros | Bytes | Bits por peso |
|---|---|---|---|
| Pesos ternarios (I2_S) | 2.084.044.800 | 521.017.920 | 2,000 |
| Tabla de embeddings (F16) | 328.335.360 | 656.670.720 | 16,000 |
| Normalizaciones (F32) | 440.320 | 1.761.280 | 32,000 |
| Archivo completo | 2.412.820.480 | 1.187.801.280 | 3,938 |
De ahí salen dos cosas, y las dos contradicen la propaganda.
El peso ternario, el de tres valores, cuesta exactamente 2 bits, no 1,58. El 1,58 es log₂(3), la cuenta teórica de tres símbolos. Para acercarse habría que empaquetar cinco valores en ocho bits, que es lo que hace el formato TQ1_0 de llama.cpp. El formato I2_S de Microsoft no lo hace: guarda cuatro pesos por byte, dos bits cada uno, y deja el cuarto estado sin usar.
Y hay una tabla enorme al principio del modelo, el vocabulario. Cada palabra posible se convierte en un vector. Esa tabla no es ternaria. Se come el 55,3% del archivo ella sola. El modelo usa el tokenizador de Llama 3, con 128.256 entradas. En F16 eso son 656 MB que la técnica del 1 bit no tocó. Cuanto menor es el modelo ternario, mayor es la porción del vocabulario, y por eso el ahorro prometido no escala hacia abajo.
Dónde medí
Dos máquinas, a propósito con instrucciones vectoriales diferentes. Una es el equipo de escritorio donde trabajo. La otra es un mini PC que sirve de servidor de infraestructura en casa y no tiene GPU alguna, que es exactamente el escenario para el que se diseñó BitNet.
Las dos máquinas
- titan
- Intel i7-12700 · 8P+4E, 20 hilos · AVX2 + AVX-VNNI, sin AVX-512 · 31 GiB · Ubuntu 24.04
- aegis
- AMD Ryzen 7 7840HS · 8 núcleos, 16 hilos · AVX-512 completo, con VNNI · 58 GiB · openSUSE Leap 16
- bitnet.cpp
- commit 0b341e5, del 27/07/2026 · submódulo llama.cpp 390c3077
- Compilador
- clang 22.1.8 · cmake 4.4.2 (vía micromamba, sin root)
- Modelo
- microsoft/BitNet-b1.58-2B-4T-gguf · ggml-model-i2_s.gguf
- Método
- llama-bench -p 128 -n 32 -r 3, barriendo 4/8/12/16/20 hilos
Un detalle de método que cambia el resultado: no uses el e2e_benchmark.py del repositorio para medir prefill. Llama a llama-bench con -b 1, y con batch 1 la petición de 512 tokens entra de uno en uno. El prefill deja de ser multiplicación matriz por matriz y pasa a ser matriz por vector, lo que derrumba el número hasta la velocidad de decodificación. Llamé a llama-bench directamente, con el batch por defecto.
Todos los formatos de abajo salieron del mismo checkpoint. Convertí los pesos bf16 oficiales a GGUF f16 y cuanticé desde ahí. Arquitectura, tokenizador y valores de los pesos son idénticos en todas las filas. Lo único que cambia es el kernel, el trozo de código que hace la cuenta.
La ganancia depende de contra quién compares
titan · Intel i7-12700
| Formato | Archivo | 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 |
aegis · AMD Ryzen 7 7840HS
| Formato | Archivo | 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 |
Mira la tabla y pregunta: ¿el I2_S le ganó a quién?
Cuánto gana el I2_S, en decode, según el adversario
| Comparación | titan | aegis |
|---|---|---|
| I2_S sobre F16 — el número del README | 4,24x | 4,20x |
| I2_S sobre Q4_K_M — lo que usas hoy | 1,52x | 1,26x |
| TQ2_0 sobre I2_S — llama.cpp contra el oficial | 1,38x | 1,46x |
El 4,2x contra f16 es real y apareció prácticamente igual en las dos máquinas. No llegué a los 6,17x, y tampoco lo esperaba: aquel número salió de modelos sintéticos, sin entrenar, en un i7 de 13ª generación, y el propio paper lo dice en letra pequeña.
Mira la segunda fila. Frente al Q4_K_M, el archivo que ya está en tu disco, la ganancia cae a algo entre 1,26x y 1,52x. Está bien. Queda en el terreno del ajuste, lejos de un cambio de categoría. Y viene con un precio que no aparece en ningún benchmark, del que hablo más abajo.
El llama.cpp que ya tienes gana en el chat
Cuando el TQ2_0 le ganó al I2_S, repetí la medición para estar seguro.
El llama.cpp mainline tiene tipos ternarios propios desde 2024, TQ1_0 y TQ2_0, que no tienen nada que ver con bitnet.cpp. Cuanticé el mismo checkpoint a TQ2_0 solo por curiosidad, y decodificó 1,38x más rápido que el I2_S en titan y 1,46x más rápido en aegis. En un archivo 32% menor, porque TQ2_0 también comprime esa tabla de vocabulario que I2_S deja en F16.
bitnet.cpp no pierde en todo. En prefill es dominante, y por amplio margen: 345 contra 207 tok/s en titan, 580 contra 244 en aegis. Es su kernel procesando la petición, que es donde aparece la multiplicación matriz por matriz y donde el truco del ternario rinde más.
Traduciendo: el kernel de Microsoft es mejor para tragar la petición, el de llama.cpp es mejor para escupir tokens. Si tu carga es RAG, clasificación, cualquier cosa que lee mucho y escribe poco, bitnet.cpp compensa. Si es chat, el llama.cpp común ya te sirve mejor, y ni siquiera necesitas clonar nada.
Conviene notar dónde el mini PC le ganó al equipo de escritorio. Los 580 tok/s de prefill del Ryzen contra los 345 del Intel no son cuestión de reloj ni de núcleos. Es el AVX-512 con VNNI, una forma de que el procesador haga la misma cuenta en varios números a la vez. El i7-12700 no lo tiene porque Intel lo deshabilitó en toda la 12ª generación. Para carga ternaria en CPU, esa instrucción vale más que la marca del procesador.
El modelo viene con la función equivocada
Aquí el banco de pruebas se convirtió en otra cosa.
La función de activación es el condimento en el medio de la red. El modelo aprendió con uno. El código aplica otro. La velocidad ni siquiera cambia, porque las dos funciones cuestan lo mismo al procesador. La calidad cambia, y cambia feo.
El config.json de BitNet b1.58 2B declara "hidden_act": "relu2". El modelo fue entrenado con ReLU al cuadrado en el feed-forward, una elección poco común y deliberada. Solo que el grafo de llama.cpp incrustado en bitnet.cpp, en el archivo src/models/bitnet.cpp, línea 133, hace esto:
grep -n 'LLM_FFN_SILU' 3rdparty/llama.cpp/src/models/bitnet.cpp 133: LLM_FFN_SILU, LLM_FFN_PAR, il);
SiLU, no relu². El grafo está aplicando una función que el modelo no aprendió.
No quise fiarme de una lectura de código, así que medí. Ejecuté perplejidad en wikitext-2. La perplejidad es una forma de medir si el modelo está “sorprendido” con el texto: cuanto menor el número, más el texto parece haber sido entrenado en él. 17 es el número de Microsoft. Ejecuté con el repositorio tal como viene, cambié LLM_FFN_SILU por LLM_FFN_RELU_SQR, recompilé y ejecuté de nuevo. Mismo modelo, mismo archivo de texto, mismos 100 bloques, misma máquina.
Perplejidad en wikitext-2, una línea de diferencia
| Build | Perplejidad | Desviación |
|---|---|---|
| Como viene el repositorio hoy (SiLU) | 98,80 | ± 2,26 |
| Cambiando una línea a relu² | 17,85 | ± 0,34 |
| Referencia publicada por Microsoft | 17,11 | ± 0,13 |
El modelo queda 5,5 veces peor de lo que debería. Y el valor corregido coincide con la referencia de la propia Microsoft, lo que confirma que lo único equivocado era efectivamente esa línea.
Solo que ese cambio de una línea sirve para mi banco de pruebas y no sirve como corrección de verdad. La clase llama_model_bitnet_b158, que es la del modelo de Microsoft, hereda el grafo de llama_model_bitnet, que es la arquitectura antigua de 1bitLLM. Y aquella sí usa SiLU de verdad. Cambiar la línea arregla el modelo nuevo y rompe el viejo, que pasa a ejecutarse con la activación equivocada en la dirección contraria.
Un arreglo que pueda subir al proyecto tiene que distinguir las dos: o b158 recibe grafo propio, o la activación pasa a leerse desde el metadato del GGUF. Es el diseño que un mantenedor de llama.cpp ya pidió en uno de los pull requests cerrados. Mientras nadie lo hace, quien clona elige cuál de los dos modelos va a ejecutarse mal.
Después de medir me puse a buscar. El problema ya se conoce: está registrado como issue #602 desde el 8 de agosto, y hay dos pull requests que lo corrigen en llama.cpp mainline. Los dos fueron cerrados por sus propios autores, sin que nadie los rechazara. El bug sigue ahí.
El camino documentado para generar el f16 no funciona
Para armar las tablas de arriba necesitaba el mismo modelo en f16. El repositorio documenta cómo hacerlo. El conversor acepta --outtype f16. En el clon de hoy, eso no termina.
El archivo 3rdparty/llama.cpp/gguf-py/gguf/constants.py tiene dos diccionarios, uno que nombra las arquitecturas de modelo y otro que nombra los proyectores de visión. Las dos líneas que registran BitNet fueron pegadas en el segundo:
# 3rdparty/llama.cpp/gguf-py/gguf/constants.py, líneas 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 dos
MODEL_ARCH.BITNET_25: "bitnet-25", # <- están en el diccionario equivocado
}
El resultado es que MODEL_ARCH.BITNET_25 es la única arquitectura de todo el proyecto sin nombre en el diccionario correcto, y el conversor muere con KeyError antes de escribir el primer tensor. Lo arreglé moviendo la línea al diccionario correcto, y ahí descubrí la segunda capa: el nombre "bitnet-25" no existe en el runtime en C++, que solo conoce "bitnet" y "bitnet-b1.58". El conversor producía un archivo que el propio proyecto no puede abrir.
Verifiqué que los 332 tensores generados son idénticos a los del GGUF oficial y apunté BITNET_25 a "bitnet-b1.58". Ahí el conversor cerró el archivo y el runtime lo abrió.
El efecto práctico: el camino documentado para generar el baseline f16 está roto. La comparación de velocidad que el README usa para afirmar 6,17x no es reproducible por quien clona el repositorio hoy. El único artefacto que funciona es el GGUF precocinado que publica Microsoft.
Si quieres rehacer esto
Dejé las tres correcciones y el guion completo en dos forks, porque un parche que no puedes aplicar no sirve de nada:
- jordaobass/llama.cpp — los tres parches: la activación, el registro de arquitectura y el
src1_contde macOS - jordaobass/BitNet — el submódulo ya apuntando allí, más el
BANCADA.mdcon el paso a paso y los JSON crudos dellama-benchde las dos máquinas
Clonar con --recursive y seguir el BANCADA.md debería reproducir las tablas de arriba, incluido el paso del baseline f16 que se rompe en el repositorio oficial. Nada de eso necesita root: cmake y clang vienen por micromamba, porque ninguna de mis dos máquinas tenía compilador instalado.
Lo que cuestan los 1,3x
Falta decir lo que pagas por el 1,3x.
BitNet b1.58 2B tiene 4.096 tokens de contexto. Contexto es la memoria a corto plazo: el archivo que leyó, la pregunta, la respuesta anterior. Está en el config.json, no es una limitación del programa. ChatGPT, Grok y Claude Code trabajan con decenas o cientos de miles. En 2026, 4096 deja al modelo fuera de prácticamente cualquier cosa agéntica, cualquier RAG serio o la lectura de un documento.
En la comparación de calidad de la propia Microsoft, el modelo saca 54,19 de promedio contra 55,23 de Qwen2.5-1.5B. Pierde, en la tabla hecha por quienes lo construyeron. Gana en GSM8K y razonamiento común, y pierde claramente en MMLU y en código.
Y ese es el techo del ecosistema. No existe modelo ternario nativo preentrenado por encima de 3B: el mayor es Falcon-E-3B, y Microsoft nunca liberó los pesos de 7B o 70B que aparecen en los papers. Todo lo que hay por encima es conversión después del entrenamiento, y las dos mejores recetas, la de Intel y la de Prism ML, no publicaron su código de conversión.
Lo intenté en el Mac y no llegué al número
Faltaba la tercera arquitectura, así que liberé disco en el MacBook (M4 Pro, 10P+4E, macOS 26.5) y fui a ejecutar el mismo banco de pruebas. No llegué a los tokens por segundo. El camino hasta allí dio más que el número.
El build por defecto no pasa en macOS. Dos paradas, en secuencia. La primera es el linker rechazando libggml-base.dylib, porque referencia quantize_i2_s y dequantize_row_i2_s, que están definidas en ggml-cpu. En Linux, ELF tolera símbolos indefinidos en una biblioteca compartida y nadie se da cuenta; el Mach-O de Mac no lo tolera. Pasa con -Wl,-undefined,dynamic_lookup.
La segunda es código que no compila. El bloque de I2_S en ggml-cpu.c lee src1_cont, que se declara dentro de un #if GGML_USE_LLAMAFILE en la línea 1361, y el bloque de I2_S está fuera de cualquier guarda:
clang -c ggml-cpu.c ggml-cpu.c:1495:40: error: use of undeclared identifier ‘src1_cont’
Donde esa macro no queda definida, el proyecto simplemente no compila. Es un arreglo de una línea, y se convirtió en el tercer parche del fork.
El kernel que Microsoft optimizó para ARM tampoco compila. Esto es lo más extraño, porque TL1 es el camino oficial en ARM y el vídeo de demostración del propio README es un Apple M2. Activando BITNET_ARM_TL1=ON, bitnet-lut-kernels.h pide tensor->backend y GGML_BACKEND_TYPE_CPU, dos cosas que ggml eliminó de la API. El generador de kernels se quedó atrás del mismo llama.cpp que el proyecto lleva como submódulo.
Y con Metal en el build, I2_S muere callado. En la configuración por defecto de macOS, que incluye Metal, cargar el GGUF oficial tumba el proceso con SIGSEGV y ningún mensaje. Apagando Metal y Accelerate, empieza a funcionar.
Ahí me detengo, por un motivo aburrido. La máquina no estaba en condiciones de ser medida: había una VM de Virtualization.framework comiéndose dos núcleos, varios simuladores de iOS encendidos y el load average en 387. El prefill que salió de ahí, 9,26 tok/s, no me permite separar un fallback escalar de la disputa por CPU. Un número que no sé leer no entra en una tabla. Eso espera hasta que tenga la máquina tranquila.
Número de tok/s en ARM no tengo para publicar. Lo que tengo es el estado del código en agosto de 2026: bitnet.cpp no compila en macOS sin dos remiendos, y el kernel que anuncia para ARM no compila en absoluto.
Qué haría yo
Si tienes GPU, nada de esto importa y el post termina aquí. ChatGPT, Grok y Claude Code siguen en la nube; el Qwen en esta placa sigue en la placa. Un BitNet de 2B con 4096 de contexto no entra en esa conversación.
Si quieres ejecutar en CPU, yo no clonaría bitnet.cpp. Tomaría el llama.cpp que ya tienes, cuanticaría a TQ2_0 y seguiría. Decodifica más rápido, el archivo es menor, corre en más backends y la activación viene bien. Compilar bitnet.cpp solo vale la pena si tu carga es casi toda prefill, y entonces vale bastante.
Antes de creer en 6,17x, pregunta contra qué. Si la respuesta es f16 en CPU, rehace la cuenta con el Q4_K_M que está en el disco.