Compilando modelos para NPUs de Qualcomm (QNN HTP)
Un procedimiento, no un script: cada modelo necesita su propia exportación y comprobaciones. Sigue cómo sherpa-onnx compila sus modelos NPU publicados (k2-fsa/sherpa-onnx, .github/workflows/export-*-qnn.yaml, .github/scripts/export-qnn/, scripts/qnn/), que se sabe que funcionan. Lee el workflow correspondiente allí antes de empezar; adáptalo, no inventes.
0. Decide si vale la pena la NPU
- Mide primero en el teléfono objetivo: % de CPU y tiempo real del modelo tal como se ejecuta ahora (tics de
/proc/<pid>/statcada 2 s,top -H -p <pid>por hilo), y batería durante una tarea fija si el objetivo es la energía. La NPU compensa para trabajo grande y constante de matrices (codificadores, pases de lotes largos); para un modelo pequeño en 1-2 hilos la ganancia puede ser pequeña. - Solo formas estáticas. La NPU ejecuta grafos de tamaño fijo: lote fijo (1), longitud de entrada fija (tamaño de fragmento, o 30 s para el codificador de Whisper). Un modelo cuya longitud de entrada varía necesita un fragmento fijo, relleno o varios grafos.
- Una familia de chips por compilación. Un binario de contexto se compila para una arquitectura Hexagon (v73, v75, v79, v81…) y solo se ejecuta allí; otros teléfonos (y Exynos / Tensor / MediaTek) mantienen la ruta de CPU. Siempre planea un fallback a CPU.
- Límites de tamaño. Los modelos muy grandes (más de ~1B parámetros en fp16) pueden no caber en un contexto: necesitan pesos de 8 bits (w8a16) y/o dividirse en varios grafos. Las propias compilaciones de Whisper de sherpa-onnx se detienen en medium (769M).
Chips (de sherpa-onnx scripts/qnn/device_info.py): SM8550 Snapdragon 8 Gen 2 (v73), SM8650 8 Gen 3 (v75), SM8750 8 Elite (v79), SM8850 (v81), QCS9100 (v73). SM8350 (v68) no tiene fp16: omítelo. Encuentra el chip de un teléfono con adb shell getprop ro.soc.model.
1. Herramientas (solo Linux x86-64)
El SDK de QNN (QAIRT) se ejecuta en Linux x86-64 (o Windows), no en macOS. En una Mac: una VM Linux / caja en la nube, o Docker con --platform linux/amd64 (lento: emulado). sherpa-onnx construye una imagen Docker para ello (Dockerfile-2.40, qnn-sdk-docker-build.yaml).
- SDK QAIRT, ej. 2.40.0.251030: de Qualcomm (cuenta gratuita), o la copia que descarga el CI de sherpa-onnx (
huggingface.co/csukuangfj/qnn-toolkit,v2.40.0.251030.zip). Verifica que su licencia permita tu uso. source qairt/<version>/bin/envsetup.sh(estableceQNN_SDK_ROOT), luegosudo ${QNN_SDK_ROOT}/bin/check-linux-dependency.shypython3 ${QNN_SDK_ROOT}/bin/check-python-dependency.- Entorno virtual Python 3.10 con
numpy<2 onnx==1.17.0 onnxruntime onnxsimmás las dependencias de exportación del modelo. - Android NDK en
PATH(ndk-build), para las bibliotecas de modelo aarch64;LDFLAGS="-Wl,-z,max-page-size=16384"(páginas de 16 KB, requerido en Android 15+).
2. Exporta ONNX para la NPU
Re-exporta desde el punto de control original (PyTorch), no desde un ONNX hecho para CPU:
--dynamic-batch 0(lote 1), sin cuantización int8 en el ONNX (--enable-int8-quantization 0: cuantiza en QNN en su lugar),--use-int32-inputs 1(sin tensores int64), fragmento/longitud fijos.- Simplifica (
onnxsim) y verifica que cada entrada/salida tenga una forma fija (sherpa-onnxscripts/pyannote/segmentation/show-onnx.py). - Ejecuta el ONNX en CPU (onnxruntime) contra el original en algunas entradas reales: misma salida (o mismo texto).
Ejemplos: Zipformer2 en streaming (icefall zipformer/export-onnx-streaming.py, ver sherpa-onnx scripts/zipformer-transducer/x-asr/qnn/export-streaming.sh); Whisper (sherpa-onnx scripts/whisper/qnn/export_onnx.py, codificador y decodificador como grafos separados).
3. Convierte, construye, compila (por grafo, por chip)
Para cada grafo ONNX $m (ej. codificador, decodificador, unidor):
# a) ONNX → grafo C++ de QNN + pesos. Activaciones fp16: --act_bitwidth 16 --bias_bitwidth 32.
# Para pesos de 8 bits (modelos grandes): añade cuantización con una --input_list de entradas reales.
qnn-onnx-converter --input_network $m.onnx --output_path out/$m.cpp --act_bitwidth 16 --bias_bitwidth 32
# b) Configuración HTP para el chip (sherpa-onnx scripts/qnn/generate_config.py: 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) Bibliotecas de modelo: x86_64 (para compilar el contexto en el host) y 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) Binario de contexto para el chip: lo que carga el teléfono.
$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
Guarda los registros del convertidor y generador: las operaciones no soportadas y las advertencias de cuantización aparecen allí.
4. Ejecuta en el teléfono
- Entrega las bibliotecas de ejecución de QNN para Android aarch64 (
libQnnHtp.so,libQnnHtpV79Skel.soy el stub para el chip,libQnnSystem.so; sherpa-onnx las publica comoqnn-libs-<version>.tar.bz2). La biblioteca Skel se ejecuta en el DSP:OnlineRecognizer.prependAdspLibraryPath(dir)de sherpa-onnx (oADSP_LIBRARY_PATH) debe apuntar a su carpeta. - sherpa-onnx:
QnnConfig(backendLib = ".../libQnnHtp.so", contextBinary = ".../$m.bin", systemLib = ".../libQnnSystem.so")en la configuración del modelo (ej.OnlineTransducerModelConfig(..., qnnConfig = ...)),provider = "qnn". - Alternativa con ONNX Runtime: el proveedor de ejecución QNN (
QNNExecutionProvider,backend_path=libQnnHtp.so), que también puede almacenar en caché binarios de contexto por sí mismo. - Elige por dispositivo: si
ro.soc.modelcoincide con un chip compilado y las bibliotecas cargan, NPU; si no, CPU. Nunca asumas.
5. Verifica
- Misma salida: ejecuta la misma entrada/audio en CPU y NPU; para voz, tasa de error de palabras en 10+ clips reales. Las activaciones de 16 bits suelen ser sin pérdida; los pesos de 8 bits deben comprobarse.
- Velocidad: factor de tiempo real y latencia de primera salida en el teléfono.
- Energía: % de CPU del proceso, y caída de batería durante una tarea fija de 30-60 min, CPU vs NPU, pantalla apagada, mismo brillo y red. Repite; el estado térmico cambia los resultados.
- Registra los resultados (chip, versión del SDK, commit del modelo, cuantización, números) junto al modelo.
Dificultades
- Formas dinámicas, entradas int64, operaciones no soportadas (algunas operaciones de atención o control de flujo): corrígelas en la exportación (envuelve el modelo, corrige longitudes), no en QNN.
- Los binarios de contexto están vinculados a la familia de versiones del SDK: compila con la misma versión de QAIRT que las bibliotecas de ejecución.
- Un modelo que cae a CPU para algunas operaciones pierde silenciosamente la mayor parte de la ganancia: revisa los registros en busca de particiones de CPU.
- Licencias: la del modelo (redistribuir una forma compilada es distribuir el modelo) y la del SDK de QNN.
- Ruta alternativa sin Linux local: Qualcomm AI Hub (compilación y perfilado en la nube en dispositivos reales; necesita una cuenta; revisa sus términos para tu aplicación).