File size: 11,117 Bytes
7850e45 | 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 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | #!/usr/bin/env python3
"""Make a block-FP8 copy of Clef-Flash from Cloudflare's own files, with llm-compressor.
1. Check every input file against the hash that Cloudflare publishes for
revision 41318bf. Stop on a mismatch.
2. Quantize with the FP8_BLOCK scheme of llm-compressor: FP8 weights with one
scale for each 128x128 block, and FP8 activations scaled at run time. This
is the layout of Qwen/Qwen3.6-27B-FP8. It uses no calibration data. The
vision encoder, lm_head and in_proj_a/b stay in BF16, as in Qwen's release.
3. Copy the Clef head, Cloudflare's code and the tokenizer files unchanged.
The new config.json is Cloudflare's config.json plus quantization_config.
4. Check the output. Every tensor that is not FP8 must be bit-identical to
Cloudflare's tensor. No tensor can be missing or extra.
5. Write MANIFEST.json with the versions and the hash of each output file.
Run it in the amd-vllm022 image after `pip install llmcompressor==0.13.0`
(see README.md). The image already has torch 2.10.0 and transformers 5.12.1,
which llm-compressor 0.13.0 accepts. Runs on CPU.
python3 quantize_fp8.py --src /path/to/clef-flash --dst /path/to/clef-flash-fp8
"""
import argparse
import hashlib
import json
import shutil
import sys
from pathlib import Path
import torch
from safetensors import safe_open
SOURCE = "Cloudflare/clef-flash@41318bf2106d135c7a92da745fec4b255279b4de"
# SHA-256 of the large files, as Hugging Face publishes them (the LFS oid).
SHA256 = {
"joint_head.safetensors": "19cdcec8c81dc9212be320fff47462ab342fbc1278be4368fb3da71241cf5ba0",
"model-00001-of-00004.safetensors": "8b45a8e968141cdcc58fb71c9adfc258e2c77b5f062bc636c1fd5bc5d916b565",
"model-00002-of-00004.safetensors": "7590856c713eed844a2dcf48e6c43c4de165b788bc3f80e328311183cdbc7db8",
"model-00003-of-00004.safetensors": "e6eac2467952c33361ed7dcb3c7959d1086bbe57201cd3749c3d769fdc17fe63",
"model-00004-of-00004.safetensors": "9fcecc6556b39171238373a465f409794b7f821fb4cd1e6459e3a9c0fe317af7",
"tokenizer.json": "06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523",
}
# Git blob hashes of the small files, as Hugging Face publishes them.
GIT_BLOB = {
"joint_schema_model.py": "6a3ac5fc030a3f33f7fa860a1271886b64a6606d",
"joint_head_config.json": "52467b6c7f7d1a7e628b518eb7a6cd7e9bdb9a57",
"config.json": "aff766f2ab6e0e48b6aa297265689b981bdd2042",
"model.safetensors.index.json": "778b7bbb7ebec3f1e100b1059b1c6d54bd00e3c1",
"processor_config.json": "33818c7f9e991ad735fd240209f4fa73e6c28c50",
"tokenizer_config.json": "0f19934ef65115549ff9629ff2c83d15e40e32d8",
}
# Qwen keeps these layers in BF16 in Qwen3.6-27B-FP8. The recipe targets only
# Linear layers, so the embeddings stay in BF16 too.
IGNORE = ["lm_head", "re:.*visual.*", "re:.*linear_attn.in_proj_a$", "re:.*linear_attn.in_proj_b$"]
# In each of the 32 layers: 3 MLP weights. In the 8 full-attention layers:
# q, k, v, o. In the 24 linear-attention layers: in_proj_qkv, in_proj_z, out_proj.
EXPECTED_FP8 = 32 * 3 + 8 * 4 + 24 * 3
# Files that the model save does not write, or writes differently. They are
# copied from Cloudflare's directory without change.
COPY = [
"joint_head.safetensors",
"joint_head_config.json",
"joint_schema_model.py",
"tokenizer.json",
"tokenizer_config.json",
"processor_config.json",
"chat_template.jinja",
"generation_config.json",
"LICENSE",
]
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def git_blob(path: Path) -> str:
data = path.read_bytes()
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
def check_inputs(src: Path) -> list[str]:
"""Return one line for each input file that is missing or different."""
problems = []
for checks, hash_file in ((SHA256, sha256), (GIT_BLOB, git_blob)):
for name, expected in checks.items():
path = src / name
if not path.is_file():
problems.append(f"MISSING {name}")
elif hash_file(path) != expected:
problems.append(f"CHANGED {name}")
else:
print(f"OK {name}")
return problems
def quantize(src: Path, dst: Path) -> None:
import contextlib
import importlib
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
from transformers import Qwen3_5ForConditionalGeneration
# Qwen's norms compute x * (1 + w). llm-compressor changes w to 1 + w for
# calibration and back after, in BF16, which rounds every norm weight
# (llmcompressor/modeling/offset_norm.py, still so in 0.14.0). FP8_BLOCK
# without data does not use the norms, so this skips the change.
# check_output() proves that the norms stay bit-identical. import_module
# gives the module: the package attribute of the same name is the function.
oneshot_module = importlib.import_module("llmcompressor.entrypoints.oneshot")
oneshot_module.norm_calibration_context = lambda model: contextlib.nullcontext()
model = Qwen3_5ForConditionalGeneration.from_pretrained(src, dtype=torch.bfloat16)
oneshot(model=model, recipe=QuantizationModifier(targets="Linear", scheme="FP8_BLOCK", ignore=IGNORE))
# Shards and an index, as in Cloudflare's release. The model server finds
# lm_head through model.safetensors.index.json.
model.save_pretrained(dst, save_compressed=True, max_shard_size="5GB")
index = dst / "model.safetensors.index.json"
if not index.exists():
# A model that fits in one shard gets no index. Write it.
with safe_open(str(dst / "model.safetensors"), framework="pt") as f:
weight_map = {name: "model.safetensors" for name in f.keys()}
index.write_text(json.dumps({"metadata": {}, "weight_map": weight_map}, indent=2) + "\n")
class Tensors:
"""Read tensors by name from a directory of safetensors shards."""
def __init__(self, path: Path):
self.shard_of = json.loads((path / "model.safetensors.index.json").read_text())["weight_map"]
self.files = {s: safe_open(str(path / s), framework="pt", device="cpu") for s in set(self.shard_of.values())}
def names(self) -> set[str]:
return set(self.shard_of)
def get(self, name: str) -> torch.Tensor:
return self.files[self.shard_of[name]].get_tensor(name)
def check_output(src: Path, dst: Path) -> tuple[list[float], list[str]]:
"""Compare the output with Cloudflare's tensors.
Return the relative error of each FP8 weight, and a list of problems.
"""
before, after = Tensors(src), Tensors(dst)
errors, problems = [], []
for name in sorted(before.names()):
if name not in after.names():
problems.append(f"missing: {name}")
continue
old, new = before.get(name), after.get(name)
if new.dtype == torch.float8_e4m3fn:
rows, cols = new.shape
scale = after.get(name + "_scale").float()[:, None, :, None]
restored = (new.float().reshape(rows // 128, 128, cols // 128, 128) * scale).reshape(rows, cols)
errors.append(((restored - old.float()).norm() / old.float().norm()).item())
elif new.dtype != old.dtype or not torch.equal(new, old):
problems.append(f"changed: {name}")
allowed = before.names() | {name + "_scale" for name in before.names()}
problems += [f"unexpected: {name}" for name in sorted(after.names() - allowed)]
if len(errors) != EXPECTED_FP8:
problems.append(f"{len(errors)} FP8 weights, expected {EXPECTED_FP8}")
return errors, problems
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--src", required=True, type=Path, help="Cloudflare's clef-flash directory.")
parser.add_argument("--dst", required=True, type=Path, help="New, empty output directory.")
args = parser.parse_args()
src, dst = args.src, args.dst
print(f"Checking the input files against {SOURCE}")
problems = check_inputs(src)
if problems:
print("\n".join(problems))
sys.exit("The input files are not Cloudflare's published files. Nothing was written.")
if dst.exists() and any(dst.iterdir()):
sys.exit(f"{dst} is not empty.")
dst.mkdir(parents=True, exist_ok=True)
print("Quantizing with llm-compressor (FP8_BLOCK)")
quantize(src, dst)
quantization_config = json.loads((dst / "config.json").read_text())["quantization_config"]
config = json.loads((src / "config.json").read_text())
config["quantization_config"] = quantization_config
(dst / "config.json").write_text(json.dumps(config, indent=2) + "\n")
for name in COPY:
if (src / name).is_file():
shutil.copyfile(src / name, dst / name)
print("Checking the output against Cloudflare's tensors")
errors, problems = check_output(src, dst)
if problems:
print("\n".join(problems))
sys.exit(f"The output in {dst} is not valid. Do not use it.")
# Apache-2.0 asks for a notice on changed files.
(dst / "QUANTIZATION.md").write_text(
f"This directory is a modified copy of {SOURCE} (Apache-2.0).\n\n"
"Changed files: the model-*.safetensors shards, model.safetensors.index.json and config.json.\n"
"quantize_fp8.py converted the MLP, full-attention and large linear-attention weights of the\n"
"language model to FP8 with llm-compressor (FP8_BLOCK: one scale for each 128x128 block).\n"
"All other tensors are bit-identical to the original. MANIFEST.json lists the hash of each file.\n"
)
import compressed_tensors
import llmcompressor
import transformers
manifest = {
"source": SOURCE,
"script_sha256": sha256(Path(__file__)),
"versions": {
"llmcompressor": llmcompressor.__version__,
"compressed_tensors": compressed_tensors.__version__,
"transformers": transformers.__version__,
"torch": torch.__version__,
},
"recipe": {"targets": "Linear", "scheme": "FP8_BLOCK", "ignore": IGNORE},
"fp8_weights": len(errors),
"relative_error_mean": round(sum(errors) / len(errors), 6),
"relative_error_max": round(max(errors), 6),
"files": {p.name: sha256(p) for p in sorted(dst.iterdir()) if p.is_file() and p.name != "MANIFEST.json"},
}
(dst / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
size = sum(p.stat().st_size for p in dst.glob("model-*.safetensors")) / 2**30
print(
f"\nOK: {len(errors)} weights in FP8 (relative error mean {manifest['relative_error_mean']:.2%}, "
f"max {manifest['relative_error_max']:.2%}). All other tensors are bit-identical.\n"
f"Weights: {size:.2f} GiB. Output: {dst}"
)
if __name__ == "__main__":
main()
|