LocateAnything-3B 搬上 RK3588:NPU 跑平行框解碼的實戰紀錄

RK3588 開發板上方浮現多個偵測框的概念圖

我本來以為只要把 KV cache 的最後幾格砍掉重來就好,結果 RKLLM 回我這一行:

E rkllm: Start_pos and end_pos are only valid when keep_history == 0 and the generation has been paused by returning 1 in the callback!

rkllm_clear_kv_cache 回傳 -1,靠北,我要的範圍回捲就這樣被擋在門外。偏偏 NVIDIA 的 LocateAnything-3B 整個解碼流程,每一步都得把「視窗」丟掉再重來。

這篇是我把這個模型搬到 RK3588 NPU 的完整紀錄:怎麼轉、卡在哪、怎麼繞過去,還有最重要的,轉完到底掉了多少精度。結論先講:轉換成功,在 Rock 5C 上能端到端跑,平均每張圖約 5.8 秒;但在我的 100 張 COCO 子集上,F1@0.5 從 GPU 的 0.774 掉到 0.606。成品我已經放到 Hugging Face ,授權限非商業研究,下面會講細節。

環境與前置條件
- 板子:Radxa Rock 5C(RK3588,8GB),RKNPU 驅動,librkllmrt.so 1.3.0
- 轉檔機:x86 Linux,RTX 3090,Python 3.10;rkllm-toolkit 1.3.0、rknn-toolkit2 2.5.0(兩個必須分開 venv)
- 板上相依:rknn-toolkit-lite2 2.5.0、numpy、Pillow、tokenizers
- 前置知識:知道 VLM 的基本結構,用過 RKLLM 或 RKNN 其中一個
- 記憶體:LLM 約 3.8GB + 視覺約 0.9GB,8GB 板子要先停掉其他吃 NPU 的服務

LocateAnything 到底快在哪

視覺定位模型通常把一個框寫成四個座標 token,一個一個吐。慢是一回事,更麻煩的是框的四個數字其實彼此相依,卻被硬拆成獨立的預測。NVIDIA 在 論文 裡的做法是 Parallel Box Decoding(PBD):把一個框當成一個單位,一次預測完。

模型本身是 MoonViT 視覺編碼器、一個 MLP 投影層,加上 Qwen2.5-3B-Instruct 當語言模型,見 NVIDIA 的模型卡 。官方 專案頁 給的數字是 H100 上 hybrid 模式 12.7 boxes/s,對比 Qwen3-VL 的 1.1 boxes/s,號稱超過 10 倍。注意這是 H100 的數字,跟 RK3588 沒有半毛關係。而且這是官方自報的數字:專案頁另有一處 ablation 寫 13.2 BPS,條件不同,我也沒看到 Qwen3-VL 是否在同一台機器上測的說明,所以只當參考,後面我會用自己的實測說話。

左邊一顆顆串起的珠鏈與右邊一次出現的發光方框對比圖

我花了點時間讀原始碼,PBD 的一步其實是這樣:

  1. 輸入尾端接上「重複的最後一個 token」加 5 個 <text_mask>,湊成 6 格視窗。
  2. 視窗內部用雙向 attention,視窗外只看前面的 KV cache。
  3. 6 個位置的 logits 剛好對應一個框:<box> x1 y1 x2 y2 </box>,座標是 0 到 1000 的 token。
  4. 視窗的 KV 每一步都丟掉,只保留已接受 token 的 cache。
  5. 解碼出問題(格式不合法、座標候選太分歧)就退回逐 token,等遇到 </box> 再切回來,這就是 hybrid 模式。

所以一個框只要一次 forward,不是六次。聽起來很美,直到你想把它塞進 RKLLM。

RK3588 能給什麼、不能給什麼

RK3588 的 NPU 標稱 6 TOPS,見 CNX Software 的規格整理 (這是二手來源,我沒找到官方 datasheet)。RKLLM 是 Rockchip 的 LLM 部署工具,官方 repo 列的支援平台(2026-11-02 查證)有 RK3588、RK3576、RK3562、RV1126B,模型清單裡有 Qwen 家族、Gemma、InternVL、Qwen-VL 等,我看了一遍,沒有 LocateAnything。CNX 當年的報導 介紹過這套 toolkit 的發布;官方 README 則說明 toolkit 是在電腦上做模型轉換、板子上用 runtime 的 C/C++ 介面部署,所以流程就是在 x86 上轉成 .rkllm,再放到板子上跑。

我把需要的功能對著 rkllm.h 逐項查:

