ELAI S.r.l.

Embedded AI: perché un modello entra nella flash ma non nella RAM

Vita dei tensori, ordine degli operatori, arena e scratch: un esperimento riproducibile riduce il picco da 192 a 136 KiB e ne chiarisce i limiti.

Embedded AI: perché un modello entra nella flash ma non nella RAM

Embedded AI · Analisi della memoria · 24 settembre 2026

Abstract: i pesi non descrivono il picco RAM

Un modello può entrare nella flash di un microcontrollore e fallire durante l’allocazione dei tensori. La domanda di questo articolo è precisa: quanto conta l’ordine di esecuzione nel picco di memoria, a parità di grafo e dimensioni dei dati? Costruiamo un grafo con due rami, enumeriamo tutti gli ordini validi e verifichiamo un’allocazione statica senza sovrapposizioni pericolose. Il picco passa da 192 a 136 KiB senza cambiare le dimensioni dei tensori. Poi introduciamo scratch e memoria del sistema per mostrare dove questo risparmio smette di bastare.

È un esperimento di contabilità eseguito su un grafo didattico, non un benchmark LiteRT o una prova su scheda. Non vengono eseguite convoluzioni e non si misura accuratezza, latenza o energia. Il contributo consiste in un risultato verificabile sullo spazio necessario, con ipotesi esplicite. Servono nozioni di array, grafi aciclici e intervalli; non è richiesta una particolare architettura neurale. Un KiB corrisponde sempre a 1024 byte.

1. Tre quantità che non vanno confuse

La dimensione del file del modello include pesi, struttura e metadati serializzati. La memoria delle attivazioni contiene dati dipendenti dall’ingresso, prodotti durante l’inferenza. La RAM totale comprende inoltre strutture del runtime, stack, heap, comunicazione, acquisizione e altre funzioni del firmware. Un rapporto fra dimensione del modello e RAM disponibile confronta quindi oggetti diversi. I pesi possono essere letti dalla flash oppure copiati in RAM: è una scelta di piattaforma da verificare, non una proprietà universale.

Per un tensore denso con forma n₁×…×n_d e b byte per elemento, i dati occupano b volte il prodotto delle dimensioni, prima di padding e allineamento. Un’attivazione INT8 da 32×32×96 occupa 98.304 byte, cioè 96 KiB, anche se il layer che la produce ha pochi pesi. Ridurre i parametri senza cambiare forma delle attivazioni può lasciare invariato il vero collo di bottiglia. Qui tutte le dimensioni sono già espresse in KiB e compatibili con allineamento a 16 byte.

2. Definire la vita di un tensore

Consideriamo operatori puri, senza effetti collaterali, eseguiti uno alla volta. Ciascun operatore richiede tutti gli ingressi e un buffer di uscita distinto. Non sono consentiti calcolo in-place, ricomputazione o spostamenti dei dati durante l’esecuzione. Un tensore nasce quando viene allocata l’uscita del produttore e muore soltanto dopo l’ultimo consumatore. Se il produttore del nuovo tensore usa quello vecchio, entrambi sono vivi durante quella chiamata: liberare prima l’ingresso sottostima il picco.

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

Gli estremi inclusi esprimono una convenzione precisa: t identifica l’esecuzione di un operatore, prima del rilascio dei suoi ingressi. L’uscita finale resta viva fino alla lettura da parte dell’applicazione. Nel nostro esempio l’ingresso iniziale può invece essere rilasciato dopo il primo operatore. Se il runtime o l’acquisizione ne trattiene il puntatore, questa ipotesi va cambiata. Un diagramma di dipendenze da solo non contiene tutte queste regole di proprietà dei buffer.

3. Un grafo piccolo ma sufficiente a fallire

Usiamo un ingresso I da 16 KiB che produce A da 32 KiB. Da A partono due rami: B da 96 KiB seguito da C da 8 KiB, e D da 64 KiB seguito da E da 8 KiB. Infine F combina C ed E e produce 16 KiB. Le lettere indicano sia l’operatore sia il suo unico tensore di uscita. Una possibile interpretazione è espansione dei canali, riduzione spaziale e concatenazione finale; non stiamo però definendo un modello addestrato.

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

