ELAI S.r.l.

IA embedded: por qué un modelo cabe en flash pero no en RAM

Vidas de tensores, orden de operadores, arena y scratch: un experimento reproducible reduce el pico de 192 a 136 KiB y explica sus límites.

IA embedded: por qué un modelo cabe en flash pero no en RAM

IA embedded · Análisis de memoria · 24 de septiembre de 2026

Resumen: los pesos no describen el pico de RAM

Un modelo puede caber en la flash del microcontrolador y fallar al asignar tensores. La pregunta es precisa: cuánto influye el orden de ejecución en el pico de memoria manteniendo el grafo y los tamaños de datos. Construimos un grafo con dos ramas, enumeramos todos los órdenes válidos y verificamos una asignación estática sin solapamientos peligrosos. El pico baja de 192 a 136 KiB sin cambiar tamaños. Después añadimos scratch y memoria del sistema para mostrar cuándo ese ahorro deja de bastar.

Es un experimento de contabilidad ejecutado sobre un grafo didáctico, no un benchmark de LiteRT ni una prueba en placa. No se ejecutan convoluciones ni se miden precisión, latencia o energía. La contribución es un requisito de espacio verificable bajo hipótesis explícitas. Bastan arrays, grafos acíclicos e intervalos; no se exige una arquitectura neuronal concreta. Un KiB equivale siempre a 1024 bytes.

1. Tres cantidades que deben distinguirse

El archivo del modelo incluye pesos, estructura y metadatos serializados. Las activaciones contienen datos dependientes de la entrada producidos durante inferencia. La RAM total añade estructuras del runtime, pila, heap, comunicaciones, adquisición y otras funciones del firmware. Comparar directamente tamaño del archivo y RAM disponible mezcla objetos distintos. Los pesos pueden leerse desde flash o copiarse a RAM: es una decisión de plataforma que debe comprobarse, no una propiedad universal.

Para un tensor denso de forma n₁×…×n_d y b bytes por elemento, los datos ocupan b por el producto de dimensiones, antes de padding y alineación. Una activación INT8 de 32×32×96 ocupa 98.304 bytes, 96 KiB, aunque su capa tenga pocos pesos. Reducir parámetros sin cambiar las formas puede dejar intacto el cuello de botella. Aquí todos los tamaños se expresan en KiB y son compatibles con alineación de 16 bytes.

2. Definir la vida de un tensor

Suponemos operadores puros, sin efectos laterales, ejecutados de uno en uno. Cada uno necesita todas las entradas y un búfer de salida distinto. Excluimos cálculo in-place, recomputación y movimientos de datos durante la ejecución. Un tensor nace al asignar la salida de su productor y muere después del último consumidor. Si el productor nuevo usa el tensor anterior, ambos están vivos durante esa llamada; liberar primero la entrada subestima el pico.

L_t = {i : birth_i ≤ t ≤ last_use_i} M_live(t) = Σ(i ∈ L_t) size_i M_peak = max_t M_live(t)

Los extremos incluidos fijan una convención: t identifica la ejecución del operador antes de liberar sus entradas. La salida final sigue viva hasta que la aplicación la lee. En el ejemplo, la entrada inicial puede liberarse después del primer operador. Si el runtime o la adquisición conserva su puntero, la hipótesis debe cambiar. El diagrama de dependencias no contiene por sí solo todas estas reglas de propiedad de búferes.

3. Un grafo pequeño que basta para fallar

La entrada I, de 16 KiB, produce A, de 32 KiB. De A salen dos ramas: B, de 96 KiB, seguida de C, de 8 KiB; y D, de 64 KiB, seguida de E, de 8 KiB. F combina C y E y produce 16 KiB. Las letras identifican operador y único tensor de salida. Una interpretación posible es expansión de canales, reducción espacial y concatenación final; no definimos un modelo entrenado.

I(16) → A(32) → B(96) → C(8) ─┐ └→ D(64) → E(8) ─┴→ F(16) [KiB]

