Skip to content

Commit a5cbeba

Browse files
kmbandyclaude
andcommitted
fix(MAD-223): save per-row-per-group scale + add ml8_io reconstruction module
centroid_quantizer.py.export() docstring claimed to return scale_per_group but actually didn't — the saved .pt artifacts from calibrate_ml8.py were missing the scale tensor, making them un-reconstructable from disk. (The PPL eval in calibrate_ml8.py happens to work because it operates on the in-memory model whose weights are overwritten directly with Q; the disk format gap only surfaces when you try to reload.) Fix: - centroid_quantizer.py: accumulate per-find_params scales into collected_scales (parallel to collected_centroids), include them in export()'s return dict and reset_capture(). - calibrate_ml8.py: save scale_per_group in each layer's .pt blob. Add reconstruction-formula docstring inline. - ml8_io.py (NEW): load_ml8_layer + reconstruct_weight utilities for dequantizing a layer's saved blob back to a fp32 weight tensor. Includes bits_per_value calculator (verified: 4.125 bpv for 9216x2560 group=128 n_centroids=16 — under MAD-223's 4.5 bpv design target) and a CPU-only self-test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d0b2819 commit a5cbeba

3 files changed

Lines changed: 174 additions & 3 deletions

File tree

scripts/calibration/calibrate_ml8.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,12 @@ def main():
373373
"shape": [rows, in_feat],
374374
"group_size": args.group_size,
375375
"n_centroids": args.n_centroids,
376-
"indices": export["indices"].cpu(),
377-
"centroids_per_group": export["centroids_per_group"].cpu(),
376+
# Reconstruction:
377+
# W[r, c] = centroids_per_group[c // group_size][indices[r, c]]
378+
# * scale_per_group[r, c // group_size]
379+
"indices": export["indices"].cpu(), # [rows, in_features] int8 (values 0..15)
380+
"centroids_per_group": export["centroids_per_group"].cpu(), # [n_groups, n_centroids] fp32
381+
"scale_per_group": export["scale_per_group"].cpu(), # [rows, n_groups] fp32
378382
"mse": export["mse"],
379383
"w_snr_db": export["w_snr_db"],
380384
"y_snr_db": export["y_snr_db"],

scripts/calibration/centroid_quantizer.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ def __init__(self, n_centroids: int = 16, n_iter: int = 25):
6060
self.per_column_indices: list[torch.Tensor] = []
6161
# Per-group centroid LUTs (one per find_params() call):
6262
self.collected_centroids: list[torch.Tensor] = []
63+
# Per-group per-row scales (one [rows, 1] per find_params() call).
64+
# Required to reconstruct the quantized weight from saved indices+centroids:
65+
# W_reconstructed[r, c] = centroids[group_of(c)][indices[r, c]] * scales[r, group_of(c)]
66+
self.collected_scales: list[torch.Tensor] = []
6367
# For auto-gptq compatibility — it checks .maxq > 0 in enabled() and
6468
# all(scale != 0) in ready().
6569
self.register_buffer("maxq", torch.tensor(self.n_centroids - 1))
@@ -125,8 +129,9 @@ def find_params(self, x: torch.Tensor, weight: bool = True):
125129
# group. Centroid quant has no zero-point offset; expose a zero-filled
126130
# tensor of matching shape so the collection logic doesn't choke.
127131
self.zero = torch.zeros_like(self.scale)
128-
# Stash this group's LUT for export.
132+
# Stash this group's LUT and per-row scale for export.
129133
self.collected_centroids.append(self.centroids.detach().clone())
134+
self.collected_scales.append(self.scale.detach().clone()) # [rows, 1]
130135

131136
def quantize(self, x: torch.Tensor) -> torch.Tensor:
132137
"""Snap each value in x to nearest centroid*scale. Stores indices.
@@ -172,16 +177,20 @@ def export(self) -> dict:
172177
raise RuntimeError("no quantize() calls captured; did fasterquant run?")
173178
indices = torch.cat(self.per_column_indices, dim=1) # [rows, in_features]
174179
centroids = torch.stack(self.collected_centroids, dim=0) # [n_groups, n_centroids]
180+
# scales: [rows, n_groups]. Cat list of [rows, 1] tensors along dim=1.
181+
scales = torch.cat(self.collected_scales, dim=1)
175182
return {
176183
"indices": indices,
177184
"centroids_per_group": centroids,
185+
"scale_per_group": scales,
178186
}
179187

180188
def reset_capture(self):
181189
"""Clear per-call accumulators so the same quantizer can be reused on
182190
the next layer."""
183191
self.per_column_indices.clear()
184192
self.collected_centroids.clear()
193+
self.collected_scales.clear()
185194
self.last_indices = None
186195
self.centroids = None
187196
self.scale = None

scripts/calibration/ml8_io.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""ml8_io — load and reconstruct saved per-layer ml8-4 quantized weights.
2+
3+
Companion to scripts/calibration/calibrate_ml8.py. Each .pt file produced by
4+
the driver contains:
5+
6+
{
7+
"name": str, # tensor name (e.g. "model.layers.0.mlp.up_proj")
8+
"shape": [rows, in_features],
9+
"group_size": int, # e.g. 128
10+
"n_centroids": int, # 16 for ml8-4
11+
"indices": int8 [rows, in_features], # values 0..n_centroids-1
12+
"centroids_per_group": fp32 [n_groups, n_centroids],
13+
"scale_per_group": fp32 [rows, n_groups],
14+
"mse": float,
15+
"w_snr_db": float,
16+
"y_snr_db": float,
17+
"rel_err": float,
18+
}
19+
20+
Reconstruction formula:
21+
g = c // group_size # which group column c belongs to
22+
W[r, c] = centroids_per_group[g][indices[r, c]] * scale_per_group[r, g]
23+
24+
Usage:
25+
from ml8_io import load_ml8_layer, reconstruct_weight
26+
blob = load_ml8_layer("/path/to/layer.pt")
27+
W = reconstruct_weight(blob)
28+
assert tuple(W.shape) == tuple(blob["shape"])
29+
"""
30+
31+
from __future__ import annotations
32+
33+
from pathlib import Path
34+
from typing import Any
35+
36+
import torch
37+
38+
39+
def load_ml8_layer(path: str | Path) -> dict[str, Any]:
40+
"""Load a single layer's ml8-4 blob."""
41+
return torch.load(path, map_location="cpu", weights_only=True)
42+
43+
44+
def reconstruct_weight(blob: dict[str, Any]) -> torch.Tensor:
45+
"""Dequantize: produce the float reconstruction of the original weight."""
46+
indices = blob["indices"] # [rows, in_features] int
47+
centroids = blob["centroids_per_group"] # [n_groups, n_centroids] fp
48+
scales = blob["scale_per_group"] # [rows, n_groups] fp
49+
group_size = int(blob["group_size"])
50+
rows, in_features = blob["shape"]
51+
n_groups = centroids.shape[0]
52+
53+
# Sanity
54+
assert indices.shape == (rows, in_features), \
55+
f"indices shape {tuple(indices.shape)} != {(rows, in_features)}"
56+
assert scales.shape == (rows, n_groups), \
57+
f"scales shape {tuple(scales.shape)} != {(rows, n_groups)}"
58+
59+
# For each column c, group index g = c // group_size.
60+
# Vectorized: build column->group mapping and gather.
61+
dev = indices.device
62+
centroids = centroids.to(dev).float()
63+
scales = scales.to(dev).float()
64+
indices = indices.long() # for gather
65+
66+
col_idx = torch.arange(in_features, device=dev)
67+
group_idx = col_idx // group_size # [in_features]
68+
69+
# For each (r, c): val = centroids[group_idx[c], indices[r, c]] * scales[r, group_idx[c]]
70+
# Build per-column centroid LUT broadcast: [in_features, n_centroids] = centroids[group_idx]
71+
cent_cols = centroids[group_idx] # [in_features, n_centroids]
72+
# Gather centroid value per (r, c): need centroids[group_idx[c], indices[r, c]]
73+
# Equivalent: cent_cols[c, indices[r, c]]
74+
# Use advanced indexing.
75+
# Expand cent_cols to [1, in_features, n_centroids], gather along last dim by indices [rows, in_features, 1]
76+
centroid_vals = torch.gather(
77+
cent_cols.unsqueeze(0).expand(rows, -1, -1), # [rows, in_features, n_centroids]
78+
2,
79+
indices.unsqueeze(-1) # [rows, in_features, 1]
80+
).squeeze(-1) # [rows, in_features]
81+
82+
# Per-column scale lookup: scales[r, group_idx[c]] → [rows, in_features]
83+
scale_cols = scales[:, group_idx] # [rows, in_features]
84+
85+
return centroid_vals * scale_cols
86+
87+
88+
def bits_per_value(blob: dict[str, Any], scale_dtype_bits: int = 16) -> float:
89+
"""Compute effective bits-per-value for this layer's ml8 encoding.
90+
91+
indices: ceil(log2(n_centroids)) bits per value (typically 4)
92+
centroids: n_centroids * lut_bits per group (amortized over rows*group_size values)
93+
scales: scale_dtype_bits per (row, group) = (rows * n_groups * scale_bits) / numel
94+
95+
For ml8-4 (n_centroids=16, group_size=128, scales fp16):
96+
idx_bits = 4
97+
centroid overhead per value = 16 * 32 / (rows * 128) (negligible for rows ≫ 1)
98+
scale overhead per value = 16 / 128 = 0.125
99+
Total ≈ 4.125 bpv (excluding small centroid overhead)
100+
"""
101+
rows, in_features = blob["shape"]
102+
group_size = int(blob["group_size"])
103+
n_groups = (in_features + group_size - 1) // group_size
104+
n_centroids = int(blob["n_centroids"])
105+
106+
idx_bits = (n_centroids - 1).bit_length() # ceil(log2(n_centroids))
107+
numel = rows * in_features
108+
idx_total = numel * idx_bits
109+
centroid_total = n_groups * n_centroids * 32 # centroids stored fp32
110+
scale_total = rows * n_groups * scale_dtype_bits
111+
112+
total_bits = idx_total + centroid_total + scale_total
113+
return total_bits / numel
114+
115+
116+
# Tiny self-test ─────────────────────────────────────────────────────────────
117+
118+
def _self_test() -> bool:
119+
"""Synthetic round-trip: build a fake blob, reconstruct, verify shape + values."""
120+
rows, in_features, group_size, n_centroids = 8, 16, 4, 16
121+
n_groups = in_features // group_size
122+
# Random centroids in [-1, 1]
123+
centroids = torch.linspace(-1, 1, n_centroids).unsqueeze(0).expand(n_groups, -1).clone()
124+
# Random per-row scales
125+
scales = torch.rand(rows, n_groups) + 0.1
126+
# Random indices
127+
indices = torch.randint(0, n_centroids, (rows, in_features), dtype=torch.int8)
128+
129+
blob = {
130+
"name": "test",
131+
"shape": [rows, in_features],
132+
"group_size": group_size,
133+
"n_centroids": n_centroids,
134+
"indices": indices,
135+
"centroids_per_group": centroids,
136+
"scale_per_group": scales,
137+
}
138+
139+
W = reconstruct_weight(blob)
140+
assert tuple(W.shape) == (rows, in_features), W.shape
141+
142+
# Spot-check: a few (r, c) values
143+
for r in range(rows):
144+
for c in range(in_features):
145+
g = c // group_size
146+
expected = centroids[g, indices[r, c].long()] * scales[r, g]
147+
got = W[r, c]
148+
assert torch.allclose(got, expected, atol=1e-6), \
149+
f"mismatch at ({r}, {c}): expected {expected} got {got}"
150+
151+
bpv = bits_per_value(blob)
152+
print(f" self-test PASSED. {rows}x{in_features} group={group_size} "
153+
f"n_centroids={n_centroids}{bpv:.3f} bpv")
154+
return True
155+
156+
157+
if __name__ == "__main__":
158+
_self_test()

0 commit comments

Comments
 (0)