L’ordine A-B-D-C-E-F è topologicamente valido: nessun operatore parte prima dei suoi ingressi. Durante D devono però convivere A, ancora necessario a D, B, che aspetta C, e l’uscita D stessa. Il totale è 32+96+64=192 KiB. La dipendenza di B da A non impone di completare subito C, ma rimandare C costa memoria. L’ordine valido non è quindi necessariamente un buon ordine per la RAM.

Completiamo invece il ramo B-C prima di avviare D: A-B-C-D-E-F. Il picco avviene durante C, quando A deve ancora attendere D e B deve ancora essere letto: 32+96+8=136 KiB. Il risparmio è 56 KiB, circa il 29,17% del picco precedente. La somma di tutti i tensori è 240 KiB: allocarli tutti separatamente sprecherebbe spazio, ma anche limitarsi al tensore più grande, 96 KiB, sottostimerebbe il fabbisogno.

PassoOperatore, piano ABCDEFKiB viviOperatore, piano ABDCEFKiB vivi
1A48A48
2B128B128
3C136D192
4D104C168
5E80E80
6F32F32

4. Enumerazione e prova dell’ottimo nel caso scelto

A deve essere primo e F ultimo; fra loro dobbiamo intercalare le due catene B-C e D-E mantenendone l’ordine interno. Gli intercalamenti sono sei. Lo script enumera le permutazioni, scarta quelle che violano le dipendenze e calcola il picco con contatori degli usi residui. Il minimo osservato è 136 KiB. Per sei operatori questa ricerca esaustiva è trasparente; non è una strategia scalabile per reti con migliaia di nodi.

Possiamo anche motivare il limite senza fidarci dell’enumerazione. Quando nasce B, servono già A e B, cioè 128 KiB. Se l’altro ramo è completato, E aggiunge 8 KiB: siamo a 136. Se D esiste ma E non ancora, il costo è ancora maggiore. Se D non è partito, eseguire C richiede A+B+C=136; eseguire D prima di C richiede invece A+B+D=192. Ogni possibilità impone almeno 136 KiB. Un ordine che raggiunge quel valore dimostra l’ottimo sotto le ipotesi stabilite.

5. Somma dei vivi e dimensione dell’arena

M_peak è un limite inferiore allo spazio dell’allocatore: non garantisce da solo che buffer contigui trovino posto senza frammentazione. Definiamo un offset o_i per ogni tensore. Se due vite si sovrappongono, gli intervalli di memoria [o_i,o_i+size_i) devono essere disgiunti. L’arena richiesta è il massimo degli estremi o_i+size_i. Ottimizzare l’ordine e trovare gli offset sono problemi collegati ma distinti; un pianificatore euristico può lasciare spazi inutilizzati.

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

Questi offset raggiungono il limite nel nostro esempio. I e B condividono l’inizio a 32 KiB perché I muore prima che B nasca. D riusa parte dello spazio di B soltanto dopo C. E riusa l’inizio occupato da A dopo che D ha finito di leggerlo. C resta nella coda dell’arena fino a F. Lo script controlla tutte le coppie di tensori vivi a ogni operatore, non soltanto la somma delle dimensioni. Qui 136 KiB sono quindi anche una costruzione realizzabile, con scratch nullo e i vincoli dichiarati.

6. Scratch: il massimo della somma non è la somma dei massimi

Un kernel può richiedere spazio temporaneo: ad esempio per trasformare blocchi di dati prima della moltiplicazione. Aggiungiamo uno scratch di S KiB usato soltanto durante D, nell’ordine ABCDEF. Durante D i tensori occupano 104 KiB, mentre il massimo senza scratch, 136 KiB, si trova durante C. Ne segue M_peak(S)=max(136,104+S). Fino a S=32 KiB lo scratch non aumenta il limite dei dati vivi; a S=48 il limite diventa 152 KiB.

Attenzione: 152 KiB non è una nuova arena dimostrata dal piano precedente. In quel piano, durante D il vuoto contiguo interno misura solo 32 KiB; uno scratch contiguo da 48 KiB non vi entra. Una soluzione conservativa è riservare altri 48 KiB fuori dall’arena da 136, ottenendo 184 KiB. Per ottenere meno serve un nuovo piano verificato. Questo distingue un limite teorico di simultaneità da una configurazione di memoria realmente costruita.

