chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:46 +08:00
commit f4e68ed970
84 changed files with 14896 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
Convert LLMCompressor compressed-tensors quantized model → MLX-native format.
Supports any bit-width (W4A16, W8A16, etc.) produced by LLMCompressor's
pack-quantized format, including per-channel (strategy=channel) and per-group
(strategy=group) quantization. Handles cross-shard layers automatically.
Usage:
python convert_compressed_tensors_to_mlx.py /path/to/src /path/to/dst [--verify]
"""
import argparse
import json
import os
import shutil
import glob
import numpy as np
from pathlib import Path
_MLX_MAX_GROUP_SIZE = {4: 128, 8: 128}
_MLX_DEFAULT_GROUP_SIZE = 128
def _effective_group_size(num_bits, config_group_size, in_features):
if config_group_size is not None and config_group_size > 0:
return config_group_size
max_gs = _MLX_MAX_GROUP_SIZE.get(num_bits, _MLX_DEFAULT_GROUP_SIZE)
return min(max_gs, in_features)
def convert(src_dir: str, dst_dir: str) -> dict:
from safetensors.torch import load_file, save_file
import torch
src_dir = Path(src_dir)
dst_dir = Path(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
with open(src_dir / "config.json") as f:
config = json.load(f)
qconfig = config.get("quantization_config", {})
group_cfg = qconfig.get("config_groups", {}).get("group_0", {}).get("weights", {})
config_group_size = group_cfg.get("group_size", None)
num_bits = group_cfg.get("num_bits", 4)
symmetric = group_cfg.get("symmetric", True)
strategy = group_cfg.get("strategy", "group")
ignore = set(qconfig.get("ignore", []))
pack_factor = 32 // num_bits
offset = 1 << (num_bits - 1)
per_channel = (strategy == "channel") or (config_group_size is None)
print(f"Config: {num_bits}-bit, strategy={strategy}, "
f"config_group_size={config_group_size}, "
f"symmetric={symmetric}, offset={offset}, ignore={ignore}")
# Copy auxiliary files
skip_ext = {".safetensors"}
skip_names = {"model.safetensors.index.json", "config.json"}
for f in src_dir.iterdir():
if f.suffix in skip_ext or f.name in skip_names:
continue
if f.is_file():
shutil.copy2(f, dst_dir / f.name)
# ── Load ALL shards, track which shard each key came from ────
st_files = sorted(glob.glob(str(src_dir / "model*.safetensors")))
if not st_files:
st_files = sorted(glob.glob(str(src_dir / "*.safetensors")))
# key → (tensor, source_filename)
all_tensors = {}
for st_file in st_files:
fname = os.path.basename(st_file)
print(f" Loading {fname}...")
for key, tensor in load_file(st_file).items():
all_tensors[key] = (tensor, fname)
# ── Classify: quantized vs plain ─────────────────────────────
quant_layers = {} # base → {packed, scale, shape}
plain = {} # key → (tensor, fname)
for key, (tensor, fname) in all_tensors.items():
matched = False
for suffix, part in [(".weight_packed", "packed"),
(".weight_scale", "scale"),
(".weight_shape", "shape")]:
if key.endswith(suffix):
base = key.removesuffix(suffix)
quant_layers.setdefault(base, {})[part] = (tensor, fname)
matched = True
break
if not matched:
plain[key] = (tensor, fname)
# ── Convert ──────────────────────────────────────────────────
# Output: per-shard tensors
import torch
shard_out = {} # fname → {key: tensor}
weight_map = {}
total_converted = 0
actual_group_size = None
for base, parts in sorted(quant_layers.items()):
if "packed" not in parts:
print(f" ⚠ Skipping {base}: no weight_packed")
continue
if "scale" not in parts:
print(f" ⚠ Skipping {base}: no weight_scale")
continue
packed, packed_fname = parts["packed"]
scale, _ = parts["scale"]
shape_tensor = parts.get("shape", (None, None))[0]
if shape_tensor is not None:
in_features = int(shape_tensor[1].item())
out_features = int(shape_tensor[0].item())
else:
in_features = packed.shape[1] * pack_factor
out_features = packed.shape[0]
gs = _effective_group_size(num_bits, config_group_size, in_features)
if actual_group_size is None:
actual_group_size = gs
print(f" Using group_size={gs} "
f"({'broadcast from per-channel' if per_channel else 'from config'})")
# packed → uint32 as-is
mlx_weight = torch.from_numpy(packed.numpy().view(np.uint32).copy())
# scale → fp16, broadcast if per-channel
scale_fp16 = scale.to(torch.float16)
n_groups = in_features // gs
if scale_fp16.shape[1] < n_groups:
scale_fp16 = scale_fp16.expand(out_features, n_groups).contiguous()
mlx_scales = scale_fp16
mlx_biases = -float(offset) * mlx_scales
# Write to the shard that owns weight_packed
shard_out.setdefault(packed_fname, {})[f"{base}.weight"] = mlx_weight
shard_out[packed_fname][f"{base}.scales"] = mlx_scales
shard_out[packed_fname][f"{base}.biases"] = mlx_biases
total_converted += 1
# Plain tensors
total_passthrough = 0
for key, (tensor, fname) in plain.items():
if tensor.dtype == torch.bfloat16:
tensor = tensor.to(torch.float16)
shard_out.setdefault(fname, {})[key] = tensor
total_passthrough += 1
# ── Save shards ──────────────────────────────────────────────
for fname, tensors in sorted(shard_out.items()):
out_path = dst_dir / fname
save_file(tensors, str(out_path))
print(f" Saved {fname}: {len(tensors)} tensors")
for key in tensors:
weight_map[key] = fname
# Index
with open(dst_dir / "model.safetensors.index.json", "w") as f:
json.dump({"metadata": {"total_size": 0}, "weight_map": weight_map}, f, indent=2)
# Config
new_config = dict(config)
new_config.pop("quantization_config", None)
new_config["quantization"] = {
"group_size": actual_group_size or _MLX_DEFAULT_GROUP_SIZE,
"bits": num_bits,
}
with open(dst_dir / "config.json", "w") as f:
json.dump(new_config, f, indent=2)
stats = {"bits": num_bits, "group_size": actual_group_size,
"strategy": strategy, "converted": total_converted,
"passthrough": total_passthrough}
print(f"\n✅ Done — {total_converted} quantized layers converted.")
print(f" Output: {dst_dir}")
return stats
def verify(src_dir: str, dst_dir: str, n_check: int = 3):
from safetensors.torch import load_file
import torch
try:
import mlx.core as mx
except ImportError:
print("⚠ mlx not available — skipping verification")
return
src_dir, dst_dir = Path(src_dir), Path(dst_dir)
with open(src_dir / "config.json") as f:
src_cfg = json.load(f)
qconfig = src_cfg.get("quantization_config", {})
group_cfg = qconfig.get("config_groups", {}).get("group_0", {}).get("weights", {})
config_group_size = group_cfg.get("group_size", None)
num_bits = group_cfg.get("num_bits", 4)
with open(dst_dir / "config.json") as f:
dst_cfg = json.load(f).get("quantization", {})
mlx_group_size = dst_cfg.get("group_size", 128)
bits = dst_cfg.get("bits", num_bits)
offset = 1 << (bits - 1)
pack_factor = 32 // bits
# Load all shards from both dirs
src_all = {}
for f in sorted(glob.glob(str(src_dir / "model*.safetensors"))):
src_all.update(load_file(f))
dst_all = {}
for f in sorted(glob.glob(str(dst_dir / "model*.safetensors"))):
dst_all.update(load_file(f))
checked = 0
for key in sorted(src_all):
if not key.endswith(".weight_packed") or checked >= n_check:
continue
base = key.removesuffix(".weight_packed")
packed = src_all[f"{base}.weight_packed"].numpy().view(np.uint32)
scale = src_all[f"{base}.weight_scale"].to(torch.float32).numpy()
shape_t = src_all.get(f"{base}.weight_shape")
if shape_t is not None:
out_feat, in_feat = int(shape_t[0].item()), int(shape_t[1].item())
else:
in_feat = packed.shape[1] * pack_factor
out_feat = packed.shape[0]
ref = np.zeros((out_feat, in_feat), dtype=np.float32)
for i in range(pack_factor):
vals = (packed >> (bits * i)) & ((1 << bits) - 1)
signed = vals.astype(np.int32) - offset
cols = np.arange(packed.shape[1]) * pack_factor + i
cols = cols[cols < in_feat]
if scale.shape[1] == 1:
ref[:, cols] = signed[:, :len(cols)] * scale[:, 0:1]
else:
groups = cols // config_group_size
ref[:, cols] = signed[:, :len(cols)] * scale[:, groups]
w = mx.array(dst_all[f"{base}.weight"].numpy().view(np.uint32))
s = mx.array(dst_all[f"{base}.scales"].to(torch.float32).numpy())
b = mx.array(dst_all[f"{base}.biases"].to(torch.float32).numpy())
mlx_deq = np.array(mx.dequantize(w, s, b, group_size=mlx_group_size, bits=bits))
max_diff = np.max(np.abs(ref - mlx_deq))
cos = (np.dot(ref.flatten(), mlx_deq.flatten())
/ (np.linalg.norm(ref.flatten()) * np.linalg.norm(mlx_deq.flatten()) + 1e-12))
status = "" if cos > 0.9999 else ""
print(f" {status} {base}: max_diff={max_diff:.2e}, cos={cos:.8f}")
checked += 1
print(f"\n Verified {checked} layers")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Convert LLMCompressor compressed-tensors → MLX format"
)
parser.add_argument("src", help="Source compressed-tensors model directory")
parser.add_argument("dst", help="Destination MLX model directory")
parser.add_argument("--verify", action="store_true",
help="Spot-check dequantized values after conversion")
args = parser.parse_args()
convert(args.src, args.dst)
if args.verify:
print("\n--- Verification ---")
verify(args.src, args.dst)
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Unified PPL evaluation: FP16, W8A16, Cider per-channel, per-group gs=64, per-group gs=128."""
import argparse, math, sys, time, gc, os
import mlx.core as mx
import mlx.nn as nn
import numpy as np
import tqdm
from mlx_lm.utils import get_total_parameters, load
from cider.nn import CiderLinear
home_dir = os.path.expanduser("~")
WIKITEXT_LOCAL = os.path.join(home_dir, "Downloads/wikitext/wikitext-2-raw-v1")
FP16_MODEL = os.path.join(home_dir, "Downloads/Meta-Llama-3-8B")
W8A16_MODEL = os.path.join(home_dir, "Downloads/Llama3-8B-GPTQ-W8A16-mlx")
def load_wikitext_local(tokenizer, split="test"):
from datasets import load_dataset
split_map = {
"test": f"{WIKITEXT_LOCAL}/test-00000-of-00001.parquet",
}
data = load_dataset("parquet", data_files={split: split_map[split]}, split=split)
encodings = tokenizer.encode("\n\n".join(data["text"]), return_tensors="pt")
return encodings.numpy()
def convert_model_with_gs(model, target_group_size):
counter = [0]
_TARGET = (nn.Linear, nn.QuantizedLinear)
def _walk(module):
for name, child in module.children().items():
if isinstance(child, _TARGET):
setattr(module, name, CiderLinear.from_float(child, target_group_size=target_group_size))
counter[0] += 1
if counter[0] % 28 == 0: gc.collect()
elif isinstance(child, list):
for i, item in enumerate(child):
if isinstance(item, _TARGET):
child[i] = CiderLinear.from_float(item, target_group_size=target_group_size)
counter[0] += 1
if counter[0] % 28 == 0: gc.collect()
elif isinstance(item, nn.Module):
_walk(item)
elif isinstance(child, nn.Module):
_walk(child)
_walk(model)
return counter[0]
def eval_ppl(model, data, n_samples, seq_length):
all_losses = []
total_chunks = data.shape[1] // seq_length
n = n_samples if n_samples > 0 else total_chunks
for i in tqdm.tqdm(range(n), desc="Evaluating PPL"):
batch = mx.array(data[:, i * seq_length: (i + 1) * seq_length])
logits = model(batch[:, :-1]).astype(mx.float32)
losses = nn.losses.cross_entropy(logits, batch[:, 1:], reduction="none")
mx.eval(losses)
all_losses.append(losses.flatten())
all_losses = mx.concatenate(all_losses)
mean_loss = all_losses.mean().item()
ppl = math.exp(mean_loss)
std_dev = mx.sqrt(mx.var(all_losses, ddof=1)).item()
se_ppl = ppl * (std_dev / math.sqrt(all_losses.size))
return ppl, se_ppl
def run_config(config_name, model_path, cider_gs=None, data=None, n_samples=50, seq_length=2048):
"""Run a single config and return results dict."""
print(f"\n{'='*60}")
print(f" {config_name}")
print(f"{'='*60}")
print(f" Loading {model_path}...")
model, tokenizer = load(model_path, tokenizer_config={"trust_remote_code": True})
if cider_gs is not None:
gs_str = "per-channel" if cider_gs == 0 else f"per-group(gs={cider_gs})"
print(f" Converting to CiderLinear ({gs_str})...")
t0 = time.perf_counter()
n = convert_model_with_gs(model, cider_gs)
print(f" {n} layers converted in {time.perf_counter()-t0:.1f}s")
# Warmup
dummy = mx.array([[1, 2, 3, 4, 5, 6, 7, 8]])
_ = model(dummy); mx.eval(_)
if data is None:
data = load_wikitext_local(tokenizer, "test")
mx.reset_peak_memory()
start = time.time()
ppl, se = eval_ppl(model, data, n_samples, seq_length)
elapsed = time.time() - start
peak_mem = mx.get_peak_memory() / 1e9
result = {"name": config_name, "ppl": ppl, "se": se, "time": elapsed, "mem": peak_mem}
print(f" PPL = {ppl:.3f} ± {se:.3f} | {elapsed:.1f}s | {peak_mem:.2f} GB")
# Free memory
del model
gc.collect()
mx.metal.clear_cache()
return result, data
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--num-samples", type=int, default=-1)
parser.add_argument("--sequence-length", type=int, default=2048)
args = parser.parse_args()
np.random.seed(123)
mx.random.seed(123)
configs = [
# ("Baseline FP16", FP16_MODEL, None),
# ("Baseline W8A16 (MLX native)", W8A16_MODEL, None),
# ("Cider W8A8 per-channel", W8A16_MODEL, 0),
("Cider W8A8 per-group(gs=64)", W8A16_MODEL, 64),
("Cider W8A8 per-group(gs=128)", W8A16_MODEL, 128),
]
results = []
data = None
for name, model_path, cider_gs in configs:
np.random.seed(123)
mx.random.seed(123)
r, data = run_config(name, model_path, cider_gs, data, args.num_samples, args.sequence_length)
results.append(r)
# Summary table
print(f"\n\n{'='*70}")
print(f" PPL COMPARISON SUMMARY (Qwen3-8B, wikitext-2, seq={args.sequence_length}, n={args.num_samples})")
print(f"{'='*70}")
print(f" {'Config':<30} {'PPL':>10} {'±SE':>8} {'Time':>8} {'Mem':>8}")
print(f" {'-'*30} {'-'*10} {'-'*8} {'-'*8} {'-'*8}")
baseline_ppl = results[0]["ppl"] if results else None
for r in results:
delta = f"(+{(r['ppl']-baseline_ppl)/baseline_ppl*100:.2f}%)" if baseline_ppl and r['ppl'] != baseline_ppl else ""
print(f" {r['name']:<30} {r['ppl']:>8.3f} ±{r['se']:.3f} {r['time']:>6.1f}s {r['mem']:>6.2f}GB {delta}")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
import base64
from io import BytesIO
import json
from datasets import Dataset
import torch
from compressed_tensors.offload import dispatch_model
from datasets import load_dataset
# Note: this is an optional utility for processing vision inputs for qwen.
# This can be installed via the "qwen" extra
from qwen_vl_utils import process_vision_info
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
from llmcompressor.modifiers.awq import AWQModifier
import random
# Load model.
model_id = "/mnt/data/ws/project/ms-swift/output/v3_sft_4b/v0-20260421-095015/checkpoint-50349"
model = Qwen3VLForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map="auto")
processor = AutoProcessor.from_pretrained(model_id)
# Oneshot arguments
DATASET_ID = "/mnt/data/ws/project/ms-swift/train_v4.jsonl"
DATASET_SPLIT = "test[:512]"
NUM_CALIBRATION_SAMPLES = 512
MAX_SEQUENCE_LENGTH = 40000
# Load dataset and preprocess.
def reservoir_sampling(file_path, k):
"""
从大文件中均匀随机采样 k 个样本
时间复杂度: O(n)
空间复杂度: O(k)
"""
reservoir = []
with open(file_path, 'r', encoding='utf-8') as f:
# 先填充前 k 个样本
for i, line in enumerate(f):
l = line.strip()
if len(l) == 0:
continue
if i < k:
reservoir.append(l)
else:
# 对于第 i 个样本,以 k/i 的概率替换蓄水池中的某个样本
j = random.randint(0, i)
if j < k:
reservoir[j] = l
return reservoir
# Apply chat template and tokenize inputs.
calib_data_raw = reservoir_sampling(DATASET_ID, NUM_CALIBRATION_SAMPLES)
def preprocess_and_tokenize(example):
# preprocess
text = processor.apply_chat_template(
example["messages"], tokenize=False, add_generation_prompt=True
)
if "<image>" not in text:
return None
text = text.replace("<image>", "<|vision_start|><|image_pad|><|vision_end|>")
res = processor(
text=[text],
images=example["images"],
padding=False,
max_length=MAX_SEQUENCE_LENGTH,
truncation=True,
)
# tokenize
return res
calib_data = []
for i, item in enumerate(calib_data_raw):
print("load %d/%d items..." % (i, len(calib_data_raw)))
u = preprocess_and_tokenize(json.loads(item))
if u is None:
continue
calib_data.append(preprocess_and_tokenize(json.loads(item)))
calib_dataset = Dataset.from_list(calib_data)
# Define a oneshot data collator for multimodal inputs.
def data_collator(batch):
assert len(batch) == 1
return {key: torch.tensor(value) for key, value in batch[0].items()}
# Recipe
recipe = [
AWQModifier(duo_scaling=False),
QuantizationModifier(
scheme="W8A16",
ignore=["re:.*lm_head", "re:.*visual.*"],
),
]
# Perform oneshot
oneshot(
model=model,
tokenizer=model_id,
dataset=calib_dataset,
recipe=recipe,
max_seq_length=MAX_SEQUENCE_LENGTH,
num_calibration_samples=NUM_CALIBRATION_SAMPLES,
data_collator=data_collator,
sequential_targets=["Qwen3VLTextDecoderLayer"],
)
# Save to disk compressed.
SAVE_DIR = model_id.rstrip("/").split("/")[-1] + "-W8A16"
model.save_pretrained(SAVE_DIR, save_compressed=True)
processor.save_pretrained(SAVE_DIR)