ELAI S.r.l.

AI su microcontrollori: perché un arrotondamento può cambiare la risposta

Dalla somma INT32 all’uscita INT8: un esempio completo per capire scale, zero-point, arrotondamenti e saturazione, con codice eseguito.

AI su microcontrollori: perché un arrotondamento può cambiare la risposta

Lo stesso modello, una risposta diversa

Un sensore intelligente installato su una macchina deve trasformare pochi segnali in un punteggio. Sul computer di sviluppo il risultato sembra corretto, ma dopo il porting sul microcontrollore alcuni valori cambiano di una sola unità intera. È rumore innocuo oppure un errore? La risposta dipende da ciò che quell’intero rappresenta. Se il punteggio è vicino a una soglia, un passo della rappresentazione numerica può cambiare la decisione. Prima di modificare la rete neurale, conviene capire il passaggio che riporta una somma ampia nel piccolo formato di uscita.

Studiamo la requantizzazione: il cambio di scala, seguito dall’arrotondamento e dalla limitazione all’intervallo rappresentabile. Deriveremo un neurone con tre ingressi, eseguiremo il calcolo usando frazioni esatte e costruiremo controesempi per valori a metà, negativi e saturati. L’obiettivo non è dimostrare che INT8 sia inaffidabile: è rendere esplicito il contratto numerico che due implementazioni devono condividere. Non abbiamo usato una scheda, misurato latenza o valutato una rete completa; il codice è un riferimento didattico, non l’emulazione bit per bit di un runtime commerciale.

Un intero è un’etichetta su una scala

INT8 indica un intero con segno a otto bit: in complemento a due rappresenta valori da −128 a 127. In una rete quantizzata quel numero non coincide necessariamente con il valore matematico della grandezza. Servono una scala positiva s e un punto zero z, detto zero-point. La scala dice quanto vale un passo; z indica quale intero corrisponde allo zero reale. È come numerare tacche su un righello spostato: conoscere solo il numero della tacca non basta a ricavare la lunghezza. L’analogia descrive la conversione, non il funzionamento della rete.

r = s(q − z)

q è l’intero memorizzato e r il valore ricostruito. Nel nostro esempio tutti i segnali sono normalizzati e senza unità fisiche: non stiamo convertendo direttamente volt o gradi. Con s = 0,1 e z = 10, q = 13 significa r = 0,3; q = 10 significa zero; q = 0 significa −1. Quest’ultimo caso conta anche quando si aggiungono bordi nulli a un’immagine: uno zero matematico deve essere codificato con z, non necessariamente con il byte numericamente uguale a zero.

Costruiamo il calcolo completo, prima dell’arrotondamento

Il neurone somma tre prodotti e aggiunge un bias, cioè una costante che sposta il risultato. Gli ingressi interi sono [13, 9, 12], con sₓ = 0,1 e zₓ = 10: rappresentano [0,3; −0,1; 0,2]. I pesi interi sono [2, −3, 1], con s𝑤 = 0,2 e z𝑤 = 0: rappresentano [0,4; −0,6; 0,2]. Il bias reale è 0,02. Abbiamo scelto numeri rappresentabili esattamente sulle rispettive griglie, così possiamo isolare l’ultimo arrotondamento senza confonderlo con un precedente errore di quantizzazione degli ingressi.

Il calcolo reale dà 0,3 × 0,4 + (−0,1) × (−0,6) + 0,2 × 0,2 + 0,02 = 0,24. Per ottenere lo stesso valore da interi sottraiamo prima il punto zero agli ingressi. I prodotti diventano 3 × 2, (−1) × (−3), 2 × 1: rispettivamente 6, 3 e 2. Ogni unità di questa somma vale sₓs𝑤 = 0,02. Il bias deve quindi essere espresso nella stessa scala: bq = 0,02/0,02 = 1. Sommiamo 6 + 3 + 2 + 1 = 12, un accumulatore A che ricostruisce ancora 12 × 0,02 = 0,24.

A = Σᵢ (qxᵢ − zₓ)qwᵢ + bq; bq = b/(sₓs𝑤) y = sₓs𝑤 A

