拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android本地跑LLaMA-7B:QNN框架部署实战与避坑指南

Android本地跑LLaMA-7B:QNN框架部署实战与避坑指南 手机也能跑大模型不是概念演示是真正能跑起来。我这次选的是QNN框架也就是高通那套神经网络SDK搭上LLaMA-7B模型从模型转换、底层库编译到Android工程集成全程自己动手。说实话中间踩过的坑比想象中多但最后推理速度能到每秒几个token完全能在本地做实时对话式应用。这篇文章就把我的完整过程、工具选型和避坑经验一次说清楚给想在Android上跑大模型的朋友一个能直接参考的路线。我知道很多人一听到“7B模型部署到手机”就觉得不靠谱毕竟这个参数规模的模型在PC上都要占好几个GB显存。但现在的硬件和工具链已经把门槛压到很低高通芯片自带NPUQNN框架就是专门吃这一点红利的东西。配合INT4甚至INT3量化7B模型的体积可以压到3GB左右配合8GB以上运存的手机理论上完全有戏。接下来我会把整个链路拆开从为什么选QNN到每一步怎么落地再到实战中踩过的坑尽量讲清楚。1. 为什么要在手机本地跑大模型以及QNN到底是什么1.1 本地部署的核心价值离线、隐私和低延迟先想清楚一个问题既然云端大模型那么多而且能力更强为什么还要费劲在手机上跑一个缩水的模型我的答案有三个也是在实战中感受到的真实痛点。第一是离线可用。地铁、飞机、山区信号差的地方云端API根本连不上。本地部署后模型就在手机里任何场景都能随时调出来。第二是隐私安全。数据不出设备笔记、邮件、聊天记录这类敏感内容不需要上传到别人的服务器对研发调试和生产工具来说太重要了。第三是延迟体验。本地推理省略了网络往返对交互式应用比如语音助手、实时摘要、代码补全这类的场景响应速度更可控。当然本地跑模型也有明显代价手机算力有限大模型参数动辄几十亿必须靠量化压缩和芯片的NPU加速才能跑得动。这也是为什么很多人在这一步会被劝退。但如果你手上的手机是高通骁龙8系NPU算力其实已经远超十年前的高性能显卡只是大部分人不知道怎么调用而已。1.2 QNN框架的定位高通芯片上的算力调度中枢QNNQualcomm Neural Network是高通推出的统一AI推理框架它不像PyTorch那样负责训练而是专门做推理加速。简单理解它把所有底层算子调度到高通芯片上最合适的计算单元上执行包括CPU、GPU还有专门的NPUHexagon DSP。它的架构分两层底层是不同加速器的驱动比如QNN HTPHexagon Tensor Processor主要负责NPU上的卷积、矩阵乘这些高密度运算上层是统一接口开发者写的代码不用关心调度细节QNN会把算子按最优策略分配到不同硬件上。这和直接在Android上用TensorFlow Lite有本质区别因为QNN能更精细地利用芯片特性。选择QNN还有一个现实原因它就是高通芯片上的“亲儿子”更新节奏快算子支持全面。相比其他推理框架比如MNN、ncnnQNN最大的优势是能直接把算子放到NPU上执行而不仅仅是用CPU的汇编优化去加速。实测下来同样是7B模型CPU推理大概每秒出两三个token如果用QNN调度到NPU上速度可以明显提升一个量级这个差距在移动端是决定性的。1.3 部署方案选型为什么不用TensorFlow Lite或MNN在动手之前我也比较过其他方案。TensorFlow Lite是大部分人的第一选择但它的移动端生态主要针对Apk里的图像识别和小型模型对7B这种大模型支持很弱量化工具链也偏老很多LLM算子不支持。MNN和ncnn是国内团队开源的移动端推理框架优化做得很出色但它们的定位更偏向通用模型针对高通NPU的深度优化没有QNN那么直接。当然QNN也有明显短板比如学习曲线陡、资料少、坑多。但既然目标是“在手机上把7B模型跑起来”舍它其谁。如果完全不要求NPU加速只是想在CPU上验证效果那用llama.cpp是最快的路。但如果你想把模型真正产品化做成一个流畅的AppQNN才是从工程角度最值得投入的方向。所以我最终决定在QNN这条路上死磕到底。2. 部署前的整体设计思路与关键技术决策2.1 模型量化为什么必须从FP16压缩到INT4LLaMA-7B的原始权重是FP16精度体积大约14GB这显然不可能塞进手机所以第一步就是量化。量化本质上是把每个权重从16位浮点数变成8位、4位甚至更低的整数用精度换取体积和计算速度。一张可以拿生活类比的例子FP16是把每种颜色都用16bit精细记录的照片INT4则是精简到16色虽然色彩层次减少但照片的主体内容依然能认出来。在我实测过程中7B模型INT8量化后体积约7GB对手机存储和内存还是太吃紧INT4量化后大概3GB相对比较能落地。量化精度损失需要自己验证LLaMA-7B这种规模的模型在文本生成任务上INT4和FP16的结果差距比想象中要小因为语言类任务对权重的细节没有图像识别那么敏感。实际操作时用GPTQ或AWQ算法做4bit量化效果比简单的round-to-nearest要好很多。2.2 模型转换链路选择PyTorch到ONNX再到QNNQNN本身不直接读取PyTorch的权重它需要一个固定的中间格式最常用的是ONNX。所以我的整体流程是先把HuggingFace上的LLaMA-7B模型导出成ONNX再用QNN官方提供的转换工具qnn-onnx-converter把ONNX模型转成QNN模型库。这里有个关键点直接转换整个7B模型ONNX文件可能有好几GBqnn-onnx-converter对内存要求很高稍不小心就会进程死掉。我的经验是先在HuggingFace上用transformers把模型加载成fp16权重然后使用torch.onnx.export导出到临时目录。导出时要指定opset_version比如17或更高版本不然某些算子不支持。导出完再逐一检查ONNX模型QNN对动态输入的支持比较有限所以输入尺寸最好固定比如把seq_len固定为128或256这样可以显著降低转换难度。2.3 运行环境要求手机硬件和系统版本的最低门槛不是随便一部Android手机都能跑7B模型。我重点建议满足这些硬件条件第一芯片必须是骁龙8 Gen1以上最好是8 Gen2或8 Gen3因为Hexagon NPU算力代际差异非常大第二运行内存至少8GB因为最终模型文件压缩后可能有3GB推理过程中还要额外分配KVCache和中间激活值第三系统Android 12以上太低版本可能在动态库链接和SELinux权限上出问题。存储空间也要注意模型文件加代码至少预留5GB空间。如果手机是低功耗芯片及时用QNN也未必跑得动7B可以换用1.1B或3B的小模型。这个门槛说实话已经劝退一半玩家了但如果你手头有台今年头部旗舰机那理论上是没问题的。2.4 工具链清单需要准备哪些SDK和开发环境我之前做事习惯先把工具链备齐不然到一半去下载太浪费时间。这次我准备了这些工具版本建议用途Android Studio最新稳定版开发Android工程、编译APKAndroid SDK Platform 33API 33以上兼容Android 13QNN SDK2.x版本模型转换和runtime库TensorFlow / PyTorch对应Python环境导出ONNX模型ONNX Runtime最新版验证ONNX输出CMake / NDKNDK r23以上编译原生C库QNN SDK可以从高通开发者官网下载需要注册开发者账号这个步骤不难但要注意SDK只支持Linux和Windows系统Mac无法直接使用命令行工具。我用的是Ubuntu 22.04出问题概率相对小。准备环境时还有一个容易忽略的点就是NDK版本必须和QNN的交叉编译工具兼容否则后面编译动态库会报一堆奇怪的错误。3. 实战全过程从环境配置到Android工程集成3.1 配置QNN SDK和Python虚拟环境把QNN SDK下载解压后我建议放在一个不带空格的路径下比如/opt/qcom/qnn不然后面脚本很容易因为路径解析出错找不到文件。然后把bin目录加入环境变量同时安装Python依赖包。我通常还会单独创建一个conda环境避免和系统的Python版本混在一起。export QNN_SDK_ROOT/opt/qcom/qnn export LD_LIBRARY_PATH$QNN_SDK_ROOT/lib/x86_64-linux-clang/sdk: $QNN_SDK_ROOT/lib/x86_64-linux-clang export PYTHONPATH$QNN_SDK_ROOT/lib/python这里特别注意QNN SDK的Python工具依赖numpy、onnx等包版本匹配很关键。比如numpy版本过新可能导致导入失败。推荐用Python 3.8到3.10之间的版本太新或太旧都可能遇到莫名其妙的问题。我最后用的Python是3.9numpy是1.23.x一切正常。3.2 模型量化与ONNX导出一步都不能少首先在HuggingFace上把LLaMA-7B下载下来。注意这些模型可能有中文或英文版本基础版不擅长中文建议选用特定的中文微调版。我用的是某个经过中文指令微调的开源版效果更好。加载模型后用transformers的tokenizer准备一个固定输入然后调用torch.onnx.export导出import torch from transformers import LlamaTokenizer, LlamaForCausalLM model_path your-llama-model-path model LlamaForCausalLM.from_pretrained(model_path, torch_dtypetorch.float16) tokenizer LlamaTokenizer.from_pretrained(model_path) input_ids torch.zeros((1, 1), dtypetorch.long) inputs { input_ids: input_ids, attention_mask: torch.ones_like(input_ids), } torch.onnx.export( model, (inputs[input_ids], inputs[attention_mask]), llama-7b.onnx, input_names[input_ids, attention_mask], output_names[logits], opset_version17, do_constant_foldingTrue, dynamic_axes{input_ids: {0: batch_size, 1: seq_len}, attention_mask: {0: batch_size, 1: seq_len}} )这里要提醒一下尽管我定义了动态轴但后续QNN转换最好还是用固定shape重新导出否则即便转换成功在手机上跑也会异常。所以我最后又用seq_len128固定输入大小导出了第二个ONNX后续转换都用固定版本。3.3 用qnn-onnx-converter把ONNX转成QNN模型库QNN转换工具常见的有两种qnn-onnx-converter和qnn-tflite-converter。对RNN类模型onnx的算子兼容性更好。执行转换命令如下python $QNN_SDK_ROOT/bin/qnn-onnx-converter \ --input_network llama-7b-fixed.onnx \ --output_path ./llama_qnn \ --input_list input_list.txt \ --quantization_config config.json \ --backend htp重点说明input_list.txt和量化配置。为了让模型吃INT4量化我们需要提供一个data batch文件列表让工具在转换时做校准。校准数据可以直接拿txt文件里的几段文本tokenize成输入形状后保存成二进制。config.json里可以指定权重量化位宽{ activation_width: 16, weight_width: 4, include_activations: false }这样权重部分用INT4激活用FP16精度和速度比较均衡。转换过程可能持续几十分钟甚至更久特别是7B模型会看到内存占用飙到20GB以上。如果你机器内存不够建议用云服务器或者换小一点的模型先验证流程。3.4 生成QNN context二进制和runtime动态库转换成功后会在输出目录生成一组文件包括模型上下文二进制比如model.bin和一个包含算子注册的c库。但要在Android上使用还需要用QNN提供的交叉编译工具编译出Android平台可用的so文件。具体思路是写一个简单的系统调用层把模型上下文加载和推理接口封装成JNI可调用的函数。这里强烈建议直接用qnn-sample-app做参考高通SDK里有一个完整的Android端示例工程把HTP后端相关代码直接拷贝过来改比自己从头写快得多。示例工程里通常包含libQnnHtp.so、libQnnHtpV*.so和libQnnHtpPrepare.so这些动态库要放在APK的libs/arm64-v8a目录下。模型上下文二进制也要拷进工程可以放在assets或cache目录下。3.5 创建Android Studio工程并集成JNI接口新建Android工程时选择Native C模板CMakeLists里把QNN的include目录加进来同时链接需要的so库。一个简化版CMakeLists长这样cmake_minimum_required(VERSION 3.22.1) project(llama_qnn_demo) add_library(llama_qnn SHARED src/main/cpp/qnn_llama.cpp ) target_include_directories(llama_qnn PRIVATE ${CMAKE_SOURCE_DIR}/src/main/cpp/include ${QNN_SDK_ROOT}/include/qnn ) target_link_libraries(llama_qnn android log dl )JNI层最关键的是加载库顺序必须先把QNN的HTP库加载好再加载模型上下文库接着调用qnn_interface_create获取函数指针最后初始化后端和创建推理图。这个顺序一旦错就会在运行时报SIGSEGV而且日志里不会给任何提示。我踩过一次坑只加载了模型库没加载HTP驱动直接调用模型入口崩溃。后来看了高通文档才知道HTP的So需要按版本号分别加载比如V73对应8 Gen2V75对应8 Gen3。3.6 加载模型并进行文本生成推理推理过程分三个阶段初始化上下文、执行输入输出、释放资源。JNI函数声明如下extern C JNIEXPORT jstring JNICALL Java_com_example_llama_QnnLlama_generate( JNIEnv *env, jobject /* this */, jstring input_text) { std::string prompt env-GetStringUTFChars(input_text, nullptr); // 1. 把prompt转成token id序列 // 2. 执行model inference循环生成下一个token // 3. 返回完整字符串 }文本生成是自回归过程需要循环调用模型推理。每次推理输入是当前所有token序列输出是下一个token的概率分布。用argmax或top-k采样选择token把新token拼回去继续推理。我在Java层做了一个简单的回调接口每生成一个token就更新到TextView上这样用户能实时看到文字逐字出现体验比等最终结果好很多。4. 那些年踩过的坑QNN移植必须注意的八个问题4.1 模型转换阶段的算子不支持错误当时我在执行qnn-onnx-converter时报了一堆Unsupported operator错误点开日志全是RMSNorm和RotaryEmbedding相关的算子。LLaMA系列模型用了很多自定义算子QNN早期版本不能直接识别。解决办法是用--override_input_shape和--extra-arguments手动拆分graph或者在ONNX导出时把这些自定义算子合并成更基础的形式例如把RMSNorm转换成Pow、ReduceMean、Div的组合把RotaryEmbedding转成多个Gather和Concat。还有一个偏门的方法是使用QNN SDK自带的qnn-onnx-converter --convert_layout_to_nhwc参数可能解决部分布局敏感的问题。如果实在搞不定就升级QNN SDK到2.15以上新版本对Transformer类模型的算子支持已经好了很多。4.2 输入输出shape固定化带来的推理限制我前面说尽量用固定shape导ONNX这里有个代价输入序列长度被限制为128或256。如果你要输入更长的文本比如读一篇长文章就塞不进去了。折中方案是设计一个分段处理逻辑Java层把长文本截断成多个片段逐个生成摘要再把结果拼起来。另一个想法是采用流式输入每轮只处理最近一段但会增加后端处理复杂度。如果你对长上下文有强需求可以尝试动态shape路径但QNN转换后的模型在NPU上的性能会明显下降有时甚至不如CPU。所以我的建议是先用固定shape跑通全流程产品上线前再根据具体场景做裁剪。4.3 内存占用过高中途被系统强杀7B模型就算用了INT4模型权重也要3GB左右。再加上中间激活、KVCache、Android系统自身占用8GB运存基本占满。我在真机上遇到过好几次App突然闪退查了logcat才发现是low memory killer杀掉了进程。为了解决这个问题我把模型加载放到一个后台Service里并且在Manifest中申请android:largeHeaptrue这能稍微提升堆内存上限但对原生内存帮助不大。更根本的解决办法是分批加载权重。QNN支持模型上下文deferred加载先只创建推理图等真正执行推理时再加载权重。这样启动时可以节省大量内存。另外减少批处理大小也能降低内存峰值我们推理时batch_size改成1就够了。4.4 JNI层精度和字符串编码的坑生成模型输出的是token id需要转成文本。这个过程中最容易出问题的是tokenizer不一致。在PC上导出模型时用的tokenizer是原始LlamaTokenizer但手机端没有Python环境需要把tokenizer完全移植到JNI层。我使用了一个开源C版本的tokenizer库直接加载HuggingFace的tokenizer.json避开中文编码问题。中文输入输出还有一个容易踩的坑就是Java层与C层传递字符串时必须统一用UTF-8编码否则生成的中文会变成乱码。更严重的是如果tokenizer的vocab里没有某个扩展字符推理时token id会对应到未知词生成结果可能完全失控。建议在Java层做一次输入清洗把非法字符过滤掉。4.5 HTP调度优先级导致的应用卡顿默认情况下QNN会把NPU算力全部占满可能导致手机界面卡顿甚至温度快速上升。我的优化方法是在初始化上下文时设置一个power_config指定性能级别为低功耗或均衡模式。例如QnnHtp_PerformanceInfrastructure_PowerConfig_t powerConfig; powerConfig.option QNN_HTP_PERFORMANCE_OPTION_SUSTAINED_HIGH_PERFORMANCE;持续高性能模式适合一次性长文本生成但如果你的应用还要处理用户输入建议改用BURST模式在生成token时短时间拉高频率生成完成后立刻降频。实测这样能让手机整体体验好很多。4.6 库文件平台ID不匹配QNN的Android库有特定平台ID如果不匹配手机芯片运行时会出现类Unknown HTP architecture的错误。这时要检查libQnnHtpV*.so的版本号。例如V68对应骁龙865V73对应8 Gen2V75对应8 Gen3。如果你的手机是8 Gen1又是V73可能也会出问题因为平台ID内部有更细的小版本。选择系统对应的so库是最容易踩却最容易被忽略的一步。还有一种情况是只拷了V73的so但实际设备是8 Gen1结果报错日志显示了V69版本。解决这个问题的方法很简单把所有V开头的HTP库都打包进APK运行时QNN会根据芯片自动选择我实测这样最稳。4.7 模型启动时间过长第一次加载3GB的模型到内存耗时可能长达二三十秒。这个启动时间对用户体验来说太致命了。我把模型加载拆分成了两个阶段Activity启动时先初始化QNN后端和创建上下文但真正重的权重加载放到线程池里异步执行。同时做一个启动动画告诉用户“模型准备中”让等待变得不那么难熬。如果还想再优化可以把加载好的上下文缓存成内存映射文件下次启动直接mmap速度能快好几倍。4.8 电池发热和降频长时间推理时手机背壳会明显发热。与此同时NPU频率会降低推理速度会逐渐下滑。为解决这个问题我在后台增加了温度监测逻辑一旦电池温度超过40度就自动切换到低功耗模式并把生成速度限制在每秒3个token以内。这样虽然牺牲速度但能避免手机直接热到烫手甚至系统强制退出。5. 实际效果与性能调优别急着跑先让模型“热身”5.1 实测数据不同芯片上的推理速度我最终在骁龙8 Gen2的工程机上做了完整测试。模型用INT4量化上下文长度固定为128生成新的token速度大约在每秒6到8个token之间。作为对比同样的模型用纯CPU推理速度大概是每秒1到2个token。也就是说QNN的NPU加速效果非常明显。骁龙8 Gen3我找朋友测了一下生成速度能到每秒10个token以上基本达到可以流畅做对话的程度。测试设备NPU推理速度CPU推理速度峰值内存骁龙8 Gen27 token/s1.5 token/s4.2GB骁龙8 Gen311 token/s2 token/s4.6GB这个数据在量产App里当然还不够看但对于个人项目和内部分发来说已经能实现很多跨时代的玩法了。5.2 性能调优三板斧内存池、上下文缓存和线程优先级推理时间中除了真正的算子计算外内存分配和拷贝也占了很大比重。QNN本身已经做了内存池管理但我们依然可以在JNI层直接复用预先分配好的Tensor结构避免每次推理都重新创建。将KVCache的缓冲区提前分配好也能明显减少运行时间。我还在代码里把生成循环中不需要log的日志全部关掉因为每次打印字符串都可能触发JNI回调拖慢节奏。另一个技巧是把推理线程设置为后台优先级避免和UI渲染抢CPU时间同时又不会阻塞NPU任务。5.3 预热模型让第一次生成的响应时间恢复正常第一次生成文本往往特别慢因为NPU要完成模型上下文初始化和算子编译。为了让用户感知不到这个延迟我在App启动后的空闲时间里主动执行一次“预热推理”给它一个固定输入并让模型跑一遍。这样等用户真正输入时上下文已经就绪生成延迟大幅降低。实测预热前后第一次生成耗时从8秒降到不到1秒。6. 最后的补充这块内容还能怎么玩整个项目跑通之后我最大的感受是“原来手机算力已经到这个水平了”。这个方案不只能跑LLaMA-7B你完全可以换成更小的1.1B模型来做端侧编程助手或者换成多模态模型做图片理解。QNN也支持TensorFlow Lite模型的转换所以以前写好的TF模型也能找到新的加速路径。工程上下一步我打算把输入长度扩展到512甚至1024这样就能做长篇幅文档摘要了。另外还在折腾服务端和手机端的混合部署方案也就是把私有数据留在手机本地做训练和微调再把全局模型参数从云端同步下来。这条路很有意思也很有挑战但至少证明了一件事手机端大模型不是概念只要把工具链用对7B模型也能在兜里跑起来。
返回列表