tools: IQ3_XXS-codebook scheme in the quant ablation (#452 step 1)

Adds `-iq3` to the ablation harness: a faithful torch model of llama.cpp's
deployed 3.06-bpw IQ3_XXS format — 256-entry 4-dim magnitude grid
(extracted from ggml-common.h, MIT), signs factored per 8 weights with
the odd-parity constraint priced in (a violating block flips its
smallest-magnitude sign), fp16 super-scale per 256 + 4-bit sub-scale per
32 searched over all 16 codes. Nearest-grid search runs as chunked
matmul-argmin (|g|^2 - 2 q.g) — a full cdist materializes tens of GB on
a 100M-param tensor and OOMed the first run.

Measured (OLMoE-1B-7B, n=200 x hellaswag/arc/mmlu; the first four rows
reproduce the published ablations exactly):

  fp16                58.0%
  int4 per-row        48.7%   (-9.3pp, the shipped container's scheme)
  int3-g64            50.5%   (-7.5pp)
  int3-g64-e8-rot     51.5%   (-6.5pp, simulated rate-scaled ball)
  int3-iq3            49.3%   (-8.7pp)
  int3-iq3-rot        51.5%   (-6.5pp)

The deployable IQ3 codebook plus rotation exactly ties the simulated E8
ball — that settles #452's codebook decision toward the IQ3-style block
structure, with rotation mandatory (worth 2.2pp on this codebook).
This commit is contained in:
ZacharyZcR
2026-07-20 12:55:44 +08:00
parent e9b36141a4
commit ae4e31a15a
2 changed files with 70 additions and 1 deletions
+69 -1
View File
@@ -117,11 +117,79 @@ def quantize_param(w, bits, group, rot=False, e8=""):
def _grid_or_e8(x, bits, group, e8):
if e8 == "-iq3":
return _quant_iq3(x.float())
if e8:
return _quant_e8(x.float(), group, bits, ball=(e8 == "-e8"))
return _quant_last_dim(x, bits, group)
# --------------------------------------------------------------------------------------
# IQ3_XXS-style codebook (#452 candidate (a)): llama.cpp's deployed 3.06-bpw scheme.
# 4-dim magnitude blocks quantized to a 256-entry lattice-subset grid (magnitudes on the
# odd ladder 4,12,..,62 in half-units), signs factored out per 8 weights with an odd-parity
# constraint (7 stored + 1 derived: a block whose true signs violate parity gets its
# smallest-magnitude sign flipped — modelled here so the ablation pays the real cost).
# Scales: fp16 super-scale per 256 + 4-bit sub-scale per 32, db = d*(0.5+s)*0.5.
# Grid extracted from ggml-common.h (MIT).
# --------------------------------------------------------------------------------------
_IQ3_GRID = None
def _iq3_grid(device):
global _IQ3_GRID
if _IQ3_GRID is None or _IQ3_GRID.device != device:
import json, os
path = os.path.join(os.path.dirname(__file__), "iq3xxs_grid.json")
_IQ3_GRID = torch.tensor(json.load(open(path)), dtype=torch.float32, device=device)
return _IQ3_GRID # [256,4], half-unit magnitudes (value/2 = weight units)
def _quant_iq3(x):
orig = x.shape
K = orig[-1]
assert K % 256 == 0, "iq3 needs multiples of 256 along the input dim"
xb = x.reshape(-1, 256) # super-blocks
grid = _iq3_grid(x.device) * 0.5 # weight units
out = torch.empty_like(xb)
signs = torch.sign(xb); signs[signs == 0] = 1.0
mags = xb.abs()
for sb in range(8): # 8 sub-blocks of 32
m = mags[:, sb*32:(sb+1)*32] # [N,32]
s = signs[:, sb*32:(sb+1)*32]
# per-8 sign parity: flip the smallest-|w| sign where the product is negative
s8 = s.reshape(-1, 4, 8)
m8 = m.reshape(-1, 4, 8)
viol = (s8.prod(-1) < 0) # odd number of minus signs
idxmin = m8.argmin(-1)
flip = torch.zeros_like(s8)
flip.scatter_(-1, idxmin[..., None], 1.0)
s8 = torch.where(viol[..., None].expand_as(s8) & (flip > 0), -s8, s8)
s = s8.reshape(-1, 32)
# sub-scale search: db candidates from the 4-bit code, super d from block RMS
d = m.pow(2).mean(-1, keepdim=True).sqrt() / 20.0 + 1e-12 # rough anchor
best = None
for code in range(16):
db = d * (0.5 + code) * 0.5
q = m / db # [N,32] target magnitudes
q4 = q.reshape(-1, 4) # 4-dim grid blocks
# chunked argmin ||q-g||^2 = argmin(|g|^2 - 2 q.g): a full cdist on a
# 100M-param tensor materializes tens of GB — this stays at ~256 MB.
g2 = grid.pow(2).sum(-1)
idx = torch.empty(q4.shape[0], dtype=torch.long, device=q4.device)
CH = 1 << 18
for i0 in range(0, q4.shape[0], CH):
cc = q4[i0:i0+CH]
idx[i0:i0+CH] = (g2 - 2.0 * (cc @ grid.T)).argmin(-1)
hit = grid[idx].reshape(-1, 8, 4)
rec = (hit.reshape(-1, 32) * db)
err = (rec - m).pow(2).sum(-1, keepdim=True)
if best is None:
best = (err, rec)
else:
take = err < best[0]
best = (torch.where(take, err, best[0]), torch.where(take, rec, best[1]))
out[:, sb*32:(sb+1)*32] = best[1] * s
return out.reshape(orig)
def _rot_quant(x, bits, group, e8=""):
"""W -> Qn(W@Q) @ Q^T along the last (input) dim — see rotation() above."""
q = rotation(x.shape[-1], x.device)
@@ -206,7 +274,7 @@ def _quant_e8(x, group, bits, ball):
return best_out.reshape(shp)
SCHEME_RE = re.compile(r"^int(2|3|4|8)(?:-g(\d+))?(-e8u?)?(-rot)?(-nohead)?$")
SCHEME_RE = re.compile(r"^int(2|3|4|8)(?:-g(\d+))?(-e8u?|-iq3)?(-rot)?(-nohead)?$")
def parse_scheme(name):