Angshul commited on
Commit
c675b8d
·
verified ·
1 Parent(s): 31b31dc

Upload 17 files

Browse files
README.md CHANGED
@@ -1,3 +1,131 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # shapequant
2
+
3
+ Data-free, training-free quantization of Hugging Face causal language models.
4
+
5
+ No calibration text and no fine-tuning are used. The method builds each layer's input covariance from the weights
6
+ themselves and uses it to place rounding error where the inputs don't reach (noise-shaped rounding). The output is a
7
+ standard **GPTQ-format** checkpoint (INT4/INT8, asymmetric, group-wise), so GPTQ-capable stacks can serve it.
8
+ A dependency-light loader is included.
9
+
10
+ Supported `model_type`s:
11
+ * `llama`, `mistral`, `qwen2`: includes Llama-2/3/3.1/3.3-70B and Qwen2.5-72B.
12
+ * `opt` (pre-LN), `gpt2`.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install -e . # torch, transformers, accelerate, safetensors, huggingface_hub
18
+ ```
19
+
20
+ ## Python API
21
+
22
+ ```python
23
+ from shapequant import quantize_model, load_quantized, perplexity, wikitext2_windows, estimate_memory
24
+
25
+ # uniform INT4, groups of 128 (default method: data-free shaped rounding)
26
+ quantize_model("meta-llama/Llama-3.3-70B-Instruct", "llama70b-sq-int4", bits=4, group_size=128, device="cuda")
27
+
28
+ # mixed INT4/INT8/FP16 at 4.75 bits/weight (type rule: V, attention-output and MLP-output projections weighted 4x)
29
+ quantize_model("HuggingFaceTB/SmolLM2-135M", "smol-sq-mixed", budget=4.75, group_size=64)
30
+
31
+ # baseline on the same grid
32
+ quantize_model("facebook/opt-125m", "opt-rtn-int4", bits=4, method="rtn")
33
+
34
+ model = load_quantized("smol-sq-mixed", device="cpu") # or device_map="auto"
35
+ ```
36
+
37
+ Key arguments of `quantize_model(model, out_dir, ...)`:
38
+
39
+ | argument | default | meaning |
40
+ |---|---|---|
41
+ | `bits` | 4 | 4 or 8, uniform |
42
+ | `group_size` | 128 | must divide every input dimension (use 64 for hidden size 576, etc.) |
43
+ | `method` | `"shaped"` | `"shaped"` (data-free covariance) or `"rtn"` (round to nearest, same grid) |
44
+ | `budget` | None | average bits/weight for mixed INT4/INT8/FP16 (must be ≥ INT4 cost, 4 + 32/group) |
45
+ | `lam`, `kappa` | 1.0, 1.0 | design H = I + lam·S; kappa = attention concentration (1 = theory value) |
46
+ | `n_samples` | 16384 | Monte Carlo samples for the MLP-input covariance |
47
+ | `dynamic_groups` | True | fit each group's grid to the error-fed weights (standard GPTQ); False = grids from original weights |
48
+ | `device` | cuda if available | where the per-layer work runs |
49
+
50
+ ## CLI
51
+
52
+ ```bash
53
+ shapequant memory meta-llama/Llama-3.1-70B # peak-memory estimate from the config only
54
+ shapequant quantize meta-llama/Llama-3.1-70B out/ --bits 4 --group-size 128 --device cuda
55
+ shapequant quantize HuggingFaceTB/SmolLM2-135M out/ --budget 5.0 --group-size 64
56
+ shapequant eval out/ --windows 32 # WikiText-2 perplexity (needs `datasets`) or --text file.txt
57
+ ```
58
+
59
+ ## How it scales to 70B
60
+
61
+ * **Streaming.** Weights are read tensor by tensor from the safetensors shards. Only one decoder layer is ever
62
+ resident, and it is written out before the next one is read. Host RAM is not proportional to model size.
63
+ * **Per-layer device work.**
64
+ * The residual-stream second moment (d×d) and the norm-output moments are updated layer by layer.
65
+ * Monte Carlo of the layer's own MLP nonlinearity gives the (inter×inter) MLP-input covariance.
66
+ * Error-feedback rounding uses lazy block updates (block 128). Modules that read the same input
67
+ (q/k/v, gate/up) are stacked and rounded in one pass.
68
+ * **Peak device memory** is about one layer in fp32 plus two (in×in) fp32 matrices for the largest input.
69
+ For Llama-3.1-70B the largest input is the 28672-wide MLP input. `shapequant memory <model>` prints the estimate.
70
+ * **Output.** Sharded `model-0000x-of-0000y.safetensors` (4 GB shards by default) + index + `config.json`
71
+ with `quantization_config` (`quant_method: gptq`, `checkpoint_format: gptq`, per-module overrides under `dynamic`).
72
+
73
+ ## Checkpoint format
74
+
75
+ The checkpoint is GPTQ v1, as written by AutoGPTQ/GPTQModel:
76
+ * `qweight` int32 (in·b/32, out); `qzeros` int32 (G, out·b/32), stored as z − 1; `scales` fp16 (G, out);
77
+ `g_idx` = arange(in)//group; `sym=False`, `desc_act=False`.
78
+ * Zero-points are kept ≥ 1, so the v1 "z − 1" convention never underflows.
79
+ * Mixed-precision modules are listed in `quantization_config["dynamic"]`:
80
+ * `"+:<regex>": {"bits": 8}` for INT8 modules;
81
+ * `"-:<regex>": {}` for modules left in FP16 (GPTQModel convention).
82
+ * Everything else (embeddings, norms, untied lm_head) is stored in fp16.
83
+
84
+ ## Results
85
+
86
+ `RESULTS.md` has the full numbers. Summary (WikiText-2, 32 × 1024 tokens; perplexity, lower is better):
87
+
88
+ | model | dense | RTN INT4 | shaped INT4 | RTN mixed | shaped mixed |
89
+ |---|---|---|---|---|---|
90
+ | GPT-2 124M (g128, mixed 4.75 b/w) | 26.84 | 29.91 | **29.14** | 29.62 | **28.86** |
91
+ | OPT-125m (g128, mixed 4.75 b/w) | 29.27 | 31.98 | **31.39** | 30.71 | **30.44** |
92
+ | SmolLM2-135M (g64, mixed 5.0 b/w) | 15.46 | **19.28** | 19.29 | **17.82** | 17.83 |
93
+
94
+ At standard INT4 the gain over RTN is modest (−2.6%, −1.9%, +0.1%). It was much larger at 3 bits in the research
95
+ experiments, but 3-bit is not a standard packed format and is not exported here.
96
+ Reproduce with `python tests/real_models.py <model> <out> --group 128 --budget 4.75`.
97
+
98
+ ## Verified here, and not
99
+
100
+ * **Verified:**
101
+ * unit tests and tiny-model round trips for llama / qwen2 / opt / gpt2;
102
+ * real GPT-2, OPT-125m and SmolLM2-135M runs (quantize → save → load → perplexity) on CPU;
103
+ * memory estimates from the real 70B configs;
104
+ * the attention-sized per-layer steps at full Llama-3.1-70B shapes on CPU.
105
+ * **Not verified:**
106
+ * an end-to-end 70B run (needs a GPU);
107
+ * the MLP-sized 70B steps (need ~7 GB for two 28672² matrices);
108
+ * loading the checkpoints with GPTQModel / vLLM kernels. The format follows the GPTQ v1 layout and the bundled
109
+ loader reads it, but no GPU kernel was run here.
110
+
111
+ ## Method
112
+
113
+ For a linear block y = xW with quantization error R, the output error is tr(RᵀΣR) with Σ = E[xxᵀ].
114
+ No data is available, so Σ is built from the model:
115
+ * **LayerNorm/RMSNorm inputs:** token embeddings under a Zipf prior on token id (plus positions), then every
116
+ earlier attention-output and MLP-output projection added to the residual stream.
117
+ * **MLP second input:** the layer's own nonlinearity applied by Monte Carlo to that Gaussian.
118
+ * **Attention output:** a mixture of value vectors, E[ooᵀ] = μμᵀ + κ·blockdiag(C_v).
119
+
120
+ Rounding is the sequential error-feedback recursion that minimises tr(R H Rᵀ) with H = I + λS. With H = I it is
121
+ exactly round-to-nearest.
122
+
123
+ ## Tests
124
+
125
+ ```bash
126
+ pytest -q tests/test_core.py tests/test_end_to_end.py
127
+ ```
128
+ * `test_core.py`: H = I reproduces RTN; blocked == sequential recursion; GPTQ pack/unpack is exact;
129
+ zero-point safety; shaping lowers the H-weighted error.
130
+ * `test_end_to_end.py`: tiny Llama/Qwen2/OPT/GPT-2 models; quantize → save → load gives logits identical to the
131
+ dequantized reference (uniform and mixed bits).
RESULTS.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Results
2
+
3
+ CPU (2 cores), WikiText-2 test, 32 windows of 1024 tokens, one run per setting. Final package defaults:
4
+ shaped coding with λ = 1 and κ = 1 (nothing tuned) and dynamic group grids. RTN uses the same GPTQ grid.
5
+
6
+ | model | group | dense | RTN INT4 | shaped INT4 | RTN mixed | shaped mixed | mixed bits/w |
7
+ |---|---|---|---|---|---|---|---|
8
+ | GPT-2 124M | 128 | 26.84 | 29.91 | **29.14** | 29.62 | **28.86** | 4.75 |
9
+ | OPT-125m | 128 | 29.27 | 31.98 | **31.39** | 30.71 | **30.44** | 4.75 |
10
+ | SmolLM2-135M (Llama) | 64 | 15.46 | **19.28** | 19.29 | **17.82** | 17.83 | 5.0 |
11
+
12
+ Uniform INT4 is 4 + 32/group bits per quantized weight: 4.25 for groups of 128, 4.5 for groups of 64.
13
+ "Mixed" is INT4/INT8/FP16 per module chosen by the type rule at the stated budget.
14
+
15
+ Change in perplexity from shaped coding vs RTN:
16
+ * GPT-2 124M: INT4 -2.6%, mixed -2.6%
17
+ * OPT-125m: INT4 -1.9%, mixed -0.9%
18
+ * SmolLM2-135M (Llama): INT4 +0.1%, mixed +0.1%
19
+
20
+ Static group grids (grids fixed from the original weights), for comparison:
21
+ * GPT-2 124M: shaped INT4 29.09, shaped mixed 28.82
22
+ * OPT-125m: shaped INT4 32.03, shaped mixed 31.02
23
+ * SmolLM2-135M (Llama): shaped INT4 19.19, shaped mixed 17.74
24
+
25
+ Exact per-layer output error vs RTN under the true input covariance (OPT-125m, layers 0/4/8, evaluation only):
26
+ geometric-mean ratio 0.868 (data-free H), 0.282 (true covariance). The layer-level gain only partly reaches perplexity.
27
+
28
+ ## 70B scaling
29
+
30
+ Estimates from the real configs (`shapequant memory`):
31
+
32
+ | model | params | device peak | host buffer | INT4 checkpoint | largest H |
33
+ |---|---|---|---|---|---|
34
+ | Llama-3.1-70B | 70.6B | 21.2 GB | 5.6 GB | 37.8 GB | 28672 |
35
+ | Qwen2.5-72B | 72.7B | 22.1 GB | 5.6 GB | 39.4 GB | 29568 |
36
+ | Llama-2-70B | 69.0B | 21.2 GB | 5.6 GB | 34.8 GB | 28672 |
37
+
38
+ Per-layer core at Llama-3.1-70B shapes, executed on cpu (2 cores): stacked q/k/v (10240×8192) 26 s, o_proj (8192×8192) 19 s, attention-output covariance 1.7 s.
39
+ The MLP-sized steps (28672-wide covariance, Monte Carlo) need ~7 GB for two 28672² fp32 matrices and were not run
40
+ on this 7.6 GB machine. 70B needs a GPU (≥ 24 GB, 40–80 GB comfortable). Work per layer is the same order as GPTQ's.
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shapequant"
7
+ version = "0.1.0"
8
+ description = "Data-free, training-free noise-shaped quantization of Hugging Face causal LMs (GPTQ-format output)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Angshul Majumdar", email = "angshul@iiitd.ac.in" }]
13
+ dependencies = ["torch>=2.1", "safetensors>=0.4", "transformers>=4.40", "accelerate>=0.26", "huggingface_hub>=0.20"]
14
+
15
+ [project.optional-dependencies]
16
+ eval = ["datasets"]
17
+ test = ["pytest"]
18
+
19
+ [project.scripts]
20
+ shapequant = "shapequant.cli:main"
21
+
22
+ [tool.setuptools]
23
+ packages = ["shapequant"]
shapequant/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """shapequant: data-free, training-free noise-shaped quantization of Hugging Face causal LMs."""
2
+ from .quantize import quantize_model, allocate
3
+ from .qlinear import load_quantized, QuantLinear
4
+ from .evaluate import perplexity, wikitext2_windows, windows_from_text
5
+ from .memory import estimate as estimate_memory
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["quantize_model", "allocate", "load_quantized", "QuantLinear", "perplexity", "wikitext2_windows",
9
+ "windows_from_text", "estimate_memory"]
shapequant/arch.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Architecture adapters: tensor names and structure for the supported Hugging Face causal LMs.
2
+
3
+ Supported model_type: llama, mistral, qwen2 (incl. Llama-2/3/3.1/3.3-70B, Qwen2.5-72B), opt (pre-LN), gpt2.
4
+ A linear module is described by (module_name, input_key, role):
5
+ input_key in {"ln1", "attn_out", "ln2", "act"} - which activation it reads
6
+ role in {"q","k","v","qkv","o","gate","up","down"}
7
+ Weights are returned in torch Linear layout (out, in); GPT-2 Conv1D weights are transposed on read.
8
+ """
9
+ from dataclasses import dataclass, field
10
+ from typing import List, Optional, Tuple
11
+
12
+
13
+ @dataclass
14
+ class Linear:
15
+ name: str # module path, e.g. "model.layers.3.self_attn.q_proj"
16
+ input_key: str
17
+ role: str
18
+ conv1d: bool = False # stored (in, out)
19
+
20
+
21
+ @dataclass
22
+ class Arch:
23
+ model_type: str
24
+ n_layer: int
25
+ d: int
26
+ n_head: int
27
+ n_kv: int
28
+ head_dim: int
29
+ inter: int
30
+ norm: str # "rms" | "ln"
31
+ norm_eps: float
32
+ act: str # "swiglu" | "relu" | "gelu_new" | "gelu"
33
+ embed: str
34
+ pos_embed: Optional[str]
35
+ pos_offset: int
36
+ final_norm: str
37
+ lm_head: Optional[str]
38
+ vocab: int
39
+ max_pos: int
40
+ layers: List[List[Linear]] = field(default_factory=list)
41
+ norms: List[Tuple[str, str]] = field(default_factory=list) # per layer: (ln1 prefix, ln2 prefix)
42
+
43
+ def norm_names(self, L, which):
44
+ p = self.norms[L][0 if which == "ln1" else 1]
45
+ return p + ".weight", (p + ".bias" if self.norm == "ln" else None)
46
+
47
+
48
+ def from_config(cfg: dict) -> Arch:
49
+ mt = cfg.get("model_type")
50
+ if mt in ("llama", "mistral", "qwen2"):
51
+ d, H = cfg["hidden_size"], cfg["num_attention_heads"]
52
+ act = cfg.get("hidden_act", "silu")
53
+ if act != "silu":
54
+ raise NotImplementedError(f"{mt} with hidden_act={act}")
55
+ a = Arch(mt, cfg["num_hidden_layers"], d, H, cfg.get("num_key_value_heads", H), cfg.get("head_dim") or d // H,
56
+ cfg["intermediate_size"], "rms", cfg.get("rms_norm_eps", 1e-6), "swiglu",
57
+ "model.embed_tokens.weight", None, 0, "model.norm.weight",
58
+ None if cfg.get("tie_word_embeddings", False) else "lm_head.weight",
59
+ cfg["vocab_size"], cfg.get("max_position_embeddings", 4096))
60
+ for L in range(a.n_layer):
61
+ p = f"model.layers.{L}."
62
+ a.layers.append([Linear(p + "self_attn.q_proj", "ln1", "q"), Linear(p + "self_attn.k_proj", "ln1", "k"),
63
+ Linear(p + "self_attn.v_proj", "ln1", "v"), Linear(p + "self_attn.o_proj", "attn_out", "o"),
64
+ Linear(p + "mlp.gate_proj", "ln2", "gate"), Linear(p + "mlp.up_proj", "ln2", "up"),
65
+ Linear(p + "mlp.down_proj", "act", "down")])
66
+ a.norms.append((p + "input_layernorm", p + "post_attention_layernorm"))
67
+ return a
68
+ if mt == "opt":
69
+ if not cfg.get("do_layer_norm_before", True):
70
+ raise NotImplementedError("post-LN OPT (opt-350m) is not supported")
71
+ if cfg.get("word_embed_proj_dim", cfg["hidden_size"]) != cfg["hidden_size"]:
72
+ raise NotImplementedError("OPT with project_in/out is not supported")
73
+ d, H = cfg["hidden_size"], cfg["num_attention_heads"]
74
+ a = Arch(mt, cfg["num_hidden_layers"], d, H, H, d // H, cfg["ffn_dim"], "ln", 1e-5,
75
+ {"relu": "relu"}.get(cfg.get("activation_function", "relu"), cfg.get("activation_function")),
76
+ "model.decoder.embed_tokens.weight", "model.decoder.embed_positions.weight", 2,
77
+ "model.decoder.final_layer_norm.weight", None if cfg.get("tie_word_embeddings", True) else "lm_head.weight",
78
+ cfg["vocab_size"], cfg.get("max_position_embeddings", 2048))
79
+ for L in range(a.n_layer):
80
+ p = f"model.decoder.layers.{L}."
81
+ a.layers.append([Linear(p + "self_attn.q_proj", "ln1", "q"), Linear(p + "self_attn.k_proj", "ln1", "k"),
82
+ Linear(p + "self_attn.v_proj", "ln1", "v"), Linear(p + "self_attn.out_proj", "attn_out", "o"),
83
+ Linear(p + "fc1", "ln2", "up"), Linear(p + "fc2", "act", "down")])
84
+ a.norms.append((p + "self_attn_layer_norm", p + "final_layer_norm"))
85
+ return a
86
+ if mt == "gpt2":
87
+ d, H = cfg["n_embd"], cfg["n_head"]
88
+ a = Arch(mt, cfg["n_layer"], d, H, H, d // H, cfg.get("n_inner") or 4 * d, "ln", cfg.get("layer_norm_epsilon", 1e-5),
89
+ {"gelu_new": "gelu_new"}.get(cfg.get("activation_function", "gelu_new"), cfg.get("activation_function")),
90
+ "transformer.wte.weight", "transformer.wpe.weight", 0, "transformer.ln_f.weight", None,
91
+ cfg["vocab_size"], cfg.get("n_positions", 1024))
92
+ for L in range(a.n_layer):
93
+ p = f"transformer.h.{L}."
94
+ a.layers.append([Linear(p + "attn.c_attn", "ln1", "qkv", True), Linear(p + "attn.c_proj", "attn_out", "o", True),
95
+ Linear(p + "mlp.c_fc", "ln2", "up", True), Linear(p + "mlp.c_proj", "act", "down", True)])
96
+ a.norms.append((p + "ln_1", p + "ln_2"))
97
+ return a
98
+ raise NotImplementedError(f"model_type={mt!r} is not supported (supported: llama, mistral, qwen2, opt, gpt2)")
shapequant/cli.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse, json, sys
2
+
3
+
4
+ def main(argv=None):
5
+ p = argparse.ArgumentParser("shapequant", description="Data-free, training-free quantization of Hugging Face causal LMs")
6
+ sub = p.add_subparsers(dest="cmd", required=True)
7
+ q = sub.add_parser("quantize", help="quantize a model (hub id or local dir) to a GPTQ-format checkpoint")
8
+ q.add_argument("model"); q.add_argument("out")
9
+ q.add_argument("--bits", type=int, default=4, choices=[4, 8])
10
+ q.add_argument("--group-size", type=int, default=128)
11
+ q.add_argument("--method", default="shaped", choices=["shaped", "rtn"])
12
+ q.add_argument("--budget", type=float, default=None, help="average bits/weight for mixed INT4/INT8/FP16 (e.g. 4.75)")
13
+ q.add_argument("--lam", type=float, default=1.0); q.add_argument("--kappa", type=float, default=1.0)
14
+ q.add_argument("--samples", type=int, default=16384); q.add_argument("--device", default=None)
15
+ q.add_argument("--revision", default=None); q.add_argument("--shard-gb", type=float, default=4.0)
16
+ q.add_argument("--static-groups", action="store_true", help="fit group grids to the original weights instead of the error-fed ones")
17
+ e = sub.add_parser("eval", help="perplexity of a quantized checkpoint on WikiText-2 (or a text file)")
18
+ e.add_argument("ckpt"); e.add_argument("--text", default=None); e.add_argument("--seq-len", type=int, default=1024)
19
+ e.add_argument("--windows", type=int, default=32); e.add_argument("--device", default="cpu"); e.add_argument("--bos", action="store_true")
20
+ m = sub.add_parser("memory", help="peak-memory estimate from a config (hub id, dir or config.json)")
21
+ m.add_argument("model"); m.add_argument("--method", default="shaped", choices=["shaped", "rtn"])
22
+ a = p.parse_args(argv)
23
+ if a.cmd == "quantize":
24
+ from .quantize import quantize_model
25
+ s = quantize_model(a.model, a.out, bits=a.bits, group_size=a.group_size, method=a.method, budget=a.budget, lam=a.lam,
26
+ kappa=a.kappa, n_samples=a.samples, device=a.device, revision=a.revision, max_shard_size_gb=a.shard_gb,
27
+ dynamic_groups=not a.static_groups)
28
+ print(json.dumps({k: v for k, v in s.items() if k != "bits_per_module"}, indent=2))
29
+ elif a.cmd == "eval":
30
+ from transformers import AutoTokenizer
31
+ from .qlinear import load_quantized
32
+ from .evaluate import wikitext2_windows, perplexity
33
+ tok = AutoTokenizer.from_pretrained(a.ckpt)
34
+ model = load_quantized(a.ckpt, device=a.device)
35
+ W = wikitext2_windows(tok, a.seq_len, a.windows, a.text)
36
+ print(f"perplexity {perplexity(model, W, tok.bos_token_id if a.bos else None):.3f} on {W.numel()} tokens")
37
+ elif a.cmd == "memory":
38
+ import os
39
+ from .memory import estimate
40
+ src = a.model
41
+ if not os.path.exists(src):
42
+ from huggingface_hub import hf_hub_download
43
+ src = hf_hub_download(a.model, "config.json")
44
+ print(json.dumps(estimate(src, method=a.method), indent=2))
45
+
46
+
47
+ if __name__ == "__main__":
48
+ sys.exit(main())
shapequant/core.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quantizer core (torch, CPU or GPU).
2
+
3
+ Grid: standard asymmetric GPTQ grid per (output row, input group): range includes 0,
4
+ scale = (max - min) / (2^b - 1), integer zero-point z = round(-min / scale), q = clamp(round(w/scale) + z, 0, 2^b - 1),
5
+ w_hat = (q - z) * scale. Grids are fixed from the original weights ("static groups").
6
+ Zero-points are kept >= 1 so the checkpoint can use the GPTQ v1 storage convention (stores z - 1).
7
+
8
+ Rounding: sequential error feedback that minimises tr(R H R^T) for a design matrix H (in x in), i.e. the
9
+ OBQ/GPTQ recursion with lazy block updates. H = None gives round-to-nearest. With H = I it is identical to RTN.
10
+ """
11
+ import torch
12
+
13
+
14
+ def group_params(W, bits, group):
15
+ """W: (out, in). Returns scale, zero of shape (out, in // group)."""
16
+ out, inn = W.shape
17
+ assert inn % group == 0, f"input dim {inn} not divisible by group size {group}"
18
+ maxq = 2 ** bits - 1
19
+ Wg = W.reshape(out, inn // group, group)
20
+ xmin = torch.clamp(Wg.amin(-1), max=0.0)
21
+ xmax = torch.clamp(Wg.amax(-1), min=0.0)
22
+ both0 = (xmin == 0) & (xmax == 0)
23
+ xmin, xmax = torch.where(both0, -torch.ones_like(xmin), xmin), torch.where(both0, torch.ones_like(xmax), xmax)
24
+ scale = (xmax - xmin) / maxq
25
+ zero = torch.round(-xmin / scale)
26
+ # keep z >= 1 (GPTQ v1 storage): if the group has no negative part, use z = 1 and rescale so max still fits
27
+ low = zero < 1
28
+ scale = torch.where(low, xmax / (maxq - 1), scale)
29
+ zero = torch.where(low, torch.ones_like(zero), zero)
30
+ zero = torch.clamp(zero, 1, maxq)
31
+ return scale, zero
32
+
33
+
34
+ def _q(w, scale, zero, maxq):
35
+ return torch.clamp(torch.round(w / scale) + zero, 0, maxq)
36
+
37
+
38
+ def quantize(W, bits, group, H=None, block=128, dynamic_groups=True):
39
+ """W: (out, in) float tensor (torch Linear layout). Returns (q uint8 (out, in), scale (out, G), zero (out, G)).
40
+ H: (in, in) positive-definite design matrix, or None for round-to-nearest.
41
+ dynamic_groups: each group's grid is fitted to the error-fed (updated) weights when the group starts,
42
+ as in standard GPTQ, so feedback never pushes weights outside their grid. False = grids from the original W."""
43
+ W = W.to(torch.float32)
44
+ out, inn = W.shape
45
+ maxq = 2 ** bits - 1
46
+ scale, zero = group_params(W, bits, group)
47
+ gid = torch.arange(inn, device=W.device) // group
48
+ if H is None:
49
+ Q = _q(W, scale[:, gid], zero[:, gid], maxq)
50
+ return Q.to(torch.uint8), scale, zero
51
+ W = W.clone()
52
+ H = H.to(device=W.device, dtype=torch.float32)
53
+ L = torch.linalg.cholesky(H)
54
+ Hinv = torch.cholesky_inverse(L); del L
55
+ U = torch.linalg.cholesky(Hinv, upper=True); del Hinv
56
+ Q = torch.zeros((out, inn), dtype=torch.uint8, device=W.device)
57
+ for i1 in range(0, inn, block):
58
+ i2 = min(i1 + block, inn); n = i2 - i1
59
+ W1 = W[:, i1:i2].clone(); E1 = torch.zeros_like(W1); U1 = U[i1:i2, i1:i2]
60
+ for i in range(n):
61
+ c = i1 + i; g = c // group
62
+ if dynamic_groups and c % group == 0:
63
+ seg = W1[:, i:i + group] if i + group <= n else torch.cat([W1[:, i:], W[:, i2:c + group]], 1)
64
+ sg, zg = group_params(seg, bits, group)
65
+ scale[:, g], zero[:, g] = sg[:, 0], zg[:, 0]
66
+ w = W1[:, i]
67
+ q = _q(w, scale[:, g], zero[:, g], maxq)
68
+ Q[:, c] = q.to(torch.uint8)
69
+ err = (w - (q - zero[:, g]) * scale[:, g]) / U1[i, i]
70
+ W1[:, i:] -= err.unsqueeze(1) * U1[i, i:].unsqueeze(0)
71
+ E1[:, i] = err
72
+ if i2 < inn:
73
+ W[:, i2:] -= E1 @ U[i1:i2, i2:]
74
+ return Q, scale, zero
75
+
76
+
77
+ def dequantize(Q, scale, zero, group):
78
+ gid = torch.arange(Q.shape[1], device=Q.device) // group
79
+ return (Q.to(torch.float32) - zero[:, gid]) * scale[:, gid]
80
+
81
+
82
+ # --------------------------------------------------------------- GPTQ v1 packing (AutoGPTQ / GPTQModel "gptq" format)
83
+ def pack(Q, scale, zero, bits, group):
84
+ """Returns dict(qweight int32 (in*bits/32, out), qzeros int32 (G, out*bits/32), scales fp16 (G, out), g_idx int32 (in,))."""
85
+ assert bits in (2, 4, 8)
86
+ per = 32 // bits
87
+ out, inn = Q.shape
88
+ q = Q.t().contiguous().to(torch.int64) # (in, out)
89
+ assert inn % per == 0 and out % per == 0
90
+ qw = torch.zeros((inn // per, out), dtype=torch.int64, device=Q.device)
91
+ for k in range(per):
92
+ qw |= q[k::per, :] << (bits * k)
93
+ z = (zero.t().contiguous().to(torch.int64) - 1) # v1 convention: store z - 1
94
+ qz = torch.zeros((z.shape[0], out // per), dtype=torch.int64, device=Q.device)
95
+ for k in range(per):
96
+ qz |= z[:, k::per] << (bits * k)
97
+ to32 = lambda t: (t & 0xFFFFFFFF).to(torch.int64).where(t < 2 ** 31, t - 2 ** 32).to(torch.int32)
98
+ return {"qweight": to32(qw), "qzeros": to32(qz), "scales": scale.t().contiguous().to(torch.float16),
99
+ "g_idx": (torch.arange(inn, device=Q.device) // group).to(torch.int32)}
100
+
101
+
102
+ def unpack(qweight, qzeros, scales, g_idx, bits):
103
+ """Inverse of pack: dequantized weight in torch Linear layout (out, in), float32."""
104
+ per = 32 // bits; mask = 2 ** bits - 1
105
+ qw = qweight.to(torch.int64) & 0xFFFFFFFF
106
+ inn = qw.shape[0] * per
107
+ q = torch.empty((inn, qw.shape[1]), dtype=torch.int64, device=qw.device)
108
+ for k in range(per):
109
+ q[k::per] = (qw >> (bits * k)) & mask
110
+ qz = qzeros.to(torch.int64) & 0xFFFFFFFF
111
+ z = torch.empty((qz.shape[0], qz.shape[1] * per), dtype=torch.int64, device=qz.device)
112
+ for k in range(per):
113
+ z[:, k::per] = ((qz >> (bits * k)) & mask) + 1
114
+ g = g_idx.to(torch.int64)
115
+ W = (q.to(torch.float32) - z[g].to(torch.float32)) * scales[g].to(torch.float32) # (in, out)
116
+ return W.t().contiguous()
shapequant/evaluate.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Perplexity on fixed windows (same protocol as the paper experiments)."""
2
+ import math
3
+ import torch
4
+
5
+
6
+ def windows_from_text(tokenizer, text, seq_len=1024, n=None, offset=0):
7
+ ids = tokenizer(text, add_special_tokens=False, return_tensors="pt").input_ids[0]
8
+ k = len(ids) // seq_len
9
+ W = ids[: k * seq_len].view(k, seq_len)[offset:]
10
+ return W if n is None else W[:n]
11
+
12
+
13
+ def wikitext2_windows(tokenizer, seq_len=1024, n=None, path=None):
14
+ if path is not None:
15
+ text = open(path).read()
16
+ else:
17
+ from datasets import load_dataset
18
+ text = "\n\n".join(load_dataset("wikitext", "wikitext-2-raw-v1", split="test")["text"])
19
+ return windows_from_text(tokenizer, text, seq_len, n)
20
+
21
+
22
+ @torch.no_grad()
23
+ def perplexity(model, windows, bos_token_id=None, device=None):
24
+ """windows: LongTensor (n, L). If bos_token_id is set it is prepended to every window and all L tokens are scored."""
25
+ device = device or next(model.parameters()).device
26
+ nll, cnt = 0.0, 0
27
+ for w in windows:
28
+ ids = w if bos_token_id is None else torch.cat([torch.tensor([bos_token_id]), w])
29
+ ids = ids.to(device)[None]
30
+ logits = model(ids).logits[0, :-1].float()
31
+ tgt = ids[0, 1:]
32
+ nll += float(torch.nn.functional.cross_entropy(logits, tgt, reduction="sum")); cnt += len(tgt)
33
+ return math.exp(nll / cnt)
shapequant/io.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy weight reading (one tensor at a time) and sharded safetensors writing. Nothing loads the whole model."""
2
+ import os, json, glob, shutil
3
+ import torch
4
+
5
+
6
+ def resolve(path_or_repo, revision=None):
7
+ """Local directory, or a Hugging Face repo id (downloads config, tokenizer and weight files)."""
8
+ if os.path.isdir(path_or_repo):
9
+ return path_or_repo
10
+ from huggingface_hub import snapshot_download
11
+ return snapshot_download(path_or_repo, revision=revision,
12
+ allow_patterns=["*.json", "*.safetensors", "*.model", "*.txt", "*.tiktoken", "*.bin"],
13
+ ignore_patterns=["*onnx*", "*.msgpack", "*.h5", "*consolidated*", "original/*"])
14
+
15
+
16
+ class Reader:
17
+ """Reads individual tensors from safetensors shards (or a small pytorch_model.bin) by name.
18
+ Accepts names with or without a leading 'model.' / 'transformer.' prefix."""
19
+
20
+ def __init__(self, path):
21
+ self.path = path
22
+ st = sorted(glob.glob(os.path.join(path, "*.safetensors")))
23
+ self.where, self._bin = {}, None
24
+ if st:
25
+ from safetensors import safe_open
26
+ self._open = {}
27
+ for f in st:
28
+ with safe_open(f, framework="pt") as h:
29
+ for k in h.keys():
30
+ self.where[k] = f
31
+ else:
32
+ bins = sorted(glob.glob(os.path.join(path, "pytorch_model*.bin")))
33
+ if not bins:
34
+ raise FileNotFoundError(f"no *.safetensors or pytorch_model*.bin in {path}")
35
+ self._bin = {}
36
+ for f in bins:
37
+ sd = torch.load(f, map_location="cpu", mmap=True, weights_only=True)
38
+ for k, v in sd.items():
39
+ self._bin[k] = v; self.where[k] = f
40
+
41
+ def _key(self, name):
42
+ if name in self.where:
43
+ return name
44
+ for cand in (name.split(".", 1)[1] if "." in name else name, "model." + name, "transformer." + name):
45
+ if cand in self.where:
46
+ return cand
47
+ tail = name.split(".", 1)[1] if name.startswith(("model.", "transformer.")) else name
48
+ for k in self.where:
49
+ if k.endswith(tail) and (len(k) == len(tail) or k[-len(tail) - 1] == "."):
50
+ return k
51
+ return None
52
+
53
+ def has(self, name):
54
+ return self._key(name) is not None
55
+
56
+ def get(self, name, device="cpu", dtype=torch.float32):
57
+ k = self._key(name)
58
+ if k is None:
59
+ raise KeyError(name)
60
+ if self._bin is not None:
61
+ t = self._bin[k]
62
+ else:
63
+ from safetensors import safe_open
64
+ with safe_open(self.where[k], framework="pt", device="cpu") as h:
65
+ t = h.get_tensor(k)
66
+ return t.to(device=device, dtype=dtype)
67
+
68
+ def all_keys(self):
69
+ return list(self.where)
70
+
71
+
72
+ class ShardWriter:
73
+ """Accumulates tensors and writes model-0000x-of-0000y.safetensors shards plus the index json."""
74
+
75
+ def __init__(self, out_dir, max_shard_bytes=4 * 2 ** 30):
76
+ os.makedirs(out_dir, exist_ok=True)
77
+ self.out, self.max, self.buf, self.size, self.shards, self.index = out_dir, max_shard_bytes, {}, 0, [], {}
78
+
79
+ def add(self, name, t):
80
+ t = t.detach().to("cpu").contiguous().clone()
81
+ nb = t.numel() * t.element_size()
82
+ if self.buf and self.size + nb > self.max:
83
+ self.flush()
84
+ self.buf[name] = t; self.size += nb
85
+
86
+ def flush(self):
87
+ if not self.buf:
88
+ return
89
+ from safetensors.torch import save_file
90
+ fn = f"tmp-{len(self.shards):05d}.safetensors"
91
+ save_file(self.buf, os.path.join(self.out, fn), metadata={"format": "pt"})
92
+ self.shards.append((fn, list(self.buf)))
93
+ self.buf, self.size = {}, 0
94
+
95
+ def close(self):
96
+ self.flush()
97
+ n = len(self.shards); total = 0; wm = {}
98
+ for i, (fn, names) in enumerate(self.shards):
99
+ final = "model.safetensors" if n == 1 else f"model-{i + 1:05d}-of-{n:05d}.safetensors"
100
+ os.replace(os.path.join(self.out, fn), os.path.join(self.out, final))
101
+ total += os.path.getsize(os.path.join(self.out, final))
102
+ for k in names:
103
+ wm[k] = final
104
+ if n > 1:
105
+ json.dump({"metadata": {"total_size": total}, "weight_map": wm},
106
+ open(os.path.join(self.out, "model.safetensors.index.json"), "w"), indent=2)
107
+ return total
108
+
109
+
110
+ def copy_side_files(src, dst):
111
+ for f in os.listdir(src):
112
+ if f.startswith(("tokenizer", "special_tokens_map", "generation_config", "vocab", "merges")) or f.endswith((".model", ".tiktoken")):
113
+ shutil.copy(os.path.join(src, f), os.path.join(dst, f))
shapequant/memory.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Peak-memory estimate for quantize_model from a config (no weights needed)."""
2
+ import json, os
3
+ from .arch import from_config
4
+
5
+
6
+ def estimate(config, n_samples=16384, batch=2048, method="shaped"):
7
+ """config: dict, path to config.json, or a directory. Returns bytes for the accelerator and for host RAM."""
8
+ if isinstance(config, str):
9
+ p = os.path.join(config, "config.json") if os.path.isdir(config) else config
10
+ config = json.load(open(p))
11
+ a = from_config(config)
12
+ f32, f64 = 4, 8
13
+ lin = sum(_numel(a, l) for l in a.layers[0]) # one decoder layer, fp32 on device
14
+ ins = {"ln1": a.d, "attn_out": a.n_head * a.head_dim, "ln2": a.d, "act": a.inter}
15
+ # quantizing the largest input group: stacked W (fp32) + H + Cholesky chain (2 alive) + error buffer
16
+ worst = 0
17
+ for key, n in ins.items():
18
+ rows = sum(_out(a, l) for l in a.layers[0] if l.input_key == key)
19
+ worst = max(worst, rows * n * f32 * 2 + 2 * n * n * f32 + rows * 128 * f32 * 2)
20
+ shaped = 0
21
+ if method == "shaped":
22
+ shaped = (3 * a.d * a.d * f64 # residual Sig, moments
23
+ + 2 * a.inter * a.inter * f32 # S_act accumulator + H
24
+ + batch * (a.d + 2 * a.inter) * f32 # Monte Carlo batch
25
+ + 4 * (a.n_head * a.head_dim) ** 2 * f64) # attention-output S
26
+ embed = a.vocab * a.d * f32
27
+ device_peak = lin * f32 + max(worst + shaped, embed + 2 * a.d * a.d * f64)
28
+ n_params = a.n_layer * lin + a.vocab * a.d * (1 if a.lm_head is None else 2)
29
+ out_bytes = a.n_layer * lin * (4.25 / 8) + a.vocab * a.d * 2 * (1 if a.lm_head is None else 2)
30
+ return {"model_type": a.model_type, "params_B": n_params / 1e9, "device_peak_GB": device_peak / 2 ** 30,
31
+ "host_buffer_GB": 4 + lin * 2 / 2 ** 30, "int4_checkpoint_GB": out_bytes / 2 ** 30,
32
+ "largest_H_dim": max(ins.values())}
33
+
34
+
35
+ def _out(a, l):
36
+ return {"q": a.n_head * a.head_dim, "k": a.n_kv * a.head_dim, "v": a.n_kv * a.head_dim, "qkv": 3 * a.d,
37
+ "o": a.d, "gate": a.inter, "up": a.inter, "down": a.d}[l.role]
38
+
39
+
40
+ def _in(a, l):
41
+ return {"ln1": a.d, "attn_out": a.n_head * a.head_dim, "ln2": a.d, "act": a.inter}[l.input_key]
42
+
43
+
44
+ def _numel(a, l):
45
+ return _out(a, l) * _in(a, l)
shapequant/qlinear.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loading shapequant / GPTQ-v1 checkpoints with plain transformers + torch (no CUDA kernels needed).
2
+
3
+ The checkpoint is standard GPTQ format, so GPTQ-capable stacks (GPTQModel, vLLM, TGI) can load it with their
4
+ fast kernels. This loader is the dependency-light path: each quantized layer keeps packed int32 weights and
5
+ dequantizes on the fly (optionally caching the fp16 weight).
6
+ """
7
+ import os, re, json, glob
8
+ import torch
9
+ import torch.nn as nn
10
+ import torch.nn.functional as F
11
+
12
+ from . import core
13
+
14
+
15
+ class QuantLinear(nn.Module):
16
+ def __init__(self, in_features, out_features, bits, group_size, bias, dtype=torch.float16, cache=False):
17
+ super().__init__()
18
+ self.in_features, self.out_features, self.bits, self.group_size, self.cache = in_features, out_features, bits, group_size, cache
19
+ per = 32 // bits; G = in_features // group_size
20
+ self.register_buffer("qweight", torch.zeros((in_features // per, out_features), dtype=torch.int32))
21
+ self.register_buffer("qzeros", torch.zeros((G, out_features // per), dtype=torch.int32))
22
+ self.register_buffer("scales", torch.zeros((G, out_features), dtype=torch.float16))
23
+ self.register_buffer("g_idx", torch.zeros(in_features, dtype=torch.int32))
24
+ self.register_buffer("bias", torch.zeros(out_features, dtype=dtype) if bias else None)
25
+ self._w = None
26
+
27
+ def weight_fp(self, dtype):
28
+ if self._w is not None and self._w.dtype == dtype:
29
+ return self._w
30
+ w = core.unpack(self.qweight, self.qzeros, self.scales, self.g_idx, self.bits).to(dtype)
31
+ if self.cache:
32
+ self._w = w
33
+ return w
34
+
35
+ def forward(self, x):
36
+ b = None if self.bias is None else self.bias.to(x.dtype)
37
+ return F.linear(x, self.weight_fp(x.dtype), b)
38
+
39
+ def extra_repr(self):
40
+ return f"in={self.in_features}, out={self.out_features}, bits={self.bits}, group={self.group_size}"
41
+
42
+
43
+ def _bits_for(name, qc):
44
+ bits = qc.get("bits", 4)
45
+ for pat, ov in (qc.get("dynamic") or {}).items():
46
+ sign, rx = (pat[0], pat[2:]) if pat[:2] in ("+:", "-:") else ("+", pat)
47
+ if re.match(rx, name):
48
+ if sign == "-":
49
+ return None
50
+ bits = ov.get("bits", bits)
51
+ return bits
52
+
53
+
54
+ def _set_module(model, name, mod):
55
+ parent, _, child = name.rpartition(".")
56
+ setattr(model.get_submodule(parent) if parent else model, child, mod)
57
+
58
+
59
+ def load_quantized(path, device="cpu", dtype=torch.float16, cache_weights=False, device_map=None):
60
+ """Load a checkpoint written by quantize_model. device_map="auto" dispatches layers over GPUs/CPU (accelerate)."""
61
+ from transformers import AutoConfig, AutoModelForCausalLM
62
+ from accelerate import init_empty_weights
63
+ from safetensors.torch import load_file
64
+ cfg = AutoConfig.from_pretrained(path)
65
+ qc = cfg.quantization_config if isinstance(cfg.quantization_config, dict) else cfg.quantization_config.to_dict()
66
+ del cfg.quantization_config
67
+ with init_empty_weights(include_buffers=False):
68
+ try:
69
+ model = AutoModelForCausalLM.from_config(cfg, dtype=dtype) # transformers >= 4.56
70
+ except TypeError:
71
+ model = AutoModelForCausalLM.from_config(cfg, torch_dtype=dtype)
72
+ files = sorted(glob.glob(os.path.join(path, "*.safetensors")))
73
+ sd = {}
74
+ for f in files:
75
+ sd.update(load_file(f))
76
+ qnames = sorted({k[: -len(".qweight")] for k in sd if k.endswith(".qweight")})
77
+ msd = model.state_dict()
78
+ def find(name): # map checkpoint module name to model module name (prefix differences)
79
+ for cand in (name, name.split(".", 1)[1], "model." + name):
80
+ try:
81
+ model.get_submodule(cand); return cand
82
+ except AttributeError:
83
+ continue
84
+ raise KeyError(name)
85
+ ren = {}
86
+ for qn in qnames:
87
+ mn = find(qn); old = model.get_submodule(mn)
88
+ if hasattr(old, "in_features"):
89
+ inn, out = old.in_features, old.out_features
90
+ else: # GPT-2 Conv1D: weight (in, out)
91
+ inn, out = old.weight.shape
92
+ b = _bits_for(qn, qc)
93
+ _set_module(model, mn, QuantLinear(inn, out, b, qc["group_size"], (qn + ".bias") in sd, dtype, cache_weights))
94
+ ren[qn] = mn
95
+ fixed = {}
96
+ for k, v in sd.items():
97
+ mod = k.rsplit(".", 1)[0]
98
+ if mod in ren:
99
+ k = ren[mod] + "." + k.rsplit(".", 1)[1]
100
+ elif k not in msd:
101
+ for cand in (k.split(".", 1)[1], "model." + k):
102
+ if cand in msd:
103
+ k = cand; break
104
+ fixed[k] = v.to(dtype) if v.is_floating_point() and not k.endswith(".scales") else v
105
+ missing, unexpected = model.load_state_dict(fixed, strict=False, assign=True)
106
+ model.tie_weights()
107
+ still = [n for n, p in model.named_parameters() if p.device.type == "meta"]
108
+ if still:
109
+ raise RuntimeError(f"weights missing from checkpoint: {still[:5]}")
110
+ model.eval()
111
+ if device_map is not None:
112
+ from accelerate import dispatch_model, infer_auto_device_map
113
+ return dispatch_model(model, infer_auto_device_map(model, no_split_module_classes=model._no_split_modules))
114
+ return model.to(device)
shapequant/quantize.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-free, training-free quantization of Hugging Face causal LMs, streamed one decoder layer at a time.
2
+
3
+ quantize_model("meta-llama/Llama-3.3-70B-Instruct", "out/llama70b-sq-int4", bits=4, group_size=128)
4
+
5
+ method="shaped": noise-shaped rounding with the model-implied input covariance H = I + lam*S (default, lam = 1).
6
+ method="rtn": plain round-to-nearest on the same grid (baseline).
7
+ budget=<bits/weight>: mixed INT4 / INT8 / FP16 per module by the type rule (writers into the residual stream,
8
+ i.e. V, attention-output and MLP-output projections, count 4x). None = uniform `bits`.
9
+ Output: GPTQ-format checkpoint (quant_method "gptq", checkpoint_format "gptq", sym=False, desc_act=False),
10
+ per-module bit overrides in quantization_config["dynamic"], plus everything else unquantized in fp16.
11
+ """
12
+ import os, json, time, math
13
+ import torch
14
+
15
+ from . import core, sigma
16
+ from .arch import from_config
17
+ from .io import resolve, Reader, ShardWriter, copy_side_files
18
+
19
+ __all__ = ["quantize_model", "allocate"]
20
+
21
+ TYPE_WEIGHT = {"q": 1.0, "k": 1.0, "qkv": 1.0, "gate": 1.0, "up": 1.0, "v": 4.0, "o": 4.0, "down": 4.0}
22
+
23
+
24
+ def _get_linear(reader, lin, device):
25
+ W = reader.get(lin.name + ".weight", device=device)
26
+ if lin.conv1d:
27
+ W = W.T.contiguous()
28
+ b = reader.get(lin.name + ".bias", device=device) if reader.has(lin.name + ".bias") else None
29
+ return W, b
30
+
31
+
32
+ def _default_device(device):
33
+ if device is not None:
34
+ return torch.device(device)
35
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
36
+
37
+
38
+ def allocate(arch, reader, budget, group_size, device, log=print):
39
+ """Per-module bits in {4, 8, 16} minimising sum_l s_l D_l(b_l) under an average bits/weight budget.
40
+ s_l = type weight / ||W_l||_F^2, D_l(b) = ||W_l - RTN_b(W_l)||_F^2 (exact). One streaming pass over the weights."""
41
+ over = 32.0 / group_size
42
+ cost = torch.tensor([4 + over, 8 + over, 16.0], dtype=torch.float64)
43
+ names, D, n, s = [], [], [], []
44
+ for L in range(arch.n_layer):
45
+ for lin in arch.layers[L]:
46
+ W, _ = _get_linear(reader, lin, device)
47
+ row = []
48
+ for b in (4, 8):
49
+ q, sc, z = core.quantize(W, b, group_size)
50
+ row.append(float(((W - core.dequantize(q, sc, z, group_size)) ** 2).sum()))
51
+ row.append(float(((W - W.half().float()) ** 2).sum()))
52
+ names.append(lin.name); D.append(row); n.append(W.numel()); s.append(TYPE_WEIGHT[lin.role] / float((W ** 2).sum()))
53
+ log(f"[allocate] scanned layer {L + 1}/{arch.n_layer}")
54
+ D, n, s = torch.tensor(D, dtype=torch.float64), torch.tensor(n, dtype=torch.float64), torch.tensor(s, dtype=torch.float64)
55
+ N = n.sum()
56
+ used = lambda k: float((n * cost[k]).sum() / N)
57
+ pick = lambda mu: (s[:, None] * D + mu * n[:, None] * cost[None, :]).argmin(1)
58
+ if used(torch.zeros(len(n), dtype=torch.long)) > budget + 1e-9:
59
+ raise ValueError(f"budget {budget} is below the INT4 floor {cost[0]:.3f} bits/weight")
60
+ lo, hi = 0.0, 1.0
61
+ while used(pick(hi)) > budget:
62
+ hi *= 2
63
+ for _ in range(200):
64
+ mid = (lo + hi) / 2
65
+ lo, hi = (mid, hi) if used(pick(mid)) > budget else (lo, mid)
66
+ k = pick(hi)
67
+ while True: # spend leftover budget on the best marginal upgrades
68
+ best, gain = None, 0.0
69
+ for l in range(len(k)):
70
+ if k[l] < 2:
71
+ extra = float(n[l] * (cost[k[l] + 1] - cost[k[l]]) / N)
72
+ if used(k) + extra <= budget + 1e-12:
73
+ r = float(s[l] * (D[l, k[l]] - D[l, k[l] + 1]) / (n[l] * (cost[k[l] + 1] - cost[k[l]])))
74
+ if r > gain:
75
+ best, gain = l, r
76
+ if best is None:
77
+ break
78
+ k[best] += 1
79
+ bits = {nm: (4, 8, 16)[int(x)] for nm, x in zip(names, k)}
80
+ return bits, used(k)
81
+
82
+
83
+ def quantize_model(model, out_dir, bits=4, group_size=128, method="shaped", budget=None, lam=1.0, kappa=1.0,
84
+ n_samples=16384, device=None, max_shard_size_gb=4.0, revision=None, seed=0, dynamic_groups=True, log=print):
85
+ """Quantize a Hugging Face causal LM (local dir or hub id) and write a GPTQ-format checkpoint to out_dir.
86
+ Peak memory: one decoder layer of weights plus a few (in x in) matrices; see shapequant.memory.estimate."""
87
+ t0 = time.time()
88
+ assert method in ("shaped", "rtn")
89
+ assert bits in (4, 8) or budget is not None
90
+ dev = _default_device(device)
91
+ src = resolve(model, revision)
92
+ cfg = json.load(open(os.path.join(src, "config.json")))
93
+ arch = from_config(cfg)
94
+ reader = Reader(src)
95
+ if budget is not None:
96
+ bits_of, bpw = allocate(arch, reader, budget, group_size, dev, log)
97
+ log(f"[allocate] {sum(v == 4 for v in bits_of.values())} INT4, {sum(v == 8 for v in bits_of.values())} INT8, "
98
+ f"{sum(v == 16 for v in bits_of.values())} FP16 modules; {bpw:.3f} bits/weight")
99
+ else:
100
+ bits_of = None
101
+
102
+ writer = ShardWriter(out_dir, int(max_shard_size_gb * 2 ** 30))
103
+ quantized = set()
104
+ prefixes = tuple(_layer_prefixes(arch))
105
+ for key in reader.all_keys(): # everything outside the decoder layers: fp16 copy
106
+ ck = _canon(key, arch)
107
+ if ck.startswith("lm_head") and arch.lm_head is None: # tied to the input embedding
108
+ continue
109
+ if not ck.startswith(prefixes):
110
+ writer.add(ck, reader.get(key, dtype=torch.float16))
111
+
112
+ res = sigma.ResidualModel(arch, reader, dev) if method == "shaped" else None
113
+ for L in range(arch.n_layer):
114
+ tl = time.time()
115
+ lins = {lin.name: lin for lin in arch.layers[L]}
116
+ W = {nm: _get_linear(reader, lin, dev) for nm, lin in lins.items()}
117
+ S = {}
118
+ if method == "shaped":
119
+ m1 = res.norm_moments(L, "ln1")
120
+ S["ln1"] = sigma.S_norm(m1)
121
+ v = [lin for lin in lins.values() if lin.role in ("v", "qkv")][0]
122
+ Wv, bv = W[v.name]
123
+ if v.role == "qkv":
124
+ Wv, bv = Wv[2 * arch.d:3 * arch.d], (None if bv is None else bv[2 * arch.d:3 * arch.d])
125
+ S["attn_out"] = sigma.S_attn(arch, m1, Wv, bv, kappa)
126
+ o = [lin for lin in lins.values() if lin.role == "o"][0]
127
+ res.add_writer(W[o.name][0])
128
+ m2 = res.norm_moments(L, "ln2")
129
+ S["ln2"] = sigma.S_norm(m2)
130
+ up = [lin for lin in lins.values() if lin.role == "up"][0]
131
+ gate = [lin for lin in lins.values() if lin.role == "gate"]
132
+ S["act"] = sigma.S_act(arch, m2, W[up.name][0], W[up.name][1], W[gate[0].name][0] if gate else None,
133
+ n_samples=n_samples, seed=seed + L, device=dev)
134
+ dn = [lin for lin in lins.values() if lin.role == "down"][0]
135
+ res.add_writer(W[dn.name][0])
136
+ # group modules that read the same input and use the same bit width: one shaped pass on the stacked rows
137
+ groups = {}
138
+ for nm, lin in lins.items():
139
+ b = bits if bits_of is None else bits_of[nm]
140
+ groups.setdefault((lin.input_key, b), []).append(nm)
141
+ uses = {}
142
+ for (ik, b) in groups:
143
+ if b != 16:
144
+ uses[ik] = uses.get(ik, 0) + 1
145
+ for (ik, b), nms in groups.items():
146
+ if b == 16:
147
+ for nm in nms:
148
+ writer.add(nm + ".weight", (W[nm][0].T if lins[nm].conv1d else W[nm][0]).half())
149
+ if W[nm][1] is not None:
150
+ writer.add(nm + ".bias", W[nm][1].half())
151
+ continue
152
+ Wcat = torch.cat([W[nm][0] for nm in nms], 0)
153
+ H = None
154
+ if method == "shaped":
155
+ uses[ik] -= 1 # last use: reuse S's memory for H
156
+ H = S.pop(ik) if uses[ik] == 0 else S[ik].clone()
157
+ H = H.to(device=dev, dtype=torch.float32); H.mul_(lam); H.diagonal().add_(1.0)
158
+ q, sc, z = core.quantize(Wcat, b, group_size, H=H, dynamic_groups=dynamic_groups)
159
+ del H
160
+ r0 = 0
161
+ for nm in nms:
162
+ r1 = r0 + W[nm][0].shape[0]
163
+ pk = core.pack(q[r0:r1], sc[r0:r1], z[r0:r1], b, group_size)
164
+ for k_, t in pk.items():
165
+ writer.add(nm + "." + k_, t)
166
+ if W[nm][1] is not None:
167
+ writer.add(nm + ".bias", W[nm][1].half())
168
+ quantized.add(nm); r0 = r1
169
+ for which in ("ln1", "ln2"):
170
+ gname, bname = arch.norm_names(L, which)
171
+ writer.add(gname, reader.get(gname, dtype=torch.float16))
172
+ if bname and reader.has(bname):
173
+ writer.add(bname, reader.get(bname, dtype=torch.float16))
174
+ del W, S
175
+ if dev.type == "cuda":
176
+ torch.cuda.empty_cache()
177
+ log(f"[quantize] layer {L + 1}/{arch.n_layer} done in {time.time() - tl:.1f}s")
178
+ total = writer.close()
179
+
180
+ qc = {"quant_method": "gptq", "bits": bits if bits_of is None else 4, "group_size": group_size, "desc_act": False,
181
+ "sym": False, "static_groups": not dynamic_groups, "true_sequential": False, "damp_percent": 0.0, "checkpoint_format": "gptq",
182
+ "pack_dtype": "int32", "lm_head": False,
183
+ "quantizer": ["shapequant:0.1.0"],
184
+ "shapequant": {"method": method, "lam": lam, "kappa": kappa, "dynamic_groups": dynamic_groups, "n_samples": n_samples, "budget": budget,
185
+ "data_free": True, "training_free": True}}
186
+ if bits_of is not None:
187
+ qc["dynamic"] = {}
188
+ for nm, b in bits_of.items():
189
+ if b == 8:
190
+ qc["dynamic"]["+:^" + nm.replace(".", r"\.") + "$"] = {"bits": 8}
191
+ elif b == 16:
192
+ qc["dynamic"]["-:^" + nm.replace(".", r"\.") + "$"] = {}
193
+ cfg_out = dict(cfg); cfg_out["quantization_config"] = qc; cfg_out["torch_dtype"] = "float16"
194
+ json.dump(cfg_out, open(os.path.join(out_dir, "config.json"), "w"), indent=2)
195
+ copy_side_files(src, out_dir)
196
+ summary = {"model": model, "out_dir": out_dir, "method": method, "group_size": group_size,
197
+ "bits": bits if bits_of is None else "mixed", "checkpoint_bytes": total, "minutes": (time.time() - t0) / 60}
198
+ if bits_of is not None:
199
+ summary["bits_per_module"] = bits_of
200
+ json.dump(summary, open(os.path.join(out_dir, "shapequant_summary.json"), "w"), indent=2)
201
+ log(f"[done] {out_dir}: {total / 2 ** 30:.2f} GiB in {summary['minutes']:.1f} min")
202
+ return summary
203
+
204
+
205
+ def _layer_prefixes(arch):
206
+ return [arch.norms[L][0].rsplit(".", 1)[0] + "." for L in range(arch.n_layer)]
207
+
208
+
209
+ def _canon(key, arch):
210
+ """Key with the canonical root used by the architecture ('model.' for llama/opt, 'transformer.' for gpt2)."""
211
+ root = "transformer." if arch.model_type == "gpt2" else "model."
212
+ if key.startswith((root, "lm_head")):
213
+ return key
214
+ return root + key
shapequant/sigma.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model-implied input second moments, built from the weights only and streamed layer by layer.
2
+
3
+ Residual stream: Sig_0 = E_{t~pi}[e_t e_t^T] (+ positional embeddings), pi = Zipf prior on token id
4
+ (BPE ids are assigned in merge order, i.e. by frequency); then Sig += W^T W for every writer
5
+ (attention output and MLP output) in order.
6
+ Norm outputs: LayerNorm: C = P Sig P / (tr/n), cov = g C g, mean = beta, M2 = cov + beta beta^T.
7
+ RMSNorm: C = Sig / (tr/n), M2 = g C g, mean = g m / s, cov = M2 - mean mean^T.
8
+ MLP 2nd input: Monte Carlo of the block's own nonlinearity on x ~ N(mean, cov).
9
+ Attention output: E[o o^T] = mu_o mu_o^T + kappa * blockdiag_heads(C_v of the head's KV group).
10
+ Every S is normalised to trace n. Design H = I + lam * S.
11
+ """
12
+ import math
13
+ import torch
14
+
15
+ F64 = torch.float64
16
+
17
+
18
+ class ResidualModel:
19
+ def __init__(self, arch, reader, device, ctx=1024, zipf_shift=10.0, chunk=8192):
20
+ self.a, self.r, self.dev = arch, reader, device
21
+ E = reader.get(arch.embed, device=device, dtype=torch.float32)
22
+ V = E.shape[0]
23
+ pi = 1.0 / (torch.arange(V, device=device, dtype=F64) + zipf_shift); pi /= pi.sum()
24
+ Sig = torch.zeros((arch.d, arch.d), dtype=F64, device=device); m = torch.zeros(arch.d, dtype=F64, device=device)
25
+ for s in range(0, V, chunk):
26
+ e = E[s:s + chunk].to(F64); w = pi[s:s + chunk]
27
+ Sig += (e * w[:, None]).T @ e; m += w @ e
28
+ del E
29
+ if arch.pos_embed is not None and reader.has(arch.pos_embed):
30
+ P = reader.get(arch.pos_embed, device=device, dtype=F64)
31
+ T = min(ctx, P.shape[0] - arch.pos_offset)
32
+ P = P[arch.pos_offset: arch.pos_offset + T]
33
+ pm = P.mean(0)
34
+ Sig += P.T @ P / T + torch.outer(m, pm) + torch.outer(pm, m); m = m + pm
35
+ self.Sig, self.m = Sig, m
36
+
37
+ def add_writer(self, W_out_in):
38
+ """W in torch layout (out=d, in); the writer adds W h with isotropic h."""
39
+ W = W_out_in.to(device=self.dev, dtype=F64)
40
+ self.Sig += W @ W.T
41
+
42
+ def norm_moments(self, L, which):
43
+ a = self.a; n = a.d
44
+ gname, bname = a.norm_names(L, which)
45
+ g = self.r.get(gname, device=self.dev, dtype=F64)
46
+ if a.norm == "ln":
47
+ Sg = self.Sig - self.Sig.mean(0, keepdim=True)
48
+ Sg = Sg - Sg.mean(1, keepdim=True) # P Sig P
49
+ C = Sg / (torch.trace(Sg) / n)
50
+ cov = g[:, None] * C * g[None, :]
51
+ b = self.r.get(bname, device=self.dev, dtype=F64) if bname and self.r.has(bname) else torch.zeros(n, dtype=F64, device=self.dev)
52
+ return b, cov, cov + torch.outer(b, b)
53
+ s2 = torch.trace(self.Sig) / n
54
+ M2 = g[:, None] * (self.Sig / s2) * g[None, :]
55
+ mu = g * self.m / torch.sqrt(s2)
56
+ w, Q = torch.linalg.eigh(M2 - torch.outer(mu, mu))
57
+ cov = (Q * w.clamp(min=0)) @ Q.T
58
+ return mu, cov, M2
59
+
60
+
61
+ def normalise(M):
62
+ return M.mul_(M.shape[0] / torch.trace(M))
63
+
64
+
65
+ def S_norm(moments):
66
+ return normalise(moments[2].clone())
67
+
68
+
69
+ def S_act(arch, moments, W_up, b_up, W_gate, n_samples=16384, batch=2048, seed=0, device="cpu"):
70
+ """W_up/W_gate: torch layout (inter, d). Returns (inter, inter) float32."""
71
+ mu, cov, _ = moments
72
+ w, Q = torch.linalg.eigh(cov)
73
+ R = (Q * w.clamp(min=0).sqrt()).to(torch.float32)
74
+ mu = mu.to(torch.float32)
75
+ Wu = W_up.to(device=device, dtype=torch.float32).T
76
+ Wg = None if W_gate is None else W_gate.to(device=device, dtype=torch.float32).T
77
+ bu = None if b_up is None else b_up.to(device=device, dtype=torch.float32)
78
+ gen = torch.Generator(device=device).manual_seed(seed)
79
+ M = torch.zeros((Wu.shape[1], Wu.shape[1]), dtype=torch.float32, device=device); done = 0
80
+ while done < n_samples:
81
+ b = min(batch, n_samples - done)
82
+ x = mu + torch.randn((b, R.shape[0]), generator=gen, device=device) @ R.T
83
+ u = x @ Wu
84
+ if bu is not None:
85
+ u = u + bu
86
+ if arch.act == "swiglu":
87
+ z = x @ Wg; h = z * torch.sigmoid(z) * u
88
+ elif arch.act == "relu":
89
+ h = torch.relu(u)
90
+ elif arch.act == "gelu_new":
91
+ h = 0.5 * u * (1 + torch.tanh(math.sqrt(2 / math.pi) * (u + 0.044715 * u ** 3)))
92
+ elif arch.act == "gelu":
93
+ h = torch.nn.functional.gelu(u)
94
+ else:
95
+ raise NotImplementedError(arch.act)
96
+ M += h.T @ h; done += b
97
+ return normalise(M.div_(done))
98
+
99
+
100
+ def S_attn(arch, moments, W_v, b_v, kappa=1.0):
101
+ """W_v: torch layout (n_kv*head_dim, d). Returns (n_head*head_dim)^2 float64."""
102
+ mu, cov, _ = moments
103
+ Wv = W_v.to(device=cov.device, dtype=F64)
104
+ mv = Wv @ mu + (0 if b_v is None else b_v.to(device=cov.device, dtype=F64))
105
+ Cv = Wv @ cov @ Wv.T
106
+ H, dh = arch.n_head, arch.head_dim; per = H // arch.n_kv
107
+ n = H * dh
108
+ mo = torch.zeros(n, dtype=F64, device=cov.device); M = torch.zeros((n, n), dtype=F64, device=cov.device)
109
+ for h in range(H):
110
+ gk = h // per; s, t = slice(h * dh, (h + 1) * dh), slice(gk * dh, (gk + 1) * dh)
111
+ mo[s] = mv[t]; M[s, s] = kappa * Cv[t, t]
112
+ return normalise(M + torch.outer(mo, mo))
tests/real_models.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-model check: quantize -> load -> WikiText-2 perplexity, for RTN and shaped, uniform INT4 and mixed.
2
+ usage: python tests/real_models.py <hf_model_dir_or_id> <out_dir> [--group 128] [--budget 4.75] [--windows 32] [--bos ID] [--text wt2_test.txt]"""
3
+ import argparse, json, os, time, torch
4
+ from transformers import AutoModelForCausalLM, AutoTokenizer
5
+ from shapequant import quantize_model, load_quantized, perplexity, wikitext2_windows
6
+
7
+ p = argparse.ArgumentParser()
8
+ p.add_argument("model"); p.add_argument("out"); p.add_argument("--group", type=int, default=128)
9
+ p.add_argument("--budget", type=float, default=4.75); p.add_argument("--windows", type=int, default=32)
10
+ p.add_argument("--bos", type=int, default=None); p.add_argument("--text", default=None); p.add_argument("--device", default="cpu")
11
+ a = p.parse_args()
12
+ tok = AutoTokenizer.from_pretrained(a.model)
13
+ W = wikitext2_windows(tok, 1024, a.windows, a.text)
14
+ R = {"dense": perplexity(AutoModelForCausalLM.from_pretrained(a.model, dtype=torch.float32).eval().to(a.device), W, a.bos)}
15
+ for tag, kw in (("rtn_int4", dict(bits=4, method="rtn")), ("shaped_int4", dict(bits=4)),
16
+ ("rtn_mixed", dict(budget=a.budget, method="rtn")), ("shaped_mixed", dict(budget=a.budget))):
17
+ t = time.time(); d = os.path.join(a.out, tag)
18
+ s = quantize_model(a.model, d, group_size=a.group, device=a.device, log=lambda *x: None, **kw)
19
+ R[tag] = {"ppl": perplexity(load_quantized(d, dtype=torch.float32, device=a.device), W, a.bos),
20
+ "minutes": (time.time() - t) / 60, "checkpoint_MB": s["checkpoint_bytes"] / 2 ** 20}
21
+ print(tag, R[tag], flush=True)
22
+ json.dump(R, open(os.path.join(a.out, "results.json"), "w"), indent=1)
tests/scale_70b.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """70B scaling check without the weights: memory estimates from real configs, and timing of the per-layer core at
2
+ Llama-3.1-70B shapes on whatever device is available (random weights; the arithmetic is shape-dependent only)."""
3
+ import json, sys, time, torch
4
+ from shapequant import estimate_memory, core, sigma
5
+ from shapequant.arch import from_config
6
+
7
+ CFGS = {
8
+ "Llama-3.1-70B": {"model_type": "llama", "hidden_size": 8192, "intermediate_size": 28672, "num_hidden_layers": 80,
9
+ "num_attention_heads": 64, "num_key_value_heads": 8, "vocab_size": 128256, "hidden_act": "silu",
10
+ "rms_norm_eps": 1e-5, "max_position_embeddings": 131072, "tie_word_embeddings": False},
11
+ "Qwen2.5-72B": {"model_type": "qwen2", "hidden_size": 8192, "intermediate_size": 29568, "num_hidden_layers": 80,
12
+ "num_attention_heads": 64, "num_key_value_heads": 8, "vocab_size": 152064, "hidden_act": "silu",
13
+ "rms_norm_eps": 1e-6, "max_position_embeddings": 32768, "tie_word_embeddings": False},
14
+ "Llama-2-70B": {"model_type": "llama", "hidden_size": 8192, "intermediate_size": 28672, "num_hidden_layers": 80,
15
+ "num_attention_heads": 64, "num_key_value_heads": 8, "vocab_size": 32000, "hidden_act": "silu",
16
+ "rms_norm_eps": 1e-5, "max_position_embeddings": 4096, "tie_word_embeddings": False},
17
+ }
18
+ out = {k: estimate_memory(v) for k, v in CFGS.items()}
19
+ print(json.dumps(out, indent=1))
20
+ dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
21
+ a = from_config(CFGS["Llama-3.1-70B"]); d = a.d; t = {}
22
+ torch.manual_seed(0)
23
+ # attention-input group (q,k,v stacked: 10240 x 8192) and attention-output (8192 x 8192) at full 70B size
24
+ for name, rows in (("qkv_stacked", 64 * 128 + 2 * 8 * 128), ("o_proj", d)):
25
+ W = torch.randn(rows, d, device=dev) * 0.02
26
+ A = torch.randn(d, 256, device=dev) / 16; H = A @ A.T; H.diagonal().add_(1.0)
27
+ t0 = time.time(); q, s, z = core.quantize(W, 4, 128, H=H); t[name] = time.time() - t0
28
+ pk = core.pack(q, s, z, 4, 128); assert pk["qweight"].shape == (d // 8, rows)
29
+ del W, H, A, q, s, z, pk
30
+ # attention-output covariance construction at 70B size (GQA 64 query / 8 KV heads)
31
+ mu = torch.zeros(d, dtype=torch.float64, device=dev); cov = torch.eye(d, dtype=torch.float64, device=dev)
32
+ t0 = time.time(); S = sigma.S_attn(a, (mu, cov, cov), torch.randn(8 * 128, d, device=dev) * 0.02, None); t["S_attn"] = time.time() - t0
33
+ assert S.shape == (d, d); del S, cov
34
+ print(json.dumps({"device": str(dev), "seconds": t}, indent=1))
35
+ json.dump({"estimates": out, "timing": {"device": str(dev), "seconds": t}}, open(sys.argv[1] if len(sys.argv) > 1 else "scale_70b.json", "w"), indent=1)
tests/test_core.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from shapequant import core
3
+
4
+
5
+ def _ref_sequential(W, bits, group, H, dynamic=False):
6
+ """Unblocked reference of the error-feedback recursion."""
7
+ W = W.clone().float(); out, inn = W.shape; maxq = 2 ** bits - 1
8
+ sc, z = core.group_params(W, bits, group)
9
+ U = torch.linalg.cholesky(torch.cholesky_inverse(torch.linalg.cholesky(H)), upper=True)
10
+ Q = torch.zeros_like(W)
11
+ for c in range(inn):
12
+ g = c // group
13
+ if dynamic and c % group == 0:
14
+ s_, z_ = core.group_params(W[:, c:c + group], bits, group); sc[:, g], z[:, g] = s_[:, 0], z_[:, 0]
15
+ q = torch.clamp(torch.round(W[:, c] / sc[:, g]) + z[:, g], 0, maxq); Q[:, c] = q
16
+ err = (W[:, c] - (q - z[:, g]) * sc[:, g]) / U[c, c]
17
+ W[:, c:] -= err[:, None] * U[c, c:][None, :]
18
+ return Q
19
+
20
+
21
+ def test_identity_design_is_rtn():
22
+ torch.manual_seed(0)
23
+ W = torch.randn(64, 256)
24
+ q0, s0, z0 = core.quantize(W, 4, 64)
25
+ for dyn in (False, True):
26
+ q1, s1, z1 = core.quantize(W, 4, 64, H=torch.eye(256), dynamic_groups=dyn)
27
+ assert torch.equal(q0, q1) and torch.equal(s0, s1)
28
+
29
+
30
+ def test_blocked_equals_sequential():
31
+ torch.manual_seed(1)
32
+ W = torch.randn(48, 384); A = torch.randn(384, 384); H = A @ A.T / 384 + torch.eye(384)
33
+ for dyn in (False, True):
34
+ for blk in (32, 64, 128, 256):
35
+ q, _, _ = core.quantize(W, 4, 128, H=H, block=blk, dynamic_groups=dyn)
36
+ assert (q.float() - _ref_sequential(W, 4, 128, H, dyn)).abs().max() == 0, (dyn, blk)
37
+
38
+
39
+ def test_pack_roundtrip():
40
+ torch.manual_seed(2)
41
+ for bits in (4, 8):
42
+ W = torch.randn(96, 256) * 0.05
43
+ q, s, z = core.quantize(W, bits, 128)
44
+ pk = core.pack(q, s, z, bits, 128)
45
+ Wd = core.unpack(pk["qweight"], pk["qzeros"], pk["scales"], pk["g_idx"], bits)
46
+ ref = core.dequantize(q, s.half().float(), z, 128)
47
+ assert torch.allclose(Wd, ref, atol=0, rtol=0)
48
+ assert pk["qweight"].dtype == torch.int32 and pk["qweight"].shape == (256 * bits // 32, 96)
49
+
50
+
51
+ def test_zero_points_v1_safe():
52
+ W = torch.rand(32, 128) # all positive groups: zero-point must still be >= 1
53
+ q, s, z = core.quantize(W, 4, 64)
54
+ assert z.min() >= 1
55
+ err = (W - core.dequantize(q, s, z, 64)).abs().max() / W.abs().max()
56
+ assert err < 0.05
57
+
58
+
59
+ def test_shaping_reduces_weighted_error():
60
+ torch.manual_seed(3)
61
+ W = torch.randn(128, 256); A = torch.randn(256, 32); H = torch.eye(256) + A @ A.T
62
+ def cost(q, s, z):
63
+ R = W - core.dequantize(q, s, z, 64); return float(torch.trace(R @ H @ R.T))
64
+ assert cost(*core.quantize(W, 4, 64, H=H)) < cost(*core.quantize(W, 4, 64))
tests/test_end_to_end.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny random models of every supported architecture: quantize -> save -> load -> logits must equal a model whose
2
+ weights are the dequantized values (exactness of format, loader and module mapping)."""
3
+ import json, os, tempfile
4
+ import pytest
5
+ import torch
6
+ from transformers import AutoModelForCausalLM, LlamaConfig, OPTConfig, GPT2Config, Qwen2Config
7
+
8
+ from shapequant import quantize_model, load_quantized, core
9
+
10
+ CFGS = {
11
+ "llama": LlamaConfig(hidden_size=128, intermediate_size=256, num_hidden_layers=2, num_attention_heads=4,
12
+ num_key_value_heads=2, vocab_size=512, max_position_embeddings=256, tie_word_embeddings=False),
13
+ "qwen2": Qwen2Config(hidden_size=128, intermediate_size=256, num_hidden_layers=2, num_attention_heads=4,
14
+ num_key_value_heads=2, vocab_size=512, max_position_embeddings=256, tie_word_embeddings=True),
15
+ "opt": OPTConfig(hidden_size=128, ffn_dim=256, num_hidden_layers=2, num_attention_heads=4, vocab_size=512,
16
+ max_position_embeddings=256, word_embed_proj_dim=128),
17
+ "gpt2": GPT2Config(n_embd=128, n_layer=2, n_head=4, vocab_size=512, n_positions=256),
18
+ }
19
+
20
+
21
+ @pytest.mark.parametrize("kind", list(CFGS))
22
+ @pytest.mark.parametrize("budget", [None, 6.5])
23
+ def test_roundtrip(kind, budget):
24
+ torch.manual_seed(0)
25
+ src = AutoModelForCausalLM.from_config(CFGS[kind]).eval()
26
+ with tempfile.TemporaryDirectory() as d:
27
+ src.save_pretrained(os.path.join(d, "src"), safe_serialization=True)
28
+ s = quantize_model(os.path.join(d, "src"), os.path.join(d, "q"), bits=4, group_size=64, budget=budget,
29
+ n_samples=2048, device="cpu", log=lambda *a: None)
30
+ qm = load_quantized(os.path.join(d, "q"), dtype=torch.float32)
31
+ # reference: same architecture, weights replaced by the dequantized values
32
+ ref = AutoModelForCausalLM.from_config(CFGS[kind]).eval()
33
+ ref.load_state_dict(src.state_dict())
34
+ sd = ref.state_dict()
35
+ from safetensors.torch import load_file
36
+ ck = load_file(os.path.join(d, "q", "model.safetensors"))
37
+ qc = json.load(open(os.path.join(d, "q", "config.json")))["quantization_config"]
38
+ from shapequant.qlinear import _bits_for
39
+ n_q = 0
40
+ for k in ck:
41
+ if k.endswith(".qweight"):
42
+ mod = k[:-8]; bits = _bits_for(mod, qc)
43
+ Wd = core.unpack(ck[mod + ".qweight"], ck[mod + ".qzeros"], ck[mod + ".scales"], ck[mod + ".g_idx"], bits)
44
+ key = next(c for c in (mod + ".weight", mod.split(".", 1)[1] + ".weight") if c in sd)
45
+ sd[key] = Wd.T if kind == "gpt2" else Wd
46
+ n_q += 1
47
+ ref.load_state_dict(sd)
48
+ ids = torch.randint(0, 512, (1, 40))
49
+ with torch.no_grad():
50
+ a, b, c = qm(ids).logits, ref(ids).logits, src(ids).logits
51
+ assert n_q > 0
52
+ assert torch.allclose(a, b, atol=2e-3), (a - b).abs().max() # loader == dequantized reference
53
+ assert (a - c).abs().max() < 5 * (c.std() + 1e-3) # close to the original model
54
+ if budget is not None:
55
+ assert "dynamic" in qc and s["bits"] == "mixed"