synsema
ES Español

Un modelo que ya tenés, y una arquitectura que podés editar

Desde la v0.6.27 el provider local embebido acepta el nombre de un modelo que ya está en tu caché de Ollama o de Hugging Face — no se descarga nada — y una arquitectura es un archivo de texto que lee el binario ya compilado, así que sumar un modelo dejó de esperar un release nuestro.

El provider local corre un modelo cuantizado dentro del proceso de Synsema: sin servidor, sin API key, sin socket — el único provider que funciona con la red denegada por completo. Eso ya existía. Lo que cambia en la v0.6.27 son las dos cosas que hacían incómodo empezar: había que conseguir un .gguf y darle una ruta, y un modelo cuya arquitectura no tuviéramos compilada quedaba directamente afuera.

El modelo es un nombre, y no se descarga nada§

SYNSEMA_LLM_PROVIDER=local

SYNSEMA_LLM_MODEL=/models/qwen2.5-3b-instruct-q4_k_m.gguf   # una ruta a un .gguf
SYNSEMA_LLM_MODEL=qwen3:0.6b                                # un model:tag que ya está en la caché de Ollama
SYNSEMA_LLM_MODEL=org/repo                                  # un repo que ya está en la caché de Hugging Face

Ninguna de las tres baja un byte. Si usás Ollama ya está — ollama no necesita ni estar corriendo, se leen sólo sus archivos en disco. Un nombre que no está lista los tres lugares donde buscó, con rutas reales, en vez de arrancar una descarga que nadie pidió.

synsema llm status dice qué hay de verdad en la máquina:

Modelos locales ya descargados (2):
  gemma3:270m                            ollama:gemma3:270m  sha256:735af2139dc6
  qwen3:0.6b                             ollama:qwen3:0.6b  sha256:7f4030143c1c
  Usá SYNSEMA_LLM_MODEL=<nombre> — no se descarga nada.

El sha256 sale gratis: el almacén de Ollama es direccionado por contenido, así que el hash de los pesos sale del nombre del archivo y no de una segunda lectura. Importa más adelante, cuando quieras decir qué pesos respondieron.

Las arquitecturas son un archivo, no un release§

Todo runtime que corre GGUF — llama.cpp, candle, Ollama — escribe cada arquitectura a mano, en su propio lenguaje. Sumar una significa código, un pull request, una review y un release. Nuestro propio PR a candle, de tres líneas y aprobado, quedó abierto más de dos meses, y con él dos arquitecturas cuantizadas que necesitábamos. Copiar ese modelo nos convertiría a nosotros en el cuello de botella de cualquiera que quiera correr un modelo al que no llegamos.

Así que en el motor escrito por nosotros una arquitectura es un archivo de texto que el binario ya compilado lee al arrancar. Las operaciones — matmul, RMSNorm, RoPE, atención, SwiGLU — están compiladas; lo que faltaba es el orden y los parámetros, y eso es data:

arch qwen3
kind decoder

prologue
  x = embed(token_embd.weight)

block
  h = rms_norm(x, blk.{i}.attn_norm.weight)
  q = matmul(h, blk.{i}.attn_q.weight)
  k = matmul(h, blk.{i}.attn_k.weight)
  v = matmul(h, blk.{i}.attn_v.weight)
  norm_heads(q, blk.{i}.attn_q_norm.weight, head_count)     # <- lo que qwen3 le agrega a llama
  norm_heads(k, blk.{i}.attn_k_norm.weight, head_count_kv)
  rope(q, head_count)
  rope(k, head_count_kv)
  a = attention(q, k, v)
  o = matmul(a, blk.{i}.attn_output.weight)
  add(x, o)

block corre una vez por capa con {i} reemplazado por el índice. Los tensores se llaman exactamente como los llama el GGUF, así que escribir una definición es sobre todo copiar la lista de tensores del archivo, y lo que el GGUF ya declara — cantidad de cabezas, largo del embedding, la ventana deslizante — no se repite. Cuatro definiciones vienen embebidas en el binario (llama, qwen2, qwen3, gemma3); SYNSEMA_INFER_ARCHDEF=<directorio> suma las tuyas, o reemplaza las nuestras, sin ningún compilador en el medio:

