Aller au contenu principal

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 :

  1. Charger le fichier .tflite en mémoire.
  2. Allouer les tenseurs intermédiaires en fonction des formes d'entrée.
  3. Écrire les données d'entrée dans les tenseurs correspondants.
  4. Invoquer l'inférence.
  5. 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éPlateformeAccepteEffet typique sur le fil rouge
CPU par défauttoutestout145 ms sur Galaxy A15
XNNPACKtoutesfloat32, float16, int890 ms sur Galaxy A15
GPUAndroid, iOSfloat16 (best), float3245 ms sur Galaxy A15
NNAPIAndroid 8.1+int8, float1630 ms sur Galaxy A15
Core MLiOS 12+float1612 ms sur iPhone 12
HexagonAndroid + DSP Qualcommint820 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.

L'ordre de préférence des délégués n'est pas universel

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

CibleDéléguéLatence
Nokia G21 (Snapdragon 220)XNNPACK90 ms
Nokia G21NNAPI65 ms
Galaxy A15 (Helio G99)XNNPACK55 ms
Galaxy A15NNAPI30 ms
iPhone 12XNNPACK32 ms
iPhone 12Core ML12 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_threads ré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.