From 913378be9ec3b05d8844f0fbf445b8423d9acd9c Mon Sep 17 00:00:00 2001 From: Abhinay Kukkadapu Date: Wed, 18 Mar 2026 10:44:27 -0700 Subject: [PATCH 1/2] Improve QNN backend AOT user experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Read QNN_VERSION from qnn_config.sh instead of hardcoding in download_qnn_sdk.py, eliminating version duplication - Add download progress bars for SDK (~2.4GB), libc++ (~575MB), and glibc (~10MB) downloads with percentage and MB counters - Add extraction progress for SDK, libc++, and tar archives - Add retry + resume for SDK download — on mid-stream failures (ChunkedEncodingError, ConnectionError), resumes from where it left off using HTTP Range headers instead of restarting - Replace NullHandler logger with configured StreamHandler so users see status messages (previously all output was swallowed) - Cache SDK at ~/.cache/executorch/qnn/sdk-/ instead of inside the pip package directory, surviving pip reinstalls - Include version in cache directory name for automatic upgrade detection when qnn_config.sh version changes - Set both QNN_SDK_ROOT and LD_LIBRARY_PATH after auto-download - Print SDK version and path on first import (cached or fresh) - Print user-provided QNN_SDK_ROOT path when set via env var - Add visible warning before glibc re-exec with guidance - Replace unhelpful "check logs above" error with actionable message including full SDK download URL and env var setup instructions ghstack-source-id: ddb21d7232d4217014e4315e0b0079a6a0abeda9 Pull Request resolved: https://github.com/pytorch/executorch/pull/17989 --- backends/qualcomm/__init__.py | 26 +- backends/qualcomm/scripts/download_qnn_sdk.py | 354 ++++++++++++------ 2 files changed, 269 insertions(+), 111 deletions(-) diff --git a/backends/qualcomm/__init__.py b/backends/qualcomm/__init__.py index 5770dfb0fcd..c7d735a69b9 100644 --- a/backends/qualcomm/__init__.py +++ b/backends/qualcomm/__init__.py @@ -1,13 +1,29 @@ import os -from .scripts.download_qnn_sdk import install_qnn_sdk, is_linux_x86 +from .scripts.download_qnn_sdk import install_qnn_sdk, is_linux_x86, QNN_ZIP_URL env_flag = os.getenv("EXECUTORCH_BUILDING_WHEEL", "0").lower() # If users have preinstalled QNN_SDK_ROOT, we will use it. qnn_sdk_root_flag = os.getenv("QNN_SDK_ROOT", None) -if env_flag not in ("1", "true", "yes") and not qnn_sdk_root_flag and is_linux_x86(): - ok = install_qnn_sdk() - if not ok: - raise RuntimeError("Failed to install QNN SDK. Please check the logs above.") +if env_flag not in ("1", "true", "yes"): + if qnn_sdk_root_flag: + print( + f"[QNN] Using QNN SDK at {qnn_sdk_root_flag} (from QNN_SDK_ROOT)", + flush=True, + ) + elif is_linux_x86(): + ok = install_qnn_sdk() + if not ok: + raise RuntimeError( + "Failed to set up QNN SDK.\n\n" + "To resolve, try one of:\n" + " 1. Download the SDK manually from:\n" + f" {QNN_ZIP_URL}\n" + " Or go to step 2 if QNN SDK already exists.\n" + " 2. Set QNN_SDK_ROOT to an existing SDK installation:\n" + " export QNN_SDK_ROOT=/path/to/qualcomm/sdk\n" + " export LD_LIBRARY_PATH=" + "$QNN_SDK_ROOT/lib/x86_64-linux-clang/:$LD_LIBRARY_PATH" + ) diff --git a/backends/qualcomm/scripts/download_qnn_sdk.py b/backends/qualcomm/scripts/download_qnn_sdk.py index 5524adf8988..7117ef80a9b 100644 --- a/backends/qualcomm/scripts/download_qnn_sdk.py +++ b/backends/qualcomm/scripts/download_qnn_sdk.py @@ -18,10 +18,68 @@ from requests.adapters import HTTPAdapter, Retry logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) +if not logger.handlers: + _handler = logging.StreamHandler(sys.stdout) + _handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(_handler) + logger.setLevel(logging.INFO) PKG_ROOT = pathlib.Path(__file__).parent.parent -SDK_DIR = PKG_ROOT / "sdk" / "qnn" + + +def _progress(msg: str) -> None: + """Print a progress line with carriage return (no newline). Not suited for logging.""" + print(msg, end="", flush=True) + + +def _progress_newline() -> None: + """End a progress line.""" + print(flush=True) + + +########################## +# Version from qnn_config +########################## + + +def _read_qnn_config() -> Dict[str, str]: + """Parse qnn_config.sh to extract QNN_VERSION and QNN_ZIP_URL.""" + config_path = pathlib.Path(__file__).parent / "qnn_config.sh" + config: Dict[str, str] = {} + if not config_path.exists(): + return config + with open(config_path) as f: + for line in f: + line = line.strip() + if line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + # Strip quotes and resolve bash-style ${VAR} references + val = val.strip('"') + config[key.strip()] = val + # Resolve ${QNN_VERSION} in QNN_ZIP_URL + if "QNN_ZIP_URL" in config and "QNN_VERSION" in config: + config["QNN_ZIP_URL"] = config["QNN_ZIP_URL"].replace( + "${QNN_VERSION}", config["QNN_VERSION"] + ) + return config + + +_QNN_CONFIG = _read_qnn_config() +QNN_VERSION = _QNN_CONFIG.get("QNN_VERSION", "2.37.0.250724") +QNN_ZIP_URL = _QNN_CONFIG.get( + "QNN_ZIP_URL", + f"https://softwarecenter.qualcomm.com/api/download/software/sdks/" + f"Qualcomm_AI_Runtime_Community/All/{QNN_VERSION}/v{QNN_VERSION}.zip", +) + + +def _get_sdk_dir() -> pathlib.Path: + """Get the versioned SDK cache directory (e.g. ~/.cache/executorch/qnn/sdk-2.37.0.250724/).""" + try: + return _get_staging_dir(f"sdk-{QNN_VERSION}") + except ValueError: + return PKG_ROOT / "sdk" / "qnn" def is_linux_x86() -> bool: @@ -89,7 +147,7 @@ def _get_staging_dir(*parts: str) -> pathlib.Path: return base.joinpath(*APP_NAMESPACE, *parts) -def _atomic_download(url: str, dest: pathlib.Path): +def _atomic_download(url: str, dest: pathlib.Path, label: str = ""): """ Download URL into dest atomically: - Write to a temp file in the same dir @@ -101,10 +159,23 @@ def _atomic_download(url: str, dest: pathlib.Path): with tempfile.NamedTemporaryFile(dir=dest.parent, delete=False) as tmp: tmp_path = pathlib.Path(tmp.name) + def _reporthook(block_num: int, block_size: int, total_size: int) -> None: + downloaded = block_num * block_size + if total_size > 0: + pct = min(downloaded * 100 / total_size, 100) + dl_mb = downloaded // (1024 * 1024) + total_mb = total_size // (1024 * 1024) + prefix = f"[QNN] Downloading {label}: " if label else "[QNN] Downloading: " + _progress(f"\r{prefix}{dl_mb}/{total_mb} MB ({pct:.0f}%)") + try: - urllib.request.urlretrieve(url, tmp_path) + urllib.request.urlretrieve(url, tmp_path, reporthook=_reporthook) + if label: + _progress_newline() tmp_path.replace(dest) # atomic rename except Exception: + if label: + _progress_newline() # Clean up partial file on failure if tmp_path.exists(): tmp_path.unlink(missing_ok=True) @@ -116,9 +187,57 @@ def _atomic_download(url: str, dest: pathlib.Path): #################### -def _download_archive(url: str, archive_path: pathlib.Path) -> bool: - """Robust streaming download with retries.""" +def _stream_to_file( + session: requests.Session, + url: str, + archive_path: pathlib.Path, + attempt: int, + max_retries: int, +) -> bool: + """Single download attempt with resume support. Returns True on success.""" + downloaded = archive_path.stat().st_size if archive_path.exists() else 0 + headers = {"Range": f"bytes={downloaded}-"} if downloaded > 0 else {} + + with session.get(url, stream=True, headers=headers) as r: + if r.status_code == 200 and downloaded > 0: + downloaded = 0 # Server doesn't support Range — restart + r.raise_for_status() + + total = downloaded + int(r.headers.get("content-length", 0)) + mode = "ab" if downloaded > 0 else "wb" + + if attempt > 1: + dl_mb = downloaded // (1024 * 1024) + total_mb = total // (1024 * 1024) + logger.info( + f"[QNN] Resuming download from {dl_mb}/{total_mb} MB " + f"(attempt {attempt}/{max_retries})..." + ) + + with open(archive_path, mode) as f: + for chunk in r.iter_content(1024 * 1024): + if not chunk: + continue + f.write(chunk) + downloaded += len(chunk) + if total: + pct = downloaded * 100 / total + dl_mb = downloaded // (1024 * 1024) + total_mb = total // (1024 * 1024) + _progress( + f"\r[QNN] Downloading: {dl_mb}/{total_mb} MB ({pct:.0f}%)" + ) + if total: + _progress_newline() + + logger.info("[QNN] Download complete.") + return True + +def _download_archive( + url: str, archive_path: pathlib.Path, max_retries: int = 3 +) -> bool: + """Streaming download with retry + resume on mid-stream failures.""" logger.debug("Archive will be saved to: %s", archive_path) session = requests.Session() @@ -130,30 +249,31 @@ def _download_archive(url: str, archive_path: pathlib.Path) -> bool: ) session.mount("https://", HTTPAdapter(max_retries=retries)) - try: - with session.get(url, stream=True) as r: - r.raise_for_status() - - downloaded = 0 - chunk_size = 1024 * 1024 # 1MB - - with open(archive_path, "wb") as f: - for chunk in r.iter_content(chunk_size): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - logger.info("Download completed!") - - except Exception as e: - logger.exception("Error during download: %s", e) - return False - - if archive_path.exists() and archive_path.stat().st_size == 0: - logger.warning("Downloaded file is empty!") - return False - elif not archive_path.exists(): - logger.error("File was not downloaded!") + for attempt in range(1, max_retries + 1): + try: + if _stream_to_file(session, url, archive_path, attempt, max_retries): + break + except ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ) as e: + _progress_newline() + if attempt < max_retries: + logger.warning( + f"[QNN] Download interrupted: {type(e).__name__}. " + f"Retrying ({attempt}/{max_retries})..." + ) + else: + logger.error(f"[QNN] Download failed after {max_retries} attempts: {e}") + return False + except Exception as e: + _progress_newline() + logger.error(f"[QNN] Download error: {e}") + return False + + if not archive_path.exists() or archive_path.stat().st_size == 0: + logger.error("[QNN] Downloaded file is empty or missing!") return False return True @@ -163,11 +283,10 @@ def _extract_archive( url: str, archive_path: pathlib.Path, content_dir: str, dst_folder: pathlib.Path ): """Extract archive based on type (zip or tar).""" + logger.info("[QNN] Extracting SDK...") if url.endswith(".zip"): - logger.info("Extracting ZIP archive...") _extract_zip(archive_path, content_dir, dst_folder) elif url.endswith((".tar.gz", ".tgz")): - logger.info("Extracting TAR archive...") _extract_tar(archive_path, content_dir, dst_folder) else: raise ValueError(f"Unsupported archive format: {url}") @@ -175,56 +294,55 @@ def _extract_archive( def _verify_extraction(dst_folder: pathlib.Path): """Check if extraction succeeded and log contents.""" - logger.info("Verifying extraction to %s", dst_folder) if dst_folder.exists(): logger.debug("SDK directory exists. Contents:") for item in dst_folder.iterdir(): logger.debug(" %s", item.name) else: - logger.error("SDK directory was not created!") + logger.error("[QNN] Error: SDK directory was not created!") -def _download_qnn_sdk(dst_folder=SDK_DIR) -> Optional[pathlib.Path]: +def _download_qnn_sdk( + dst_folder: Optional[pathlib.Path] = None, +) -> Optional[pathlib.Path]: """ Download and extract the Qualcomm SDK into dst_folder. Only runs on Linux x86 platforms. """ - QNN_VERSION = "2.37.0.250724" - logger.info("Downloading Qualcomm SDK...") - QAIRT_URL = ( - f"https://softwarecenter.qualcomm.com/api/download/software/sdks/" - f"Qualcomm_AI_Runtime_Community/All/{QNN_VERSION}/v{QNN_VERSION}.zip" - ) - QAIRT_CONTENT_DIR = f"qairt/{QNN_VERSION}" + if dst_folder is None: + dst_folder = _get_sdk_dir() + + qairt_content_dir = f"qairt/{QNN_VERSION}" if not is_linux_x86(): logger.info("[QNN] Skipping Qualcomm SDK (only supported on Linux x86).") return None - else: - logger.info("[QNN] Downloading Qualcomm SDK for Linux x86") + + logger.info(f"[QNN] Downloading Qualcomm AI Runtime SDK v{QNN_VERSION}...") dst_folder.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as tmpdir: - archive_path = pathlib.Path(tmpdir) / pathlib.Path(QAIRT_URL).name - if not _download_archive(QAIRT_URL, archive_path): + archive_path = pathlib.Path(tmpdir) / pathlib.Path(QNN_ZIP_URL).name + if not _download_archive(QNN_ZIP_URL, archive_path): return None - _extract_archive(QAIRT_URL, archive_path, QAIRT_CONTENT_DIR, dst_folder) + _extract_archive(QNN_ZIP_URL, archive_path, qairt_content_dir, dst_folder) _verify_extraction(dst_folder) + logger.info(f"[QNN] QNN SDK v{QNN_VERSION} ready at {dst_folder}") return dst_folder def _extract_zip(archive_path, content_dir, target_dir): logger.debug("Extracting %s to %s", archive_path, target_dir) - logger.debug("Looking for content in subdirectory: %s", content_dir) target_dir.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(archive_path, "r") as zip_ref: files_to_extract = [f for f in zip_ref.namelist() if f.startswith(content_dir)] + total = len(files_to_extract) - for file in files_to_extract: + for i, file in enumerate(files_to_extract): relative_path = pathlib.Path(file).relative_to(content_dir) if relative_path == pathlib.Path("."): continue @@ -237,12 +355,19 @@ def _extract_zip(archive_path, content_dir, target_dir): with zip_ref.open(file) as src, open(out_path, "wb") as dst: shutil.copyfileobj(src, dst) + if total > 0: + pct = (i + 1) * 100 // total + _progress(f"\r[QNN] Extracting SDK: {pct}%") + if total > 0: + _progress_newline() + def _extract_tar(archive_path: pathlib.Path, prefix: str, target_dir: pathlib.Path): with tarfile.open(archive_path, "r:gz") as tf: - for m in tf.getmembers(): - if not m.name.startswith(prefix + "/"): - continue + members = [m for m in tf.getmembers() if m.name.startswith(prefix + "/")] + total = len(members) + + for i, m in enumerate(members): relpath = pathlib.Path(m.name).relative_to(prefix) if not relpath.parts or relpath.parts[0] == "..": continue @@ -258,6 +383,12 @@ def _extract_tar(archive_path: pathlib.Path, prefix: str, target_dir: pathlib.Pa with src, open(out_path, "wb") as dst: dst.write(src.read()) + if total > 0: + pct = (i + 1) * 100 // total + _progress(f"\r[QNN] Extracting SDK: {pct}%") + if total > 0: + _progress_newline() + #################### # libc management @@ -302,8 +433,8 @@ def _resolve_glibc_loader() -> pathlib.Path | None: def _stage_prebuilt_glibc(): - """Download + extract Fedora 35 glibc RPM into /tmp.""" - logger.info(">>> Staging prebuilt glibc-%s from Fedora 35 RPM", GLIBC_VERSION) + """Download + extract Fedora 35 glibc RPM.""" + logger.info(f"[QNN] Staging glibc {GLIBC_VERSION}...") _get_glibc_libdir().mkdir(parents=True, exist_ok=True) rpm_path = _get_staging_dir("glibc") / "glibc.rpm" work_dir = _get_staging_dir("glibc") / "extracted" @@ -313,11 +444,20 @@ def _stage_prebuilt_glibc(): ) rpm_path.parent.mkdir(parents=True, exist_ok=True) - logger.info("[glibc] Downloading %s -> %s", rpm_url, rpm_path) + + def _reporthook(block_num: int, block_size: int, total_size: int) -> None: + downloaded = block_num * block_size + if total_size > 0: + pct = min(downloaded * 100 / total_size, 100) + dl_mb = downloaded // (1024 * 1024) + total_mb = total_size // (1024 * 1024) + _progress(f"\r[QNN] Downloading glibc: {dl_mb}/{total_mb} MB ({pct:.0f}%)") + try: - urllib.request.urlretrieve(rpm_url, rpm_path) + urllib.request.urlretrieve(rpm_url, rpm_path, reporthook=_reporthook) + _progress_newline() except Exception as e: - logger.error("[glibc] Failed to download %s: %s", rpm_url, e) + logger.error(f"\n[QNN] Failed to download glibc: {e}") raise # Extract @@ -340,7 +480,7 @@ def _stage_prebuilt_glibc(): src = work_dir / "lib64" / lib if src.exists(): shutil.copy2(src, _get_glibc_libdir() / lib) - logger.info("[glibc] Staged %s", lib) + logger.debug("[glibc] Staged %s", lib) else: logger.warning("[glibc] Missing %s in RPM", lib) @@ -352,17 +492,16 @@ def ensure_glibc_minimum(min_version: str = GLIBC_VERSION): - Else → stage Fedora RPM and re-exec under staged loader. """ current = _current_glibc_version() - logger.info("[glibc] Current loaded glibc: %s", current) + logger.debug("[glibc] Current loaded glibc: %s", current) # If system glibc already sufficient → skip everything m = re.match(r"(\d+\.\d+)", current) if m and _parse_version(m.group(1)) >= _parse_version(min_version): - logger.info("[glibc] System glibc >= %s, no staging needed.", min_version) return # Avoid infinite loop if os.environ.get(GLIBC_REEXEC_GUARD) == "1": - logger.info("[glibc] Already re-exec'd once, continuing.") + logger.debug("[glibc] Already re-exec'd once, continuing.") return # Stage prebuilt if not already staged @@ -371,11 +510,13 @@ def ensure_glibc_minimum(min_version: str = GLIBC_VERSION): loader = _resolve_glibc_loader() if not loader: - logger.error("[glibc] Loader not found in %s", _get_glibc_libdir()) + logger.error(f"[QNN] Warning: glibc loader not found in {_get_glibc_libdir()}") return - logger.info( - "[glibc] Re-execing under loader %s with libdir %s", loader, _get_glibc_libdir() + logger.error( + f"[QNN] System glibc ({current}) is older than required ({min_version}).\n" + "[QNN] Re-launching Python under a staged glibc loader.\n" + "[QNN] To avoid this, set QNN_SDK_ROOT and LD_LIBRARY_PATH manually." ) os.environ[GLIBC_REEXEC_GUARD] = "1" os.execv( @@ -403,7 +544,7 @@ def _stage_libcxx(target_dir: pathlib.Path): target_dir.mkdir(parents=True, exist_ok=True) if all((target_dir / libname).exists() for libname in REQUIRED_LIBCXX_LIBS): - logger.info("[libcxx] Already staged at %s, skipping download", target_dir) + logger.debug("[libcxx] Already staged at %s, skipping download", target_dir) return libcxx_stage = _get_staging_dir(f"libcxx-{LLVM_VERSION}") @@ -411,16 +552,24 @@ def _stage_libcxx(target_dir: pathlib.Path): temp_extract = libcxx_stage / LIBCXX_BASE_NAME if not temp_tar.exists(): - logger.info("[libcxx] Downloading %s", LLVM_URL) - _atomic_download(LLVM_URL, temp_tar) + logger.info(f"[QNN] Downloading libc++ (LLVM {LLVM_VERSION})...") + _atomic_download(LLVM_URL, temp_tar, label="libc++") # Sanity check before extracting if not temp_tar.exists() or temp_tar.stat().st_size == 0: raise FileNotFoundError(f"[libcxx] Tarball missing or empty: {temp_tar}") - logger.info("[libcxx] Extracting %s", temp_tar) + logger.info("[QNN] Extracting libc++...") with tarfile.open(temp_tar, "r:xz") as tar: - tar.extractall(temp_extract.parent) + members = tar.getmembers() + total = len(members) + for i, member in enumerate(members): + tar.extract(member, temp_extract.parent) + if total > 0: + pct = (i + 1) * 100 // total + _progress(f"\r[QNN] Extracting libc++: {pct}%") + if total > 0: + _progress_newline() lib_src = temp_extract / "lib" / "x86_64-unknown-linux-gnu" for fname in REQUIRED_LIBCXX_LIBS: @@ -432,7 +581,7 @@ def _stage_libcxx(target_dir: pathlib.Path): continue shutil.copy(src_path, target_dir / fname) - logger.info("[libcxx] Staged libc++ to %s", target_dir) + logger.debug("[libcxx] Staged libc++ to %s", target_dir) REQUIRED_QNN_LIBS: List[str] = [ @@ -492,33 +641,34 @@ def _ensure_qnn_sdk_lib() -> bool: """ all_present, locs = _check_libs_in_ld(REQUIRED_QNN_LIBS) if all_present: - logger.info( - "[QNN] libQnnHtp.so found in LD_LIBRARY_PATH; skipping SDK install." - ) - for lib, p in locs.items(): - logger.info(" - %s: %s", lib, p) + logger.info("[QNN] Using QNN SDK libs from LD_LIBRARY_PATH") return True - # Not found → use packaged SDK - qnn_sdk_dir = SDK_DIR - logger.info("[QNN] libQnnHtp.so not found in LD_LIBRARY_PATH.") + # Not found → use cached/packaged SDK + # Directory name includes version (sdk-X.Y.Z), so a version bump + # in qnn_config.sh naturally creates a new directory. + qnn_sdk_dir = _get_sdk_dir() if not qnn_sdk_dir.exists(): - logger.info("[QNN] SDK dir missing; downloading...") - _download_qnn_sdk() + _download_qnn_sdk(qnn_sdk_dir) else: - logger.info("[QNN] Using existing SDK at %s", qnn_sdk_dir) + logger.info(f"[QNN] Using cached QNN SDK v{QNN_VERSION} at {qnn_sdk_dir}") os.environ["QNN_SDK_ROOT"] = str(qnn_sdk_dir) + sdk_lib_dir = str(qnn_sdk_dir / "lib" / "x86_64-linux-clang") + ld_path = os.environ.get("LD_LIBRARY_PATH", "") + if sdk_lib_dir not in ld_path: + os.environ["LD_LIBRARY_PATH"] = ( + f"{sdk_lib_dir}:{ld_path}" if ld_path else sdk_lib_dir + ) + qnn_lib = qnn_sdk_dir / "lib" / "x86_64-linux-clang" / "libQnnHtp.so" - logger.info("[QNN] Loading %s", qnn_lib) lib_loaded = False try: ctypes.CDLL(str(qnn_lib), mode=ctypes.RTLD_GLOBAL) - logger.info("[QNN] Loaded libQnnHtp.so from packaged SDK.") lib_loaded = True except OSError as e: - logger.error("[QNN][ERROR] Failed to load %s: %s", qnn_lib, e) + logger.error(f"[QNN] Failed to load {qnn_lib}: {e}") return lib_loaded @@ -550,24 +700,19 @@ def _ensure_libcxx_stack() -> bool: """ all_present, locs = _check_libs_in_ld(REQUIRED_LIBCXX_LIBS) if all_present: - logger.info( + logger.debug( "[libcxx] All libc++ libs present in LD_LIBRARY_PATH; skipping staging." ) - for lib, p in locs.items(): - logger.info(" - %s: %s", lib, p) return True - logger.info( - "[libcxx] Some libc++ libs missing in LD_LIBRARY_PATH; staging packaged libc++..." - ) lib_loaded = False try: - libcxx_dir = PKG_ROOT / "sdk" / f"libcxx-{LLVM_VERSION}" + libcxx_dir = _get_staging_dir(f"libcxx-{LLVM_VERSION}") _stage_libcxx(libcxx_dir) _load_libcxx_libs(libcxx_dir) - logger.info("[libcxx] Staged and loaded libc++ from %s", libcxx_dir) lib_loaded = True except Exception as e: + logger.error(f"[QNN] Failed to stage/load libc++: {e}") logger.exception("[libcxx][ERROR] Failed to stage/load libc++: %s", e) return lib_loaded @@ -577,21 +722,16 @@ def _ensure_libcxx_stack() -> bool: # --------------- def install_qnn_sdk() -> bool: """ - Initialize Qualcomm backend with separated logic: + Initialize Qualcomm backend: - QNN SDK: - - If libQnnHtp.so exists in LD_LIBRARY_PATH: do nothing. - - Else: ensure packaged SDK, load libQnnHtp.so. - - libc++ stack: - - If required libc++ libs exist in LD_LIBRARY_PATH: do nothing. - - Else: stage and load packaged libc++. + 1. Ensure glibc >= 2.34 (may re-exec under staged loader) + 2. Ensure libc++ is available (download + stage if needed) + 3. Ensure QNN SDK is available (download if needed, detect version upgrades) + 4. Set QNN_SDK_ROOT and LD_LIBRARY_PATH Returns: - True if both steps succeeded (or were already satisfied), else False. + True if all steps succeeded (or were already satisfied), else False. """ - logger.info("[QNN] Starting SDK installation") - # Make sure we’re running under >= 2.34 ensure_glibc_minimum(GLIBC_VERSION) @@ -606,7 +746,7 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument( "--dst-folder", type=pathlib.Path, - default=SDK_DIR, + default=None, help="Destination directory for the Qualcomm SDK.", ) parser.add_argument( @@ -623,13 +763,15 @@ def main(argv: Optional[List[str]] = None) -> int: logging.basicConfig(level=logging.INFO) + dst = args.dst_folder if args.dst_folder else _get_sdk_dir() + sdk_path: Optional[pathlib.Path] if args.install_sdk: if not install_qnn_sdk(): return 1 - sdk_path = pathlib.Path(os.environ.get("QNN_SDK_ROOT", args.dst_folder)) + sdk_path = pathlib.Path(os.environ.get("QNN_SDK_ROOT", dst)) else: - sdk_path = _download_qnn_sdk(dst_folder=args.dst_folder) + sdk_path = _download_qnn_sdk(dst_folder=dst) if sdk_path is None: return 1 From 7dfe35a5153ace3c7618ba60e9b4d466561d774b Mon Sep 17 00:00:00 2001 From: Abhinay Kukkadapu Date: Wed, 18 Mar 2026 10:44:28 -0700 Subject: [PATCH 2/2] Improve QNN backend build-from-source user experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove EXECUTORCH_BUILD_WHEEL_DO_NOT_USE gate in CMakeLists.txt so QNN SDK auto-downloads for all build paths (editable installs, build.sh), not just wheel builds - Use shared cache dir (~/.cache/executorch/qnn/) instead of cmake build dir, so SDK survives build dir cleans and is shared across all build flows - Remove hard QNN_SDK_ROOT requirement from build.sh — cmake handles auto-download during configure when SDK is not set - Auto-download Android NDK in build.sh via install_qnn_sdk.sh when ANDROID_NDK_ROOT is not set - Fix PYTHON_EXECUTABLE default in build.sh (was checked before being used, with a buggy test that never triggered) - Default EXECUTORCH_BUILD_QNN to ON on Linux x86 in pybind preset, matching wheel behavior so editable installs build QNN out of the box - Redirect download_qnn_sdk.py progress output to stderr when --print-sdk-path is used, so cmake can capture the path cleanly ghstack-source-id: 7a0428cc3af54fa82d0cd39c5117540ae40b0b93 Pull Request resolved: https://github.com/pytorch/executorch/pull/17990 --- backends/qualcomm/CMakeLists.txt | 71 ++++++++----------- backends/qualcomm/scripts/build.sh | 51 ++++++++----- backends/qualcomm/scripts/download_qnn_sdk.py | 21 +++++- tools/cmake/preset/pybind.cmake | 2 +- 4 files changed, 84 insertions(+), 61 deletions(-) diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt index e93731ec34e..c1f96b1df14 100644 --- a/backends/qualcomm/CMakeLists.txt +++ b/backends/qualcomm/CMakeLists.txt @@ -33,53 +33,40 @@ if(NOT DEFINED QNN_SDK_ROOT AND DEFINED ENV{QNN_SDK_ROOT}) ) endif() -# Last-resort fallback: download during cmake configure when building wheels and -# QNN_SDK_ROOT was not provided externally. -if(NOT DEFINED QNN_SDK_ROOT AND EXECUTORCH_BUILD_WHEEL_DO_NOT_USE) - set(_qnn_default_sdk_dir "${CMAKE_CURRENT_BINARY_DIR}/sdk/qnn") - - if(EXISTS "${_qnn_default_sdk_dir}" AND EXISTS "${_qnn_default_sdk_dir}/lib") - message(STATUS "Found cached Qualcomm SDK at ${_qnn_default_sdk_dir}") - set(QNN_SDK_ROOT - ${_qnn_default_sdk_dir} - CACHE PATH "Qualcomm SDK root directory" FORCE - ) - else() - message(STATUS "Downloading Qualcomm SDK (fallback)") - execute_process( - COMMAND - ${PYTHON_EXECUTABLE} - ${EXECUTORCH_SOURCE_DIR}/backends/qualcomm/scripts/download_qnn_sdk.py - --dst-folder ${_qnn_default_sdk_dir} --print-sdk-path - WORKING_DIRECTORY ${EXECUTORCH_SOURCE_DIR} - RESULT_VARIABLE _qnn_sdk_download_result - OUTPUT_VARIABLE _qnn_sdk_download_output - ERROR_VARIABLE _qnn_sdk_download_error - OUTPUT_STRIP_TRAILING_WHITESPACE - ) - if(NOT _qnn_sdk_download_result EQUAL 0 OR _qnn_sdk_download_output - STREQUAL "" - ) - message( - FATAL_ERROR - "Failed to download Qualcomm SDK. stdout: ${_qnn_sdk_download_output}\n" - "stderr: ${_qnn_sdk_download_error}" - ) - endif() - set(QNN_SDK_ROOT - ${_qnn_sdk_download_output} - CACHE PATH "Qualcomm SDK root directory" FORCE +# Auto-download QNN SDK when QNN_SDK_ROOT was not provided externally. The SDK +# is cached in ~/.cache/executorch/qnn/ so it survives build dir cleans and is +# shared across editable installs, build.sh, and wheel builds. +if(NOT DEFINED QNN_SDK_ROOT) + message(STATUS "QNN_SDK_ROOT not set. Auto-downloading QNN SDK...") + execute_process( + COMMAND + ${PYTHON_EXECUTABLE} + ${EXECUTORCH_SOURCE_DIR}/backends/qualcomm/scripts/download_qnn_sdk.py + --print-sdk-path + WORKING_DIRECTORY ${EXECUTORCH_SOURCE_DIR} + RESULT_VARIABLE _qnn_sdk_download_result + OUTPUT_VARIABLE _qnn_sdk_download_output + ERROR_VARIABLE _qnn_sdk_download_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT _qnn_sdk_download_result EQUAL 0 OR _qnn_sdk_download_output STREQUAL + "" + ) + message( + FATAL_ERROR + "Failed to download Qualcomm SDK.\n" + "stderr: ${_qnn_sdk_download_error}\n" + "Set QNN_SDK_ROOT manually, e.g. cmake <..> -DQNN_SDK_ROOT=<...>" ) endif() + set(QNN_SDK_ROOT + ${_qnn_sdk_download_output} + CACHE PATH "Qualcomm SDK root directory" FORCE + ) set(ENV{QNN_SDK_ROOT} ${QNN_SDK_ROOT}) endif() -if(NOT DEFINED QNN_SDK_ROOT) - message( - FATAL_ERROR - "Please define QNN_SDK_ROOT, e.g. cmake <..> -DQNN_SDK_ROOT=<...>" - ) -elseif(CMAKE_TOOLCHAIN_FILE MATCHES ".*(iOS|ios\.toolchain)\.cmake$") +if(CMAKE_TOOLCHAIN_FILE MATCHES ".*(iOS|ios\.toolchain)\.cmake$") message(FATAL_ERROR "ios is not supported by Qualcomm AI Engine Direct") endif() diff --git a/backends/qualcomm/scripts/build.sh b/backends/qualcomm/scripts/build.sh index 52fbfb50eb1..ac84b261f33 100755 --- a/backends/qualcomm/scripts/build.sh +++ b/backends/qualcomm/scripts/build.sh @@ -16,21 +16,34 @@ if [[ "$(uname -s)" == "Darwin" ]]; then exit 1 fi -if [[ -z ${QNN_SDK_ROOT} ]]; then - echo "Please export QNN_SDK_ROOT=/path/to/qnn_sdk" - exit -1 -fi +SCRIPT_DIR="$( cd "$(dirname "$0")" ; pwd -P)" + +# If QNN_SDK_ROOT is set, pass it to cmake. Otherwise cmake will +# auto-download the SDK via download_qnn_sdk.py during configure. +if [[ -n ${QNN_SDK_ROOT} ]]; then + QNN_SDK_CMAKE_FLAG="-DQNN_SDK_ROOT=${QNN_SDK_ROOT}" + # Ensure LD_LIBRARY_PATH includes QNN SDK libs + QNN_LIB_DIR="${QNN_SDK_ROOT}/lib/x86_64-linux-clang" + if [[ -d "${QNN_LIB_DIR}" ]] && [[ ":${LD_LIBRARY_PATH:-}:" != *":${QNN_LIB_DIR}:"* ]]; then + export LD_LIBRARY_PATH="${QNN_LIB_DIR}:${LD_LIBRARY_PATH:-}" + fi +else + QNN_SDK_CMAKE_FLAG="" + echo "[QNN] QNN_SDK_ROOT not set. SDK will be auto-downloaded during cmake configure." +fi set -o xtrace usage() { echo "Usage: Build the aarch64 version of executor runner or the python interface of Qnn Manager" - echo "First, you need to set the environment variable for QNN_SDK_ROOT" - echo ", and if you want to build the android version of executor runner" - echo ", you need to export ANDROID_NDK_ROOT=/path/to/android_ndkXX" + echo "" + echo "QNN SDK and Android NDK will be auto-downloaded if not set." + echo "To use a custom SDK, export QNN_SDK_ROOT=/path/to/qnn_sdk" + echo "To use a custom NDK, export ANDROID_NDK_ROOT=/path/to/android_ndkXX" echo "(or export TOOLCHAIN_ROOT_HOST=/path/to/sysroots/xx_host, " echo "TOOLCHAIN_ROOT_TARGET=/path/to/sysroots/xx_target for linux embedded with --enable_linux_embedded)" + echo "" echo "e.g.: executorch$ ./backends/qualcomm/scripts/build.sh --skip_x86_64" exit 1 } @@ -51,13 +64,13 @@ BUILD_TYPE="RelWithDebInfo" BUILD_JOB_NUMBER="16" # Default to use CDSP for now -DSP_TYPE=3 +DSP_TYPE=3 -if [ -z PYTHON_EXECUTABLE ]; then +if [ -z "$PYTHON_EXECUTABLE" ]; then PYTHON_EXECUTABLE="python3" fi -if [ -z BUCK2 ]; then +if [ -z "$BUCK2" ]; then BUCK2="buck2" fi @@ -85,8 +98,14 @@ PRJ_ROOT="$( cd "$(dirname "$0")/../../.." ; pwd -P)" if [ "$BUILD_ANDROID" = true ]; then if [[ -z ${ANDROID_NDK_ROOT} ]]; then - echo "Please export ANDROID_NDK_ROOT=/path/to/android_ndkXX" - exit -1 + echo "[QNN] ANDROID_NDK_ROOT not set. Auto-downloading Android NDK..." + source "${SCRIPT_DIR}/install_qnn_sdk.sh" + setup_android_ndk + if [[ -z ${ANDROID_NDK_ROOT} ]]; then + echo "[QNN] Error: Failed to download Android NDK." + echo "[QNN] Set ANDROID_NDK_ROOT manually." + exit 1 + fi fi BUILD_ROOT=$PRJ_ROOT/$CMAKE_ANDROID @@ -112,7 +131,7 @@ if [ "$BUILD_ANDROID" = true ]; then -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON \ -DEXECUTORCH_ENABLE_EVENT_TRACER=ON \ -DEXECUTORCH_ENABLE_LOGGING=ON \ - -DQNN_SDK_ROOT=$QNN_SDK_ROOT \ + ${QNN_SDK_CMAKE_FLAG} \ -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake \ -DANDROID_ABI='arm64-v8a' \ -DEXECUTORCH_BUILD_KERNELS_QUANTIZED=ON \ @@ -126,7 +145,7 @@ if [ "$BUILD_ANDROID" = true ]; then CMAKE_PREFIX_PATH="${BUILD_ROOT};${BUILD_ROOT}/third-party/gflags;" # DSP_TYPE variable only matters when building direct_mode. - # Ignore the variable for traditional mode. + # Ignore the variable for traditional mode. if [ "$BUILD_HEXAGON" = "true" ]; then DIRECT_MODE_FLAG="-DBUILD_DIRECT_MODE=ON" @@ -274,7 +293,7 @@ if [ "$BUILD_OE_LINUX" = true ]; then -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON \ -DEXECUTORCH_ENABLE_EVENT_TRACER=ON \ -DEXECUTORCH_ENABLE_LOGGING=ON \ - -DQNN_SDK_ROOT=$QNN_SDK_ROOT \ + ${QNN_SDK_CMAKE_FLAG} \ -DEXECUTORCH_BUILD_KERNELS_QUANTIZED=ON \ -DPYTHON_EXECUTABLE=$PYTHON_EXECUTABLE \ -B$BUILD_ROOT @@ -335,7 +354,7 @@ if [ "$BUILD_X86_64" = true ]; then cmake \ -DCMAKE_BUILD_TYPE=$BUILD_TYPE \ -DCMAKE_INSTALL_PREFIX=$BUILD_ROOT \ - -DQNN_SDK_ROOT=${QNN_SDK_ROOT} \ + ${QNN_SDK_CMAKE_FLAG} \ -DEXECUTORCH_BUILD_QNN=ON \ -DEXECUTORCH_BUILD_DEVTOOLS=ON \ -DEXECUTORCH_BUILD_EXTENSION_LLM=ON \ diff --git a/backends/qualcomm/scripts/download_qnn_sdk.py b/backends/qualcomm/scripts/download_qnn_sdk.py index 7117ef80a9b..896d96b0cb7 100644 --- a/backends/qualcomm/scripts/download_qnn_sdk.py +++ b/backends/qualcomm/scripts/download_qnn_sdk.py @@ -23,18 +23,23 @@ _handler.setFormatter(logging.Formatter("%(message)s")) logger.addHandler(_handler) logger.setLevel(logging.INFO) + logger.propagate = False PKG_ROOT = pathlib.Path(__file__).parent.parent +# Output stream for progress messages. Defaults to stdout, but redirected to +# stderr when --print-sdk-path is used (so stdout only contains the path). +_output_stream = sys.stdout + def _progress(msg: str) -> None: """Print a progress line with carriage return (no newline). Not suited for logging.""" - print(msg, end="", flush=True) + print(msg, end="", flush=True, file=_output_stream) def _progress_newline() -> None: """End a progress line.""" - print(flush=True) + print(flush=True, file=_output_stream) ########################## @@ -317,6 +322,10 @@ def _download_qnn_sdk( logger.info("[QNN] Skipping Qualcomm SDK (only supported on Linux x86).") return None + if dst_folder.exists() and any(dst_folder.iterdir()): + logger.info(f"[QNN] Using cached QNN SDK v{QNN_VERSION} at {dst_folder}") + return dst_folder + logger.info(f"[QNN] Downloading Qualcomm AI Runtime SDK v{QNN_VERSION}...") dst_folder.mkdir(parents=True, exist_ok=True) @@ -761,6 +770,14 @@ def main(argv: Optional[List[str]] = None) -> int: ) args = parser.parse_args(argv) + # When --print-sdk-path is used, stdout must contain ONLY the SDK path. + # Redirect all logger and progress output to stderr. + if args.print_sdk_path: + global _output_stream + _output_stream = sys.stderr + for handler in logger.handlers: + handler.stream = sys.stderr + logging.basicConfig(level=logging.INFO) dst = args.dst_folder if args.dst_folder else _get_sdk_dir() diff --git a/tools/cmake/preset/pybind.cmake b/tools/cmake/preset/pybind.cmake index 699a7c50358..a0d06d74d17 100644 --- a/tools/cmake/preset/pybind.cmake +++ b/tools/cmake/preset/pybind.cmake @@ -37,7 +37,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") set_overridable_option(EXECUTORCH_BUILD_EXTENSION_LLM_RUNNER ON) set_overridable_option(EXECUTORCH_BUILD_EXTENSION_LLM ON) if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|i.86)$") - set_overridable_option(EXECUTORCH_BUILD_QNN OFF) + set_overridable_option(EXECUTORCH_BUILD_QNN ON) endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "WIN32"