El orden A-B-D-C-E-F es topológicamente válido: ningún operador empieza sin sus entradas. Durante D conviven A, necesario para D, B, que espera a C, y la propia salida D. El total es 32+96+64=192 KiB. La dependencia de B respecto a A no obliga a completar C inmediatamente, pero aplazar C cuesta memoria. Un orden válido no es necesariamente bueno para la RAM.

Completemos B-C antes de D: A-B-C-D-E-F. El pico ocurre durante C, cuando A aún espera a D y B todavía debe leerse: 32+96+8=136 KiB. El ahorro es 56 KiB, aproximadamente el 29,17% del pico anterior. Todos los tensores suman 240 KiB: asignarlos por separado desperdicia espacio, pero considerar solo el mayor, 96 KiB, subestima la necesidad.

PasoOperador, orden ABCDEFKiB vivosOperador, orden ABDCEFKiB vivos
1A48A48
2B128B128
3C136D192
4D104C168
5E80E80
6F32F32

4. Enumeración y prueba del óptimo en este caso

A debe ser primero y F último; entre ambos intercalamos B-C y D-E respetando el orden interno. Hay seis intercalaciones. El script enumera permutaciones, descarta violaciones de dependencias y calcula picos con contadores de usos pendientes. El mínimo observado es 136 KiB. Para seis operadores la búsqueda exhaustiva es transparente; no escala a redes con miles de nodos.

Podemos justificar el límite sin confiar en la enumeración. Al nacer B, A y B ya requieren 128 KiB. Si la otra rama terminó, E añade 8 KiB: 136. Si existe D pero aún no E, el coste es mayor. Si D no empezó, ejecutar C exige A+B+C=136; ejecutar D antes de C exige A+B+D=192. Toda posibilidad requiere al menos 136 KiB. Un orden que lo alcanza demuestra optimalidad bajo las hipótesis fijadas.

5. Suma de datos vivos y tamaño de la arena

M_peak es un límite inferior al espacio del asignador; no garantiza por sí solo que los búferes contiguos quepan sin fragmentación. Asignamos a cada tensor un desplazamiento o_i. Si sus vidas se solapan, los intervalos [o_i,o_i+size_i) deben ser disjuntos. La arena necesaria es el mayor extremo o_i+size_i. Optimizar el orden y hallar desplazamientos son problemas relacionados pero distintos; una heurística puede dejar huecos.

ABCDEF: offsets [KiB] I: 32 A: 0 B: 32 C: 128 D: 32 E: 0 F: 8 max_i(offset_i+size_i) = 136 KiB

Estos desplazamientos alcanzan el límite. I y B empiezan en 32 KiB porque I muere antes de nacer B. D reutiliza parte de B solo después de C. E reutiliza el inicio de A cuando D termina de leerlo. C permanece al final de la arena hasta F. El script comprueba cada pareja viva en cada operador, no solo las sumas. Así, 136 KiB es una construcción realizable con scratch nulo y las restricciones indicadas.

6. Scratch: máximo de la suma no es suma de máximos

Un kernel puede necesitar espacio temporal, por ejemplo para transformar bloques antes de multiplicar. Añadamos S KiB de scratch solo durante D, en el orden ABCDEF. Durante D los tensores ocupan 104 KiB; el máximo sin scratch, 136 KiB, ocurre durante C. Por tanto M_peak(S)=max(136,104+S). Hasta S=32 KiB no crece el límite de datos vivos; con S=48 pasa a 152 KiB.

Atención: 152 KiB no es una nueva arena demostrada por la colocación anterior. Durante D, el hueco contiguo interno de ese plan solo mide 32 KiB; un scratch contiguo de 48 KiB no cabe. Una solución conservadora reserva otros 48 KiB fuera de la arena de 136, sumando 184 KiB. Reducirlo exige un plan nuevo verificado. Así distinguimos un límite teórico de simultaneidad de una disposición realmente construida.

Arriba: datos vivos por paso en ambos órdenes, con letras de operadores. Abajo: sensibilidad del pico al scratch durante D; no mide RAM de una placa ni tamaño final del asignador.
Arriba: datos vivos por paso en ambos órdenes, con letras de operadores. Abajo: sensibilidad del pico al scratch durante D; no mide RAM de una placa ni tamaño final del asignador.

