Module 6 — L'interprète TensorFlow Lite et ses délégués
Le fichier .tflite produit par les cinq modules précédents ne s'exécute pas
tout seul. C'est un graphe sérialisé qu'il faut charger dans un composant
capable de le parcourir, d'allouer les tenseurs intermédiaires et
d'appeler les bonnes opérations sur le bon matériel. Ce composant s'appelle
l'interprète, et sa configuration est ce qui fait passer la latence de
145 ms à 30 ms sur les téléphones du fil rouge.
Le cycle de vie en cinq étapes
Quelle que soit la plateforme (Python, Android, iOS), l'interprète suit toujours la même séquence :
- Charger le fichier
.tfliteen mémoire. - Allouer les tenseurs intermédiaires en fonction des formes d'entrée.
- Écrire les données d'entrée dans les tenseurs correspondants.
- Invoquer l'inférence.
- Lire les données de sortie.
En Python, cette séquence tient en une dizaine de lignes :
import tensorflow as tf, numpy as np
interprete = tf.lite.Interpreter(
model_path="modeles/plantvillage_qat_elague.tflite",
num_threads=4,
)
interprete.allocate_tensors()
entree = interprete.get_input_details()[0]
sortie = interprete.get_output_details()[0]
interprete.set_tensor(entree["index"], image_uint8[None, ...])
interprete.invoke()
probabilites = interprete.get_tensor(sortie["index"])[0]
print("classe predite :", probabilites.argmax())
Le paramètre num_threads mérite une mention immédiate : par défaut, il vaut
1, et une image tourne alors sur un seul cœur. Passer à 4 sur un téléphone
quadri-cœur divise la latence par 2,5 à 3 sans changer une ligne du modèle.
C'est le gain le moins cher du cours.
Un tenseur d'entrée mal formé fait planter l'inférence
L'interprète est intransigeant sur les formes et les types. Une image en
float32 envoyée à un modèle qui attend uint8 — parce qu'on a quantifié
au module 3 avec inference_input_type = tf.uint8 — lève une exception
explicite : « type mismatch ». Une image de 256x256 envoyée à un modèle
224x224 produit le même type d'erreur.
Le prétraitement côté application doit reproduire exactement les transformations attendues :
def preparer(image_pil):
"""Reproduit exactement ce que le modele attend."""
image = image_pil.resize((224, 224))
tableau = np.asarray(image, dtype=np.uint8) # shape (224, 224, 3)
return tableau[None, ...] # shape (1, 224, 224, 3)
Sur mobile, ce prétraitement se fait dans du code natif (Kotlin, Swift),
mais la logique est identique : redimensionner, ne pas modifier les canaux,
respecter le type d'entrée. Le module 2 a insisté pour que le prétraitement
« lourd » (rescaling en float) vive dans le modèle ; le module 6 précise
que le prétraitement « léger » (redimensionnement, conversion en uint8)
reste, lui, côté application.
Le délégué : qui exécute réellement les opérations
Par défaut, l'interprète exécute chaque opération en logiciel sur le CPU. Un délégué est un composant qui prend en charge tout ou partie des opérations en les redirigeant vers un accélérateur : GPU mobile, NPU dédié, API système. On l'attache au moment de la construction de l'interprète.
Le tableau ci-dessous récapitule les délégués courants et leurs cibles :
| Délégué | Plateforme | Accepte | Effet typique sur le fil rouge |
|---|---|---|---|
| CPU par défaut | toutes | tout | 145 ms sur Galaxy A15 |
| XNNPACK | toutes | float32, float16, int8 | 90 ms sur Galaxy A15 |
| GPU | Android, iOS | float16 (best), float32 | 45 ms sur Galaxy A15 |
| NNAPI | Android 8.1+ | int8, float16 | 30 ms sur Galaxy A15 |
| Core ML | iOS 12+ | float16 | 12 ms sur iPhone 12 |
| Hexagon | Android + DSP Qualcomm | int8 | 20 ms sur téléphones Snapdragon compatibles |
XNNPACK est activé par défaut sur mobile depuis TFLite 2.5 pour les
tenseurs float. Sur int8, il faut souvent l'activer explicitement avec
XNNPACK_QS8 pour les modèles quantifiés symétriquement.
Attacher un délégué en pratique
En Java sur Android :
Interpreter.Options options = new Interpreter.Options();
options.setNumThreads(4);
// Delegue GPU
GpuDelegate.Options gpuOptions = new GpuDelegate.Options();
gpuOptions.setPrecisionLossAllowed(true); // active le float16
GpuDelegate gpu = new GpuDelegate(gpuOptions);
options.addDelegate(gpu);
Interpreter interpreter = new Interpreter(loadModelFile(), options);
En Swift sur iOS :
var options = Interpreter.Options()
options.threadCount = 4
let coreMl = CoreMLDelegate()
let interpreter = try Interpreter(
modelPath: chemin,
options: options,
delegates: coreMl != nil ? [coreMl!] : []
)
Les deux plateformes suivent la même logique : un délégué est un objet qu'on ajoute à la liste des options, et l'interprète bascule automatiquement les opérations compatibles vers lui.
Le repli sur CPU est silencieux et souvent coûteux
Voici le point qui piège la plupart des équipes lors du premier déploiement. Quand un délégué ne peut pas exécuter une opération — parce que le modèle utilise un opérateur non supporté par ce délégué, ou parce que la puce n'a pas la version d'API requise —, l'interprète ne renvoie aucune erreur. Il exécute l'opération sur CPU, ce qui déclenche des copies mémoire entre CPU et accélérateur, souvent plusieurs fois par inférence.
Le résultat est un modèle « avec délégué GPU actif » qui tourne plus lentement que sans délégué, à cause de ces allers-retours. La latence sur la boîte de dialogue reste identique, mais elle vient d'une exécution mixte qui casse le pipeline.
Le diagnostic passe par les journaux TFLite avec verbosity élevée :
tf.lite.experimental.set_delegate_debug_mode(True)
Sur Android, adb logcat -s tflite affiche pour chaque opération le
composant qui l'a exécutée. Une opération suivie de « delegated to CPU »
dans un modèle censé tourner entièrement sur GPU est le signal d'un
problème à corriger avant de mesurer la latence.
Sur un même modèle, le meilleur délégué change d'un téléphone à l'autre : NNAPI est excellent sur Snapdragon 8 Gen 1, catastrophique sur certains Kirin, imprévisible sur les puces MediaTek d'entrée de gamme. Le module 9 insiste : le choix se fait à l'exécution, sur mesure du parc réel, jamais par principe.
Une stratégie de repli, écrite à la main
Un déploiement robuste sur un parc hétérogène implique de tester plusieurs délégués au démarrage et de retenir celui qui donne la meilleure latence médiane sur trois inférences de chauffe. Le pseudo-code Kotlin :
val candidats = listOf(
{ addDelegate(GpuDelegate()); "gpu" },
{ addDelegate(NnApiDelegate()); "nnapi" },
{ addDelegate(HexagonDelegate(context)); "hexagon" },
{ /* CPU seul */ "cpu" },
)
var meilleur = "cpu" ; var meilleurTemps = Long.MAX_VALUE
for (initialiser in candidats) {
try {
val options = Interpreter.Options().setNumThreads(4).apply { initialiser() }
val essai = Interpreter(fichierModele, options)
val temps = mesurerLatence(essai, imageChauffe, 3) // ms median
if (temps < meilleurTemps) { meilleur = initialiser.toString(); meilleurTemps = temps }
essai.close()
} catch (_: Throwable) { /* le delegue n'est pas disponible */ }
}
Ce coût — trois inférences de chauffe par candidat au premier lancement — est absorbé une fois pour toutes, et sa décision est mémorisée dans les préférences de l'application. Il évite d'écrire une table matérielle manuelle qui vieillira avec chaque nouvelle génération de puces.
Le tableau de compromis après le module 6
En reprenant la variante retenue au module 5 (2,1 Mo, 95,7 %) :
| Cible | Délégué | Latence |
|---|---|---|
| Nokia G21 (Snapdragon 220) | XNNPACK | 90 ms |
| Nokia G21 | NNAPI | 65 ms |
| Galaxy A15 (Helio G99) | XNNPACK | 55 ms |
| Galaxy A15 | NNAPI | 30 ms |
| iPhone 12 | XNNPACK | 32 ms |
| iPhone 12 | Core ML | 12 ms |
C'est le tableau final de latence : les trois téléphones du fil rouge passent tous largement sous les 100 ms, ce qui laisse toute sa place au prétraitement, à l'affichage et à la boucle caméra.
En résumé
- Le cycle de l'interprète est identique sur toutes les plateformes :
charger → allouer → écrire → invoquer → lire, avec
num_threadsréglé à 4 comme premier gain gratuit. - Un délégué matériel redirige les opérations compatibles vers GPU, NNAPI, Core ML ou XNNPACK selon la plateforme et la version de l'API.
- Le repli sur CPU est silencieux : une opération non supportée par le délégué crée des copies mémoire qui peuvent rendre l'ensemble plus lent qu'un CPU pur ; le diagnostic passe par les journaux TFLite verbeux.
- Sur un parc hétérogène, choisir le délégué au démarrage par mesure de latence médiane est plus robuste que toute table matérielle statique.
Module suivant : l'intégration dans une application Android, du chargement du modèle à l'exécution en arrière-plan.