Módulo 10 · Despliegue

Runtime on-device (Flutter)

Entrenaste, cuantizaste y exportaste a GGUF. Ahora el modelo corre en un teléfono, sin servidor. El error más común es tratar la inferencia como una llamada de red: pedir la respuesta y esperar el bloque completo al final. No es así. La UI debería consumir tokens por streaming mientras el modelo los produce, con la inferencia ejecutándose fuera del hilo de UI. El camino real es: runtime nativo → token stream / callback → isolate de Dart → estado de la UI. Este módulo fija la mecánica y la matemática mínima para razonar sobre memoria, latencia y energía en el dispositivo.

Al terminar sabrás

  • Trazar el flujo runtime nativo → token stream → isolate → UI sin bloquear el hilo de render.
  • Explicar por qué el modelo se carga con mmap y qué implica para el uso de RAM.
  • Calcular la memoria del KV cache a una longitud de contexto dada y por qué GQA la reduce.
  • Distinguir la fase prefill (compute-bound) de la fase decode (memory-bandwidth-bound).
  • Medir TTFT, tokens/s, RAM y comportamiento térmico/batería, y comparar cuantizaciones.

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 UI nunca espera la respuesta completa: pinta cada token en cuanto llega.

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.

Nota · por qué importa

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:

$$ \mathrm{Mem}_{kv} = 2 \cdot n_{\text{layers}} \cdot n_{\text{kv\_heads}} \cdot d_{\text{head}} \cdot L \cdot \text{bytes} $$

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.

Cuidado

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:

$$ t_{\text{total}} \approx t_{\text{prefill}}(L_{\text{in}}) + L_{\text{out}}\cdot t_{\text{decode}} $$

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:

$$ \text{throughput de decode} \;\approx\; \frac{\text{ancho de banda de memoria}}{\text{bytes del modelo}} $$

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:

$$ \text{emite el token en cuanto se elige}\;\; \arg\max / \mathrm{sample}(z_t), \qquad \text{tiempo al primer token} = t_{\text{prefill}} $$

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:

$$ \text{energía por token} \;\approx\; \text{potencia}\cdot t_{\text{decode}}, \qquad \text{cuantizar baja los bytes del modelo} \Rightarrow \text{más throughput de decode y menos energía} $$

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.

PiezaDónde viveResponsabilidad
Runtime nativoC/C++/Rust (llama.cpp)mmap del GGUF, prefill, decode token a token, KV cache
Puente FFIDart ↔ nativoExponer cargar / generar / cancelar; pasar punteros y bytes
Isolate de inferenciaDart (fuera del hilo de UI)Correr el bucle de generación, sostener el contexto, emitir tokens
UIDart (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.

En el teléfono

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.

Cómo practicar
  1. Compila llama.cpp para el teléfono (o usa un binding).
  2. Carga el GGUF Q4_K_M del Módulo 9.
  3. Expón una función nativa que reciba el prompt y haga streaming de tokens.
  4. Puentea a Flutter con Dart FFI / flutter_rust_bridge y renderiza los tokens conforme llegan.
  5. Benchmark on-device: TTFT, tokens/s por número de threads, RAM del KV cache y temperatura/batería; compara Q4 vs Q8.
Herramientas: Rust · llama.cpp · Flutter · Dart FFI
Rust · on-device

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.

Ejercicio 1 · calentamiento

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}$.

Ejercicio 2

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.

Ejercicio 3

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.

Ejercicio 4

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.

Ejercicio 5 · ejercicio top

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.

Puntos clave
  • 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.