quantgap 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.
- quantgap/__init__.py +3 -0
- quantgap/backends/__init__.py +60 -0
- quantgap/backends/base.py +118 -0
- quantgap/backends/hf.py +77 -0
- quantgap/backends/llamacpp.py +99 -0
- quantgap/backends/mlx.py +86 -0
- quantgap/backends/openai_compat.py +164 -0
- quantgap/cli.py +521 -0
- quantgap/config.py +60 -0
- quantgap/hub/__init__.py +1 -0
- quantgap/hub/packs.py +142 -0
- quantgap/hub/resolve.py +247 -0
- quantgap/hub/submit.py +87 -0
- quantgap/models.py +202 -0
- quantgap/pack/__init__.py +4 -0
- quantgap/pack/reader.py +142 -0
- quantgap/pack/runner.py +301 -0
- quantgap/pack/writer.py +162 -0
- quantgap/probes/__init__.py +11 -0
- quantgap/probes/build.py +484 -0
- quantgap/probes/data/SOURCES.md +15 -0
- quantgap/probes/data/probes_v1.jsonl +160 -0
- quantgap/probes/registry.py +34 -0
- quantgap/probes/render.py +158 -0
- quantgap/report/__init__.py +0 -0
- quantgap/report/chart.py +92 -0
- quantgap/report/jsonout.py +25 -0
- quantgap/report/table.py +82 -0
- quantgap/score/__init__.py +4 -0
- quantgap/score/compare.py +33 -0
- quantgap/score/damage.py +103 -0
- quantgap/score/kld.py +73 -0
- quantgap/score/recommend.py +78 -0
- quantgap/score/run.py +260 -0
- quantgap/score/stats.py +33 -0
- quantgap/tokcheck.py +71 -0
- quantgap/util.py +95 -0
- quantgap-0.1.0.dist-info/METADATA +311 -0
- quantgap-0.1.0.dist-info/RECORD +42 -0
- quantgap-0.1.0.dist-info/WHEEL +4 -0
- quantgap-0.1.0.dist-info/entry_points.txt +2 -0
- quantgap-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Pick the best quant that fits a memory budget (section 7.5 of the spec)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from quantgap.models import ScoreResult
|
|
8
|
+
from quantgap.util import GIB, human_size
|
|
9
|
+
|
|
10
|
+
OVERHEAD_FACTOR = 1.1
|
|
11
|
+
OVERHEAD_BYTES = GIB
|
|
12
|
+
ASSUMPTION = (
|
|
13
|
+
"Memory estimate = file size x 1.1 + 1 GiB (context buffers at 2K context). "
|
|
14
|
+
"Real use depends on your engine and context length."
|
|
15
|
+
)
|
|
16
|
+
DAMAGE_WARN_GAP = 3 # prompts worse than the reference
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimated_memory(size_bytes: int) -> float:
|
|
20
|
+
return size_bytes * OVERHEAD_FACTOR + OVERHEAD_BYTES
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _overlap(a: ScoreResult, b: ScoreResult) -> bool:
|
|
24
|
+
lo1, hi1 = a.metrics.kld_mean_ci95
|
|
25
|
+
lo2, hi2 = b.metrics.kld_mean_ci95
|
|
26
|
+
return lo1 <= hi2 and lo2 <= hi1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Recommendation:
|
|
31
|
+
sentence: str
|
|
32
|
+
chosen: ScoreResult | None
|
|
33
|
+
warnings: list[str] = field(default_factory=list)
|
|
34
|
+
not_clearly_different: list[ScoreResult] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _name(r: ScoreResult) -> str:
|
|
38
|
+
return r.quant.label
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def recommend(results: list[ScoreResult], budget_bytes: int) -> Recommendation:
|
|
42
|
+
budget_txt = human_size(budget_bytes)
|
|
43
|
+
fits = [r for r in results if estimated_memory(r.quant.size_bytes) <= budget_bytes]
|
|
44
|
+
if not fits:
|
|
45
|
+
smallest = min(results, key=lambda r: r.quant.size_bytes)
|
|
46
|
+
need = estimated_memory(smallest.quant.size_bytes)
|
|
47
|
+
return Recommendation(
|
|
48
|
+
f"Nothing fits in {budget_txt}. The smallest, {_name(smallest)}, needs about "
|
|
49
|
+
f"{human_size(need)}.",
|
|
50
|
+
None,
|
|
51
|
+
)
|
|
52
|
+
best = min(fits, key=lambda r: r.metrics.kld_mean)
|
|
53
|
+
ties = [r for r in fits if r is not best and _overlap(r, best)]
|
|
54
|
+
pool = [best, *ties]
|
|
55
|
+
chosen = min(pool, key=lambda r: (r.quant.size_bytes, r.metrics.kld_mean))
|
|
56
|
+
size = human_size(chosen.quant.size_bytes)
|
|
57
|
+
sentence = f"For {budget_txt}, use {_name(chosen)} ({size})."
|
|
58
|
+
if chosen is not best:
|
|
59
|
+
saved = best.quant.size_bytes - chosen.quant.size_bytes
|
|
60
|
+
sentence += (
|
|
61
|
+
f" It is not clearly different from {_name(best)}, which has the lowest KLD, "
|
|
62
|
+
f"and is {human_size(saved)} smaller."
|
|
63
|
+
)
|
|
64
|
+
elif ties:
|
|
65
|
+
names = ", ".join(_name(t) for t in ties)
|
|
66
|
+
sentence += f" {names} are not clearly different but are larger."
|
|
67
|
+
warnings: list[str] = []
|
|
68
|
+
d = chosen.metrics.damage
|
|
69
|
+
if d is not None:
|
|
70
|
+
if d.loops - d.loops_ref >= DAMAGE_WARN_GAP:
|
|
71
|
+
warnings.append(
|
|
72
|
+
f"Warning: {_name(chosen)} loops in {d.loops} prompts, the original in {d.loops_ref}."
|
|
73
|
+
)
|
|
74
|
+
if d.wrong_script - d.wrong_script_ref >= DAMAGE_WARN_GAP:
|
|
75
|
+
warnings.append(
|
|
76
|
+
f"Warning: {_name(chosen)} writes the wrong script in {d.wrong_script} prompts."
|
|
77
|
+
)
|
|
78
|
+
return Recommendation(sentence, chosen, warnings, ties)
|
quantgap/score/run.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Score one quant against one pack. Backend-agnostic: everything backend-specific is behind the
|
|
2
|
+
`Backend` interface."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from quantgap import __version__
|
|
13
|
+
from quantgap.backends.base import Backend, BackendSettings, ResolvedSource
|
|
14
|
+
from quantgap.models import (
|
|
15
|
+
BackendInfo,
|
|
16
|
+
DamageMetrics,
|
|
17
|
+
GroupMetrics,
|
|
18
|
+
Metrics,
|
|
19
|
+
QuantInfo,
|
|
20
|
+
ScoreResult,
|
|
21
|
+
)
|
|
22
|
+
from quantgap.pack.reader import GreedyRow, Pack
|
|
23
|
+
from quantgap.score import damage as dmg
|
|
24
|
+
from quantgap.score.kld import kld_topk, ppl_ratio
|
|
25
|
+
from quantgap.score.stats import bootstrap_mean_ci, percentile
|
|
26
|
+
from quantgap.tokcheck import TokenizerMismatch, check_tokenizer
|
|
27
|
+
from quantgap.util import hardware_string, jsonable_float, utc_now
|
|
28
|
+
|
|
29
|
+
QUICK_FRACTION = 0.25
|
|
30
|
+
QUICK_GREEDY_PER_GROUP = 2
|
|
31
|
+
NONDETERMINISM_TOL = 1e-3
|
|
32
|
+
|
|
33
|
+
Progress = Callable[[str, int, int], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScoreError(RuntimeError):
|
|
37
|
+
"""A clear, user-facing reason scoring cannot run."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class _SeqScore:
|
|
42
|
+
group: str
|
|
43
|
+
kl: np.ndarray
|
|
44
|
+
agree: np.ndarray
|
|
45
|
+
lq_target: np.ndarray
|
|
46
|
+
lp_target: np.ndarray
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def select_sequences(pack: Pack, groups: list[str] | None, quick: bool) -> list[int]:
|
|
50
|
+
"""Sequence ids to score. --groups filters; --quick keeps the first 25% of each group."""
|
|
51
|
+
rows = pack.probes()
|
|
52
|
+
by_group: dict[str, list[int]] = {}
|
|
53
|
+
for r in rows:
|
|
54
|
+
by_group.setdefault(r.group, []).append(r.seq_id)
|
|
55
|
+
chosen: list[int] = []
|
|
56
|
+
for g, ids in by_group.items():
|
|
57
|
+
if groups and g not in groups:
|
|
58
|
+
continue
|
|
59
|
+
ids = sorted(ids)
|
|
60
|
+
if quick:
|
|
61
|
+
ids = ids[: max(1, math.ceil(len(ids) * QUICK_FRACTION))]
|
|
62
|
+
chosen += ids
|
|
63
|
+
if not chosen:
|
|
64
|
+
raise ScoreError(
|
|
65
|
+
f"No sequences match --groups {groups}. Groups in this pack: {sorted(by_group)}."
|
|
66
|
+
)
|
|
67
|
+
return chosen
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_context(pack: Pack, settings: BackendSettings, seq_ids: list[int]) -> None:
|
|
71
|
+
longest = max(len(r.token_ids) for r in pack.probes() if r.seq_id in set(seq_ids))
|
|
72
|
+
if settings.ctx < longest + 8:
|
|
73
|
+
raise ScoreError(
|
|
74
|
+
f"--ctx {settings.ctx} is too small. The longest probe has {longest} tokens, "
|
|
75
|
+
f"so use --ctx {longest + 8} or more (we suggest 2048)."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_damage(
|
|
80
|
+
backend: Backend, pack: Pack, quick: bool, progress: Progress | None
|
|
81
|
+
) -> DamageMetrics | None:
|
|
82
|
+
rows: list[GreedyRow] = pack.greedy()
|
|
83
|
+
if quick:
|
|
84
|
+
seen: dict[str, int] = {}
|
|
85
|
+
keep = []
|
|
86
|
+
for r in rows:
|
|
87
|
+
seen[r.group] = seen.get(r.group, 0) + 1
|
|
88
|
+
if seen[r.group] <= QUICK_GREEDY_PER_GROUP:
|
|
89
|
+
keep.append(r)
|
|
90
|
+
rows = keep
|
|
91
|
+
max_new = pack.manifest.greedy.max_new_tokens
|
|
92
|
+
loops = loops_ref = ws = ws_ref = jv = jv_ref = jn = 0
|
|
93
|
+
divs: list[int] = []
|
|
94
|
+
try:
|
|
95
|
+
for i, r in enumerate(rows):
|
|
96
|
+
if progress:
|
|
97
|
+
progress("generating", i, len(rows))
|
|
98
|
+
q_ids = backend.generate_greedy(list(r.prompt_ids), max_new)
|
|
99
|
+
q_text = backend.decode(q_ids)
|
|
100
|
+
divs.append(dmg.first_divergence(q_ids, r.ref_ids))
|
|
101
|
+
loops += dmg.is_loop(q_ids)
|
|
102
|
+
loops_ref += dmg.is_loop(r.ref_ids)
|
|
103
|
+
if r.group in dmg.SCRIPT_CHECK_GROUPS:
|
|
104
|
+
ws += dmg.has_wrong_script(q_text, r.ref_text)
|
|
105
|
+
# The reference is compared with itself, so it flags nothing by definition.
|
|
106
|
+
if r.group == "json":
|
|
107
|
+
jn += 1
|
|
108
|
+
jv += dmg.json_valid(q_text)
|
|
109
|
+
jv_ref += dmg.json_valid(r.ref_text)
|
|
110
|
+
except Exception as e: # a backend that cannot generate should not lose the KLD result
|
|
111
|
+
raise ScoreError(f"Visible-damage checks failed: {e}. Use --no-damage to skip them.") from e
|
|
112
|
+
n = max(1, len(rows))
|
|
113
|
+
return DamageMetrics(
|
|
114
|
+
n_prompts=len(rows),
|
|
115
|
+
loops=int(loops),
|
|
116
|
+
loops_ref=int(loops_ref),
|
|
117
|
+
loop_rate=loops / n,
|
|
118
|
+
wrong_script=int(ws),
|
|
119
|
+
wrong_script_ref=int(ws_ref),
|
|
120
|
+
wrong_script_rate=ws / n,
|
|
121
|
+
json_valid=int(jv),
|
|
122
|
+
json_valid_ref=int(jv_ref),
|
|
123
|
+
json_n=jn,
|
|
124
|
+
json_valid_rate=(jv / jn) if jn else 0.0,
|
|
125
|
+
first_divergence_median=dmg.median_or_zero(divs),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def score_quant(
|
|
130
|
+
pack: Pack,
|
|
131
|
+
backend: Backend,
|
|
132
|
+
source: ResolvedSource,
|
|
133
|
+
settings: BackendSettings,
|
|
134
|
+
*,
|
|
135
|
+
groups: list[str] | None = None,
|
|
136
|
+
quick: bool = False,
|
|
137
|
+
do_damage: bool = True,
|
|
138
|
+
warnings: list[str] | None = None,
|
|
139
|
+
progress: Progress | None = None,
|
|
140
|
+
) -> ScoreResult:
|
|
141
|
+
warnings = list(warnings or [])
|
|
142
|
+
manifest = pack.manifest
|
|
143
|
+
topk = pack.topk()
|
|
144
|
+
probes = {r.seq_id: r for r in pack.probes()}
|
|
145
|
+
seq_ids = select_sequences(pack, groups, quick)
|
|
146
|
+
check_context(pack, settings, seq_ids)
|
|
147
|
+
|
|
148
|
+
# Tokenizer check (never silently fixed).
|
|
149
|
+
fp = backend.tokenizer_fingerprint()
|
|
150
|
+
max_id = max(int(t.ids.max()) for t in topk.values() if len(t.ids))
|
|
151
|
+
try:
|
|
152
|
+
check_tokenizer(manifest.tokenizer, fp, max_id)
|
|
153
|
+
except TokenizerMismatch as e:
|
|
154
|
+
raise ScoreError(str(e)) from e
|
|
155
|
+
|
|
156
|
+
# E6: run the first sequence twice and see if the numbers move.
|
|
157
|
+
nondeterministic = False
|
|
158
|
+
first = next(s for s in seq_ids if len(topk[s].pos))
|
|
159
|
+
a = backend.prompt_logprobs(probes[first].token_ids.tolist(), topk[first].pos, topk[first].ids)
|
|
160
|
+
b = backend.prompt_logprobs(probes[first].token_ids.tolist(), topk[first].pos, topk[first].ids)
|
|
161
|
+
both = ~np.isnan(a.gathered) & ~np.isnan(b.gathered)
|
|
162
|
+
if both.any() and np.max(np.abs(a.gathered[both] - b.gathered[both])) > NONDETERMINISM_TOL:
|
|
163
|
+
nondeterministic = True
|
|
164
|
+
warnings.append("Running the same text twice gave different numbers (non-deterministic).")
|
|
165
|
+
|
|
166
|
+
scores: list[_SeqScore] = []
|
|
167
|
+
approximate = False
|
|
168
|
+
clamps = 0
|
|
169
|
+
for i, sid in enumerate(seq_ids):
|
|
170
|
+
if progress:
|
|
171
|
+
progress("scoring", i, len(seq_ids))
|
|
172
|
+
t = topk[sid]
|
|
173
|
+
if len(t.pos) == 0:
|
|
174
|
+
continue
|
|
175
|
+
pl = backend.prompt_logprobs(probes[sid].token_ids.tolist(), t.pos, t.ids)
|
|
176
|
+
approximate |= pl.approximate
|
|
177
|
+
res = kld_topk(t.logp, pl.gathered)
|
|
178
|
+
clamps += res.clamps
|
|
179
|
+
scores.append(
|
|
180
|
+
_SeqScore(
|
|
181
|
+
probes[sid].group,
|
|
182
|
+
res.kl,
|
|
183
|
+
pl.top1 == t.ids[:, 0],
|
|
184
|
+
pl.target,
|
|
185
|
+
t.logp_target,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if progress:
|
|
189
|
+
progress("scoring", len(seq_ids), len(seq_ids))
|
|
190
|
+
|
|
191
|
+
kl = np.concatenate([s.kl for s in scores])
|
|
192
|
+
agree = np.concatenate([s.agree for s in scores])
|
|
193
|
+
lq = np.concatenate([s.lq_target for s in scores])
|
|
194
|
+
lp = np.concatenate([s.lp_target for s in scores])
|
|
195
|
+
sums = np.array([s.kl.sum() for s in scores])
|
|
196
|
+
counts = np.array([len(s.kl) for s in scores])
|
|
197
|
+
ci = bootstrap_mean_ci(sums, counts)
|
|
198
|
+
|
|
199
|
+
by_group: dict[str, GroupMetrics] = {}
|
|
200
|
+
for g in dict.fromkeys(s.group for s in scores):
|
|
201
|
+
sel = [s for s in scores if s.group == g]
|
|
202
|
+
gk = np.concatenate([s.kl for s in sel])
|
|
203
|
+
ga = np.concatenate([s.agree for s in sel])
|
|
204
|
+
by_group[g] = GroupMetrics(
|
|
205
|
+
kld_mean=jsonable_float(float(gk.mean())),
|
|
206
|
+
top1_agree=jsonable_float(float(ga.mean())),
|
|
207
|
+
n_positions=len(gk),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if clamps > max(10, 0.01 * len(kl)):
|
|
211
|
+
warnings.append(
|
|
212
|
+
f"{clamps} probabilities were raised to 1e-12 before taking logs. A high number "
|
|
213
|
+
"means something is broken (wrong tokenizer or a damaged model)."
|
|
214
|
+
)
|
|
215
|
+
if approximate:
|
|
216
|
+
warnings.append(
|
|
217
|
+
"approximate (server top-N): the server returned only its own top-N, so some "
|
|
218
|
+
"reference ids were counted in the tail bucket."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
damage_metrics = None
|
|
222
|
+
if do_damage:
|
|
223
|
+
damage_metrics = run_damage(backend, pack, quick, progress)
|
|
224
|
+
|
|
225
|
+
metrics = Metrics(
|
|
226
|
+
kld_mean=jsonable_float(float(kl.mean())),
|
|
227
|
+
kld_mean_ci95=(jsonable_float(ci[0]), jsonable_float(ci[1])),
|
|
228
|
+
kld_p99=jsonable_float(percentile(kl, 99)),
|
|
229
|
+
kld_p999=jsonable_float(percentile(kl, 99.9)),
|
|
230
|
+
top1_agree=jsonable_float(float(agree.mean())),
|
|
231
|
+
ppl_ratio=jsonable_float(ppl_ratio(lq, lp)),
|
|
232
|
+
n_positions=len(kl),
|
|
233
|
+
n_sequences=len(scores),
|
|
234
|
+
clamp_count=clamps,
|
|
235
|
+
by_group=by_group,
|
|
236
|
+
damage=damage_metrics,
|
|
237
|
+
)
|
|
238
|
+
return ScoreResult(
|
|
239
|
+
pack_id=pack.pack_id,
|
|
240
|
+
pack_name=manifest.short_name,
|
|
241
|
+
quant=QuantInfo(
|
|
242
|
+
source=source.source,
|
|
243
|
+
sha256=source.sha256,
|
|
244
|
+
size_bytes=source.size_bytes,
|
|
245
|
+
label=source.label,
|
|
246
|
+
publisher=source.publisher,
|
|
247
|
+
format=source.format,
|
|
248
|
+
),
|
|
249
|
+
backend=BackendInfo(
|
|
250
|
+
name=backend.name, version=backend.version(), settings=settings.public()
|
|
251
|
+
),
|
|
252
|
+
metrics=metrics,
|
|
253
|
+
hardware=hardware_string(),
|
|
254
|
+
quantgap=__version__,
|
|
255
|
+
created_at=utc_now(),
|
|
256
|
+
quick=quick,
|
|
257
|
+
approximate=approximate,
|
|
258
|
+
nondeterministic=nondeterministic,
|
|
259
|
+
warnings=warnings,
|
|
260
|
+
)
|
quantgap/score/stats.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Percentiles and a bootstrap confidence interval that resamples whole sequences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
N_BOOT = 1000
|
|
8
|
+
SEED = 0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def percentile(x: np.ndarray, q: float) -> float:
|
|
12
|
+
return float(np.percentile(x, q)) if len(x) else float("nan")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def bootstrap_mean_ci(
|
|
16
|
+
seq_sums: np.ndarray, seq_counts: np.ndarray, n_boot: int = N_BOOT, seed: int = SEED
|
|
17
|
+
) -> tuple[float, float]:
|
|
18
|
+
"""95% interval for the mean per-position value, resampling *sequences*.
|
|
19
|
+
|
|
20
|
+
Positions inside one sequence are not independent, so resampling positions would give an
|
|
21
|
+
interval that is too narrow. `seq_sums[i]` is the sum of the value over sequence i and
|
|
22
|
+
`seq_counts[i]` its number of positions.
|
|
23
|
+
"""
|
|
24
|
+
seq_sums = np.asarray(seq_sums, dtype=np.float64)
|
|
25
|
+
seq_counts = np.asarray(seq_counts, dtype=np.float64)
|
|
26
|
+
n = len(seq_sums)
|
|
27
|
+
if n == 0:
|
|
28
|
+
return (float("nan"), float("nan"))
|
|
29
|
+
rng = np.random.default_rng(seed)
|
|
30
|
+
idx = rng.integers(0, n, size=(n_boot, n))
|
|
31
|
+
means = seq_sums[idx].sum(axis=1) / seq_counts[idx].sum(axis=1)
|
|
32
|
+
lo, hi = np.percentile(means, [2.5, 97.5])
|
|
33
|
+
return (float(lo), float(hi))
|
quantgap/tokcheck.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tokenizer compatibility check.
|
|
2
|
+
|
|
3
|
+
A quant can only be compared to a pack if both use the same token ids. We check this before
|
|
4
|
+
scoring and stop with a clear error if it fails. We never "fix" a mismatch silently.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from quantgap.models import TokenizerInfo
|
|
12
|
+
|
|
13
|
+
# Mixed scripts, code, numbers, emoji, odd spacing. Special tokens are checked by id, not by text.
|
|
14
|
+
CHECK_STRING = (
|
|
15
|
+
"Hello, world! 你好,世界。 नमस्ते दुनिया. مرحبا بالعالم. ¿Qué tal?\n"
|
|
16
|
+
"def f(x):\n return {'a': [1, 2.5e-3, None]} # 😀🚀\n"
|
|
17
|
+
" two spaces\ttab 12345 3.14159 https://example.com/a?b=c&d=e\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenizerMismatch(RuntimeError):
|
|
22
|
+
"""The quant's tokenizer is not the same as the pack's."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TokFingerprint:
|
|
27
|
+
vocab_size: int
|
|
28
|
+
bos_id: int | None
|
|
29
|
+
eos_id: int | None
|
|
30
|
+
check_ids: list[int] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_tokenizer(pack_tok: TokenizerInfo, quant: TokFingerprint, pack_top_ids_max: int) -> None:
|
|
34
|
+
"""Raise TokenizerMismatch if the quant cannot be compared to the pack.
|
|
35
|
+
|
|
36
|
+
`pack_top_ids_max` is the largest token id seen anywhere in the pack's top-k lists and targets.
|
|
37
|
+
A quant may have extra padding ids beyond the reference vocabulary if those never appear there.
|
|
38
|
+
"""
|
|
39
|
+
# 1. vocabulary size
|
|
40
|
+
# A server cannot tell us its vocabulary size (vocab_size <= 0): skip this one check.
|
|
41
|
+
if quant.vocab_size > 0 and quant.vocab_size != pack_tok.vocab_size:
|
|
42
|
+
if quant.vocab_size < pack_tok.vocab_size:
|
|
43
|
+
raise TokenizerMismatch(
|
|
44
|
+
f"Check 1 (vocab size) failed: quant has {quant.vocab_size} tokens, "
|
|
45
|
+
f"pack expects {pack_tok.vocab_size}."
|
|
46
|
+
)
|
|
47
|
+
if pack_top_ids_max >= pack_tok.vocab_size: # pragma: no cover - defensive
|
|
48
|
+
raise TokenizerMismatch(
|
|
49
|
+
"Check 1 (vocab size) failed: pack ids exceed its own vocabulary."
|
|
50
|
+
)
|
|
51
|
+
# extra padding ids are fine: no pack id points at them.
|
|
52
|
+
# 2. fixed check string
|
|
53
|
+
if pack_tok.check_ids and quant.check_ids != pack_tok.check_ids:
|
|
54
|
+
a, b = pack_tok.check_ids, quant.check_ids
|
|
55
|
+
i = next((j for j in range(min(len(a), len(b))) if a[j] != b[j]), min(len(a), len(b)))
|
|
56
|
+
got = b[i] if i < len(b) else "(end)"
|
|
57
|
+
want = a[i] if i < len(a) else "(end)"
|
|
58
|
+
raise TokenizerMismatch(
|
|
59
|
+
f"Check 2 (encode a fixed test string) failed at token index {i}: "
|
|
60
|
+
f"pack has id {want}, quant has id {got}. "
|
|
61
|
+
"These models do not use the same tokenizer, so scores would be meaningless."
|
|
62
|
+
)
|
|
63
|
+
# 3. BOS / EOS ids
|
|
64
|
+
for label, want, got in (
|
|
65
|
+
("BOS", pack_tok.bos_id, quant.bos_id),
|
|
66
|
+
("EOS", pack_tok.eos_id, quant.eos_id),
|
|
67
|
+
):
|
|
68
|
+
if want is not None and got is not None and want != got:
|
|
69
|
+
raise TokenizerMismatch(
|
|
70
|
+
f"Check 3 ({label} id) failed: pack has {want}, quant has {got}."
|
|
71
|
+
)
|
quantgap/util.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Small helpers shared by several modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_SIZE_RE = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*([kmgt]?i?b?)\s*$", re.IGNORECASE)
|
|
13
|
+
_UNITS = {
|
|
14
|
+
"": 1,
|
|
15
|
+
"b": 1,
|
|
16
|
+
"k": 1000,
|
|
17
|
+
"kb": 1000,
|
|
18
|
+
"m": 1000**2,
|
|
19
|
+
"mb": 1000**2,
|
|
20
|
+
"g": 1000**3,
|
|
21
|
+
"gb": 1000**3,
|
|
22
|
+
"t": 1000**4,
|
|
23
|
+
"tb": 1000**4,
|
|
24
|
+
"ki": 1024,
|
|
25
|
+
"kib": 1024,
|
|
26
|
+
"mi": 1024**2,
|
|
27
|
+
"mib": 1024**2,
|
|
28
|
+
"gi": 1024**3,
|
|
29
|
+
"gib": 1024**3,
|
|
30
|
+
"ti": 1024**4,
|
|
31
|
+
"tib": 1024**4,
|
|
32
|
+
}
|
|
33
|
+
GIB = 1024**3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_size(text: str) -> int:
|
|
37
|
+
"""Parse '16GB', '16GiB', '16000MB' into bytes. GB means 10^9, GiB means 2^30."""
|
|
38
|
+
m = _SIZE_RE.match(text)
|
|
39
|
+
if not m or m.group(2).lower() not in _UNITS:
|
|
40
|
+
raise ValueError(f"Cannot read size {text!r}. Try something like 16GB, 16GiB or 16000MB.")
|
|
41
|
+
return int(float(m.group(1)) * _UNITS[m.group(2).lower()])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def human_size(n: int | float) -> str:
|
|
45
|
+
n = float(n)
|
|
46
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
47
|
+
if n < 1000 or unit == "TB":
|
|
48
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
49
|
+
n /= 1000
|
|
50
|
+
return f"{n:.1f} TB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sha256_bytes(data: bytes) -> str:
|
|
54
|
+
return hashlib.sha256(data).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sha256_file(path: str | Path, chunk: int = 1 << 20) -> str:
|
|
58
|
+
h = hashlib.sha256()
|
|
59
|
+
with open(path, "rb") as f:
|
|
60
|
+
while block := f.read(chunk):
|
|
61
|
+
h.update(block)
|
|
62
|
+
return h.hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def utc_now() -> str:
|
|
66
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hardware_string() -> str:
|
|
70
|
+
"""A short description of this machine, for the result file."""
|
|
71
|
+
parts = [platform.processor() or platform.machine() or "unknown CPU"]
|
|
72
|
+
try: # optional, never required
|
|
73
|
+
import torch
|
|
74
|
+
|
|
75
|
+
if torch.cuda.is_available():
|
|
76
|
+
parts = [torch.cuda.get_device_name(0) + " / CUDA"]
|
|
77
|
+
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
78
|
+
parts = [f"Apple {platform.machine()} / MPS"]
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
if platform.system() == "Darwin" and platform.machine() == "arm64" and "Apple" not in parts[0]:
|
|
82
|
+
parts = ["Apple silicon / Metal"]
|
|
83
|
+
return f"{parts[0]} ({platform.system()})"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def free_disk_bytes(path: str | Path) -> int:
|
|
87
|
+
p = Path(path)
|
|
88
|
+
while not p.exists() and p != p.parent:
|
|
89
|
+
p = p.parent
|
|
90
|
+
return shutil.disk_usage(p).free
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def jsonable_float(x: float) -> float:
|
|
94
|
+
"""Round to 9 significant digits so result files are stable across runs."""
|
|
95
|
+
return float(f"{x:.9g}")
|