PBD 需要的能力RKLLM 有沒有實際狀況
餵 token 與圖像 embedding有INPUT_TOKEN、INPUT_MULTIMODAL
一次取多個位置的 logits有RKLLM_INFER_GET_LOGITS,回傳 num_tokens × vocab
KV cache 範圍回捲有但極受限註解寫只在 keep_history == 0 且回呼暫停時有效,我實測一般呼叫直接 -1
視窗內雙向 attention沒有RKLLMInferParam 沒有 mask 或 position 欄位

最後一列是硬傷。官方標頭裡唯一帶 mask 的是 cross-attention 的 encoder 參數,跟 decoder 視窗無關。所以我預期這件事做不到原樣,只能近似。

轉換:兩個模型,兩個 venv

LLM 這半邊

LocateAnything 的權重是一個大檔,語言模型是標準 Qwen2,只是詞表擴到 152,681(多了框和座標 token)。第一步是把它拆成一個乾淨的 Qwen2 checkpoint:

# Python 3.10, transformers 4.57.1, safetensors
import json
from safetensors import safe_open
from safetensors.torch import save_file

idx = json.load(open("model/model.safetensors.index.json"))["weight_map"]
llm = {}
for fname in sorted(set(idx.values())):
    with safe_open(f"model/{fname}", "pt") as sf:
        for k in sf.keys():
            if k.startswith("language_model."):
                llm[k[len("language_model."):]] = sf.get_tensor(k)

# lm_head 與 embed_tokens 實際上是共用的,只存一份
llm.pop("lm_head.weight")
save_file({k: v.contiguous() for k, v in llm.items()}, "qwen25_3b_la/model.safetensors")

再配一份 Qwen2Config(tie_word_embeddings=True、vocab_size=152681),用普通的 Qwen2ForCausalLM.from_pretrained 載回來確認沒爛。接著用 rkllm-toolkit 轉 w8a8,在 3090 上一兩分鐘就出爐,產物 3.76GB。

這裡有個小缺口要老實講:官方 README 寫最新是 v1.3.1(我 2026-11-02 查的),但我手上只找得到 1.3.0 的 wheel(從板子上的 SDK 包拿的),所以這次用 1.3.0 轉。Qwen2 架構在 1.3.0 就支援,runtime 也剛好是板子上現成的 1.3.0。

視覺這半邊

MoonViT 原本支援動態解析度,用複數 RoPE、雙三次插值的位置 embedding、一堆 cu_seqlens。這些在 RKNN 上都是地雷。我的做法是直接固定輸入 448×448:網格 32×32 共 1024 個 patch,經過 2×2 合併剩 256 個 image token。

位置 embedding 和 2D RoPE 都改成預先算好的常數,複數乘法改寫成實數的 cos/sin 旋轉,attention 用最樸素的 matmul + softmax。最後接上 mlp1,直接輸出 256×2048,整個圖丟給 RKNN。我拿官方模型對同一張圖算參考輸出,對照結果:

shape (256, 2048)  cos 1.000011  max|diff| 0.00054  ref std 1.2129
# cos 略大於 1 是 fp32 浮點誤差,實質上就是 1;兩邊都是 fp32,這段還沒碰到 NPU 的 fp16

這個結果讓我放心不少,至少「圖變 embedding」這段,數值上跟官方是一致的。然後用 rknn-toolkit2 2.5.0 轉成 fp16 的 .rknn(不量化),0.92GB。

兩個工具包不要裝同一個 venv
rkllm-toolkit 1.3.0 與 rknn-toolkit2 2.5.0 放在同一個環境,pip 會直接報 ResolutionImpossible。分開兩個 venv,各裝各的,一個都不委屈。

大塊水晶被切成兩半並壓縮進小型晶片載板的概念圖

繞過限制:前綴 KV 重用

回到開頭那個 -1。範圍回捲走不通,我就去問:RKLLM 自己會不會偷偷幫我重用前綴?我寫了一支小探針,每次用 GET_LOGITS 送一串 token,觀察「這次實際重算了幾列」:

呼叫送入內容重算列數KV 長度耗時
S147 個 token4747355 ms
S2前 40 個相同,後 7 個換掉647145 ms
S1 再送一次同 S1647139 ms
S3前 40 個 + 5 個 <text_mask>545143 ms

重點在 S2:只重算了 6 列,不是 7。我回頭看 token,是因為被換掉的尾巴頭尾剛好都是 </box>,所以共同前綴多了一格。這證明 runtime 會自動找最長共同前綴、截斷其後的 KV 再接著算。S1 再送一次,logits 跟 fp32 的 PyTorch 參考比,cosine 平均 0.9993。

換句話說,我根本不需要手動回捲,每一步直接重送「已接受的全部 token + 5 個 <text_mask>」就好,runtime 會幫我重用前綴。連圖像輸入也一樣:

