端侧跑大模型最尴尬的从来不是算力不够,而是生态不对。手里一台AMD机器,显卡参数表看着挺漂亮,翻开各种部署教程,满屏都是另一套命令、另一套环境变量,照抄过去第一步就报错。我前后在三台不同配置的AMD设备上折腾过Qwen3.8-Flash-Next的部署——一台是24GB显存的独显台式机,一台是核显轻薄本,还有一台带NPU的新平台迷你主机——踩的坑完全不同,能跑通的参数也完全不同。这篇就把整个链路拆开讲清楚:AMD端侧推理到底有哪几条路、显存该怎么提前算、llama.cpp的HIP后端怎么编译、启动参数每一项在干什么、哪些坑会让你卡一整天,以及NPU那条路到底值不值得走。适合手里有AMD设备、想把模型真正落在本地跑起来的人,从完全没碰过ROCm的新手到已经能跑但速度不理想的进阶用户,都能拿到可以直接抄的东西。
1. AMD上跑端侧大模型,路线选择比参数调优更先决
很多人一上来就问"用什么参数最快",其实在AMD平台上,第一个要回答的问题根本不是参数,而是"走哪条后端"。同样一张卡,选错后端,性能可能只有正确路线的一半,甚至直接跑不起来。我把实际验证过的三条路线各自的适用边界列出来,你可以先对号入座,再往下看细节。
1.1 ROCm + HIP:性能上限最高,但对版本最挑剔
这是AMD独显的主线方案。核心思路是把CUDA那套计算图通过HIP层翻译到AMD GPU上执行,llama.cpp、vLLM、PyTorch都能走这条路。它的优势非常明确:能吃到完整的矩阵运算加速库,prefill阶段吞吐高,显存管理也最贴近硬件。
代价是版本耦合极紧。ROCm的版本、内核版本、GPU的gfx架构代号、编译器版本,这四者任何一个不匹配,你看到的报错就是"device not found"或者编译到一半挂掉,而且报错信息往往和真正的原因毫无关系。我见过最离谱的一次,编译失败的真实原因是系统里的python-dev包版本太老,但报错指向的是hipcc找不到某个头文件。
这条路线我建议这样判断:如果你是RX 7000系列或更新的独立显卡,且系统能装到官方支持的发行版版本,就走ROCm,别犹豫。如果你用的是核显或者比较老的卡,先看看官方支持列表里有没有你的gfx代号,没有的话直接跳到Vulkan路线,能省掉大半天。
1.2 Vulkan 后端:驱动没配好时的保底方案
Vulkan这条路经常被低估。它不依赖ROCm那一整套运行时,只要有能正常工作的显卡驱动就能跑,跨平台一致性极好,Windows下尤其省心。
实测下来,Vulkan在decode阶段(也就是逐token生成阶段)的表现其实相当能打,因为decode是访存密集型,瓶颈在显存带宽而不是算力,Vulkan的调度开销在这个场景里被摊薄了。差距主要体现在prefill阶段——处理长prompt的时候,Vulkan比HIP慢三到五成是常见情况。
所以判断标准很简单:如果你的使用场景是短prompt、长输出的对话,Vulkan完全够用;如果是长文档摘要、代码库分析这种动辄上万token输入的活儿,还是老老实实上HIP。
1.3 Ryzen AI NPU:省电优先的第三条路
新一代带NPU的平台又多了一个选项。NPU的特点是能效比高,跑推理的时候整机功耗明显低于独显方案,风扇基本不转。但它的限制也很硬:模型必须先转成特定格式的量化ONNX,算子支持有白名单,不在名单里的结构会被切回CPU执行,一混合就慢得没法看。
我目前的判断是,NPU适合做常驻的后台小任务——比如本地文档的实时分类、输入法的语义联想、会议录音的转写后处理。想拿它当主力跑一个30B级别的模型,现阶段还不现实。
1.4 三条路线的横向对照
| 维度 | ROCm + HIP | Vulkan | Ryzen AI NPU |
|---|---|---|---|
| 硬件门槛 | 独显,且在支持列表内 | 任意可正常驱动的GPU | 特定NPU平台 |
| 部署难度 | 高,版本敏感 | 低,装好驱动即可 | 很高,需模型转换 |
| prefill 表现 | 最好 | 中等 | 弱(长序列受限) |
| decode 表现 | 好 | 接近 | 中等 |
| 功耗 | 高 | 高 | 极低 |
| 适合场景 | 长上下文、批量任务 | 对话、快速验证 | 常驻轻量任务 |
这张表建议存下来。后面所有内容,除非特别说明,都是围绕ROCm + HIP这条主线展开的,因为它是真正能发挥硬件实力的方案。
2. 动手之前先把显存这笔账算清楚
我见过太多人下载完模型直接启动,然后眼睁睁看着加载到87%崩掉。问题几乎都出在同一个地方:只算了权重,没算KV Cache和运行时开销。这件事必须在下载之前做,因为算完你可能发现需要换一个量化版本,而重新下载几十GB是很痛苦的。
2.1 权重占用:量化格式直接决定下限
权重占用可以用一个很朴素的方法估:参数量乘以每个参数的字节数,再留10%到15%的余量。
- FP16 / BF16:每参数2字节
- INT8 / Q8_0:每参数约1.06字节
- Q5_K_M:每参数约0.72字节
- Q4_K_M:每参数约0.60字节
- Q3_K_M:每参数约0.48字节
注意Q4_K_M这类混合量化不是严格的4bit,它会把一部分敏感层(比如embedding和输出层)保留在更高精度,所以实际字节数高于理论值。我一般按0.62估算,比较稳。
这里有个容易被忽略的点:MoE结构的模型,权重占用按总参数量算,不是按激活参数量算。很多人看到"激活参数只有3B"就以为显存占用和3B模型差不多,这是彻底的误解。激活参数只影响计算量,权重还是得全部塞进显存(或者内存)。
2.2 KV Cache:真正吃掉余量的那头
KV Cache的公式不难,难的是很多人根本没意识到它存在。
单token的KV占用 = 2 × 层数 × KV头数 × head维度 × 每元素字节数
乘上你要跑的上下文长度,就是总占用。注意公式里的"KV头数"不是注意力头数,如果模型用了分组查询注意力,KV头数会少很多,这是好事。
举个具体的:一个48层、8个KV头、head维度128的模型,用FP16存KV,单token占用是 2 × 48 × 8 × 128 × 2 = 196608 字节,约192KB。跑8192上下文就是1.5GB,跑32768就是6GB。这个数字在权重已经吃掉20GB的情况下,足够把一张24GB的卡逼到崩溃。
把KV Cache量化成Q8,占用直接减半,而质量损失在绝大多数任务上感知不到。这是我优先推荐打开的一个开关。
2.3 拿一个30B级MoE模型做一次完整测算
以我手上这份Qwen3.8-Flash-Next权重为例(总参数量在30B量级,MoE结构,激活参数约3B,48层,8个KV头),在24GB显存卡上做测算:
| 项目 | 精度/格式 | 占用估算 |
|---|---|---|
| 权重 | Q4_K_M | 约18.5 GB |
| KV Cache(8192上下文) | FP16 | 约1.5 GB |
| KV Cache(8192上下文) | Q8 | 约0.78 GB |
| 计算缓冲区 | — | 约1.0–1.5 GB |
| 运行时开销 | — | 约0.5 GB |
FP16 KV的情况下,总计约21.5到22GB,能塞进24GB,但只剩不到2GB的喘息空间,一旦上下文涨到16K立刻溢出。改成Q8 KV之后,16K上下文的总占用还在22.5GB左右,勉强守住。
如果卡只有16GB,结论就很清楚了:要么把上下文压到4K以内,要么把一部分层卸载到CPU。卸载的代价是速度断崖式下跌,因为每生成一个token都要走一遍PCIe。我的经验是,卸载比例超过20%之后,速度会掉到原来的三分之一以下,体验就废了。
2.4 核显与UMA:共享内存机型的特殊处理
核显平台是另一套逻辑。它没有独立显存,用的是系统内存的一部分,通过GTT或者UMA的方式划给GPU。这时候"显存不够"这个判断本身就不成立,你真正要关注的是两个东西:BIOS里分配的显存上限,以及内存带宽。
内存带宽是核显的真正天花板。DDR5双通道理论带宽在80到100GB/s量级,而独显是几百GB/s到上TB/s。decode阶段每个token都要把权重完整读一遍(激活的那部分),所以速度几乎和带宽成正比。这也解释了为什么核显跑MoE模型会比跑同尺寸的稠密模型快——MoE每个token只需要读取激活的那部分专家权重,实际读的数据量小得多。
核显机型上我通常会把BIOS里的显存分配调到最大,同时在系统层面确认GTT上限够用,Linux下可以通过内核参数调整。这一步不做,模型加载会直接失败,而且报错信息不会告诉你真实原因。
3. llama.cpp + HIP 从零编译到跑通
选定了ROCm路线,接下来就是把它跑起来。这一节我把整个链路按顺序拆开,每一步都说明为什么这么做,而不是只给命令。
3.1 环境对齐:先确认四件事再动手
在敲任何编译命令之前,先做四项检查,这四项任何一项不对,后面全是白费功夫。
第一,确认GPU的gfx架构代号。跑一条查询命令就能拿到,记下这个代号,后面编译和排错全靠它。
第二,确认ROCm版本支持你的gfx代号。官方支持列表里没有的代号,要么走Vulkan,要么用环境变量覆盖(后面第4节细讲)。
第三,确认内核版本和ROCm匹配。ROCm对内核版本有明确要求,版本太高反而会出问题——新版内核经常会改一些驱动接口,导致ROCm的预编译模块加载失败。
第四,确认当前用户在render和video用户组里。这一步纯粹是权限问题,但症状表现为"找不到设备",非常迷惑人。加完组要重新登录才生效,不是重启终端就行。
提示:这四项检查做完再动手,能省掉后面至少一半的排错时间。尤其是用户组这一项,我见过有人为此重装了三次系统。
3.2 编译参数怎么选:后端开关逐个说
llama.cpp的编译选项看起来很多,真正影响AMD平台性能的就那么几个。
cmake -B build \ -DGGML_HIP=ON \ -DAMDGPU_TARGETS="gfx1100" \ -DGGML_HIP_ROCWMMA_FATTN=ON \ -DCMAKE_BUILD_TYPE=Release \ -DGGML_NATIVE=ON cmake --build build --config Release -j$(nproc)逐项解释一下。
GGML_HIP=ON是打开HIP后端的主开关,不开这个编译出来的还是CPU版本。
AMDGPU_TARGETS必须精确填你的gfx代号。填错了编译能过,运行时找不到kernel。填多个代号会让编译时间翻倍,除非你要把二进制拷给不同机器用,否则只填自己的。
GGML_HIP_ROCWMMA_FATTN=ON打开的是基于ROCm矩阵指令的Flash Attention实现。这个开关对长上下文的prefill提升非常明显,我的实测里长prompt场景能快出三到四成。但它对gfx架构有要求,老卡开了会编译失败,失败的话关掉即可,不影响功能。
GGML_NATIVE=ON让编译器针对当前CPU做优化,主要影响那些跑在CPU上的辅助算子,收益不大但没坏处。
编译过程通常十到三十分钟,取决于CPU。编译期间大量报warning是正常的,只要不是error就不用管。
3.3 模型文件的准备:转换与再量化
拿到原始权重之后,一般要先转成GGUF格式,再按需量化。
转换脚本在仓库的转换目录里,基本命令是传入原始权重路径和输出路径。这里有个坑:转换脚本对原始权重的目录结构有要求,如果你下载的是已经分片的文件,需要保证所有分片都在同一目录下,且命名符合规范,否则脚本会读一半就报错。
量化阶段,Q4_K_M是我最常用的默认值。它在绝大多数任务上和FP16的差距感知不到,体积却只有四分之一多一点。如果显存实在紧张,可以往下降到Q3_K_M,但要注意3bit量化对代码生成和数学推理的影响会明显一些,日常对话还行。反过来,如果你显存宽裕又想追求质量,Q5_K_M或Q6_K是更好的选择,代价是速度会略降——因为要读的字节多了,而decode是带宽瓶颈。
注意:量化是一次性成本,量化完的文件建议保留原始版本。我吃过一次亏,为了省硬盘把原始权重删了,后来想换个量化精度,只能重新下载几十GB。
3.4 启动参数逐项拆解
这一步是最容易出问题的地方,因为参数之间会相互影响。我给一份自己常用的配置,然后逐项说清楚。
./build/bin/llama-server \ -m ./models/qwen38-flash-next-Q4_K_M.gguf \ -ngl 99 \ -c 16384 \ --cache-type-k q8_0 \ --cache-type-v q8_0 \ -fa \ -b 2048 \ -ub 512 \ --threads 8 \ --host 127.0.0.1 \ --port 8080-ngl 99表示把所有层都放到GPU上。填99是个约定俗成的写法,意思是"能放多少放多少"。如果显存不够,llama.cpp会自动回退部分层到CPU,但这个过程不透明,你很难知道具体放了多少层。想精确控制就手动指定层数,从总层数开始往下试,直到能稳定加载。
-c 16384是上下文长度。这个值直接决定KV Cache大小,是显存占用的主要变量。不要一上来就填32768,先按实际需求定,跑通了再往上加。
--cache-type-k和--cache-type-v是KV Cache的精度。K和V分开设置是有讲究的:K对量化更敏感,V相对宽容。实践里两个都设成q8_0问题不大,但如果发现输出质量有异常,可以只量化V,K保持FP16。
-fa打开Flash Attention。这个开关在长上下文下既省显存又快,几乎没理由不开。
-b和-ub是批处理大小和微批大小。这两个参数共同决定prefill阶段的并行度。-b 2048配合-ub 512是个比较稳的组合,显存占用可控,prefill速度也不错。显存宽裕的话可以把-ub往上加,但别超过-b。
--threads设成物理核心数。注意是物理核心,不是超线程之后的逻辑核心数。设成逻辑核心数反而会因为调度竞争变慢,这个坑我在好几台机器上都验证过。
4. 卡住一整天的那几个坑,以及它们真正的成因
这一节是我认为最有价值的部分。下面每个问题的排查链路我都完整写出来,因为很多时候报错信息和真实原因隔了十万八千里,你需要的不是答案,是排查思路。
4.1 gfx架构不被识别:报错指向的地方全是错的
症状:编译顺利通过,运行时报"no usable GPU found"或者直接段错误。
排查链路:
- 先确认ROCm本身能看见卡。跑一个设备信息查询命令,如果这一步就没输出,问题在驱动层,不在llama.cpp。
- 如果ROCm能看见卡,但llama.cpp看不见,那就是gfx代号不匹配。这时候去看编译时填的
AMDGPU_TARGETS和实际代号是否一致。 - 如果代号一致还是不行,检查环境变量里有没有残留的旧配置,尤其是那些指定可见设备的变量,有时候会意外屏蔽掉你的卡。
根因与修复:最常见的情况是消费级显卡的gfx代号不在ROCm官方支持列表里。系统识别出的代号是一个值,但ROCm的预编译kernel只认列表里的值。解决办法是用环境变量把代号覆盖成最接近的支持型号:
export HSA_OVERRIDE_GFX_VERSION=11.0.0这个变量告诉运行时"假装我是这个型号"。绝大多数情况下功能正常,少数涉及新指令集的特性和性能会受影响。这个方法在核显上尤其常用。
提示:这个环境变量要在启动进程的那个shell里生效,写进配置文件容易被其他程序读到,产生意想不到的副作用。
4.2 加载到一半OOM:不是显存不够,是碎片
症状:进度条走到80%到95%之间崩掉,报显存分配失败。但按你的计算明明够用。
排查链路:
- 先看实际占用。加载过程中另开一个终端盯显存使用曲线,如果是缓慢上涨然后突然失败,说明确实是总量不够;如果是锯齿状波动后失败,那是碎片问题。
- 把上下文长度砍半再试一次。如果能过,说明KV Cache的预分配策略和你预期不符。
- 关掉Flash Attention再试。某些架构上FA会额外申请一块较大的连续显存,刚好卡在边界上。
根因与修复:有几种情况。一是显卡上还跑着桌面环境,占了1到2GB,你的计算没把这部分算进去。二是KV Cache在部分实现里是提前按最大上下文一次性分配的,不是按需增长,所以你以为的8192够用,实际上启动时就按8192全额占了。三是驱动层的显存碎片,重启能解决。
我的处理顺序是:先把上下文降到4096验证能不能跑通,跑通之后按2的幂次往上加,找到稳定上限,然后在这个上限的80%位置定下来,留出余量。
4.3 输出重复、乱码、无限循环:先怀疑采样而不是模型
症状:模型能跑,速度也正常,但输出会陷入重复,或者冒出大量乱码字符。
排查链路:
- 先把温度调到0(贪心解码)测试。如果乱码消失,问题在采样参数,不在模型文件。
- 如果温度0下还乱,换一个量化精度更低的版本试试。如果换了就好,说明是这个量化文件本身有问题,可能是转换时的精度损失叠加。
- 如果所有量化版本都乱,检查KV Cache量化。把
--cache-type-k和--cache-type-v都去掉,用FP16再测一次。 - 最后才怀疑权重文件完整性,做一次哈希校验。
根因与修复:我遇到过最常见的原因是重复惩罚参数设得太高。很多人为了让输出不啰嗦,把repeat penalty调到1.3甚至更高,结果模型被逼着换用生僻词,越走越偏,最后输出乱码。这个参数在1.05到1.15之间比较安全,超过1.2就开始有副作用。
第二个常见原因是KV Cache量化对K的影响。前面说过K更敏感,如果你的任务对精度要求高(比如代码补全),建议K保持FP16,只量化V。
4.4 跑分很漂亮,实际用起来卡:问题不在GPU
症状:单独跑基准测试,tok/s数字很好看,但接到实际应用里,感觉响应一顿一顿的。
排查链路:
- 测首token延迟和后续token速度,分开看。如果首token延迟很高但后续很快,问题在prefill;如果都慢,问题在别处。
- 看进程的CPU占用。如果某个核心跑满而GPU利用率不高,是CPU侧的瓶颈。
- 检查调用方是否每次请求都重新加载模型。有些客户端实现每次请求起一个新进程,那就解释得通了。
根因与修复:最常见的是没有用服务端模式。如果你的应用每次调用都启动一次命令行程序,模型要重新加载几十GB的权重到显存,那个时间是以十秒计的。正确做法是用服务端模式常驻,通过HTTP接口调用,模型只加载一次。
第二个原因是prompt构造得太啰嗦。系统提示词动辄两三千token,每次请求都要重新prefill一遍,首token延迟自然高。这个可以用前缀缓存缓解,把固定的系统提示词部分缓存起来,后续请求只处理变化的部分。
5. 把速度从"能用"推到"好用"的几个抓手
跑通只是及格线。真正决定这套东西能不能日常用的,是吞吐和延迟。这一节讲三个我认为收益最高的优化方向。
5.1 分清prefill和decode:它们的瓶颈完全不同
这是一个必须先建立的认识:prefill是算力密集型,decode是访存密集型。两者优化手段几乎相反。
prefill阶段要处理整个输入序列,矩阵乘法可以高度并行,吃的是GPU的算力。优化方向是增大批处理大小、打开Flash Attention、用更高效的计算库。
decode阶段每个token只处理一个位置,但要把权重完整读一遍,吃的是显存带宽。优化方向是降低权重的读取量——也就是用更激进的量化,或者用MoE这种只读激活部分的结构。
理解了这一点,你就能判断自己的场景该往哪优化。长文档摘要类任务,prefill占大头,重点调-b和-ub;对话类任务,decode占大头,重点在量化精度和显存带宽。搞反了方向,调半天没效果。
我的实测数据供参考:24GB显存档,Q4_K_M量化,8K输入的prefill速度在600 tok/s上下,decode速度在45 tok/s上下。这两个数字差了一个数量级,所以短输入长输出的场景下,prefill耗时几乎可以忽略,你感受到的全是decode。
5.2 KV Cache量化与上下文裁剪的取舍
KV Cache量化是最划算的优化,没有之一。把FP16降到Q8,显存直接省一半,长上下文的容量翻倍,而质量损失在对话、摘要、翻译这些任务上基本测不出来。
代价出现在两个场景:一是数学和代码这类对数值精度敏感的任务,二是超长上下文下的长程依赖。前者我建议K保持FP16,只量化V;后者建议把上下文控制在模型训练长度以内,超出部分即使塞进去,注意力也已经衰减得很厉害了。
上下文裁剪是另一个思路。很多场景其实不需要那么长的上下文,比如客服问答,把历史对话按轮次截断,保留最近若干轮加一个摘要,效果和全量塞进去差不多,但速度快得多、显存省得多。这个思路比调参更有效,因为它从根上减少了工作量。
5.3 服务化封装:并发请求怎么处理
单用户场景下,一个常驻的服务端进程就够了。多用户或者批量任务场景,就要考虑并发。
最朴素的做法是串行排队,简单但吞吐低。稍微好一点的是开多个进程各占一份显存,问题是这样显存直接翻倍,很快就爆了。
正确做法是用支持连续批处理的推理框架。llama.cpp自带的服务端在较新版本里已经支持一定程度的并发,原理是把多个请求的序列拼在一个batch里,共享一次权重读取,这样decode阶段的带宽利用率大幅提升。
并发数不是越高越好。我的经验是在显存允许的前提下,先测单个请求的decode速度,然后逐步加并发,观察总吞吐和单请求延迟的曲线。通常在并发数达到某个点之后,总吞吐不再增长而延迟继续恶化,那个拐点就是你的最优并发数。这个数在不同硬件上差别很大,必须实测,没法套公式。
另外要记得设置请求队列上限。不加限制的话,请求堆积会撑爆KV Cache,表现为突然的OOM或者请求大面积超时。
6. NPU 那条路:现在该不该碰
最后说说NPU。我花了差不多一周时间在这条路上,结论是:能做,但有明确的前置条件,不是所有人都值得投入。
6.1 模型转换的实际工作量
NPU路线的核心难点不在推理,在转换。整个链路是:原始权重 → 量化 → 导出ONNX → 用厂商工具链编译成能在NPU上执行的格式。
第一道坎是算子支持。NPU的算子白名单是有限的,模型里但凡有一个结构不在名单里,编译阶段就会报错或者被切到CPU。而现在的模型结构迭代很快,新结构往往还没有被工具链支持,你就得自己拆算子、改图,工作量不可控。
第二道坎是量化精度。NPU通常对int8或者int4有硬件加速,但量化过程需要校准数据。校准集选得不好,精度掉得很厉害,而且这个损失是模型级的,你很难定位到具体哪一层出了问题。
第三道坎是输入形状固定。很多NPU编译出来的模型要求固定的输入形状,上下文长度、batch大小都得提前定死。这和对话场景的变长输入天然冲突,做起来很别扭。
我这一周的实际结果是:小模型(激活参数几个B的量级)跑通了,能效比确实漂亮,功耗只有独显方案的零头;但想跑大一点的模型,卡在算子支持和精度上,性价比不如直接上独显。
6.2 什么场景该上NPU,什么场景别碰
该上的场景:常驻后台、一直开着、负载轻、对功耗敏感。比如本地文档的实时索引和分类、语音输入的实时转写、邮件自动打标签这类任务。这些任务的特点是单次计算量小、频率高、可以接受一定延迟,NPU的能效优势能完全发挥出来。
不该碰的场景:长上下文推理、批量数据处理、需要频繁切换模型的开发调试。这些场景要么对吞吐要求高,要么对灵活性要求高,NPU两条都不占优。
还有一种折中思路值得提:异构分工。把轻量的预处理和后处理放NPU,把重负载的生成放独显,两者并行不冲突。这个方案我在一台双硬件平台上试过,实际收益主要体现在整机功耗和风扇噪音上,推理速度提升有限,但对需要长时间开机的场景来说体验改善明显。
最后分享一个我踩坑得到的小经验:端侧部署最容易被低估的成本不是硬件,是版本管理。ROCm版本、驱动版本、编译参数、模型量化版本,这四个变量组合起来的状态,必须记下来。我后来养成了一个习惯,每跑通一个配置就写一个脚本把这一整套固定下来,包括环境变量和启动命令。因为在端侧环境里,能跑通一次不代表下次还能跑通——系统更新、驱动升级、甚至换了张卡,都可能让之前那套配置失效。有了这个脚本,回滚和复现的成本就从半天降到十分钟。