Sopra: dati vivi per passo nei due ordini, con lettere degli operatori. Sotto: sensibilità del picco dei dati vivi allo scratch durante D; il grafico non misura la RAM di una scheda né la dimensione finale dell’allocatore.
Sopra: dati vivi per passo nei due ordini, con lettere degli operatori. Sotto: sensibilità del picco dei dati vivi allo scratch durante D; il grafico non misura la RAM di una scheda né la dimensione finale dell’allocatore.

7. Dal grafo al budget del firmware

Consideriamo un budget ipotetico di 256 KiB di RAM utilizzabile. Supponiamo 24 KiB persistenti, 32 KiB per stack e servizi, e due buffer di acquisizione da 16 KiB, separati dall’ingresso I per scelta esplicita di copia. Il costo esterno all’arena è 88 KiB. Con scratch nullo, il piano da 136 porta il totale a 224 KiB, lasciandone 32. L’ordine con picco 192 richiede almeno 280 KiB complessivi e non può entrare nel budget, qualunque sia la frammentazione.

Se riserviamo lo scratch da 48 KiB separatamente, il totale conservativo diventa 136+48+88=272 KiB e non entra più. Il solo limite dei vivi darebbe 152+88=240 KiB, ma non abbiamo costruito un allocatore che lo raggiunga. Una scheda non diventa adeguata perché una formula inferiore al budget sembra promettente. Inoltre RAM fisicamente presente, RAM accessibile al DMA e RAM del banco richiesto da un acceleratore possono essere insiemi diversi: il budget deve rispettare la mappa reale.

8. Che cosa verificare in un runtime reale

La documentazione TensorFlow Lite Micro distingue nell’arena una parte non persistente, una temporanea e una persistente, e descrive API per registrare le allocazioni. Non bisogna trasferire automaticamente quei dettagli a ogni runtime chiamato LiteRT: piattaforme e percorsi di esecuzione differiscono. Per una prova reale salveremmo hash del modello, revisione del runtime, kernel selezionati, compilatore, allineamento, dimensioni degli ingressi e mappa del linker, confrontando pianificazione e picco misurato. Qui queste misure hardware non sono state eseguite.

Liberis e Lane studiano il riordino degli operatori nella versione arXiv v2 del 2020, con algoritmo e prove su microcontrollore. Abbiamo letto metodi, esperimenti e appendice: il loro lavoro motiva il problema, ma i numeri di questo articolo provengono dal nostro grafo, non dalla riproduzione del loro benchmark. Il codice qui usa volutamente enumerazione completa invece di presentare un ottimizzatore da produzione. Un risultato ottimo su sei nodi non dimostra prestazioni computazionali su una rete grande.

Le alternative modificano ipotesi diverse. La quantizzazione riduce byte per elemento ma può richiedere conversioni e nuovi buffer; la fusione evita materializzazioni, se il kernel lo permette; il calcolo in-place richiede prova che i dati sovrascritti non servano più; la ricomputazione scambia memoria con lavoro aggiuntivo. Non si possono sommare percentuali di risparmio ottenute separatamente: dopo ogni cambiamento vanno ricalcolati vite, scratch e offset. Nemmeno meno RAM implica automaticamente meno energia, perché gli accessi e il tempo possono aumentare.

9. Riproducibilità e conclusione

L’esperimento usa Python 3.14.0 e sola libreria standard, senza dati casuali; i grafici usano Matplotlib 3.11.2. Il pacchetto contiene grafo, dimensioni, sei piani validi, somme per operatore, offset e controlli delle collisioni. Il frammento seguente mostra il conteggio dell’ordine migliore; l’archivio include anche enumerazione e sensibilità allo scratch. Non si tratta di pseudocodice presentato come risultato: i file JSON derivano dall’esecuzione salvata.

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))

Per EL-AI le applicazioni embedded sono un ambito editoriale e di esplorazione tecnica; questo esempio non dimostra un prodotto disponibile né una scheda validata dall’azienda. La conclusione è che la fattibilità richiede tre risposte separate: quali dati devono coesistere, dove vengono collocati e quanto spazio resta al resto del sistema. Contare soltanto pesi o tensori massimi non risponde a nessuna delle tre in modo completo.

Fonti e materiali

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.

Fonti consultate il 24 settembre 2026; la documentazione main può evolvere. Codice, risultati e istruzioni. Risultati JSON. Testo ed esperimento preparati con assistenza AI, senza dichiarare peer review. Copertina ImageGen illustrativa: non rappresenta un prodotto EL-AI.