# Model zoo: variantes adaptadas y selección device-aware > El catálogo vive en [`examples/model-zoo.json`](../../examples/model-zoo.json) y es el > **contrato** entre los dos planos: Rust (`mesh_scheduler::ModelZoo`) y Python > (`hecate.model_zoo.ModelZoo`) leen el mismo fichero. Un test de cada lado lo verifica. ## 1. Qué es una variante Un modelo no se despliega: se despliega **una variante**. La diferencia es lo que permite que un portátil de 2012 y una workstation con dos RTX 3080 sirvan la misma tarea: ```text tarea: chat ├── ncm-chat-fp16 16 bits 7.00 GB precisión 0.912 180 ms (referencia) ├── ncm-chat-int8 8 bits 3.50 GB precisión 0.905 95 ms ├── ncm-chat-int4 4 bits 1.75 GB precisión 0.884 61 ms └── ncm-chat-ternary 1.58 b 0.30 GB precisión 0.798 44 ms ``` `resident_gb` no es un campo: se calcula (`params_millions × bytes_por_peso`). Así una variante no puede declarar 0.3 GB y ocupar 7 (un test lo comprueba: la validación rechaza cualquier variante cuyos mínimos declarados no cubran su huella). Este planteamiento sigue la línea de **LMEdge** (predecir precisión, latencia y recursos de cada combinación modelo-cuantización) y de **JUGGLER** (enrutamiento *task/model/device-aware* sobre perfiles de dispositivo). HECATE se queda con la parte de decisión; la predicción de precisión no la hace, la recibe del catálogo. ## 2. Las tres comprobaciones duras Antes de puntuar nada, una variante tiene que pasar tres filtros. Si no, se descarta con el motivo escrito: | Filtro | Condición | Motivo del descarte | | --- | --- | --- | | Memoria | `resident_gb ≤ usable_memory_gb × 0.9` y `min_vram_gb ≤ VRAM` si la declara | `InsufficientMemory` | | Latencia | `predicted ≤ max_latency_ms` si la tarea lo impone | `OverLatencyBudget` | | Precisión | `expected_accuracy ≥ min_accuracy` si la tarea lo impone | `BelowAccuracyFloor` | El 10 % de margen no es pesimismo: un nodo cuya memoria útil es exactamente la huella del modelo **no** puede ejecutarlo, porque el runtime, el contexto y la fragmentación también ocupan. Los descartes se devuelven en la decisión (`Selection::rejected`), no se tiran. Cuando un nodo no puede servir una tarea, la respuesta incluye por qué, variante a variante: ```text ninguna variante sirve la tarea chat en este nodo: ncm-chat-fp16: no cabe: 7.00 GB de pesos frente a 1.20 GB disponibles; ncm-chat-int8: no cabe: 3.50 GB de pesos frente a 1.20 GB disponibles; ncm-chat-int4: no cabe: 1.75 GB de pesos frente a 1.20 GB disponibles; ncm-chat-ternary: excede latencia: 51.2 ms predichos frente a 10.0 ms de presupuesto ``` ## 3. La banda de calidad (y el error que evita) El objetivo `Balanced` no compara todas las variantes entre sí. Primero descarta las que pierden más de un 5 % de precisión **relativa** frente a la mejor viable (`MAX_ACCURACY_LOSS`), y sólo entonces puntúa. Sin esa banda el criterio se rompe, y se rompe de una forma concreta: la normalización min-max da a la variante más pequeña un `1.0` en latencia y otro `1.0` en energía. Con un peso del 55 % entre ambas, **la más barata gana siempre**, en todos los nodos. El resultado sería servir 0.798 de precisión en un nodo que podía dar 0.905, y —peor— los tres nodos del parque recibirían la misma variante, que es justo lo que este rediseño quiere evitar. Con la banda aplicada sobre la topología de ejemplo: | Nodo | VRAM | Variante elegida (equilibrado) | Motivo | | --- | --- | --- | --- | | Geekom (2× 3080) | 24 GB | `ncm-chat-int8` | 0.905 de precisión a la mitad de huella que fp16 | | ThinkPad T14 | 6 GB | `ncm-chat-int4` | fp16 no cabe; int8 cabe pero int4 aprovecha mejor el nodo | | Portátil antiguo | 0 GB | `ncm-chat-ternary` | única que cabe en 1.2 GB libres | Quien quiera sacrificar más precisión lo pide explícitamente con `Objective::Latency` (`--objective latency`) y, si hace falta, un mínimo con `--min-accuracy`. ## 4. Predicción de latencia, y por qué no es un roofline La latencia del catálogo está medida en un **dispositivo de referencia** (40 GFLOPS, 20 GB/s). Para otro nodo se reescala por la restricción dominante: ```text ratio_cómputo = gflops_efectivos / gflops_referencia ratio_memoria = GB/s_efectivos / GB/s_referencia speed = min(ratio_cómputo, ratio_memoria) latencia = latencia_referencia / speed · (tokens/128) · (1 + contención) ``` **Por qué no el roofline clásico.** La primera versión calculaba `max(FLOPs/GFLOPS_medidos, bytes/GB_s_medidos)`. Con los GFLOPS que mide el perfilador (un matmul escalar sin SIMD) predecía **1.6 segundos** para una variante de 3.5 B parámetros en un nodo con acelerador, porque un motor de inferencia real es uno o dos órdenes de magnitud más rápido que ese bucle. Anclar a la latencia medida en el catálogo y escalar por capacidades relativas evita tener que inventar una constante de "aceleración del runtime". `gflops_efectivos` y `GB/s_efectivos` son las medidas del nodo multiplicadas por su ratio de aceleración (`1 + 11·VRAM/(VRAM+8)`, que satura). Ese multiplicador es una **aproximación declarada**: el perfil no transporta el ancho de banda de la GPU. Es el sitio exacto donde hay que mejorar cuando se mida. La predicción se multiplica por `1 + current_load`: un nodo cargado servirá más lento, y fingir que está vacío produciría decisiones optimistas que se pagan en la cola. ## 5. Carga de la variante con cuantización real `python/hecate/model_zoo/quantization.py` implementa y **empaqueta**: | Esquema | Escala | Códigos por byte | Verificación | | --- | --- | --- | --- | | `int8` | una por canal de salida | 1 | error ≤ `span/127` | | `int4` | una por grupo de 32 pesos | 2 | error ≤ `span/7`; bytes exactamente la mitad | | ternario | una por canal de salida | 4 | códigos en `{−1, 0, 1}`; bytes exactamente un cuarto | Guardar códigos `int4` en un tensor `int8` no ahorra ni un byte: sólo cambia el rango. Empaquetar dos por byte es lo que convierte la promesa de compresión en memoria ocupada, y por eso los tests **cuentan bytes** además de medir el error. `load_variant` construye una pila de bloques transformer con la profundidad y la anchura de la variante, con los pesos cuantizados en buffers. **Aviso honesto:** el repositorio no contiene pesos entrenados del NCM —el modelo se genera de forma sintética y determinista, igual que `hecate.ncm_transformer`— así que el módulo cargado es funcional y reproducible pero su tamaño es de kilobytes, no de gigabytes. Lo que sí es real y está verificado es la cuantización, el empaquetado y la reducción de huella. ## 6. Uso ```bash # Qué variante corresponde a este nodo (perfilándolo antes) ./target/release/hecate-node zoo --task chat # Contra un perfil ya medido, y con presupuesto de latencia ./target/release/hecate-node zoo --profile perfil-t14.json --latency-budget 20 # El catálogo que consumen los dos planos ./target/release/hecate-node zoo --emit > examples/model-zoo.json ``` ## 7. Estado real de la campaña | Semana | Entregable | Estado | | --- | --- | --- | | 2 | Selector y model zoo | ✅ catálogo versionado, selector en los dos planos, variantes distintas por nodo | | 2 | El nodo antiguo recibe una variante que cabe en 8 GB | ✅ 0.30 GB de pesos en 1.2 GB libres | | 4 | Benchmarks de latencia antes/después | ❌ no iniciados: exigen medir la inferencia real en cada nodo | ## 8. Límites * Las cifras de precisión del catálogo son **declaradas** (campaña del proyecto), no medidas por este código. Son el dato más blando del sistema y el primero que hay que reemplazar. * La latencia predicha es una estimación para ordenar candidatas, no un benchmark. * El ratio de aceleración se deriva de la VRAM, no de un ancho de banda de GPU medido. * `load_variant` carga un modelo sintético, no pesos entrenados. * No hay caché de decisiones: dos selecciones idénticas se recalculan.