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())