La relazione assume pesi con punto zero nullo e un bias rappresentabile nella scala del prodotto; se il bias non lo fosse, richiederebbe un proprio arrotondamento dichiarato. A è un intero: nella pipeline tipica si accumula in INT32 per avere più spazio dei singoli operandi INT8. Questo articolo assume che non si verifichi overflow nell’accumulazione. Riservare abbastanza bit è necessario, ma non risolve i problemi del successivo cambio di scala.

Dalla somma al byte di uscita

L’uscita usa sᵧ = 0,08 e zᵧ = −7: ha quindi un righello diverso. Per trasformare A in passi di uscita dobbiamo dividere il suo valore reale per sᵧ. Il rapporto M = sₓs𝑤/sᵧ = 1/4 converte le due scale. Definiamo R come arrotondamento al più vicino, con i casi esattamente a metà allontanati dallo zero. Definiamo clamp come limitazione tra −128 e 127. Il nostro contratto, nell’ordine indicato, è:

M = sₓs𝑤/sᵧ qy = clamp(R(MA) + zᵧ, −128, 127) ŷ = sᵧ(qy − zᵧ)

Per A = 12 troviamo MA = 3, già intero. Aggiungiamo −7 e memorizziamo qy = −4. Quel byte negativo rappresenta un risultato positivo: ŷ = 0,08 × (−4 + 7) = 0,24. Il segno del codice non è quindi il segno della grandezza. Nemmeno aggiungere il bias direttamente nella scala di uscita sarebbe equivalente in generale: qui il bias appartiene all’accumulatore, prima della conversione.

A metà tra due valori: serve una regola completa

Ora cambiamo soltanto A, portandolo a 10. Il risultato prima dell’arrotondamento è MA = 2,5. Due interi, 2 e 3, sono ugualmente vicini. La nostra regola sceglie 3, mentre l’arrotondamento al pari sceglie 2, perché è pari. Dopo l’aggiunta di zᵧ otteniamo rispettivamente −4 e −5, che ricostruiscono 0,24 e 0,16. Il valore prima della requantizzazione era 0,20: entrambi sono distanti 0,04, ma non sono lo stesso risultato.

Se una decisione ipotetica scatta per ŷ ≥ 0,20, la prima versione la attiva e la seconda no. Non abbiamo misurato una perdita di accuratezza su un dataset: è un controesempio che dimostra perché una differenza di un codice può essere rilevante. Non esiste una regola di arrotondamento universalmente migliore per ogni rete. Il problema di compatibilità è usare in validazione una regola diversa da quella del dispositivo, oppure ignorare la tolleranza richiesta dalla decisione.

Anche l’ordine conta. Con la nostra regola R(2,5) − 7 = −4, ma R(2,5 − 7) = R(−4,5) = −5. Spostare l’aggiunta dello zero-point prima dell’arrotondamento sembra un riordino innocuo dell’algebra reale, ma cambia il caso di parità rispetto allo zero. La formula compatta deve dunque indicare dove si arrotonda. Se un runtime adotta un’altra convenzione, il riferimento di confronto deve riprodurre quella convenzione; il nostro esempio non dichiara che tutti i runtime usino R.

Numeri negativi, troncamento e distorsione

Eliminare la parte decimale non significa sempre arrotondare al più vicino. Con A = −11, MA = −2,75: il più vicino è −3, il troncamento verso zero dà −2. L’arrotondamento verso meno infinito dà invece −3. Con A = 11, il troncamento e l’arrotondamento verso meno infinito danno entrambi 2, mentre il più vicino è 3. Le differenze non sono simmetriche per ogni regola. La tabella riporta i codici finali dopo aver aggiunto zᵧ = −7; per leggerli come grandezze occorre sempre sottrarre zᵧ e moltiplicare per 0,08.

AMAR: lontano da 0Parità al pariTroncamentoVerso −∞
-11−2.75-10-10-9-10
-10−2.5-10-9-9-10
00-7-7-7-7
102.5-4-5-5-5
112.75-4-4-5-5