圖像呼叫回傳的 logits 列數耗時
第一次(256 個圖像 token + 約 34 個文字 token,KV 共 290)19約 1,981 ms
同樣的提示詞再送1140 ms
後面接 <ref>person</ref><box>4162 ms
再接 <97><515>2158 ms

這裡的 <97> 這種座標 token 和 <box> 都會被切成單一 token,剛好對得上。於是引擎的一步長這樣:

# 簡化自 rk3588/locate3b.py
text = tok.decode(gen, skip_special_tokens=False)       # 已接受的 token 轉回文字
prompt = head + text + "<text_mask>" * 5                # 視窗:5 個 mask
logits = run_logits(prompt, image_embeds)[-6:]          # 取最後 6 列 = 一個框
# 之後走官方 decode_bbox_avg / decode_ref / handle_pattern 的 numpy 移植

代價呢?視窗內部變成因果,不是原本的雙向。因為視窗的位置排布跟官方一樣(最後一個真實 token 在 L-1,mask 在 L 到 L+4),差別只剩 mask token 之間能不能互相看見。這是個近似,我後面用數字量化它。

圖片 448x448
   │
   ▼
RKNN 視覺 fp16(256 個 image token)──┐
                                      ▼
提示詞 + 已接受 token + 5 個 text_mask ──► RKLLM Qwen2.5-3B w8a8(GET_LOGITS)
                                      │
                                      ▼
                               最後 6 列 logits
                                      │
                                      ▼
                        CPU numpy:取樣與 PBD 解碼
                         │                      │
                   合法框 │               格式有誤 │
                         ▼                      ▼
               接受 6 個 token            退回逐 token
                         └──────► 回到下一步 ◄───┘

踩坑紀錄

Q1:模型一直吐框、永遠不結束(greedy 的陷阱)
現象:我最早用 greedy 加 repetition_penalty=1.1,bus.jpg 找到四個人之後,後面接了一大串座標微幅遞增的重複框。
量化:100 張 COCO 子集,GPU 官方模型 greedy 的 F1 只有 0.37 到 0.45,100 張裡只有 1 到 2 張正常結束。
原因:官方 batch_infer.py 的預設是 temperature 0.7、top_p 0.9、repetition_penalty 1.1 的抽樣,不是 greedy。我偏離了官方設定。
解法:改用官方抽樣後,GPU 上 F1 升到 0.73 到 0.77,100 張全部正常結束。板上引擎也補上同樣的抽樣。

Q2:rkllm_clear_kv_cache 一直回 -1
現象:終端機印出開頭那行 Start_pos and end_pos are only valid when keep_history == 0 ...。
原因:標頭檔寫範圍清除只在 keep_history == 0 且回呼暫停時有效。我在 GET_LOGITS 模式下,連讓回呼回傳 1 之後再清,也一樣回 -1。為什麼,我只能推測這個流程是為 generate 的暫停情境設計的,沒有深入驗證。
解法:放棄手動回捲,靠前綴重用。

Q3:venv 建不起來,ensurepip is not available
原因:Ubuntu 的 python3.10-venv 沒裝,而且我不想動 sudo。
解法:python3 -m venv --without-pip venv,再下載 get-pip.py 在 venv 裡裝 pip。

Q4:ImportError: cannot import name 'AutoProcessor'
原因:transformers 的延遲載入吃掉了真正的錯誤,底層是系統的 Pillow 9.0.1 太舊,缺 PIL.Image.Resampling。
解法:在 venv 裡 pip install --ignore-installed "pillow==11.1.0"。用 import transformers.models.auto.processing_auto 直接匯入才看得到真正的錯誤。

Q5:板子記憶體放不下兩個東西
現象:LLM 3.8GB、視覺 0.9GB,加起來約 4.7GB,8GB 板子上原本的 rkllm-api 已經載著別的模型。
解法:實驗時暫停 rkllm-api,結束再重啟。我自己有一隻叫 DORO 的 Discord 聊天 bot,它的模型就掛在這個服務上,測試期間它連不上模型,夭壽,是我自己害的。另外板子磁碟只剩 1.1GB,這也是個隱憂。

精度與速度:我量到的數字

評測方式:從 COCO val2017 抽 100 張(固定種子,每張取最多的那個類別,1 到 8 個目標,框都不小於 32×32 像素),用 Locate all the instances that matches the following description: <類別>.,算 box F1@0.5(依輸出順序貪婪匹配)加上匹配框的平均 IoU。所有設定都套用同一套後處理:遇到與先前框 IoU 大於 0.85 的重複框就截斷,最多取 30 個。抽樣參數同官方,單一種子。

