Compilation de modèles pour les NPU Qualcomm (QNN HTP)
Ceci est une procédure, pas un script : chaque modèle nécessite son propre export et ses propres vérifications. Elle suit la façon dont sherpa-onnx compile ses modèles NPU publiés (k2-fsa/sherpa-onnx, .github/workflows/export-*-qnn.yaml, .github/scripts/export-qnn/, scripts/qnn/), qui sont connus pour fonctionner. Lisez le workflow correspondant avant de commencer ; adaptez, n'inventez pas.
0. Déterminer si le NPU en vaut la peine
- Mesurez d'abord sur le téléphone cible : utilisation CPU (%) et temps réel du modèle tel qu'il s'exécute actuellement (
/proc/<pid>/stattoutes les 2 s,top -H -p <pid>par thread), et autonomie de la batterie sur une tâche fixe si l'objectif est l'économie d'énergie. Le NPU est rentable pour les gros travaux matriciels stables (encodeurs, longs passages par lots) ; pour un petit modèle sur 1 à 2 threads, le gain peut être faible. - Formes statiques uniquement. Le NPU exécute des graphes de taille fixe : lot fixe (1), longueur d'entrée fixe (taille de segment, ou 30 s pour l'encodeur de Whisper). Un modèle dont la longueur d'entrée varie nécessite un segment fixe, du remplissage ou plusieurs graphes.
- Une famille de puces par compilation. Un binaire de contexte est compilé pour une architecture Hexagon (v73, v75, v79, v81…) et ne fonctionne que sur celle-ci ; les autres téléphones (et Exynos / Tensor / MediaTek) conservent le chemin CPU. Prévoyez toujours une solution de repli vers le CPU.
- Limites de taille. Les très grands modèles (plus d'environ 1 milliard de paramètres en fp16) peuvent ne pas tenir dans un contexte : ils nécessitent des poids 8 bits (w8a16) et/ou une division en plusieurs graphes. Les propres versions Whisper de sherpa-onnx s'arrêtent à la taille moyenne (769 M).
Puces (d'après scripts/qnn/device_info.py de sherpa-onnx) : SM8550 Snapdragon 8 Gen 2 (v73), SM8650 8 Gen 3 (v75), SM8750 8 Elite (v79), SM8850 (v81), QCS9100 (v73). SM8350 (v68) n'a pas de fp16 : ignorez-le. Trouvez la puce d'un téléphone avec adb shell getprop ro.soc.model.
1. Outils (Linux x86-64 uniquement)
Le SDK QNN (QAIRT) fonctionne sur Linux x86-64 (ou Windows), pas sur macOS. Sur Mac : une VM Linux/une instance cloud, ou Docker avec --platform linux/amd64 (lent : émulé). sherpa-onnx construit une image Docker pour cela (Dockerfile-2.40, qnn-sdk-docker-build.yaml).
- SDK QAIRT, par exemple 2.40.0.251030 : depuis Qualcomm (compte gratuit), ou la copie téléchargée par le CI de sherpa-onnx (
huggingface.co/csukuangfj/qnn-toolkit,v2.40.0.251030.zip). Vérifiez que sa licence autorise votre utilisation. source qairt/<version>/bin/envsetup.sh(définitQNN_SDK_ROOT), puissudo ${QNN_SDK_ROOT}/bin/check-linux-dependency.shetpython3 ${QNN_SDK_ROOT}/bin/check-python-dependency.- Environnement virtuel Python 3.10 avec
numpy<2 onnx==1.17.0 onnxruntime onnxsimplus les dépendances d'export du modèle lui-même. - Android NDK dans le
PATH(ndk-build), pour les bibliothèques de modèles aarch64 ;LDFLAGS="-Wl,-z,max-page-size=16384"(pages de 16 Ko, requis sous Android 15+).
2. Exporter ONNX pour le NPU
Ré-exporter à partir du point de contrôle original (PyTorch), pas à partir d'un ONNX fabriqué pour CPU :
--dynamic-batch 0(lot 1), pas de quantification int8 dans l'ONNX (--enable-int8-quantization 0: quantifiez dans QNN à la place),--use-int32-inputs 1(pas de tenseurs int64), segment/longueur fixe.- Simplifiez (
onnxsim) et vérifiez que chaque entrée/sortie a une forme fixe (scripts/pyannote/segmentation/show-onnx.pyde sherpa-onnx). - Exécutez l'ONNX sur CPU (onnxruntime) par rapport à l'original sur quelques entrées réelles : même sortie (ou même texte).
Exemples : Zipformer2 en streaming (icefall zipformer/export-onnx-streaming.py, voir scripts/zipformer-transducer/x-asr/qnn/export-streaming.sh de sherpa-onnx) ; Whisper (scripts/whisper/qnn/export_onnx.py de sherpa-onnx, encodeur et décodeur en tant que graphes séparés).
3. Convertir, construire, compiler (par graphe, par puce)
Pour chaque graphe ONNX $m (par exemple encodeur, décodeur, joncteur) :
# a) ONNX → graphe C++ QNN + poids. Activations fp16 : --act_bitwidth 16 --bias_bitwidth 32.
# Pour les poids 8 bits (grands modèles) : ajoutez une quantification avec un --input_list d'entrées réelles.
qnn-onnx-converter --input_network $m.onnx --output_path out/$m.cpp --act_bitwidth 16 --bias_bitwidth 32
# b) Configuration HTP pour la puce (generate_config.py de sherpa-onnx : soc_id, dsp_arch, vtcm).
python3 generate_config.py --soc SM8750 --graph-name $m --output-dir out/config-$m --qnn-sdk-root $QNN_SDK_ROOT
# c) Bibliothèques de modèles : x86_64 (pour compiler le contexte sur l'hôte) et aarch64-android.
python3 $QNN_SDK_ROOT/bin/x86_64-linux-clang/qnn-model-lib-generator -c out/$m.cpp -b out/$m.bin -o out/model_libs
# d) Binaire de contexte pour la puce : ce que le téléphone charge.
$QNN_SDK_ROOT/bin/x86_64-linux-clang/qnn-context-binary-generator \
--backend $QNN_SDK_ROOT/lib/x86_64-linux-clang/libQnnHtp.so \
--model out/model_libs/x86_64-linux-clang/lib$m.so \
--output_dir out/binary --binary_file $m \
--config_file out/config-$m/htp_backend_extensions.json
Conservez les journaux du convertisseur et du générateur : les opérations non prises en charge et les avertissements de quantification y apparaissent.
4. Exécution sur le téléphone
- Expédiez les bibliothèques d'exécution QNN pour Android aarch64 (
libQnnHtp.so,libQnnHtpV79Skel.soet stub pour la puce,libQnnSystem.so; sherpa-onnx les publie sous forme deqnn-libs-<version>.tar.bz2). La bibliothèque Skel s'exécute sur le DSP :OnlineRecognizer.prependAdspLibraryPath(dir)de sherpa-onnx (ouADSP_LIBRARY_PATH) doit pointer vers son dossier. - sherpa-onnx :
QnnConfig(backendLib = ".../libQnnHtp.so", contextBinary = ".../$m.bin", systemLib = ".../libQnnSystem.so")sur la configuration du modèle (par exempleOnlineTransducerModelConfig(..., qnnConfig = ...)),provider = "qnn". - Alternative ONNX Runtime : le fournisseur d'exécution QNN (
QNNExecutionProvider,backend_path=libQnnHtp.so), qui peut également mettre en cache lui-même les binaires de contexte. - Choisissez par appareil : si
ro.soc.modelcorrespond à une puce compilée et que les bibliothèques se chargent, NPU ; sinon CPU. Ne présumez jamais.
5. Vérification
- Même sortie : exécutez la même entrée/audio sur CPU et NPU ; pour la parole, le taux d'erreur sur 10+ extraits réels. Les activations 16 bits sont généralement sans perte ; les poids 8 bits doivent être vérifiés.
- Vitesse : facteur temps réel et latence de la première sortie sur le téléphone.
- Puissance : utilisation CPU du processus et baisse de la batterie sur une tâche fixe de 30 à 60 minutes, CPU vs NPU, écran éteint, même luminosité et réseau. Répétez ; l'état thermique modifie les résultats.
- Enregistrez les résultats (puce, version du SDK, commit du modèle, quantification, chiffres) à côté du modèle.
Pièges
- Formes dynamiques, entrées int64, opérations non prises en charge (certaines opérations d'attention ou de contrôle de flux) : corrigez-les dans l'export (encapsulez le modèle, corrigez les longueurs), pas dans QNN.
- Les binaires de contexte sont liés à la famille de versions du SDK : construisez avec la même version du SDK QAIRT que les bibliothèques d'exécution.
- Un modèle qui revient silencieusement au CPU pour certaines opérations perd la majeure partie du gain : vérifiez les journaux pour les partitions CPU.
- Licences : celle du modèle (redistribuer une forme compilée revient à distribuer le modèle) et celle du SDK QNN.
- Alternative sans Linux local : Qualcomm AI Hub (compilation et profilage dans le cloud sur des appareils réels ; nécessite un compte ; vérifiez ses conditions pour votre application).