QNN on Linux (QAIRT)#

arm64-only, and opt-in: the lane is off unless a QAIRT SDK zip is staged in linux/qnn-sdk/. With no zip the build is byte-identical to a QNN-less build on every arch. See linux/qnn-sdk/README.md for the staging discipline.

Staging#

The zip is login-gated (qpm.qualcomm.com, Qualcomm ID + EULA). Drop exactly one *.zip in linux/qnn-sdk/; Dockerfile.media bind-mounts that directory at /opt/scripts/qnn-sdk on every heavy RUN.

QNN_SDK_LINUX_ZIP_SHA256 in versions.env is populated with the v2.49.0.260730 hash, so re-staging that exact version needs no re-pin — the existing hash validates it and a mismatch means a different build was downloaded. Only a newer SDK needs sha256sum <zip> plus a versions.env update in the same commit.

resolve_qnn_sdk (01-core/qnn-sdk.sh) verifies the hash, extracts, and asserts three canaries: the include/QNN/QnnInterface.h anchor, libQnnCpu.so under lib/${QNN_SDK_LINUX_LIBDIR} (default aarch64-oe-linux-gcc11.2), and QNN_OP_STFT in QnnOpDef.h — the QNN API 2.25+ marker ORT has needed since 1.29.

QAIRT_HEADERS_DIR — the trap that cost a build#

Pass the directory that directly HOLDS Qnn*.h, i.e. <sdk>/include/QNN — not <sdk>/include.

Upstream’s cache variable documents itself as “Path to pre-downloaded QAIRT/QNN headers (root containing Qnn*.h)” (litert/vendors/CMakeLists.txt:20), and LiteRT’s own vendor code includes it bare:

#include "QnnLog.h"  // from @qairt        <- litert/vendors/qualcomm/core/common.h:13

It is easy to believe the include root works, because upstream does contain a probe that tries <dir> and then <dir>/QNN:

if(NOT QAIRT_HEADERS_DIR)          # :61
  ...
  foreach(_cand IN LISTS _qnn_candidates)          # :86
    if(_cand AND EXISTS "${_cand}/QnnCommon.h")
      set(QAIRT_HEADERS_DIR "${_cand}")
    elseif(_cand AND EXISTS "${_cand}/QNN/QnnCommon.h")
      set(QAIRT_HEADERS_DIR "${_cand}/QNN")        # :91
    endif()
  endforeach()
endif()                                            # :96

That probe is inside if(NOT QAIRT_HEADERS_DIR). It only ever runs on the path where upstream downloads the SDK itself. When we set the variable from the outside — the whole point of staging our own SDK — the block is skipped and the value is consumed verbatim at :268. There is no /QNN fallback.

Symptom when it is wrong, ~7 minutes into the LiteRT build:

litert/vendors/qualcomm/core/common.h:13:10: fatal error: QnnLog.h: No such file or directory
gmake[2]: *** [vendors/qualcomm/core/CMakeFiles/qnn_common.dir/build.make:79] Error 1

_litert_qairt_include_dir (03-media/build/litert/build-litert.sh) now builds the path once for both the configure path and the wheel path, and asserts QnnLog.h is really there before handing it to CMake — so a future SDK layout change fails immediately with a clear message instead of deep inside a compile.

No staged SDK: upstream’s unhashed 1.5 GB download#

When QAIRT_HEADERS_DIR is empty, upstream file(DOWNLOAD)s QAIRT 2.50.0.260828 (LiteRT v2.3.0’s litert/vendors/CMakeLists.txt) from softwarecenter.qualcomm.com — no EXPECTED_HASH, no STATUS check, and not gated on LITERT_ENABLE_QUALCOMM, so it fires even on builds that want no NPU.

It cannot be dodged with a stub path: any non-empty QAIRT_HEADERS_DIR force-enables Qualcomm (:331-334) with headers we do not have. So _litert_disable_qairt_header_download patches the guard itself to if(FALSE). The patch is idempotent and fails loudly if the anchor moves.

MediaTek’s NeuroPilot fetch (AWS S3) and the Samsung LiteCore fetch are dodged with stub header dirs — both gate on the header EXISTING, so a stub is safe.

The guard lives in 03-media/build/litert/android/litert-qairt-guard.sh and is sourced by both lanes. It has to be: the android lane clones its own tree, so the cross lane’s patched copy cannot cover it — which is exactly how it went unnoticed until 2026-09-03 (backlog YA). It sits under android/ because Dockerfile.android COPYs only that directory, the same constraint that put litert-eigen-fetch.sh there.

The android lane can never have a staged SDK anyway: the only zip this repo takes is the Linux aarch64-oe-linux-gcc11.2 build, the wrong ABI for android. So its QAIRT_HEADERS_DIR is always empty and the download always fired — the android build even carries a retry loop written around that download truncating mid-transfer, which was the symptom without the cause.

What is real, and what was invented#

Windows #154 established that TFLITE_ENABLE_QNN, USE_QNN and IREE_TARGET_BACKEND_QNN are not upstream options. CMake drops an undeclared -D silently and exits 0, so all three logged success and did nothing. TVM and IREE have no Qualcomm path at all.

framework

real switch

state

onnxruntime

onnxruntime_USE_QNN

real, validated

LiteRT

LITERT_ENABLE_QUALCOMM, auto-forced by QAIRT_HEADERS_DIR

real, validated

TVM

—

no Qualcomm path; flag removed

IREE

—

no Qualcomm path; flag removed

Validation, 2026-09-03 (media-arm64, real v2.49.0.260730 staged)#

  • QNN: SDK zip SHA256 verified (QNN_SDK_LINUX_ZIP_SHA256) — the existing pin validated the staged zip; no re-pin was needed.

  • ORT: Built target onnxruntime_providers_qnn → libonnxruntime_providers_qnn.so linked.

  • The staged libs land. 45 libQnn*.so were copied into onnxruntime/capi and into the wheel — the full backend set: HTP stubs for V68/V69/V73/V75/V79/V81, LPAI, DSP, GPU, libQnnSystem.so, libQnnSaver.so, and libQnnTFLiteDelegate.so on the LiteRT side.

  • LiteRT: Qualcomm dispatch ON, with LITERT_ENABLE_NPU=ON.

  • First run FAILED at qnn_common on the QAIRT_HEADERS_DIR trap above — which is exactly what this validation existed to find. Fixed, then re-run.