diff --git a/.ci/scripts/test_wheel_package_qnn.sh b/.ci/scripts/test_wheel_package_qnn.sh new file mode 100644 index 00000000000..39c52a4a396 --- /dev/null +++ b/.ci/scripts/test_wheel_package_qnn.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# === CI Wheel Build & Test Script === + +# Exit immediately on error, print each command, and capture all output to build.log +set -e +set -x +exec > >(tee -i build.log) 2>&1 + +# Save repo root +REPO_ROOT=$(pwd) + +# ---------------------------- +# Dynamically create script_qnn_wheel_test.py +# ---------------------------- +cat > "/tmp/script_qnn_wheel_test.py" << 'EOF' +# pyre-ignore-all-errors +import argparse + +import torch +from executorch.backends.qualcomm.quantizer.quantizer import QnnQuantizer +from executorch.backends.qualcomm.utils.utils import ( + generate_htp_compiler_spec, + generate_qnn_executorch_compiler_spec, + get_soc_to_chipset_map, + to_edge_transform_and_lower_to_qnn, +) +from executorch.exir.backend.utils import format_delegated_graph +from executorch.examples.models.model_factory import EagerModelFactory +from executorch.exir.capture._config import ExecutorchBackendConfig +from executorch.extension.export_util.utils import save_pte_program +from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e, prepare_qat_pt2e + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("-f", "--output_folder", type=str, default="", help="The folder to store the exported program") + parser.add_argument("--soc", type=str, default="SM8650", help="Specify the SoC model.") + parser.add_argument("-q", "--quantization", choices=["ptq", "qat"], help="Run post-traininig quantization.") + args = parser.parse_args() + + class LinearModule(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(3, 3) + def forward(self, arg): + return self.linear(arg) + def get_example_inputs(self): + return (torch.randn(3, 3),) + + model = LinearModule() + example_inputs = model.get_example_inputs() + + if args.quantization: + quantizer = QnnQuantizer() + m = torch.export.export(model.eval(), example_inputs, strict=True).module() + if args.quantization == "qat": + m = prepare_qat_pt2e(m, quantizer) + m(*example_inputs) + elif args.quantization == "ptq": + m = prepare_pt2e(m, quantizer) + m(*example_inputs) + m = convert_pt2e(m) + else: + m = model + + use_fp16 = True if args.quantization is None else False + backend_options = generate_htp_compiler_spec(use_fp16=use_fp16) + compile_spec = generate_qnn_executorch_compiler_spec( + soc_model=get_soc_to_chipset_map()[args.soc], + backend_options=backend_options, + ) + delegated_program = to_edge_transform_and_lower_to_qnn(m, example_inputs, compile_spec) + output_graph = format_delegated_graph(delegated_program.exported_program().graph_module) + # Ensure QnnBackend is in the output graph + assert "QnnBackend" in output_graph + executorch_program = delegated_program.to_executorch( + config=ExecutorchBackendConfig(extract_delegate_segments=False) + ) + save_pte_program(executorch_program, "linear", args.output_folder) + +if __name__ == "__main__": + main() +EOF + +# ---------------------------- +# Wheel build and .so checks +# ---------------------------- +echo "=== Building Wheel Package ===" +source .ci/scripts/utils.sh +install_executorch +EXECUTORCH_BUILDING_WHEEL=1 python setup.py bdist_wheel +unset EXECUTORCH_BUILDING_WHEEL + +WHEEL_FILE=$(ls dist/*.whl | head -n 1) +echo "Found wheel: $WHEEL_FILE" + +PYTHON_VERSION=$1 +# ---------------------------- +# Check wheel does NOT contain qualcomm/sdk +# ---------------------------- +echo "Checking wheel does not contain qualcomm/sdk..." +SDK_FILES=$(unzip -l "$WHEEL_FILE" | awk '{print $4}' | grep "executorch/backends/qualcomm/sdk" || true) +if [ -n "$SDK_FILES" ]; then + echo "ERROR: Wheel package contains unexpected qualcomm/sdk files:" + echo "$SDK_FILES" + exit 1 +else + echo "OK: No qualcomm/sdk files found in wheel" +fi + +# ---------------------------- +# Check .so files in the wheel +# ---------------------------- +echo "Checking for .so files inside the wheel..." +WHEEL_SO_FILES=$(unzip -l "$WHEEL_FILE" | awk '{print $4}' | grep "executorch/backends/qualcomm/python" || true) +if [ -z "$WHEEL_SO_FILES" ]; then + echo "ERROR: No .so files found in wheel under executorch/backends/qualcomm/python" + exit 1 +else + echo "Wheel contains the following .so files:" + echo "$WHEEL_SO_FILES" +fi + +# ---------------------------- +# Helpers +# ---------------------------- +get_site_packages_dir () { + local PYBIN="$1" + "$PYBIN" - <<'PY' +import sysconfig, sys +print(sysconfig.get_paths().get("purelib") or sysconfig.get_paths().get("platlib")) +PY +} + +run_core_tests () { + local PYBIN="$1" # path to python + local PIPBIN="$2" # path to pip + local LABEL="$3" # label to print (conda/venv) + + echo "=== [$LABEL] Installing wheel & deps ===" + "$PIPBIN" install --upgrade pip + "$PIPBIN" install "$WHEEL_FILE" + "$PIPBIN" install torch=="2.9.0.dev20250906" --index-url "https://download.pytorch.org/whl/nightly/cpu" + "$PIPBIN" install --pre torchao --index-url "https://download.pytorch.org/whl/nightly/cpu" + + echo "=== [$LABEL] Import smoke tests ===" + "$PYBIN" -c "import executorch; print('executorch imported successfully')" + "$PYBIN" -c "import executorch.backends.qualcomm; print('executorch.backends.qualcomm imported successfully')" + + echo "=== [$LABEL] List installed executorch/backends/qualcomm/python ===" + local SITE_DIR + SITE_DIR="$(get_site_packages_dir "$PYBIN")" + local SO_DIR="$SITE_DIR/executorch/backends/qualcomm/python" + ls -l "$SO_DIR" || echo "Folder does not exist!" + + echo "=== [$LABEL] Run export script to generate linear.pte ===" + (cd "$REPO_ROOT" && "$PYBIN" "/tmp/script_qnn_wheel_test.py") + + if [ -f "$REPO_ROOT/linear.pte" ]; then + echo "[$LABEL] Model file linear.pte successfully created" + else + echo "ERROR: [$LABEL] Model file linear.pte was not created" + exit 1 + fi +} + +# ---------------------------- +# Conda environment setup & tests +# ---------------------------- +echo "=== Testing in Conda env ===" +TEMP_ENV_DIR=$(mktemp -d) +echo "Using temporary directory for conda: $TEMP_ENV_DIR" +conda create -y -p "$TEMP_ENV_DIR/env" python=$PYTHON_VERSION +# derive python/pip paths inside the conda env +CONDA_PY="$TEMP_ENV_DIR/env/bin/python" +CONDA_PIP="$TEMP_ENV_DIR/env/bin/pip" +# Some images require conda run; keep pip/python direct to simplify path math +run_core_tests "$CONDA_PY" "$CONDA_PIP" "conda" + +# Cleanup conda env +conda env remove -p "$TEMP_ENV_DIR/env" -y || true +rm -rf "$TEMP_ENV_DIR" + +# ---------------------------- +# Python venv setup & tests +# ---------------------------- +echo "=== Testing in Python venv ===" +TEMP_VENV_DIR=$(mktemp -d) +echo "Using temporary directory for venv: $TEMP_VENV_DIR" +python3 -m venv "$TEMP_VENV_DIR/venv" +VENV_PY="$TEMP_VENV_DIR/venv/bin/python" +VENV_PIP="$TEMP_VENV_DIR/venv/bin/pip" + +# Ensure venv has wheel/build basics if needed +"$VENV_PIP" install --upgrade pip + +run_core_tests "$VENV_PY" "$VENV_PIP" "venv" + +# Cleanup venv +rm -rf "$TEMP_VENV_DIR" + +echo "=== All tests completed! ===" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 68d7f90d09c..aaa445f0f3f 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -13,6 +13,33 @@ concurrency: cancel-in-progress: true jobs: + test-qnn-wheel-packages-linux: + name: test-qnn-wheel-packages-linux + uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + python-version: [ "3.10", "3.11", "3.12" ] + with: + runner: linux.2xlarge + docker-image: ci-image:executorch-ubuntu-22.04-qnn-sdk + submodules: 'recursive' + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: 180 + script: | + # The generic Linux job chooses to use base env, not the one setup by the image + CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") + conda activate "${CONDA_ENV}" + + # Create a clean env for each python version + conda create -y -n test_env_${{ matrix.python-version }} python=${{ matrix.python-version }} + conda activate test_env_${{ matrix.python-version }} + + PYTHON_EXECUTABLE=python bash .ci/scripts/test_wheel_package_qnn.sh "${{ matrix.python-version }}" + test-setup-linux-gcc: name: test-setup-linux-gcc uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main diff --git a/.gitignore b/.gitignore index 511fb324ba2..b166f8c9512 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ cmake-android-out/ cmake-ios-out/ cmake-out* cmake-out-android/ +build-android/ +build-x86/ dist/ ethos-u-scratch/ executorch.egg-info diff --git a/backends/qualcomm/__init__.py b/backends/qualcomm/__init__.py new file mode 100644 index 00000000000..3afa62648f9 --- /dev/null +++ b/backends/qualcomm/__init__.py @@ -0,0 +1,13 @@ +import os + +from .scripts.download_qnn_sdk import install_qnn_sdk + + +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: + ok = install_qnn_sdk() + + if not ok: + raise RuntimeError("Failed to install QNN SDK. Please check the logs above.") diff --git a/backends/qualcomm/runtime/backends/QnnImplementation.cpp b/backends/qualcomm/runtime/backends/QnnImplementation.cpp index 42f866d22cc..7083f2bef30 100644 --- a/backends/qualcomm/runtime/backends/QnnImplementation.cpp +++ b/backends/qualcomm/runtime/backends/QnnImplementation.cpp @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ #include - #include "QnnInterface.h" namespace executorch { namespace backends { @@ -52,7 +51,11 @@ Error QnnImplementation::StartBackend( const QnnSaver_Config_t** saver_config) { Qnn_ErrorHandle_t error = QNN_SUCCESS; void* lib_handle = nullptr; - lib_handle = dlopen(lib_path.c_str(), RTLD_NOW | RTLD_GLOBAL); + // If the library is already loaded, return the handle. + lib_handle = dlopen(lib_path.c_str(), RTLD_NOW | RTLD_NOLOAD); + if (!lib_handle) { + lib_handle = dlopen(lib_path.c_str(), RTLD_NOW | RTLD_GLOBAL); + } if (lib_handle == nullptr) { QNN_EXECUTORCH_LOG_ERROR( "Cannot Open QNN library %s, with error: %s", diff --git a/backends/qualcomm/scripts/build.sh b/backends/qualcomm/scripts/build.sh index 297f81fc85d..c84911cf851 100755 --- a/backends/qualcomm/scripts/build.sh +++ b/backends/qualcomm/scripts/build.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # Copyright (c) Qualcomm Innovation Center, Inc. # All rights reserved # diff --git a/backends/qualcomm/scripts/download_qnn_sdk.py b/backends/qualcomm/scripts/download_qnn_sdk.py new file mode 100644 index 00000000000..0269fa0ff4c --- /dev/null +++ b/backends/qualcomm/scripts/download_qnn_sdk.py @@ -0,0 +1,374 @@ +# Add these imports for additional logging +import ctypes +import logging +import os +import pathlib +import platform +import shutil +import tarfile +import tempfile +import urllib.request +import zipfile +from typing import Dict, List, Optional, Tuple + +# Module logger (library-friendly) +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +PKG_ROOT = pathlib.Path(__file__).parent.parent +SDK_DIR = PKG_ROOT / "sdk" / "qnn" + + +def is_linux_x86() -> bool: + """ + Check if the current platform is Linux x86_64. + + Returns: + bool: True if the system is Linux x86_64, False otherwise. + """ + return platform.system().lower() == "linux" and platform.machine().lower() in ( + "x86_64", + "amd64", + "i386", + "i686", + ) + + +def _download_archive(url: str, archive_path: pathlib.Path) -> bool: + """Download archive from URL with progress reporting.""" + logger.debug("Archive will be saved to: %s", archive_path) + + try: + urllib.request.urlretrieve(url, archive_path, _make_report_progress()) + 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!") + return False + return True + + +def _make_report_progress(): + """Return a callback to report download progress.""" + last_reported = 0 + + def report_progress(block_num, block_size, total_size): + nonlocal last_reported + try: + downloaded = block_num * block_size + percent = downloaded / total_size * 100 if total_size else 100.0 + except Exception: + percent, downloaded, total_size = 0.0, block_num * block_size, 0 + if percent - last_reported >= 20 or percent >= 100: + logger.info( + "Downloaded: %d/%d bytes (%.2f%%)", downloaded, total_size, percent + ) + last_reported = percent + + return report_progress + + +def _extract_archive( + url: str, archive_path: pathlib.Path, content_dir: str, dst_folder: pathlib.Path +): + """Extract archive based on type (zip or tar).""" + 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}") + + +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!") + + +def _download_qnn_sdk(dst_folder=SDK_DIR) -> 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 not is_linux_x86(): + logger.info("Skipping Qualcomm SDK (only supported on Linux x86).") + return None + + 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): + return None + + _extract_archive(QAIRT_URL, archive_path, QAIRT_CONTENT_DIR, dst_folder) + _verify_extraction(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)] + + for file in files_to_extract: + relative_path = pathlib.Path(file).relative_to(content_dir) + if relative_path == pathlib.Path("."): + continue + + out_path = target_dir / relative_path + if file.endswith("/"): + out_path.mkdir(parents=True, exist_ok=True) + else: + out_path.parent.mkdir(parents=True, exist_ok=True) + with zip_ref.open(file) as src, open(out_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + +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 + relpath = pathlib.Path(m.name).relative_to(prefix) + if not relpath.parts or relpath.parts[0] == "..": + continue + + out_path = target_dir / relpath + if m.isdir(): + out_path.mkdir(parents=True, exist_ok=True) + else: + out_path.parent.mkdir(parents=True, exist_ok=True) + src = tf.extractfile(m) + if src is None: + continue + with src, open(out_path, "wb") as dst: + dst.write(src.read()) + + +LLVM_VERSION = "14.0.0" +LIBCXX_BASE_NAME = f"clang+llvm-{LLVM_VERSION}-x86_64-linux-gnu-ubuntu-18.04" +LLVM_URL = f"https://github.com/llvm/llvm-project/releases/download/llvmorg-{LLVM_VERSION}/{LIBCXX_BASE_NAME}.tar.xz" +REQUIRED_LIBCXX_LIBS = [ + "libc++.so.1.0", + "libc++abi.so.1.0", + "libunwind.so.1", +] + + +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) + return + + temp_tar = pathlib.Path("/tmp") / f"{LIBCXX_BASE_NAME}.tar.xz" + temp_extract = pathlib.Path("/tmp") / LIBCXX_BASE_NAME + + if not temp_tar.exists(): + logger.info("[libcxx] Downloading %s", LLVM_URL) + urllib.request.urlretrieve(LLVM_URL, temp_tar) + + logger.info("[libcxx] Extracting %s", temp_tar) + with tarfile.open(temp_tar, "r:xz") as tar: + tar.extractall(temp_extract.parent) + + lib_src = temp_extract / "lib" / "x86_64-unknown-linux-gnu" + for fname in REQUIRED_LIBCXX_LIBS: + src_path = lib_src / fname + if not src_path.exists(): + logger.warning( + "[libcxx] %s not found in extracted LLVM src_path %s", fname, src_path + ) + continue + shutil.copy(src_path, target_dir / fname) + + logger.info("[libcxx] Staged libc++ to %s", target_dir) + + +REQUIRED_QNN_LIBS: List[str] = [ + "libQnnHtp.so", +] + + +def _ld_library_paths() -> List[pathlib.Path]: + """Split LD_LIBRARY_PATH into ordered directories (skip empties).""" + raw = os.environ.get("LD_LIBRARY_PATH", "") + return [pathlib.Path(p) for p in raw.split(":") if p.strip()] + + +def _find_lib_in_ld_paths( + libname: str, ld_dirs: Optional[List[pathlib.Path]] = None +) -> Optional[pathlib.Path]: + """Return first matching path to `libname` in LD_LIBRARY_PATH, or None.""" + if ld_dirs is None: + ld_dirs = _ld_library_paths() + for d in ld_dirs: + candidate = d / libname + try: + if candidate.exists(): + return candidate.resolve() + except Exception: + # Ignore unreadable / permission issues, keep looking. + pass + return None + + +def _check_libs_in_ld( + libnames: List[str], +) -> Tuple[bool, Dict[str, Optional[pathlib.Path]]]: + """ + Check if each lib in `libnames` exists in LD_LIBRARY_PATH directories. + + Returns: + all_present: True iff every lib was found + locations: mapping lib -> path (or None if missing) + """ + ld_dirs = _ld_library_paths() + locations: Dict[str, Optional[pathlib.Path]] = {} + for lib in libnames: + locations[lib] = _find_lib_in_ld_paths(lib, ld_dirs) + all_present = all(locations[lib] is not None for lib in libnames) + return all_present, locations + + +# ----------------------- +# Ensure QNN SDK library +# ----------------------- +def _ensure_qnn_sdk_lib() -> bool: + """ + Ensure libQnnHtp.so is available. + - If found in LD_LIBRARY_PATH: do nothing, return True. + - Otherwise: ensure packaged SDK is present, then load libQnnHtp.so from it. + """ + 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) + return True + + # Not found → use packaged SDK + qnn_sdk_dir = SDK_DIR + logger.info("[QNN] libQnnHtp.so not found in LD_LIBRARY_PATH.") + if not qnn_sdk_dir.exists(): + logger.info("[QNN] SDK dir missing; downloading...") + _download_qnn_sdk() + else: + logger.info("[QNN] Using existing SDK at %s", qnn_sdk_dir) + + os.environ["QNN_SDK_ROOT"] = str(qnn_sdk_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) + return lib_loaded + + +def _load_libcxx_libs(lib_path): + logger.debug("running _load_libcxx_libs") + candidates = list(lib_path.glob("*.so*")) + priority = ["libc++abi", "libc++"] + sorted_candidates = [ + f for name in priority for f in candidates if f.name.startswith(name) + ] + sorted_candidates += [f for f in candidates if f not in sorted_candidates] + logger.debug("sorted_candidates: %s", sorted_candidates) + for sofile in sorted_candidates: + try: + ctypes.CDLL(str(sofile), mode=ctypes.RTLD_GLOBAL) + logger.info("Loaded %s", sofile.name) + except OSError as e: + logger.warning("[WARN] Failed to load %s: %s", sofile.name, e) + + +# --------------------- +# Ensure libc++ family +# --------------------- +def _ensure_libcxx_stack() -> bool: + """ + Ensure libc++ stack is available. + - If all required libc++ libs are found in LD_LIBRARY_PATH: do nothing. + - Otherwise: stage and load the packaged libc++ bundle. + """ + all_present, locs = _check_libs_in_ld(REQUIRED_LIBCXX_LIBS) + if all_present: + logger.info( + "[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}" + _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.exception("[libcxx][ERROR] Failed to stage/load libc++: %s", e) + return lib_loaded + + +# --------------- +# Public entrypoint +# --------------- +def install_qnn_sdk() -> bool: + """ + Initialize Qualcomm backend with separated logic: + + 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++. + + Returns: + True if both steps succeeded (or were already satisfied), else False. + """ + ok_libcxx = _ensure_libcxx_stack() + ok_qnn = _ensure_qnn_sdk_lib() + return bool(ok_qnn and ok_libcxx) diff --git a/setup.py b/setup.py index a112802f2a6..bac57e7c3eb 100644 --- a/setup.py +++ b/setup.py @@ -47,16 +47,18 @@ # derivative works thereof, in binary and source code form. import contextlib + +# Import this before distutils so that setuptools can intercept the distuils +# imports. +import logging import os import re import shutil import site -import sys - -# Import this before distutils so that setuptools can intercept the distuils -# imports. -import setuptools # noqa: F401 # usort: skip import subprocess +import sys +import sysconfig +import tempfile from distutils import log # type: ignore[import-not-found] from distutils.sysconfig import get_python_lib # type: ignore[import-not-found] @@ -68,6 +70,11 @@ from setuptools.command.build_ext import build_ext from setuptools.command.build_py import build_py +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) + try: from tools.cmake.cmake_cache import CMakeCache except ImportError: @@ -455,6 +462,69 @@ def run(self): if self._ran_build: return + try: + # Following code is for building the Qualcomm backend. + from backends.qualcomm.scripts.download_qnn_sdk import _download_qnn_sdk + + os.environ["EXECUTORCH_BUILDING_WHEEL"] = "1" + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + sdk_path = _download_qnn_sdk(dst_folder=tmp_path) + + logging.info("sdk_path: ", sdk_path) + if not sdk_path: + raise RuntimeError("Qualcomm SDK not found, cannot build backend") + + # Determine paths + prj_root = Path(__file__).parent.resolve() + logging.info("prj_root: ", prj_root) + build_sh = prj_root / "backends/qualcomm/scripts/build.sh" + build_root = prj_root / "build-x86" + + if not build_sh.exists(): + raise FileNotFoundError(f"{build_sh} not found") + + # Run build.sh with SDK path exported + env = dict(**os.environ) + env["QNN_SDK_ROOT"] = str(sdk_path) + subprocess.check_call([str(build_sh), "--skip_aarch64"], env=env) + + # Copy the main .so into the wheel package + so_src = build_root / "backends/qualcomm/libqnn_executorch_backend.so" + so_dst = Path( + self.get_ext_fullpath("executorch.backends.qualcomm.qnn_backend") + ) + self.mkpath(str(so_dst.parent)) # ensure destination exists + self.copy_file(str(so_src), str(so_dst)) + logging.info(f"Copied Qualcomm backend: {so_src} -> {so_dst}") + + # Copy Python adaptor .so files + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + + so_files = [ + ( + "executorch.backends.qualcomm.python.PyQnnManagerAdaptor", + prj_root + / f"backends/qualcomm/python/PyQnnManagerAdaptor{ext_suffix}", + ), + ( + "executorch.backends.qualcomm.python.PyQnnWrapperAdaptor", + prj_root + / f"backends/qualcomm/python/PyQnnWrapperAdaptor{ext_suffix}", + ), + ] + + for module_name, so_src in so_files: + so_dst = Path(self.get_ext_fullpath(module_name)) + self.mkpath(str(so_dst.parent)) + self.copy_file(str(so_src), str(so_dst)) + logging.info(f"Copied Qualcomm backend: {so_src} -> {so_dst}") + + except ImportError: + logging.error("Fail to build Qualcomm backend") + logging.exception("Import error") + if self.editable_mode: self._ran_build = True self.run_command("build") @@ -463,6 +533,7 @@ def run(self): def copy_extensions_to_source(self) -> None: """For each extension in `ext_modules`, we need to copy the extension file from the build directory to the correct location in the local + file from the build directory to the correct location in the local directory. This should only be triggered when inplace mode (editable mode) is enabled.