SYNSEMA_INFER_BACKEND=rust
SYNSEMA_INFER_ARCHDEF=./archdefs
Arquitecturas que corre el backend `rust` (4):
  gemma3 (20 pasos por capa, en el binario, sha 9e02d6e4ca46)
  llama (16 pasos por capa, en el binario, sha da3951d21c0d)
  qwen2 (19 pasos por capa, en el binario, sha d48ca19bf049)
  qwen3 (18 pasos por capa, ./archdefs/qwen3.archdef, sha 5b8d9db76a68)

Un archivo tuyo con el nombre de uno de los nuestros gana, y la línea lo muestra con su ruta y su sha, así que una sustitución nunca es silenciosa.

No hay control de flujo, y ahí está la propiedad de seguridad§

Una definición no tiene condicionales, ni loops, ni llamadas a funciones, ni forma de abrir un archivo, un socket o el entorno. Describe un grafo de multiplicaciones de matrices. Correr la definición que escribió otra persona no corre su código: lo peor que puede hacer una hostil es no cargar, o dar números mal con tus pesos, bajo los mismos límites de recursos que cualquier modelo. Eso es lo que hace razonable aceptar una definición de un desconocido, y es exactamente por qué los plugins nativos (.so/.dll) nunca fueron una opción acá: serían ejecución remota de código con pasos extra.

Un test lo hace cumplir: veintiuna palabras entre las que están if, while, for, exec, import, open, http y env se rechazan como operaciones que no existen. El día que se agregue control de flujo la propiedad se termina, así que no se va a agregar.

Y un archivo roto nunca cae a los nuestros. La primera versión sí caía, y con un silu mal tipeado el modelo respondía perfecto — usando nuestra definición. Quien escribió el archivo habría jurado que corría el suyo. Ahora esa arquitectura queda no disponible y el error nombra el archivo y la línea:

[local error: no se pudo cargar 'qwen3:0.6b': el modelo declara la arquitectura 'qwen3', que este
binario no conoce.
Conocidas: gemma3, llama, qwen2.
Definiciones que no cargaron:
  - ./archdefs/qwen3.archdef: línea 28: no existe la operación `siluu` — ¿quisiste decir `silu`?]

Dos motores, y por qué cambiarías§

candle (default)SYNSEMA_INFER_BACKEND=rust
Arquitecturasllama, qwen2, qwen3 — compiladaslas mismas más gemma3, cada una un archivo que podés leer y reemplazar
Una arquitectura nuevapide un binario nuevopide un archivo de texto
SIMDse elige al compilar: un build común corre el camino escalarse elige al correr — AVX, AVX2+FMA, AVX-512, NEON
RAM~2,6× el tamaño del .gguf~1,1× — el archivo se mapea y los pesos quedan cuantizados

La fila del SIMD es la que se ve como número en una máquina cualquiera. candle elige sus kernels AVX2 con #[cfg(target_feature)], y el target x86-64 por defecto no habilita AVX2, así que el binario que bajás corre el camino escalar salvo que se recompile para tu CPU — una recompilación que midió ~3,1× en prefill. El motor rust despacha según la CPU que encuentra, así que el binario oficial usa las instrucciones de la máquina donde cae.

La fila de la RAM es la que hace que un modelo más grande que tu memoria corra: con los pesos mapeados y cuantizados, un GGUF de 523 MB cuesta 583 MB residentes contra los 1.355 MB de candle. Pagina, va lento, termina. La generación sigue 1,37× más lenta que candle (11,1 s contra 8,1 s para 40 tokens, medido sobre el mismo archivo) porque token a token el matmul es una sola fila, y ahí lo que domina es mover memoria.

