一、概要
内容包含三部分:一是可直接运行的反量化代码实现(将FP8量化权重转换为FP16);二是反量化过程中核心技术原理的解析;三是实操过程中遇到的问题及优化建议。
二、反量化代码实现与流程
反量化代码的核心目标是将存储于safetensors文件的FP8量化权重,通过手动处理转换为FP16精度权重,同时清理量化配置、保留模型必要文件,最终生成可正常加载和推理的FP16模型。
python
代码示例:
from transformers import AutoTokenizer, AutoConfig
import torch
import os
import json
from safetensors.torch import load_file, save_file
from collections import OrderedDict
import shutil
model_path = "/data/workspace/models/tencent/HY-MT1.5-1.8B-FP8"
fp16_model_path = "./hunyuan_fp16_clean"
def check_transformers_support():
"""检查 transformers 库对混元模型的支持"""
import transformers
print(f"Transformers 版本: {transformers.__version__}")
try:
from transformers.models.hunyuan_v1_dense.modeling_hunyuan_v1_dense import HunYuanDenseV1ForCausalLM
print(f"✓ HunYuanDenseV1ForCausalLM 可导入")
return True
except Exception as e:
print(f"✗ 导入失败: {e}")
return False
def dequantize_fp8_to_fp16(model_path: str, output_path: str):
"""手动将 FP8 量化权重转换为 FP16"""
print(f"从 {model_path} 加载并反量化...")
os.makedirs(output_path, exist_ok=True)
# 1. 加载并清理配置文件
config_path = os.path.join(model_path, "config.json")
with open(config_path, "r") as f:
config = json.load(f)
print(f"原始 model_type: {config.get('model_type')}")
# 移除量化配置
if "quantization_config" in config:
print("移除 quantization_config...")
del config["quantization_config"]
config["torch_dtype"] = "float16"
output_config_path = os.path.join(output_path, "config.json")
with open(output_config_path, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print(f"保存配置到: {output_config_path}")
# 2. 复制其他必要文件
copy_extensions = ['.json', '.txt', '.model', '.py', '.tiktoken', '.vocab']
for filename in os.listdir(model_path):
if filename == 'config.json':
continue
src = os.path.join(model_path, filename)
if os.path.isfile(src):
should_copy = any(filename.endswith(ext) for ext in copy_extensions)
if should_copy:
dst = os.path.join(output_path, filename)
shutil.copy2(src, dst)
print(f"复制: {filename}")
# 3. 查找所有 safetensors 文件
safetensor_files = sorted([f for f in os.listdir(model_path) if f.endswith('.safetensors')])
if not safetensor_files:
raise FileNotFoundError("未找到 .safetensors 文件")
print(f"\n找到 {len(safetensor_files)} 个 safetensors 文件")
# 4. 收集所有 scale 张量
all_scales = {}
print("\n第一遍: 收集所有 scale 张量...")
for sf_file in safetensor_files:
tensors = load_file(os.path.join(model_path, sf_file))
for name, tensor in tensors.items():
if 'scale' in name.lower():
all_scales[name] = tensor
print(f" 找到 scale: {name} shape={tensor.shape}")
print(f"共找到 {len(all_scales)} 个 scale 张量")
# 5. 处理每个文件
weight_map = {}
total_size = 0
print("\n第二遍: 反量化权重...")
for sf_idx, sf_file in enumerate(safetensor_files):
print(f"\n处理 [{sf_idx+1}/{len(safetensor_files)}]: {sf_file}")
tensors = load_file(os.path.join(model_path, sf_file))
converted_tensors = OrderedDict()
fp8_count = 0
other_count = 0
for name, tensor in tensors.items():
if 'scale' in name.lower():
continue
original_dtype = tensor.dtype
is_fp8 = original_dtype in [torch.float8_e4m3fn, torch.float8_e5m2]
if is_fp8:
fp8_count += 1
possible_scale_names = [
name.replace('.weight', '.weight_scale'),
name + '_scale',
f"{name}_scale",
]
scale = None
for scale_name in possible_scale_names:
if scale_name in all_scales:
scale = all_scales[scale_name]
break
if scale is not None:
weight_fp32 = tensor.to(torch.float32) * scale.to(torch.float32)
converted_tensors[name] = weight_fp32.to(torch.float16)
else:
converted_tensors[name] = tensor.to(torch.float16)
print(f" ⚠ 无scale: {name}")
else:
other_count += 1
if original_dtype in [torch.float32, torch.bfloat16]:
converted_tensors[name] = tensor.to(torch.float16)
else:
converted_tensors[name] = tensor
print(f" FP8 张量: {fp8_count}, 其他张量: {other_count}")
if len(safetensor_files) == 1:
out_filename = "model.safetensors"
else:
out_filename = sf_file
output_file = os.path.join(output_path, out_filename)
save_file(converted_tensors, output_file)
file_size = os.path.getsize(output_file)
total_size += file_size
for name in converted_tensors.keys():
weight_map[name] = out_filename
print(f" 保存: {output_file} ({file_size / 1024 / 1024:.1f} MB)")
# 6. 创建 index 文件
if len(safetensor_files) > 1:
index = {
"metadata": {"total_size": total_size},
"weight_map": weight_map
}
index_path = os.path.join(output_path, "model.safetensors.index.json")
with open(index_path, "w") as f:
json.dump(index, f, indent=2)
print(f"\n创建索引: {index_path}")
print(f"\n{'='*50}")
print(f"✓ 反量化完成!")
print(f" 输出目录: {output_path}")
print(f" 总大小: {total_size / 1024 / 1024 / 1024:.2f} GB")
print(f"{'='*50}")
return output_path
def verify_model(model_path: str):
"""验证反量化后的模型"""
print("\n" + "="*50)
print("验证模型加载...")
print("="*50)
# 显式导入模型类
from transformers.models.hunyuan_v1_dense.modeling_hunyuan_v1_dense import HunYuanDenseV1ForCausalLM
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_path, trust_remote_code=True)
print(f"Config 类型: {type(config).__name__}")
model = HunYuanDenseV1ForCausalLM.from_pretrained(
model_path,
config=config,
torch_dtype=torch.float16,
device_map="cpu",
low_cpu_mem_usage=True,
)
print(f"\n✓ 模型加载成功!")
print(f" 类型: {type(model).__name__}")
print(f" 参数量: {sum(p.numel() for p in model.parameters()):,}")
# 检查权重 dtype
print("\n 权重类型检查 (前5个):")
for name, param in list(model.named_parameters())[:5]:
print(f" {name}: {param.dtype}")
# 检查是否还有 FP8 权重
fp8_params = []
for name, param in model.named_parameters():
if param.dtype in [torch.float8_e4m3fn, torch.float8_e5m2]:
fp8_params.append(name)
if fp8_params:
print(f"\n ⚠ 仍有 {len(fp8_params)} 个 FP8 参数!")
else:
print(f"\n ✓ 无 FP8 参数,全部已转换")
# 推理测试
print("\n推理测试...")
try:
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
inputs = tokenizer("Hello", return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
print(f"✓ 推理测试通过! output shape: {outputs.logits.shape}")
except Exception as e:
print(f"⚠ 推理测试跳过: {e}")
del model
return True
if __name__ == "__main__":
print("="*50)
print("检查 Transformers 库支持")
print("="*50)
support_ok = check_transformers_support()
if not support_ok:
print("\n⚠ 导入检查失败,但继续尝试...")
# 执行反量化
clean_model_path = dequantize_fp8_to_fp16(model_path, fp16_model_path)
# 验证
try:
verify_model(clean_model_path)
print(f"\n{'='*50}")
print(f"✓ 全部完成! 可以导出 ONNX")
print(f" 模型路径: {clean_model_path}")
print(f"{'='*50}")
except Exception as e:
print(f"✗ 验证失败: {e}")
import traceback
traceback.print_exc()
2.1 前置检查:Transformers库支持校验
代码首先通过check_transformers_support函数校验当前Transformers库是否支持混元模型(HunYuanDenseV1ForCausalLM)。该步骤为基础前置操作,因反量化后模型的加载与验证依赖于库对模型类的支持,若导入失败会给出提示但仍尝试继续执行。
2.2 反量化执行(dequantize_fp8_to_fp16函数)
1.配置文件清理与重构:读取原始模型的config.json,移除量化相关的"quantization_config"字段,将"torch_dtype"指定为"float16",并保存到输出目录。此操作目的是消除量化配置对后续模型加载的影响,明确目标精度。
2.必要文件复制:筛选并复制原始模型中除config.json外的关键文件,确保模型加载所需的词典、词表等组件完整。
3.Scale张量收集:遍历所有safetensors文件,收集名称中包含"scale"的张量至all_scales字典。Scale张量是量化过程中记录的缩放因子,是反量化恢复原始权重的核心依赖,需提前全局收集以便后续匹配。
4.权重反量化转换:这是核心步骤,分两遍处理safetensors文件:第一遍收集Scale张量,第二遍对每个权重张量执行反量化。对于FP8类型的权重,通过命名规则推导匹配对应的Scale张量,执行反量化计算;对于非FP8类型权重,直接转换为FP16精度。
5.结果保存:根据原始safetensors文件数量,生成对应的输出文件(单文件直接命名为model.safetensors,多文件保留原命名),并记录权重与文件的映射关系。
6.索引文件创建:若存在多个safetensors文件,生成model.safetensors.index.json索引文件,记录总文件大小和权重映射关系,保障模型正确加载。
2.3 后置验证:模型有效性校验
通过verify_model函数验证反量化后模型的可用性:加载模型配置与模型本身,检查权重精度是否均为非FP8类型,执行简单推理测试,确保模型可正常运行。验证通过后,模型可用于后续ONNX导出等任务。
三、量化与反量化的底层逻辑
3.1 量化与反量化的数学关系
量化的本质是通过"缩放"将高精度权重(FP32)压缩为低精度(FP8)存储,反量化则是通过反向"缩放"恢复原始权重范围,核心数学关系如下:
•量化过程(FP32 → FP8):fp8_weight = fp32_weight / scale,通过除以缩放因子scale,将大范围FP32值压缩到FP8可表示的范围。
•反量化过程(FP8 → FP32):fp32_weight = fp8_weight * scale,通过乘以相同的scale,恢复原始FP32权重的数值范围。
3.2 代码与原理的映射:关键反量化计算步骤
代码中核心反量化计算语句weight_fp32 = tensor.to(torch.float32) * scale.to(torch.float32),严格遵循上述原理,同时引入"高精度中间态"保障精度,具体拆解如下:
| 步骤 | 代码操作 | 精度转换 | 原理依据与目的 |
|---|---|---|---|
| 1 | tensor.to(torch.float32) |
FP8 → FP32 | 将低精度 FP8 权重转换为高精度 FP32,为后续乘法提供足够精度储备 |
| 2 | scale.to(torch.float32) |
原精度 → FP32 | 确保缩放因子与权重处于同一高精度维度,避免乘法过程中精度丢失或溢出 |
| 3 | 二者相乘 | FP32 × FP32 | 遵循反量化原理,通过乘法恢复原始 FP32 权重范围,高精度计算保障结果准确 |
| 4 | weight_fp32.to(torch.float16) |
FP32 → FP16 | 将恢复后的高精度权重转换为目标 FP16 精度,平衡存储效率与推理精度 |
补充说明:选择"FP8→FP32→FP16"的迂回转换而非直接"FP8→FP16",核心原因是FP8动态范围小,直接与scale相乘可能超出表示范围导致溢出;而FP32拥有23位尾数,能提供更高的计算精度,避免中间过程的精度损失。
3.3 Scale张量的匹配机制
反量化的前提是为每个FP8权重找到对应的Scale张量,代码采用全局收集+命名规则推导的匹配策略,确保Scale与权重精准对应,具体逻辑如下:
1.全局收集:第一遍遍历所有safetensors文件,将名称包含"scale"的张量统一存入all_scales字典,建立Scale张量的全局索引。
2.规则推导:第二遍处理权重时,针对每个权重名称(如"model.layers.0.self_attn.q_proj.weight"),通过命名规则生成可能的Scale名称(核心规则:将".weight"替换为".weight_scale")。
3.精准匹配:在all_scales字典中查找推导生成的Scale名称,找到则获取对应的Scale张量,未找到则给出警告并直接将FP8权重转换为FP16。
该策略的核心依据是FP8量化的通用命名约定(xxx.weight对应xxx.weight_scale),通过字符串替换实现高效匹配,同时兼顾了通用性和易用性。
四、量化相关基础认知与问题解决
4.1 量化Scale命名的行业现状
需明确的是,当前行业内量化Scale的命名无统一标准,不同框架/格式的命名规则存在差异,这也是代码中采用"包含scale字符串"初步筛选的原因。常见框架的Scale命名示例如下:
| 框架/格式 | Scale 命名示例 |
|---|---|
| ONNX QDQ | xxx_scale, xxx_QuantizeLinear_scale |
| PyTorch (FX) | weight_scale, activation_post_process_scale |
| HuggingFace (GPTQ/AWQ) | scales, qscales |
| FP8 格式 | xxx_scale, input_scale, weight_scale |
基于此现状,代码中基础筛选逻辑(if 'scale' in name.lower())的优势是通用性强,能覆盖大多数情况,但可能误匹配(如"rescale_layer""upscale"等含"scale"的非Scale张量)。
4.2 实操问题与优化建议
4.2.1 已遇问题:Torch与Torchvision版本不匹配
实操中遇到的核心问题是:使用官方指令安装的PyTorch 2.5.1版本与Torchvision版本不匹配,导致模型推理加载失败。即便模型不涉及视觉功能,Transformers库在加载时仍会校验Torchvision依赖,使用mock vision绕过校验会因库版本较新而失败。
4.2.2 量化与反量化优化建议
•量化实现优化:文档中反量化采用手动逐层处理方式,若需提升效率,可使用llm-compressor量化包实现快捷量化。
•模型配置检查:量化/反量化前需重点检查模型config.json,确认"architectures"为[HunYuanDenseV1ForCausalLM]、"model_type"为"hunyuan_v1_dense",避免因配置不匹配导致加载失败。
•Scale筛选优化:针对基础筛选逻辑的误匹配问题,可采用更精确的匹配策略,通过预设Scale模式(如"_scale""/.scale""scales"等)并排除误匹配关键词(如"rescale""upscale")提升筛选准确性。
•通用处理流程:处理未知量化格式模型时,建议遵循"探索模型结构→识别量化格式→针对性提取Scale"的流程:先打印所有张量名称和形状,根据命名模式判断量化类型(GPTQ/AWQ/FP8等),再编写对应提取逻辑。