Instructions to use happyme531/Qwen3-ASR-1.7B-RKLLM with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- RKLLM
How to use happyme531/Qwen3-ASR-1.7B-RKLLM with RKLLM:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
更新转换脚本和文档(claude写的,感觉也不是特别好)
Browse files- README.md +2 -2
- convert/README.md +171 -0
- convert/audio_encoder/common.py +199 -0
- convert/audio_encoder/export_audio_encoder_onnx.py +84 -0
- convert/audio_encoder/export_audio_encoder_rknn.py +56 -0
- convert/audio_encoder/onnx_run_audio_encoder.py +73 -0
- convert/llm/export_rkllm_direct.py +159 -0
- convert/llm/extract_qwen3_text_model.py +149 -0
README.md
CHANGED
|
@@ -78,7 +78,7 @@ time_total_sec: 31.765
|
|
| 78 |
|
| 79 |
## 模型转换
|
| 80 |
|
| 81 |
-
|
| 82 |
|
| 83 |
## 已知问题
|
| 84 |
|
|
@@ -157,7 +157,7 @@ time_total_sec: 31.765
|
|
| 157 |
|
| 158 |
### Model Conversion
|
| 159 |
|
| 160 |
-
|
| 161 |
|
| 162 |
### Known Issues
|
| 163 |
|
|
|
|
| 78 |
|
| 79 |
## 模型转换
|
| 80 |
|
| 81 |
+
https://huggingface.co/happyme531/Qwen3-ASR-1.7B-RKLLM/blob/main/convert/README.md
|
| 82 |
|
| 83 |
## 已知问题
|
| 84 |
|
|
|
|
| 157 |
|
| 158 |
### Model Conversion
|
| 159 |
|
| 160 |
+
https://huggingface.co/happyme531/Qwen3-ASR-1.7B-RKLLM/blob/main/convert/README.md
|
| 161 |
|
| 162 |
### Known Issues
|
| 163 |
|
convert/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Qwen3-ASR-1.7B → RK3588 模型转换
|
| 2 |
+
|
| 3 |
+
### (English README see below)
|
| 4 |
+
|
| 5 |
+
把 [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) 转成 RK3588 上可运行的 RKNN + RKLLM。
|
| 6 |
+
|
| 7 |
+
- 音频编码器(`thinker.audio_tower`)→ ONNX → **RKNN**
|
| 8 |
+
- 文本 LLM(`thinker.model + thinker.lm_head`)→ 标准 `Qwen3ForCausalLM` → **RKLLM**
|
| 9 |
+
- 全程 **FP16,不量化**
|
| 10 |
+
|
| 11 |
+
## 目录
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
convert/
|
| 15 |
+
├── audio_encoder/
|
| 16 |
+
│ ├── common.py 共享工具
|
| 17 |
+
│ ├── export_audio_encoder_onnx.py PyTorch → ONNX
|
| 18 |
+
│ ├── export_audio_encoder_rknn.py ONNX → RKNN
|
| 19 |
+
│ └── onnx_run_audio_encoder.py ONNX 对齐校验(可选)
|
| 20 |
+
└── llm/
|
| 21 |
+
├── extract_qwen3_text_model.py 抽取标准 Qwen3 文本权重
|
| 22 |
+
└── export_rkllm_direct.py HF → RKLLM
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## 准备
|
| 26 |
+
|
| 27 |
+
把原始模型放到工作目录,并把官方 [QwenLM/Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR) 仓库克隆到同级目录(`audio_encoder/common.py` 需要从里面 import `modeling_qwen3_asr`):
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
huggingface-cli download Qwen/Qwen3-ASR-1.7B --local-dir .
|
| 31 |
+
git clone https://github.com/QwenLM/Qwen3-ASR.git
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
主机依赖:`torch transformers safetensors numpy scipy soundfile onnx onnxruntime`,以及 [`rknn-toolkit2`](https://github.com/airockchip/rknn-toolkit2) 和 [`rkllm-toolkit`](https://github.com/airockchip/rknn-llm)。
|
| 35 |
+
|
| 36 |
+
## 1. LLM → RKLLM
|
| 37 |
+
|
| 38 |
+
先抽出干净的 Qwen3 文本权重(直接喂原模型会因为残留的 `mrope/vision_config` 字段被 RKLLM 误判成视觉模型):
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
python convert/llm/extract_qwen3_text_model.py \
|
| 42 |
+
--model-path . \
|
| 43 |
+
--output-dir ./qwen3_text_hf
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
然后转 RKLLM:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
python convert/llm/export_rkllm_direct.py \
|
| 50 |
+
--model-path ./qwen3_text_hf \
|
| 51 |
+
--target-platform rk3588 --num-npu-core 3 \
|
| 52 |
+
--dtype float16 --max-context 4096 \
|
| 53 |
+
--savepath ./rknn/language_model.rkllm
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## 2. 音频编码器 → RKNN
|
| 57 |
+
|
| 58 |
+
音频塔被包成「100 mel 帧 / chunk」的静态模型(这是原模型本身的处理粒度),长音频在运行时分块跑再拼回去。
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
# PyTorch → ONNX
|
| 62 |
+
python convert/audio_encoder/export_audio_encoder_onnx.py \
|
| 63 |
+
--model-path . --savepath ./onnx/qwen3_asr_audio_chunk100.onnx
|
| 64 |
+
|
| 65 |
+
# (可选) 对齐校验,正常 max_abs_diff ≈ 1e-7
|
| 66 |
+
python convert/audio_encoder/onnx_run_audio_encoder.py \
|
| 67 |
+
--model-path . --onnx-path ./onnx/qwen3_asr_audio_chunk100.onnx \
|
| 68 |
+
--audio-path asr_example_zh.wav --compare-torch
|
| 69 |
+
|
| 70 |
+
# ONNX → RKNN
|
| 71 |
+
python convert/audio_encoder/export_audio_encoder_rknn.py \
|
| 72 |
+
--onnx-path ./onnx/qwen3_asr_audio_chunk100.onnx \
|
| 73 |
+
--target-platform rk3588 --savepath ./rknn/audio_encoder.rknn
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## 3. 产物
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
rknn/
|
| 80 |
+
├── audio_encoder.rknn
|
| 81 |
+
└── language_model.rkllm
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
直接对接仓库根目录的 `run_qwen3_asr_e2e.py`。
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
# Qwen3-ASR-1.7B → RK3588 Model Conversion
|
| 89 |
+
|
| 90 |
+
Convert [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) to RKNN + RKLLM for RK3588.
|
| 91 |
+
|
| 92 |
+
- Audio encoder (`thinker.audio_tower`) → ONNX → **RKNN**
|
| 93 |
+
- Text LLM (`thinker.model + thinker.lm_head`) → standard `Qwen3ForCausalLM` → **RKLLM**
|
| 94 |
+
- **FP16 throughout, no quantization**
|
| 95 |
+
|
| 96 |
+
## Layout
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
convert/
|
| 100 |
+
├── audio_encoder/
|
| 101 |
+
│ ├── common.py shared helpers
|
| 102 |
+
│ ├── export_audio_encoder_onnx.py PyTorch → ONNX
|
| 103 |
+
│ ├── export_audio_encoder_rknn.py ONNX → RKNN
|
| 104 |
+
│ └── onnx_run_audio_encoder.py ONNX parity check (optional)
|
| 105 |
+
└── llm/
|
| 106 |
+
├── extract_qwen3_text_model.py extract standard Qwen3 text weights
|
| 107 |
+
└── export_rkllm_direct.py HF → RKLLM
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Setup
|
| 111 |
+
|
| 112 |
+
Place the original model in your working directory and clone the official [QwenLM/Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR) repo as a sibling (`audio_encoder/common.py` imports `modeling_qwen3_asr` from it):
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
huggingface-cli download Qwen/Qwen3-ASR-1.7B --local-dir .
|
| 116 |
+
git clone https://github.com/QwenLM/Qwen3-ASR.git
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Host dependencies: `torch transformers safetensors numpy scipy soundfile onnx onnxruntime`, plus [`rknn-toolkit2`](https://github.com/airockchip/rknn-toolkit2) and [`rkllm-toolkit`](https://github.com/airockchip/rknn-llm).
|
| 120 |
+
|
| 121 |
+
## 1. LLM → RKLLM
|
| 122 |
+
|
| 123 |
+
First extract clean Qwen3 text weights (feeding the original model directly trips RKLLM into thinking it's a vision model because of leftover `mrope/vision_config` fields):
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
python convert/llm/extract_qwen3_text_model.py \
|
| 127 |
+
--model-path . \
|
| 128 |
+
--output-dir ./qwen3_text_hf
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
Then convert to RKLLM:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
python convert/llm/export_rkllm_direct.py \
|
| 135 |
+
--model-path ./qwen3_text_hf \
|
| 136 |
+
--target-platform rk3588 --num-npu-core 3 \
|
| 137 |
+
--dtype float16 --max-context 4096 \
|
| 138 |
+
--savepath ./rknn/language_model.rkllm
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## 2. Audio encoder → RKNN
|
| 142 |
+
|
| 143 |
+
The audio tower is wrapped as a static "100 mel frames / chunk" model (this matches the model's native processing granularity); long audio is split, run chunk-by-chunk and concatenated at runtime.
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
# PyTorch → ONNX
|
| 147 |
+
python convert/audio_encoder/export_audio_encoder_onnx.py \
|
| 148 |
+
--model-path . --savepath ./onnx/qwen3_asr_audio_chunk100.onnx
|
| 149 |
+
|
| 150 |
+
# (optional) parity check, expect max_abs_diff ≈ 1e-7
|
| 151 |
+
python convert/audio_encoder/onnx_run_audio_encoder.py \
|
| 152 |
+
--model-path . --onnx-path ./onnx/qwen3_asr_audio_chunk100.onnx \
|
| 153 |
+
--audio-path asr_example_zh.wav --compare-torch
|
| 154 |
+
|
| 155 |
+
# ONNX → RKNN
|
| 156 |
+
python convert/audio_encoder/export_audio_encoder_rknn.py \
|
| 157 |
+
--onnx-path ./onnx/qwen3_asr_audio_chunk100.onnx \
|
| 158 |
+
--target-platform rk3588 --savepath ./rknn/audio_encoder.rknn
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
## 3. Artifacts
|
| 163 |
+
|
| 164 |
+
```
|
| 165 |
+
rknn/
|
| 166 |
+
├── audio_encoder.rknn
|
| 167 |
+
└── language_model.rkllm
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
These plug directly into `run_qwen3_asr_e2e.py` at the repo root.
|
| 171 |
+
|
convert/audio_encoder/common.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
import torch
|
| 8 |
+
from scipy.signal import resample_poly
|
| 9 |
+
from transformers import WhisperFeatureExtractor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 13 |
+
QWEN_ASR_CORE = REPO_ROOT / "Qwen3-ASR" / "qwen_asr" / "core"
|
| 14 |
+
if str(QWEN_ASR_CORE) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(QWEN_ASR_CORE))
|
| 16 |
+
|
| 17 |
+
from transformers_backend.modeling_qwen3_asr import ( # noqa: E402
|
| 18 |
+
Qwen3ASRForConditionalGeneration,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
TORCH_DTYPES = {
|
| 23 |
+
"float16": torch.float16,
|
| 24 |
+
"bfloat16": torch.bfloat16,
|
| 25 |
+
"float32": torch.float32,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_torch_dtype(dtype: str) -> torch.dtype:
|
| 30 |
+
if dtype not in TORCH_DTYPES:
|
| 31 |
+
raise ValueError(f"Unsupported dtype: {dtype}")
|
| 32 |
+
return TORCH_DTYPES[dtype]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_waveform(audio_path: str, target_sr: int = 16000) -> np.ndarray:
|
| 36 |
+
audio, sr = sf.read(audio_path, dtype="float32", always_2d=False)
|
| 37 |
+
audio = np.asarray(audio, dtype=np.float32)
|
| 38 |
+
if audio.ndim == 2:
|
| 39 |
+
audio = audio.mean(axis=-1)
|
| 40 |
+
if sr != target_sr:
|
| 41 |
+
divisor = math.gcd(int(sr), int(target_sr))
|
| 42 |
+
up = int(target_sr // divisor)
|
| 43 |
+
down = int(sr // divisor)
|
| 44 |
+
audio = resample_poly(audio, up=up, down=down).astype(np.float32)
|
| 45 |
+
return audio
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def configure_feature_extractor_for_audio(feature_extractor: WhisperFeatureExtractor, waveform: np.ndarray) -> None:
|
| 49 |
+
required_seconds = max(1, math.ceil(waveform.shape[0] / float(feature_extractor.sampling_rate)))
|
| 50 |
+
if required_seconds <= feature_extractor.chunk_length:
|
| 51 |
+
return
|
| 52 |
+
feature_extractor.chunk_length = required_seconds
|
| 53 |
+
feature_extractor.n_samples = int(required_seconds * feature_extractor.sampling_rate)
|
| 54 |
+
feature_extractor.nb_max_frames = feature_extractor.n_samples // feature_extractor.hop_length
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def extract_mel_features(model_path: str, audio_path: str) -> tuple[np.ndarray, int]:
|
| 58 |
+
feature_extractor = WhisperFeatureExtractor.from_pretrained(model_path)
|
| 59 |
+
waveform = load_waveform(audio_path)
|
| 60 |
+
configure_feature_extractor_for_audio(feature_extractor, waveform)
|
| 61 |
+
outputs = feature_extractor(
|
| 62 |
+
waveform,
|
| 63 |
+
sampling_rate=16000,
|
| 64 |
+
return_attention_mask=True,
|
| 65 |
+
return_tensors="np",
|
| 66 |
+
)
|
| 67 |
+
input_features = outputs["input_features"][0].astype(np.float32)
|
| 68 |
+
feature_len = int(outputs["attention_mask"][0].sum())
|
| 69 |
+
return input_features, feature_len
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def split_mel_features(input_features: np.ndarray, feature_len: int, chunk_frames: int) -> list[tuple[np.ndarray, int]]:
|
| 73 |
+
chunks = []
|
| 74 |
+
start = 0
|
| 75 |
+
while start < feature_len:
|
| 76 |
+
cur_len = min(chunk_frames, feature_len - start)
|
| 77 |
+
chunk = np.zeros((input_features.shape[0], chunk_frames), dtype=np.float32)
|
| 78 |
+
chunk[:, :cur_len] = input_features[:, start : start + cur_len]
|
| 79 |
+
chunks.append((chunk, cur_len))
|
| 80 |
+
start += cur_len
|
| 81 |
+
return chunks
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_audio_encoder(model_path: str, dtype: str = "float32", device: str = "cpu") -> torch.nn.Module:
|
| 85 |
+
model = Qwen3ASRForConditionalGeneration.from_pretrained(
|
| 86 |
+
model_path,
|
| 87 |
+
dtype=get_torch_dtype(dtype),
|
| 88 |
+
)
|
| 89 |
+
model = model.to(device)
|
| 90 |
+
model.eval()
|
| 91 |
+
tower = model.thinker.audio_tower
|
| 92 |
+
tower.config._attn_implementation = "eager"
|
| 93 |
+
for layer in tower.layers:
|
| 94 |
+
layer.self_attn.config._attn_implementation = "eager"
|
| 95 |
+
tower.eval()
|
| 96 |
+
return tower
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def get_chunk_output_length(length: torch.Tensor) -> torch.Tensor:
|
| 100 |
+
length = length.to(torch.int64)
|
| 101 |
+
length = torch.div(length + 1, 2, rounding_mode="floor")
|
| 102 |
+
length = torch.div(length + 1, 2, rounding_mode="floor")
|
| 103 |
+
length = torch.div(length + 1, 2, rounding_mode="floor")
|
| 104 |
+
return length
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def get_chunk_output_length_value(length: int) -> int:
|
| 108 |
+
value = int(length)
|
| 109 |
+
value = (value + 1) // 2
|
| 110 |
+
value = (value + 1) // 2
|
| 111 |
+
value = (value + 1) // 2
|
| 112 |
+
return value
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class StaticChunkAudioEncoder(torch.nn.Module):
|
| 116 |
+
def __init__(self, tower: torch.nn.Module, chunk_frames: int = 100):
|
| 117 |
+
super().__init__()
|
| 118 |
+
self.tower = tower
|
| 119 |
+
self.chunk_frames = int(chunk_frames)
|
| 120 |
+
self.max_aftercnn_len = int(get_chunk_output_length(torch.tensor(self.chunk_frames)).item())
|
| 121 |
+
|
| 122 |
+
def forward(self, input_features: torch.Tensor, feature_len: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 123 |
+
batch_size = input_features.shape[0]
|
| 124 |
+
outputs = []
|
| 125 |
+
valid_lens = []
|
| 126 |
+
|
| 127 |
+
for batch_idx in range(batch_size):
|
| 128 |
+
current_feature_len = feature_len.reshape(-1)[batch_idx].to(torch.int64)
|
| 129 |
+
padded = input_features[batch_idx, :, : self.chunk_frames].unsqueeze(0).unsqueeze(0)
|
| 130 |
+
|
| 131 |
+
padded_embed = torch.nn.functional.gelu(self.tower.conv2d1(padded))
|
| 132 |
+
padded_embed = torch.nn.functional.gelu(self.tower.conv2d2(padded_embed))
|
| 133 |
+
padded_embed = torch.nn.functional.gelu(self.tower.conv2d3(padded_embed))
|
| 134 |
+
|
| 135 |
+
batch, channels, freq, time_steps = padded_embed.size()
|
| 136 |
+
padded_embed = self.tower.conv_out(
|
| 137 |
+
padded_embed.permute(0, 3, 1, 2).contiguous().view(batch, time_steps, channels * freq)
|
| 138 |
+
)
|
| 139 |
+
positional_embedding = self.tower.positional_embedding.positional_embedding[:time_steps, :]
|
| 140 |
+
padded_embed = padded_embed + positional_embedding.unsqueeze(0).to(padded_embed.dtype)
|
| 141 |
+
hidden_states = padded_embed.squeeze(0)
|
| 142 |
+
|
| 143 |
+
valid_len = get_chunk_output_length(current_feature_len.unsqueeze(0))[0].to(torch.int32)
|
| 144 |
+
valid_positions = torch.arange(time_steps, device=hidden_states.device, dtype=torch.int32) < valid_len
|
| 145 |
+
allowed = valid_positions[:, None] & valid_positions[None, :]
|
| 146 |
+
|
| 147 |
+
zeros = torch.zeros((1, 1, time_steps, time_steps), dtype=hidden_states.dtype, device=hidden_states.device)
|
| 148 |
+
minus_inf = torch.full(
|
| 149 |
+
(1, 1, time_steps, time_steps),
|
| 150 |
+
torch.finfo(hidden_states.dtype).min,
|
| 151 |
+
dtype=hidden_states.dtype,
|
| 152 |
+
device=hidden_states.device,
|
| 153 |
+
)
|
| 154 |
+
attention_mask = torch.where(allowed.unsqueeze(0).unsqueeze(0), zeros, minus_inf)
|
| 155 |
+
cu_seqlens = torch.stack(
|
| 156 |
+
(
|
| 157 |
+
torch.zeros((), dtype=torch.int32, device=hidden_states.device),
|
| 158 |
+
valid_len,
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
for encoder_layer in self.tower.layers:
|
| 163 |
+
hidden_states = encoder_layer(
|
| 164 |
+
hidden_states,
|
| 165 |
+
cu_seqlens=cu_seqlens,
|
| 166 |
+
attention_mask=attention_mask,
|
| 167 |
+
)[0]
|
| 168 |
+
|
| 169 |
+
hidden_states = self.tower.ln_post(hidden_states)
|
| 170 |
+
hidden_states = self.tower.proj1(hidden_states)
|
| 171 |
+
hidden_states = self.tower.act(hidden_states)
|
| 172 |
+
hidden_states = self.tower.proj2(hidden_states)
|
| 173 |
+
|
| 174 |
+
outputs.append(hidden_states.unsqueeze(0))
|
| 175 |
+
valid_lens.append(valid_len)
|
| 176 |
+
|
| 177 |
+
return torch.cat(outputs, dim=0), torch.stack(valid_lens, dim=0)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def run_chunked_torch(
|
| 181 |
+
model_path: str,
|
| 182 |
+
input_features: np.ndarray,
|
| 183 |
+
feature_len: int,
|
| 184 |
+
chunk_frames: int = 100,
|
| 185 |
+
dtype: str = "float32",
|
| 186 |
+
device: str = "cpu",
|
| 187 |
+
) -> np.ndarray:
|
| 188 |
+
tower = load_audio_encoder(model_path=model_path, dtype=dtype, device=device)
|
| 189 |
+
wrapper = StaticChunkAudioEncoder(tower=tower, chunk_frames=chunk_frames).to(device).eval()
|
| 190 |
+
|
| 191 |
+
outputs = []
|
| 192 |
+
for chunk, chunk_len in split_mel_features(input_features, feature_len, chunk_frames):
|
| 193 |
+
chunk_tensor = torch.from_numpy(chunk).unsqueeze(0).to(device=device, dtype=get_torch_dtype(dtype))
|
| 194 |
+
chunk_len_tensor = torch.tensor([chunk_len], dtype=torch.int32, device=device)
|
| 195 |
+
with torch.no_grad():
|
| 196 |
+
features, valid_len = wrapper(chunk_tensor, chunk_len_tensor)
|
| 197 |
+
outputs.append(features[0, : valid_len[0].item()].detach().cpu().numpy())
|
| 198 |
+
|
| 199 |
+
return np.concatenate(outputs, axis=0)
|
convert/audio_encoder/export_audio_encoder_onnx.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import onnxruntime as ort
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from common import StaticChunkAudioEncoder, get_torch_dtype, load_audio_encoder
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def parse_args():
|
| 11 |
+
parser = argparse.ArgumentParser(description="Export Qwen3-ASR audio encoder chunk model to ONNX.")
|
| 12 |
+
parser.add_argument("--model-path", type=str, default=".", help="Path to Qwen3-ASR model directory.")
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--savepath",
|
| 15 |
+
type=str,
|
| 16 |
+
default="rknn_deploy/audio_encoder/onnx/qwen3_asr_audio_chunk100.onnx",
|
| 17 |
+
help="Output ONNX path.",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument("--chunk-frames", type=int, default=100, help="Fixed mel chunk length.")
|
| 20 |
+
parser.add_argument(
|
| 21 |
+
"--dtype",
|
| 22 |
+
type=str,
|
| 23 |
+
default="float32",
|
| 24 |
+
choices=["float16", "bfloat16", "float32"],
|
| 25 |
+
help="Torch dtype used for loading and export.",
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument("--device", type=str, default="cpu", help="Torch device for export.")
|
| 28 |
+
return parser.parse_args()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main():
|
| 32 |
+
args = parse_args()
|
| 33 |
+
savepath = Path(args.savepath)
|
| 34 |
+
savepath.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
tower = load_audio_encoder(model_path=args.model_path, dtype=args.dtype, device=args.device)
|
| 37 |
+
wrapper = StaticChunkAudioEncoder(tower=tower, chunk_frames=args.chunk_frames).to(args.device).eval()
|
| 38 |
+
|
| 39 |
+
input_features = torch.zeros(
|
| 40 |
+
(1, 128, args.chunk_frames),
|
| 41 |
+
dtype=get_torch_dtype(args.dtype),
|
| 42 |
+
device=args.device,
|
| 43 |
+
)
|
| 44 |
+
feature_len = torch.tensor([args.chunk_frames], dtype=torch.int32, device=args.device)
|
| 45 |
+
|
| 46 |
+
with torch.no_grad():
|
| 47 |
+
torch_features, torch_valid_len = wrapper(input_features, feature_len)
|
| 48 |
+
|
| 49 |
+
torch.onnx.export(
|
| 50 |
+
wrapper,
|
| 51 |
+
(input_features, feature_len),
|
| 52 |
+
savepath.as_posix(),
|
| 53 |
+
input_names=["input_features", "feature_len"],
|
| 54 |
+
output_names=["audio_features", "valid_len"],
|
| 55 |
+
opset_version=18,
|
| 56 |
+
dynamo=False,
|
| 57 |
+
dynamic_axes={
|
| 58 |
+
"input_features": {0: "batch"},
|
| 59 |
+
"feature_len": {0: "batch"},
|
| 60 |
+
"audio_features": {0: "batch"},
|
| 61 |
+
"valid_len": {0: "batch"},
|
| 62 |
+
},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
ort_session = ort.InferenceSession(savepath.as_posix(), providers=["CPUExecutionProvider"])
|
| 66 |
+
ort_features, ort_valid_len = ort_session.run(
|
| 67 |
+
None,
|
| 68 |
+
{
|
| 69 |
+
"input_features": input_features.detach().cpu().numpy(),
|
| 70 |
+
"feature_len": feature_len.detach().cpu().numpy(),
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
max_diff = float((torch_features.detach().cpu() - torch.from_numpy(ort_features)).abs().max().item())
|
| 74 |
+
|
| 75 |
+
print(f"saved: {savepath}")
|
| 76 |
+
print(f"chunk_frames: {args.chunk_frames}")
|
| 77 |
+
print(f"chunk_output_frames: {wrapper.max_aftercnn_len}")
|
| 78 |
+
print(f"torch_valid_len: {int(torch_valid_len[0].item())}")
|
| 79 |
+
print(f"ort_valid_len: {int(ort_valid_len.reshape(-1)[0])}")
|
| 80 |
+
print(f"max_abs_diff(torch_vs_onnx): {max_diff:.8f}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
main()
|
convert/audio_encoder/export_audio_encoder_rknn.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
from rknn.api import RKNN
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def parse_args():
|
| 9 |
+
parser = argparse.ArgumentParser(description="Convert Qwen3-ASR audio encoder ONNX to RKNN.")
|
| 10 |
+
parser.add_argument(
|
| 11 |
+
"--onnx-path",
|
| 12 |
+
type=str,
|
| 13 |
+
default="rknn_deploy/audio_encoder/onnx/qwen3_asr_audio_chunk100.onnx",
|
| 14 |
+
help="Path to exported ONNX model.",
|
| 15 |
+
)
|
| 16 |
+
parser.add_argument("--target-platform", type=str, default="rk3588", help="RK target platform.")
|
| 17 |
+
parser.add_argument("--chunk-frames", type=int, default=100, help="Fixed mel chunk length.")
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--savepath",
|
| 20 |
+
type=str,
|
| 21 |
+
default="rknn_deploy/audio_encoder/rknn/qwen3_asr_audio_chunk100_rk3588.rknn",
|
| 22 |
+
help="Output RKNN path.",
|
| 23 |
+
)
|
| 24 |
+
return parser.parse_args()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main():
|
| 28 |
+
args = parse_args()
|
| 29 |
+
savepath = Path(args.savepath)
|
| 30 |
+
savepath.parent.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
rknn = RKNN(verbose=False)
|
| 33 |
+
rknn.config(target_platform=args.target_platform)
|
| 34 |
+
|
| 35 |
+
ret = rknn.load_onnx(
|
| 36 |
+
model=args.onnx_path,
|
| 37 |
+
inputs=["input_features", "feature_len"],
|
| 38 |
+
input_size_list=[[1, 128, args.chunk_frames], [1]],
|
| 39 |
+
input_initial_val=[None, np.asarray([args.chunk_frames], dtype=np.int32)],
|
| 40 |
+
)
|
| 41 |
+
if ret != 0:
|
| 42 |
+
raise SystemExit(f"rknn.load_onnx failed: {ret}")
|
| 43 |
+
|
| 44 |
+
ret = rknn.build(do_quantization=False, dataset=None)
|
| 45 |
+
if ret != 0:
|
| 46 |
+
raise SystemExit(f"rknn.build failed: {ret}")
|
| 47 |
+
|
| 48 |
+
ret = rknn.export_rknn(savepath.as_posix())
|
| 49 |
+
if ret != 0:
|
| 50 |
+
raise SystemExit(f"rknn.export_rknn failed: {ret}")
|
| 51 |
+
|
| 52 |
+
print(f"saved: {savepath}")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
convert/audio_encoder/onnx_run_audio_encoder.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import onnxruntime as ort
|
| 6 |
+
|
| 7 |
+
from common import extract_mel_features, get_chunk_output_length_value, run_chunked_torch, split_mel_features
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def parse_args():
|
| 11 |
+
parser = argparse.ArgumentParser(description="Run Qwen3-ASR audio encoder ONNX by chunk and optionally compare to PyTorch.")
|
| 12 |
+
parser.add_argument("--model-path", type=str, default=".", help="Path to Qwen3-ASR model directory.")
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--onnx-path",
|
| 15 |
+
type=str,
|
| 16 |
+
default="rknn_deploy/audio_encoder/onnx/qwen3_asr_audio_chunk100.onnx",
|
| 17 |
+
help="Path to exported ONNX.",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument("--audio-path", type=str, required=True, help="Path to input audio.")
|
| 20 |
+
parser.add_argument("--chunk-frames", type=int, default=100, help="Fixed mel chunk length.")
|
| 21 |
+
parser.add_argument("--compare-torch", action="store_true", help="Compare concatenated ONNX output with PyTorch.")
|
| 22 |
+
parser.add_argument("--savepath", type=str, default=None, help="Optional path to save concatenated features as .npy.")
|
| 23 |
+
return parser.parse_args()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
args = parse_args()
|
| 28 |
+
input_features, feature_len = extract_mel_features(args.model_path, args.audio_path)
|
| 29 |
+
chunks = split_mel_features(input_features, feature_len, args.chunk_frames)
|
| 30 |
+
|
| 31 |
+
session = ort.InferenceSession(args.onnx_path, providers=["CPUExecutionProvider"])
|
| 32 |
+
|
| 33 |
+
outputs = []
|
| 34 |
+
for chunk, chunk_len in chunks:
|
| 35 |
+
session_outputs = session.run(
|
| 36 |
+
None,
|
| 37 |
+
{
|
| 38 |
+
"input_features": chunk[None, ...].astype(np.float32),
|
| 39 |
+
"feature_len": np.asarray([chunk_len], dtype=np.int32),
|
| 40 |
+
},
|
| 41 |
+
)
|
| 42 |
+
ort_features = session_outputs[0]
|
| 43 |
+
if len(session_outputs) >= 2:
|
| 44 |
+
valid_len = int(np.asarray(session_outputs[1]).reshape(-1)[0])
|
| 45 |
+
else:
|
| 46 |
+
valid_len = get_chunk_output_length_value(chunk_len)
|
| 47 |
+
outputs.append(ort_features[0, :valid_len])
|
| 48 |
+
|
| 49 |
+
concat_features = np.concatenate(outputs, axis=0) if outputs else np.zeros((0, 2048), dtype=np.float32)
|
| 50 |
+
print(f"input_feature_len: {feature_len}")
|
| 51 |
+
print(f"num_chunks: {len(chunks)}")
|
| 52 |
+
print(f"audio_features: {concat_features.shape}")
|
| 53 |
+
|
| 54 |
+
if args.savepath:
|
| 55 |
+
savepath = Path(args.savepath)
|
| 56 |
+
savepath.parent.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
np.save(savepath, concat_features)
|
| 58 |
+
print(f"saved_features: {savepath}")
|
| 59 |
+
|
| 60 |
+
if args.compare_torch:
|
| 61 |
+
ref = run_chunked_torch(
|
| 62 |
+
model_path=args.model_path,
|
| 63 |
+
input_features=input_features,
|
| 64 |
+
feature_len=feature_len,
|
| 65 |
+
chunk_frames=args.chunk_frames,
|
| 66 |
+
)
|
| 67 |
+
max_diff = float(np.max(np.abs(ref - concat_features))) if ref.size and concat_features.size else 0.0
|
| 68 |
+
print(f"torch_features: {ref.shape}")
|
| 69 |
+
print(f"max_abs_diff(torch_vs_onnx): {max_diff:.8f}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
main()
|
convert/llm/export_rkllm_direct.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from rkllm.api import RKLLM
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_args():
|
| 10 |
+
parser = argparse.ArgumentParser(
|
| 11 |
+
description="Directly convert a Hugging Face model directory to RKLLM."
|
| 12 |
+
)
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--model-path",
|
| 15 |
+
type=str,
|
| 16 |
+
default=".",
|
| 17 |
+
help="Path to the Hugging Face model directory.",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument(
|
| 20 |
+
"--target-platform",
|
| 21 |
+
type=str,
|
| 22 |
+
default="rk3588",
|
| 23 |
+
help="RK target platform.",
|
| 24 |
+
)
|
| 25 |
+
parser.add_argument(
|
| 26 |
+
"--num-npu-core",
|
| 27 |
+
type=int,
|
| 28 |
+
default=3,
|
| 29 |
+
help="Number of NPU cores to use during conversion.",
|
| 30 |
+
)
|
| 31 |
+
parser.add_argument(
|
| 32 |
+
"--device",
|
| 33 |
+
type=str,
|
| 34 |
+
default="cpu",
|
| 35 |
+
help="Device used by rkllm-toolkit during conversion, usually cpu or cuda.",
|
| 36 |
+
)
|
| 37 |
+
parser.add_argument(
|
| 38 |
+
"--dtype",
|
| 39 |
+
type=str,
|
| 40 |
+
default="float16",
|
| 41 |
+
choices=["float16", "bfloat16", "float32"],
|
| 42 |
+
help="Model loading dtype for rkllm-toolkit.",
|
| 43 |
+
)
|
| 44 |
+
parser.add_argument(
|
| 45 |
+
"--do-quantization",
|
| 46 |
+
action="store_true",
|
| 47 |
+
help="Enable RKLLM quantization. Disabled by default for FP16 export.",
|
| 48 |
+
)
|
| 49 |
+
parser.add_argument(
|
| 50 |
+
"--quantized-dtype",
|
| 51 |
+
type=str,
|
| 52 |
+
default="w8a8",
|
| 53 |
+
help="Quantized dtype used only when --do-quantization is set.",
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--quantized-algorithm",
|
| 57 |
+
type=str,
|
| 58 |
+
default="normal",
|
| 59 |
+
help="Quantization algorithm used only when --do-quantization is set.",
|
| 60 |
+
)
|
| 61 |
+
parser.add_argument(
|
| 62 |
+
"--dataset",
|
| 63 |
+
type=str,
|
| 64 |
+
default=None,
|
| 65 |
+
help="Calibration dataset JSON path, only needed when --do-quantization is set.",
|
| 66 |
+
)
|
| 67 |
+
parser.add_argument(
|
| 68 |
+
"--optimization-level",
|
| 69 |
+
type=int,
|
| 70 |
+
default=1,
|
| 71 |
+
choices=[0, 1],
|
| 72 |
+
help="0 prioritizes speed/memory, 1 prioritizes accuracy.",
|
| 73 |
+
)
|
| 74 |
+
parser.add_argument(
|
| 75 |
+
"--max-context",
|
| 76 |
+
type=int,
|
| 77 |
+
default=4096,
|
| 78 |
+
help="Max context passed to rkllm build. Must be a multiple of 32.",
|
| 79 |
+
)
|
| 80 |
+
parser.add_argument(
|
| 81 |
+
"--savepath",
|
| 82 |
+
type=str,
|
| 83 |
+
default=None,
|
| 84 |
+
help="Output .rkllm path. If omitted, a default name is generated under ./rkllm.",
|
| 85 |
+
)
|
| 86 |
+
return parser.parse_args()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def make_default_savepath(model_path: str, target_platform: str, do_quantization: bool, quantized_dtype: str) -> str:
|
| 90 |
+
model_name = Path(model_path).resolve().name or "model"
|
| 91 |
+
suffix = quantized_dtype if do_quantization else "fp16"
|
| 92 |
+
return os.path.join("rkllm", f"{model_name}_{suffix}_{target_platform}.rkllm")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def main():
|
| 96 |
+
args = parse_args()
|
| 97 |
+
|
| 98 |
+
if args.max_context % 32 != 0:
|
| 99 |
+
raise ValueError("--max-context must be a multiple of 32")
|
| 100 |
+
|
| 101 |
+
if args.do_quantization and not args.dataset:
|
| 102 |
+
raise ValueError("--dataset is required when --do-quantization is enabled")
|
| 103 |
+
|
| 104 |
+
savepath = args.savepath or make_default_savepath(
|
| 105 |
+
model_path=args.model_path,
|
| 106 |
+
target_platform=args.target_platform,
|
| 107 |
+
do_quantization=args.do_quantization,
|
| 108 |
+
quantized_dtype=args.quantized_dtype,
|
| 109 |
+
)
|
| 110 |
+
os.makedirs(os.path.dirname(savepath) or ".", exist_ok=True)
|
| 111 |
+
|
| 112 |
+
print(f"[RKLLM] model_path={args.model_path}")
|
| 113 |
+
print(f"[RKLLM] target_platform={args.target_platform}")
|
| 114 |
+
print(f"[RKLLM] device={args.device}, dtype={args.dtype}")
|
| 115 |
+
print(f"[RKLLM] do_quantization={args.do_quantization}")
|
| 116 |
+
if args.do_quantization:
|
| 117 |
+
print(f"[RKLLM] quantized_dtype={args.quantized_dtype}")
|
| 118 |
+
print(f"[RKLLM] quantized_algorithm={args.quantized_algorithm}")
|
| 119 |
+
print(f"[RKLLM] dataset={args.dataset}")
|
| 120 |
+
print(f"[RKLLM] savepath={savepath}")
|
| 121 |
+
|
| 122 |
+
llm = RKLLM()
|
| 123 |
+
|
| 124 |
+
ret = llm.load_huggingface(
|
| 125 |
+
model=args.model_path,
|
| 126 |
+
device=args.device,
|
| 127 |
+
dtype=args.dtype,
|
| 128 |
+
)
|
| 129 |
+
if ret != 0:
|
| 130 |
+
print("[RKLLM] load_huggingface failed", file=sys.stderr)
|
| 131 |
+
raise SystemExit(ret)
|
| 132 |
+
|
| 133 |
+
build_kwargs = dict(
|
| 134 |
+
do_quantization=args.do_quantization,
|
| 135 |
+
optimization_level=args.optimization_level,
|
| 136 |
+
target_platform=args.target_platform,
|
| 137 |
+
num_npu_core=args.num_npu_core,
|
| 138 |
+
dataset=args.dataset,
|
| 139 |
+
max_context=args.max_context,
|
| 140 |
+
)
|
| 141 |
+
if args.do_quantization:
|
| 142 |
+
build_kwargs["quantized_dtype"] = args.quantized_dtype
|
| 143 |
+
build_kwargs["quantized_algorithm"] = args.quantized_algorithm
|
| 144 |
+
|
| 145 |
+
ret = llm.build(**build_kwargs)
|
| 146 |
+
if ret != 0:
|
| 147 |
+
print("[RKLLM] build failed", file=sys.stderr)
|
| 148 |
+
raise SystemExit(ret)
|
| 149 |
+
|
| 150 |
+
ret = llm.export_rkllm(savepath)
|
| 151 |
+
if ret != 0:
|
| 152 |
+
print("[RKLLM] export_rkllm failed", file=sys.stderr)
|
| 153 |
+
raise SystemExit(ret)
|
| 154 |
+
|
| 155 |
+
print(f"[RKLLM] export done: {savepath}")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|
convert/llm/extract_qwen3_text_model.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from safetensors import safe_open
|
| 7 |
+
from safetensors.torch import save_file
|
| 8 |
+
from transformers import Qwen3Config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
COPY_FILES = [
|
| 12 |
+
"generation_config.json",
|
| 13 |
+
"tokenizer_config.json",
|
| 14 |
+
"tokenizer.json",
|
| 15 |
+
"vocab.json",
|
| 16 |
+
"merges.txt",
|
| 17 |
+
"chat_template.json",
|
| 18 |
+
"special_tokens_map.json",
|
| 19 |
+
"preprocessor_config.json",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_args():
|
| 24 |
+
parser = argparse.ArgumentParser(
|
| 25 |
+
description="Extract Qwen3-ASR thinker text weights into a standard Qwen3ForCausalLM Hugging Face directory."
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"--model-path",
|
| 29 |
+
type=str,
|
| 30 |
+
default=".",
|
| 31 |
+
help="Path to the original Qwen3-ASR model directory.",
|
| 32 |
+
)
|
| 33 |
+
parser.add_argument(
|
| 34 |
+
"--output-dir",
|
| 35 |
+
type=str,
|
| 36 |
+
default="rkllm/qwen3_text_hf",
|
| 37 |
+
help="Path to the output Hugging Face directory for standard Qwen3.",
|
| 38 |
+
)
|
| 39 |
+
parser.add_argument(
|
| 40 |
+
"--dry-run",
|
| 41 |
+
action="store_true",
|
| 42 |
+
help="Only print the planned extraction result without writing files.",
|
| 43 |
+
)
|
| 44 |
+
return parser.parse_args()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def map_key(src_key: str) -> str | None:
|
| 48 |
+
if src_key.startswith("thinker.model."):
|
| 49 |
+
return "model." + src_key[len("thinker.model.") :]
|
| 50 |
+
if src_key == "thinker.lm_head.weight":
|
| 51 |
+
return "lm_head.weight"
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_json(path: Path):
|
| 56 |
+
with path.open("r", encoding="utf-8") as f:
|
| 57 |
+
return json.load(f)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def make_qwen3_config(src_config: dict, src_dir: Path) -> Qwen3Config:
|
| 61 |
+
text_config = dict(src_config["thinker_config"]["text_config"])
|
| 62 |
+
default_keys = set(Qwen3Config().to_dict().keys())
|
| 63 |
+
filtered = {k: v for k, v in text_config.items() if k in default_keys}
|
| 64 |
+
filtered["architectures"] = ["Qwen3ForCausalLM"]
|
| 65 |
+
filtered["model_type"] = "qwen3"
|
| 66 |
+
# Qwen3-ASR reuses a 3-axis MRoPE-style config shape, but its text side feeds
|
| 67 |
+
# identical position ids across all 3 axes. For plain Qwen3/RKLLM export, this
|
| 68 |
+
# should be treated as standard RoPE instead of multimodal MRoPE.
|
| 69 |
+
filtered["rope_scaling"] = None
|
| 70 |
+
|
| 71 |
+
generation_config = {}
|
| 72 |
+
generation_path = src_dir / "generation_config.json"
|
| 73 |
+
if generation_path.exists():
|
| 74 |
+
generation_config = load_json(generation_path)
|
| 75 |
+
|
| 76 |
+
filtered["pad_token_id"] = generation_config.get("pad_token_id", 151643)
|
| 77 |
+
filtered["eos_token_id"] = 151645
|
| 78 |
+
filtered["bos_token_id"] = generation_config.get("pad_token_id", 151643)
|
| 79 |
+
return Qwen3Config(**filtered)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def copy_support_files(src_dir: Path, dst_dir: Path):
|
| 83 |
+
for name in COPY_FILES:
|
| 84 |
+
src = src_dir / name
|
| 85 |
+
if src.exists():
|
| 86 |
+
shutil.copy2(src, dst_dir / name)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def main():
|
| 90 |
+
args = parse_args()
|
| 91 |
+
src_dir = Path(args.model_path).resolve()
|
| 92 |
+
dst_dir = Path(args.output_dir).resolve()
|
| 93 |
+
index_path = src_dir / "model.safetensors.index.json"
|
| 94 |
+
config_path = src_dir / "config.json"
|
| 95 |
+
|
| 96 |
+
src_index = load_json(index_path)
|
| 97 |
+
src_config = load_json(config_path)
|
| 98 |
+
|
| 99 |
+
selected = {}
|
| 100 |
+
total_size = 0
|
| 101 |
+
shard_to_keys: dict[str, list[tuple[str, str]]] = {}
|
| 102 |
+
|
| 103 |
+
for src_key, shard_name in src_index["weight_map"].items():
|
| 104 |
+
dst_key = map_key(src_key)
|
| 105 |
+
if dst_key is None:
|
| 106 |
+
continue
|
| 107 |
+
selected[dst_key] = shard_name
|
| 108 |
+
shard_to_keys.setdefault(shard_name, []).append((src_key, dst_key))
|
| 109 |
+
|
| 110 |
+
print(f"source_model: {src_dir}")
|
| 111 |
+
print(f"output_dir: {dst_dir}")
|
| 112 |
+
print(f"selected_tensors: {len(selected)}")
|
| 113 |
+
print(f"source_shards: {len(shard_to_keys)}")
|
| 114 |
+
|
| 115 |
+
if args.dry_run:
|
| 116 |
+
for shard_name, pairs in sorted(shard_to_keys.items()):
|
| 117 |
+
print(f"{shard_name}: {len(pairs)} tensors")
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
|
| 122 |
+
for shard_name, pairs in sorted(shard_to_keys.items()):
|
| 123 |
+
shard_path = src_dir / shard_name
|
| 124 |
+
tensors = {}
|
| 125 |
+
with safe_open(shard_path, framework="pt", device="cpu") as f:
|
| 126 |
+
for src_key, dst_key in pairs:
|
| 127 |
+
tensor = f.get_tensor(src_key)
|
| 128 |
+
tensors[dst_key] = tensor
|
| 129 |
+
total_size += tensor.numel() * tensor.element_size()
|
| 130 |
+
save_file(tensors, str(dst_dir / shard_name))
|
| 131 |
+
print(f"wrote {shard_name}: {len(tensors)} tensors")
|
| 132 |
+
|
| 133 |
+
dst_index = {
|
| 134 |
+
"metadata": {"total_size": total_size},
|
| 135 |
+
"weight_map": {dst_key: shard_name for dst_key, shard_name in selected.items()},
|
| 136 |
+
}
|
| 137 |
+
with (dst_dir / "model.safetensors.index.json").open("w", encoding="utf-8") as f:
|
| 138 |
+
json.dump(dst_index, f, ensure_ascii=False, indent=2)
|
| 139 |
+
f.write("\n")
|
| 140 |
+
|
| 141 |
+
qwen3_config = make_qwen3_config(src_config, src_dir)
|
| 142 |
+
qwen3_config.save_pretrained(dst_dir)
|
| 143 |
+
|
| 144 |
+
copy_support_files(src_dir, dst_dir)
|
| 145 |
+
print("done")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|