File size: 8,697 Bytes
69cbd51 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | #!/usr/bin/env python3
"""Convert stabilityai/stable-diffusion-3.5-medium to OpenVINO IR (FP16), then
compress weights to INT4 with NNCF.
Method mirrors the conversion documented in
https://huggingface.co/HelloSun/FLUX.2-klein-4B-OpenVINO-INT4
INT4 : transformer + text_encoder_3 (T5-XXL)
OVWeightQuantizationConfig(bits=4, sym=False, group_size=128,
group_size_fallback="adjust", ratio=1.0)
rest : default INT8
OVWeightQuantizationConfig(bits=8)
via OVQuantizer + ov_config=OVConfig(
quantization_config=OVPipelineQuantizationConfig(...))
SD3.5-medium has three text encoders. Only text_encoder_3 (T5-XXL, 4.7B params,
~9.5 GB FP16) is pushed to INT4; the two small CLIP encoders (CLIP-L / CLIP-G)
stay at INT8 because quantizing them costs little size and risks prompt fidelity.
Inference parameters are NOT changed by this script: they stay at the values in
the original model card (1024x1024, 40 steps, guidance 4.5).
Usage
-----
export HF_TOKEN=... # SD3.5-medium is gated
python quantize_int4.py
# or split the two phases
python quantize_int4.py --export-only
python quantize_int4.py --quantize-only
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
MODEL_ID = "stabilityai/stable-diffusion-3.5-medium"
REPO_ID = "HelloSun/SD3.5-medium-OpenVINO-INT4"
# INT4 keeps the two large components. group_size_fallback="adjust" downshifts to
# the largest compatible group when a tensor's shape is not divisible by 128,
# which is common for the small projection matrices inside MMDiT blocks.
INT4_COMPONENTS = ("transformer", "text_encoder_3")
INT4_KWARGS = {
"bits": 4,
"sym": False,
"group_size": 128,
"group_size_fallback": "adjust",
"ratio": 1.0,
}
def log(msg: str) -> None:
print(f"[quantize_int4] {msg}", flush=True)
def dir_size_gb(path: Path) -> float:
total = 0
for root, _dirs, files in os.walk(path):
for name in files:
fp = Path(root) / name
if fp.is_file() and not fp.is_symlink():
total += fp.stat().st_size
return total / 1024**3
def component_sizes(path: Path) -> dict[str, float]:
"""Per-subfolder size in GB, for the components we actually quantize."""
out: dict[str, float] = {}
if not path.is_dir():
return out
for child in sorted(p for p in path.iterdir() if p.is_dir()):
out[child.name] = dir_size_gb(child)
return out
def require_token() -> None:
"""SD3.5-medium is gated (license must be accepted on the Hub)."""
if os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"):
return
log("No HF_TOKEN in environment. If the repo is not already accepted for")
log("your account, run: hf auth login")
def export_fp16(out_dir: Path) -> None:
"""FP16 export via optimum-cli, matching the reference card's export step."""
if (out_dir / "openvino_config.json").is_file():
log(f"FP16 export already present at {out_dir}, skipping.")
return
cmd = [
"optimum-cli",
"export",
"openvino",
"--model",
MODEL_ID,
"--task",
"text-to-image",
"--library",
"diffusers",
"--weight-format",
"fp16",
str(out_dir),
]
log("running: " + " ".join(cmd))
log("(downloads ~16 GB of FP16 weights; this is the long part)")
subprocess.run(cmd, check=True)
def quantize_int4(fp16_dir: Path, out_dir: Path) -> None:
import shutil
# immediate_save=True overwrites files in save_directory in place, so the
# FP16 IR must already be there. Seed it automatically on first run.
out_exists = out_dir.exists() and any(out_dir.iterdir())
if not out_exists:
if (fp16_dir / "model_index.json").is_file():
log(f"seeding {out_dir} from {fp16_dir} (required for immediate_save=True)")
shutil.copytree(fp16_dir, out_dir, dirs_exist_ok=True)
# TemporaryDirectory (used internally for each sub-model) must live on the
# same filesystem as out_dir, otherwise rename() fails with EXDEV
# (Invalid cross-device link). E.g.: export TMPDIR=/data-nvme/work/tmp
try:
if out_dir.exists() and Path(tempfile.gettempdir()).stat().st_dev != Path(out_dir).stat().st_dev:
log("WARNING: TMPDIR is on a different filesystem than out_dir; "
"set TMPDIR inside the same filesystem to avoid EXDEV rename errors.")
except OSError:
pass
from optimum.intel import (
OVConfig,
OVDiffusionPipeline,
OVPipelineQuantizationConfig,
OVQuantizer,
OVWeightQuantizationConfig,
)
log(f"loading FP16 OpenVINO pipeline from {fp16_dir}")
model = OVDiffusionPipeline.from_pretrained(str(fp16_dir))
available = sorted(c for c in ("transformer", "text_encoder_3") if getattr(model, c, None) is not None)
if available != list(INT4_COMPONENTS):
log(f"WARNING: expected components {list(INT4_COMPONENTS)}, pipeline exposes {available}")
def int4_cfg():
return OVWeightQuantizationConfig(**INT4_KWARGS)
quantization_config = OVPipelineQuantizationConfig(
quantization_configs={name: int4_cfg() for name in INT4_COMPONENTS},
# Everything else (CLIP encoders, VAE decoder/encoder) at INT8.
default_config=OVWeightQuantizationConfig(bits=8),
)
quantizer = OVQuantizer.from_pretrained(model)
log("quantizing -> INT4 (transformer, text_encoder_3) / INT8 (rest)")
quantizer.quantize(
ov_config=OVConfig(quantization_config=quantization_config),
save_directory=str(out_dir),
# Saves each sub-model as soon as it is compressed, so the FP16 copy of a
# component can be released. Cuts peak RSS substantially. The in-memory
# pipeline is unusable afterwards, which is fine here.
immediate_save=True,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--fp16-dir", default="sd3.5-medium-ov-fp16", type=Path)
parser.add_argument("--out-dir", default=REPO_ID.split("/")[-1], type=Path)
parser.add_argument("--export-only", action="store_true")
parser.add_argument("--quantize-only", action="store_true")
args = parser.parse_args()
if args.export_only and args.quantize_only:
parser.error("--export-only and --quantize-only are mutually exclusive")
require_token()
report: dict[str, object] = {"model_id": MODEL_ID, "repo_id": REPO_ID}
if not args.quantize_only:
t0 = time.perf_counter()
export_fp16(args.fp16_dir)
report["export_seconds"] = round(time.perf_counter() - t0, 2)
report["fp16_size_gb"] = round(dir_size_gb(args.fp16_dir), 2)
report["fp16_components_gb"] = {k: round(v, 2) for k, v in component_sizes(args.fp16_dir).items()}
log(f"FP16 export done: {report['fp16_size_gb']} GB")
if args.export_only:
(args.out_dir).mkdir(parents=True, exist_ok=True)
(args.out_dir / "benchmark_quantization.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
return 0
if args.out_dir.exists() and args.out_dir.is_dir() and any(args.out_dir.iterdir()):
log(f"WARNING: {args.out_dir} is not empty; quantize will overwrite its contents.")
t0 = time.perf_counter()
quantize_int4(args.fp16_dir, args.out_dir)
report["quantize_seconds"] = round(time.perf_counter() - t0, 2)
report["int4_size_gb"] = round(dir_size_gb(args.out_dir), 2)
report["int4_components_gb"] = {k: round(v, 2) for k, v in component_sizes(args.out_dir).items()}
if isinstance(report.get("fp16_size_gb"), float) and report["fp16_size_gb"]:
report["compression_ratio"] = round(report["fp16_size_gb"] / report["int4_size_gb"], 2)
report["int4_config"] = {"components": list(INT4_COMPONENTS), **INT4_KWARGS, "rest": "int8"}
args.out_dir.mkdir(parents=True, exist_ok=True)
(args.out_dir / "benchmark_quantization.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
log(json.dumps(report, indent=2, ensure_ascii=False))
log("done. copy README.md / REPORT.md / *.py into the output dir, then:")
log(f" huggingface-cli upload {REPO_ID} {args.out_dir} --repo-type model")
return 0
if __name__ == "__main__":
sys.exit(main())
|