7. Del grafo al presupuesto del firmware

Consideremos 256 KiB hipotéticos de RAM utilizable: 24 KiB persistentes, 32 KiB para pila y servicios y dos búferes de adquisición de 16 KiB, separados de I por un diseño explícito de copia. Fuera de la arena hay 88 KiB. Sin scratch, el plan de 136 suma 224 KiB y deja 32. El orden con pico 192 requiere al menos 280 KiB totales y no cabe, independientemente de la fragmentación.

Reservar 48 KiB de scratch por separado da 136+48+88=272 KiB: ya no cabe. El límite de datos vivos da 152+88=240 KiB, pero no hemos construido un asignador que lo alcance. Una placa no resulta adecuada porque una fórmula inferior al presupuesto parezca prometedora. RAM física, RAM accesible por DMA y banco exigido por un acelerador pueden ser conjuntos distintos; el presupuesto debe respetar el mapa real.

8. Qué verificar en un runtime real

La documentación de TensorFlow Lite Micro distingue áreas no persistente, temporal y persistente en la arena y describe APIs para registrar asignaciones. No deben extrapolarse estos detalles a todo runtime llamado LiteRT: cambian plataformas y rutas de ejecución. Una prueba real guardaría hash del modelo, revisión del runtime, kernels, compilador, alineación, tamaños de entrada y mapa del enlazador, comparando planificación y pico medido. Aquí no se realizaron esas mediciones hardware.

Liberis y Lane estudian el reordenamiento en su arXiv v2 de 2020, con algoritmo y pruebas en microcontrolador. Leímos métodos, experimentos y apéndice: motivan el problema, pero nuestras cifras proceden de nuestro grafo, no de reproducir su benchmark. El código usa deliberadamente enumeración exhaustiva y no pretende ser un optimizador de producción. La optimalidad en seis nodos no demuestra rendimiento computacional en una red grande.

Las alternativas cambian hipótesis distintas. Cuantizar reduce bytes por elemento, pero puede exigir conversiones y nuevos búferes; fusionar evita materializaciones si el kernel lo admite; calcular in-place exige demostrar que los datos sobrescritos ya no hacen falta; recomputar cambia memoria por trabajo adicional. No pueden sumarse porcentajes de ahorro independientes: tras cada cambio hay que recalcular vidas, scratch y desplazamientos. Menos RAM tampoco implica menos energía si aumentan accesos y tiempo.

9. Reproducibilidad y conclusión

El experimento usa Python 3.14.0 y solo biblioteca estándar, sin datos aleatorios; los gráficos usan Matplotlib 3.11.2. El archivo contiene grafo, tamaños, seis órdenes válidos, sumas por operador, desplazamientos y controles de colisión. El fragmento cuenta el mejor orden; el archivo añade enumeración y sensibilidad al scratch. No es pseudocódigo presentado como resultado: los JSON proceden de la ejecución guardada.

sizes = dict(I=16, A=32, B=96, C=8, D=64, E=8, F=16)
live_sets = ['IA', 'AB', 'ABC', 'ACD', 'CDE', 'CEF']
usage = [sum(sizes[t] for t in live) for live in live_sets]
print(usage)  # KiB
print(max(usage))

Para EL-AI, las aplicaciones embedded son un área editorial y de exploración técnica; este ejemplo no demuestra un producto disponible ni una placa validada por la empresa. La viabilidad exige tres respuestas: qué datos deben coexistir, dónde se colocan y cuánto espacio queda para el sistema. Contar solo pesos o tensores máximos no responde completamente a ninguna.

Fuentes y materiales

Edgar Liberis, Nicholas D. Lane, Neural networks on microcontrollers: saving memory at inference via operator reordering, arXiv:1910.05110v2 (2020). TensorFlow Lite Micro, Memory Management.

Fuentes consultadas el 24 de septiembre de 2026; la documentación main puede evolucionar. Código, resultados e instrucciones. Resultados JSON. Texto y experimento preparados con asistencia de IA, sin afirmar revisión por pares. Portada ImageGen ilustrativa: no representa un producto EL-AI.