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.
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.
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.
| Passo | Operatore, piano ABCDEF | KiB vivi | Operatore, piano ABDCEF | KiB vivi |
|---|---|---|---|---|
| 1 | A | 48 | A | 48 |
| 2 | B | 128 | B | 128 |
| 3 | C | 136 | D | 192 |
| 4 | D | 104 | C | 168 |
| 5 | E | 80 | E | 80 |
| 6 | F | 32 | F | 32 |
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.
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.

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.

