Synsemadocsv0.6.xENES

LLM

Inferencia local y arquitecturas

El proveedor LLM local (Config de proveedor) y el backend laya del judge (Judge) corren sobre la capa de inferencia propia de Synsema. Esta página es sobre la parte que es configuración, no código: qué motor corre, qué arquitecturas conoce, y cómo sumar una sin recompilar nada.

Por qué acá una arquitectura es un archivo§

Todos los runtimes que corren modelos GGUF —llama.cpp, candle, Ollama— escriben cada arquitectura a mano, en su propio lenguaje. Sumar una exige escribir código, abrir un pull request, esperar la review y esperar el release. Eso es un cuello de botella humano, y no escala hacia abajo: cuanto más chico el equipo, más lento.

A Synsema le pegó desde afuera. Una función de tres líneas que aportamos a candle quedó aprobada y después estuvo abierta más de dos meses, y con ella dos arquitecturas cuantizadas que nuestros usuarios necesitaban. Construir la misma forma acá nos convertiría en nosotros el cuello de botella, y somos menos gente que ellos.

Así que en Synsema una arquitectura es un archivo de texto que el binario ya compilado lee al arrancar. Las operaciones (matmul, RMSNorm, RoPE, atención, SwiGLU, …) ya están compiladas adentro; lo que falta es el orden y los parámetros, y eso son datos. Un .archdef es al motor de inferencia lo que un .syn es al intérprete: el motor no se recompila para correr uno nuevo.

Nadie tiene que esperarnos.

Elegir el motor§

SYNSEMA_INFER_BACKEND=rust         # el motor propio — el que corre archivos .archdef
                                   # cualquier otra cosa, o nada: candle (el default)
SYNSEMA_INFER_ARCHDEF=./archdefs   # un directorio con archivos <arch>.archdef

Los dos resuelven environ del proceso > .env > default, como cualquier otro knob, y synsema init escribe los dos en el .env.example.

candle (default)SYNSEMA_INFER_BACKEND=rust
Arquitecturasllama, qwen2, qwen3 — compiladasllama, qwen2, qwen3, gemma3 — archivos
Una arquitectura nuevanecesita un binario nuevonecesita un archivo de texto
SIMDse elige al compilar (un build simple corre el camino escalar)se elige en runtime: AVX / AVX2+FMA / AVX-512 / NEON
RAM~2,6× lo que pesa el .gguf~1,1× — mapeado, y los pesos grandes quedan cuantizados

Cambiar de motor cambia el texto generado. Los dos son correctos; aproximan los mismos números de forma distinta (candle cuantiza la activación antes del matmul, nosotros multiplicamos en f32). Por eso el motor es parte de lo que hay que declarar para reproducir una salida, junto con el binario y los pesos. candle sigue siendo el default mientras los dos coexistan.

Las definiciones sólo las corre el motor rust. Con candle la lista está compilada y un .archdef se ignora — synsema llm status lo dice así, en vez de aparentar lo contrario.

Qué está en efecto ahora mismo§

synsema llm status          # motor, arquitecturas, origen y sha de cada una
synsema llm status --json   # lo mismo bajo "inference", con el sha COMPLETO
Arquitecturas que corre el backend `rust` (4):
  gemma3 (20 pasos por capa, en el binario, sha b935fcfab2d6)
  llama (16 pasos por capa, en el binario, sha a580fa45fdb6)
  qwen2 (19 pasos por capa, en el binario, sha fbba11641175)
  qwen3 (18 pasos por capa, ./archdefs/qwen3.archdef, sha 5b8d9db76a68)
  Definiciones del operador: ./archdefs

El sha es el sha256 del texto de la definición. Dos corridas con el mismo sha corrieron la misma arquitectura — por eso se imprime. Un archivo tuyo con el nombre de uno nuestro gana, y la línea lo muestra, así que la sustitución nunca es silenciosa.

El formato§

Tres secciones, en este orden. block corre una vez por capa, con {i} reemplazado por el índice de la capa. Los tensores se nombran igual que los nombra el GGUF, así que escribir una definición es sobre todo copiar la lista de tensores del archivo.

Éste es qwen3.archdef, el que viene en el binario, entero:

# qwen3 — llama con RMSNorm POR CABEZA en Q y K, antes de RoPE. Esa es toda la diferencia.
#
# El orden importa: normalizar después de RoPE da otro modelo, y uno que igual genera texto, así
# que el error no se nota hasta comparar contra el upstream.

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)
  # Lo propio de qwen3, y lo único.
  norm_heads(q, blk.{i}.attn_q_norm.weight, head_count)
  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)

  h = rms_norm(x, blk.{i}.ffn_norm.weight)
  g = matmul(h, blk.{i}.ffn_gate.weight)
  silu(g)
  u = matmul(h, blk.{i}.ffn_up.weight)
  mul(g, u)
  d = matmul(g, blk.{i}.ffn_down.weight)
  add(x, d)

epilogue
  x = rms_norm(x, output_norm.weight)
  x = last(x)
  logits = matmul(x, output.weight | token_embd.weight)

El diff entre dos definiciones es lo que diferencia a las dos arquitecturas — no hay banderas ni un if arch == … del otro lado de un archivo. qwen2 simplemente tiene tres líneas de sesgo que llama no tiene.

