File size: 7,410 Bytes
fdc6474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Convert BF16 attention / shared_experts / dense-MLP linears to FP8 e4m3.

Targets (layers 0..77 only; layer 78 = MTP is copied verbatim):
  - model.layers.N.self_attn.{q_a_proj,q_b_proj,kv_a_proj_with_mqa,
    kv_b_proj,o_proj}.weight   (discovered from the index; indexer.* and
    *layernorm* are never touched)
  - model.layers.N.mlp.shared_experts.{gate_proj,up_proj,down_proj}.weight
  - model.layers.{0,1,2}.mlp.{gate_proj,up_proj,down_proj}.weight

Scale convention: PER-TENSOR (one fp32 scalar per module, stored as
<prefix>.weight_scale shape [1]). vLLM's Fp8LinearMethod (non-block path)
registers PerTensorScaleParameter and requantizes fused shards with the max
scale; per-output-channel scales are NOT loadable there, so we fall back to
per-tensor (correctness over granularity).

Modules that vLLM fuses (q_a_proj + kv_a_proj_with_mqa -> fused_qkv_a_proj,
gate_proj + up_proj -> gate_up_proj) share one scale (the group max) so the
load-time requantize_with_max_scale is a bit-exact identity instead of a
second lossy rounding.

Never modifies SRC; writes DST fresh.

