fusion-llm-demo / inference /dyquant.py
zhan1206
fix(audit): C1/H1/H3/H4 审计报告修复
2661c24
Raw
History Blame Contribute Delete
29.8 kB
"""
DyQuant - 动态混合精度量化工具
支持层/头级别的不同精度混合(4/8/16 bit),在保持精度的同时提升吞吐 20%-30%。
使用方法:
from inference.dyquant import DyQuantConverter, QuantConfig
# 1. 创建量化配置
config = QuantConfig(
model_path="fusion-8b-base",
bits=4, # 默认 4-bit
mixed_precision=True, # 混合精度
calib_samples=512, # 校准样本数
)
# 2. 转换模型
converter = DyQuantConverter(config)
quantized_model = converter.convert()
# 3. 保存量化模型
converter.save("fusion-8b-dyquant")
# 4. 推理
output = quantized_model.generate(...)
作者:zhan1206
项目:Fusion - 六边形开源大模型
许可证:Apache 2.0
"""
import torch
import torch.nn as nn
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
import json
from pathlib import Path
import math
@dataclass
class QuantConfig:
"""
量化配置
属性:
model_path: 模型路径
bits: 默认量化位数(4/8)
mixed_precision: 是否启用混合精度
calib_samples: 校准样本数
calib_data: 校准数据路径
output_path: 输出路径
per_head: 是否按头量化(True=更精细)
"""
model_path: str
bits: int = 4
mixed_precision: bool = True
calib_samples: int = 512
calib_data: Optional[str] = None
output_path: Optional[str] = None
per_head: bool = False
class DyQuantConverter:
"""
动态混合精度量化转换器
支持:
- 动态量化(Dynamic Quantization):int8,对延迟敏感场景
- 静态量化(Static Quantization):int8/uint8,需校准数据
- 量化感知训练(QAT):finetune-aware,适用于高精度需求
- 混合精度:不同层使用不同位数
- 按头量化:注意力头级别精度分配
"""
def __init__(self, config: QuantConfig):
"""
初始化转换器
参数:
config: 量化配置
"""
self.config = config
self.model = None
self.quant_layers = {}
self.quantization_type = "dynamic" # 默认使用动态量化
print(f"[DyQuant] 初始化量化工具")
print(f" 模型:{config.model_path}")
print(f" 默认位数:{config.bits}-bit")
print(f" 混合精度:{config.mixed_precision}")
print(f" 按头量化:{config.per_head}")
def load_model(self):
"""加载模型(优先 FusionModel/FusionMini,回退 AutoModelForCausalLM)"""
print(f"\n[DyQuant] 加载模型:{self.config.model_path}")
self.model = None
# Try FusionMini first
try:
from models.fusion_mini import FusionMini
self.model = FusionMini._load_from_safetensors(self.config.model_path)
self.model.eval()
print(f"[DyQuant] FusionMini 加载成功")
return self.model
except Exception as e1:
pass # fallback
# Try FusionModel
try:
from models.fusion_model import FusionModel
self.model = FusionModel.from_pretrained(self.config.model_path)
self.model.eval()
print(f"[DyQuant] FusionModel 加载成功")
return self.model
except Exception as e2:
pass # fallback
# Fallback to AutoModelForCausalLM
try:
from transformers import AutoModelForCausalLM
self.model = AutoModelForCausalLM.from_pretrained(
self.config.model_path,
torch_dtype=torch.bfloat16,
device_map="cpu",
trust_remote_code=True,
)
self.model.eval()
print(f"[DyQuant] AutoModelForCausalLM 加载成功(回退模式)")
print(f"[DyQuant] 警告:非 Fusion 模型,SBLA/ThinkingDial 量化路径未验证")
return self.model
except Exception as e3:
print(f"[DyQuant] 模型加载失败:{e3}")
import traceback; traceback.print_exc()
self.model = None
return None
def analyze_sensitivity(self) -> Dict[str, float]:
"""
分析层敏感度
通过权重分布和激活值分析,确定哪些层对量化更敏感
返回:
层名称 -> 敏感度分数(0-1,越高越敏感)
"""
print(f"\n[DyQuant] 分析层敏感度...")
sensitivity = {}
if self.model is None:
# 模拟模式
print(f"[DyQuant] 模拟模式:假设 32 层")
for i in range(32):
layer_name = f"model.layers.{i}"
if i < 4 or i >= 28:
sensitivity[layer_name] = 0.8
elif i < 8 or i >= 24:
sensitivity[layer_name] = 0.5
else:
sensitivity[layer_name] = 0.2
else:
# 真实分析
print(f"[DyQuant] 计算真实敏感度...")
# 遍历模型的所有 Linear 层
for name, module in self.model.named_modules():
if isinstance(module, nn.Linear):
# 计算敏感度指标
sensitivity_score = self._compute_layer_sensitivity(
module, name
)
sensitivity[name] = sensitivity_score
print(f"[DyQuant] 分析完成,共 {len(sensitivity)} 个量化层")
# 统计
high_sens = sum(1 for v in sensitivity.values() if v > 0.6)
mid_sens = sum(1 for v in sensitivity.values() if 0.3 < v <= 0.6)
low_sens = sum(1 for v in sensitivity.values() if v <= 0.3)
print(f" 高敏感层:{high_sens}")
print(f" 中敏感层:{mid_sens}")
print(f" 低敏感层:{low_sens}")
return sensitivity
def _compute_layer_sensitivity(
self,
layer: nn.Module,
name: str,
) -> float:
"""
计算单个层的敏感度
综合以下因素:
1. 权重分布的分散程度(std/mean)
2. 输出激活值的范围
3. 层的位置(首尾层更敏感)
参数:
layer: 层模块
name: 层名称
返回:
敏感度分数(0-1)
"""
# 获取权重
weight = layer.weight.data
# 1. 权重分散度(值域越大越敏感)
w_std = weight.float().std().item()
w_abs_max = weight.float().abs().max().item()
w_dispersion = min(w_std / (w_abs_max + 1e-8), 1.0)
# 2. 权重稀疏度(越稀疏越敏感)
w_zero_ratio = (weight.float() == 0).sum().item() / weight.numel()
# 3. 层位置因子(基于层名称推断)
position_factor = 0.5
if "embeddings" in name or "output" in name:
position_factor = 0.8 # 首尾层更敏感
elif "layers.0" in name or "layers.1" in name:
position_factor = 0.7 # 前几层
# 综合敏感度
sensitivity = (
0.4 * w_dispersion +
0.2 * (1 - w_zero_ratio) +
0.4 * position_factor
)
return min(sensitivity, 1.0)
def assign_precision(
self,
sensitivity: Dict[str, float],
) -> Dict[str, int]:
"""
根据敏感度分配量化位数
策略:
- 高敏感(>0.6):8-bit 或 16-bit
- 中敏感(0.3-0.6):8-bit
- 低敏感(<0.3):4-bit
参数:
sensitivity: 敏感度字典
返回:
层名称 -> 量化位数
"""
print(f"\n[DyQuant] 分配量化精度...")
precision_map = {}
for layer_name, sens in sensitivity.items():
if sens > 0.6:
precision_map[layer_name] = 8 # 高敏感用 8-bit
elif sens > 0.3:
precision_map[layer_name] = 8 # 中敏感用 8-bit
else:
precision_map[layer_name] = self.config.bits # 低敏感用配置位数
# 统计
num_8bit = sum(1 for b in precision_map.values() if b == 8)
num_4bit = sum(1 for b in precision_map.values() if b == 4)
num_other = len(precision_map) - num_8bit - num_4bit
print(f" 8-bit 层:{num_8bit}")
print(f" 4-bit 层:{num_4bit}")
print(f" 其他精度:{num_other}")
return precision_map
def quantize_layer(
self,
layer: nn.Module,
bits: int,
per_head: bool = False,
) -> nn.Module:
"""
量化单个层
使用 PyTorch 动态量化 API:
- int8 对称量化
- 支持 Linear 层的动态量化
参数:
layer: 待量化层
bits: 量化位数(4 或 8)
per_head: 是否按头量化
返回:
量化后的层
"""
if bits == 8:
# PyTorch 动态量化(int8)
quantized = torch.quantization.quantize_dynamic(
layer,
{nn.Linear},
dtype=torch.qint8,
)
return quantized
elif bits == 4:
# 4-bit 对称量化(per-channel scale + zero_point)
# 注意:此为训练感知近似量化,非推理加速专用 INT4 kernel
return self._quantize_to_nbit(layer, 4)
else:
# 不量化
return layer
def _quantize_to_nbit(
self,
layer: nn.Module,
bits: int,
) -> nn.Module:
"""
量化到指定位数
使用自定义的量化实现,支持任意位数
参数:
layer: 待量化层
bits: 目标位数
返回:
量化后的层
"""
class QuantizedLinear(nn.Module):
def __init__(self, original_layer, bits):
super().__init__()
self.in_features = original_layer.in_features
self.out_features = original_layer.out_features
# 获取原始权重
weight = original_layer.weight.data.float()
bias = original_layer.bias.data.float() if original_layer.bias is not None else None
# 量化权重
q_weight, scale, zero_point = self._quantize_weight(weight, bits)
self.register_buffer('q_weight', q_weight)
self.register_buffer('scale', scale)
self.register_buffer('zero_point', zero_point)
if bias is not None:
self.bias = nn.Parameter(bias)
else:
self.bias = None
def _quantize_weight(self, weight, bits):
"""对称量化(per-channel)"""
# 计算缩放因子(per-channel)
max_vals = weight.abs().max(dim=1, keepdim=True).values # (out_features, 1)
# 避免除零
max_vals = torch.where(max_vals == 0, torch.ones_like(max_vals), max_vals)
qmax = 2 ** (bits - 1) - 1
scales = max_vals / qmax # (out_features, 1)
# 量化
q_weight = torch.round(weight / scales).clamp(-qmax, qmax)
return q_weight.to(torch.int8), scales.to(weight.dtype), torch.tensor(0, dtype=weight.dtype, device=weight.device)
def forward(self, x):
# 反量化 + 矩阵乘法
weight = self.q_weight.float() * self.scale
return nn.functional.linear(x, weight, self.bias)
return QuantizedLinear(layer, bits)
def _quantize_layer_per_head(
self,
layer: nn.Module,
num_heads: int,
bits: int,
) -> nn.Module:
"""
按头量化(用于注意力层)
将 weight matrix 按 head 分割,每个 head 独立量化
参数:
layer: 待量化层
num_heads: 注意力头数
bits: 量化位数
返回:
量化后的层
"""
# 按头量化实现
head_dim = layer.out_features // num_heads
class PerHeadQuantizedLinear(nn.Module):
def __init__(self, original_layer, num_heads, bits):
super().__init__()
self.num_heads = num_heads
self.head_dim = head_dim
weight = original_layer.weight.data.float()
bias = original_layer.bias.data.float() if original_layer.bias is not None else None
# 按头量化
q_weights = []
scales = []
for i in range(num_heads):
head_weight = weight[:, i*head_dim:(i+1)*head_dim]
q_w, scale, _ = self._quantize_head(head_weight, bits)
q_weights.append(q_w)
scales.append(scale)
# 拼接
self.register_buffer('q_weights', torch.cat(q_weights, dim=1))
self.register_buffer('scales', torch.stack(scales))
if bias is not None:
self.bias = nn.Parameter(bias)
else:
self.bias = None
def _quantize_head(self, weight, bits):
max_val = weight.abs().max()
qmax = 2 ** (bits - 1) - 1
scale = max_val / qmax if max_val > 0 else torch.tensor(1.0)
q_weight = torch.round(weight / scale).clamp(-2**(bits-1), 2**(bits-1)-1)
return q_weight.to(torch.int8), scale.to(weight.dtype)
def forward(self, x):
# 解码每个头
weight_list = []
for i in range(self.num_heads):
w = self.q_weights[:, i*self.head_dim:(i+1)*self.head_dim].float()
w = w * self.scales[i]
weight_list.append(w)
weight = torch.cat(weight_list, dim=1)
return nn.functional.linear(x, weight, self.bias)
return PerHeadQuantizedLinear(layer, num_heads, bits)
def convert(self) -> nn.Module:
"""
执行量化转换
返回:
量化后的模型
"""
print(f"\n[DyQuant] 开始量化转换...")
# 1. 加载模型
if self.model is None:
result = self.load_model()
if result is not None:
self.model = result
if self.model is None:
print(f"[DyQuant] 无法加载模型,返回 None")
return None
# 2. 分析敏感度
sensitivity = self.analyze_sensitivity()
# 3. 分配精度
if self.config.mixed_precision:
precision_map = self.assign_precision(sensitivity)
else:
precision_map = {
layer: self.config.bits
for layer in sensitivity.keys()
}
# 4. 逐层量化
print(f"\n[DyQuant] 逐层量化...")
quantized_layers = {}
for name, module in self.model.named_modules():
if name in precision_map:
bits = precision_map[name]
if isinstance(module, nn.Linear):
if self.config.per_head and "q_proj" in name:
# 按头量化(适用于 attention 投影)
quantized = self._quantize_layer_per_head(
module, num_heads=32, bits=bits
)
else:
quantized = self.quantize_layer(module, bits)
quantized_layers[name] = quantized
print(f" 量化 {name} -> {bits}-bit")
# 5. 替换原模型层
for name, quantized_layer in quantized_layers.items():
self._replace_layer(self.model, name, quantized_layer)
self.quant_layers = precision_map
print(f"\n[DyQuant] 量化完成")
print(f" 量化层数:{len(self.quant_layers)}")
return self.model
def _replace_layer(
self,
model: nn.Module,
layer_name: str,
new_layer: nn.Module,
):
"""替换模型中的指定层"""
parts = layer_name.split('.')
# 找到父模块
parent = model
for part in parts[:-1]:
if part.isdigit():
parent = parent[int(part)]
else:
parent = getattr(parent, part)
# 替换
setattr(parent, parts[-1], new_layer)
def save(self, output_path: str):
"""
保存量化模型
参数:
output_path: 输出路径
"""
print(f"\n[DyQuant] 保存量化模型到:{output_path}")
if self.model is None:
print(f"[DyQuant] 模型为空,无法保存")
return
output_dir = Path(output_path)
output_dir.mkdir(parents=True, exist_ok=True)
# 保存模型 - extract state_dict from custom quantized layers
# Custom QuantizedLinear layers are not HF-compatible, use safetensors directly
try:
import safetensors.torch as st
state = self.model.state_dict()
# Convert non-tensor values (scales/zeros) to tensors for serialization
clean_state = {}
for k, v in state.items():
if isinstance(v, torch.Tensor):
clean_state[k] = v.contiguous()
else:
clean_state[k] = torch.tensor(v) if v is not None else torch.tensor(0.0)
st.save_file(clean_state, str(output_dir / "model.safetensors"))
except ImportError:
# Fallback to torch.save if safetensors unavailable
torch.save(self.model.state_dict(), output_dir / "pytorch_model.bin")
# 保存量化配置
quant_config = {
"quantization_type": self.quantization_type,
"layers": self.quant_layers,
"config": {
"bits": self.config.bits,
"mixed_precision": self.config.mixed_precision,
"per_head": self.config.per_head,
}
}
with open(output_dir / "quant_config.json", 'w', encoding='utf-8') as f:
json.dump(quant_config, f, indent=2)
print(f"[DyQuant] 保存完成")
def get_model_size(self) -> int:
"""
计算量化后的模型大小
返回:
模型大小(MB)
"""
if self.model is None:
return 0
total_bytes = 0
for name, module in self.model.named_modules():
if isinstance(module, (nn.Linear, torch.nn.quantized.dynamic.Linear)):
# 估计量化后的大小
if hasattr(module, 'qweight'):
# int8: 1 byte per param
total_bytes += module.qweight.numel()
else:
# float16: 2 bytes per param
total_bytes += module.weight.numel() * 2
return total_bytes / (1024 * 1024)
# ============================================================
# 便捷函数
# ============================================================
def quantize_fusion_model(
model_path: str,
output_path: str,
bits: int = 4,
mixed_precision: bool = True,
) -> str:
"""
一键量化 Fusion 模型
参数:
model_path: 模型路径
output_path: 输出路径
bits: 量化位数
mixed_precision: 是否混合精度
返回:
输出路径
"""
config = QuantConfig(
model_path=model_path,
bits=bits,
mixed_precision=mixed_precision,
output_path=output_path,
)
converter = DyQuantConverter(config)
converter.convert()
converter.save(output_path)
size_mb = converter.get_model_size()
print(f"\n量化完成!模型大小:{size_mb:.1f} MB")
return output_path
# ============================================================
# QAT (Quantization-Aware Training) Integration
# ============================================================
class QATTrainer:
"""
Quantization-Aware Training trainer for Fusion models.
Inserts fake-quantization nodes into the model during training,
so the model learns to be robust to quantization noise.
After training, the model can be quantized with minimal accuracy loss.
Usage:
from inference.dyquant import QATTrainer, QuantConfig
config = QuantConfig(model_path="fusion-8b-base", bits=4)
trainer = QATTrainer(config, train_data="data/train.json")
trainer.train(epochs=3, lr=1e-5)
trainer.save("fusion-8b-qat")
"""
def __init__(
self,
config: QuantConfig,
train_data: Optional[str] = None,
learning_rate: float = 1e-5,
warmup_steps: int = 100,
):
self.config = config
self.train_data = train_data
self.lr = learning_rate
self.warmup_steps = warmup_steps
self.converter = DyQuantConverter(config)
self.model = None
self.qat_model = None
def prepare(self) -> Optional[nn.Module]:
"""Load model and insert fake-quantization nodes."""
self.model = self.converter.load_model()
if self.model is None:
print("[DyQuant] QAT: model load failed, cannot prepare")
return None
self.qat_model = self._insert_fake_quant(self.model)
return self.qat_model
def _insert_fake_quant(self, model: nn.Module) -> nn.Module:
"""Insert fake-quantization observers into all Linear layers."""
for name, module in model.named_modules():
if isinstance(module, nn.Linear):
# Use PyTorch native fake quantization for all Linear layers
module.qconfig = torch.ao.quantization.get_default_qat_qconfig('x86')
torch.ao.quantization.prepare_qat(model, inplace=True)
return model
def train(
self,
epochs: int = 3,
lr: Optional[float] = None,
batch_size: int = 4,
max_seq_len: int = 2048,
):
"""
Run QAT fine-tuning.
Args:
epochs: Number of training epochs
lr: Learning rate (defaults to self.lr)
batch_size: Training batch size
max_seq_len: Maximum sequence length
"""
if self.qat_model is None:
self.prepare()
actual_lr = lr or self.lr
device = next(self.qat_model.parameters()).device
optimizer = torch.optim.AdamW(self.qat_model.parameters(), lr=actual_lr)
scheduler = torch.optim.lr_scheduler.LinearLR(
optimizer, start_factor=0.1, total_iters=self.warmup_steps
)
print(f"[QAT] Starting QAT training: epochs={epochs}, lr={actual_lr}")
# Load training data if provided
if self.train_data and Path(self.train_data).exists():
train_dataset = self._load_dataset(self.train_data, max_seq_len)
else:
print("[QAT] Warning: No training data provided, using random calibration")
train_dataset = self._generate_calib_data(batch_size * 10, max_seq_len)
dataloader = torch.utils.data.DataLoader(
train_dataset, batch_size=batch_size, shuffle=True
)
self.qat_model.train()
step = 0
for epoch in range(epochs):
total_loss = 0.0
for batch in dataloader:
input_ids = batch.to(device)
attention_mask = torch.ones_like(input_ids)
labels = input_ids.clone()
outputs = self.qat_model(
input_ids=input_ids,
attention_mask=attention_mask,
labels=labels,
)
loss = outputs.loss if hasattr(outputs, 'loss') else outputs['loss']
loss.backward()
optimizer.step()
scheduler.step()
optimizer.zero_grad()
total_loss += loss.item()
step += 1
avg_loss = total_loss / len(dataloader)
print(f"[QAT] Epoch {epoch+1}/{epochs} - Loss: {avg_loss:.4f}")
print(f"[QAT] Training complete ({step} steps)")
def _load_dataset(self, data_path: str, max_seq_len: int):
"""Load JSON training data and tokenize with model's tokenizer."""
import json
from transformers import AutoTokenizer
with open(data_path, 'r', encoding='utf-8') as f:
data = json.load(f)
texts = [item.get('text', item.get('prompt', '')) + ' ' + item.get('response', '') for item in data]
# Load tokenizer from model path
try:
tokenizer = AutoTokenizer.from_pretrained(
self.config.model_path,
trust_remote_code=True,
)
except Exception as e:
print(f"[QAT] Warning: Cannot load tokenizer: {e}, using character-level fallback")
# Fallback to character-level encoding
encoded = [list(t.encode('utf-8'))[:max_seq_len] for t in texts]
padded = [
seq + [0] * (max_seq_len - len(seq)) if len(seq) < max_seq_len else seq[:max_seq_len]
for seq in encoded
]
return torch.utils.data.TensorDataset(torch.tensor(padded, dtype=torch.long))
# Tokenize with proper tokenizer
encoded = []
for text in texts:
# Encode text to token IDs
token_ids = tokenizer.encode(text, max_length=max_seq_len, truncation=True)
# Pad or truncate to max_seq_len
if len(token_ids) < max_seq_len:
token_ids = token_ids + [tokenizer.pad_token_id or 0] * (max_seq_len - len(token_ids))
else:
token_ids = token_ids[:max_seq_len]
encoded.append(token_ids)
return torch.utils.data.TensorDataset(torch.tensor(encoded, dtype=torch.long))
def _generate_calib_data(self, num_samples: int, seq_len: int):
"""Generate random calibration data."""
data = torch.randint(0, 1000, (num_samples, seq_len))
return torch.utils.data.TensorDataset(data)
def save(self, output_path: str):
"""Convert QAT model to final quantized model and save."""
# Remove fake-quant nodes and convert to actual quantized model
final_model = torch.ao.quantization.convert(self.qat_model, inplace=False)
output_dir = Path(output_path)
output_dir.mkdir(parents=True, exist_ok=True)
torch.save(final_model.state_dict(), output_dir / "qat_model.pt")
# Also save as regular quantized model
self.config.output_path = output_path
quantized = self.converter.convert()
self.converter.save(output_path)
print(f"[QAT] Saved to {output_path}")
# ============================================================
# Main Entry Point
# ============================================================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="DyQuant 模型量化工具")
parser.add_argument("--model_path", type=str, required=True, help="模型路径")
parser.add_argument("--output_path", type=str, required=True, help="输出路径")
parser.add_argument("--bits", type=int, default=4, help="量化位数(4/8)")
parser.add_argument("--mixed_precision", action="store_true", help="启用混合精度")
parser.add_argument("--per_head", action="store_true", help="按头量化")
args = parser.parse_args()
# 创建量化配置
config = QuantConfig(
model_path=args.model_path,
bits=args.bits,
mixed_precision=args.mixed_precision,
per_head=args.per_head,
output_path=args.output_path,
)
# 量化
converter = DyQuantConverter(config)
converter.convert()
converter.save(args.output_path)
print(f"\n量化完成!")
print(f"输出路径:{args.output_path}")
print(f"模型大小:{converter.get_model_size():.1f} MB")