Reglas§

Las operaciones§

embed · rms_norm · matmul · add_bias · norm_heads · rope · attention · silu · gelu · gelu_tanh · relu · mul · add · scale · copy · last

dst = op(src, …) asigna a un registro; op(dst, …) modifica uno en el lugar. Ésa es toda la gramática.

gelu y gelu_tanh no son la misma función. gelu_tanh es la aproximación por tanh (gelu_pytorch_tanh); difieren en ~1e-3, y un modelo corrido con la que no es deriva en vez de fallar. Gemma 3 quiere gelu_tanh — equivocarse en esto costó una investigación real, porque la salida sigue siendo legible y lo único que se mueve son los números (logit top 7,91 contra 27,02).

Sin control de flujo, y ésa es la propiedad de seguridad§

Una definición no tiene condicionales, ni bucles, ni llamadas a funciones, ni forma de abrir un archivo, un socket o el entorno. Describe un grafo de multiplicaciones de matrices.

Eso es lo que hace aceptable usar una definición escrita por alguien que no conocés. Correr una no ejecuta su código: lo peor que puede hacer un archivo malicioso es no cargar, o dar números equivocados con tus pesos, acotado por el mismo límite de recursos que cualquier modelo. Bajar un .archdef no es como instalar un plugin. La alternativa obvia —plugins nativos en .so/.dll— sería ejecución remota de código con pasos extra, y está descartada.

Hay un test que lo vigila: veintiuna palabras entre las que están if, while, loop, for, exec, import, open, http y env se rechazan como operaciones que no existen. El día que se le agregue control de flujo, la propiedad se pierde. No se va a agregar.

Escribir una§

1. Leé general.architecture y los nombres de los tensores de tu GGUF. 2. Copiá la definición nuestra más parecida (llama es la más simple) y guardala como <arch>.archdef. 3. Cambiá la línea arch para que coincida con el nombre del archivo, y ajustá el bloque a los tensores que tu archivo de verdad tiene. 4. Ponela en un directorio; apuntá SYNSEMA_INFER_ARCHDEF ahí y poné SYNSEMA_INFER_BACKEND=rust. 5. synsema llm status — tu definición tiene que aparecer, con su ruta y su sha. 6. Corré un prompt y compará contra ollama run <modelo> con el mismo prompt. Los tokens tienen que coincidir. Si no coinciden, el orden de los pasos está mal muchísimo más seguido que la matemática.

Ese último paso es el que hay que tomarse en serio. Un oráculo prueba que dos implementaciones coinciden, no que acierten: nosotros validamos gemma3 contra candle, dio verde, y candle tenía el mismo bug que nosotros. Compará contra lo que corre el resto del mundo.

Cuándo una definición no alcanza§

El formato cubre el ~80% de las arquitecturas que son remixes de bloques conocidos. No aspira al 100%, y pretenderlo lo convertiría en un lenguaje de programación mal diseñado. Una arquitectura con matemática genuinamente nueva necesita Rust, y está bien — lo que cambió es quién puede sumar un modelo, no que cualquiera pueda sumar cualquier modelo.

Los encoders (ModernBERT, Laya) también están escritos a mano a propósito: atención bidireccional, dos bases de RoPE alternadas y cabezas de decisión no comparten casi nada con un decoder, y forzarlos al mismo vocabulario habría dado un formato peor para los dos.

Errores§

Una definición es dato ajeno, igual que un .gguf, así que se la exige lo mismo que a synsema check: fallar temprano, nombrar la línea, decir el arreglo. Nunca un panic a mitad de un forward, treinta segundos después.

⚠ no cargó — ./archdefs/qwen3.archdef: línea 28: no existe la operación `siluu` — ¿quisiste decir `silu`?

Un archivo roto deja esa arquitectura no disponible — nunca cae de vuelta a la nuestra. Esto importa más de lo que suena. La primera versión caía de vuelta, y una prueba en vivo mostró por qué está mal: con un typo en silu, el modelo respondía perfecto, usando nuestra definición. El operador habría jurado que su archivo estaba corriendo. Ahora el modelo se niega a cargar, y el error nombra el archivo:

[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`?]

Quién elige§

El operador elige el motor, el modelo y el directorio de definiciones — nunca el programa .syn. Un programa pide generar texto; no puede nombrar un modelo, una arquitectura ni una ruta, y no puede hacer que el motor lea un archivo que el operador no habilitó. Descubrir caches le ofrece candidatos a quien escribe la configuración; no le abre el disco al programa. La misma regla que con los secretos y los hosts: ver Capabilities.

Determinismo y procedencia§

Con SYNSEMA_LLM_TEMPERATURE=0 (el default) la generación es greedy y repetible. Para poder decir qué corrió exactamente hacen falta tres cosas juntas — y las tres están en synsema llm status --json:

De dónde sale
los pesosmodels_on_disk[].digest — gratis, del store direccionado por contenido de Ollama
la arquitecturaarchitectures[].sha256 y architectures[].origin
el motorbackend — candle y rust producen texto distinto con los mismos pesos

El binario en sí se atestigua aparte: ver Atestación.