wepiqx commited on
Commit
c99f13f
·
0 Parent(s):

ASHQ1 v6: IQ tier spectrum, --allow-q3-or-lower, has_imatrix safety

Browse files

- Full IQ tier range (IQ2_XXS through IQ4_XS) for aggressive spread
- --allow-q3-or-lower now uses IQ types instead of plain Q3_K
- has_imatrix check prevents crash on tensors without imatrix at low bitrates
- Depth factor removed (A/B test proved no PPL benefit)
- IQ4_NL MSE_BPW adjusted for free-upgrade pass
- Ornith-9B-MTP results added to README

Files changed (11) hide show
  1. .gitignore +6 -0
  2. README.md +206 -0
  3. classifier.py +244 -0
  4. config_generator.py +155 -0
  5. constants.py +222 -0
  6. imatrix_reader.py +179 -0
  7. main.py +185 -0
  8. model_reader.py +110 -0
  9. quantizer.py +75 -0
  10. requirements.txt +2 -0
  11. utils.py +53 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.gguf
2
+ *.gguf.hdr
3
+ *.gguf.tmp
4
+ __pycache__/
5
+ *.pyc
6
+ .env
README.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ pipeline_tag: text-generation
6
+ library_name: gguf
7
+ tags:
8
+ - quantization
9
+ - gguf
10
+ - llama-cpp
11
+ - imatrix
12
+ - hybrid-quantization
13
+ - selective-quantization
14
+ - priority-queue
15
+ - mse
16
+ - theoretical-optimization
17
+ - qwen3.5
18
+ - gemma4
19
+ - moe
20
+ - mtp
21
+ ---
22
+
23
+ # ASHQ1 — Autonomous Selective Hybrid Quantization
24
+
25
+ > ⚠️ **Experimental.** ASHQ1 is a personal research project that I will be refining over time. Use at your own risk. Results may vary between architectures and fine-tunes. Feedback and contributions welcome.
26
+
27
+ ASHQ1 is a post-training quantization method for GGUF models that uses an **imatrix-driven priority queue** to maximise theoretical quality per megabyte. Instead of uniform bit-depth or heuristic layer-blocking, it treats tied tensor groups as monolithic entities and greedily upgrades them by strict mathematical utility — the product of summed importance and theoretical MSE reduction, divided by size cost.
28
+
29
+ ## Results
30
+
31
+ | Target | Model | Arch | MTP | Actual | PPL (ctx=1024) | vs Q6_K |
32
+ |:------:|-------|:----:|:---:|:------:|:---------------:|:-------:|
33
+ | 5500 | Qwable-9B | Qwen3.5 | — | 5,503 MiB | 7.4334 | — |
34
+ | 5700 | Qwythos-9B | Qwen3.5 | yes | 5,713 MiB | **7.5411** | **−0.047** |
35
+ | 6000 | Ornith-1.0-9B | Qwen3.5 | yes | 6,015 MiB | **7.6774** | — |(1)
36
+ | 6000 | Ornith-1.0-9B | Qwen3.5 | yes | 6,012 MiB | **7.4697** | — |(2)
37
+
38
+ ASHQ1 at 5700 MiB beats uniform Q6_K by **0.047 PPL** at **19% smaller** (5713 vs 7076 MiB).
39
+
40
+ > (1) Using `--allow-q3-or-lower` — aggressive IQ2→Q8_0 spread.
41
+ > (2) Default Q4_K floor — conservative distribution, best PPL at 6000 MiB.
42
+
43
+ ## Real-World Validation
44
+
45
+ ASHQ1's theoretical quality advantage transfers to real agentic coding. We tested Ornith-1.0-9B ASHQ1 6500 (6.4 GB, 33% smaller than Q8_0) as the backend for [Pi](https://pi.dev/), an autonomous coding agent that uses `llama.cpp` as its LLM backend.
46
+
47
+ At `temperature 0.6`, the model was tasked with building a complete personal finance dashboard as a single HTML file — Canvas charts, budget tracker, dark mode, transaction filtering, upcoming bills, responsive layout. The agent worked autonomously: planned the architecture, wrote the entire ~1100-line file, caught its own bugs (`date.now` → `date.getTime`), fixed dark mode logic, ran Node.js validation, and iterated until all checks passed. The final `finance-dashboard.html` was a polished, production-quality single-page app — no external dependencies, no hallucinations, no broken features.
48
+
49
+ This is not cherry-picked. It's the first test we ran. The benchmarks didn't lie — ASHQ1 preserves enough quality that a 6.4 GB quant can drive an autonomous coding agent to build complete, working applications from scratch.
50
+
51
+ ## How It Works
52
+
53
+ ### 1. Floor Assignment
54
+
55
+ Every tensor starts at a minimum tier by class. SSM params and norms lock at F16. Embeddings start at Q5_K. Weight matrices start at Q4_K (or IQ4_XS for QAT models). MTP heads deploy at Q8_0.
56
+
57
+ With `--allow-q3-or-lower`, low-importance tensors (`ffn_down`, `attn_output`, `ssm_out`) start as low as IQ2_XXS, giving the priority queue more room to upgrade important tensors to Q8_0. Tensors missing imatrix data are kept at Q4_K to avoid garbage at low bitrates.
58
+
59
+ ### 2. Importance
60
+
61
+ Imatrix `in_sum2` measures how much each weight contributes to the output variance. Layer position weighting was tested but showed no PPL benefit and has been removed.
62
+
63
+ ### 3. Tied Group Detection
64
+
65
+ Tensors with numerically identical `in_sum2` arrays are tied (shared weights). They form a single upgrade group — all members upgrade together as one unit. Group importance is the **sum** of its members' importance, preventing large groups from being starved of budget.
66
+
67
+ ### 4. Priority Queue Drain
68
+
69
+ All possible single-tier upgrades are pushed into a max-heap:
70
+
71
+ ```
72
+ utility/MiB = sum(timp[group]) × (MSE(cur) − MSE(next)) / (size(next) − size(cur))
73
+ ```
74
+
75
+ MSE per tier is theoretical: `MSE = 2^(-2 × bpw)`. K-quants get +0.1 effective bpw vs IQ-quants at the same real bpw, so IQ4_NL→Q4_K is a free quality gain.
76
+
77
+ The queue pops the highest-utility upgrade, applies it, pushes the next upgrade for that group, and drains until the budget is exhausted. A final pass catches any remaining zero-cost upgrades.
78
+
79
+ ## Why It Works
80
+
81
+ | Problem | ASHQ1 Solution |
82
+ |---------|---------------|
83
+ | Uniform quant wastes bits on low-importance tensors | Priority queue allocates budget where it matters |
84
+ | Heuristic hand-tuning doesn't scale | Single knob: `--size` in MiB |
85
+ | Hand-tuned SHQ hybrids need days of PPL sweeps | Queue converges in ~1 sec for any budget |
86
+ | Large tied groups starved by per-tensor logic | `sum(timp)` prevents 32× group penalty |
87
+ | IQ4_NL→Q4_K at same bpw is a no-op | Free-upgrade pass catches zero-cost quality gains |
88
+ | No PPL-per-budget curve needed | Queue optimises for MSE directly |
89
+ | Tensors without imatrix crash at low bitrates | `has_imatrix` check falls back to Q4_K floor |
90
+
91
+ ## Supported Architectures
92
+
93
+ | Arch | Detection | Features |
94
+ |------|-----------|----------|
95
+ | `qwen35` | SSM + QKV | Hybrid attention, SSM layers, GQA, **MTP support** |
96
+ | `mellum2` | MoE (`exps` tensors) | Mixture of Experts, GQA, router F16 |
97
+ | `gemma4` | Layer-scale norms | QAT support, Q4_K attention floor |
98
+
99
+ MTP (Multi-Token Prediction) heads are handled explicitly: MTP tensors deploy at Q8_0 and are excluded from the classifier's budget (their cost is subtracted from the target upfront). Tensor names with `nextn.*` or layers beyond `n_layers` are detected as MTP at runtime.
100
+
101
+ ### Looking for: Qwen3.6 support
102
+
103
+ Qwen3.6 is one of the most capable local LLMs right now, but I can't handle it on my hardware. The BF16 source is ~55 GB — I don't have enough RAM to even load it, let alone quantize. If you have access to a Qwen3.6 GGUF (any quantization) and can run `llama-imatrix` on it — or if you'd like to collaborate on adding architecture detection — please reach out. I can handle the integration, I just need the raw tensor names and imatrix data to map out the class system.
104
+
105
+ New architectures can be added via `ARCH_FEATURES` in `constants.py`.
106
+
107
+ ## Code Structure
108
+
109
+ | File | Role |
110
+ |------|------|
111
+ | `main.py` | CLI entry point, orchestration, `--show-floors`, multiple `--imatrix` support |
112
+ | `model_reader.py` | Reads GGUF, detects architecture/prefix/n_layers/MTP at runtime |
113
+ | `imatrix_reader.py` | Parses imatrix GGUF, detects tied groups via `np.allclose(in_sum2)`, combines multiple imatrix |
114
+ | `classifier.py` | Floor assignment → tied group building → priority queue drain → free upgrade pass |
115
+ | `config_generator.py` | Generates `--tensor-type` regex rules from classified tensors (valid ECMAScript regex with pipe-alternated ranges) |
116
+ | `quantizer.py` | Subprocess wrapper around `llama-quantize` |
117
+ | `constants.py` | TENSOR_CLASS mapping, CLASS_HARD_FLOORS, CLASS_MAX_TIER, MSE_BPW, TIER_BPW, ARCH_FEATURES |
118
+
119
+ ## Usage
120
+
121
+ ### Quantization
122
+
123
+ ```bash
124
+ pip install -r requirements.txt
125
+
126
+ # Dry run (∼1 sec)
127
+ python main.py --model model.gguf --imatrix imatrix.gguf --size 6800
128
+
129
+ # Actual quant (∼10 min)
130
+ python main.py --model model.gguf --imatrix imatrix.gguf --size 6800 --run
131
+
132
+ # Show hard floors
133
+ python main.py --show-floors
134
+
135
+ # Multiple imatrix (combined with max/mean)
136
+ python main.py --model model.gguf --imatrix i1.gguf --imatrix i2.gguf \
137
+ --imatrix-method max --size 6800 --run
138
+
139
+ # Allow low-bit tensors (IQ2_XXS through Q8_0 spread)
140
+ python main.py --model model.gguf --imatrix imatrix.gguf --size 6000 \
141
+ --allow-q3-or-lower --run
142
+ ```
143
+
144
+ The `llama-quantize` binary path is set in `quantizer.py:6`.
145
+
146
+ ### Inference (llama-server)
147
+
148
+ Recommended server flags for serving ASHQ1 quants:
149
+
150
+ ```bash
151
+ ./build/bin/llama-server \
152
+ -m model-ASHQ1.gguf \
153
+ -c 50000 \
154
+ --jinja \
155
+ -fit off \
156
+ -ngl 99 \
157
+ --flash-attn on \
158
+ --cache-type-k q8_0 \
159
+ --cache-type-v q8_0 \
160
+ --port 8080 \
161
+ --mmap \
162
+ --temp 1.0 \
163
+ --top-p 0.95 \
164
+ --min-p 0 \
165
+ --top-k 20 \
166
+ --seed -1 \
167
+ --parallel 1
168
+ ```
169
+
170
+ ## Tier Reference
171
+
172
+ | Tier | BPW | MSE_BPW |
173
+ |------|:---:|:-------:|
174
+ | F16 | 16.0 | 16.0 |
175
+ | Q8_0 | 8.50 | 8.50 |
176
+ | Q6_K | 6.5625 | 6.5625 |
177
+ | Q5_K | 5.50 | 5.50 |
178
+ | Q4_K | 4.50 | 4.50 |
179
+ | IQ4_NL | 4.50 | (2) |
180
+ | IQ4_XS | 4.25 | 4.25 |
181
+ | Q3_K | 3.4375 | 3.4375 |
182
+ | IQ3_M | 3.66 | — |
183
+ | IQ3_S | 3.44 | 3.44 |
184
+ | IQ3_XXS | 3.0625 | 3.0625 |
185
+ | IQ2_S | 2.50 | 2.50 |
186
+ | IQ2_XS | 2.3125 | 2.3125 |
187
+ | IQ2_XXS | 2.0625 | 2.0625 |
188
+ | IQ1_S | 1.5625 | 1.5625 |
189
+
190
+ > (2) IQ4_NL uses IQ4_XS MSE_BPW for the free-upgrade pass (same real bpw as Q4_K).
191
+
192
+ ## Quantization Configs
193
+
194
+ Generated configs are valid `llama-quantize` arguments with ECMAScript-compatible regex patterns. Each `--tensor-type` rule matches a group of tensors that share the same target tier, with layers grouped into contiguous ranges:
195
+
196
+ - `(blk|BLK)\.(3|7|11|15|19|23|27|31)\.attn_k=Q8_0` — specific attention layers at Q8_0
197
+ - `(blk|BLK)\.((?:22|23|24|25|26))\.ffn_gate=Q6_K` — range of FFN layers at Q6_K
198
+ - `.*output_norm.*=F16` — global catch-all
199
+
200
+ Rules are sorted by specificity (specific layers, high tiers first) because `llama-quantize` uses first-match-wins.
201
+
202
+ ## References
203
+
204
+ - [ASHQ1 repo](https://huggingface.co/wepiqx/ASHQ1)
205
+ - [GGUF specification](https://github.com/ggerganov/ggml/blob/master/docs/gguf.md)
206
+ - [llama.cpp](https://github.com/ggerganov/llama.cpp)
classifier.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import heapq
2
+ import warnings
3
+ from functools import lru_cache
4
+ from typing import Dict, List, Set, Tuple, Any
5
+ from dataclasses import dataclass, field
6
+
7
+ from constants import (
8
+ TIER_ORDER, TIER_BPW, GGUF_OVERHEAD_FACTOR, CLASS_MAX_TIER,
9
+ CAN_Q3, ALLOW_LOWER_FLOOR, MTP_DEPLOY_TIER, get_tensor_class, get_tensor_type,
10
+ is_mtp_tensor,
11
+ )
12
+
13
+ # Выносим делитель в константу (8 бит * 1024 байт * 1024 кбайт)
14
+ BITS_IN_MIB = 8 * 1024 * 1024.0
15
+
16
+ # Предвычисляем множители размеров для каждого тира (ускорение математики)
17
+ TIER_SIZE_MULTIPLIER = {
18
+ tier: (bpw / BITS_IN_MIB) * GGUF_OVERHEAD_FACTOR
19
+ for tier, bpw in TIER_BPW.items()
20
+ }
21
+
22
+ # K-quants используют блоки по 256 элементов и требуют выравнивания (padding) 3D-тензоров MoE
23
+ K_QUANTS = {"Q3_K", "Q4_K", "Q5_K", "Q6_K"}
24
+ MOE_PAD_TYPES = {"ffn_gate_exps", "ffn_up_exps", "ffn_down_exps", "ffn_down"}
25
+
26
+ MSE_BPW = {
27
+ "IQ1_S": 1.5625, "IQ2_XXS": 2.0625, "IQ2_XS": 2.3125,
28
+ "IQ2_S": 2.5,
29
+ "IQ3_XXS": 3.0625,
30
+ "Q3_K": 3.4375, "IQ3_S": 3.44,
31
+ "IQ4_XS": 4.25, "IQ4_NL": 4.25, "Q4_K": 4.50,
32
+ "Q5_K": 5.50, "Q6_K": 6.5625, "Q8_0": 8.50, "F16": 16.0,
33
+ }
34
+
35
+ @dataclass(order=True, slots=True)
36
+ class UpgradeItem:
37
+ """Элемент очереди апгрейдов. Сравнивается только по neg_utility."""
38
+ neg_utility: float
39
+ group_id: int = field(compare=False)
40
+ next_tier: str = field(compare=False)
41
+ cost_delta: float = field(compare=False)
42
+
43
+
44
+ def _tier_index(tier: str) -> int:
45
+ if tier not in TIER_ORDER:
46
+ raise ValueError(f"Unknown tier: {tier}")
47
+ return TIER_ORDER.index(tier)
48
+
49
+ def _tier_at(idx: int) -> str:
50
+ if not (0 <= idx < len(TIER_ORDER)):
51
+ raise IndexError(f"Tier index {idx} out of range")
52
+ return TIER_ORDER[idx]
53
+
54
+ def _size_mib(tier: str, n_elements: int) -> float:
55
+ """Размер тензора в MiB с учётом оверхеда GGUF."""
56
+ if n_elements <= 0:
57
+ return 0.0
58
+ return n_elements * TIER_SIZE_MULTIPLIER.get(tier, 0.0)
59
+
60
+ @lru_cache(maxsize=64)
61
+ def _mse_delta(cur_tier: str, next_tier: str) -> float:
62
+ return (2 ** (-2 * MSE_BPW[cur_tier])) - (2 ** (-2 * MSE_BPW[next_tier]))
63
+
64
+ def _push_upgrade(group_id: int,
65
+ group_registry: Dict[int, Tuple[List[str], int, int]],
66
+ assignments: Dict[str, str],
67
+ tensor_importance: Dict[str, float],
68
+ upgrade_queue: List[UpgradeItem],
69
+ importance_table: Dict[str, Any]):
70
+
71
+ # Храним 3 элемента: имена, реальный размер, размер с padding
72
+ g_names, g_elements, g_elements_padded = group_registry[group_id]
73
+ rep_name = g_names[0]
74
+ cur_tier = assignments[rep_name]
75
+ cur_idx = _tier_index(cur_tier)
76
+
77
+ rep_info = importance_table.get(rep_name, {})
78
+ ttype = rep_info["type"] if "type" in rep_info else get_tensor_type(rep_name)
79
+ cls = get_tensor_class(ttype)
80
+ max_tier = CLASS_MAX_TIER.get(cls, "Q8_0")
81
+
82
+ if cur_idx >= _tier_index(max_tier) or cur_idx >= len(TIER_ORDER) - 1:
83
+ return
84
+
85
+ next_tier = _tier_at(cur_idx + 1)
86
+
87
+ # Выбираем размер в зависимости от того, относится ли тир к K-quants
88
+ cur_size_g = g_elements_padded if cur_tier in K_QUANTS else g_elements
89
+ next_size_g = g_elements_padded if next_tier in K_QUANTS else g_elements
90
+
91
+ cost_delta = _size_mib(next_tier, next_size_g) - _size_mib(cur_tier, cur_size_g)
92
+
93
+ quality_delta = _mse_delta(cur_tier, next_tier)
94
+ if quality_delta <= 0:
95
+ return
96
+
97
+ if cost_delta == 0:
98
+ utility_per_mb = float('inf')
99
+ elif cost_delta < 0:
100
+ return
101
+ else:
102
+ total_g_imp = sum(tensor_importance.get(n, 0) for n in g_names)
103
+ utility_per_mb = (total_g_imp * quality_delta) / cost_delta
104
+
105
+ heapq.heappush(upgrade_queue, UpgradeItem(-utility_per_mb, group_id, next_tier, cost_delta))
106
+
107
+
108
+ def compute_initial_assignments(non_mtp_names: Set[str], mtp_names: Set[str],
109
+ importance_table: Dict, allow_q3: bool, is_qat: bool = False) -> Dict[str, str]:
110
+ assignments = {name: MTP_DEPLOY_TIER for name in mtp_names}
111
+
112
+ for name in non_mtp_names:
113
+ rep_info = importance_table.get(name, {})
114
+ has_imatrix = "importance_mean" in rep_info
115
+ ttype = rep_info["type"] if "type" in rep_info else get_tensor_type(name)
116
+ cls = get_tensor_class(ttype)
117
+
118
+ if cls in ("norms", "ssm_params"):
119
+ assignments[name] = "F16"
120
+ elif cls == "embd":
121
+ assignments[name] = "Q4_K" if is_qat else "Q5_K"
122
+ else:
123
+ base_floor = ("Q4_K" if cls == "attn_proj" else "IQ4_XS") if is_qat else "Q4_K"
124
+ if allow_q3 and cls in CAN_Q3 and has_imatrix:
125
+ assignments[name] = ALLOW_LOWER_FLOOR
126
+ else:
127
+ assignments[name] = base_floor
128
+
129
+ return assignments
130
+
131
+
132
+ def build_groups(tied_groups: List[List[str]], non_mtp_names: Set[str],
133
+ ne_map: Dict[str, int], padded_ne_map: Dict[str, int]) -> Dict[int, Tuple[List[str], int, int]]:
134
+ group_registry = {}
135
+ assigned_tensors = set()
136
+
137
+ for group_idx, group in enumerate(tied_groups):
138
+ clean_group = [n for n in group if n in non_mtp_names]
139
+ if clean_group:
140
+ g_elements = sum(ne_map.get(n, 0) for n in clean_group)
141
+ g_elements_padded = sum(padded_ne_map.get(n, 0) for n in clean_group)
142
+ group_registry[group_idx] = (clean_group, g_elements, g_elements_padded)
143
+ assigned_tensors.update(clean_group)
144
+
145
+ unassigned_tensors = non_mtp_names - assigned_tensors
146
+ next_group_idx = len(group_registry)
147
+
148
+ for name in unassigned_tensors:
149
+ group_registry[next_group_idx] = ([name], ne_map.get(name, 0), padded_ne_map.get(name, 0))
150
+ next_group_idx += 1
151
+
152
+ return group_registry
153
+
154
+
155
+ def optimal_classify(importance_table: dict, tied_groups: list, model: dict,
156
+ target_size_mib: float, allow_q3: bool = False) -> Tuple[dict, dict]:
157
+ if target_size_mib <= 0:
158
+ raise ValueError("target_size_mib must be positive")
159
+
160
+ features = model.get("features", {})
161
+ has_mtp = features.get("has_mtp", False)
162
+ n_layers = features.get("n_layers", 31)
163
+ is_qat = features.get("is_qat", False)
164
+ model_tensors = model.get("tensors", {})
165
+
166
+ ne_map = {k: v["n_elements"] for k, v in model_tensors.items()}
167
+ for tname, info in importance_table.items():
168
+ if tname not in ne_map:
169
+ ne_map[tname] = info["n_elements"]
170
+
171
+ # --- Вычисление MoE Padding (Целочисленное выравнивание) ---
172
+ moe_d_ff = features.get("moe_intermediate_size", 0)
173
+ padded_ne_map = dict(ne_map)
174
+ if moe_d_ff > 0 and moe_d_ff % 256 != 0:
175
+ aligned_d_ff = ((moe_d_ff + 255) // 256) * 256
176
+ for name, n_el in ne_map.items():
177
+ ttype = importance_table.get(name, {}).get("type", get_tensor_type(name))
178
+ if ttype in MOE_PAD_TYPES:
179
+ padded_ne_map[name] = (n_el // moe_d_ff) * aligned_d_ff
180
+ # -----------------------------------------------------------
181
+
182
+ all_names = set(ne_map.keys())
183
+ mtp_names = {n for n in all_names if is_mtp_tensor(n, n_layers)} if has_mtp else set()
184
+ non_mtp_names = all_names - mtp_names
185
+
186
+ tensor_importance = {}
187
+ for name in non_mtp_names:
188
+ raw_imp = importance_table.get(name, {}).get("importance_mean", 0.0)
189
+ tensor_importance[name] = raw_imp
190
+
191
+ assignments = compute_initial_assignments(non_mtp_names, mtp_names, importance_table, allow_q3, is_qat)
192
+ group_registry = build_groups(tied_groups, non_mtp_names, ne_map, padded_ne_map)
193
+
194
+ mtp_cost = sum(_size_mib(MTP_DEPLOY_TIER, ne_map.get(n, 0)) for n in mtp_names)
195
+ effective_target = target_size_mib - mtp_cost
196
+
197
+ current_size = sum(
198
+ _size_mib(assignments[n], padded_ne_map.get(n, ne_map.get(n, 0)) if assignments[n] in K_QUANTS else ne_map.get(n, 0))
199
+ for n in non_mtp_names
200
+ )
201
+
202
+ if current_size > effective_target:
203
+ warnings.warn(f"Initial size {current_size:.1f} MiB already exceeds target {effective_target:.1f} MiB", RuntimeWarning)
204
+
205
+ upgrade_queue = []
206
+ for g_id in group_registry:
207
+ _push_upgrade(g_id, group_registry, assignments, tensor_importance, upgrade_queue, importance_table)
208
+
209
+ while upgrade_queue:
210
+ item = heapq.heappop(upgrade_queue)
211
+ g_id, next_tier, cost_delta = item.group_id, item.next_tier, item.cost_delta
212
+
213
+ if cost_delta > 0 and current_size + cost_delta > effective_target:
214
+ continue
215
+
216
+ for n in group_registry[g_id][0]:
217
+ assignments[n] = next_tier
218
+ current_size += cost_delta
219
+
220
+ _push_upgrade(g_id, group_registry, assignments, tensor_importance, upgrade_queue, importance_table)
221
+
222
+ return assignments, padded_ne_map
223
+
224
+
225
+ def compute_stats(assignments: dict, ne_map: dict = None, padded_ne_map: dict = None) -> dict:
226
+ """Собирает статистику по тирам с точным учётом K_QUANTS padding."""
227
+ stats = {"by_tier_count": {}, "by_tier_mib": {}, "total_mib": 0.0, "tensor_count": 0}
228
+ for name, tier in assignments.items():
229
+ if not isinstance(tier, str):
230
+ continue
231
+ stats["tensor_count"] += 1
232
+ stats["by_tier_count"][tier] = stats["by_tier_count"].get(tier, 0) + 1
233
+
234
+ if ne_map:
235
+ if padded_ne_map and tier in K_QUANTS:
236
+ elements = padded_ne_map.get(name, ne_map.get(name, 0))
237
+ else:
238
+ elements = ne_map.get(name, 0)
239
+
240
+ size = _size_mib(tier, elements)
241
+ stats["by_tier_mib"][tier] = stats["by_tier_mib"].get(tier, 0.0) + size
242
+ stats["total_mib"] += size
243
+
244
+ return stats
config_generator.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from constants import QUANT_RANK
3
+
4
+
5
+ def get_regex_priority(regex: str) -> int:
6
+ """Higher = more specific = should come first (first-match-wins)."""
7
+ score = 0
8
+
9
+ if "nextn" in regex:
10
+ score += 200
11
+ if re.search(r"(blk|BLK)\\.3[0-2]\\.", regex):
12
+ score += 100
13
+ if re.search(r"(blk|BLK)\\.0\\.", regex):
14
+ score += 90
15
+ if re.search(r"(blk|BLK)\\.31\\.", regex):
16
+ score += 80
17
+ # Layer-range group: (blk|BLK)\.( — more specific than \d+
18
+ if r"(blk|BLK)\.(" in regex:
19
+ score += 50
20
+ elif r"(blk|BLK)\.\d" in regex:
21
+ score += 30
22
+ if regex.startswith(".*"):
23
+ score -= 50
24
+ if regex.endswith(r"\.weight"):
25
+ score += 10
26
+
27
+ return score
28
+
29
+
30
+ def _is_contiguous(lst, low, high):
31
+ if not lst:
32
+ return False
33
+ return len(lst) == (high - low + 1)
34
+
35
+
36
+ def _group_ranges(lst):
37
+ if not lst:
38
+ return
39
+ start = lst[0]
40
+ end = lst[0]
41
+ for i in range(1, len(lst)):
42
+ if lst[i] == end + 1:
43
+ end = lst[i]
44
+ else:
45
+ yield (start, end)
46
+ start = end = lst[i]
47
+ yield (start, end)
48
+
49
+
50
+ def _range_to_regex(start: int, end: int) -> str:
51
+ """Convert a range of layer numbers [start, end] to a valid regex."""
52
+ if start == end:
53
+ return str(start)
54
+ # For single-digit ranges, use character class
55
+ if end <= 9:
56
+ return f"[{start}-{end}]"
57
+ # Enumerate all numbers as pipe alternatives
58
+ alt = "|".join(str(i) for i in range(start, end + 1))
59
+ return f"(?:{alt})"
60
+
61
+
62
+ def generate_flags(
63
+ assignments: dict,
64
+ model: dict,
65
+ base_type: str,
66
+ target_size_mib: float = None,
67
+ ) -> dict:
68
+ is_qat = model.get("features", {}).get("is_qat", False)
69
+ output_type = "Q5_K"
70
+ token_embd_type = "Q4_K" if is_qat else "Q5_K"
71
+
72
+ max_layer = model.get("features", {}).get("n_layers", 31)
73
+
74
+ rules = []
75
+
76
+ # Group blk tensors by (ttype, tier)
77
+ type_tier_layers = {}
78
+ for tname, tier in assignments.items():
79
+ parts = tname.split(".")
80
+ if len(parts) >= 3 and parts[0] in ("blk", "BLK"):
81
+ try:
82
+ layer = int(parts[1])
83
+ except ValueError:
84
+ continue
85
+ ttype = parts[2]
86
+ key = (ttype, tier)
87
+ if key not in type_tier_layers:
88
+ type_tier_layers[key] = []
89
+ type_tier_layers[key].append(layer)
90
+
91
+ # Generate rules for blk tensor groups
92
+ for (ttype, tier), layers in sorted(
93
+ type_tier_layers.items(),
94
+ key=lambda x: -QUANT_RANK.get(x[0][1], 0),
95
+ ):
96
+ layers = sorted(set(layers))
97
+
98
+ if len(layers) >= 8 and _is_contiguous(layers, 0, max_layer):
99
+ pattern = f"(blk|BLK)\\.\\d+\\.{ttype}={tier}"
100
+ else:
101
+ parts = []
102
+ for start, end in _group_ranges(layers):
103
+ if start == end:
104
+ parts.append(str(start))
105
+ else:
106
+ parts.append(_range_to_regex(start, end))
107
+ desc = "|".join(parts)
108
+ pattern = f"(blk|BLK)\\.({desc})\\.{ttype}={tier}"
109
+
110
+ prio = get_regex_priority(pattern) + (
111
+ 10 if tier == "Q8_0" else 5 if tier == "Q6_K" else 0
112
+ ) + (5 if len(layers) == 1 else 0) + (3 if "ffn_down" in ttype else 0)
113
+
114
+ rules.append((pattern, prio))
115
+
116
+ # Generate rules for global tensors (non-blk)
117
+ prefix = model.get("features", {}).get("prefix", "blk")
118
+ for tname, tier in assignments.items():
119
+ parts = tname.split(".")
120
+ if len(parts) >= 2 and parts[0].lower() == prefix.lower():
121
+ continue
122
+ ttype = parts[0] if len(parts) >= 1 else tname
123
+ if ttype in ("token_embd", "output"):
124
+ continue
125
+ if ttype == tname and "." in tname:
126
+ # e.g. "nextn.eh_proj" without blk prefix
127
+ pass
128
+ # Check that this global tensor wasn't already handled as a blk tensor
129
+ pattern = f".*{re.escape(ttype)}.*={tier}"
130
+ prio = get_regex_priority(pattern) + (5 if tier == "Q8_0" else 0)
131
+ # Deduplicate (same pattern may appear from different names)
132
+ if not any(p == pattern for p, _ in rules):
133
+ rules.append((pattern, prio))
134
+
135
+ rules.sort(key=lambda x: -x[1])
136
+
137
+ flags = {
138
+ "imatrix": None,
139
+ "output_tensor_type": output_type,
140
+ "token_embedding_type": token_embd_type,
141
+ "tensor_type_rules": [f'--tensor-type "{r[0]}"' for r in rules],
142
+ "base_type": base_type,
143
+ "target_size_mib": target_size_mib,
144
+ }
145
+
146
+ return flags
147
+
148
+
149
+ def format_flags(flags: dict) -> str:
150
+ lines = []
151
+ lines.append(" --output-tensor-type " + flags["output_tensor_type"])
152
+ lines.append(" --token-embedding-type " + flags["token_embedding_type"])
153
+ for rule in flags["tensor_type_rules"]:
154
+ lines.append(" " + rule)
155
+ return "\n".join(lines)
constants.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ GGUF_TYPE_NAMES = {
4
+ 0: "F32",
5
+ 1: "F16",
6
+ 2: "Q4_0", 3: "Q4_1", 6: "Q5_0", 7: "Q5_1",
7
+ 8: "Q8_0",
8
+ 10: "Q4_K", 11: "Q5_K", 12: "Q6_K",
9
+ 13: "Q5_K_M", 14: "Q4_K_M",
10
+ 15: "IQ4_XS", 16: "IQ4_NL",
11
+ 20: "IQ3_XXS",
12
+ 24: "IQ2_XXS",
13
+ 30: "IQ1_S",
14
+ }
15
+
16
+ GGUF_TYPE_NAMES_INV = {v: k for k, v in GGUF_TYPE_NAMES.items()}
17
+
18
+ # Ordered from worst to best quality
19
+ TIER_ORDER = [
20
+ "IQ1_S", "IQ2_XXS", "IQ2_XS", "IQ2_S",
21
+ "IQ3_XXS", "Q3_K", "IQ3_S",
22
+ "IQ4_XS", "IQ4_NL", "Q4_K", "Q5_K", "Q6_K", "Q8_0", "F16",
23
+ ]
24
+
25
+ # Exact bits per weight from ggml block structs (ggml_type_sizef * 8)
26
+ # Does NOT include GGUF overhead — GGUF_OVERHEAD_FACTOR is applied separately
27
+ TIER_BPW = {
28
+ "IQ1_S": 1.5625,
29
+ "IQ2_XXS": 2.0625,
30
+ "IQ2_XS": 2.3125,
31
+ "IQ2_S": 2.5,
32
+ "IQ3_XXS": 3.0625,
33
+ "Q3_K": 3.4375,
34
+ "IQ3_S": 3.44,
35
+ "IQ4_XS": 4.25,
36
+ "IQ4_NL": 4.5,
37
+ "Q4_K": 4.5,
38
+ "Q5_K": 5.5,
39
+ "Q6_K": 6.5625,
40
+ "Q8_0": 8.5,
41
+ "F16": 16.0,
42
+ }
43
+ GGUF_OVERHEAD_FACTOR = 1.0
44
+
45
+ # Quality retention per tier (1.0 = F16, no loss). Used for non-linear utility.
46
+ QUALITY_WEIGHTS = {
47
+ "F16": 1.000,
48
+ "Q8_0": 0.995,
49
+ "Q6_K": 0.990,
50
+ "Q5_K": 0.978,
51
+ "IQ4_XS": 0.940,
52
+ "IQ4_NL": 0.945,
53
+ "Q4_K": 0.960,
54
+ "Q3_K": 0.900,
55
+ }
56
+
57
+ # Quant quality rank: higher = better (same order as TIER_ORDER)
58
+ QUANT_RANK = {tier: i for i, tier in enumerate(TIER_ORDER)}
59
+
60
+ # Per-class hard floor — NEVER go below this without --allow-q3-or-lower
61
+ # Matches hand-tuned v2 patterns: gate=Q6_K, attn_proj=Q6_K, ffn=IQ4_XS
62
+ CLASS_HARD_FLOORS = {
63
+ "gate": "Q8_0",
64
+ "attn_proj": "Q8_0",
65
+ "ffn_gate_up": "IQ4_XS",
66
+ "ffn_down": "Q6_K",
67
+ "norms": "F16",
68
+ "ssm_params": "F16",
69
+ "mtp": "IQ4_XS",
70
+ "embd": "Q5_K",
71
+ }
72
+
73
+ # Per-class start tier — where greedy begins (Q5_K for all, like OptA base type)
74
+ # Greedy upgrades from here toward CLASS_MAX_TIER
75
+ CLASS_START_TIER = {
76
+ "gate": "Q8_0",
77
+ "attn_proj": "Q8_0",
78
+ "ffn_gate_up": "Q5_K",
79
+ "ffn_down": "Q6_K",
80
+ "norms": "F16",
81
+ "ssm_params": "F16",
82
+ "mtp": "Q5_K",
83
+ "embd": "Q5_K",
84
+ }
85
+
86
+ # Per-class max tier — never exceed this (for greedy upgrades)
87
+ # Deep layers can be upgraded to Q8_0 for important tensors
88
+ CLASS_MAX_TIER = {
89
+ "gate": "Q8_0",
90
+ "attn_proj": "Q8_0",
91
+ "ffn_gate_up": "Q8_0",
92
+ "ffn_down": "Q8_0",
93
+ "norms": "F16",
94
+ "ssm_params": "F16",
95
+ "mtp": "Q8_0",
96
+ "embd": "Q5_K",
97
+ }
98
+
99
+ # Classes that can go to Q3_K when --allow-q3-or-lower is set
100
+ CAN_Q3 = {"ffn_gate", "ffn_up", "ffn_down", "attn_output", "ssm_out"}
101
+ ALLOW_LOWER_FLOOR = "IQ2_XXS" # lowest starting tier for --allow-q3-or-lower
102
+
103
+ # Default floor for unclassified tensors / unknown class
104
+ DEFAULT_FLOOR = "Q4_K"
105
+
106
+ # Tier for MTP head deployment
107
+ MTP_DEPLOY_TIER = "Q8_0"
108
+
109
+ # Preferred tier for extreme importance spikes (>50% of total importance)
110
+ SPIKE_IMPORTANCE_RATIO = 0.50
111
+ SPIKE_PREFERRED_TIER = "F16"
112
+
113
+ TENSOR_CLASS = {
114
+ # Qwen 3.5 hybrid
115
+ "attn_gate": "gate",
116
+ "ssm_alpha": "gate",
117
+ "ssm_beta": "gate",
118
+ "attn_q": "attn_proj",
119
+ "attn_k": "attn_proj",
120
+ "attn_v": "attn_proj",
121
+ "attn_qkv": "attn_proj",
122
+ "attn_output": "attn_proj",
123
+ "ffn_gate": "ffn_gate_up",
124
+ "ffn_up": "ffn_gate_up",
125
+ "ffn_down": "ffn_down",
126
+ "ssm_out": "ffn_down",
127
+ "ssm_conv1d": "norms",
128
+ "router": "norms",
129
+ "ssm_dt": "ssm_params",
130
+ "ssm_a": "ssm_params",
131
+ "nextn": "mtp",
132
+ "ffn_gate_exps": "ffn_gate_up",
133
+ "ffn_up_exps": "ffn_gate_up",
134
+ "ffn_down_exps": "ffn_down",
135
+ "ffn_gate_inp": "norms",
136
+
137
+ # Standard llama.cpp tensor names
138
+ "q_proj": "attn_proj",
139
+ "k_proj": "attn_proj",
140
+ "v_proj": "attn_proj",
141
+ "o_proj": "attn_proj",
142
+ "gate_proj": "ffn_gate_up",
143
+ "up_proj": "ffn_gate_up",
144
+ "down_proj": "ffn_down",
145
+ }
146
+
147
+ ARCH_FEATURES = {
148
+ "qwen35": {
149
+ "has_qkv": True,
150
+ "has_ssm": True,
151
+ "has_mtp": True,
152
+ "has_moe": False,
153
+ "is_qat": False,
154
+ "prefix": "blk",
155
+ "n_layers": 32,
156
+ },
157
+ "mellum2": {
158
+ "has_qkv": False,
159
+ "has_ssm": False,
160
+ "has_mtp": False,
161
+ "has_moe": True,
162
+ "is_qat": False,
163
+ "prefix": "blk",
164
+ "n_layers": 28,
165
+ },
166
+ "gemma4": {
167
+ "has_qkv": False,
168
+ "has_ssm": False,
169
+ "has_mtp": False,
170
+ "has_moe": False,
171
+ "is_qat": True,
172
+ "prefix": "blk",
173
+ "n_layers": 48,
174
+ },
175
+ }
176
+
177
+
178
+ def strip_weight(name: str) -> str:
179
+ return name.lstrip(".").removesuffix(".weight").removesuffix(".bias")
180
+
181
+
182
+ def get_tensor_type(name: str) -> str:
183
+ parts = strip_weight(name).split(".")
184
+ if len(parts) >= 2 and parts[0] in ("blk", "BLK"):
185
+ return parts[2] if len(parts) >= 3 else "unknown"
186
+ if "token_embd" in name:
187
+ return "token_embd"
188
+ if name.startswith("output") and "norm" not in name:
189
+ return "output"
190
+ return name
191
+
192
+
193
+ def get_tensor_class(ttype: str) -> str:
194
+ if ttype in TENSOR_CLASS:
195
+ return TENSOR_CLASS[ttype]
196
+ if "norm" in ttype or "scale" in ttype:
197
+ return "norms"
198
+ if ttype.startswith("ssm_"):
199
+ return "ssm_params"
200
+ if ttype in ("token_embd", "output", "embed_tokens", "lm_head",
201
+ "vision_embedder", "audio_embedder"):
202
+ return "embd"
203
+ return "unknown"
204
+
205
+
206
+ def is_mtp_tensor(name: str, n_layers: int = 32) -> bool:
207
+ if "nextn" in name:
208
+ return True
209
+ if n_layers > 40:
210
+ return False # Deep models (Gemma4, 48 layers) use separate drafter, not in-model MTP
211
+ layer = get_layer_number(name)
212
+ return layer is not None and layer >= n_layers
213
+
214
+
215
+ def get_layer_number(name: str) -> int | None:
216
+ parts = strip_weight(name).split(".")
217
+ if len(parts) >= 2 and parts[0] in ("blk", "BLK"):
218
+ try:
219
+ return int(parts[1])
220
+ except ValueError:
221
+ return None
222
+ return None
imatrix_reader.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gguf
2
+ import numpy as np
3
+ from typing import List
4
+
5
+
6
+ def read_imatrix(path: str) -> dict:
7
+ """Parse imatrix GGUF, return per-tensor importance data."""
8
+ r = gguf.GGUFReader(path)
9
+ raw = {}
10
+ meta = {}
11
+
12
+ for k, v in r.fields.items():
13
+ try:
14
+ meta[k] = v.data
15
+ except:
16
+ meta[k] = str(v)
17
+
18
+ for t in r.tensors:
19
+ name = t.name
20
+ arr = np.array(t.data, dtype=np.float64)
21
+
22
+ if name.endswith(".in_sum2"):
23
+ base = name[:-8]
24
+ if base not in raw:
25
+ raw[base] = {}
26
+ raw[base]["in_sum2"] = arr
27
+ elif name.endswith(".counts"):
28
+ base = name[:-7]
29
+ if base not in raw:
30
+ raw[base] = {}
31
+ raw[base]["counts"] = float(np.mean(arr))
32
+
33
+ result = {}
34
+ for base, data in raw.items():
35
+ if "in_sum2" not in data:
36
+ continue
37
+ arr = data["in_sum2"]
38
+ result[base] = {
39
+ "importance_mean": float(np.mean(arr)),
40
+ "importance_sum": float(np.sum(arr)),
41
+ "importance_max": float(np.max(arr)),
42
+ "importance_min": float(np.min(arr)),
43
+ "n_elements": arr.size,
44
+ "in_sum2_raw": arr,
45
+ }
46
+
47
+ return {
48
+ "path": path,
49
+ "tensors": result,
50
+ "n_tensors": len(result),
51
+ "meta": meta,
52
+ }
53
+
54
+
55
+ def combine_imatrix(imatrix_list: List[dict], method: str = "max") -> dict:
56
+ """Combine multiple imatrix into one by aggregating importance.
57
+
58
+ Args:
59
+ imatrix_list: List of imatrix dicts from read_imatrix()
60
+ method: "max", "mean", or "weighted_mean"
61
+ """
62
+ if not imatrix_list:
63
+ return {}
64
+ if len(imatrix_list) == 1:
65
+ return imatrix_list[0]
66
+
67
+ # Get all tensor names across all imatrix
68
+ all_names = set()
69
+ for im in imatrix_list:
70
+ all_names.update(im["tensors"].keys())
71
+
72
+ combined_tensors = {}
73
+ for name in all_names:
74
+ vals = []
75
+ in_sum2_raw = None
76
+ n_elements = 0
77
+
78
+ for im in imatrix_list:
79
+ if name in im["tensors"]:
80
+ t = im["tensors"][name]
81
+ vals.append(t["importance_mean"])
82
+ if in_sum2_raw is None and "in_sum2_raw" in t:
83
+ in_sum2_raw = t["in_sum2_raw"]
84
+ n_elements = max(n_elements, t["n_elements"])
85
+
86
+ if not vals:
87
+ continue
88
+
89
+ if method == "max":
90
+ imp_mean = max(vals)
91
+ elif method == "mean":
92
+ imp_mean = sum(vals) / len(vals)
93
+ elif method == "weighted_mean":
94
+ # Could weight by n_elements or dataset size
95
+ imp_mean = sum(vals) / len(vals)
96
+ else:
97
+ imp_mean = max(vals)
98
+
99
+ combined_tensors[name] = {
100
+ "importance_mean": imp_mean,
101
+ "importance_sum": imp_mean * n_elements,
102
+ "importance_max": max(v.get("importance_max", 0) for im in imatrix_list if name in im["tensors"] for v in [im["tensors"][name]]),
103
+ "importance_min": min(v.get("importance_min", float('inf')) for im in imatrix_list if name in im["tensors"] for v in [im["tensors"][name]]),
104
+ "n_elements": n_elements,
105
+ "in_sum2_raw": in_sum2_raw,
106
+ }
107
+
108
+ # Merge metadata
109
+ combined_meta = {}
110
+ for im in imatrix_list:
111
+ for k, v in im["meta"].items():
112
+ if k not in combined_meta:
113
+ combined_meta[k] = v
114
+
115
+ return {
116
+ "path": "+".join(im["path"] for im in imatrix_list),
117
+ "tensors": combined_tensors,
118
+ "n_tensors": len(combined_tensors),
119
+ "meta": combined_meta,
120
+ }
121
+
122
+
123
+ def detect_tied_groups(imatrix: dict, atol: float = 1e-5) -> list:
124
+ """Find tied tensor groups (identical importance arrays)."""
125
+ names = sorted(imatrix["tensors"].keys())
126
+ tied_groups = []
127
+ visited = set()
128
+
129
+ for i, n1 in enumerate(names):
130
+ if n1 in visited:
131
+ continue
132
+ group = [n1]
133
+ arr1 = imatrix["tensors"][n1].get("in_sum2_raw")
134
+ if arr1 is None:
135
+ tied_groups.append(group)
136
+ visited.add(n1)
137
+ continue
138
+ for j in range(i + 1, len(names)):
139
+ n2 = names[j]
140
+ if n2 in visited:
141
+ continue
142
+ arr2 = imatrix["tensors"][n2].get("in_sum2_raw")
143
+ if arr2 is None:
144
+ continue
145
+ if arr1.shape == arr2.shape and np.allclose(arr1, arr2, atol=atol):
146
+ group.append(n2)
147
+ visited.add(n2)
148
+ tied_groups.append(group)
149
+ visited.add(n1)
150
+
151
+ return tied_groups
152
+
153
+
154
+ def build_importance_table(imatrix: dict, model: dict) -> dict:
155
+ """Build unified importance table, merging imatrix with model tensor info."""
156
+ table = {}
157
+ for tname, info in imatrix["tensors"].items():
158
+ ttype = _imatrix_type(tname)
159
+ table[tname] = {
160
+ "importance_mean": info["importance_mean"],
161
+ "importance_sum": info["importance_sum"],
162
+ "importance_max": info["importance_max"],
163
+ "importance_min": info["importance_min"],
164
+ "n_elements": info["n_elements"],
165
+ "type": ttype,
166
+ }
167
+ # Also index by name without trailing dot (safety for old code)
168
+ for tname, info in list(table.items()):
169
+ if tname.endswith("."):
170
+ alt = tname.rstrip(".")
171
+ table[alt] = info
172
+ return table
173
+
174
+
175
+ def _imatrix_type(name: str) -> str:
176
+ parts = name.split(".")
177
+ if len(parts) >= 3 and parts[0] == "blk":
178
+ return parts[2]
179
+ return name
main.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+
8
+ from model_reader import read_model
9
+ from imatrix_reader import read_imatrix, detect_tied_groups, build_importance_table
10
+ from classifier import optimal_classify, compute_stats
11
+ from config_generator import generate_flags, format_flags
12
+ from quantizer import run_dry_run, run_quantization
13
+ from constants import CLASS_HARD_FLOORS
14
+
15
+
16
+ def _get_base_type(model: dict) -> str:
17
+ is_qat = model.get("features", {}).get("is_qat", False)
18
+ return "IQ4_XS" if is_qat else "Q5_K_M"
19
+
20
+
21
+ def main():
22
+ parser = argparse.ArgumentParser(
23
+ description="SHQ-program: imatrix-driven hybrid quantization"
24
+ )
25
+ parser.add_argument("--model", help="BF16 GGUF model path")
26
+ parser.add_argument("--imatrix", action="append", default=[],
27
+ help="Imatrix GGUF path (can be specified multiple times)")
28
+ parser.add_argument("--imatrix-method", choices=["max", "mean"], default="max",
29
+ help="How to combine multiple imatrix: max (conservative) or mean (default: max)")
30
+ parser.add_argument("--size", type=float, default=6800,
31
+ help="Target file size in MiB (default: 6800 = ~6.6 GB)")
32
+ parser.add_argument("--output", default=None, help="Output GGUF path")
33
+ parser.add_argument("--run", action="store_true", help="Execute quantization")
34
+ parser.add_argument("--show-config", action="store_true", help="Print config and exit")
35
+ parser.add_argument("--verbose", action="store_true", help="Detailed output")
36
+ parser.add_argument("--allow-q3-or-lower", action="store_true",
37
+ help="Allow Q3_K for low-importance tensors (risk of quality loss)")
38
+ parser.add_argument("--aggro", type=float, default=None,
39
+ help="[deprecated] Use --size instead")
40
+ parser.add_argument("--show-floors", action="store_true",
41
+ help="Print class hard floors and exit")
42
+
43
+ args = parser.parse_args()
44
+
45
+ if args.show_floors:
46
+ _show_floors()
47
+ return
48
+
49
+ if not args.model or not args.imatrix:
50
+ parser.print_usage()
51
+ print("main.py: error: --model and --imatrix are required")
52
+ sys.exit(1)
53
+
54
+ target_mib = args.size
55
+
56
+ print("=== SHQ-program ===")
57
+ print(f"Model: {args.model}")
58
+ if len(args.imatrix) == 1:
59
+ print(f"Imatrix: {args.imatrix[0]}")
60
+ else:
61
+ print(f"Imatrix: {len(args.imatrix)} files ({args.imatrix_method})")
62
+ for p in args.imatrix:
63
+ print(f" - {p}")
64
+ print(f"Target: {target_mib:.0f} MiB ({target_mib / 1024:.2f} GB)")
65
+ if args.allow_q3_or_lower:
66
+ print(" --allow-q3-or-lower: low-importance tensors may go to Q3_K")
67
+ print()
68
+
69
+ print("[1/4] Reading model...")
70
+ model = read_model(args.model)
71
+ print(f" Architecture: {model['architecture']}")
72
+ print(f" Tensors: {model['n_tensors']}")
73
+ print(f" Features: {json.dumps(model['features'], indent=2)}")
74
+
75
+ print("\n[2/4] Reading imatrix...")
76
+ imatrix_list = [read_imatrix(p) for p in args.imatrix]
77
+ for im in imatrix_list:
78
+ print(f" {im['path']}: {im['n_tensors']} tensors, datasets={im['meta'].get('imatrix.datasets', '?')}")
79
+
80
+ from imatrix_reader import combine_imatrix
81
+ imatrix = combine_imatrix(imatrix_list, method=args.imatrix_method)
82
+ print(f" Combined: {imatrix['n_tensors']} tensors")
83
+
84
+ print("\n[3/4] Detecting tied groups...")
85
+ tied_groups = detect_tied_groups(imatrix)
86
+ print(f" Found {len(tied_groups)} tied groups:")
87
+ for g in tied_groups:
88
+ if len(g) > 1:
89
+ print(f" TIED ({len(g)}): {g[0].replace('.weight', '')} = "
90
+ f"{g[1].replace('.weight', '')}")
91
+
92
+ imp_table = build_importance_table(imatrix, model)
93
+
94
+ print("\n[4/4] Classifying tensors (greedy imatrix-driven)...")
95
+
96
+ # Получаем и маппинг тиров, и точную карту паддингов напрямую из классификатора
97
+ assignments, padded_ne_map = optimal_classify(
98
+ imp_table, tied_groups, model,
99
+ target_size_mib=target_mib,
100
+ allow_q3=args.allow_q3_or_lower,
101
+ )
102
+
103
+ ne_map = {k: v["n_elements"] for k, v in model.get("tensors", {}).items()}
104
+ for tname, info in imp_table.items():
105
+ if tname not in ne_map:
106
+ ne_map[tname] = info["n_elements"]
107
+
108
+ # Передаем padded_ne_map для корректного вывода логов на экран
109
+ _show_tier_summary(assignments, imp_table, ne_map, padded_ne_map)
110
+
111
+ base_type = _get_base_type(model)
112
+ flags = generate_flags(assignments, model, base_type, target_mib)
113
+ flags["imatrix"] = args.imatrix
114
+
115
+ print(f"\nConfig (base={flags['base_type']}):")
116
+ print(format_flags(flags))
117
+
118
+ if args.show_config:
119
+ return
120
+
121
+ print("\n--- Dry Run ---")
122
+ dry_size = run_dry_run(flags, args.model)
123
+ _show_size_result(dry_size, target_mib)
124
+
125
+ if not args.run:
126
+ print("\nDry run only. Use --run to execute quantization.")
127
+ return
128
+
129
+ if not args.output:
130
+ base = os.path.splitext(os.path.basename(args.model))[0]
131
+ args.output = base + "-SHQ.gguf"
132
+
133
+ print(f"\n--- Running quantization: {args.output} ---")
134
+ success = run_quantization(flags, args.model, args.output)
135
+ if success:
136
+ print("Done!")
137
+ else:
138
+ print("Failed!")
139
+ sys.exit(1)
140
+
141
+
142
+ def _show_tier_summary(assignments, imp_table, ne_map, padded_ne_map=None):
143
+ stats = compute_stats(assignments, ne_map, padded_ne_map)
144
+
145
+ print("\n Tier distribution:")
146
+ for tier in sorted(stats["by_tier_count"].keys()):
147
+ count = stats["by_tier_count"][tier]
148
+ mib = stats["by_tier_mib"].get(tier, 0.0)
149
+ print(f" {tier}: {count} tensors ({mib:.1f} MiB)")
150
+ print(f" Total estimated size: {stats['total_mib']:.1f} MiB")
151
+
152
+ ranked = sorted(
153
+ [(n, v) for n, v in imp_table.items()],
154
+ key=lambda x: -x[1]["importance_mean"],
155
+ )
156
+ print("\n Top 10 by importance:")
157
+ for n, v in ranked[:10]:
158
+ tier = assignments.get(n, "base")
159
+ display = n.replace(".weight", "").replace(".bias", "")
160
+ print(f" {display[:52]:52s} imp={v['importance_mean']:10.0f} tier={tier}")
161
+
162
+
163
+ def _show_size_result(dry_size, target_mib):
164
+ if dry_size:
165
+ print(f" Estimated size: {dry_size:.0f} MiB ({dry_size / 1024:.2f} GB)")
166
+ diff = dry_size - target_mib
167
+ if diff > 0:
168
+ print(f" ⚠ Over target by {diff:.0f} MiB")
169
+ else:
170
+ print(f" ✓ Under target by {-diff:.0f} MiB")
171
+ else:
172
+ print(" ⚠ Could not parse size from dry-run output")
173
+
174
+
175
+ def _show_floors():
176
+ print(" Class hard floors (never below without --allow-q3-or-lower):\n")
177
+ max_n = max(len(c) for c in CLASS_HARD_FLOORS)
178
+ for cls, floor in sorted(CLASS_HARD_FLOORS.items()):
179
+ print(f" {cls:<{max_n}} → {floor}")
180
+ print(f"\n Default floor (unknown class): Q4_K")
181
+ print(f" --allow-q3-or-lower enables Q3_K for: ffn_down, attn_output, ssm_out")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
model_reader.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gguf
2
+ import numpy as np
3
+ from constants import ARCH_FEATURES
4
+
5
+
6
+ def detect_architecture(tensors: dict) -> str:
7
+ """Detect model architecture from tensor names."""
8
+ names = list(tensors.keys())
9
+
10
+ has_ssm = any("ssm_" in n for n in names)
11
+ has_qkv = any("attn_qkv" in n for n in names)
12
+ has_nextn = any("nextn" in n for n in names)
13
+ has_moe = any("exps" in n for n in names)
14
+ has_separate_qkv = any("attn_q.weight" in n for n in names)
15
+ has_gemma_specific = any(
16
+ t in n for t in ("layer_output_scale", "post_attention_norm", "post_ffw_norm")
17
+ for n in names
18
+ )
19
+
20
+ if has_ssm and has_qkv:
21
+ return "qwen35"
22
+ if has_moe:
23
+ return "mellum2"
24
+ # Исправление: заменяем неопределённую has_blk_attn на уже существующий признак
25
+ if has_separate_qkv or has_gemma_specific:
26
+ return "gemma4"
27
+ return "unknown"
28
+
29
+
30
+ def _detect_prefix(tensors: dict) -> str:
31
+ for name in tensors:
32
+ if name.startswith("BLK."):
33
+ return "BLK"
34
+ return "blk"
35
+
36
+
37
+ def _estimate_layers(tensors: dict) -> int:
38
+ max_layer = 0
39
+ for name in tensors:
40
+ parts = name.split(".")
41
+ if len(parts) >= 2 and parts[0] in ("blk", "BLK"):
42
+ try:
43
+ layer = int(parts[1])
44
+ if layer > max_layer:
45
+ max_layer = layer
46
+ except ValueError:
47
+ pass
48
+ return max_layer + 1 # layers are 0-indexed
49
+
50
+
51
+ def read_model(path: str) -> dict:
52
+ """Parse BF16 GGUF, return model info."""
53
+ r = gguf.GGUFReader(path)
54
+ tensors = {}
55
+ meta = {}
56
+
57
+ for k, v in r.fields.items():
58
+ # Безопасное извлечение данных (список/число, а не raw numpy)
59
+ try:
60
+ data = v.data
61
+ if isinstance(data, np.ndarray):
62
+ data = data.tolist()
63
+ elif isinstance(data, (np.generic,)):
64
+ data = data.item()
65
+ meta[k] = data
66
+ except Exception:
67
+ meta[k] = str(v)
68
+
69
+ for t in r.tensors:
70
+ shape = list(t.shape)
71
+ name = t.name
72
+ n_elements = int(np.prod(shape))
73
+ tensors[name] = {
74
+ "shape": shape,
75
+ "n_elements": n_elements,
76
+ "size_mib": n_elements * 2 / 1024 / 1024,
77
+ }
78
+
79
+ arch = detect_architecture(tensors)
80
+ arch_features = ARCH_FEATURES.get(arch, {}).copy()
81
+
82
+ prefix = _detect_prefix(tensors)
83
+ n_layers = _estimate_layers(tensors)
84
+
85
+ if arch == "mellum2" and arch_features.get("moe_intermediate_size", 0) == 0:
86
+ arch_features["moe_intermediate_size"] = 896
87
+
88
+ arch_features["prefix"] = prefix
89
+ if n_layers > 0:
90
+ arch_features["n_layers"] = n_layers
91
+
92
+ has_moe = any("exps" in n for n in tensors)
93
+ if has_moe:
94
+ arch_features["has_moe"] = True
95
+
96
+ has_nextn = any("nextn" in n for n in tensors)
97
+ # blk.32 is MTP only in ~32-layer models (Qwen). Skip for deeper models (Gemma4, 48 layers).
98
+ has_blk32 = n_layers <= 33 and any(
99
+ n.startswith("blk.32.") or n.startswith("BLK.32.") for n in tensors
100
+ )
101
+ arch_features["has_mtp"] = has_nextn or has_blk32
102
+
103
+ return {
104
+ "path": path,
105
+ "architecture": arch,
106
+ "features": arch_features,
107
+ "tensors": tensors,
108
+ "n_tensors": len(tensors),
109
+ "meta": meta,
110
+ }
quantizer.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import subprocess
2
+ import re
3
+ import os
4
+ from utils import parse_size_line, parse_quant_size
5
+
6
+
7
+ QUANTIZER_PATH = "/home/maxyag27/llm-tools/llama.cpp/build/bin/llama-quantize"
8
+
9
+
10
+ def _build_cmd(flags: dict, model_in: str, model_out: str, dry_run: bool = False) -> list:
11
+ """Build llama-quantize command. Options before positional args."""
12
+ cmd = [QUANTIZER_PATH]
13
+
14
+ if dry_run:
15
+ cmd.append("--dry-run")
16
+
17
+ if flags.get("imatrix"):
18
+ imatrix = flags["imatrix"]
19
+ if isinstance(imatrix, list):
20
+ imatrix = imatrix[0] # llama-quantize accepts one imatrix; we combined in importance table
21
+ cmd.extend(["--imatrix", imatrix])
22
+ cmd.extend(["--output-tensor-type", flags["output_tensor_type"]])
23
+ cmd.extend(["--token-embedding-type", flags["token_embedding_type"]])
24
+
25
+ for rule in flags["tensor_type_rules"]:
26
+ parts = rule.split(" ", 1)
27
+ if len(parts) == 2:
28
+ cmd.extend(["--tensor-type", parts[1].strip('"')])
29
+
30
+ # Positional args: model_in model_out type
31
+ cmd.append(model_in)
32
+ cmd.append(model_out)
33
+ cmd.append(flags["base_type"])
34
+
35
+ return cmd
36
+
37
+
38
+ def run_dry_run(flags: dict, model_in: str) -> float | None:
39
+ """Run llama-quantize --dry-run and return quant size in MiB."""
40
+ cmd = _build_cmd(flags, model_in, "/dev/null", dry_run=True)
41
+
42
+ result = subprocess.run(
43
+ cmd,
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=300,
47
+ )
48
+
49
+ output = (result.stdout or "") + (result.stderr or "")
50
+ size = parse_quant_size(output)
51
+ if size is not None:
52
+ return size
53
+
54
+ print("STDERR:", result.stderr[:2000])
55
+ return None
56
+
57
+
58
+ def run_quantization(flags: dict, model_in: str, model_out: str, dry_run: bool = False) -> bool:
59
+ """Run actual quantization or dry run."""
60
+ cmd = _build_cmd(flags, model_in, model_out, dry_run=dry_run)
61
+
62
+ print("Running:", " ".join(cmd[:6]) + " ...")
63
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
64
+
65
+ if dry_run:
66
+ output = (result.stdout or "") + (result.stderr or "")
67
+ return parse_quant_size(output)
68
+
69
+ print(result.stderr[:500])
70
+ success = result.returncode == 0
71
+ if success:
72
+ import os
73
+ size_mib = os.path.getsize(model_out) / 1024 / 1024
74
+ print(f"Done: {model_out} ({size_mib:.0f} MiB)")
75
+ return success
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gguf>=0.10.0
2
+ numpy>=1.24.0
utils.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from constants import GGUF_TYPE_NAMES, GGUF_TYPE_NAMES_INV
3
+
4
+
5
+ def type_name(type_id: int) -> str:
6
+ return GGUF_TYPE_NAMES.get(type_id, f"UNKNOWN({type_id})")
7
+
8
+
9
+ def type_id(name: str) -> int:
10
+ return GGUF_TYPE_NAMES_INV.get(name, -1)
11
+
12
+
13
+ def parse_size_line(line: str) -> float | None:
14
+ import re
15
+ m = re.search(r"quant size\s*=\s*([0-9.]+)\s*MiB", line)
16
+ if m:
17
+ return float(m.group(1))
18
+ m = re.search(r"model size\s*=\s*([0-9.]+)\s*MiB", line)
19
+ if m:
20
+ return float(m.group(1))
21
+ return None
22
+
23
+
24
+ def parse_quant_size(output: str) -> float | None:
25
+ """Extract quant size from full output (prefers quant over model size)."""
26
+ import re
27
+ # Try quant size first across all lines
28
+ m = re.search(r"quant size\s*=\s*([0-9.]+)\s*MiB", output)
29
+ if m:
30
+ return float(m.group(1))
31
+ # Fall back to model size
32
+ m = re.search(r"model size\s*=\s*([0-9.]+)\s*MiB", output)
33
+ if m:
34
+ return float(m.group(1))
35
+ return None
36
+
37
+
38
+ def parse_fallback_warnings(output: str) -> int:
39
+ import re
40
+ return len(re.findall(r"converting to\s+(q[0-9]_[0-9KMS]|iq[0-9])", output))
41
+
42
+
43
+ def format_size(mib: float) -> str:
44
+ if mib >= 1024:
45
+ return f"{mib/1024:.2f} GB"
46
+ return f"{mib:.0f} MiB"
47
+
48
+
49
+ def get_tensor_type(tensor_name: str) -> str:
50
+ parts = tensor_name.split(".")
51
+ if len(parts) >= 2 and parts[0] == "blk":
52
+ return parts[2] if len(parts) >= 3 else "unknown"
53
+ return "global"