Cambiar de motor cambia el texto generado. Los dos son correctos — aproximan los mismos números de manera distinta — así que el motor es parte de lo que declarás para reproducir una salida, al lado del binario y de los pesos. candle sigue siendo el default mientras existan los dos.

Si corriste un GGUF de llama, Mistral o Gemma antes de la v0.6.27, sus respuestas estaban mal§

Éste es el arreglo que vale leer incluso si nada de lo anterior te interesa. Los GGUF cuyo tokenizer.ggml.model es llama — llama 1 y 2, Mistral, Gemma — guardan rangos de token, no log-probabilidades, y el tokenizador los segmentaba con un Viterbi que maximiza una suma de puntajes. The capital of France is entraba al modelo como once fragmentos en vez de cinco palabras. Nada fallaba. El modelo simplemente respondía mal, y no había manera de notarlo desde la salida.

Ahora es el algoritmo de referencia — fusionar el par vecino de mejor puntaje, como hace llama.cpp — con reconocimiento literal de los tokens especiales, fallback a bytes y add_space_prefix leído de los metadatos. La familia BPE (qwen, llama 3) nunca estuvo afectada.

Gemma 3 traía dos más: su activación en el MLP era silu y es gelu_pytorch_tanh (copiado del quantized_gemma3 de candle, que la hardcodea mientras su propio gemma3 sin cuantizar la lee de la config), y <start_of_turn> no se reconocía, así que un GGUF de gemma caía a modo plano y se portaba como un modelo base. Con los tres arreglados, el motor genera token por token lo que genera Ollama para el mismo prompt.

Lo que cuesta, en serio§

Está hecho para prompts cortos. El prefill en CPU es de ~12 tok/s, así que un prompt de 1000 tokens tarda ~90 s en un 0.5B; la generación es de ~11 tok/s en un 0.5B y ~5 tok/s en un 3B con cuatro hilos. La carga del modelo — 7 s para un 0.5B, ~35 s para un 3B — se paga una vez por proceso, así que bajo serve el primer request la paga y el resto la reusa (medido: 8,2 s → 1,3 s).

Dicho de otro modo: para un clasificador, un router, un reescritor, un resumen de tres párrafos, en una máquina sin GPU y sin salida a internet, es la herramienta correcta. Para un prompt de 4.000 tokens con una respuesta larga en CPU no lo es, y ninguna configuración lo va a volver.

Esto es todo junto en una laptop, con un modelo que ya estaba en la caché de Ollama:

$ SYNSEMA_LLM_PROVIDER=local SYNSEMA_LLM_MODEL=gemma3:270m SYNSEMA_INFER_BACKEND=rust synsema run probe.syn
1) Apple
2) Sweet
tokens: 35
provider vivo: true

5,4 s de reloj para dos llamadas reason, carga del modelo incluida. Nada descargado, ninguna clave en ningún lado.

Decir qué corrió§

Con SYNSEMA_LLM_TEMPERATURE=0 (el default) la generación es greedy y repetible. Para decir qué exactamente produjo una salida hacen falta tres cosas juntas, y synsema llm status --json las lleva bajo una clave inference, con los sha completos:

De dónde sale
los pesosmodels_on_disk[].digest
la arquitecturaarchitectures[].sha256 y .origin
el motorbackend — candle y rust producen texto distinto con los mismos pesos

La procedencia sólo sirve si se puede comparar, y comparar prosa no es comparar.

Quién elige§

El programa .syn nunca nombra un modelo, una arquitectura ni una ruta, y no puede hacer que el motor lea un archivo que la configuración no habilitó. Pide generar texto; todo lo de arriba es configuración de despliegue. Descubrir las cachés de Ollama y de Hugging Face le ofrece candidatos a quien escribe esa configuración — no le abre el disco al programa.

La página del manual es Inferencia local. El slot del juez recibió el mismo tratamiento en el mismo release — un checkpoint en disco respondiendo preguntas tipadas sin red y sin clave — y eso está en Correr el juez local con Laya.