1La tubería en el dispositivo
El modelo cuantizado (por ejemplo un GGUF Q4_K_M) vive como archivo en el
almacenamiento del teléfono. Un runtime nativo —típicamente llama.cpp
compilado para ARM— lo carga, corre la inferencia y emite tokens uno por uno. La app Flutter
no ejecuta la matemática: la delega al runtime a través de FFI (Dart llamando a
C/C++/Rust) y recibe cada token por un callback o un stream. Ese trabajo pesado se
aísla en un isolate de Dart, de modo que el hilo de UI solo recibe eventos y actualiza
el estado.
GGUF (mmap) → runtime nativo (llama.cpp / FFI) → token stream → isolate de Dart → estado → UI (por token)
La separación tiene una razón dura: el hilo de UI de Flutter tiene un presupuesto de ~16 ms por frame. Cualquier cómputo de inferencia en ese hilo congela la interfaz. Por eso la regla es aislar la inferencia fuera del hilo de UI y comunicar por mensajes.
2Carga por mmap
El runtime no copia el archivo entero a RAM. Lo mapea en memoria con mmap:
el sistema operativo asocia el rango de direcciones del proceso al archivo GGUF en disco. Las
páginas se cargan de forma perezosa, solo cuando se tocan (page fault), el resident set
crece a medida que se usan los pesos, y varias instancias pueden compartir las mismas
páginas de solo lectura sin duplicarlas.
Al mapear el archivo en memoria, el resident set crece de forma perezosa por fallos de página (solo se cargan las páginas que se tocan), esas páginas se comparten entre procesos y nunca se copia el archivo completo a RAM.
Con mmap, un GGUF de varios cientos de MB no dispara un pico de RAM equivalente al arrancar. Bajo presión de memoria, el SO puede descartar páginas limpias de solo lectura (los pesos) y volver a leerlas del archivo cuando hagan falta, en vez de mandarlas a swap. Esto es clave en un teléfono con RAM limitada.
3Threads de CPU, GPU y NPU
En CPU, el runtime paraleliza las multiplicaciones de matrices entre varios hilos. Subir el número de threads acelera hasta saturar el ancho de banda de memoria o los núcleos de rendimiento; pasado ese punto, más hilos calientan el dispositivo sin ganar throughput. Muchos SoC móviles ofrecen además aceleración por GPU o NPU; descargar el cómputo ahí puede subir tokens/s y bajar el consumo, a cambio de más complejidad de integración y soporte desigual entre dispositivos.
La medición manda: el número óptimo de threads es una propiedad del dispositivo, no del modelo. Por eso el benchmark on-device barre el número de threads y observa dónde se aplana la curva de tokens/s.
4KV cache y context size
Como viste en el Módulo 0, el KV cache guarda las claves y valores ya calculados para no recomputar la atención en cada paso. En el dispositivo ese caché es memoria real que crece con la longitud del contexto $L$. Su tamaño es:
El factor 2 es porque se guardan K y V. El caché escala lineal con la longitud de contexto: doblar $L$ dobla la memoria del KV cache. Aquí entra una decisión de arquitectura del modelo: con GQA (grouped-query attention, como en Qwen3) varias cabezas de query comparten las mismas cabezas de key/value, de modo que $n_{\text{kv\_heads}} < n_{\text{heads}}$ reduce sustancialmente $\mathrm{Mem}_{kv}$ frente a multi-head clásico.
El context size que fijas al cargar el modelo reserva (o acota) el KV cache. Pedir una ventana enorme "por si acaso" puede agotar la RAM del teléfono antes de generar nada. Dimensiona $L$ contra la memoria disponible del dispositivo, no contra el máximo teórico del modelo.
5Prefill vs decode: dos regímenes distintos
La latencia total de una respuesta se descompone en dos fases con perfiles de cómputo opuestos. El prefill procesa el prompt de entrada de longitud $L_{\text{in}}$ de una vez; el decode genera los $L_{\text{out}}$ tokens de salida, uno a uno:
El prefill es compute-bound: procesa muchos tokens en paralelo, así que lo limita la capacidad aritmética. El decode es memory-bandwidth-bound: para producir un solo token hay que recorrer todos los pesos del modelo desde memoria. Por eso el throughput de decode está gobernado por el ancho de banda, no por los FLOPs:
Esta fórmula explica de golpe por qué la cuantización ayuda tanto en el móvil: bajar los bytes del modelo (Q8 → Q4) sube directamente el throughput de decode, aunque no cambie ni un FLOP la aritmética por token.
6Streaming, cancelación y reutilización del contexto
En generación autoregresiva no hace falta esperar al final: en cuanto el runtime elige el token $t$ puede emitirlo y seguir con el siguiente. El streaming es exactamente eso — publicar el token en cuanto se decide:
De aquí sale una métrica central: el tiempo al primer token (TTFT) es esencialmente el tiempo de prefill, porque el primer token no puede salir hasta procesar todo el prompt. Después del primero, la cadencia la marca $t_{\text{decode}}$.
- Cancelación: como la UI recibe tokens uno a uno, el usuario puede detener la generación a medio camino; el isolate señala al runtime que pare el bucle de decode y libere el turno.
- Prompt caching / reutilización del contexto: si el prefijo del prompt no cambia entre llamadas (system prompt, historial), se conserva su KV cache y se evita recomputar el prefill de esa parte — solo se procesan los tokens nuevos.
7Energía, temperatura y batería
Cada token de decode consume energía; a grandes rasgos, la energía por token es la potencia instantánea por el tiempo que cuesta ese token:
Una generación sostenida calienta el SoC; si supera el umbral térmico, el dispositivo hace throttling y baja frecuencias, con lo que tokens/s cae a mitad de la respuesta. Por eso el benchmark honesto mide una generación sostenida, no una ráfaga corta: la temperatura y la batería son parte del resultado, no una nota al margen.
8FFI: el puente Dart ↔ nativo
Dart no ejecuta el runtime; lo invoca. A través de FFI la app llama a las funciones C
expuestas por llama.cpp (o un wrapper en C++/Rust): cargar el modelo, tokenizar,
correr prefill, pedir el siguiente token, liberar. El isolate de inferencia mantiene el
puntero al contexto nativo vivo entre llamadas para reutilizar el KV cache, y reenvía
cada token al hilo de UI por el canal de mensajes del isolate.
| Pieza | Dónde vive | Responsabilidad |
|---|---|---|
| Runtime nativo | C/C++/Rust (llama.cpp) | mmap del GGUF, prefill, decode token a token, KV cache |
| Puente FFI | Dart ↔ nativo | Exponer cargar / generar / cancelar; pasar punteros y bytes |
| Isolate de inferencia | Dart (fuera del hilo de UI) | Correr el bucle de generación, sostener el contexto, emitir tokens |
| UI | Dart (hilo de render) | Recibir tokens por stream, actualizar estado, ofrecer cancelar |
Regla mental: la UI no debería recibir la respuesta completa al final. Recibe un flujo, y la inferencia que lo alimenta corre en otro hilo. Todo lo demás de este módulo —memoria, latencia, energía— cae en su sitio una vez que esa separación está clara.
Aquí todo es on-device. La app no debe bloquear la UI: la inferencia corre en un hilo/isolate aparte y los tokens llegan por streaming. El tiempo al primer token (TTFT) es el prefill; los tokens/s son el throughput de decode. La batería y la temperatura acotan la generación sostenida.
- Compila llama.cpp para el teléfono (o usa un binding).
- Carga el GGUF Q4_K_M del Módulo 9.
- Expón una función nativa que reciba el prompt y haga streaming de tokens.
- Puentea a Flutter con Dart FFI /
flutter_rust_bridgey renderiza los tokens conforme llegan. - Benchmark on-device: TTFT, tokens/s por número de threads, RAM del KV cache y temperatura/batería; compara Q4 vs Q8.
Hay tres rutas para correr el modelo en el teléfono desde Rust: (a) bindings a llama.cpp con el crate llama-cpp-2 (máxima madurez y velocidad); (b) mistral.rs, un motor de inferencia en Rust puro que soporta GGUF y cuantización; (c) candle, el framework de Hugging Face en Rust.
El puente a Flutter lo genera flutter_rust_bridge: la inferencia corre en un hilo nativo y emite tokens por un Stream de Dart, para no bloquear la UI.
// loop de generación que emite cada token por un callback while !done { let logits = ctx.decode(&last_token)?; // usa el KV cache interno let next = sampler.sample(&logits); // greedy o top-p on_token(ctx.token_to_str(next)?); // -> Stream de Dart last_token = next; }
Lecturas y recursos
✦Ejercicios
De menor a mayor complejidad. El último es el que hace un practicante de verdad.
Calcula la memoria del KV cache
Con los hiperparámetros de Qwen3-0.6B ($n_{\text{layers}}$, $n_{\text{kv\_heads}}$, $d_{\text{head}}$), calcula $\mathrm{Mem}_{kv}$ para $L \in \{512, 2048, 8192\}$ en KV a 16 bits (2 bytes). Reporta en MB.
Entrega: tabla $L$ → MB del KV cache. Pista: $\mathrm{Mem}_{kv} = 2 \cdot n_{\text{layers}} \cdot n_{\text{kv\_heads}} \cdot d_{\text{head}} \cdot L \cdot \text{bytes}$.
GQA vs multi-head
Toma los mismos números y compara el KV cache con GQA (el $n_{\text{kv\_heads}}$ real de Qwen3) contra un hipotético multi-head donde $n_{\text{kv\_heads}} = n_{\text{heads}}$. ¿Cuánto ahorra GQA en porcentaje?
Entrega: ratio $\mathrm{Mem}_{kv}^{\text{GQA}} / \mathrm{Mem}_{kv}^{\text{MHA}}$ + una frase interpretándolo. Pista: solo cambia $n_{\text{kv\_heads}}$; el resto se cancela.
Estima el throughput de decode
Para tu GGUF Q4_K_M y para Q8_0, calcula los bytes del modelo y estima el techo de tokens/s dividiendo por el ancho de banda de memoria nominal del teléfono. Compara con lo que esperas medir.
Entrega: tabla cuantización → bytes → tok/s estimado. Pista: throughput de decode $\approx$ ancho de banda / bytes del modelo.
Streaming detrás de un isolate
Enlaza llama.cpp por Dart FFI y corre una generación en un isolate separado, reenviando cada token al hilo de UI por un Stream. Mide el TTFT (tiempo al primer token) y verifica que la UI sigue respondiendo durante la generación.
Entrega: demo que imprime tokens en vivo + TTFT medido. Pista: el TTFT es esencialmente $t_{\text{prefill}}$; corre el bucle de decode fuera del hilo de render.
Despliega y haz benchmark on-device
Despliega el GGUF Q4_K_M de tu Qwen3-0.6B fine-tuned en un teléfono vía llama.cpp enlazado por Dart FFI en una app Flutter, implementa streaming de tokens a la UI, y haz benchmark on-device: tiempo al primer token, tokens/s sostenidos según el número de threads, RAM del KV cache a una longitud de contexto fija, y consumo de temperatura/batería en una generación sostenida — luego compara Q4 vs Q8 en el mismo dispositivo.
Entrega: una tabla de benchmark on-device (TTFT, tok/s, RAM, °C/batería) para Q4 vs Q8 con el demo de streaming. Pista: barre el número de threads hasta que tok/s se aplane; mide en generación sostenida para capturar el throttling térmico.
- La UI consume tokens por streaming; la inferencia corre en un isolate, fuera del hilo de UI.
- mmap carga los pesos de forma perezosa desde el GGUF: no copia el archivo a RAM y comparte páginas.
- El KV cache crece lineal con el contexto; GQA lo reduce vía menos $n_{\text{kv\_heads}}$.
- El prefill es compute-bound; el decode es memory-bandwidth-bound ⇒ menos bytes = más tok/s.
- El TTFT $\approx t_{\text{prefill}}$; mide en generación sostenida para capturar térmica y batería.