設定PrecisionRecallF1@0.5F1@0.75匹配框 mIoU
原模型,GPU bf16,雙向視窗,4480.8420.7160.7740.6600.874
原模型,GPU bf16,因果視窗,4480.7600.7160.7380.6240.871
原模型,GPU bf16,雙向,原生解析度0.7470.7120.7290.6450.885
本轉換,Rock 5C,w8a80.7100.5290.6060.4080.795

兩根高低不同的發光玻璃柱象徵精度差距

這張表我這樣解讀。

因果視窗的代價不大。 雙向到因果,F1 從 0.774 掉到 0.738,約 3.6 個百分點,匹配框的 IoU 幾乎沒變(0.874 對 0.871)。這個近似比我預期的溫和。

真正的落差在量化與 NPU 數值。 從 GPU 因果的 0.738 到板子的 0.606,又掉了約 13 個點,召回率從 0.72 掉到 0.53,定位精度(mIoU 0.795)也下滑。這一段我沒有把 w8a8、fp16 視覺和其他數值差異拆開,所以只能說「量化加 NPU」是主因的推測,並沒有證據分解。

樣本很小。 n = 100、單一種子,幾個百分點內的差距都在雜訊範圍。我也沒有跑完整的 COCO,不要把這張表當 benchmark 引用。

速度方面,100 張的平均是每張 5.78 秒(中位數 4.90 秒),引擎有逐張記錄分段時間,拆開來看:

階段平均耗時說明
圖片載入 + 視覺編碼(RKNN)約 2.67 秒幾乎是固定值,100 張的最小值也是 2.66 秒
LLM(RKLLM,含第一次 prefill)約 2.92 秒平均約 7 個解碼步;第一次 prefill 約 2 秒,之後每步約 140 到 160 ms
CPU 取樣與 PBD 解碼約 0.19 秒numpy 實作

三項加起來剛好就是 5.78 秒。也就是說,一半的時間花在把圖變成 256 個 embedding,真正「吐框」的部分反而是小頭,這也是我之後最想優化的地方。對照 GPU 約 0.14 秒一張,板子大約慢了 40 倍,所以別期待它有 H100 那種「10 倍」的體感。

授權:能發布,但要守規矩

這個部分我花的時間比想像中多。LocateAnything-3B 用 NVIDIA License,條款 裡有兩個重點:再散布必須附完整授權並保留版權聲明;衍生作品只能用於「非商業」,也就是研究或評估用途。轉換後的權重屬於衍生作品,所以我的 repo 沿用同一個授權,不能自己放寬。

三個互相扣住的金屬環與一把黃銅鑰匙放在電路板上方

語言模型 Qwen2.5-3B-Instruct 另有 Qwen Research License ,要附授權全文、標示檔案被修改過、NOTICE 放它指定的那句版權文字,文件裡還要顯著標示「Built with Qwen」。視覺編碼器 MoonViT-SO-400M 的模型卡宣告 MIT,不過上游沒有附 LICENSE 檔,也沒寫版權持有人,所以我只引用它宣告的授權並連結來源,沒有替它編一行版權。

另外 Hugging Face 上已經有一個 RKNNAI 的版本 ,但它針對的是 RK1828(RK182X 系列),格式是 .rknn 加 .weight,跟 RK3588 的 .rkllm 不一樣,不能直接拿來用。我搜過 LocateAnything rknn 和 rkllm,目前沒看到別人發布 RK3588 的版本,不過我只搜了標題與關聯,漏看是可能的。

值不值得用

方案優點缺點適合
這份轉換版(RK3588)離線、省電、不吃 GPUF1 掉到 0.606、慢、固定 448、非商業研究、邊緣實驗、看 NPU 極限
原模型 + GPU精度最高、最快要 GPU正式驗證與基準
YOLO 這類固定類別偵測器結構簡單,轉成 RKNN 的路徑成熟只能偵測訓練過的類別,沒辦法用一句話描述目標類別固定的產線

我自己的結論是:如果你要的是「任意文字描述找物件」,而且願意接受精度折損,這個轉換版是目前 RK3588 上少數能做到的。如果你只需要固定類別,YOLO 更實際。

這個版本還有幾件沒做:沒有保長寬比的多解析度版本、沒有跑完整 COCO、沒有把量化誤差跟 fp16 視覺的貢獻拆開、也還沒接進 rkllm-api 當成一個正式的服務。有興趣的話,歡迎直接到 Hugging Face 下載來玩,轉檔與評測腳本都在 tools/,發現精度或授權上的問題也請告訴我。

參考資料

沒有留言:

張貼留言

LocateAnything-3B 搬上 RK3588:NPU 跑平行框解碼的實戰紀錄

我本來以為只要把 KV cache 的最後幾格砍掉重來就好,結果 RKLLM 回我這一行: E rkllm: Start_pos and end_pos are only valid when keep_history == 0 and the generation has ...