happyme531 commited on
Commit
b70ae8e
·
verified ·
1 Parent(s): b97314a

更新转换脚本和文档(claude写的,感觉也不是特别好)

Browse files
README.md CHANGED
@@ -78,7 +78,7 @@ time_total_sec: 31.765
78
 
79
  ## 模型转换
80
 
81
- ### TODO
82
 
83
  ## 已知问题
84
 
@@ -157,7 +157,7 @@ time_total_sec: 31.765
157
 
158
  ### Model Conversion
159
 
160
- #### TODO
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()