Nel grafico eseguiamo tutti gli interi A da −16 a 16, senza saturazione, e mostriamo l’errore ricostruito ŷ − y. Le linee tra punti aiutano la lettura, non indicano prove su ingressi continui. Il troncamento spinge i valori verso lo zero reale; la regola verso meno infinito introduce errori sempre non positivi. Su questa griglia simmetrica l’errore medio di R e del troncamento è zero, ma ciò non rende identici gli errori individuali e non dimostra assenza di distorsione su dati reali non simmetrici.

Errore sullo stesso accumulatore sintetico: M = 1/4, sᵧ = 0,08, zᵧ = −7. Le linee grigie segnano ±0,04, mezzo passo di uscita; la regola al più vicino vi rimane entro, senza saturazione.
Errore sullo stesso accumulatore sintetico: M = 1/4, sᵧ = 0,08, zᵧ = −7. Le linee grigie segnano ±0,04, mezzo passo di uscita; la regola al più vicino vi rimane entro, senza saturazione.

Due passaggi esatti sulla carta, due arrotondamenti diversi

Un dispositivo può realizzare un fattore frazionario con moltiplicazioni intere e spostamenti di bit. Per capire il rischio non serve imitare uno specifico processore. Prendiamo un secondo esempio, M = 3/8, e due algoritmi dichiarati: uno arrotonda soltanto alla fine, l’altro calcola prima 3A/4, arrotonda, divide per due e arrotonda ancora. Senza arrotondamenti 3/8 e (3/4)/2 sono identici. Con A = 1 il primo dà R(0,375) = 0; il secondo dà R(R(0,75)/2) = R(0,5) = 1.

Non stiamo denunciando un difetto di una libreria: abbiamo costruito due contratti numerici diversi. In alcune implementazioni passaggi intermedi differenti sono intenzionali e validati. Per confrontare risultati occorre conoscere moltiplicatore, shift, larghezza degli intermedi e regole di arrotondamento in ogni passaggio. Verificare solo il fattore reale finale M non basta. Nel pacchetto sono calcolati entrambi gli algoritmi per tutti gli A da −16 a 16, compresi i valori negativi.

Quanto errore possiamo delimitare?

Con un solo arrotondamento al più vicino, moltiplicatore esatto e assenza di saturazione, la distanza tra R(MA) e MA non supera mezzo intero. Moltiplicando per la scala di uscita otteniamo una garanzia locale sul valore ricostruito:

|ŷ − y| ≤ sᵧ/2 = 0.04

È una dimostrazione su questo passaggio, non sull’errore rispetto a un modello originale in virgola mobile: a monte potrebbero esserci pesi, ingressi e bias già approssimati. Non è neppure una garanzia sulla decisione finale di molti strati. Se una successiva operazione amplifica le differenze, il mezzo passo può crescere; se il punteggio è vicino alla soglia, può già bastare a cambiarne il lato. Un’analisi dell’intera rete richiede il percorso completo delle operazioni e dati rappresentativi.

Se il dispositivo usa un moltiplicatore approssimato M̂, senza altri arrotondamenti intermedi, possiamo separare due contributi. Dalla disuguaglianza triangolare segue |ŷ − y| ≤ sᵧ(1/2 + |A|·|M̂ − M|), sempre senza clipping. Il primo contributo è l’arrotondamento finale, il secondo l’errore del moltiplicatore amplificato dall’accumulatore. Un piccolo errore relativo sulla costante non autorizza quindi a ignorare l’intervallo di A. La formula non copre overflow o ulteriori arrotondamenti nascosti.

La saturazione rompe la garanzia del mezzo passo

Aumentiamo A fino a 600, mantenendo tutte le scale iniziali. Il valore reale è 12, MA = 150 e il codice prima della limitazione è 150 − 7 = 143. INT8 non può contenerlo: clamp restituisce 127. Il valore ricostruito è 0,08 × (127 + 7) = 10,72, con errore −1,28. Non è un errore di arrotondamento: abbiamo raggiunto il bordo della rappresentazione. La garanzia di 0,04 non si applica perché la sua ipotesi di assenza di saturazione è violata.

