rwkv-quant 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rwkv_quant/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .api import quantize, calibrate
2
+ from .calibration import QuantConfig, GROUPS
3
+
4
+ __all__ = ["quantize", "calibrate", "QuantConfig", "GROUPS"]
rwkv_quant/api.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ Высокоуровневый API. Два входа:
3
+ - quantize(ckpt, out, preset=...) -- быстрый старт, готовые пресеты
4
+ - quantize(ckpt, out, config=...) -- полный контроль через QuantConfig
5
+ - calibrate(ckpt, corpus) -- автоматически подобрать QuantConfig
6
+ под конкретный чекпоинт вместо
7
+ пресета "с потолка" (см. README:
8
+ чувствительность к квантованию НЕ
9
+ переносится между масштабами модели)
10
+ """
11
+ import torch
12
+
13
+ from .presets import PRESETS
14
+ from .calibration import GROUPS, QuantConfig
15
+ from .calibration.ablation import single_group_ablation, perplexity, combined_sanity_check
16
+ from .models.rwkv7_ref import RWKV7Ref
17
+ from .formats import save
18
+
19
+
20
+ def quantize(checkpoint_path: str, output_path: str, preset: str = "reduction",
21
+ config: QuantConfig = None):
22
+ """
23
+ Quick-start: quantize(ckpt, out, preset="compression")
24
+ Advanced: quantize(ckpt, out, config=QuantConfig(proj=4, ...))
25
+
26
+ preset игнорируется, если передан config.
27
+ """
28
+ if config is None:
29
+ if preset not in PRESETS:
30
+ raise ValueError(f"unknown preset {preset!r}, choose from {list(PRESETS)}")
31
+ config = PRESETS[preset]
32
+
33
+ # метаданные (naming/n_layer/...) дешевле всего взять из уже написанного
34
+ # загрузчика RWKV7Ref, вместо повторной ручной детекции формата
35
+ model = RWKV7Ref(checkpoint_path, device="cpu", dtype=torch.bfloat16)
36
+ naming, n_layer, n_embd = model.naming, model.n_layer, model.n_embd
37
+ head_size, vocab_size = model.head_size, model.vocab_size
38
+ del model
39
+
40
+ if checkpoint_path.endswith(".pth"):
41
+ sd = torch.load(checkpoint_path, map_location="cpu")
42
+ else:
43
+ from safetensors.torch import load_file
44
+ sd = load_file(f"{checkpoint_path}/model.safetensors")
45
+
46
+ return save(sd, config, output_path, naming, n_layer, n_embd, head_size, vocab_size)
47
+
48
+
49
+ def calibrate(checkpoint_path: str, eval_corpus_path: str, device: str = "mps",
50
+ ppl_threshold_pct: float = 5.0, spqr_outlier_frac: float = 0.02,
51
+ small_clip_percentile: float = 99.9, verbose: bool = True) -> QuantConfig:
52
+ """
53
+ Прогоняет single-group ablation на конкретном чекпоинте и подбирает
54
+ QuantConfig вместо использования пресета "с потолка".
55
+
56
+ Эвристика (по находкам из README, не универсальный поиск):
57
+ 1. Для каждой группы -- минимальная битность из {8,6,4,2} с Δppl% <=
58
+ ppl_threshold_pct без какой-либо доп. обработки.
59
+ 2. Если группа из {proj, cmix, emb_head} не прошла порог даже на INT8 --
60
+ пробуем INT4 + SpQR sparse outlier (percentile-clipping для них НЕ
61
+ пробуем: он делает эти группы хуже, см. README).
62
+ 3. Если группа "small" не прошла порог -- пробуем INT6 + percentile
63
+ clip (SpQR для неё не нужен, clip уже полностью решает проблему).
64
+ """
65
+ model = RWKV7Ref(checkpoint_path, device=device, dtype=torch.bfloat16)
66
+ data = torch.load(eval_corpus_path).to(device)
67
+
68
+ baseline_ppl, results = single_group_ablation(
69
+ model, data, groups=GROUPS, bits_grid=(8, 6, 4, 2), verbose=verbose)
70
+
71
+ best_bits = {}
72
+ for group in GROUPS:
73
+ group_results = [r for r in results if r[0] == group and r[3] <= ppl_threshold_pct]
74
+ best_bits[group] = min((r[1] for r in group_results), default=16)
75
+
76
+ outlier_fracs, clip_percentiles = {}, {}
77
+
78
+ for group in ("proj", "cmix", "emb_head"):
79
+ if best_bits[group] > 4:
80
+ cfg_try = QuantConfig(**{group: 4}, outlier_fracs={group: spqr_outlier_frac})
81
+ ppl_try = perplexity(model, data, cfg_try)
82
+ delta = 100 * (ppl_try - baseline_ppl) / baseline_ppl
83
+ if verbose:
84
+ print(f" retry {group} @ INT4+SpQR({spqr_outlier_frac*100:.0f}%): Δ={delta:+.2f}%")
85
+ if delta <= ppl_threshold_pct * 3: # мягче: это уже "сжатие ценой качества"
86
+ best_bits[group] = 4
87
+ outlier_fracs[group] = spqr_outlier_frac
88
+
89
+ if best_bits["small"] > 6:
90
+ cfg_try = QuantConfig(small=6, clip_percentiles={"small": small_clip_percentile})
91
+ ppl_try = perplexity(model, data, cfg_try)
92
+ delta = 100 * (ppl_try - baseline_ppl) / baseline_ppl
93
+ if verbose:
94
+ print(f" retry small @ INT6+clip(p{small_clip_percentile}): Δ={delta:+.2f}%")
95
+ if delta <= ppl_threshold_pct:
96
+ best_bits["small"] = 6
97
+ clip_percentiles["small"] = small_clip_percentile
98
+
99
+ # single-group ablation выше оценивает каждую группу НЕЗАВИСИМО и не
100
+ # может поймать эффекты взаимодействия при одновременном квантовании
101
+ # (см. presets.py: все 4 LoRA-ветки на INT4 порознь безобидны, вместе --
102
+ # ~150x взрыв ppl). Обязательная проверка целиком перед выдачей конфига.
103
+ best_bits, outlier_fracs, final_ppl, final_delta = combined_sanity_check(
104
+ model, data, best_bits, outlier_fracs, clip_percentiles,
105
+ baseline_ppl=baseline_ppl, ppl_threshold_pct=ppl_threshold_pct, verbose=verbose)
106
+
107
+ config = QuantConfig(clip_percentiles=clip_percentiles, outlier_fracs=outlier_fracs, **best_bits)
108
+ if verbose:
109
+ print(f"\nCalibrated config: {config}\noutlier_fracs={outlier_fracs} clip={clip_percentiles}")
110
+ print(f"Финальный Δppl(целиком) = {final_delta:+.2f}%")
111
+ return config
File without changes
File without changes
File without changes
@@ -0,0 +1,106 @@
1
+ """
2
+ backends/metal/quant_linear.py — INT8 per-row scale + SpQR sparse-outlier
3
+ Linear на Metal (MLX custom kernel), без промежуточного bf16-разворачивания.
4
+
5
+ Заменяет formats.reader.load_dequantized() (весь тензор int8 -> bf16 в
6
+ памяти) на прямой матмул по codes/scale + точечную поправку по
7
+ разреженным outlier'ам. codes уже нулевые в outlier-позициях (см.
8
+ writer._real_quantize_sparse_outlier), поэтому outlier-коррекция — это
9
+ чистое дополнение, а не overwrite.
10
+ """
11
+ import numpy as np
12
+ import mlx.core as mx
13
+
14
+ from ...formats.schema import int8_codes
15
+
16
+
17
+ def _build_outlier_csr(outlier_indices, outlier_values, n_rows):
18
+ """torch [n_outliers,2] (row,col) + [n_outliers] значения ->
19
+ row_offsets[n_rows+1], cols[n_outliers], vals[n_outliers] — отсортировано
20
+ по row, для CSR-обхода внутри kernel'я. Выполняется один раз при загрузке."""
21
+ if outlier_indices is None or outlier_indices.numel() == 0:
22
+ return (mx.array(np.zeros(n_rows + 1, dtype=np.int32)),
23
+ mx.array(np.zeros(1, dtype=np.int32)),
24
+ mx.array(np.zeros(1, dtype=np.float32)))
25
+
26
+ rows = outlier_indices[:, 0].numpy()
27
+ cols = outlier_indices[:, 1].numpy()
28
+ vals = outlier_values.float().numpy()
29
+
30
+ order = np.argsort(rows, kind="stable")
31
+ rows, cols, vals = rows[order], cols[order], vals[order]
32
+
33
+ row_offsets = np.zeros(n_rows + 1, dtype=np.int32)
34
+ row_offsets[1:] = np.cumsum(np.bincount(rows, minlength=n_rows))
35
+
36
+ return (mx.array(row_offsets), mx.array(cols.astype(np.int32)), mx.array(vals.astype(np.float32)))
37
+
38
+
39
+ _kernel_cache = {}
40
+
41
+
42
+ def _get_kernel(IN: int, OUT: int, has_outliers: bool):
43
+ key = (IN, OUT, has_outliers)
44
+ if key in _kernel_cache:
45
+ return _kernel_cache[key]
46
+
47
+ hdr = f"""
48
+ constant uint IN_C = {IN};
49
+ constant uint OUT_C = {OUT};
50
+ """
51
+ outlier_body = """
52
+ uint start = row_offsets[oi];
53
+ uint end = row_offsets[oi+1];
54
+ for (uint idx = start; idx < end; idx++) {
55
+ uint c = (uint)outlier_cols[idx];
56
+ acc += x[n*IN_C + c] * outlier_vals[idx];
57
+ }
58
+ """ if has_outliers else ""
59
+
60
+ body = """
61
+ uint oi = thread_position_in_grid.x; // output feature
62
+ uint n = thread_position_in_grid.y; // batch row
63
+
64
+ float acc = 0.0f;
65
+ for (uint c = 0; c < IN_C; c++)
66
+ acc += x[n*IN_C + c] * (float)codes[oi*IN_C + c];
67
+ acc *= (float)scale[oi];
68
+ """ + outlier_body + """
69
+ out[n*OUT_C + oi] = acc;
70
+ """
71
+ kern = mx.fast.metal_kernel(
72
+ name=f"quant_linear_{'spqr' if has_outliers else 'plain'}_{IN}_{OUT}",
73
+ input_names=["x", "codes", "scale", "row_offsets", "outlier_cols", "outlier_vals"],
74
+ output_names=["out"],
75
+ header=hdr, source=body,
76
+ )
77
+ _kernel_cache[key] = kern
78
+ return kern
79
+
80
+
81
+ class QuantLinear:
82
+ """Один Linear на int8 codes + per-row scale (+ опц. SpQR), веса живут
83
+ на GPU в упакованном виде — никогда не разворачиваются в полный bf16."""
84
+
85
+ def __init__(self, qt):
86
+ # qt: rwkv_quant.formats.schema.QuantizedTensor, bits < 16
87
+ assert qt.bits < 16, "QuantLinear только для квантованных тензоров"
88
+ self.out_features, self.in_features = qt.shape
89
+ self.codes = mx.array(int8_codes(qt).numpy()) # int8 [out, in] (packed распаковывается: v1 -- референс)
90
+ self.scale = mx.array(qt.scale.float().numpy().reshape(-1)) # fp32 [out]
91
+ self.row_offsets, self.outlier_cols, self.outlier_vals = _build_outlier_csr(
92
+ qt.outlier_indices, qt.outlier_values, self.out_features)
93
+ self.has_outliers = qt.outlier_indices is not None and qt.outlier_indices.numel() > 0
94
+
95
+ def __call__(self, x):
96
+ lead_shape = x.shape[:-1]
97
+ x2d = x.reshape(-1, self.in_features).astype(mx.float32)
98
+ N = x2d.shape[0]
99
+ kern = _get_kernel(self.in_features, self.out_features, self.has_outliers)
100
+ out = kern(
101
+ inputs=[x2d, self.codes, self.scale, self.row_offsets, self.outlier_cols, self.outlier_vals],
102
+ grid=(self.out_features, N, 1), threadgroup=(1, 1, 1),
103
+ output_shapes=[(N, self.out_features)],
104
+ output_dtypes=[mx.float32],
105
+ )[0]
106
+ return out.reshape(*lead_shape, self.out_features)