Download scripts/quantize_fp8.py from isaranto/clef-flash-FP8-block: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/isaranto/clef-flash-FP8-block/resolve/main/scripts/quantize_fp8.py
- Command line
-
hf download hf://isaranto/clef-flash-FP8-block/scripts/quantize_fp8.py
-
curl -L -o quantize_fp8.py https://huggingface.co/isaranto/clef-flash-FP8-block/resolve/main/scripts/quantize_fp8.py
11.1 kB
| #!/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() | |