Cambiare sᵧ per ampliare l’intervallo significa anche allargare la distanza tra valori consecutivi. Quindi range e risoluzione vanno scelti insieme, usando dati coerenti con l’impiego previsto. Inoltre una funzione di attivazione può restringere ulteriormente l’intervallo: per una ReLU, che annulla i valori negativi, il codice del limite zero è zᵧ, qui −7, non necessariamente 0. La saturazione numerica e la funzione di attivazione sono operazioni concettualmente diverse anche quando un kernel le fonde.

Dalle fonti alla verifica sul dispositivo

Jacob e colleghi descrivono lo schema affine, la scala del bias e la pipeline intera nel lavoro del 2018, letto nelle sezioni 2 e 4. I loro confronti MobileNet riguardano hardware Snapdragon e protocolli dichiarati: non trasferiamo quei risultati a un microcontrollore. La documentazione gemmlowp spiega la conversione dell’accumulatore, mentre la specifica LiteRT distingue tipi, intervalli e zero-point degli operatori. Sono riferimenti tecnici per capire il meccanismo; i nostri piccoli esempi sono separati dai loro benchmark e non certificano compatibilità con una versione specifica.

Nel codice Python usiamo Fraction per mantenere esatti 1/10, 1/5 e 2/25 durante il calcolo: anche 0,1 scritto come float può avere una rappresentazione binaria approssimata, che qui sarebbe una variabile inutile. La funzione away calcola il più vicino sul valore assoluto con aritmetica intera e poi ripristina il segno; requant applica nell’ordine moltiplicatore, arrotondamento, punto zero e saturazione. Le asserzioni verificano l’esempio completo, i casi di parità, il doppio arrotondamento e il limite di mezzo passo su tutta la griglia. I dati sono deterministici, senza seed.

Per L ingressi il prodotto scalare richiede O(L) operazioni; per N accumulatori già disponibili la conversione richiede O(N) passaggi. La memoria dello script cresce con le righe salvate, ma questo non misura il picco RAM di un firmware. Python usa interi a precisione arbitraria: una versione embedded deve definire esplicitamente ampiezza dei prodotti intermedi, shift validi, saturazioni e conversioni di tipo. Non basta tradurre riga per riga il codice didattico e presumere che il compilatore mantenga tutte le proprietà.

La verifica proposta sul dispositivo parte da vettori di riferimento condivisi: zero reale, valori negativi, metà intervallo, bordi di saturazione e canali con scale diverse. Si registrano modello convertito, versione del convertitore, runtime, kernel, compilatore e opzioni. Se l’uguaglianza bit per bit non è prevista, si documenta una tolleranza per operatore e si valuta separatamente l’effetto sulla decisione. Solo dopo la correttezza si misurano latenza media e percentili, picco RAM, potenza ed energia per inferenza con un protocollo hardware esplicito. Queste prove restano da eseguire: il nostro conto non produce nessuna di quelle misure.

La risposta: controllare il significato del byte

Una somma INT32 corretta non garantisce da sola un’uscita INT8 equivalente tra due sistemi. Bisogna conservare scale e zero-point, precisare la regola dei casi a metà, rispettare l’ordine delle operazioni e distinguere l’arrotondamento dalla saturazione. Nel nostro esempio lo stesso accumulatore 10 ricostruisce 0,24 oppure 0,16 a seconda della regola; con 600 il limite del formato introduce un errore molto più grande. La lezione per portare AI su dispositivi piccoli è concreta: prima di chiedere quanto velocemente gira il modello, verificare quale operazione numerica sta davvero eseguendo.

Fonti e riproducibilità

Benoit Jacob et al., Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference (2018), sections 2 and 4.

Google gemmlowp, Building a quantization paradigm from first principles.

Google LiteRT, 8-bit quantization specification.

from fractions import Fraction
from experiment import requant, run
print(requant(10), requant(10, rounder=round))
print(requant(600))
print(next(r for r in run()['double_rounding'] if r['a'] == 1))

Codice, dati e istruzioni · JSON. Calcoli didattici eseguiti con Python 3.14.0; figure con Matplotlib 3.11.2. Analisi con assistenza AI, senza dichiarare peer review o revisione umana. Copertina originale ImageGen, illustrativa: non documenta persone, sedi o installazioni EL-AI. Fonti consultate il 4 ottobre 2026.