Usage: convert_fp8_attn.py SRC DST
"""
import json
import os
import re
import shutil
import sys

import torch
from safetensors import safe_open
from safetensors.torch import save_file

SRC = sys.argv[1].rstrip("/")
DST = sys.argv[2].rstrip("/")
SHARD_BYTES = 4 << 30
FP8_MAX = 448.0
NUM_DECODER_LAYERS = 78  # layer 78 == MTP, untouched

assert os.path.abspath(DST) != os.path.abspath(SRC), "refuse overwrite"
assert not os.path.exists(os.path.join(DST, "model.safetensors.index.json")), \
    "DST already has a checkpoint"

wm = json.load(open(f"{SRC}/model.safetensors.index.json"))["weight_map"]

# Discover attention projection names actually present (verify, don't assume).
attn_projs = set()
for k in wm:
    m = re.match(r"model\.layers\.(\d+)\.self_attn\.([\w.]+)\.weight$", k)
    if not m or int(m.group(1)) >= NUM_DECODER_LAYERS:
        continue
    sub = m.group(2)
    if sub.startswith("indexer") or "norm" in sub:
        continue
    attn_projs.add(sub)
# optional argv[3]: comma-list restricting WHICH attn projections convert
# (shared_experts/dense always included); e.g. "o_proj" for the non-KV-path
# ablation. "none" = no attention projections at all.
if len(sys.argv) > 3:
    want = set() if sys.argv[3] == "none" else set(sys.argv[3].split(","))
    attn_projs &= want
print("attention projections found:", sorted(attn_projs))

MLP_PROJS = ("gate_proj", "up_proj", "down_proj")


def target_prefix(name: str) -> str | None:
    """Return module prefix (name minus .weight) if this tensor is a target."""
    m = re.match(r"model\.layers\.(\d+)\.(.+)\.weight$", name)
    if not m or int(m.group(1)) >= NUM_DECODER_LAYERS:
        return None
    li, rest = int(m.group(1)), m.group(2)
    if rest.startswith("self_attn.") and rest[len("self_attn."):] in attn_projs:
        return name[: -len(".weight")]
    if rest.startswith("mlp.shared_experts.") and \
            rest[len("mlp.shared_experts."):] in MLP_PROJS:
        return name[: -len(".weight")]
    if li < 3 and rest.startswith("mlp.") and rest[len("mlp."):] in MLP_PROJS:
        return name[: -len(".weight")]
    return None


class Writer:
    def __init__(self):
        os.makedirs(DST, exist_ok=True)
        self.cur, self.cur_bytes, self.n, self.total = {}, 0, 0, 0
        self.weight_map, self.files = {}, []

    def add(self, name, t):
        nb = t.numel() * t.element_size()
        if self.cur_bytes + nb > SHARD_BYTES and self.cur:
            self.flush()
        self.cur[name] = t
        self.cur_bytes += nb
        self.total += nb

    def flush(self):
        if not self.cur:
            return
        self.n += 1
        f = f"model-{self.n:05d}.safetensors"
        save_file(self.cur, f"{DST}/{f}")
        for k in self.cur:
            self.weight_map[k] = f
        self.files.append(f)
        self.cur, self.cur_bytes = {}, 0

    def finalize(self):
        self.flush()
        out = {}
        for i, f in enumerate(self.files, 1):
            new = f"model-{i:05d}-of-{self.n:05d}.safetensors"
            os.rename(f"{DST}/{f}", f"{DST}/{new}")
            for k, v in self.weight_map.items():
                if v == f:
                    out[k] = new
        json.dump({"metadata": {"total_size": self.total},
                   "weight_map": out},
                  open(f"{DST}/model.safetensors.index.json", "w"), indent=0)
        print(f"index: {len(out)} tensors, {self.total/2**30:.2f} GiB")


# vLLM fusion partners: shards of one fused module must share a scale so
# requantize_with_max_scale at load time is a no-op.
_PARTNER = {"q_a_proj": "kv_a_proj_with_mqa", "kv_a_proj_with_mqa": "q_a_proj",
            "gate_proj": "up_proj", "up_proj": "gate_proj"}


def partner_of(pfx: str) -> str | None:
    parent, _, leaf = pfx.rpartition(".")
    other = _PARTNER.get(leaf)
    return f"{parent}.{other}" if other else None


# Pass 1: per-module amax for every target, then group-shared scales.
amax = {}
for shard in sorted(set(wm.values())):
    with safe_open(f"{SRC}/{shard}", framework="pt") as f:
        for name in f.keys():
            pfx = target_prefix(name)
            if pfx is not None:
                t = f.get_tensor(name)
                assert t.dtype == torch.bfloat16 and t.dim() == 2, \
                    (name, t.dtype, t.shape)
                amax[pfx] = t.abs().amax().float()
scales = {}
for pfx, a in amax.items():
    p = partner_of(pfx)
    if p is not None:
        a = torch.maximum(a, amax[p])
    scales[pfx] = (a / FP8_MAX).clamp(min=1e-12)
print(f"pass 1: {len(scales)} target modules")


def quantize(name: str, w: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
    """Per-tensor (fusion-group-shared) fp8 e4m3: (w_fp8, scale fp32 [1])."""
    s = scales[name[: -len(".weight")]]
    q = (w.float() / s).clamp(-FP8_MAX, FP8_MAX).to(torch.float8_e4m3fn)
    return q, s.reshape(1)


w = Writer()
fp8_modules, bytes_before, bytes_after = [], 0, 0
for shard in sorted(set(wm.values())):
    with safe_open(f"{SRC}/{shard}", framework="pt") as f:
        for name in sorted(f.keys()):
            t = f.get_tensor(name)
            pfx = target_prefix(name)
            if pfx is None:
                w.add(name, t)
                continue
            q, s = quantize(name, t)
            w.add(name, q)
            w.add(f"{pfx}.weight_scale", s)
            fp8_modules.append(pfx)
            bytes_before += t.numel() * t.element_size()
            bytes_after += q.numel() + s.numel() * 4
    print(f"{shard} done", flush=True)
w.finalize()

fp8_modules.sort()
assert len(fp8_modules) == len(set(fp8_modules))
cfg = json.load(open(f"{SRC}/config.json"))
cfg["quantization_config"]["fp8_modules"] = fp8_modules
json.dump(cfg, open(f"{DST}/config.json", "w"), indent=2)

for f in os.listdir(SRC):
    if (f.endswith(".json")
            and f not in ("config.json", "model.safetensors.index.json")
            or f.endswith((".txt", ".jinja", ".py", ".md", ".sh"))):
        shutil.copy2(f"{SRC}/{f}", f"{DST}/{f}")

print(f"converted modules: {len(fp8_modules)}")
by_kind = {}
for p in fp8_modules:
    by_kind[p.split(".")[-1]] = by_kind.get(p.split(".")[-1], 0) + 1
print("by kind:", by_kind)
print(f"bytes converted: {bytes_before/2**30:.2f} GiB -> "
      f"{bytes_after/2**30:.2f} GiB")
print("DONE:", DST)