Files
2026-07-13 13:33:03 +08:00

922 lines
45 KiB
Python

import os
import json
import glob
import warnings
import argparse
warnings.filterwarnings("ignore")
os.environ['TOKENIZERS_PARALLELISM'] = 'false'
os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'python'
import onnx
import torch
from utils.model import LlmModel, EmbeddingModel
from utils.tokenizer import LlmTokenizer
from utils.spinner import spinner_run
from utils.custom_op import FakeLinear
from utils.onnx_rebuilder import OnnxRebuilder
from utils.mnn_converter import MNNConverter
from utils.awq_quantizer import AwqQuantizer
from utils.smooth_quantizer import SmoothQuantizer
from utils.omni_quantizer import OmniQuantizer
from utils.torch_utils import onnx_export
class LlmExporter(torch.nn.Module):
'''
Base class for all llm model export. Inherits from [`torch.nn.Module`].
'''
def __init__(self, args):
super().__init__()
self.init_from_args(args)
self.load_model(args.path)
def init_from_args(self, args):
self.args = args
self.max_new_tokens = 1024
self.dst_name = 'llm'
# load config from args
self.onnx_path = os.path.join(self.args.dst_path, 'onnx')
if self.args.tokenizer_path is None:
self.args.tokenizer_path = self.args.path
if args.lm_quant_bit is None:
self.args.lm_quant_bit = self.args.quant_bit
if args.lm_quant_block is None:
self.args.lm_quant_block = self.args.quant_block
self.args.tie_word_embeddings = False
# init export dst dir
if not os.path.exists(self.args.dst_path):
os.makedirs(self.args.dst_path)
if not os.path.exists(self.onnx_path):
os.makedirs(self.onnx_path)
@spinner_run(f'load pretrained model ', True)
def load_model(self, model_path):
self.model = LlmModel.from_pretrained(model_path, args=self.args)
self.tokenizer = LlmTokenizer.from_pretrained(
self.args.tokenizer_path,
model_type=self.model.config.model_type
)
self.model.tokenizer = self.tokenizer
self.config = self.model.config
self.model_type = self.config.model_type
if self.args.awq or self.args.smooth:
self.model.float()
if self.args.export is not None:
# set norm's weight as float for export
def visit_module(module):
if not isinstance(module, torch.nn.Linear) and hasattr(module, 'weight'):
module.float()
for name, child in module.named_children():
visit_module(child)
visit_module(self.model)
self.model_dynamic_axes = {
"input_ids" : { 0: "seq_len" },
"attention_mask" : { 2: "seq_len", 3: "seq_len" },
"position_ids" : { 1: "seq_len" },
}
self.llm_config = {
'model_type': self.config.model_type,
'hidden_size' : self.config.hidden_size,
'layer_nums': self.config.num_hidden_layers,
'attention_mask': 'float', # Will be determined by model later
'attention_type': self.config.attention_type,
'is_mrope': self.model.rotary.is_mrope
}
self.llm_config.update(self.model.get_config())
# Attention scaling (gemma4 uses 1.0 instead of 1/sqrt(head_dim))
if hasattr(self.model, 'blocks') and len(self.model.blocks) > 0:
attn = self.model.blocks[0].self_attn
if hasattr(attn, 'attn_scaling') and attn.attn_scaling != 1.0 / (self.config.head_dim ** 0.5):
self.llm_config['attn_scale'] = attn.attn_scaling
if self.config.sliding_window > 0:
self.llm_config['sliding_window'] = self.config.sliding_window
if hasattr(self.tokenizer, 'get_chat_template'):
chat_template = self.tokenizer.get_chat_template()
if chat_template is not None:
self.llm_config['jinja'] = {
'chat_template': chat_template
}
if self.tokenizer.bos_token:
self.llm_config['jinja']['bos'] = self.tokenizer.bos_token
if self.tokenizer.eos_token:
self.llm_config['jinja']['eos'] = self.tokenizer.eos_token
# gemma4's HF template is too complex for minja parser, use simplified version
if self.model_type == 'gemma4':
self.llm_config['jinja'] = {
'chat_template': "{{ bos_token }}{% for message in messages %}{% if message.role == \"system\" %}<|turn>system\n{{ message.content }}<turn|>\n{% elif message.role == \"user\" %}<|turn>user\n{{ message.content }}<turn|>\n{% elif message.role == \"assistant\" %}<|turn>model\n{{ message.content }}<turn|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|turn>model\n{% endif %}",
'bos': '<bos>',
'eos': '<turn|>'
}
# glm_ocr's HF template is too complex for minja parser, use simplified version
if self.model_type == 'glm_ocr':
self.llm_config['jinja'] = {
'chat_template': "[gMASK]<sop>{% for message in messages %}{% if message.role == \"user\" %}<|user|>\n{{ message.content }}{% elif message.role == \"assistant\" %}<|assistant|>\n{{ message.content }}{% elif message.role == \"system\" %}<|system|>\n{{ message.content }}{% endif %}{% endfor %}{% if add_generation_prompt %}<|assistant|>\n{% endif %}",
'eos': '<|endoftext|>'
}
# tie word embeddings
self.args.tie_word_embeddings = not self.args.seperate_embed and self.model.lm.lm.weight.equal(self.model.embed.embed.weight)
# Pass properties from model to exporter
self.visual = self.model.visual
self.audio = self.model.audio
self.talker = self.model.talker
self.mtp = self.model.mtp
self.scale_emb = self.model.scale_emb
return model_path
@torch.no_grad()
def response(self, query):
# self.imitate_quant()
self.model.decode_buffer = []
messages = [
{"role": "user", "content": query}
]
prompt = self.tokenizer.apply_chat_template(messages)
if query not in prompt:
prompt = query
# Use model's tokenizer methods for encoding
# For models with both visual and audio (e.g., gemma4), check content type
has_audio = self.model.audio is not None and '<audio>' in prompt
if has_audio:
# Process audio first, then let visual handle the rest
input_ids = self.model.audio.str_to_ids(prompt)
elif self.model.visual is not None:
input_ids = self.model.visual.str_to_ids(prompt)
elif self.model.audio is not None:
input_ids = self.model.audio.str_to_ids(prompt)
else:
input_ids = self.tokenizer(prompt, add_special_tokens=False, return_tensors="pt")['input_ids']
seq_len = input_ids.numel()
new_tokens = 0
while new_tokens < self.max_new_tokens:
attention_mask = self.model.get_attention_mask(seq_len, new_tokens)
position_ids = self.model.get_position_ids(seq_len, new_tokens, input_ids)
input_embeds = self.model.embedding(input_ids)
deepstack_embeds = self.model.visual.deepstacks() if self.model.visual is not None else None
logits, _, _ = self.model.forward(
input_ids=input_embeds,
attention_mask=attention_mask,
position_ids=position_ids,
logits_index=torch.tensor([-1], dtype=torch.int32),
deepstack_embeds=deepstack_embeds
)
token_id = torch.argmax(logits[:,-1,:])
seq_len += 1
new_tokens += 1
if token_id in self.tokenizer.stop_ids:
print("", end='\n')
break
# Use tokenizer's method for decoding
word = self.tokenizer.id_to_str(token_id)
print(word, end="", flush=True)
input_ids = token_id
if hasattr(self.model, 'talker') and self.model.talker is not None:
self.model.talker.generate()
def export_mtp(self):
if self.mtp is None:
return
mtp_onnx = self.mtp.export(self.onnx_path)
if self.mnn_converter:
self.mtp.unloaded_ops['/lm/lm_head/Linear'] = self.unloaded_ops['/lm/lm_head/Linear']
MNNConverter(self, self.mtp.unloaded_ops).export(mtp_onnx)
def export_eagle(self):
if self.args.eagle_path is None:
return
from utils.eagle import Eagle
self.eagle = Eagle.get_eagle(self.model_type)(self.args.eagle_path, self.model)
eagle_onnx, eagle_fc_onnx = self.eagle.export(self.onnx_path)
if self.mnn_converter:
MNNConverter(self, None).export(eagle_onnx)
MNNConverter(self, None).export(eagle_fc_onnx)
def export_dflash(self):
if not hasattr(self.args, 'dflash_path') or self.args.dflash_path is None:
return
from utils.dflash import DFlash
self.dflash = DFlash(self.args.dflash_path, self.model)
# Set target layer ids on args so model.forward() can use them
self.args.dflash_target_layer_ids = self.dflash.target_layer_ids
dflash_onnx, dflash_fc_onnx = self.dflash.export(self.onnx_path)
if self.mnn_converter:
# Disable transformerFuse for dflash model: dflash uses non-causal (bidirectional) attention,
# but MNN's fused attention assumes causal masking which breaks dflash's attention pattern.
# Use 8-bit quantization for dflash model to balance quality and size.
MNNConverter(self, self.dflash.unloaded_ops).export(dflash_onnx, quant_bit=8, transformer_fuse=False)
# FC model must NOT be quantized: the input (concatenated hidden states from
# multiple target layers) has very large value ranges during prefill, which
# causes int8 quantization overflow and produces all-zero outputs.
MNNConverter(self, None).export(dflash_fc_onnx, quant_bit=0, transformer_fuse=False)
@spinner_run(f'export embedding to ')
def export_embed(self):
import ctypes
from utils.torch_utils import quant as torch_quant
if hasattr(self.model, 'word_embeddings'):
# embedding model's embed
tensor_data = self.model.word_embeddings.weight.data
else:
tensor_data = self.model.embed.embed.weight.data
format_bit = getattr(self.args, 'embed_bit', 16)
quant_block = getattr(self.args, 'quant_block', 64)
symmetric = getattr(self.args, 'sym', False)
if self.args.skip_weight:
format_name = f'int{format_bit}' if format_bit < 16 else 'bf16'
embedding_file = f'{self.args.dst_path}/embeddings_{format_name}.bin'
# Calculate expected size
if format_bit == 16:
file_size = tensor_data.numel() * 2
else:
oc, ic = tensor_data.shape
block_size = ic if quant_block == 0 else quant_block
block_num = ic // block_size
q_weight_size = (oc * ic * format_bit + 7) // 8
alpha_size = oc * block_num * (1 if symmetric else 2) * 4
file_size = q_weight_size + alpha_size
self.llm_config['tie_embeddings'] = {
"weight_offset": 0,
"alpha_offset": q_weight_size,
"alpha_size": alpha_size,
"quant_bit": format_bit,
"quant_block": quant_block,
"alpha_dtype": "fp32",
}
with open(embedding_file, 'wb') as f:
if file_size > 0:
f.seek(file_size - 1)
f.write(b'\0')
return embedding_file
if format_bit == 16:
# BF16 format
tensor_data = tensor_data.bfloat16()
data_ptr = tensor_data.untyped_storage().data_ptr()
buffer = (ctypes.c_byte * (tensor_data.numel() * 2)).from_address(data_ptr)
embedding_file = f'{self.args.dst_path}/embeddings_bf16.bin'
with open(embedding_file, 'wb') as f:
f.write(buffer)
elif format_bit in [8, 4]:
# Quantized formats
quant_bit = format_bit
format_name = f'int{format_bit}'
awq = getattr(self.args, 'awq', False)
hqq = getattr(self.args, 'hqq', False)
# Apply quantization
q_weight, alpha = torch_quant(tensor_data.float(), quant_bit, quant_block, symmetric, awq, hqq)
# Save quantized weights and scales together in one file
embedding_file = f'{self.args.dst_path}/embeddings_{format_name}.bin'
with open(embedding_file, 'wb') as f:
weight_size = f.write(q_weight.numpy().tobytes())
alpha_size = f.write(alpha.numpy().tobytes())
self.llm_config['tie_embeddings'] = {
"weight_offset": 0,
"alpha_offset": weight_size,
"alpha_size": alpha_size,
"quant_bit": quant_bit,
"quant_block": quant_block,
"alpha_dtype": "fp32",
}
else:
raise ValueError(f"Unsupported embedding bit precision: {format_bit}")
return embedding_file
@spinner_run(f'export config to ')
def export_config(self, mnn_config = False):
with open(f'{self.args.dst_path}/export_args.json', 'w', encoding='utf-8') as f:
json.dump(self.args.__dict__, f, ensure_ascii=False, indent=4)
config_json = f'{self.args.dst_path}/llm_config.json'
with open(config_json, 'w', encoding='utf-8') as f:
json.dump(self.llm_config, f, ensure_ascii=False, indent=4)
if not mnn_config:
return config_json
with open(f'{self.args.dst_path}/config.json', 'w', encoding='utf-8') as f:
config = {
"llm_model": f"{self.dst_name}.mnn",
"llm_weight": f"{self.dst_name}.mnn.weight",
"backend_type": "cpu",
"thread_num": 4,
"precision": "low",
"memory": "low",
# "system_prompt": "You are a helpful assistant.",
"sampler_type": "mixed",
"temperature": 0.8,
"top_k": 40,
"top_p": 0.9,
"min_p": 0.05,
"tfs_z": 1.0,
"typical": 0.95,
"repetition_penalty": 1.0,
"presence_penalty": 0.0,
"frequency_penalty": 0.0,
"penalty_window": 0,
"n_gram": 8,
"ngram_factor": 1.0
}
config['tokenizer_file'] = 'tokenizer.mtok'
if self.args.embed_bit < 16:
config['embedding_file'] = f"embeddings_int{self.args.embed_bit}.bin"
if hasattr(self, 'talker') and self.talker is not None:
config['system_prompt'] = "You are Qwen, a virtual human developed by the Qwen Team, Alibaba Group, capable of perceiving auditory and visual inputs, as well as generating text and speech."
config['talker_max_new_tokens'] = 2048
config['talker_speaker'] = "Chelsie"
config['dit_steps'] = 5
config['dit_solver'] = 1
if self.model_type == "gemma3":
config.update({'precision': "normal"})
if (hasattr(self, 'visual') and self.visual is not None) or (hasattr(self, 'visual') and self.audio is not None):
config['mllm'] = {
'backend_type': "cpu",
"thread_num": 4,
"precision": "normal",
"memory": "low"
}
if self.args.eagle_path is not None:
config['speculative_type'] = 'eagle'
config['hidden_states'] = True
if hasattr(self.args, 'dflash_path') and self.args.dflash_path is not None:
config['speculative_type'] = 'dflash'
config['hidden_states'] = True
config['dflash_model'] = 'dflash.mnn'
config['dflash_fc'] = 'dflash_fc.mnn'
config['dflash_block_size'] = self.dflash.block_size
config['dflash_mask_token_id'] = self.dflash.mask_token_id
config['dflash_target_layer_ids'] = self.dflash.target_layer_ids
json.dump(config, f, ensure_ascii=False, indent=4)
return config_json
def imitate_quant(self):
def quant_dequant(linear, quant_bit = self.args.quant_bit, quant_block = self.args.quant_block):
weight = linear.weight.data
oc, ic = weight.shape
if quant_block == 0:
block_size = ic
else:
block_size = quant_block
block_num = ic // block_size
weight = weight.reshape(oc, block_num, block_size)
max_val = torch.max(weight, axis=-1, keepdims=True).values
min_val = torch.min(weight, axis=-1, keepdims=True).values
offset = 1 << (quant_bit - 1)
clip_max = offset - 1
clip_min = -offset
scale = (max_val - min_val) / (clip_max - clip_min)
q_weight = torch.round((weight - min_val) / scale) + clip_min
q_weight = torch.clip(q_weight, clip_min, clip_max)
dq_weight = (q_weight - clip_min) * scale + min_val
dq_weight = dq_weight.reshape(oc, ic).float()
linear.weight.data = dq_weight
return linear
with torch.no_grad():
for i in range(self.config.num_hidden_layers):
for name, child in self.model.blocks[i].self_attn.named_children():
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i].self_attn, name, quant_dequant(child))
for name, child in self.model.blocks[i].mlp.named_children():
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i].mlp, name, quant_dequant(child))
self.model.lm.lm = quant_dequant(self.model.lm.lm)
def unload_param(self):
self.unloaded_ops = {}
self.experts = []
self.expert_layer_ids = []
def build_faker(real, name):
faker = FakeLinear(real.in_features, real.out_features, real.bias is not None, name)
self.unloaded_ops[name] = real.cpu()
return faker
# replace linear with fakelinear to save export memory and time
with torch.no_grad():
for i in range(len(self.model.blocks)):
# different kv cache shape in different layers
# if isinstance(self.config.num_attention_heads, list):
self.model.blocks[i].self_attn.export_fused_attn = True
is_moe = hasattr(self.model.blocks[i].mlp, 'is_moe') and self.model.blocks[i].mlp.is_moe
if is_moe:
self.model.blocks[i].mlp.export_moe = True
for name, child in self.model.blocks[i].self_attn.named_children():
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i].self_attn, name, build_faker(child, f'/layers.{i}/self_attn/{name}/Linear'))
for name, child in self.model.blocks[i].mlp.named_children():
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i].mlp, name, build_faker(child, f'/layers.{i}/mlp/{name}/Linear'))
# PLE per-layer Linear layers (gemma4)
for name in ['per_layer_input_gate', 'per_layer_projection']:
child = getattr(self.model.blocks[i], name, None)
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i], name, build_faker(child, f'/layers.{i}/{name}/Linear'))
# shared_expert in MLP-level MoE
if is_moe and hasattr(self.model.blocks[i].mlp, 'shared_expert'):
for name, child in self.model.blocks[i].mlp.shared_expert.named_children():
if isinstance(child, torch.nn.Linear):
setattr(self.model.blocks[i].mlp.shared_expert, name, build_faker(child, f'/layers.{i}/mlp/shared_expert/{name}/Linear'))
# MLP-level MoE experts
if is_moe and hasattr(self.model.blocks[i].mlp, 'experts') and isinstance(self.model.blocks[i].mlp.experts, torch.nn.ModuleList):
self.experts.append(self.model.blocks[i].mlp.experts)
self.expert_layer_ids.append(i)
for j in range(len(self.model.blocks[i].mlp.experts)):
for name, cchild in self.model.blocks[i].mlp.experts[j].named_children():
if isinstance(cchild, torch.nn.Linear):
setattr(self.model.blocks[i].mlp.experts[j], name, build_faker(cchild, f'/expert/{i}_{j}/{name}'))
# gemma4 decoder-level MoE (parallel to dense MLP)
has_gemma4_moe = getattr(self.model.blocks[i], 'has_gemma4_moe', False)
if has_gemma4_moe:
self.model.blocks[i].export_moe = True
# Unload moe_gate Linear
child = self.model.blocks[i].moe_gate
if isinstance(child, torch.nn.Linear):
self.model.blocks[i].moe_gate = build_faker(child, f'/layers.{i}/moe_gate/Linear')
# Unload experts
self.experts.append(self.model.blocks[i].experts)
self.expert_layer_ids.append(i)
for j in range(len(self.model.blocks[i].experts)):
for name, cchild in self.model.blocks[i].experts[j].named_children():
if isinstance(cchild, torch.nn.Linear):
setattr(self.model.blocks[i].experts[j], name, build_faker(cchild, f'/expert/{i}_{j}/{name}'))
self.model.lm.lm = build_faker(self.model.lm.lm, f'/lm/lm_head/Linear')
# PLE model-level Linear (gemma4)
if hasattr(self.model, 'per_layer_model_projection') and isinstance(self.model.per_layer_model_projection, torch.nn.Linear):
self.model.per_layer_model_projection = build_faker(self.model.per_layer_model_projection, f'/per_layer_model_projection/Linear')
@spinner_run(f'export model weight to ')
def onnx_load_param(self, onnx_path):
return OnnxRebuilder(onnx_path, self.unloaded_ops).rebuild()
@spinner_run(f'slim the graph of ')
def slim_onnx(self, onnx_model):
import onnxslim
model = onnxslim.slim(onnx_model)
onnx.save(model, onnx_model)
return onnx_model
@spinner_run(f'export onnx model to ')
def export_onnx(self):
# unload linear weight to save export memory
self.unload_param()
# move entire model to CPU to free GPU memory for quantization
self.model.cpu()
if torch.cuda.is_available():
torch.cuda.empty_cache()
model = self.model
seq_len = 3
new_tokens = 0
input_ids = torch.arange(seq_len, dtype=torch.long)
attention_mask = model.get_attention_mask(seq_len, new_tokens)
position_ids = model.get_position_ids(seq_len, new_tokens, input_ids)
onnx_model = f'{self.onnx_path}/{self.dst_name}.onnx'
# For export onnx, don't need image or audio's embedding
input_ids = model.embedding(input_ids)
logits_index = torch.tensor([-1], dtype=torch.int32)
if hasattr(model, 'talker') and model.talker is not None:
output_names = ['logits', 'hidden_states', 'talker_embeds']
else:
output_names = ['logits', 'hidden_states']
# Qwen3-VL
if self.model_type in ['qwen3_vl', 'qwen3_vl_moe']:
# add deepstack_embeds input
deepstack_embeds = torch.randn(3, 1, self.config.hidden_size)
onnx_export(
model, (input_ids, attention_mask, position_ids, logits_index, deepstack_embeds),
onnx_model,
input_names=[
'input_ids', 'attention_mask', 'position_ids', 'logits_index', 'deepstack_embeds'
],
output_names=output_names,
dynamic_axes=self.model_dynamic_axes)
return onnx_model
# gemma4: add ple_embeddings + text_embeds_for_ple for PLE (Per-Layer Embeddings)
if hasattr(model, 'embed_tokens_per_layer') and model.embed_tokens_per_layer is not None:
raw_ids = torch.arange(seq_len, dtype=torch.long).unsqueeze(0)
ple_embeddings = model.embed_tokens_per_layer(raw_ids)
self.model_dynamic_axes['ple_embeddings'] = {1: 'seq_len'}
onnx_export(
model, (input_ids, attention_mask, position_ids, logits_index, None, ple_embeddings),
onnx_model,
input_names=[
'input_ids', 'attention_mask', 'position_ids', 'logits_index', 'ple_embeddings'
],
output_names=output_names,
dynamic_axes=self.model_dynamic_axes)
return onnx_model
# export to onnx
onnx_export(
model, (input_ids, attention_mask, position_ids, logits_index),
onnx_model,
input_names=[
'input_ids', 'attention_mask', 'position_ids', 'logits_index'
],
output_names=output_names,
dynamic_axes=self.model_dynamic_axes)
return onnx_model
def awq_quant(self):
self.awq_quantizer = AwqQuantizer(self.model)
self.awq_quantizer.quantize()
def omni_quant(self):
default_samples = 128
total_lines = default_samples
if self.args.calib_data:
print(f"检测到 calib_data: {self.args.calib_data},开始读取...")
self.model.args.calib_data = self.args.calib_data
if os.path.exists(self.args.calib_data):
with open(self.args.calib_data, 'r', encoding='utf-8') as f:
# 统计总行数
total_lines = sum(1 for _ in f)
else:
print(f"错误:找不到文件 {self.args.calib_data}")
calib_samples = min(total_lines, default_samples)
print(f"OmniQuant 将使用 {calib_samples} 个样本进行优化 (Epochs={getattr(self.args, 'omni_epochs', 20)})...")
self.omni_quantizer = OmniQuantizer(
model=self.model,
max_calib_samples=calib_samples,
act_bit=self.args.act_bit,
act_sym=self.args.act_sym,
generate_for_npu=self.args.generate_for_npu,
epochs=getattr(self.args, 'omni_epochs', 20),
lr=getattr(self.args, 'omni_lr', 5e-3),
wd=getattr(self.args, 'omni_wd', 1e-4)
)
self.omni_quantizer.quantize(self.args.generate_for_npu)
def smooth_quant(self):
total_lines = 128
if self.args.calib_data:
print(f"检测到 calib_data: {self.args.calib_data},开始读取...")
self.model.args.calib_data = self.args.calib_data
if os.path.exists(self.args.calib_data):
with open(self.args.calib_data, 'r', encoding='utf-8') as f:
total_lines = sum(1 for _ in f)
else:
print(f"错误:找不到文件 {self.args.calib_data}")
calib_samples = min(total_lines, 128)
self.smooth_quantizer = SmoothQuantizer(model = self.model, max_calib_samples = calib_samples, act_bit=self.args.act_bit, act_sym=self.args.act_sym, generate_for_npu=self.args.generate_for_npu)
self.smooth_quantizer.quantize()
def export_vision(self):
if self.visual is None:
return
vision_onnx = self.visual.export(self.onnx_path)
if self.mnn_converter:
fuse_transformer = self.visual.transformer_fuse
native_group_conv = self.visual.group_conv_native
quant_bit_visual = self.visual.quant_bit
quant_block_visual = self.visual.quant_block
if self.args.transformer_fuse:
fuse_transformer = True
if self.args.group_conv_native:
native_group_conv = True
if self.args.visual_quant_bit is not None:
quant_bit_visual = self.args.visual_quant_bit
if self.args.visual_quant_block is not None:
quant_block_visual = self.args.visual_quant_block
self.mnn_converter.export(vision_onnx, quant_bit_visual,
quant_block_visual,
transformer_fuse=fuse_transformer,
group_conv_native=native_group_conv,
weight_sym=self.args.visual_sym)
def export_audio(self):
if self.audio is None:
return
audio_onnx = self.audio.export(self.onnx_path)
if self.mnn_converter: self.mnn_converter.export(audio_onnx, self.audio.quant_bit)
def export_talker(self):
if self.talker is None:
return
talker_onnx = self.talker.export(self.onnx_path)
predit_onnx, dit_onnx, bigvgan_onnx = self.talker.token2wav.export(self.onnx_path)
if self.mnn_converter:
self.mnn_converter.export(talker_onnx, self.talker.quant_bit)
self.mnn_converter.export(predit_onnx, self.talker.token2wav.quant_bit)
self.mnn_converter.export(dit_onnx, self.talker.token2wav.quant_bit)
self.mnn_converter.export(bigvgan_onnx, self.talker.token2wav.quant_bit)
def export_ple_embed(self):
"""Export Per-Layer Embedding weights for gemma4."""
import ctypes
from utils.torch_utils import quant as torch_quant
if not hasattr(self.model, 'embed_tokens_per_layer') or self.model.embed_tokens_per_layer is None:
return
embed = self.model.embed_tokens_per_layer
tensor_data = embed.weight.data
embed_scale = getattr(embed, 'scalar_embed_scale', 1.0)
format_bit = getattr(self.args, 'embed_bit', 16)
quant_block = getattr(self.args, 'quant_block', 64)
symmetric = getattr(self.args, 'sym', False)
if format_bit in [4, 8]:
awq = getattr(self.args, 'awq', False)
hqq = getattr(self.args, 'hqq', False)
q_weight, alpha = torch_quant(tensor_data.float(), format_bit, quant_block, symmetric, awq, hqq)
format_name = f'int{format_bit}'
ple_file = f'{self.args.dst_path}/ple_embeddings_{format_name}.bin'
with open(ple_file, 'wb') as f:
weight_size = f.write(q_weight.numpy().tobytes())
alpha_size = f.write(alpha.numpy().tobytes())
self.llm_config['ple_embed_file'] = f'ple_embeddings_{format_name}.bin'
self.llm_config['ple_quant'] = [0, weight_size, alpha_size, format_bit, quant_block]
else:
tensor_data = tensor_data.bfloat16()
data_ptr = tensor_data.untyped_storage().data_ptr()
buffer = (ctypes.c_byte * (tensor_data.numel() * 2)).from_address(data_ptr)
ple_file = f'{self.args.dst_path}/ple_embeddings_bf16.bin'
with open(ple_file, 'wb') as f:
f.write(buffer)
self.llm_config['ple_embed_file'] = 'ple_embeddings_bf16.bin'
self.llm_config['ple_embed_scale'] = embed_scale
self.llm_config['ple_embed_dim'] = embed.embedding_dim
def export_language(self):
# export_embedding
if self.mnn_converter and self.args.tie_word_embeddings:
pass # mnn tie_word_embeddings need't export embedding
else:
self.export_embed()
# export PLE embedding (gemma4)
self.export_ple_embed()
# export transformer
onnx_model = self.export_onnx()
if self.args.onnx_slim:
self.slim_onnx(onnx_model)
if self.mnn_converter:
tie_embeddings_info = MNNConverter(self, self.unloaded_ops).export(onnx_model)
if tie_embeddings_info is not None:
self.llm_config['tie_embeddings'] = tie_embeddings_info
else:
self.onnx_load_param(onnx_model)
def export(self, export_type):
if not self.args.skip_weight:
if self.args.omni:
self.omni_quant()
if self.args.awq:
self.awq_quant()
if self.args.smooth:
self.smooth_quant()
export_mnn = export_type == 'mnn'
self.mnn_converter = MNNConverter(self) if export_mnn else None
self.export_talker()
self.export_vision()
self.export_audio()
self.export_eagle()
self.export_dflash()
self.export_language()
self.export_mtp()
self.export_tokenizer()
self.export_config(export_mnn)
if export_mnn:
# delete onnx file
try:
for file in glob.glob(f'{self.onnx_path}/*'):
os.remove(file)
os.rmdir(self.onnx_path)
except Exception as e:
print(f"remove onnx error: {e}")
@spinner_run(f'export tokenizer to ')
def export_tokenizer(self):
return self.tokenizer.export(self.args.dst_path)
class EmbeddingExporter(LlmExporter):
def __init__(self, args):
super().__init__(args)
def response(self, query):
self.model.eval()
prompt = self.build_prompt(query)
input_ids = self.tokenizer(prompt)['input_ids']
seq_len = len(input_ids)
input_ids = torch.tensor(input_ids)
position_ids = self.model.get_position_ids(seq_len)
attention_mask = self.model.get_attention_mask(seq_len)
inputs_embeds = self.model.word_embed(input_ids)
res = self.model.forward(inputs_embeds, attention_mask, position_ids)
print(res, res.shape)
return res
def build_prompt(self, content):
if self.config.model_type == 'bert':
return f'[CLS]{content}[SEP]'
if self.config.model_type == 'new':
return f'<s> {content}</s>'
if self.config.model_type == 'qwen3':
return f'{content}<|endoftext|>'
@spinner_run(f'load pretrained model ', True)
def load_model(self, model_path):
self.model = EmbeddingModel.from_pretrained(model_path, args=self.args)
self.config = self.model.config
self.model_type = self.config.model_type
self.tokenizer = LlmTokenizer(model_path, self.model_type)
self.llm_config = {
'model_type': self.config.model_type,
'hidden_size' : self.config.hidden_size,
# qwen3 embedding is a causal decoder (last-token pooling) and needs a
# causal mask; bert/gte encoders are bidirectional and use the all-ones
# ('int') mask. Using 'int' for qwen3 makes attention bidirectional and
# degrades the embeddings (see issue: identical/low-quality vectors).
'attention_mask': 'float' if self.config.model_type == 'qwen3' else 'int',
"jinja": {
"chat_template": self.build_prompt("{{ messages | map(attribute='content') | join('') }}")
},
'is_visual': False
}
return model_path
def export_reranker(self):
seq_len = 4
input_ids = torch.arange(12, dtype=torch.long)
position_ids = self.model.get_position_ids(seq_len)
attention_mask = self.model.get_attention_mask(seq_len)
inputs_embeds = self.model.word_embed(input_ids)
inputs_embeds = inputs_embeds.reshape(3, 4, self.config.hidden_size)
attention_mask = torch.zeros(3, 1, 1, 4, dtype=torch.float)
onnx_model = f'{self.onnx_path}/{self.dst_name}.onnx'
onnx_export(
self.model, (inputs_embeds, attention_mask, position_ids),
onnx_model,
input_names=[
'input_ids',
'attention_mask',
'position_ids'
],
output_names=['sentence_embeddings'],
dynamic_axes={
"input_ids" : { 0: "batch", 1: "seq_len" },
"position_ids" : { 1: "seq_len" },
"attention_mask" : { 0: "batch", 3: "seq_len" }
})
return onnx_model
@spinner_run(f'export onnx model to ')
def export_onnx(self):
if self.model_type == 'qwen3':
self.unload_param()
else:
self.unloaded_ops = None
if self.model.is_reranker:
return self.export_reranker()
seq_len = 3
input_ids = torch.arange(seq_len, dtype=torch.long)
position_ids = self.model.get_position_ids(seq_len)
attention_mask = self.model.get_attention_mask(seq_len)
inputs_embeds = self.model.word_embed(input_ids)
onnx_model = f'{self.onnx_path}/{self.dst_name}.onnx'
onnx_export(
self.model, (inputs_embeds, attention_mask, position_ids),
onnx_model,
input_names=[
'input_ids',
'attention_mask',
'position_ids'
],
output_names=['sentence_embeddings'],
dynamic_axes={
"input_ids" : { 1: "seq_len" },
"position_ids" : { 1: "seq_len" },
"attention_mask" : { 2: "seq_len", 3: "seq_len" }
})
return onnx_model
def export(self, export_type):
export_mnn = 'mnn' in export_type
self.export_tokenizer()
self.export_embed()
self.export_config(export_mnn)
onnx_model = self.export_onnx()
if self.args.onnx_slim:
self.slim_onnx(onnx_model)
if export_mnn:
transformer_fuse = not self.model.is_reranker
tie_embeddings_info = MNNConverter(self, self.unloaded_ops).export(onnx_model, transformer_fuse=transformer_fuse)
if tie_embeddings_info is not None:
self.llm_config['tie_embeddings'] = tie_embeddings_info
# delete onnx file
try:
for file in glob.glob(f'{self.onnx_path}/*'):
os.remove(file)
os.rmdir(self.onnx_path)
except Exception as e:
print(f"remove onnx error: {e}")
def build_args(parser):
parser.add_argument('--path', type=str, required=True,
help='path(`str` or `os.PathLike`):\nCan be either:'
'\n\t- A string, the *model id* of a pretrained model like `THUDM/chatglm-6b`. [TODO]'
'\n\t- A path to a *directory* clone from repo like `../chatglm-6b`.')
parser.add_argument('--type', type=str, default=None,
help='type(`str`, *optional*):'
'\n\tThe pretrain llm model type.'
)
parser.add_argument('--tokenizer_path', type=str, default=None, help='tokenizer path, default is `None` mean using `--path` value.')
parser.add_argument('--eagle_path', type=str, default=None, help='eagle model path, default is `None`')
parser.add_argument('--dflash_path', type=str, default=None, help='dflash draft model path, default is `None`')
parser.add_argument('--lora_path', type=str, default=None, help='lora path, default is `None` mean not apply lora.')
parser.add_argument('--gptq_path', type=str, default=None, help='gptq path, default is `None` mean not apply gptq.')
parser.add_argument('--dst_path', type=str, default='./model', help='export onnx/mnn model to path, default is `./model`.')
parser.add_argument('--verbose', action='store_true', help='Whether or not to print verbose.')
parser.add_argument('--test', type=str, help='test model inference with query `TEST`.')
parser.add_argument('--export', type=str, default=None, help='export model to an onnx/mnn model.')
parser.add_argument('--onnx_slim', action='store_true', help='Whether or not to use onnx-slim.')
parser.add_argument('--quant_bit', type=int, default=4, help='mnn quant bit, 2/3/4/8 (2 and 3 require ARMV86 i8mm + FP16), default is 4.')
parser.add_argument('--quant_block', type=int, default=64, help='mnn quant block, 0 mean channel-wise, default is 64.')
parser.add_argument('--visual_quant_bit', type=int, default=None, help='mnn visual quant bit, 4 or 8, default is setting in utils/vision.py by different vit model.')
parser.add_argument('--visual_quant_block', type=int, default=None, help='mnn quant block, default is setting in utils/vision.py by different vit model.')
parser.add_argument('--lm_quant_bit', type=int, default=None, help='mnn lm_head quant bit, 4 or 8, default is `quant_bit`.')
parser.add_argument('--lm_quant_block', type=int, default=None, help='mnn lm_head quant block, 0 mean channle-wise, default is `quant_block`.')
parser.add_argument('--mnnconvert', type=str, default='../../../build/MNNConvert', help='local mnnconvert path, if invalid, using pymnn.')
parser.add_argument('--ppl', action='store_true', help='Whether or not to get all logits of input tokens.')
parser.add_argument('--awq', action='store_true', help='Whether or not to use awq quant.')
parser.add_argument('--hqq', action='store_true', help='Whether or not to use hqq quant.')
parser.add_argument('--omni', action='store_true', help='Whether or not to use omni quant.')
parser.add_argument('--transformer_fuse', action='store_true', help='Whether or not to fuse vision transformer op.')
parser.add_argument('--enable_transformer_c4', dest='transformer_c4', action='store_true', default=False,
help='Whether or not to enable LLM C4 graph fusion.')
parser.add_argument('--group_conv_native', action='store_true', help='Whether or not to keep native group_conv.')
parser.add_argument('--smooth', action='store_true', help='Whether or not to use smooth quant.')
parser.add_argument('--sym', action='store_true', help='Whether or not to using symmetric quant (without zeropoint), default is False.')
parser.add_argument('--scale_bit', type=int, default=16, choices=[16, 32], help='Bit-width for quant scale/zero-point storage. Currently supports 16 (fp16, default) and 32 (fp32); 8/4 reserved for future.')
parser.add_argument('--visual_sym', action='store_true', help='Whether or not to using symmetric quant (without zeropoint) for visual model, default is False.')
parser.add_argument('--seperate_embed', action='store_true', help='For lm and embed shared model, whether or not to sepearte embed to avoid quant, default is False, if True, embed weight will be seperate to embedding bf16.bin.')
parser.add_argument('--lora_split', action='store_true', help='Whether or not export lora split, default is False.')
parser.add_argument('--calib_data', type=str, default=None, help='calibration data path, default is `None` mean not use calib data.')
parser.add_argument('--act_bit', type=int, default=16, help='smooth quant act bit, 8 or 16, default is 16.')
parser.add_argument('--embed_bit', type=int, default=16, choices=[16, 8, 4], help='embedding export bit precision, choices are 16 (bf16), 8 (int8), 4 (int4), default is 16.')
parser.add_argument('--act_sym', action='store_true', help='smooth quant act us sym or not, default asym.')
parser.add_argument('--quant_config', type=str, default=None, help='path to the JSON file for op-wise quantization configuration.')
parser.add_argument('--generate_for_npu', action='store_true', help='Whether or not to generate model for NPU deployment, default is False.')
parser.add_argument('--skip_weight', action='store_true', help='Whether or not to skip loading model weights, useful for testing export flow.')
# omni quant
parser.add_argument('--omni_epochs', type=int, default=20, help='OmniQuant 优化的轮数')
parser.add_argument('--omni_lr', type=float, default=5e-3, help='OmniQuant 的学习率')
parser.add_argument('--omni_wd', type=float, default=1e-4, help='OmniQuant 的权重衰减')
def export(path, **kwargs):
parser = argparse.ArgumentParser()
build_args(parser)
args = parser.parse_args(['--path', path])
for k, v in kwargs.items():
setattr(args, k, v)
if 'bge' in path:
llm_exporter = EmbeddingExporter(args)
else:
llm_exporter = LlmExporter(args)
# export
llm_exporter.export(args.export)
def main():
parser = argparse.ArgumentParser(description='llm_exporter', formatter_class=argparse.RawTextHelpFormatter)
build_args(parser)
args = parser.parse_args()
model_path = args.path
embedding_models = ['bge', 'gte', 'Qwen3-Embedding']
if any(model in model_path for model in embedding_models):
llm_exporter = EmbeddingExporter(args)
else:
llm_exporter = LlmExporter(args)
# some actions
if args.test is not None:
llm_exporter.response(args.test)
if args.export is not None:
llm_exporter.export(args.export)
if __name__ == '__main__':
main()