GenieX 运行指南:在 Snapdragon 上选择 NPU/GPU/CPU 计算单元并运行 llama_cpp 与 QAIRT 模型
【免费下载链接】GenieXRun frontier LLMs and VLMs locally on Qualcomm devices across NPU, GPU, and CPU with a few lines of code项目地址: https://gitcode.com/GitHub_Trending/ne/GenieX
导读
本文以 notes/run.md 为核心,系统讲解 GenieX 在 Snapdragon(如 X Elite 的 X1E80100,以及 SM8750、SM8850 等 SoC)上运行前沿 LLM/VLM 的核心知识:两种运行时(llama_cpp与qairt)如何分别驱动 Hexagon NPU、Adreno GPU 与 CPU,计算单元别名(cpu/gpu/npu/hybrid)的语义与底层映射,QAIRT 自定义 QNN 运行时的切换方法,Windows 上自签名 HTP 驱动的信任流程,以及--verbose性能指标的精确定义。读完本文,你将能根据模型格式与性能诉求选择正确的运行时和计算单元,诊断"是否真的跑在 NPU 上",并在 Windows Snapdragon 设备上完成从安装、拉取模型到推理的完整闭环。
核心概念:运行时、计算单元与芯片组
先厘清贯穿全文的三个术语:
- Runtime(运行时):即插件
plugin_id,取值llama_cpp或qairt。它决定了模型格式与底层加速栈。 - Compute unit(计算单元):NPU、GPU、CPU,或
hybrid,由用户传入的--device/device_id映射而来。 - Chipset(芯片组):SoC 本身,例如 Snapdragon X Elite(SM8750、SM8850 等)。
GenieX 内置两个运行时,都能驱动 Snapdragon NPU,但走的是相互独立的两套用户态栈,且消费不同的模型格式:
| 运行时 | 模型格式 | 目标计算单元 |
|---|---|---|
llama_cpp | GGUF 模型 | Hexagon NPU(经ggml-hexagon)、Adreno GPU(经 OpenCL)、或 CPU |
qairt | QAIRT.bin分片 | 仅 Hexagon NPU(经 Qualcomm QNN 运行时) |
两者不可互换:运行时是按模型选择的,而不是按设备选择的。文档中还提示,支撑 HTP 发布的 CI/S3 签名流水线见 release.md § Hexagon HTP signing。
计算单元别名:一张表看懂四种模式
别名解析表位于 SDK 而非各语言绑定层:核心函数geniex_resolve_device实现在 sdk/src/device.cpp,对外通过 sdk/include/geniex.h 暴露。Go 包装(bindings/go/device.go)、Python 包装(resolve_device,见 bindings/python/geniex/_ffi/_api.py)、Android/JNI 包装(resolve_device,见 bindings/android/app/src/main/cpp/jniutils.cpp)都只是这同一个函数的薄 FFI 封装。因此,修改别名语义意味着修改 sdk/src/device.cpp、重建 SDK bridge(/build),并在结构体形状变化时同步更新三套 FFI 桩(FFI 同步规则见 CONTRIBUTING.md)。
别名到 SDK 参数的映射关系如下:
| 别名 | 传给 SDK 的device_id | n_gpu_layers覆盖 | 适用场景 |
|---|---|---|---|
cpu | 空 | 0 | 纯 CPU 运行 |
gpu | GPUOpenCL | --ngl(默认 -1) | 经 OpenCL 使用 Adreno GPU |
npu | HTP0 | --ngl(默认 -1) | 固定单会话 HTP。确定性好,LLM 上更慢——见下文"NPU 计算单元选择(llama_cpp)" |
hybrid | 空 | --ngl(默认 -1) | llama_cpp的逐张量 HTP+CPU 调度器 |
--ngl默认-1,llama.cpp 将其解读为"所有层",所以gpu/npu/hybrid默认全量卸载,除非显式设置--ngl;该值原样穿过 SDK。qairt忽略--ngl(强制为 0)。
当用户什么都不传(--device ""或device_map="auto")时,llama_cpp与qairt的默认值都是npu——这一点在源码中有明确体现:geniex_resolve_device在 alias 为空或为auto时,将 alias 赋值为kAliasNPU(sdk/src/device.cpp)。QAIRT 只暴露一个计算单元,因此对 qairt 模型传入cpu/gpu/hybrid会被强制转换到NPU并在 stderr 给出警告——CLI不会提前退出。源码路径 sdk/src/device.cpp 中可以看到该强制转换逻辑:非 NPU 别名或设备列表会生成形如qairt plugin only supports NPU inference; ignoring device='...' and running on NPU的警告字符串。
显式设备列表:绕过别名表
除四个别名外,--compute还接受显式设备 id 列表(HTP0,HTP1,HTP2,HTP3、GPUOpenCL):
llama_cpp将列表原样传给 llama.cpp(对需要超过npu别名所固定单HTP0的多 DSP 场景很方便);--ngl仍然生效。qairt仅支持 NPU,设备列表会被强制转换到NPU并伴随警告。
在源码中,geniex_resolve_device会先用parse_device_list检测形如HTP0,HTP1的逗号分隔列表,只有每个 token 都是合法设备名(HTP+ 数字,或GPUOpenCL)时才走直通路径(sdk/src/device.cpp),并原样填入device_id(sdk/src/device.cpp)。
计算单元选择(llama_cpp):NPU 的两条性能路径
llama_cpp在 Windows ARM64 上支持 OpenCL 和 Hexagon。计算单元由geniex_LlmCreateInput上的两个输入驱动:
device_id—— 字符串,运行时相关(HTP0、GPUOpenCL、CPU等);config.n_gpu_layers—— int,决定卸载多少层,-1表示全部。
关键分支:两条性能差异巨大的运行时路径
在 sdk/plugins/llama_cpp/src/llm.cpp 中,加载逻辑依据device_id是否为空产生两条截然不同的路径。底层实现resolve_devices位于 sdk/plugins/llama_cpp/src/params.cpp:device_id为空或空串时返回空列表(沿用模型默认调度);非空时逐个调用ggml_backend_dev_by_name(name)解析,未找到的设备会打印Device '...' not found, skipping警告并跳过。
device_id为空 +n_gpu_layers=-1(hybrid别名)→ llama.cpp 的逐张量调度器。调度器检查每个张量,把可计算的算子分给 HTP、回退算子分给 CPU,回退张量使用 CPU 驻留缓冲。这是快路径。在 X1E80100 + Qwen3-1.7B-Q8_0 上实测:约 90 tok/s prefill、约 27 tok/s decode、约 200 ms TTFT,任务管理器中 NPU 占用拉满。device_id="HTP0"+n_gpu_layers=-1(npu别名)→ 运行时调用ggml_backend_dev_by_name("HTP0")并把mpar.devices固定为{HTP0}。这把模型钉在单一计算单元布局上,禁用逐张量混合调度,任何 HTP 不支持的算子都只能低效处理。同一模型下:约 60 tok/s prefill、约 22 tok/s decode、约 350 ms TTFT,任务管理器显示 CPU 占用拉满(驱动 HTP 的主机线程在忙等,所有回退也跑在 CPU 上)。适用于需要确定性布局、或希望全部权重落在已知计算单元的场景。注意:即使钉死计算单元,也需要非零的n_gpu_layers——device_id="HTP0"配ngl=0会先开 HTP 会话再在 CPU 上跑每一层,所以该别名适用默认的ngl=-1(全部层),见 sdk/src/device.cpp。
额外加成:当device_id以"HTP0"开头时,运行时还会把 KV cache 切换到 Q8_0 并启用 flash-attention(见 sdk/plugins/llama_cpp/src/llm.cpp 中build_context_params的相关处理)。这与性能是正交的——路径 (2) 即使开了这两项依然比路径 (1) 慢。
经验法则:追求最高吞吐用--device hybrid(或不传--device);需要确定性或调试张量摆放时用--device npu。
历史背景:提交fb98467("add device parameter")最初让--device npu合成device_id="HTP0",从而吞掉了快路径;后来被回退(hybrid成为隐式默认),再把两种语义拆成显式的npu/hybrid别名,让调用方自行选择。
从 CLI 运行
使用--device(短选项-d):
geniex infer Qwen/Qwen3-1.7B-GGUF # hybrid(默认)用于 llama.cpp geniex infer Qwen/Qwen3-1.7B-GGUF --device npu # 钉死 HTP0 geniex infer Qwen/Qwen3-1.7B-GGUF --device hybrid # 显式 hybrid geniex infer Qwen/Qwen3-1.7B-GGUF --device gpu geniex infer Qwen/Qwen3-1.7B-GGUF --device cpu在 CLI 实现中,--compute(-c)标志支持cpu, gpu, npu, hybrid, or an explicit device list like HTP0,HTP1,HTP2,HTP3 (llama_cpp only),并默认npu(cli/cmd/geniex/infer.go)。注意:CLI 里还有一个--vit-compute用于单独指定 VLM 视觉编码器的计算单元(如CPU或HTP2),--ngl用-n指定(cli/cmd/geniex/infer.go)。
验证到底跑了哪条路径
SDK 的默认日志处理器在 release 构建中是 no-op(见 sdk/src/ml.cpp),stdout/stderr 保持安静,所以"到底有没有用上 HTP"很容易判断错。以下是四种核验手段:
- Python:设置
GENIEX_LOG=INFO。Python 绑定会安装geniex_set_log回调,把 SDK 消息(Found device: HTP0、Using N device(s)等)路由到 stderr。看到Found device: ...说明走的是钉死HTP0路径(npu别名);没有则说明是 hybrid 路径。 - Windows:任务管理器的 NPU 曲线。hybrid 会点亮 NPU;钉死
HTP0则 CPU 拉满(主机线程在整个推理期间忙等 HTP)。 - 性能签名:在 Snapdragon X1E80100 + 1.7B Q8_0 模型上,hybrid 的 prefill ≳ 80 tok/s 且 TTFT ≲ 250 ms;钉死
HTP0的 prefill ≲ 65 tok/s 且 TTFT ≳ 340 ms。prefill 和 TTFT 比 decode 能更干净地区分两条路径。 - 线程池日志:任何卸载路径(除
cpu外的所有别名)都会输出threadpool tuned for offload: 6 threads pinned to cores [2, 8), strict, poll=1000——SDK 镜像了上游固定的-t 6 --cpu-mask 0xfc(sdk/plugins/llama_cpp/src/threadpool.cpp);可在geniex_ModelConfig中传n_threads覆盖。--device cpu则没有这行日志。
如果看到Device '...' not found, skipping,说明运行时加载成功但 GGML 后端 DLL 未加载——请确认 HTP 的测试签名仍然开启,或ggml-opencl.dll存在于sdk/pkg-geniex/lib/llama_cpp/目录。
提示:Q4_K_M 对 HTP 不是理想的量化格式——HTP 更偏好 Q4_0 / Q8_0,否则部分张量会回退到 CPU。想获得干净的 NPU 运行,请使用 Q4_0。
运行 QAIRT 模型
QAIRT 只暴露 Hexagon NPU 计算单元(plugin_id="qairt"、device_id="NPU")。SDK 的geniex_resolve_device会把--device cpu/gpu/hybrid强制转换到npu并给出 stderr 警告,以便既有 shell 流水线不被中断——你会看到类似的一行:
Warning: qairt plugin only supports NPU inference; ignoring device='cpu' and running on NPUQAIRT 模型需要geniex.json才能工作,可参考 granite4_micro 示例中的geniex.json。
使用自定义 QNN 库
GenieX 默认自带一套 QAIRT 运行时。想不重装就换用另一套运行时,把插件指向它即可:
# 方式一:CLI 标志(仅 qairt 模型) geniex infer local/granite4_micro --qairt-lib /path/to/qairt/2.XX.0 # 方式二:环境变量(任何前端都生效:CLI、pybind、Android) GENIEX_QAIRT_LIB=/path/to/qairt/2.XX.0 geniex infer local/granite4_microSDK 内嵌者应在geniex_init之前调用,而不是去动自己的环境变量——这在 Android 上是唯一途径,因为 JVM 无法setenv:
geniex_set_qairt_runtime_path("/path/to/qairt/2.XX.0"); /* "" 恢复内置运行时 */ geniex_init();geniex.set_qairt_runtime_path('/path/to/qairt/2.XX.0')GenieXSdk.getInstance().setQairtRuntimePath("/path/to/qairt/2.XX.0")优先级为:geniex_set_qairt_runtime_path→GENIEX_QAIRT_LIB→ 内置运行时。--qairt-lib会调用该 API,所以 CLI 标志胜过继承来的环境变量(在 cli/cmd/geniex/infer.go 中可以看到--qairt-lib值被直接传入SetQairtRuntimePath)。
路径接受两种布局:
- QAIRT SDK 根目录(从 Qualcomm Software Center 安装所得)。宿主库取自
lib/<triple>(aarch64-windows-msvc、aarch64-android或aarch64-oe-linux-gcc11.2),ADSP_LIBRARY_PATH会指向每个 Hexagon DSP skel 目录(lib/hexagon-v*/unsigned),从而自动匹配设备上的 HTP 架构。 - 扁平目录:直接包含
QnnHtp.dll和QnnSystem.dll(或对应的libQnn*.so)——与内置htp-files布局一致。
[!IMPORTANT] 插件接受某个运行时,不等于输出正确——覆盖可能以全速产生错误结果,务必确认覆盖解析到了你预期的位置。用
--log info运行(CLI 默认是none):Overriding the bundled QAIRT runtime from <source>: <what you passed> (host libs: <resolved dir>)
host libs:是关键——对 SDK 根目录而言,它指向lib/<triple>子目录而非你传入的根目录。<source>是geniex_set_qairt_runtime_path或GENIEX_QAIRT_LIB,这一行同时告诉你哪个开关生效了。
哪些运行时被接受,以及为什么这是受支持的覆盖
插件只通过带版本号的 C 接口触达 QNN,该接口在加载时与compiled QNN_API_VERSION_MINOR <= runtime minor协商。下限是插件编译时所用的 API 版本(QAIRT 2.36 头文件、C API 2.27),而非它内置的版本——所以比内置更新或更旧的运行时都能被接受,直到触及该下限。曾有一组构建在 Snapdragon X Elite 上以 2.45 / 2.48 / 2.49 三个版本测得吞吐完全一致。
这取代了早先的 C++IBackend路径——后者每个版本 vtable 布局都在变,一旦不匹配就可能段错误(ai-hub-models-internal#3964)。改用 C API 后这一隐患消失,这也是它成为受支持覆盖(而非仅测试用途)的原因。
QnnHtpNetRunExtensions不再加载——插件自己通过公开 C API 应用htp_backend_ext_config.json,所以运行时目录无需携带该文件。
不可用的路径会在模型加载时立即失败,并指明来源,例如:geniex_set_qairt_runtime_path does not contain QnnHtp.dll (looked in the folder itself and lib/aarch64-windows-msvc): <path>。
每进程一套运行时。QnnHtp只加载一次并常驻进程生命周期——插件从不卸载它,且ADSP_LIBRARY_PATH/SetDllDirectory是进程级的。因此路径在geniex_init时锁定,之后设置会返回GENIEX_ERROR_COMMON_ALREADY_INITIALIZED而非看似生效。geniex_deinit不会解锁:QNN 库比一次 init/deinit 周期活得更久。要对比版本,就得一个版本一个进程。
HTP 多核(qairt)
默认情况下,QAIRT 模型在一个NSP 核上执行。开关在包的htp_backend_ext_config.json里:QAIRT 核心统计devices[].cores条目,当列出多于一个核时,会在每个已加载的图上设置QNN_HTP_GRAPH_CONFIG_OPTION_NUM_CORES(插件中的ModelConfig::num_cores;详见 geniex-qairt 文档 § HTP Backend Config and Multicore Execution)。
加载时可改与不可改的内容:
- 加载时:
NUM_CORES图配置、每核perf_profile、rpc_control_latency——都在模型加载时从 JSON 读取。 - 生成时:图划分已烘焙进上下文二进制。单核编译的二进制即使请求更多核,也可能不加速(甚至拒绝该配置)。
初始化时运行时会在--log info下记录设备报告的核数与生效核数;当 JSON 请求的核数超过 SoC 暴露的核数时警告并钳制;当驱动报告多核不可用时警告但不失败(QNN 详细跟踪:Error code 1000 ... key = 304/Multicore support is unavailable)。目前大多数 Snapdragon 移动/计算 SoC 只暴露单个 NSP 核;多 NSP 部件(如某些车载 SoC)会在 QNN 平台信息中报告numCores > 1。
本地构建并运行
hf download yichqian/geniex-qairt-models --local-dir=geniex-qairt-models bazelisk run //cli -- pull local/granite4_micro \ --model-hub localfs \ --local-path /absolute/path/to/geniex-qairt-models/granite4_micro bazelisk run //cli -- infer local/granite4_micro把构建产物交给另一台机器
构建方:
bazelisk build //cli:artifact # 导出 bazel-bin/cli/artifact.zip 与 ggml-htp-v1.cer接收方:
# 解压 artifact.zip hf download yichqian/geniex-qairt-models --local-dir=geniex-qairt-models ./geniex.exe pull local/granite4_micro --model-hub localfs \ --local-path /absolute/path/to/geniex-qairt-models/granite4_micro ./geniex.exe infer local/granite4_micro运行预构建的 CI 发布版(Windows on Snapdragon)
每个v*标签都会在 Releases 页面发布 Windows ARM64 安装包。需要下载两样东西:
geniex-cli-setup.exe—— 安装器geniex-sdk-windows-arm64-<tag>.zip—— SDK
SDK 文件名编码了 HTP 签名风味:
| 文件名 | HTP 签名 | 额外设置 |
|---|---|---|
geniex-sdk-windows-arm64-<tag>.zip | Microsoft 签名 | 无需额外设置——直接跳到下方"运行" |
geniex-sdk-windows-arm64-<tag>-selfsigned.zip | 自签名(测试) | 参见"自签名回退" |
如果发布版还附带ggml-htp-v1.cer,那就是自签名风味。
运行步骤:
- 用
geniex-cli-setup.exe安装。 hf download yichqian/geniex-qairt-models --local-dir=geniex-qairt-modelsgeniex.exe pull local/granite4_micro --model-hub localfs --local-path <abs-path>\geniex-qairt-models\granite4_microgeniex.exe infer local/granite4_micro
自签名回退:让 Windows 信任 ggml-htp 驱动
仅在发布版附带-selfsignedSDK 加ggml-htp-v1.cer时需要。Windows 会拒绝加载libggml-htp.cat,除非你同时启用测试签名并信任该证书。
预构建用户已经从发布页拿到了ggml-htp-v1.cer——直接跳到下面的第 2 步。
构建者(为本地构建生成自己的证书):在提升权限的cmd.exe中运行以下命令。.pfx是构建时HEXAGON_HTP_CERT需要的;.cer是导入信任库用的。
set "PATH=C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\arm64;%PATH%" mkdir C:\Users\%USERNAME%\Certs cd C:\Users\%USERNAME%\Certs makecert -r -pe -ss PrivateCertStore -n CN=GGML.HTP.v1 -eku 1.3.6.1.5.5.7.3.3 -sv ggml-htp-v1.pvk ggml-htp-v1.cer pvk2pfx -pvk ggml-htp-v1.pvk -spc ggml-htp-v1.cer -pfx ggml-htp-v1.pfx setx /M HEXAGON_HTP_CERT "C:\Users\%USERNAME%\Certs\ggml-htp-v1.pfx"makecert会两次提示输入密码——开发用一次性证书留空即可。不要复用从他人签名二进制中提取的.cer:它没有私钥(无法用于签名),且导入随机第三方根证书是安全风险。
然后,构建者和预构建用户都需要:
启用测试签名(提升权限的 PowerShell,然后重启):
bcdedit /set TESTSIGNING ON若因 Secure Boot 报错,先在 UEFI 中禁用 Secure Boot,再重试。
把
ggml-htp-v1.cer导入两个存储(通过certlm.msc,必须以提升权限启动,否则导入会报"store was read only"):Trusted Root Certification Authorities→Certificates→ 右键 →All Tasks → Import…→ 选择ggml-htp-v1.cer。- 在
Trusted Publishers→Certificates中重复一遍。
两个存储都必需:Root 让证书链有效;Trusted Publishers 抑制驱动加载提示。
如尚未重启则重启。验证:
bcdedit /enum | Select-String testsigning # 应显示 "testsigning Yes"
上游背景参考:third-party/llama.cpp/docs/backend/snapdragon/windows.md。
更新检查
geniex 会查询缓存的"最新发布"记录,若存在新版本则打印一行提示,8 小时内至多一次。后台刷新至多每 24 小时重新拉取一次。
版本数据来自公开 S3 索引(qaihub-public-assets.s3.us-west-2.amazonaws.com/qai-hub-geniex/index.json)。失败是静默的(仅 debug 级日志,不刷屏 stdout)。
运行geniex update升级:
- Windows—— 发布后下载并启动已签名安装器;否则报告"up-to-date"。
- Linux—— 打印需要重新执行的安装脚本一行命令(
curl -fsSL … | bash);自动更新尚未接通。
任何命令传--skip-update可跳过本次调用的探测(及通知横幅)。该标志在 cli/cmd/geniex/main.go 中定义于根命令持久标志上,因此对infer、serve、model等所有子命令都生效。
性能指标:每个阶段的准确含义
--verbose(以及 Go/Python API 的ProfileData、geniex-bench)每个推理阶段报告一个数值。各指标覆盖的阶段如下:
| 指标 | 字段 | 所测阶段 |
|---|---|---|
ttft | ttft | 生成开始 → 首个采样 token。对 VLM 而言包含媒体编码器,因此与纯 prefill 数值不可比。 |
| 媒体时间 | media_time | 仅视觉/音频编码器——把像素/音频转成 decoder 空间的 embedding。纯文本运行时为0。 |
| prompt / prefill 时间 | prompt_time | Prefill——把 prompt token 跑过模型。对 VLM 包含媒体(soft)token 的 prefill;不含编码器。 |
| prompt / prefill 速度 | prefill_speed | prompt_tokens / prompt_time。 |
| decode 时间 / 速度 | decode_time/decoding_speed | 生成阶段(首 token → 末 token)。 |
要点:
media_time只是编码器。编码器下游的一切(把媒体 token 跑过模型做 prefill)都算在prompt_time里,与文本一致。prompt_tokens在 VLM 运行中计入文本 + 媒体 token,所以prefill_speed反映模型实际做的完整 prefill。ttft跨越编码器 + prefill,因此 VLM 上ttft ≈ media_time + prompt_time。
两个运行时都在同一边界测量media_time(仅编码器墙钟时间),因此数值跨插件可比:
- llama.cpp—— 按块计时:编码(
mtmd_encode_chunk)计入media_time;embedding 的 prefill(mtmd_helper_decode_image_chunk→llama_decode)进入prompt_time,与文本块一致。位图加载和 tokenize 两者都不计时,只落在ttft里;ttft ≈ media_time + prompt_time减去该开销。 - QAIRT——
media_time是编码器墙钟时间(encodeVision);媒体 token 的 NPU prefill 留在prompt_time(=ttft − media_time)。QAIRT 的prompt_time由ttft推导而来,请视为参考值而非精确值;编码器数值本身是直接测量的。
结语:一套决策清单
面对一个具体模型时,可以按如下顺序决策:
- 看模型格式:GGUF →
llama_cpp;QAIRT.bin+geniex.json→qairt。运行时不可互换,按模型选择。 - 选计算单元:
llama_cpp默认hybrid(最快速率);需要确定性/调试摆放用npu;多 DSP 场景用显式设备列表HTP0,HTP1,...。qairt只有NPU,其他别名会被静默强制转换并警告。 - 验证实际路径:
GENIEX_LOG=INFO(Python)、任务管理器 NPU 曲线、prefill/TTFT 签名、线程池日志,四选一即可确认。 - QAIRT 换运行时:按
geniex_set_qairt_runtime_path→GENIEX_QAIRT_LIB→ 内置运行时的优先级,在geniex_init前完成;用--log info确认host libs:解析正确。 - Windows 自签名:仅当发布版带
-selfsignedSDK 时,启用测试签名并导入ggml-htp-v1.cer到 Root 与 Trusted Publishers 两个存储。
以上全部内容以 notes/run.md 为主干,关键实现均可在 sdk/src/device.cpp、sdk/plugins/llama_cpp/src/llm.cpp、sdk/plugins/llama_cpp/src/params.cpp 与 cli/cmd/geniex/infer.go 等源码中交叉印证。
【免费下载链接】GenieXRun frontier LLMs and VLMs locally on Qualcomm devices across NPU, GPU, and CPU with a few lines of code项目地址: https://gitcode.com/GitHub_Trending/ne/GenieX
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考