---
slug: 52a-local-inference
title: Inferencia local y arquitecturas
description: El motor de inferencia propio de Synsema — elegilo con SYNSEMA_INFER_BACKEND, y sumá la arquitectura de un modelo escribiendo un archivo de texto .archdef que el binario ya compilado lee al arrancar, sin condicionales, sin bucles y sin forma de ejecutar código.
example_ids: []
---

# Inferencia local y arquitecturas

El proveedor LLM `local` ([Config de proveedor](/es/0.6.x/52-llm-provider-native)) y el backend `laya` del judge ([Judge](/es/0.6.x/54-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

```bash
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` |
|---|---|---|
| Arquitecturas | llama, qwen2, qwen3 — compiladas | llama, qwen2, qwen3, **gemma3** — archivos |
| Una arquitectura nueva | necesita un binario nuevo | necesita un **archivo de texto** |
| SIMD | se 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

```bash
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

- **El nombre del archivo es el nombre de la arquitectura.** `qwen3.archdef` tiene que declarar `arch qwen3`. No es burocracia: es lo único que se sabe de un archivo que ni siquiera parsea, y por lo tanto la única forma de decir *qué* arquitectura acaba de quedar rota.
- **`x` es el residual** — el registro que enlaza las capas.
- **`a | b`** significa "este tensor, o ese otro si el primero no está" (embeddings atados).
- **Lo que el GGUF ya declara no se repite.** `head_count`, `head_count_kv`, `embedding_length`, la ventana deslizante: todo sale de la metadata. Una línea `param` es sólo para lo que el archivo **no** dice — hoy la única en uso es el patrón de ventana de gemma3.
- **Los comentarios son `#`**; las líneas en blanco son libres.

### 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 **sí** 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](/es/0.6.x/20-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 pesos | `models_on_disk[].digest` — gratis, del store direccionado por contenido de Ollama |
| la arquitectura | `architectures[].sha256` y `architectures[].origin` |
| el motor | `backend` — candle y `rust` producen texto distinto con los mismos pesos |

El binario en sí se atestigua aparte: ver **[Atestación](/es/0.6.x/24-attestation)**.
