From 465c161684535a4b2d6b151607fdc285aba2e9ef Mon Sep 17 00:00:00 2001 From: boyuc Date: Mon, 24 Aug 2026 17:00:09 +0800 Subject: [PATCH 1/9] Add QNN HTP profiling OpTrace and Hextimate workflows QNN HTP profiling APIs and artifacts - Add public HTP profiling helpers for two workflows: - `generate_htp_profile_result()` for on-device OpTrace profiling. - `estimate_htp_profile_result()` for host-only Hextimate estimation. - Return `QnnHtpProfileArtifacts` for each compiled binary in a `.pte`, including QHAS HTML/JSON, Chrome trace JSON, HTP graph JSONs, binary path, profile mode, and prepare mode. - Validate Hextimate constraints before running QNN tools: - requires `online_prepare=True` - requires supported SoCs: SA8540, SA8255, QCS9100, SA8797 - requires QNN SDK support for Hextimate. - Validate offline OpTrace `.pte` inputs so missing `profile_level=3` fails early instead of producing unusable profiling output. Schematic packaging and profile-level support - Package schematic binaries into the serialized QNN context payload when `profile_level=3` is used. - Make offline-prepare OpTrace self-contained by carrying the schematic data needed by `qnn-profile-viewer`. - Preserve online-prepare behavior where QNN tools generate the profiled context and schematic from the `.dlc`. Example workflows - Add explicit example scripts for each supported HTP profiling route: - `htp_profiling_on_device_op_trace_online.py`: on-device OpTrace with `online_prepare=True`. - `htp_profiling_on_device_op_trace_offline.py`: on-device OpTrace with `online_prepare=False` and `profile_level=3`. - `htp_profiling_on_host_hextimate.py`: host-only Hextimate with `online_prepare=True`. - Keep `qairt-visualizer` optional in example scripts: - open reports automatically when installed - print `qhas_html` paths when not installed. Documentation - Rewrite the HTP profiling README around supported user workflows: - select `.pte` prepare mode - run on-device OpTrace generation - run host-only Hextimate estimation - inspect `QnnHtpProfileArtifacts` - view QHAS reports with QAIRT Visualizer. - Link README quick starts to the renamed example scripts. - Document Hextimate limitations and supported SoCs. - Add util script README entries for the three profiling examples. Tests and verification - Add a public API test covering unsupported-SoC rejection for Hextimate before reading `.pte` inputs. - Verify script syntax with: - `python -m py_compile examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py` - Verify example scripts with options: - `python -m examples.qualcomm.util_scripts.htp_profiling_on_device_op_trace_online --host --device --soc_model --build_folder build-android --online_prepare -a ` - `python -m examples.qualcomm.util_scripts.htp_profiling_on_device_op_trace_offline --host --device --soc_model --build_folder build-android --profile_level 3 -a ` - `python -m examples.qualcomm.util_scripts.htp_profiling_on_host_hextimate --soc_model SA8797 --build_folder build-x86 --enable_x86_64 --online_prepare -a ` - Preserve targeted profiling test commands: - `python -m backends.qualcomm.tests.test_qnn_delegate TestQNNQuantizedUtils.test_qnn_backend_generate_optrace --host --device --soc_model --build_folder build-android` - `python -m backends.qualcomm.tests.test_qnn_delegate TestQNNFloatingPointUtils.test_qnn_backend_generate_optrace --host --device --soc_model --build_folder build-android` - `python -m backends.qualcomm.tests.test_qnn_delegate TestQNNQuantizedUtils.test_qnn_backend_generate_hextimate --soc_model SA8797 --build_folder build-x86 --enable_x86_64` --- backends/qualcomm/debugger/README.md | 216 +++++-- backends/qualcomm/debugger/utils.py | 534 +++++++++++++----- backends/qualcomm/qnn_preprocess.py | 62 +- .../serialization/qc_compiler_spec.fbs | 1 + backends/qualcomm/serialization/qc_schema.py | 2 + backends/qualcomm/tests/test_qnn_delegate.py | 273 +++++---- backends/qualcomm/utils/utils.py | 36 +- examples/qualcomm/util_scripts/README.md | 8 + ...tp_profiling_on_device_op_trace_offline.py | 128 +++++ ...htp_profiling_on_device_op_trace_online.py | 126 +++++ ....py => htp_profiling_on_host_hextimate.py} | 73 ++- 11 files changed, 1119 insertions(+), 340 deletions(-) create mode 100644 examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py create mode 100644 examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py rename examples/qualcomm/util_scripts/{qairt_visualizer_demo.py => htp_profiling_on_host_hextimate.py} (53%) diff --git a/backends/qualcomm/debugger/README.md b/backends/qualcomm/debugger/README.md index afe2336c1d8..a7e44181bbb 100644 --- a/backends/qualcomm/debugger/README.md +++ b/backends/qualcomm/debugger/README.md @@ -1,93 +1,203 @@ -# QAIRT Visualizer +# ExecuTorch QNN Debugger & Profiler -[QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/) is a Python package designed to help you visualize and analyze data from Qualcomm AI Engine Direct (QNN) models. It provides tools to generate and interpret op traces (`optrace`) and QNN HTP Analysis Summary (`QHAS`), enabling detailed insights into your model's performance and behavior. +This directory bundles three independent debugging and profiling flows for the ExecuTorch QNN backend. They share no code and address different failure modes — jump directly to the section you need. -## Installation +**Table of contents** -You can install the QAIRT Visualizer package directly from [QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/): +- [HTP Profiling](#htp-profiling) +- [ExecuTorch QNN Intermediate Output Debugger](#executorch-qnn-intermediate-output-debugger) +- [ExecuTorch QNN HTP Heap Profiling](#executorch-qnn-htp-heap-profiling) + +--- + +# HTP Profiling + +This section shows how to produce HTP profiling results from an ExecuTorch `.pte` and inspect the output reports. The main user-facing controls are: + +1. **`.pte` generation prepare mode:** +- 1.1 `online_prepare`: controlled by `QnnConfig.online_prepare=True`. +- 1.2 `offline_prepare`: controlled by `QnnConfig.online_prepare=False`. + +2. **Public Functions:** +- 2.1 `generate_htp_profile_result()`: generates device-based profiling results. +- 2.2 `estimate_htp_profile_result()`: estimates host-based profiling results. + +3. **HTP Profile Output Format:** `QnnHtpProfileArtifacts` contains genrated html , json and chrometrace files. + +4. **Qairt-Visualizer:** QAIRT Visualizer can open the QHAS result from `qhas_json` and `chrometrace_json`. Use `QnnHtpProfileArtifacts.visualizer_reports()` to pass related reports. + +## 1. Select AOT Prepare modes for `.pte` Generation +Users choose one prepare mode before exporting the `.pte`: + +| Prepare mode | QNN config | User-facing behavior | +|:-------------|:-----------|:---------------------| +| **online_prepare** | `QnnConfig.online_prepare=True` | Export stores a graph description; profiling tools finish preparation later. | +| **offline_prepare** | `QnnConfig.online_prepare=False` | Export stores the finalized executable form; on-device profiling requires `profile_level=3`. | + +Internal detail: online prepare carries a `.dlc`, and offline prepare carries a finalized QNN context binary (`.bin`). Offline prepare must set `profile_level=3` because OpTrace instrumentation has to be baked into that context binary during export. The schematic file used by `qnn-profile-viewer` is generated or unpacked beside the dumped profiling artifacts. + +The example demos keep the profiling route explicit: + +- `examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py`: on-device OpTrace with `online_prepare`. +- `examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py`: on-device OpTrace with `offline_prepare`. +- `examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py`: host-only Hextimate with `online_prepare`. + +### 1.1 Online Prepare Mode `.pte` + +**Demo script**: ```bash -pip install qairt-visualizer +python -m examples.qualcomm.util_scripts.htp_profiling_on_device_op_trace_online \ + --host ${host} --device ${device} --soc_model ${SOC_MODEL} --build_folder build-android \ + -a ${path_to_output_folder} --online_prepare ``` -## Quick start -This command launches an interactive GUI interface to visualize the `optrace` and `QHAS` results. +**Export call**: +The export step does not need to set `profile_level`: +```python +build_executorch_binary( + model=model, + qnn_config=qnn_config, # online_prepare=True; profile_level not required + file_name=f"{args.artifact}/{pte_filename}", + dataset=[example_input], + quant_dtype=QuantDtype.use_8a8w, +) ``` -python -m examples.qualcomm.util_scripts.qairt_visualizer_demo --host ${host} --device {device} --soc_model ${SOC_MODEL} --build_folder build-android -a ${path_to_output_folder} --online_prepare + +### 1.2 Offline Prepare Mode `.pte` +**Demo script**: +```bash +python -m examples.qualcomm.util_scripts.htp_profiling_on_device_op_trace_offline \ + --host ${host} --device ${device} --soc_model ${SOC_MODEL} -build_folder build-android \ + -a ${path_to_output_folder} --profile_level 3 ``` -- If online prepare mode is `enabled`, the following artifacts will be generated: - - `model`.dlc - - `optrace`.json - - `QHAS`.json -- If online prepare mode is `disabled`, the following artifacts will be generated: - - `model`.bin - - `optrace`.json - - `QHAS`.json - -Note: Model visualization is supported only in online prepare mode. -The `.bin` format is not compatible with the QAIRT visualizer. -To enable model visualization, please add the `--online_prepare` flag. - -## Details -### 1. Lower to QNN backend -Generate an ExecuTorch binary for Qualcomm platforms. -Ensure that qnn_config.profile_level is set to 3, which will generate op_trace. + +**Export call**: +The export step **must** set `profile_level=3`: ```python qnn_config.profile_level = 3 build_executorch_binary( model=model, - qnn_config=qnn_config, + qnn_config=qnn_config, # online_prepare=False (default) file_name=f"{args.artifact}/{pte_filename}", dataset=[example_input], quant_dtype=QuantDtype.use_8a8w, - online_prepare=args.online_prepare, - optrace=True, ) ``` -### 2. Generate optrace and QHAS -Generate optrace and QHAS files using QNN tools under $QNN_SDK_ROOT. After finishing, you will get a `binaries_trace` dictionary. -``` python -adb = SimpleADB( - qnn_config=qnn_config, + +## 2.1 Generate HTP Profile Result (OpTrace) `generate_htp_profile_result()` + +Use `generate_htp_profile_result()` when you want real on-device hardware-counter profiling. This path requires sample inputs and `adb`. +```python +from executorch.backends.qualcomm.debugger.utils import generate_htp_profile_result + +artifacts = generate_htp_profile_result( + artifact_dir=args.artifact, + soc_id=get_soc_to_chipset_map()[args.soc_model], pte_path=f"{args.artifact}/{pte_filename}.pte", - workspace=f"/data/local/tmp/executorch/{pte_filename}", -) -binaries_trace = generate_optrace( - args, adb, f"{args.artifact}/{pte_filename}.pte", example_input + inputs=example_inputs, + adb=adb, ) ``` -- **`binaries_trace`**: A dictionary where keys are the dumped file paths and values are tuples containing the paths to the generated optrace and QHAS JSON files. -- Example 1: {"forward_0.dlc": (optrace.json, optrace_qnn_htp_analysis_summary.json)} -- Example 2: {"forward_0.bin": (optrace.json, optrace_qnn_htp_analysis_summary.json)} +## 2.2 Estimation of HTP profiling on Host (Hextimate) `estimate_htp_profile_result()` -### 3. Visualizing and Analyzing optrace and QHAS +Use `estimate_htp_profile_result()` when you want host-only compile-time performance estimation. This path does not use sample inputs or `adb`. -Once you have the optrace and QHAS files, you can leverage the QAIRT Visualizer to visualize the model graph, optrace and QHAS data. Here's how you can do it: +**Demo script**: +```bash +python -m examples.qualcomm.util_scripts.htp_profiling_on_host_hextimate \ + --soc_model QCS9100 -a ${path_to_output_folder} --online_prepare +``` ```python -import qairt_visualizer -qairt_visualizer.view(f"{args.artifact}/forward_0.dlc", reports=[optrace, qhas]) +from executorch.backends.qualcomm.debugger.utils import estimate_htp_profile_result + +estimates = estimate_htp_profile_result( + artifact_dir=args.artifact, + soc_id=get_soc_to_chipset_map()[args.soc_model], + pte_path=f"{args.artifact}/{pte_filename}.pte", +) ``` -or + +Limitations: +- Requires `QnnConfig.online_prepare=True`. +- Requires QNN SDK >= 2.41. +- Currently supports only the following soc_model: + - SA8540 + - SA8255 + - QCS9100 + - SA8797 + +## 3. HTP Profiling Output `QnnHtpProfileArtifacts` +Both public functions `estimate_htp_profile_result()` and `generate_htp_profile_result()` return one `QnnHtpProfileArtifacts` per compiled binary in the `.pte` (partitioned graphs yield multiple entries). + +Important fields: + +- `qhas_html`: QHAS HTML report. This is the easiest artifact to open when you want the QNN HTP Analysis Summary. +- `qhas_json`: QHAS JSON report. +- `chrometrace_json`: Chrome trace JSON. Open it with `chrome://tracing` or Perfetto. +- `htp_graph_json`: HTP graph JSON after optimization. +- `htp_graph_before_json`: HTP graph JSON before optimization. +- `runtrace_json`: runtrace JSON for device profiling, or `None` when not emitted. + +Other context fields: + +- `binary_path`: dumped internal `.dlc` or `.bin` used by `qnn-profile-viewer`. +- `mode`: `"optrace"` or `"hextimate"`. +- `prepare_mode`: `"online"` or `"offline"`. + + +## 4. Viewing HTP Analysis Summary (QHAS) with QAIRT Visualizer + +**Install** + +```bash +pip install qairt-visualizer +``` + +**Usage** + +QAIRT Visualizer can open the QHAS result from `qhas_json` and `chrometrace_json`. Use `QnnHtpProfileArtifacts.visualizer_reports()` to pass related reports. + ```python import qairt_visualizer -qairt_visualizer.view(reports=[optrace, qhas]) -``` -- `model`: Path to your QNN model file (e.g., `path_to_your_model.dlc`). -- **`reports`**: List of report file paths, including the optrace (`optrace.json`) and QHAS (`optrace_qnn_htp_analysis_summary.json`). +for artifact in artifacts: + qairt_visualizer.view(reports=artifact.visualizer_reports()) + print(f"QHAS HTML: {artifact.qhas_html}") +``` +**Example** +The example scripts already call QAIRT Visualizer after producing artifacts when `qairt-visualizer` is installed. If it is not installed, they still print the generated `qhas_html` path: -Note: Files ending with `.bin` do not support graph visualization in qairt_visualizer. +- `examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py` +- `examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py` +- `examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py` -## Demo
- QAIRT visualizer demo
+ QAIRT Visualizer showing HTP profiling results
-For more details, visit the [QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/). +For the viewer package, see [QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/). + +## Technical Details + +### Host Estimation vs On-Device Generation +| Mode | Source of measurements | Requires device? | Requires `QnnConfig.online_prepare=True` for `.pte` generation (`.dlc`)? | +|:--------------|:---------------------------------------|:-----------------|:-----------------| +| `generate_htp_profile_result` (`"optrace"`) | On-device hardware counters (real run) | Yes | No — support both mode (`.dlc` or `.bin`) | +| `estimate_htp_profile_result` (`"hextimate"`) | Compile-time performance-model estimate | No (host only) | Yes | + +### SDK compatibility + +| QAIRT SDK version | Optrace | Hextimate | QHAS JSON (optrace) | +|:------------------|:--------|:----------|:--------------------| +| 2.37 – 2.40 | Supported | **Not supported** | Valid | +| 2.41 – 2.50 | Supported | Supported | Valid | + +`estimate_htp_profile_result()` hard-errors on SDKs below 2.41 and on unsupported SoCs. # ExecuTorch QNN Intermediate Output Debugger diff --git a/backends/qualcomm/debugger/utils.py b/backends/qualcomm/debugger/utils.py index 01cb267e917..790c65cf5b5 100644 --- a/backends/qualcomm/debugger/utils.py +++ b/backends/qualcomm/debugger/utils.py @@ -4,17 +4,26 @@ import shutil import subprocess import tempfile -from typing import Sequence, Tuple +from dataclasses import dataclass +from typing import List, Literal, Optional, Sequence, Tuple import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager import pandas as pd import torch -from executorch.backends.qualcomm.serialization.qc_schema import QcomChipset +from executorch.backends.qualcomm.serialization.qc_schema import ( + QcomChipset, + QnnExecuTorchProfileLevel, +) +from executorch.backends.qualcomm.serialization.qc_schema_serialize import ( + flatbuffer_to_option, +) +from executorch.backends.qualcomm.utils.check_qnn_version import ( + is_qnn_sdk_version_less_than, +) from executorch.backends.qualcomm.utils.utils import dump_context_from_pte from graphviz import Digraph - class DrawGraph: def __init__( self, @@ -190,14 +199,78 @@ def draw(self): shutil.move(dot_file, dot_dest_file) +@dataclass(frozen=True) +class QnnHtpProfileArtifacts: + """Files emitted for one HTP optrace/hextimate run. + + Data fields: + - binary_path: dumped `.dlc` or `.bin` input. + - mode: `"optrace"` for on-device hardware counters, or `"hextimate"` + for host-only compile-time estimation. + - prepare_mode: `"online"` for `.dlc` from `QnnConfig.online_prepare=True`, + or `"offline"` for `.bin` from `online_prepare=False`. + - chrometrace_json: Chrome trace JSON, viewable with `chrome://tracing` or + Perfetto. + - qhas_json: QHAS JSON + - qhas_html: QHAS HTML report. + - htp_graph_json: HTP graph JSON after optimization. + - htp_graph_before_json: HTP graph JSON before optimization. + - runtrace_json: runtrace JSON for optrace, or None when not emitted. + + Use visualizer_reports() for the subset safe to pass to + qairt_visualizer.view(reports=...). + """ + + binary_path: str + mode: Literal["optrace", "hextimate"] + prepare_mode: Literal["online", "offline"] + chrometrace_json: str + qhas_json: Optional[str] + qhas_html: str + htp_graph_json: str + htp_graph_before_json: str + runtrace_json: Optional[str] + + def visualizer_reports(self) -> List[str]: + """Reports safe to pass to qairt_visualizer.view(reports=...).""" + reports = [self.chrometrace_json] + if self.qhas_json is not None: + reports.append(self.qhas_json) + return reports + + + +# Hextimate (compile-time perf estimation) requires SDK >= 2.41. Below that, +# qnn-context-binary-generator silently drops the hextimate parameters +_MIN_SDK_FOR_HEXTIMATE = "2.41" +_HEXTIMATE_SUPPORTED_SOCS = ( + QcomChipset.SA8540, + QcomChipset.SA8255, + QcomChipset.QCS9100, + QcomChipset.SA8797, +) + + +# - QNN: libQnnHtpNetRunExtensions.so +# - QAIRT: libQairtHtpBackendExtensions.so (QAIRT 2.49+ sdk) +_BACKEND_EXTENSIONS_LIB = "libQnnHtpNetRunExtensions.so" + + class QnnTool: + """Host-side wrapper around the QNN profiling CLI toolchain. + + Compatibility (see README.md §QAIRT Profiling for the user-facing table): + - Optrace: SDK 2.37+ + - Hextimate: SDK 2.41+ (gate and raise error) + + """ def __init__( self, - tmp_dir, - sample_input, + artifact_dir, soc_id, adb, - build_folder, + sample_input=None, + build_folder=None, workspace="/data/local/tmp/qnn_executorch_test", ): self.qnn_sdk = os.environ.get("QNN_SDK_ROOT", None) @@ -205,17 +278,18 @@ def __init__( assert self.qnn_sdk, "QNN_SDK_ROOT was not found in environment variable" assert self.ndk, "ANDROID_NDK_ROOT was not found in environment variable" - self.tmp_dir = tmp_dir + self.artifact_dir = artifact_dir self.workspace = workspace self.adb = adb self.sample_input = sample_input self.build_folder = build_folder - self.root = os.getcwd() - self.config = { - "backend_extension_config": { - "backend_extensions": { - "config_file_path": "config.json", - }, + self.soc_id = soc_id + + def _get_base_config(self): + # Generate base device profile — every subprocess call clones from this. + return { + "backend_extensions": { + "config_file_path": os.path.join(self.artifact_dir, "config.json"), }, "config": { "devices": [ @@ -224,59 +298,82 @@ def __init__( "cores": [ {"perf_profile": "burst", "rpc_control_latency": 100} ], - "soc_id": int(soc_id), + "soc_id": int(self.soc_id), } ] }, } - def qnn_context_binary_generator( - self, - qnn_binary_file="forward_0.dlc", - binary_name="forward.serialized", - ): - for file_name, data in self.config.items(): - with open(f"{self.tmp_dir}/{file_name}.json", "w") as json_file: - json.dump(data, json_file, indent=4) + def _write_config_files(self, backend_extensions: Optional[dict] = None) -> Tuple[str, str]: + """Write backend_extension_config.json and config.json in artifact_dir. + + Returns (backend_ext_path, config_path). + """ + if backend_extensions is None: + backend_extensions = self._get_base_config()["backend_extensions"] + + backend_ext_path = os.path.join(self.artifact_dir, "backend_extension_config.json") + config_path = os.path.join(self.artifact_dir, "config.json") + with open(backend_ext_path, "w") as f: + json.dump({"backend_extensions": backend_extensions}, f, indent=4) + with open(config_path, "w") as f: + json.dump(self._get_base_config()["config"], f, indent=4) + return backend_ext_path, config_path + + def _run(self, cmd: List[str], step: str) -> None: + """Run a subprocess with argv list; assert on non-zero exit.""" + result = subprocess.run(cmd, capture_output=True, cwd=self.artifact_dir) + assert result.returncode == 0, ( + f"{step} failed (exit {result.returncode}): " + f"{result.stderr.decode('utf-8', errors='replace')}" + ) + + def _qnn_context_binary_generator( + self, + qnn_binary_file: str, + binary_name: str, + enable_hextimate: bool, + ) -> None: target = "x86_64-linux-clang" - cmds = [ + backend_ext = self._get_base_config()["backend_extensions"] + if enable_hextimate: + backend_ext["shared_library_path"] = ( + f"{self.qnn_sdk}/lib/{target}/{_BACKEND_EXTENSIONS_LIB}" + ) + backend_ext_path, _ = self._write_config_files(backend_ext) + + cmd = [ f"{self.qnn_sdk}/bin/{target}/qnn-context-binary-generator", - "--backend", - f"{self.qnn_sdk}/lib/{target}/libQnnHtp.so", - "--model", - f"{self.qnn_sdk}/lib/{target}/libQnnModelDlc.so", - "--dlc_path", - f"{self.tmp_dir}/{qnn_binary_file}", - f"--config_file {self.tmp_dir}/backend_extension_config.json", - f"--binary_file {binary_name}", - f"--output_dir {self.tmp_dir}", - "--profiling_level detailed", - "--profiling_option optrace", + "--backend", f"{self.qnn_sdk}/lib/{target}/libQnnHtp.so", + "--model", f"{self.qnn_sdk}/lib/{target}/libQnnModelDlc.so", + "--dlc_path", os.path.join(self.artifact_dir, qnn_binary_file), + "--config_file", backend_ext_path, + "--binary_file", binary_name, + "--output_dir", self.artifact_dir, + "--profiling_level", "detailed", + "--profiling_option", "optrace", ] - result = subprocess.run( - " ".join(cmds), - shell=True, - executable="/bin/bash", - capture_output=True, + self._run(cmd, "qnn-context-binary-generator") + expected = os.path.join(self.artifact_dir, f"{binary_name}.bin") + assert os.path.isfile(expected), ( + f"qnn-context-binary-generator ran but did not produce {expected}" ) - assert os.path.isfile(f"{self.tmp_dir}/{binary_name}.bin"), result.stderr - - def qnn_net_run(self, graph_name="forward.serialized"): - self.config["backend_extension_config"]["backend_extensions"][ - "shared_library_path" - ] = "./libQnnHtpNetRunExtensions.so" - for file_name, data in self.config.items(): - with open(f"{self.tmp_dir}/{file_name}.json", "w") as json_file: - json.dump(data, json_file, indent=4) + def _qnn_net_run(self, graph_name: str) -> None: + # backend-extensions library path is device-relative when running via adb + backend_ext = { + "shared_library_path": f"./{_BACKEND_EXTENSIONS_LIB}", + "config_file_path": "config.json", + } + backend_ext_path, config_path = self._write_config_files(backend_ext) target = "aarch64-android" files = [ - f"{self.qnn_sdk}/lib/{target}/libQnnHtpNetRunExtensions.so", - f"{self.tmp_dir}/backend_extension_config.json", - f"{self.tmp_dir}/config.json", - f"{self.tmp_dir}/{graph_name}.bin", + f"{self.qnn_sdk}/lib/{target}/{_BACKEND_EXTENSIONS_LIB}", + backend_ext_path, + config_path, + os.path.join(self.artifact_dir, f"{graph_name}.bin"), f"{self.qnn_sdk}/bin/{target}/qnn-net-run", ] cmds = [ @@ -303,132 +400,279 @@ def qnn_net_run(self, graph_name="forward.serialized"): "pull", "-a", f"{self.workspace}/output/qnn-profiling-data_0.log", - self.tmp_dir, + self.artifact_dir, ] ) assert os.path.isfile( - f"{self.tmp_dir}/qnn-profiling-data_0.log" - ), f"Error: qnn-profiling-data_0.log not found in {self.tmp_dir}" + f"{self.artifact_dir}/qnn-profiling-data_0.log" + ), f"Error: qnn-profiling-data_0.log not found in {self.artifact_dir}" + - def qnn_profile_viewer(self, graph_name="forward_schematic", graph_idx=0): - self.config["backend_extension_config"] = {"features": {"qhas_json": True}} - for file_name, data in self.config.items(): - with open(f"{self.tmp_dir}/{file_name}.json", "w") as json_file: - json.dump(data, json_file, indent=4) + def _qnn_profile_viewer(self, schematic_stem: str, graph_idx: int) -> None: + # profile-viewer takes its own config schema (`features`), not the + # device profile schema. Written to the SAME file name because that's + # the flag qnn-profile-viewer expects — a per-step fresh config avoids + # any leak from earlier CBG/net-run configs. + backend_ext_path = os.path.join( + self.artifact_dir, "backend_extension_config.json" + ) + with open(backend_ext_path, "w") as f: + json.dump({"features": {"qhas_json": True}}, f, indent=4) target = "x86_64-linux-clang" - cmds = [ + schematic = os.path.join(self.artifact_dir, f"{schematic_stem}.bin") + assert os.path.isfile(schematic), ( + f"qnn-profile-viewer expected schematic at {schematic}; " + "in case of online_prepare, the context-binary-generator step should have produced it in artifact_dir. " + "in case of offline_prepare, the schematic should be dumpped from pte, make sure profiling_level=3 when generating pte. " + ) + + cmd = [ f"{self.qnn_sdk}/bin/{target}/qnn-profile-viewer", - f"--config {self.tmp_dir}/backend_extension_config.json", - f"--schematic {self.root}/{graph_name}.bin", - f"--reader {self.qnn_sdk}/lib/{target}/libQnnHtpOptraceProfilingReader.so", - f"--input_log {self.tmp_dir}/qnn-profiling-data_0.log", - f"--output {self.tmp_dir}/optrace_{graph_idx}.json", + "--config", backend_ext_path, + "--schematic", schematic, + "--reader", + f"{self.qnn_sdk}/lib/{target}/libQnnHtpOptraceProfilingReader.so", + "--input_log", os.path.join(self.artifact_dir, "qnn-profiling-data_0.log"), + "--output", os.path.join(self.artifact_dir, f"optrace_{graph_idx}.json"), ] - result = subprocess.run( - " ".join(cmds), - shell=True, - executable="/bin/bash", - capture_output=True, - ) - assert ( - result.returncode == 0 - ), f"Process failed with error: {result.stderr.decode('utf-8')}" + self._run(cmd, "qnn-profile-viewer") - def generate_optrace( - self, - qnn_binary_file="forward_0.dlc", - ): + def _validated_qhas_json(self, qhas_path: str) -> Optional[str]: + """Return path if the QHAS JSON parses; None if truncated (hextimate SDK bug). + + Note: QHAS JSON is truncated for hextimate mode (SDK bug, still open as of + 2.50 nightly). When time_us == 0, the SDK divides 1e6 / 0 = +Infinity, + rapidjson rejects the write, and the JSON stream is cut at ~3900 bytes + with "inf_per_s": . We detect this and return qhas_json=None + rather than repair — the HTML report and chrometrace remain valid. """ - Generate Qnn HTP Optrace Profiling https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-10/htp_backend.html#qnn-htp-optrace-profiling - and QNN HTP Analysis Summary (QHAS) https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-10/htp_backend.html#qnn-htp-analysis-summary-qhas - . You can utilize the QAIRT Visualizer (https://pypi.org/project/qairt-visualizer/) to visualize the results from the files above. + if not os.path.isfile(qhas_path): + return None + try: + with open(qhas_path, "r") as f: + json.load(f) + return qhas_path + except json.JSONDecodeError: + return None + + def run( + self, + mode: Literal["optrace", "hextimate"], + binary_file: str, + ) -> QnnHtpProfileArtifacts: + """Run qnn-profile-viewer for one dumped .dlc/.bin. + + Docs: + - Optrace: https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-10/htp_backend.html#qnn-htp-optrace-profiling + - Hextimate: https://docs.qualcomm.com/doc/80-63442-10/topic/htp_backend.html#qnn-htp-hextimate-profiling + - QHAS: https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-10/htp_backend.html#qnn-htp-analysis-summary-qhas """ - graph_name, file_extension = os.path.splitext(qnn_binary_file) - assert file_extension in [ - ".dlc", - ".bin", - ], f"Invalid file extension '{file_extension}'. Supported extensions are 'dlc' and 'bin'." + assert mode in ("optrace", "hextimate"), f"unknown mode {mode!r}" - # Attempt to extract a numeric index from the end of the graph name (e.g., "forward_123") - match = re.match(r"^(.*)_(\d+)$", graph_name) - graph_base_name = graph_name - graph_idx = 0 + graph_name, ext = os.path.splitext(binary_file) + if mode == "optrace": + assert ext in (".dlc", ".bin"), ( + f"optrace supports .dlc (online prepare) and .bin (offline prepare); " + f"got {ext!r}" + ) + else: # hextimate + assert ext == ".dlc", ( + f"hextimate requires .dlc (online prepare); got {ext!r}. " + "For offline-prepare context binaries, use mode='optrace' instead." + ) + if is_qnn_sdk_version_less_than(_MIN_SDK_FOR_HEXTIMATE): + # SDK < 2.41 silently drops hextimate config and produces a + # standard context binary — fail loudly before that trap. + raise AssertionError( + f"hextimate requires QNN SDK >= {_MIN_SDK_FOR_HEXTIMATE}; " + f"the current SDK at $QNN_SDK_ROOT={self.qnn_sdk} is older. " + "Older SDKs silently ignore hextimate parameters and emit " + "an ordinary profiling log with no hextimate events." + ) + + prepare_mode: Literal["online", "offline"] = ( + "online" if ext == ".dlc" else "offline" + ) + # Extract graph index if the file follows the "_" convention. + match = re.match(r"^(.*)_(\d+)$", graph_name) if match: graph_base_name = match.group(1) graph_idx = int(match.group(2)) - - # Handle .dlc file extension by generating a serialized version of the graph - if file_extension == ".dlc": - self.qnn_context_binary_generator( - qnn_binary_file, f"{graph_base_name}.serialized" + else: + graph_base_name = graph_name + graph_idx = 0 + + # Step 1: for online-prepare (.dlc), materialize the context binary + + # schematic on the host. Offline-prepare (.bin) already has both. + if ext == ".dlc": + self._qnn_context_binary_generator( + qnn_binary_file=binary_file, + binary_name=f"{graph_base_name}.serialized", + enable_hextimate=(mode == "hextimate"), ) graph_name = f"{graph_base_name}.serialized" - # Run the QNN graph and generate the schematic - self.qnn_net_run(graph_name=graph_name) - self.qnn_profile_viewer( - graph_name=f"{graph_base_name}_schematic", graph_idx=graph_idx - ) + # Step 2: for optrace, run on device. Hextimate is host-only. + if mode == "optrace": + self._qnn_net_run(graph_name=graph_name) - # Clean up the schematic binary file if it exists - schematic_bin_path = os.path.join(self.root, f"{graph_base_name}_schematic.bin") - if os.path.isfile(schematic_bin_path): - os.remove(schematic_bin_path) + # Step 3: post-process into optrace.json + QHAS side-artifacts. + self._qnn_profile_viewer( + schematic_stem=f"{graph_base_name}_schematic", + graph_idx=graph_idx, + ) - optrace_path = os.path.join(self.tmp_dir, f"optrace_{graph_idx}.json") - qhas_path = os.path.join( - self.tmp_dir, f"optrace_{graph_idx}_qnn_htp_analysis_summary.json" + # Collect the six output files. qnn-profile-viewer names them by + # stripping `.json` off --output and appending suffixes. + base = os.path.join(self.artifact_dir, f"optrace_{graph_idx}") + chrometrace_json = f"{base}.json" + qhas_json_candidate = f"{base}_qnn_htp_analysis_summary.json" + qhas_html = f"{base}_qnn_htp_analysis_summary.html" + htp_graph_json = f"{base}_htp.json" + htp_graph_before_json = f"{base}_htp_graph_before.json" + runtrace_json = f"{base}_runtrace.json" + + assert os.path.isfile(chrometrace_json), ( + f"qnn-profile-viewer did not produce {chrometrace_json}" ) - assert os.path.isfile(optrace_path) and os.path.isfile(qhas_path), ( - "Error: Required files not found - either " - f"{os.path.basename(optrace_path)} or {os.path.basename(qhas_path)} is missing." + + return QnnHtpProfileArtifacts( + binary_path=os.path.join(self.artifact_dir, binary_file), + mode=mode, + prepare_mode=prepare_mode, + chrometrace_json=chrometrace_json, + qhas_json=self._validated_qhas_json(qhas_json_candidate), + qhas_html=qhas_html, + htp_graph_json=htp_graph_json, + htp_graph_before_json=htp_graph_before_json, + runtrace_json=( + runtrace_json if os.path.isfile(runtrace_json) else None + ), ) - return optrace_path, qhas_path +def _validate_pte_profile_level(pte_path: str) -> None: + """Assert that any offline-prepare .pte was built with profile_level=3. + """ + from executorch.exir._serialize._program import deserialize_pte_binary + + with open(pte_path, "rb") as f: + program = deserialize_pte_binary(f.read()).program + + for execution_plan in program.execution_plan: + for delegate in execution_plan.delegates: + if delegate.id != "QnnBackend": + continue + spec = delegate.compile_specs[0] + options = flatbuffer_to_option(bytes(spec.value)) + if options.online_prepare: + continue # online-prepare: profile_level is set on-host later + assert ( + options.profile_level == QnnExecuTorchProfileLevel.kProfileOptrace + ), ( + f"{pte_path} was compiled with online_prepare=False and " + f"profile_level={options.profile_level.name}." + "HTP Profling (Optrace) feature requires profile_level=3 (kProfileOptrace) " + "at build_executorch_binary() time — \n" + "Please re-export with qnn_config.profile_level=3, or use online_prepare=True" + ) -def generate_optrace( - artifact, +def _validate_hextimate_soc(soc_id: QcomChipset) -> None: + if soc_id not in _HEXTIMATE_SUPPORTED_SOCS: + supported = ", ".join(soc.name for soc in _HEXTIMATE_SUPPORTED_SOCS) + raise AssertionError( + f"hextimate currently supports only {supported}; got {soc_id.name}." + ) + + +def _generate_htp_analysis_result( + artifact_dir: str, soc_id: QcomChipset, - adb, pte_path: str, - inputs: Sequence[Tuple[torch.Tensor]], -): - """ - Generate optrace and QHAS (QNN HTP Analysis Summary) JSON files. + mode: Literal["optrace", "hextimate"], + inputs: Optional[Sequence[Tuple[torch.Tensor]]] = None, + adb=None, +) -> List[QnnHtpProfileArtifacts]: + assert mode in ("optrace", "hextimate"), f"unknown mode {mode!r}" + if mode == "optrace": + assert adb is not None, "optrace requires adb for on-device execution" + _validate_pte_profile_level(pte_path) - Args: - artifact (str): Path to the artifact folder. - adb (SimpleADB): An object for communicating with Android device - pte_path (str): The path to the generated PTE file, including the file extension (e.g., model.pte). - inputs Sequence((Tuple[torch.Tensor])): The input tensors for the model. + dumpfiles = dump_context_from_pte(pte_path, output_dir=artifact_dir) + qnn_tool = QnnTool( + artifact_dir=artifact_dir, + sample_input=inputs, + soc_id=soc_id, + adb=adb, + build_folder=(adb.build_path if adb is not None else None), + workspace=(adb.workspace if adb is not None else None), + ) - Returns: - dict: A dictionary where keys are the dumped file paths and values are tuples containing the paths - to the generated optrace and QHAS JSON files. - """ - filename, _ = os.path.splitext(pte_path.split(os.sep)[-1]) + return [ + qnn_tool.run(mode=mode, binary_file=os.path.basename(f)) + for f in dumpfiles + ] - # Dump compiled binaries - dumpfiles = dump_context_from_pte(pte_path) - # Generate optrace and QHAS - qnn_tool = QnnTool( - artifact, - inputs, - soc_id, - adb, - build_folder=adb.build_path, - workspace=adb.workspace, +def generate_htp_profile_result( + artifact_dir: str, + soc_id: QcomChipset, + pte_path: str, + inputs: Sequence[Tuple[torch.Tensor]], + adb, +) -> List[QnnHtpProfileArtifacts]: + """Generate HTP optrace artifacts from a .pte by running on device. + + Arguments: + - artifact_dir: host directory for dumped `.dlc`/`.bin`, schematics, QNN + configs, profiling logs, and qnn-profile-viewer outputs. + - soc_id: target SoC used by the compiled `.pte`; must match the device. + - pte_path: `.pte` produced by build_executorch_binary(). + - inputs: sample input tensors used by qnn-net-run for optrace collection. + - adb: SimpleADB helper for pushing files, running qnn-net-run, and + pulling `qnn-profiling-data_0.log` from the device. + + Supported prepare modes for the input pte file: + - `QnnConfig.online_prepare=True`: `.pte` contains `.dlc`; + We will create the context binary and schematic before device execution. + - `QnnConfig.online_prepare=False`: `.pte` contains finalized `.bin`, user must + also set `QnnConfig.profile_level=3` so optrace instrumentation is already baked in; + We will extract schematic and context binary from pte and continue device execution. + + """ + return _generate_htp_analysis_result( + artifact_dir=artifact_dir, + soc_id=soc_id, + pte_path=pte_path, + inputs=inputs, + mode="optrace", + adb=adb, ) - binaries_trace = {} - for file in dumpfiles: - filename = file.split(os.sep)[-1] - optrace, qhas = qnn_tool.generate_optrace(filename) - binaries_trace[file] = (optrace, qhas) - return binaries_trace + +def estimate_htp_profile_result( + artifact_dir: str, + soc_id: QcomChipset, + pte_path: str, +) -> List[QnnHtpProfileArtifacts]: + """Estimate HTP performance with host-only hextimate artifacts. + + Arguments: + - artifact_dir: host directory for dumped `.dlc`, QNN configs, and + qnn-profile-viewer outputs. + - soc_id: target SoC used by the compiled `.pte`; currently limited to: + SA8540, SA8255, QCS9100, and SA8797. + - pte_path: `.pte` produced by build_executorch_binary(), requiring + `QnnConfig.online_prepare=True` for `.pte` generation and QNN SDK >= 2.41. + """ + _validate_hextimate_soc(soc_id) + return _generate_htp_analysis_result( + artifact_dir=artifact_dir, + soc_id=soc_id, + pte_path=pte_path, + mode="hextimate", + ) diff --git a/backends/qualcomm/qnn_preprocess.py b/backends/qualcomm/qnn_preprocess.py index a267dc2f763..7fb1b1ccafe 100644 --- a/backends/qualcomm/qnn_preprocess.py +++ b/backends/qualcomm/qnn_preprocess.py @@ -46,6 +46,62 @@ logger.setLevel(logging.DEBUG) +import os +import struct +from executorch.backends.qualcomm.serialization.qc_schema import QnnExecuTorchProfileLevel + +# ============================================================================== +# Schematic Binary Serialization (Gated under profile_level=3) +# ============================================================================== +# +# MOTIVATION: Embed AOT host-side schematic binaries directly inside the .PTE's +# processed_bytes payload so that the .PTE is completely self-contained for optrace. +# Eliminates implicit, brittle out-of-band dependencies on host CWD leftovers. +# +# SAFE HARBOR (Why it won't affect target execution): +# - Header: backends/qualcomm/runtime/backends/QnnCustomProtocol.h (.cpp / .h) +# - Decoder: QnnContextCustomProtocol::DeserializeContextCustomBuffer() +# The protocol writes a 256-byte header with `binary_size_` at offset 12 (after +# magic & signature). The target-side QNN runtime reads exactly `binary_size_` +# bytes, completely ignoring any extra data appended at the tail. 100% safe. +# +# Payload layout: +# +------------------------------------------------------------------------+ +# | [Qnn Custom Protocol Buffer (incl. Context Binary)] | +# | Size: 256 + Context Binary Size (Fully parsed and used by runtime) | +# +------------------------------------------------------------------------+ +# | [Schematic Block 1] | +# | [Schematic Block 2] ... | +# +------------------------------------------------------------------------+ +# | Total Schematics Block Length (8 bytes, uint64) | +# +------------------------------------------------------------------------+ +# | Magic Suffix (8 bytes: b"SCHEMATI") | +# +------------------------------------------------------------------------+ +# +# Each [Schematic Block] is packed as: +# +--------------------+--------------------+-----------------+--------------+ +# | Name Length (4B) | Name (UTF-8 bytes) | Data Length (8B)| Data Bytes | +# +--------------------+--------------------+-----------------+--------------+ + +def _package_schematic(qnn_context_binary, graph_names, obj_options): + if obj_options.profile_level == QnnExecuTorchProfileLevel.kProfileOptrace: + schematic_bytes = b"" + for graph_name in graph_names: + schematic_path = os.path.join(os.getcwd(), f"{graph_name}_schematic.bin") + if os.path.isfile(schematic_path): + with open(schematic_path, "rb") as f: + data = f.read() + try: + os.remove(schematic_path) # Keep workspace clean + except OSError: + pass + name_encoded = graph_name.encode('utf-8') + schematic_bytes += struct.pack(" List[str]: +def dump_context_from_pte(pte_path, output_dir=None) -> List[str]: """ - Dump compiled binaries under the same directory of pte_path. - For partitioned graph, there will be multiple files with names f"{method_name}_{index}". + Dump compiled binaries under output_dir, or the same directory as pte_path + when output_dir is not set. 'method_name' refers to the name of a method in the nn.Module that was traced to generate this program, while 'index' indicates the order of execution. @@ -220,7 +220,8 @@ def dump_context_from_pte(pte_path) -> List[str]: program = deserialize_pte_binary(program_data).program - ctx_path = os.path.dirname(pte_path) + ctx_path = output_dir or os.path.dirname(pte_path) + os.makedirs(ctx_path, exist_ok=True) dumpfiles = [] for execution_plan in program.execution_plan: for i, delegate in enumerate(execution_plan.delegates): @@ -236,6 +237,32 @@ def dump_context_from_pte(pte_path) -> List[str]: dump_file = f"{ctx_path}/{execution_plan.name}_{i}{file_extension}" with open(dump_file, "wb") as f: f.write(binary) + + # ============================================================================== + # Unpack embedded schematic binaries if present. + # Details of the serialization schema and why it is perfectly safe + # can be found in executorch/backends/qualcomm/qnn_preprocess.py. + # ============================================================================== + import struct + if len(processed_bytes) >= 16 and processed_bytes[-8:] == b"SCHEMATI": + block_len, = struct.unpack("= 16 + block_len: + schematic_block = processed_bytes[-16 - block_len : -16] + offset = 0 + while offset < len(schematic_block): + name_len, = struct.unpack(" `data_index` represents the sequence of dataset, `output_index` stands for the order of graph output. +## HTP Profiling Examples + +These examples demonstrate Qualcomm HTP profiling flows: + +* `htp_profiling_on_device_op_trace_online.py`: on-device OpTrace with `online_prepare=True`. +* `htp_profiling_on_device_op_trace_offline.py`: on-device OpTrace with `online_prepare=False` and `profile_level=3`. +* `htp_profiling_on_host_hextimate.py`: host-side Hextimate. + # Generate ET Record This section describes how to generate an ET record for a .pte program using the provided script. * Generate ET record for .pte using the provided script: diff --git a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py new file mode 100644 index 00000000000..0a122998ecb --- /dev/null +++ b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py @@ -0,0 +1,128 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""HTP profiling demo — on-device OpTrace with offline_prepare. + +This example exports a `.pte` with `QnnConfig.online_prepare=False`, generates +on-device HTP OpTrace results with `generate_htp_profile_result()`, and opens +the QHAS report with QAIRT Visualizer. + +Internal detail: offline_prepare embeds a finalized QNN context binary in the +`.pte`, so `qnn_config.profile_level=3` is required during export. + +For the online_prepare OpTrace route, see +htp_profiling_on_device_op_trace_online.py. For host-only Hextimate, see +htp_profiling_on_host_hextimate.py. +""" + +import json +import os +from multiprocessing.connection import Client + +import torch +from executorch.backends.qualcomm.debugger.utils import generate_htp_profile_result +from executorch.backends.qualcomm.export_utils import ( + build_executorch_binary, + QnnConfig, + setup_common_args_and_variables, + SimpleADB, +) +from executorch.backends.qualcomm.quantizer.quantizer import QuantDtype +from executorch.backends.qualcomm.tests.models import SimpleModel +from executorch.backends.qualcomm.utils.utils import get_soc_to_chipset_map + + +def main(args) -> None: + qnn_config = QnnConfig.load_config(args.config_file if args.config_file else args) + assert not qnn_config.online_prepare, ( + "This demo uses on-device OpTrace with offline_prepare; remove " + "--online_prepare (or set online_prepare=False in your config file). " + "For online prepare, use htp_profiling_on_device_op_trace_online.py." + ) + assert qnn_config.profile_level == 3, ( + "Offline-prepare requires qnn_config.profile_level=3 so that the AoT " + "HtpContext bakes optrace instrumentation into the context binary " + "before qnn_context_get_binary() dumps it. Pass --profile_level 3 " + "(or set profile_level=3 in your config file)." + ) + + model = SimpleModel() + example_inputs = [(torch.ones(1, 32, 28, 28), torch.ones(1, 32, 28, 28))] + + pte_filename = "qnn_simple_model" + os.makedirs(args.artifact, exist_ok=True) + + build_executorch_binary( + model=model, + qnn_config=qnn_config, + file_name=f"{args.artifact}/{pte_filename}", + dataset=example_inputs, + quant_dtype=QuantDtype.use_8a8w, + ) + + adb = SimpleADB( + qnn_config=qnn_config, + pte_path=f"{args.artifact}/{pte_filename}.pte", + workspace=f"/data/local/tmp/executorch/{pte_filename}", + ) + artifacts = generate_htp_profile_result( + artifact_dir=args.artifact, + soc_id=get_soc_to_chipset_map()[args.soc_model], + pte_path=f"{args.artifact}/{pte_filename}.pte", + inputs=example_inputs, + adb=adb, + ) + + if args.ip and args.port != -1: + with Client((args.ip, args.port)) as conn: + conn.send(json.dumps({ + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], + })) + return + + try: + import qairt_visualizer + except ImportError: + for a in artifacts: + print(f"QHAS HTML: {a.qhas_html}") + return + + # Offline-prepare emits .bin binaries, which do not support the graph + # view in qairt-visualizer — only the reports are shown. + for a in artifacts: + qairt_visualizer.view(reports=a.visualizer_reports()) + + +if __name__ == "__main__": + parser = setup_common_args_and_variables() + parser.add_argument( + "-a", + "--artifact", + type=str, + default="", + help="The folder to store the exported program", + ) + + args = parser.parse_args() + + try: + main(args) + except Exception as e: + if args.ip and args.port != -1: + with Client((args.ip, args.port)) as conn: + conn.send(json.dumps({"Error": str(e)})) + else: + raise Exception(e) diff --git a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py new file mode 100644 index 00000000000..2f45bd7e8f6 --- /dev/null +++ b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py @@ -0,0 +1,126 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""HTP profiling demo — on-device OpTrace with online_prepare. + +This example exports a `.pte` with `QnnConfig.online_prepare=True`, generates +on-device HTP OpTrace results with `generate_htp_profile_result()`, and opens +the QHAS report with QAIRT Visualizer. + +Internal detail: online_prepare stores a graph description in the `.pte`; QNN +tools create the profiled context and schematic during profiling. + +For the offline_prepare OpTrace route, see +htp_profiling_on_device_op_trace_offline.py. For host-only Hextimate, see +htp_profiling_on_host_hextimate.py. +""" + +import json +import os +from multiprocessing.connection import Client + +import torch +from executorch.backends.qualcomm.debugger.utils import generate_htp_profile_result +from executorch.backends.qualcomm.export_utils import ( + build_executorch_binary, + QnnConfig, + setup_common_args_and_variables, + SimpleADB, +) +from executorch.backends.qualcomm.quantizer.quantizer import QuantDtype +from executorch.backends.qualcomm.tests.models import SimpleModel +from executorch.backends.qualcomm.utils.utils import get_soc_to_chipset_map + + +def main(args) -> None: + qnn_config = QnnConfig.load_config(args.config_file if args.config_file else args) + assert qnn_config.online_prepare, ( + "This demo uses on-device OpTrace with online_prepare; pass " + "--online_prepare (or set online_prepare=True in your config file). " + "For offline prepare, use htp_profiling_on_device_op_trace_offline.py." + ) + + model = SimpleModel() + example_inputs = [(torch.ones(1, 32, 28, 28), torch.ones(1, 32, 28, 28))] + + pte_filename = "qnn_simple_model" + os.makedirs(args.artifact, exist_ok=True) + + # lower to QNN + build_executorch_binary( + model=model, + qnn_config=qnn_config, + file_name=f"{args.artifact}/{pte_filename}", + dataset=example_inputs, + quant_dtype=QuantDtype.use_8a8w, + ) + + adb = SimpleADB( + qnn_config=qnn_config, + pte_path=f"{args.artifact}/{pte_filename}.pte", + workspace=f"/data/local/tmp/executorch/{pte_filename}", + ) + artifacts = generate_htp_profile_result( + artifact_dir=args.artifact, + soc_id=get_soc_to_chipset_map()[args.soc_model], + pte_path=f"{args.artifact}/{pte_filename}.pte", + inputs=example_inputs, + adb=adb, + ) + + if args.ip and args.port != -1: + with Client((args.ip, args.port)) as conn: + conn.send(json.dumps({ + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], + })) + return + + try: + import qairt_visualizer + except ImportError: + for a in artifacts: + print(f"QHAS HTML: {a.qhas_html}") + return + + # Visualize each binary. Online-prepare emits .dlc, which supports the + # graph view; offline .bin binaries do not. + for a in artifacts: + if a.binary_path.endswith(".dlc"): + qairt_visualizer.view(a.binary_path, reports=a.visualizer_reports()) + else: + qairt_visualizer.view(reports=a.visualizer_reports()) + + +if __name__ == "__main__": + parser = setup_common_args_and_variables() + parser.add_argument( + "-a", + "--artifact", + type=str, + default="", + help="The folder to store the exported program", + ) + + args = parser.parse_args() + + try: + main(args) + except Exception as e: + if args.ip and args.port != -1: + with Client((args.ip, args.port)) as conn: + conn.send(json.dumps({"Error": str(e)})) + else: + raise Exception(e) diff --git a/examples/qualcomm/util_scripts/qairt_visualizer_demo.py b/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py similarity index 53% rename from examples/qualcomm/util_scripts/qairt_visualizer_demo.py rename to examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py index 9d2ce7a8806..c09ac30c791 100644 --- a/examples/qualcomm/util_scripts/qairt_visualizer_demo.py +++ b/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py @@ -4,18 +4,26 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +"""HTP profiling demo — Hextimate on host. + +This example exports a `.pte` with `QnnConfig.online_prepare=True`, estimates +HTP performance on host with `estimate_htp_profile_result()`, and opens the +QHAS report with QAIRT Visualizer. + +Hextimate is compile-time estimation, not an on-device OpTrace run. It does not +use sample inputs or adb during profiling. +""" + import json import os from multiprocessing.connection import Client -import qairt_visualizer import torch -from executorch.backends.qualcomm.debugger.utils import generate_optrace +from executorch.backends.qualcomm.debugger.utils import estimate_htp_profile_result from executorch.backends.qualcomm.export_utils import ( build_executorch_binary, QnnConfig, setup_common_args_and_variables, - SimpleADB, ) from executorch.backends.qualcomm.quantizer.quantizer import QuantDtype from executorch.backends.qualcomm.tests.models import SimpleModel @@ -24,17 +32,17 @@ def main(args) -> None: qnn_config = QnnConfig.load_config(args.config_file if args.config_file else args) + assert qnn_config.online_prepare, ( + "This demo uses host-only Hextimate; pass --online_prepare " + "(or set online_prepare=True in your config file)." + ) + model = SimpleModel() example_inputs = [(torch.ones(1, 32, 28, 28), torch.ones(1, 32, 28, 28))] pte_filename = "qnn_simple_model" os.makedirs(args.artifact, exist_ok=True) - assert ( - qnn_config.profile_level == 3 - ), "Please turn profile_level to 3 for the purpose of this tutorial." - - # lower to QNN build_executorch_binary( model=model, qnn_config=qnn_config, @@ -43,32 +51,39 @@ def main(args) -> None: quant_dtype=QuantDtype.use_8a8w, ) - # generate optrace and QHAS - adb = SimpleADB( - qnn_config=qnn_config, + artifacts = estimate_htp_profile_result( + artifact_dir=args.artifact, + soc_id=get_soc_to_chipset_map()[args.soc_model], pte_path=f"{args.artifact}/{pte_filename}.pte", - workspace=f"/data/local/tmp/executorch/{pte_filename}", - ) - binaries_trace = generate_optrace( - args.artifact, - get_soc_to_chipset_map()[args.soc_model], - adb, - f"{args.artifact}/{pte_filename}.pte", - example_inputs, ) if args.ip and args.port != -1: with Client((args.ip, args.port)) as conn: - conn.send(json.dumps({"binaries_trace": binaries_trace})) - else: - # Visualize the model and reports - for binary, (optrace, qhas) in binaries_trace.items(): - file_extension = os.path.splitext(binary)[-1] - if file_extension == ".bin": - qairt_visualizer.view(reports=[optrace, qhas]) - elif file_extension == ".dlc": - # We only show graph for dlc binary - qairt_visualizer.view(binary, reports=[optrace, qhas]) + conn.send(json.dumps({ + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], + })) + return + + try: + import qairt_visualizer + except ImportError: + for a in artifacts: + print(f"QHAS HTML: {a.qhas_html}") + return + + for a in artifacts: + qairt_visualizer.view(a.binary_path, reports=a.visualizer_reports()) + print(f"QHAS HTML: {a.qhas_html}") if __name__ == "__main__": From 225fb35e7e68cd1d894b78333ce60b8088bcaed2 Mon Sep 17 00:00:00 2001 From: boyuc Date: Mon, 17 Aug 2026 11:40:59 +0800 Subject: [PATCH 2/9] Make QNN tail protocal extensible --- backends/qualcomm/qnn_preprocess.py | 89 ++--- .../serialization/qnn_tail_protocol.py | 340 ++++++++++++++++++ backends/qualcomm/utils/utils.py | 34 +- 3 files changed, 387 insertions(+), 76 deletions(-) create mode 100644 backends/qualcomm/serialization/qnn_tail_protocol.py diff --git a/backends/qualcomm/qnn_preprocess.py b/backends/qualcomm/qnn_preprocess.py index 7fb1b1ccafe..d54c2dff557 100644 --- a/backends/qualcomm/qnn_preprocess.py +++ b/backends/qualcomm/qnn_preprocess.py @@ -5,6 +5,7 @@ # LICENSE file in the root directory of this source tree. import logging +import os from collections import defaultdict from typing import Dict, final, List @@ -18,10 +19,16 @@ from executorch.backends.qualcomm.serialization.qc_schema import ( QnnExecuTorchBackendType, QnnExecuTorchOpPackageInfo, + QnnExecuTorchProfileLevel, ) from executorch.backends.qualcomm.serialization.qc_schema_serialize import ( flatbuffer_to_option, ) +from executorch.backends.qualcomm.serialization.qnn_tail_protocol import ( + pack_schematic_payload, + pack_tail, + SECTION_SCHEMATIC, +) from executorch.backends.qualcomm.utils.constants import ( QCOM_AXIS_ORDER, QCOM_TENSOR_NAME, @@ -46,61 +53,33 @@ logger.setLevel(logging.DEBUG) -import os -import struct -from executorch.backends.qualcomm.serialization.qc_schema import QnnExecuTorchProfileLevel -# ============================================================================== -# Schematic Binary Serialization (Gated under profile_level=3) -# ============================================================================== -# -# MOTIVATION: Embed AOT host-side schematic binaries directly inside the .PTE's -# processed_bytes payload so that the .PTE is completely self-contained for optrace. -# Eliminates implicit, brittle out-of-band dependencies on host CWD leftovers. -# -# SAFE HARBOR (Why it won't affect target execution): -# - Header: backends/qualcomm/runtime/backends/QnnCustomProtocol.h (.cpp / .h) -# - Decoder: QnnContextCustomProtocol::DeserializeContextCustomBuffer() -# The protocol writes a 256-byte header with `binary_size_` at offset 12 (after -# magic & signature). The target-side QNN runtime reads exactly `binary_size_` -# bytes, completely ignoring any extra data appended at the tail. 100% safe. -# -# Payload layout: -# +------------------------------------------------------------------------+ -# | [Qnn Custom Protocol Buffer (incl. Context Binary)] | -# | Size: 256 + Context Binary Size (Fully parsed and used by runtime) | -# +------------------------------------------------------------------------+ -# | [Schematic Block 1] | -# | [Schematic Block 2] ... | -# +------------------------------------------------------------------------+ -# | Total Schematics Block Length (8 bytes, uint64) | -# +------------------------------------------------------------------------+ -# | Magic Suffix (8 bytes: b"SCHEMATI") | -# +------------------------------------------------------------------------+ -# -# Each [Schematic Block] is packed as: -# +--------------------+--------------------+-----------------+--------------+ -# | Name Length (4B) | Name (UTF-8 bytes) | Data Length (8B)| Data Bytes | -# +--------------------+--------------------+-----------------+--------------+ +def _package_tail(qnn_context_binary, graph_names, obj_options): + """Append host-side metadata sections after the QNN context binary. + + Currently packages schematic binaries when profile_level is kProfileOptrace. + See qnn_tail_protocol.py for the wire format specification. + """ + if obj_options.profile_level != QnnExecuTorchProfileLevel.kProfileOptrace: + return bytes(qnn_context_binary) + + named_blobs = [] + for graph_name in graph_names: + schematic_path = os.path.join(os.getcwd(), f"{graph_name}_schematic.bin") + if os.path.isfile(schematic_path): + with open(schematic_path, "rb") as f: + data = f.read() + try: + os.remove(schematic_path) + except OSError: + pass + named_blobs.append((graph_name, data)) + + if not named_blobs: + return bytes(qnn_context_binary) -def _package_schematic(qnn_context_binary, graph_names, obj_options): - if obj_options.profile_level == QnnExecuTorchProfileLevel.kProfileOptrace: - schematic_bytes = b"" - for graph_name in graph_names: - schematic_path = os.path.join(os.getcwd(), f"{graph_name}_schematic.bin") - if os.path.isfile(schematic_path): - with open(schematic_path, "rb") as f: - data = f.read() - try: - os.remove(schematic_path) # Keep workspace clean - except OSError: - pass - name_encoded = graph_name.encode('utf-8') - schematic_bytes += struct.pack(" bytes: + """Encode an unsigned 16-bit integer as 2 bytes, little-endian.""" + return struct.pack(" bytes: + """Encode an unsigned 32-bit integer as 4 bytes, little-endian.""" + return struct.pack(" bytes: + """Encode an unsigned 64-bit integer as 8 bytes, little-endian.""" + return struct.pack(" int: + """Read an unsigned 16-bit integer from 2 bytes at offset, little-endian.""" + return struct.unpack(" int: + """Read an unsigned 32-bit integer from 4 bytes at offset, little-endian.""" + return struct.unpack(" int: + """Read an unsigned 64-bit integer from 8 bytes at offset, little-endian.""" + return struct.unpack(" bytes: + """Pack a list of (name, data) pairs into a SCHEMATIC section payload. + + Each entry is serialized as: + [name_length: u32] [name: utf-8 bytes] [data_length: u64] [data: raw bytes] + + Args: + named_blobs: Each entry is (graph_name, schematic_binary_bytes). + + Returns: + The raw payload bytes for a SCHEMATIC section. + """ + parts: List[bytes] = [] + for name, data in named_blobs: + name_encoded = name.encode("utf-8") + parts.append(_encode_u32(len(name_encoded))) # 4B: name byte length + parts.append(name_encoded) # variable: UTF-8 name + parts.append(_encode_u64(len(data))) # 8B: blob byte length + parts.append(data) # variable: raw blob + return b"".join(parts) + + +def pack_tail(sections: List[Tuple[int, bytes]]) -> bytes: + """Pack typed sections into a complete tail appendix (sections + footer). + + Each section is serialized as: + [type_tag: u32] [payload_length: u64] [payload: raw bytes] + + The footer is appended last: + [total_sections_length: u64] [section_count: u32] [version: u16] [magic: 8B] + + Args: + sections: List of (type_tag, payload_bytes) pairs. + + Returns: + Bytes to append directly after the QNN context binary. + Returns empty bytes if ``sections`` is empty. + """ + if not sections: + return b"" + + section_parts: List[bytes] = [] + for type_tag, payload in sections: + section_parts.append(_encode_u32(type_tag)) # 4B: section type + section_parts.append(_encode_u64(len(payload))) # 8B: payload size + section_parts.append(payload) # variable: payload + section_bytes = b"".join(section_parts) + + # Fixed 22-byte footer — reader parses from the end of the buffer + footer = ( + _encode_u64(len(section_bytes)) # 8B: total length of all sections above + + _encode_u32(len(sections)) # 4B: number of sections + + _encode_u16(TAIL_VERSION) # 2B: protocol version + + TAIL_MAGIC # 8B: magic identifier for detection + ) + return section_bytes + footer + + +# ============================================================================== +# Unpacking (Host-side tooling — utils/utils.py calls these) +# ============================================================================== + + +def has_tail(processed_bytes: bytes) -> bool: + """Check whether processed_bytes carries a QNN tail appendix. + + Detection: the last 8 bytes must equal TAIL_MAGIC and the buffer must be at + least FOOTER_SIZE (22) bytes long. + """ + return ( + len(processed_bytes) >= FOOTER_SIZE + and processed_bytes[-8:] == TAIL_MAGIC + ) + + +def unpack_tail_sections( + processed_bytes: bytes, +) -> Dict[int, List[bytes]]: + """Unpack all tail sections from processed_bytes. + + Reads the fixed footer from the buffer's tail to locate and walk through + each section. Unknown section types are preserved in the output — callers + simply ignore types they do not handle. + + Returns: + Dict mapping section type tag → list of payloads (a type may appear + more than once). + + Raises: + ValueError: If the magic is present but the buffer is truncated or + corrupted. + """ + if not has_tail(processed_bytes): + return {} + + # --- Parse the fixed 22-byte footer (at the very end) --- + footer = processed_bytes[-FOOTER_SIZE:] + total_sections_len = _decode_u64(footer, 0) # bytes 0..7: total sections length + section_count = _decode_u32(footer, 8) # bytes 8..11: section count + # _version = _decode_u16(footer, 12) # bytes 12..13: version (reserved) + # bytes 14..21: magic (already verified by has_tail) + + # --- Validate that the buffer is large enough --- + expected_min = FOOTER_SIZE + total_sections_len + if len(processed_bytes) < expected_min: + raise ValueError( + f"QNN tail protocol: buffer too short. " + f"Need {expected_min} bytes for tail, got {len(processed_bytes)}." + ) + + # --- Slice out the sections block (sits just before the footer) --- + sections_block = processed_bytes[ + -(FOOTER_SIZE + total_sections_len) : -FOOTER_SIZE + ] + + # --- Walk sections sequentially --- + result: Dict[int, List[bytes]] = {} + offset = 0 + for _ in range(section_count): + if offset + 12 > len(sections_block): + raise ValueError("QNN tail protocol: truncated section header.") + + type_tag = _decode_u32(sections_block, offset) # 4B: section type + payload_len = _decode_u64(sections_block, offset + 4) # 8B: payload size + offset += 12 # advance past the 12-byte section header + + if offset + payload_len > len(sections_block): + raise ValueError( + f"QNN tail protocol: section type=0x{type_tag:02X} claims " + f"{payload_len} bytes but only {len(sections_block) - offset} " + f"remain." + ) + payload = sections_block[offset : offset + payload_len] + offset += payload_len + result.setdefault(type_tag, []).append(payload) + + return result + + +def unpack_schematic_payload( + payload: bytes, +) -> List[Tuple[str, bytes]]: + """Decode a SCHEMATIC section payload into (name, data) pairs. + + Walks the payload sequentially, reading each entry as: + [name_length: u32] [name: utf-8 bytes] [data_length: u64] [data: raw bytes] + + Args: + payload: Raw payload bytes from a SECTION_SCHEMATIC entry. + + Returns: + List of (graph_name, schematic_binary) tuples. + """ + entries: List[Tuple[str, bytes]] = [] + offset = 0 + while offset < len(payload): + if offset + 4 > len(payload): + raise ValueError("QNN tail SCHEMATIC: truncated name length.") + name_len = _decode_u32(payload, offset) # 4B: how many bytes the name uses + offset += 4 + + if offset + name_len > len(payload): + raise ValueError("QNN tail SCHEMATIC: truncated name.") + name = payload[offset : offset + name_len].decode("utf-8") + offset += name_len + + if offset + 8 > len(payload): + raise ValueError("QNN tail SCHEMATIC: truncated data length.") + data_len = _decode_u64(payload, offset) # 8B: how many bytes the blob uses + offset += 8 + + if offset + data_len > len(payload): + raise ValueError("QNN tail SCHEMATIC: truncated data.") + data = payload[offset : offset + data_len] + offset += data_len + + entries.append((name, data)) + return entries diff --git a/backends/qualcomm/utils/utils.py b/backends/qualcomm/utils/utils.py index f23ad2210c2..b955ca39c4e 100644 --- a/backends/qualcomm/utils/utils.py +++ b/backends/qualcomm/utils/utils.py @@ -238,27 +238,19 @@ def dump_context_from_pte(pte_path, output_dir=None) -> List[str]: with open(dump_file, "wb") as f: f.write(binary) - # ============================================================================== - # Unpack embedded schematic binaries if present. - # Details of the serialization schema and why it is perfectly safe - # can be found in executorch/backends/qualcomm/qnn_preprocess.py. - # ============================================================================== - import struct - if len(processed_bytes) >= 16 and processed_bytes[-8:] == b"SCHEMATI": - block_len, = struct.unpack("= 16 + block_len: - schematic_block = processed_bytes[-16 - block_len : -16] - offset = 0 - while offset < len(schematic_block): - name_len, = struct.unpack(" Date: Fri, 21 Aug 2026 13:44:13 +0800 Subject: [PATCH 3/9] Fix comments related to README --- backends/qualcomm/debugger/README.md | 57 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/backends/qualcomm/debugger/README.md b/backends/qualcomm/debugger/README.md index a7e44181bbb..540ee059e98 100644 --- a/backends/qualcomm/debugger/README.md +++ b/backends/qualcomm/debugger/README.md @@ -1,31 +1,48 @@ # ExecuTorch QNN Debugger & Profiler -This directory bundles three independent debugging and profiling flows for the ExecuTorch QNN backend. They share no code and address different failure modes — jump directly to the section you need. +This directory bundles three independent debugging and profiling flows for the ExecuTorch QNN backend. They are independent and address different failure modes — jump directly to the section you need. **Table of contents** -- [HTP Profiling](#htp-profiling) +- [Executorch QNN HTP Profiling](#executorch-qnn-htp-profiling) - [ExecuTorch QNN Intermediate Output Debugger](#executorch-qnn-intermediate-output-debugger) - [ExecuTorch QNN HTP Heap Profiling](#executorch-qnn-htp-heap-profiling) --- +# Executorch QNN HTP Profiling -# HTP Profiling +This section shows how to produce HTP profiling results and inspect the output reports. -This section shows how to produce HTP profiling results from an ExecuTorch `.pte` and inspect the output reports. The main user-facing controls are: +- The most accurate profiling mode is **on device profiling** with `generate_htp_profile_result()`, this requires a connected android device through ADB, see details in section 2.1. +- Another profiling mode is **on host estimiation** with `estimate_htp_profile_result()` this doesn't require a device and can be run on host machine. However the accuracy and soc support might be limited, see details in section 2.2. + + +Different HTP profiling feature support different set of prepare mode, prepare mode is set in AOT config, and decides which format of QNN internal graph is packed in `.pte` file, + * On device profiling support both `online_prepare` and `offline_prepare` + * On host profiling reqruires `online_prepare` + +**Host Estimation vs On-Device Generation** +| Mode | Source of measurements | Requires device? | Requires `QnnConfig.online_prepare=True` for `.pte` generation ? | +|:--------------|:---------------------------------------|:-----------------|:-----------------| +| `generate_htp_profile_result` (`"optrace"`) | On-device hardware counters (real run) | Yes | No — support both mode (`.dlc` or `.bin`) | +| `estimate_htp_profile_result` (`"hextimate"`) | Compile-time performance-model estimate | No (host only) | Yes | + + +The rest of the guide is arranged here: 1. **`.pte` generation prepare mode:** -- 1.1 `online_prepare`: controlled by `QnnConfig.online_prepare=True`. -- 1.2 `offline_prepare`: controlled by `QnnConfig.online_prepare=False`. +We introduce how to trigger each preparation mode in section 1.1 and 1.2: + - 1.1 `online_prepare`: controlled by `QnnConfig.online_prepare=True`. + - 1.2 `offline_prepare`: controlled by `QnnConfig.online_prepare=False`. -2. **Public Functions:** -- 2.1 `generate_htp_profile_result()`: generates device-based profiling results. -- 2.2 `estimate_htp_profile_result()`: estimates host-based profiling results. +3. **Public Functions for Generating Profiling Results** + + 2.1 `generate_htp_profile_result()`: generates device-based profiling results (Optrace in QNN SDK). + + 2.2 `estimate_htp_profile_result()`: estimates host-based profiling results (Hextimate in QNN SDK). -3. **HTP Profile Output Format:** `QnnHtpProfileArtifacts` contains genrated html , json and chrometrace files. +4. **HTP Profile Output Format:** `QnnHtpProfileArtifacts` contains genrated HTML, JSON and chrometrace files. -4. **Qairt-Visualizer:** QAIRT Visualizer can open the QHAS result from `qhas_json` and `chrometrace_json`. Use `QnnHtpProfileArtifacts.visualizer_reports()` to pass related reports. +5. **Qairt-Visualizer:** QAIRT Visualizer can open the QHAS result from `qhas_json` and `chrometrace_json`. Use `QnnHtpProfileArtifacts.visualizer_reports()` to pass related reports. ## 1. Select AOT Prepare modes for `.pte` Generation Users choose one prepare mode before exporting the `.pte`: @@ -68,7 +85,7 @@ build_executorch_binary( **Demo script**: ```bash python -m examples.qualcomm.util_scripts.htp_profiling_on_device_op_trace_offline \ - --host ${host} --device ${device} --soc_model ${SOC_MODEL} -build_folder build-android \ + --host ${host} --device ${device} --soc_model ${SOC_MODEL} --build_folder build-android \ -a ${path_to_output_folder} --profile_level 3 ``` @@ -134,7 +151,7 @@ Both public functions `estimate_htp_profile_result()` and `generate_htp_profile_ Important fields: -- `qhas_html`: QHAS HTML report. This is the easiest artifact to open when you want the QNN HTP Analysis Summary. +- `qhas_html`: QHAS HTML report. This is the most convenient artifact for viewing the QNN HTP Analysis Summary. - `qhas_json`: QHAS JSON report. - `chrometrace_json`: Chrome trace JSON. Open it with `chrome://tracing` or Perfetto. - `htp_graph_json`: HTP graph JSON after optimization. @@ -184,18 +201,12 @@ For the viewer package, see [QAIRT Visualizer](https://pypi.org/project/qairt-vi ## Technical Details -### Host Estimation vs On-Device Generation -| Mode | Source of measurements | Requires device? | Requires `QnnConfig.online_prepare=True` for `.pte` generation (`.dlc`)? | -|:--------------|:---------------------------------------|:-----------------|:-----------------| -| `generate_htp_profile_result` (`"optrace"`) | On-device hardware counters (real run) | Yes | No — support both mode (`.dlc` or `.bin`) | -| `estimate_htp_profile_result` (`"hextimate"`) | Compile-time performance-model estimate | No (host only) | Yes | - ### SDK compatibility -| QAIRT SDK version | Optrace | Hextimate | QHAS JSON (optrace) | -|:------------------|:--------|:----------|:--------------------| -| 2.37 – 2.40 | Supported | **Not supported** | Valid | -| 2.41 – 2.50 | Supported | Supported | Valid | +| QAIRT SDK version | Optrace | Hextimate | +|:------------------|:--------|:----------| +| 2.37 – 2.40 | Supported | **Not supported** | +| 2.41 – 2.50 | Supported | Supported | `estimate_htp_profile_result()` hard-errors on SDKs below 2.41 and on unsupported SoCs. From a18648106d3d44bf9bb16ec8570f9c5779d58b98 Mon Sep 17 00:00:00 2001 From: boyuc Date: Mon, 24 Aug 2026 14:44:55 +0800 Subject: [PATCH 4/9] decouple pte tail protocal and hextimate --- backends/qualcomm/debugger/utils.py | 6 +- backends/qualcomm/qnn_preprocess.py | 39 +- .../serialization/qnn_tail_protocol.py | 340 ------------------ backends/qualcomm/utils/utils.py | 18 - 4 files changed, 6 insertions(+), 397 deletions(-) delete mode 100644 backends/qualcomm/serialization/qnn_tail_protocol.py diff --git a/backends/qualcomm/debugger/utils.py b/backends/qualcomm/debugger/utils.py index 790c65cf5b5..ebfda7f36ca 100644 --- a/backends/qualcomm/debugger/utils.py +++ b/backends/qualcomm/debugger/utils.py @@ -323,7 +323,7 @@ def _write_config_files(self, backend_extensions: Optional[dict] = None) -> Tupl def _run(self, cmd: List[str], step: str) -> None: """Run a subprocess with argv list; assert on non-zero exit.""" - result = subprocess.run(cmd, capture_output=True, cwd=self.artifact_dir) + result = subprocess.run(cmd, capture_output=True) assert result.returncode == 0, ( f"{step} failed (exit {result.returncode}): " f"{result.stderr.decode('utf-8', errors='replace')}" @@ -421,7 +421,9 @@ def _qnn_profile_viewer(self, schematic_stem: str, graph_idx: int) -> None: json.dump({"features": {"qhas_json": True}}, f, indent=4) target = "x86_64-linux-clang" - schematic = os.path.join(self.artifact_dir, f"{schematic_stem}.bin") + # TODO: remove assumption that AOT dumpped schematic file exists in same cwd + # we need to make .pte self-contained. + schematic = os.path.join(os.getcwd(), f"{schematic_stem}.bin") assert os.path.isfile(schematic), ( f"qnn-profile-viewer expected schematic at {schematic}; " "in case of online_prepare, the context-binary-generator step should have produced it in artifact_dir. " diff --git a/backends/qualcomm/qnn_preprocess.py b/backends/qualcomm/qnn_preprocess.py index d54c2dff557..128e9bd92cd 100644 --- a/backends/qualcomm/qnn_preprocess.py +++ b/backends/qualcomm/qnn_preprocess.py @@ -24,11 +24,6 @@ from executorch.backends.qualcomm.serialization.qc_schema_serialize import ( flatbuffer_to_option, ) -from executorch.backends.qualcomm.serialization.qnn_tail_protocol import ( - pack_schematic_payload, - pack_tail, - SECTION_SCHEMATIC, -) from executorch.backends.qualcomm.utils.constants import ( QCOM_AXIS_ORDER, QCOM_TENSOR_NAME, @@ -53,34 +48,6 @@ logger.setLevel(logging.DEBUG) - -def _package_tail(qnn_context_binary, graph_names, obj_options): - """Append host-side metadata sections after the QNN context binary. - - Currently packages schematic binaries when profile_level is kProfileOptrace. - See qnn_tail_protocol.py for the wire format specification. - """ - if obj_options.profile_level != QnnExecuTorchProfileLevel.kProfileOptrace: - return bytes(qnn_context_binary) - - named_blobs = [] - for graph_name in graph_names: - schematic_path = os.path.join(os.getcwd(), f"{graph_name}_schematic.bin") - if os.path.isfile(schematic_path): - with open(schematic_path, "rb") as f: - data = f.read() - try: - os.remove(schematic_path) - except OSError: - pass - named_blobs.append((graph_name, data)) - - if not named_blobs: - return bytes(qnn_context_binary) - - sections = [(SECTION_SCHEMATIC, pack_schematic_payload(named_blobs))] - return bytes(qnn_context_binary) + pack_tail(sections) - @final class QnnBackend(BackendDetails): @staticmethod @@ -179,9 +146,8 @@ def preprocess( assert len(qnn_context_binary) != 0, "Failed to generate Qnn context binary." qnn_manager.DestroyContext() # For now, debug_handle_map is not used by QNN ExecuTorch - processed_bytes = _package_tail(qnn_context_binary, qnn_manager.GetGraphNames(), obj_options) return PreprocessResult( - processed_bytes=processed_bytes, + processed_bytes=bytes(qnn_context_binary), debug_handle_map={}, ) @@ -261,12 +227,11 @@ def preprocess_multimethod( # noqa: C901 len(qnn_context_binary) != 0 ), "Failed to generate Qnn context binary." qnn_manager.DestroyContext() - processed_bytes = _package_tail(qnn_context_binary, graph_names, option) # methods should share the same context binary for current partition for key in edge_programs.keys(): all_processed_results[key].append( PreprocessResult( - processed_bytes=processed_bytes, + processed_bytes=bytes(qnn_context_binary), debug_handle_map=debug_handle_builder.get_delegate_mapping(), ) ) diff --git a/backends/qualcomm/serialization/qnn_tail_protocol.py b/backends/qualcomm/serialization/qnn_tail_protocol.py deleted file mode 100644 index b38f0219e3e..00000000000 --- a/backends/qualcomm/serialization/qnn_tail_protocol.py +++ /dev/null @@ -1,340 +0,0 @@ -# Copyright (c) Qualcomm Innovation Center, Inc. -# All rights reserved -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. -r"""QNN Tail Protocol — extensible metadata appendix for processed_bytes. - -Embeds host-side metadata (schematics, future: calibration, timing hints, etc.) -inside the .PTE's ``processed_bytes`` buffer *after* the QNN context binary, -so the .PTE is fully self-contained for profiling and debugging tools. - -Safety Guarantee ----------------- -The on-device runtime never sees the tail. The QNN context binary is fronted by -a 256-byte custom protocol header (see ``QnnCustomProtocol.h``) whose -``binary_size_`` field tells the runtime exactly how many bytes to consume. -Anything appended past that boundary is invisible to the DSP loader. - -Wire Format (version 1) ------------------------- -The tail is appended immediately after the QNN Custom Protocol Buffer (header + -context binary). It is read from the END of ``processed_bytes`` — the fixed- -size footer at the very tail tells the reader where the sections begin. - -:: - - +========================================================================+ - | QNN Custom Protocol Buffer (256-byte header + context binary) | - | [Untouched — runtime reads only this part] | - +========================================================================+ - | | - | ┌──────────────────────────────────────────────────────────────────┐ | - | │ Section 0 │ | - | │ ┌────────────────┬───────────────────────────────────────────┐ │ | - | │ │ Type (4B LE)│ uint32 — identifies payload kind (enum) │ │ | - | │ ├────────────────┼───────────────────────────────────────────┤ │ | - | │ │ Length (8B LE)│ uint64 — byte length of Payload below │ │ | - | │ ├────────────────┼───────────────────────────────────────────┤ │ | - | │ │ Payload │ `Length` bytes, opaque to this layer │ │ | - | │ └────────────────┴───────────────────────────────────────────┘ │ | - | ├──────────────────────────────────────────────────────────────────┤ | - | │ Section 1 (same layout) │ | - | ├──────────────────────────────────────────────────────────────────┤ | - | │ ... │ | - | └──────────────────────────────────────────────────────────────────┘ | - | | - +------------------------------------------------------------------------+ - | FOOTER (fixed 22 bytes, always at the very end of processed_bytes) | - | ┌──────────────────────────────────────────────────────────────────┐ | - | │ Total Sections Length (8B LE) — sum of all section bytes above │ | - | │ Section Count (4B LE) — number of sections │ | - | │ Version (2B LE) — protocol version (currently 1) │ | - | │ Magic (8B) — b"QNNTAIL\\x00" │ | - | └──────────────────────────────────────────────────────────────────┘ | - +------------------------------------------------------------------------+ - -Section Types (uint32 enum) ---------------------------- -===== =========== =========================================================== -Value Name Payload format -===== =========== =========================================================== -0x01 SCHEMATIC Sequence of named blobs (see below). Used by optrace - tooling to locate per-graph ``*_schematic.bin`` files. -===== =========== =========================================================== - -Reserve 0x00 as invalid. New types are added by appending to this table and -updating the reader — unknown types are skipped by length. - -SCHEMATIC Payload (type 0x01) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Zero or more entries packed sequentially: - -:: - - ┌────────────────────┬────────────────────┬────────────────┬──────────────┐ - │ Name Length (4B LE)│ Name (UTF-8 bytes) │ Data Len (8B LE)│ Data Bytes │ - └────────────────────┴────────────────────┴────────────────┴──────────────┘ - -``Name`` is typically the graph name (e.g. ``"forward"``). -``Data`` is the raw schematic binary produced by the QNN compiler. - -Extensibility -------------- -To add a new payload type: - - 1. Define a new ``SECTION_*`` constant below. - 2. Add a packer helper that returns ``bytes`` for the payload. - 3. Add an unpacker case in ``unpack_tail_sections()``. - 4. Update this docstring's table. - -The reader skips any section type it does not recognise, so older readers -gracefully ignore newer section types (forward-compatible), and newer readers -handle the absence of newer sections (backward-compatible). -""" - -from __future__ import annotations - -import struct -from typing import Dict, List, Tuple - -# ============================================================================== -# Constants -# ============================================================================== - -TAIL_MAGIC: bytes = b"QNNTAIL\x00" -TAIL_VERSION: int = 1 -FOOTER_SIZE: int = 8 + 4 + 2 + 8 # total_len + count + version + magic = 22 - -# Section type tags (uint32). 0x00 is reserved/invalid. -SECTION_SCHEMATIC: int = 0x01 - - -# ============================================================================== -# Binary encoding helpers -# -# These wrap Python's `struct` module to give each wire-level read/write a -# semantic name. All integers are little-endian (LE) to match the QNN SDK -# convention and ARM host byte order. -# -# struct format reference used below: -# "<" = little-endian byte order -# "I" = unsigned 32-bit integer (4 bytes) -# "Q" = unsigned 64-bit integer (8 bytes) -# "H" = unsigned 16-bit integer (2 bytes) -# ============================================================================== - - -def _encode_u16(value: int) -> bytes: - """Encode an unsigned 16-bit integer as 2 bytes, little-endian.""" - return struct.pack(" bytes: - """Encode an unsigned 32-bit integer as 4 bytes, little-endian.""" - return struct.pack(" bytes: - """Encode an unsigned 64-bit integer as 8 bytes, little-endian.""" - return struct.pack(" int: - """Read an unsigned 16-bit integer from 2 bytes at offset, little-endian.""" - return struct.unpack(" int: - """Read an unsigned 32-bit integer from 4 bytes at offset, little-endian.""" - return struct.unpack(" int: - """Read an unsigned 64-bit integer from 8 bytes at offset, little-endian.""" - return struct.unpack(" bytes: - """Pack a list of (name, data) pairs into a SCHEMATIC section payload. - - Each entry is serialized as: - [name_length: u32] [name: utf-8 bytes] [data_length: u64] [data: raw bytes] - - Args: - named_blobs: Each entry is (graph_name, schematic_binary_bytes). - - Returns: - The raw payload bytes for a SCHEMATIC section. - """ - parts: List[bytes] = [] - for name, data in named_blobs: - name_encoded = name.encode("utf-8") - parts.append(_encode_u32(len(name_encoded))) # 4B: name byte length - parts.append(name_encoded) # variable: UTF-8 name - parts.append(_encode_u64(len(data))) # 8B: blob byte length - parts.append(data) # variable: raw blob - return b"".join(parts) - - -def pack_tail(sections: List[Tuple[int, bytes]]) -> bytes: - """Pack typed sections into a complete tail appendix (sections + footer). - - Each section is serialized as: - [type_tag: u32] [payload_length: u64] [payload: raw bytes] - - The footer is appended last: - [total_sections_length: u64] [section_count: u32] [version: u16] [magic: 8B] - - Args: - sections: List of (type_tag, payload_bytes) pairs. - - Returns: - Bytes to append directly after the QNN context binary. - Returns empty bytes if ``sections`` is empty. - """ - if not sections: - return b"" - - section_parts: List[bytes] = [] - for type_tag, payload in sections: - section_parts.append(_encode_u32(type_tag)) # 4B: section type - section_parts.append(_encode_u64(len(payload))) # 8B: payload size - section_parts.append(payload) # variable: payload - section_bytes = b"".join(section_parts) - - # Fixed 22-byte footer — reader parses from the end of the buffer - footer = ( - _encode_u64(len(section_bytes)) # 8B: total length of all sections above - + _encode_u32(len(sections)) # 4B: number of sections - + _encode_u16(TAIL_VERSION) # 2B: protocol version - + TAIL_MAGIC # 8B: magic identifier for detection - ) - return section_bytes + footer - - -# ============================================================================== -# Unpacking (Host-side tooling — utils/utils.py calls these) -# ============================================================================== - - -def has_tail(processed_bytes: bytes) -> bool: - """Check whether processed_bytes carries a QNN tail appendix. - - Detection: the last 8 bytes must equal TAIL_MAGIC and the buffer must be at - least FOOTER_SIZE (22) bytes long. - """ - return ( - len(processed_bytes) >= FOOTER_SIZE - and processed_bytes[-8:] == TAIL_MAGIC - ) - - -def unpack_tail_sections( - processed_bytes: bytes, -) -> Dict[int, List[bytes]]: - """Unpack all tail sections from processed_bytes. - - Reads the fixed footer from the buffer's tail to locate and walk through - each section. Unknown section types are preserved in the output — callers - simply ignore types they do not handle. - - Returns: - Dict mapping section type tag → list of payloads (a type may appear - more than once). - - Raises: - ValueError: If the magic is present but the buffer is truncated or - corrupted. - """ - if not has_tail(processed_bytes): - return {} - - # --- Parse the fixed 22-byte footer (at the very end) --- - footer = processed_bytes[-FOOTER_SIZE:] - total_sections_len = _decode_u64(footer, 0) # bytes 0..7: total sections length - section_count = _decode_u32(footer, 8) # bytes 8..11: section count - # _version = _decode_u16(footer, 12) # bytes 12..13: version (reserved) - # bytes 14..21: magic (already verified by has_tail) - - # --- Validate that the buffer is large enough --- - expected_min = FOOTER_SIZE + total_sections_len - if len(processed_bytes) < expected_min: - raise ValueError( - f"QNN tail protocol: buffer too short. " - f"Need {expected_min} bytes for tail, got {len(processed_bytes)}." - ) - - # --- Slice out the sections block (sits just before the footer) --- - sections_block = processed_bytes[ - -(FOOTER_SIZE + total_sections_len) : -FOOTER_SIZE - ] - - # --- Walk sections sequentially --- - result: Dict[int, List[bytes]] = {} - offset = 0 - for _ in range(section_count): - if offset + 12 > len(sections_block): - raise ValueError("QNN tail protocol: truncated section header.") - - type_tag = _decode_u32(sections_block, offset) # 4B: section type - payload_len = _decode_u64(sections_block, offset + 4) # 8B: payload size - offset += 12 # advance past the 12-byte section header - - if offset + payload_len > len(sections_block): - raise ValueError( - f"QNN tail protocol: section type=0x{type_tag:02X} claims " - f"{payload_len} bytes but only {len(sections_block) - offset} " - f"remain." - ) - payload = sections_block[offset : offset + payload_len] - offset += payload_len - result.setdefault(type_tag, []).append(payload) - - return result - - -def unpack_schematic_payload( - payload: bytes, -) -> List[Tuple[str, bytes]]: - """Decode a SCHEMATIC section payload into (name, data) pairs. - - Walks the payload sequentially, reading each entry as: - [name_length: u32] [name: utf-8 bytes] [data_length: u64] [data: raw bytes] - - Args: - payload: Raw payload bytes from a SECTION_SCHEMATIC entry. - - Returns: - List of (graph_name, schematic_binary) tuples. - """ - entries: List[Tuple[str, bytes]] = [] - offset = 0 - while offset < len(payload): - if offset + 4 > len(payload): - raise ValueError("QNN tail SCHEMATIC: truncated name length.") - name_len = _decode_u32(payload, offset) # 4B: how many bytes the name uses - offset += 4 - - if offset + name_len > len(payload): - raise ValueError("QNN tail SCHEMATIC: truncated name.") - name = payload[offset : offset + name_len].decode("utf-8") - offset += name_len - - if offset + 8 > len(payload): - raise ValueError("QNN tail SCHEMATIC: truncated data length.") - data_len = _decode_u64(payload, offset) # 8B: how many bytes the blob uses - offset += 8 - - if offset + data_len > len(payload): - raise ValueError("QNN tail SCHEMATIC: truncated data.") - data = payload[offset : offset + data_len] - offset += data_len - - entries.append((name, data)) - return entries diff --git a/backends/qualcomm/utils/utils.py b/backends/qualcomm/utils/utils.py index b955ca39c4e..aa8a665f62a 100644 --- a/backends/qualcomm/utils/utils.py +++ b/backends/qualcomm/utils/utils.py @@ -237,24 +237,6 @@ def dump_context_from_pte(pte_path, output_dir=None) -> List[str]: dump_file = f"{ctx_path}/{execution_plan.name}_{i}{file_extension}" with open(dump_file, "wb") as f: f.write(binary) - - # Unpack embedded metadata sections (e.g. schematic binaries) - # if the tail protocol appendix is present. - # See: backends/qualcomm/serialization/qnn_tail_protocol.py - from executorch.backends.qualcomm.serialization.qnn_tail_protocol import ( - has_tail, - unpack_tail_sections, - unpack_schematic_payload, - SECTION_SCHEMATIC, - ) - if has_tail(processed_bytes): - sections = unpack_tail_sections(processed_bytes) - for payload in sections.get(SECTION_SCHEMATIC, []): - for graph_name, data in unpack_schematic_payload(payload): - schematic_file = f"{ctx_path}/{graph_name}_schematic.bin" - with open(schematic_file, "wb") as sf: - sf.write(data) - dumpfiles.append(dump_file) return dumpfiles From 59ed9a667a365e8764a074c1c53cc0e296fabd6d Mon Sep 17 00:00:00 2001 From: boyuc Date: Mon, 24 Aug 2026 16:23:50 +0800 Subject: [PATCH 5/9] Add profile level in debugger README.md --- backends/qualcomm/debugger/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backends/qualcomm/debugger/README.md b/backends/qualcomm/debugger/README.md index 540ee059e98..731d8baa1a9 100644 --- a/backends/qualcomm/debugger/README.md +++ b/backends/qualcomm/debugger/README.md @@ -30,6 +30,14 @@ Different HTP profiling feature support different set of prepare mode, prepare m | `estimate_htp_profile_result` (`"hextimate"`) | Compile-time performance-model estimate | No (host only) | Yes | +**Profile level** + +| `profile_level` | QNN configuration | Use | +|:----------------|:------------------|:----| +| `0` | Profiling disabled | Default inference. | +| `2` | `QNN_PROFILE_LEVEL_DETAILED` | Collect QNN graph and per-node timing through the ExecuTorch profiler. | +| `3` | Detailed + `QNN_PROFILE_CONFIG_OPTION_ENABLE_OPTRACE` | Enable HTP Optrace hardware-trace artifacts for `qnn-profile-viewer`; required at export for offline-prepare Optrace. | + The rest of the guide is arranged here: 1. **`.pte` generation prepare mode:** We introduce how to trigger each preparation mode in section 1.1 and 1.2: @@ -43,7 +51,6 @@ We introduce how to trigger each preparation mode in section 1.1 and 1.2: 4. **HTP Profile Output Format:** `QnnHtpProfileArtifacts` contains genrated HTML, JSON and chrometrace files. 5. **Qairt-Visualizer:** QAIRT Visualizer can open the QHAS result from `qhas_json` and `chrometrace_json`. Use `QnnHtpProfileArtifacts.visualizer_reports()` to pass related reports. - ## 1. Select AOT Prepare modes for `.pte` Generation Users choose one prepare mode before exporting the `.pte`: From 88f1b0f9a1a88d5effc6010213837bbfa18f0f26 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 25 Aug 2026 15:47:28 +0800 Subject: [PATCH 6/9] Final cleanup --- backends/qualcomm/debugger/README.md | 18 +++++------------- backends/qualcomm/qnn_preprocess.py | 2 -- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/backends/qualcomm/debugger/README.md b/backends/qualcomm/debugger/README.md index 731d8baa1a9..f5fce379095 100644 --- a/backends/qualcomm/debugger/README.md +++ b/backends/qualcomm/debugger/README.md @@ -16,10 +16,10 @@ This section shows how to produce HTP profiling results and inspect the output r - The most accurate profiling mode is **on device profiling** with `generate_htp_profile_result()`, this requires a connected android device through ADB, see details in section 2.1. -- Another profiling mode is **on host estimiation** with `estimate_htp_profile_result()` this doesn't require a device and can be run on host machine. However the accuracy and soc support might be limited, see details in section 2.2. +- Another profiling mode is **on host estimiation** with `estimate_htp_profile_result()` this doesn't require a device and can be run on host machine. However, the accuracy and soc support might be limited, see details in section 2.2. -Different HTP profiling feature support different set of prepare mode, prepare mode is set in AOT config, and decides which format of QNN internal graph is packed in `.pte` file, +Different HTP profiling features support different prepare modes, prepare mode is set in AOT config, and decides which format of QNN internal graph is packed in `.pte` file, * On device profiling support both `online_prepare` and `offline_prepare` * On host profiling reqruires `online_prepare` @@ -128,6 +128,9 @@ artifacts = generate_htp_profile_result( Use `estimate_htp_profile_result()` when you want host-only compile-time performance estimation. This path does not use sample inputs or `adb`. +> [!WARNING] +> `estimate_htp_profile_result()` hard-errors on SDKs below 2.41 and on unsupported SoCs. + **Demo script**: ```bash python -m examples.qualcomm.util_scripts.htp_profiling_on_host_hextimate \ @@ -206,17 +209,6 @@ The example scripts already call QAIRT Visualizer after producing artifacts when For the viewer package, see [QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/). -## Technical Details - -### SDK compatibility - -| QAIRT SDK version | Optrace | Hextimate | -|:------------------|:--------|:----------| -| 2.37 – 2.40 | Supported | **Not supported** | -| 2.41 – 2.50 | Supported | Supported | - -`estimate_htp_profile_result()` hard-errors on SDKs below 2.41 and on unsupported SoCs. - # ExecuTorch QNN Intermediate Output Debugger diff --git a/backends/qualcomm/qnn_preprocess.py b/backends/qualcomm/qnn_preprocess.py index 128e9bd92cd..a267dc2f763 100644 --- a/backends/qualcomm/qnn_preprocess.py +++ b/backends/qualcomm/qnn_preprocess.py @@ -5,7 +5,6 @@ # LICENSE file in the root directory of this source tree. import logging -import os from collections import defaultdict from typing import Dict, final, List @@ -19,7 +18,6 @@ from executorch.backends.qualcomm.serialization.qc_schema import ( QnnExecuTorchBackendType, QnnExecuTorchOpPackageInfo, - QnnExecuTorchProfileLevel, ) from executorch.backends.qualcomm.serialization.qc_schema_serialize import ( flatbuffer_to_option, From da3883ae2d100a5ae58d714712b2352242665439 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 25 Aug 2026 19:14:45 +0800 Subject: [PATCH 7/9] lintrunner --- backends/qualcomm/debugger/utils.py | 77 +++++++++++-------- backends/qualcomm/serialization/qc_schema.py | 2 +- backends/qualcomm/tests/test_qnn_delegate.py | 13 +++- ...tp_profiling_on_device_op_trace_offline.py | 26 ++++--- ...htp_profiling_on_device_op_trace_online.py | 26 ++++--- .../htp_profiling_on_host_hextimate.py | 26 ++++--- 6 files changed, 98 insertions(+), 72 deletions(-) diff --git a/backends/qualcomm/debugger/utils.py b/backends/qualcomm/debugger/utils.py index ebfda7f36ca..91d294198cd 100644 --- a/backends/qualcomm/debugger/utils.py +++ b/backends/qualcomm/debugger/utils.py @@ -24,6 +24,7 @@ from graphviz import Digraph + class DrawGraph: def __init__( self, @@ -239,7 +240,6 @@ def visualizer_reports(self) -> List[str]: return reports - # Hextimate (compile-time perf estimation) requires SDK >= 2.41. Below that, # qnn-context-binary-generator silently drops the hextimate parameters _MIN_SDK_FOR_HEXTIMATE = "2.41" @@ -264,6 +264,7 @@ class QnnTool: - Hextimate: SDK 2.41+ (gate and raise error) """ + def __init__( self, artifact_dir, @@ -304,7 +305,9 @@ def _get_base_config(self): }, } - def _write_config_files(self, backend_extensions: Optional[dict] = None) -> Tuple[str, str]: + def _write_config_files( + self, backend_extensions: Optional[dict] = None + ) -> Tuple[str, str]: """Write backend_extension_config.json and config.json in artifact_dir. Returns (backend_ext_path, config_path). @@ -313,7 +316,9 @@ def _write_config_files(self, backend_extensions: Optional[dict] = None) -> Tupl if backend_extensions is None: backend_extensions = self._get_base_config()["backend_extensions"] - backend_ext_path = os.path.join(self.artifact_dir, "backend_extension_config.json") + backend_ext_path = os.path.join( + self.artifact_dir, "backend_extension_config.json" + ) config_path = os.path.join(self.artifact_dir, "config.json") with open(backend_ext_path, "w") as f: json.dump({"backend_extensions": backend_extensions}, f, indent=4) @@ -345,20 +350,28 @@ def _qnn_context_binary_generator( cmd = [ f"{self.qnn_sdk}/bin/{target}/qnn-context-binary-generator", - "--backend", f"{self.qnn_sdk}/lib/{target}/libQnnHtp.so", - "--model", f"{self.qnn_sdk}/lib/{target}/libQnnModelDlc.so", - "--dlc_path", os.path.join(self.artifact_dir, qnn_binary_file), - "--config_file", backend_ext_path, - "--binary_file", binary_name, - "--output_dir", self.artifact_dir, - "--profiling_level", "detailed", - "--profiling_option", "optrace", + "--backend", + f"{self.qnn_sdk}/lib/{target}/libQnnHtp.so", + "--model", + f"{self.qnn_sdk}/lib/{target}/libQnnModelDlc.so", + "--dlc_path", + os.path.join(self.artifact_dir, qnn_binary_file), + "--config_file", + backend_ext_path, + "--binary_file", + binary_name, + "--output_dir", + self.artifact_dir, + "--profiling_level", + "detailed", + "--profiling_option", + "optrace", ] self._run(cmd, "qnn-context-binary-generator") expected = os.path.join(self.artifact_dir, f"{binary_name}.bin") - assert os.path.isfile(expected), ( - f"qnn-context-binary-generator ran but did not produce {expected}" - ) + assert os.path.isfile( + expected + ), f"qnn-context-binary-generator ran but did not produce {expected}" def _qnn_net_run(self, graph_name: str) -> None: # backend-extensions library path is device-relative when running via adb @@ -408,7 +421,6 @@ def _qnn_net_run(self, graph_name: str) -> None: f"{self.artifact_dir}/qnn-profiling-data_0.log" ), f"Error: qnn-profiling-data_0.log not found in {self.artifact_dir}" - def _qnn_profile_viewer(self, schematic_stem: str, graph_idx: int) -> None: # profile-viewer takes its own config schema (`features`), not the # device profile schema. Written to the SAME file name because that's @@ -432,12 +444,16 @@ def _qnn_profile_viewer(self, schematic_stem: str, graph_idx: int) -> None: cmd = [ f"{self.qnn_sdk}/bin/{target}/qnn-profile-viewer", - "--config", backend_ext_path, - "--schematic", schematic, + "--config", + backend_ext_path, + "--schematic", + schematic, "--reader", f"{self.qnn_sdk}/lib/{target}/libQnnHtpOptraceProfilingReader.so", - "--input_log", os.path.join(self.artifact_dir, "qnn-profiling-data_0.log"), - "--output", os.path.join(self.artifact_dir, f"optrace_{graph_idx}.json"), + "--input_log", + os.path.join(self.artifact_dir, "qnn-profiling-data_0.log"), + "--output", + os.path.join(self.artifact_dir, f"optrace_{graph_idx}.json"), ] self._run(cmd, "qnn-profile-viewer") @@ -537,9 +553,9 @@ def run( htp_graph_before_json = f"{base}_htp_graph_before.json" runtrace_json = f"{base}_runtrace.json" - assert os.path.isfile(chrometrace_json), ( - f"qnn-profile-viewer did not produce {chrometrace_json}" - ) + assert os.path.isfile( + chrometrace_json + ), f"qnn-profile-viewer did not produce {chrometrace_json}" return QnnHtpProfileArtifacts( binary_path=os.path.join(self.artifact_dir, binary_file), @@ -550,15 +566,12 @@ def run( qhas_html=qhas_html, htp_graph_json=htp_graph_json, htp_graph_before_json=htp_graph_before_json, - runtrace_json=( - runtrace_json if os.path.isfile(runtrace_json) else None - ), + runtrace_json=(runtrace_json if os.path.isfile(runtrace_json) else None), ) def _validate_pte_profile_level(pte_path: str) -> None: - """Assert that any offline-prepare .pte was built with profile_level=3. - """ + """Assert that any offline-prepare .pte was built with profile_level=3.""" from executorch.exir._serialize._program import deserialize_pte_binary with open(pte_path, "rb") as f: @@ -572,9 +585,7 @@ def _validate_pte_profile_level(pte_path: str) -> None: options = flatbuffer_to_option(bytes(spec.value)) if options.online_prepare: continue # online-prepare: profile_level is set on-host later - assert ( - options.profile_level == QnnExecuTorchProfileLevel.kProfileOptrace - ), ( + assert options.profile_level == QnnExecuTorchProfileLevel.kProfileOptrace, ( f"{pte_path} was compiled with online_prepare=False and " f"profile_level={options.profile_level.name}." "HTP Profling (Optrace) feature requires profile_level=3 (kProfileOptrace) " @@ -582,6 +593,7 @@ def _validate_pte_profile_level(pte_path: str) -> None: "Please re-export with qnn_config.profile_level=3, or use online_prepare=True" ) + def _validate_hextimate_soc(soc_id: QcomChipset) -> None: if soc_id not in _HEXTIMATE_SUPPORTED_SOCS: supported = ", ".join(soc.name for soc in _HEXTIMATE_SUPPORTED_SOCS) @@ -614,10 +626,7 @@ def _generate_htp_analysis_result( workspace=(adb.workspace if adb is not None else None), ) - return [ - qnn_tool.run(mode=mode, binary_file=os.path.basename(f)) - for f in dumpfiles - ] + return [qnn_tool.run(mode=mode, binary_file=os.path.basename(f)) for f in dumpfiles] def generate_htp_profile_result( diff --git a/backends/qualcomm/serialization/qc_schema.py b/backends/qualcomm/serialization/qc_schema.py index d9f38a7f826..1ad7363fb16 100644 --- a/backends/qualcomm/serialization/qc_schema.py +++ b/backends/qualcomm/serialization/qc_schema.py @@ -72,7 +72,7 @@ class QcomChipset(IntEnum): SW6100 = 96 # v81 QCM6490 = 93 # v68 SM8845 = 97 # v81 - SA8540 = 62 # v68 + SA8540 = 62 # v68 @dataclass diff --git a/backends/qualcomm/tests/test_qnn_delegate.py b/backends/qualcomm/tests/test_qnn_delegate.py index b475af93c7c..b3b0833dc82 100644 --- a/backends/qualcomm/tests/test_qnn_delegate.py +++ b/backends/qualcomm/tests/test_qnn_delegate.py @@ -7534,7 +7534,9 @@ def test_qnn_backend_draw_graph(self): def test_qnn_backend_generate_optrace(self): if self.enable_x86_64: - self.skipTest("Optrace requires on-device execution; not supported on x86_64 host.") + self.skipTest( + "Optrace requires on-device execution; not supported on x86_64 host." + ) module = SimpleModel() # noqa: F405 sample_input = (torch.ones(1, 32, 28, 28), torch.ones(1, 32, 28, 28)) backend_options = generate_htp_compiler_spec(use_fp16=True) @@ -8621,7 +8623,9 @@ def test_qnn_backend_draw_graph(self): def test_qnn_backend_generate_optrace(self): if self.enable_x86_64: - self.skipTest("Optrace requires on-device execution; not supported on x86_64 host.") + self.skipTest( + "Optrace requires on-device execution; not supported on x86_64 host." + ) if get_backend_type(self.backend) == QnnExecuTorchBackendType.kLpaiBackend: self.skipTest("LPAI does not support optrace generation.") module = SimpleModel() # noqa: F405 @@ -8678,7 +8682,9 @@ def test_qnn_backend_generate_optrace(self): def test_qnn_backend_generate_hextimate(self): if not self.enable_x86_64: - self.skipTest("Hextimate is host-side (compile-time); requires --enable_x86_64.") + self.skipTest( + "Hextimate is host-side (compile-time); requires --enable_x86_64." + ) if get_backend_type(self.backend) == QnnExecuTorchBackendType.kLpaiBackend: self.skipTest("LPAI does not support hextimate generation.") module = SimpleModel() # noqa: F405 @@ -8724,7 +8730,6 @@ def test_qnn_backend_generate_hextimate(self): ) self.assertTrue(os.path.isfile(a.qhas_html)) - def test_qnn_backend_seq_mse(self): from executorch.backends.qualcomm._passes.seq_mse import SeqMSE diff --git a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py index 0a122998ecb..4ecb703f1d0 100644 --- a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py +++ b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_offline.py @@ -78,19 +78,23 @@ def main(args) -> None: if args.ip and args.port != -1: with Client((args.ip, args.port)) as conn: - conn.send(json.dumps({ - "artifacts": [ + conn.send( + json.dumps( { - "binary_path": a.binary_path, - "mode": a.mode, - "prepare_mode": a.prepare_mode, - "chrometrace_json": a.chrometrace_json, - "qhas_json": a.qhas_json, - "qhas_html": a.qhas_html, + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], } - for a in artifacts - ], - })) + ) + ) return try: diff --git a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py index 2f45bd7e8f6..5a8d216883e 100644 --- a/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py +++ b/examples/qualcomm/util_scripts/htp_profiling_on_device_op_trace_online.py @@ -73,19 +73,23 @@ def main(args) -> None: if args.ip and args.port != -1: with Client((args.ip, args.port)) as conn: - conn.send(json.dumps({ - "artifacts": [ + conn.send( + json.dumps( { - "binary_path": a.binary_path, - "mode": a.mode, - "prepare_mode": a.prepare_mode, - "chrometrace_json": a.chrometrace_json, - "qhas_json": a.qhas_json, - "qhas_html": a.qhas_html, + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], } - for a in artifacts - ], - })) + ) + ) return try: diff --git a/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py b/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py index c09ac30c791..b3e9f16dcce 100644 --- a/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py +++ b/examples/qualcomm/util_scripts/htp_profiling_on_host_hextimate.py @@ -59,19 +59,23 @@ def main(args) -> None: if args.ip and args.port != -1: with Client((args.ip, args.port)) as conn: - conn.send(json.dumps({ - "artifacts": [ + conn.send( + json.dumps( { - "binary_path": a.binary_path, - "mode": a.mode, - "prepare_mode": a.prepare_mode, - "chrometrace_json": a.chrometrace_json, - "qhas_json": a.qhas_json, - "qhas_html": a.qhas_html, + "artifacts": [ + { + "binary_path": a.binary_path, + "mode": a.mode, + "prepare_mode": a.prepare_mode, + "chrometrace_json": a.chrometrace_json, + "qhas_json": a.qhas_json, + "qhas_html": a.qhas_html, + } + for a in artifacts + ], } - for a in artifacts - ], - })) + ) + ) return try: From 55ce1717281d425f215fffa5747bcc621801f21e Mon Sep 17 00:00:00 2001 From: boyuc Date: Thu, 27 Aug 2026 16:11:58 +0800 Subject: [PATCH 8/9] lintrunner + compatibility shim --- backends/qualcomm/debugger/utils.py | 12 ++++++++++++ backends/qualcomm/tests/test_qnn_delegate.py | 11 +++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/backends/qualcomm/debugger/utils.py b/backends/qualcomm/debugger/utils.py index 91d294198cd..02cbca5f588 100644 --- a/backends/qualcomm/debugger/utils.py +++ b/backends/qualcomm/debugger/utils.py @@ -629,6 +629,18 @@ def _generate_htp_analysis_result( return [qnn_tool.run(mode=mode, binary_file=os.path.basename(f)) for f in dumpfiles] +# backward compatibility shim +def generate_optrace( + artifact, + soc_id: QcomChipset, + adb, + pte_path: str, + inputs: Sequence[Tuple[torch.Tensor]], +): + """see generate_htp_profile_result()""" + return generate_htp_profile_result(artifact, soc_id, pte_path, inputs, adb) + + def generate_htp_profile_result( artifact_dir: str, soc_id: QcomChipset, diff --git a/backends/qualcomm/tests/test_qnn_delegate.py b/backends/qualcomm/tests/test_qnn_delegate.py index b3b0833dc82..c3cacaefb9f 100644 --- a/backends/qualcomm/tests/test_qnn_delegate.py +++ b/backends/qualcomm/tests/test_qnn_delegate.py @@ -9,7 +9,6 @@ import json import logging import operator -import os import subprocess import sys import tempfile @@ -4461,12 +4460,12 @@ def test_qnn_backend_hadamard_transform_linear(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_optrace( + binaries_trace = generate_htp_profile_result( tmp_dir, self.chipset_table[TestQNN.soc_model], - adb, pte_path, [sample_input], + adb, ) htp_ops = [] for _, (_, qhas) in binaries_trace.items(): @@ -4521,7 +4520,7 @@ def test_qnn_backend_hadamard_transform_matmul(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_optrace( + binaries_trace = generate_htp_profile_result( tmp_dir, self.chipset_table[TestQNN.soc_model], adb, @@ -4575,12 +4574,12 @@ def test_qnn_backend_hadamard_transform_conv(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_optrace( + binaries_trace = generate_htp_profile_result( tmp_dir, self.chipset_table[TestQNN.soc_model], - adb, pte_path, [sample_input], + adb, ) htp_ops = [] for _, (_, qhas) in binaries_trace.items(): From 7bcd3db630f5560e0e3bfe89d9db2b0b84583e95 Mon Sep 17 00:00:00 2001 From: boyuc Date: Fri, 28 Aug 2026 11:21:04 +0800 Subject: [PATCH 9/9] Fix UT, run lintrunner again --- backends/qualcomm/debugger/utils.py | 6 +- backends/qualcomm/tests/test_qnn_delegate.py | 63 ++++++++++---------- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/backends/qualcomm/debugger/utils.py b/backends/qualcomm/debugger/utils.py index 02cbca5f588..84a13744090 100644 --- a/backends/qualcomm/debugger/utils.py +++ b/backends/qualcomm/debugger/utils.py @@ -631,13 +631,13 @@ def _generate_htp_analysis_result( # backward compatibility shim def generate_optrace( - artifact, + artifact: str, soc_id: QcomChipset, adb, pte_path: str, inputs: Sequence[Tuple[torch.Tensor]], -): - """see generate_htp_profile_result()""" +) -> List[QnnHtpProfileArtifacts]: + """Legacy positional wrapper for generate_htp_profile_result().""" return generate_htp_profile_result(artifact, soc_id, pte_path, inputs, adb) diff --git a/backends/qualcomm/tests/test_qnn_delegate.py b/backends/qualcomm/tests/test_qnn_delegate.py index c3cacaefb9f..2e9571be419 100644 --- a/backends/qualcomm/tests/test_qnn_delegate.py +++ b/backends/qualcomm/tests/test_qnn_delegate.py @@ -4460,19 +4460,20 @@ def test_qnn_backend_hadamard_transform_linear(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_htp_profile_result( - tmp_dir, - self.chipset_table[TestQNN.soc_model], - pte_path, - [sample_input], - adb, + artifacts = generate_htp_profile_result( + artifact_dir=tmp_dir, + soc_id=self.chipset_table[TestQNN.soc_model], + pte_path=pte_path, + inputs=[sample_input], + adb=adb, ) htp_ops = [] - for _, (_, qhas) in binaries_trace.items(): - with open(qhas, "r") as qhas_file: + for artifact in artifacts: + self.assertIsNotNone(artifact.qhas_json) + with open(artifact.qhas_json, "r") as qhas_file: qhas_data = json.load(qhas_file) - for row in qhas_data["data"]["qnn_op_types"]["data"]: - htp_ops.append(row["op"]) + for row in qhas_data["data"]["qnn_op_types"]["data"]: + htp_ops.append(row["op"]) self.assertTrue( any("HadamardTransform" in op for op in htp_ops), "Expected linear to be lowered to HadamardTransform " @@ -4520,19 +4521,20 @@ def test_qnn_backend_hadamard_transform_matmul(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_htp_profile_result( - tmp_dir, - self.chipset_table[TestQNN.soc_model], - adb, - pte_path, - [sample_input], + artifacts = generate_htp_profile_result( + artifact_dir=tmp_dir, + soc_id=self.chipset_table[TestQNN.soc_model], + pte_path=pte_path, + inputs=[sample_input], + adb=adb, ) htp_ops = [] - for _, (_, qhas) in binaries_trace.items(): - with open(qhas, "r") as qhas_file: + for artifact in artifacts: + self.assertIsNotNone(artifact.qhas_json) + with open(artifact.qhas_json, "r") as qhas_file: qhas_data = json.load(qhas_file) - for row in qhas_data["data"]["qnn_op_types"]["data"]: - htp_ops.append(row["op"]) + for row in qhas_data["data"]["qnn_op_types"]["data"]: + htp_ops.append(row["op"]) self.assertTrue( any("HadamardTransform" in op for op in htp_ops), "Expected matmul to be lowered to HadamardTransform " @@ -4574,19 +4576,20 @@ def test_qnn_backend_hadamard_transform_conv(self): with open(pte_path, "wb") as f: edge_prog_mgr.write_to_file(f) adb = self.get_adb_tool(pte_path) - binaries_trace = generate_htp_profile_result( - tmp_dir, - self.chipset_table[TestQNN.soc_model], - pte_path, - [sample_input], - adb, + artifacts = generate_htp_profile_result( + artifact_dir=tmp_dir, + soc_id=self.chipset_table[TestQNN.soc_model], + pte_path=pte_path, + inputs=[sample_input], + adb=adb, ) htp_ops = [] - for _, (_, qhas) in binaries_trace.items(): - with open(qhas, "r") as qhas_file: + for artifact in artifacts: + self.assertIsNotNone(artifact.qhas_json) + with open(artifact.qhas_json, "r") as qhas_file: qhas_data = json.load(qhas_file) - for row in qhas_data["data"]["qnn_op_types"]["data"]: - htp_ops.append(row["op"]) + for row in qhas_data["data"]["qnn_op_types"]["data"]: + htp_ops.append(row["op"]) self.assertTrue( any("HadamardTransform" in op for op in htp_ops), "Expected conv to be lowered to HadamardTransform "