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
quantgap/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Backend adapters. Each one is imported only when it is asked for, so the default install
|
|
2
|
+
(no torch, no llama.cpp) stays light."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import platform
|
|
7
|
+
|
|
8
|
+
from quantgap.backends.base import (
|
|
9
|
+
Backend,
|
|
10
|
+
BackendSettings,
|
|
11
|
+
PromptLogprobs,
|
|
12
|
+
ResolvedSource,
|
|
13
|
+
gather_from_logits,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
BACKEND_NAMES = ("llamacpp", "mlx", "openai", "hf")
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BACKEND_NAMES",
|
|
20
|
+
"Backend",
|
|
21
|
+
"BackendSettings",
|
|
22
|
+
"PromptLogprobs",
|
|
23
|
+
"ResolvedSource",
|
|
24
|
+
"gather_from_logits",
|
|
25
|
+
"get_backend",
|
|
26
|
+
"pick_backend_name",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_backend(name: str) -> Backend:
|
|
31
|
+
if name == "llamacpp":
|
|
32
|
+
from quantgap.backends.llamacpp import LlamaCppBackend
|
|
33
|
+
|
|
34
|
+
return LlamaCppBackend()
|
|
35
|
+
if name == "mlx":
|
|
36
|
+
if not (platform.system() == "Darwin" and platform.machine() == "arm64"):
|
|
37
|
+
raise RuntimeError("The mlx backend only works on Apple silicon Macs.")
|
|
38
|
+
from quantgap.backends.mlx import MLXBackend
|
|
39
|
+
|
|
40
|
+
return MLXBackend()
|
|
41
|
+
if name == "openai":
|
|
42
|
+
from quantgap.backends.openai_compat import OpenAIBackend
|
|
43
|
+
|
|
44
|
+
return OpenAIBackend()
|
|
45
|
+
if name == "hf":
|
|
46
|
+
from quantgap.backends.hf import HFBackend
|
|
47
|
+
|
|
48
|
+
return HFBackend()
|
|
49
|
+
raise ValueError(f"Unknown backend {name!r}. Choose one of: {', '.join(BACKEND_NAMES)}.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pick_backend_name(src: ResolvedSource, base_url: str | None) -> str:
|
|
53
|
+
"""Guess a backend from what the quant is."""
|
|
54
|
+
if src.format == "openai" or base_url:
|
|
55
|
+
return "openai"
|
|
56
|
+
if src.format == "gguf":
|
|
57
|
+
return "llamacpp"
|
|
58
|
+
if src.format == "mlx":
|
|
59
|
+
return "mlx"
|
|
60
|
+
return "hf"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The small interface every backend adapter has to provide (section 6.3 of the spec)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from quantgap.score.kld import log_softmax
|
|
12
|
+
from quantgap.tokcheck import TokFingerprint
|
|
13
|
+
|
|
14
|
+
LOGSOFTMAX_CHUNK = 64 # positions per float64 log-softmax, keeps memory flat for big vocabularies
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ResolvedSource:
|
|
19
|
+
"""A quant that has been located on disk (or on a server)."""
|
|
20
|
+
|
|
21
|
+
source: str # what the user typed, or the canonical hf:// URL
|
|
22
|
+
label: str # short name, for example "UD-Q4_K_XL"
|
|
23
|
+
path: Path | None = None # local file or folder; None for a server
|
|
24
|
+
size_bytes: int = 0
|
|
25
|
+
format: str = "gguf"
|
|
26
|
+
publisher: str | None = None
|
|
27
|
+
base_model: str | None = None
|
|
28
|
+
sha256: str | None = None
|
|
29
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BackendSettings:
|
|
34
|
+
ctx: int = 2048
|
|
35
|
+
n_gpu_layers: int = -1
|
|
36
|
+
n_threads: int | None = None
|
|
37
|
+
base_url: str | None = None
|
|
38
|
+
model_name: str | None = None
|
|
39
|
+
max_prompt_logprobs: int = 20
|
|
40
|
+
api_key: str | None = None
|
|
41
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def public(self) -> dict[str, Any]:
|
|
44
|
+
"""Settings that go into the result file (never the API key)."""
|
|
45
|
+
d = {"ctx": self.ctx}
|
|
46
|
+
if self.n_gpu_layers != -1:
|
|
47
|
+
d["n_gpu_layers"] = self.n_gpu_layers
|
|
48
|
+
else:
|
|
49
|
+
d["n_gpu_layers"] = -1
|
|
50
|
+
if self.n_threads:
|
|
51
|
+
d["n_threads"] = self.n_threads
|
|
52
|
+
if self.base_url:
|
|
53
|
+
d["base_url"] = self.base_url
|
|
54
|
+
if self.model_name:
|
|
55
|
+
d["model_name"] = self.model_name
|
|
56
|
+
d.update(self.extra)
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PromptLogprobs:
|
|
62
|
+
"""What a backend returns for one sequence, at the requested positions only."""
|
|
63
|
+
|
|
64
|
+
gathered: np.ndarray # (n, k) log prob of each requested id; NaN = could not tell
|
|
65
|
+
target: np.ndarray # (n,) log prob of the actual next token
|
|
66
|
+
top1: np.ndarray # (n,) the model's own best next token
|
|
67
|
+
approximate: bool = False # True for servers that only return their own top-N
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Backend(Protocol):
|
|
71
|
+
name: str
|
|
72
|
+
|
|
73
|
+
def load(self, source: ResolvedSource, settings: BackendSettings) -> None: ...
|
|
74
|
+
|
|
75
|
+
def tokenizer_fingerprint(self) -> TokFingerprint: ...
|
|
76
|
+
|
|
77
|
+
def prompt_logprobs(
|
|
78
|
+
self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
|
|
79
|
+
) -> PromptLogprobs:
|
|
80
|
+
"""Teacher forcing. Feed `token_ids`, and at each position p in `positions` return the
|
|
81
|
+
model's log probability for every id in gather_ids[row], plus for token p + 1."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]: ...
|
|
85
|
+
|
|
86
|
+
def decode(self, ids: list[int]) -> str: ...
|
|
87
|
+
|
|
88
|
+
def version(self) -> str: ...
|
|
89
|
+
|
|
90
|
+
def close(self) -> None: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gather_from_logits(
|
|
94
|
+
logits: np.ndarray, target_ids: np.ndarray, gather_ids: np.ndarray
|
|
95
|
+
) -> PromptLogprobs:
|
|
96
|
+
"""Shared maths for backends that hand back full logits.
|
|
97
|
+
|
|
98
|
+
logits: (n, V) raw scores, one row per scored position.
|
|
99
|
+
target_ids: (n,) the actual next token at each row.
|
|
100
|
+
gather_ids: (n, k) the reference's top-k ids at each row.
|
|
101
|
+
Uses a stable float64 log-softmax, in chunks so big vocabularies stay cheap.
|
|
102
|
+
"""
|
|
103
|
+
n, k = gather_ids.shape
|
|
104
|
+
gathered = np.empty((n, k), dtype=np.float64)
|
|
105
|
+
target = np.empty(n, dtype=np.float64)
|
|
106
|
+
top1 = np.empty(n, dtype=np.int64)
|
|
107
|
+
vocab = logits.shape[1]
|
|
108
|
+
for a in range(0, n, LOGSOFTMAX_CHUNK):
|
|
109
|
+
b = min(n, a + LOGSOFTMAX_CHUNK)
|
|
110
|
+
lp = log_softmax(logits[a:b])
|
|
111
|
+
ids = gather_ids[a:b]
|
|
112
|
+
oob = ids >= vocab # reference ids beyond this model's vocabulary
|
|
113
|
+
g = np.take_along_axis(lp, np.where(oob, 0, ids), axis=1)
|
|
114
|
+
gathered[a:b] = np.where(oob, -np.inf, g)
|
|
115
|
+
tid = target_ids[a:b]
|
|
116
|
+
target[a:b] = lp[np.arange(b - a), np.minimum(tid, vocab - 1)]
|
|
117
|
+
top1[a:b] = lp.argmax(axis=1)
|
|
118
|
+
return PromptLogprobs(gathered, target, top1, approximate=False)
|
quantgap/backends/hf.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Transformers backend (needs `quantgap[hf]`). Also the reference for self-tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from quantgap.backends.base import (
|
|
10
|
+
BackendSettings,
|
|
11
|
+
PromptLogprobs,
|
|
12
|
+
ResolvedSource,
|
|
13
|
+
gather_from_logits,
|
|
14
|
+
)
|
|
15
|
+
from quantgap.tokcheck import CHECK_STRING, TokFingerprint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HFBackend:
|
|
19
|
+
name = "hf"
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.model: Any = None
|
|
23
|
+
self.tok: Any = None
|
|
24
|
+
self.device: Any = None
|
|
25
|
+
|
|
26
|
+
def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
|
|
27
|
+
import torch
|
|
28
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
29
|
+
|
|
30
|
+
where = str(source.path) if source.path else source.source.removeprefix("hf://")
|
|
31
|
+
self.tok = AutoTokenizer.from_pretrained(where)
|
|
32
|
+
kwargs: dict[str, Any] = {}
|
|
33
|
+
if torch.cuda.is_available():
|
|
34
|
+
kwargs["device_map"] = "auto"
|
|
35
|
+
self.model = AutoModelForCausalLM.from_pretrained(where, **kwargs)
|
|
36
|
+
self.model.eval()
|
|
37
|
+
self.device = next(self.model.parameters()).device
|
|
38
|
+
|
|
39
|
+
def tokenizer_fingerprint(self) -> TokFingerprint:
|
|
40
|
+
return TokFingerprint(
|
|
41
|
+
vocab_size=int(self.model.get_output_embeddings().weight.shape[0])
|
|
42
|
+
if self.model.get_output_embeddings() is not None
|
|
43
|
+
else len(self.tok),
|
|
44
|
+
bos_id=self.tok.bos_token_id,
|
|
45
|
+
eos_id=self.tok.eos_token_id,
|
|
46
|
+
check_ids=list(self.tok(CHECK_STRING, add_special_tokens=False)["input_ids"]),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def prompt_logprobs(
|
|
50
|
+
self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
|
|
51
|
+
) -> PromptLogprobs:
|
|
52
|
+
import torch
|
|
53
|
+
|
|
54
|
+
ids = torch.tensor([token_ids], device=self.device)
|
|
55
|
+
with torch.no_grad():
|
|
56
|
+
logits = self.model(input_ids=ids).logits[0]
|
|
57
|
+
rows = logits[torch.from_numpy(np.asarray(positions)).to(logits.device)]
|
|
58
|
+
rows = rows.float().cpu().numpy()
|
|
59
|
+
tgt = np.asarray(token_ids, dtype=np.int64)[np.asarray(positions) + 1]
|
|
60
|
+
return gather_from_logits(rows, tgt, gather_ids)
|
|
61
|
+
|
|
62
|
+
def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
|
|
63
|
+
from quantgap.pack.runner import greedy_generate
|
|
64
|
+
|
|
65
|
+
return greedy_generate(self.model, self.tok, token_ids, max_new_tokens, self.device)
|
|
66
|
+
|
|
67
|
+
def decode(self, ids: list[int]) -> str:
|
|
68
|
+
return self.tok.decode(ids, skip_special_tokens=True)
|
|
69
|
+
|
|
70
|
+
def version(self) -> str:
|
|
71
|
+
import transformers
|
|
72
|
+
|
|
73
|
+
return f"transformers {transformers.__version__}"
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
self.model = None
|
|
77
|
+
self.tok = None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""GGUF backend through llama-cpp-python (needs `quantgap[llamacpp]`).
|
|
2
|
+
|
|
3
|
+
Verified fact (docs/DECISIONS.md, M0): `Llama(..., logits_all=True)` followed by `eval(tokens)`
|
|
4
|
+
fills `llm.scores`, an array of shape (n_ctx, n_vocab). Row i holds the logits that come out
|
|
5
|
+
after reading token i, so it predicts token i + 1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from quantgap.backends.base import (
|
|
15
|
+
BackendSettings,
|
|
16
|
+
PromptLogprobs,
|
|
17
|
+
ResolvedSource,
|
|
18
|
+
gather_from_logits,
|
|
19
|
+
)
|
|
20
|
+
from quantgap.tokcheck import CHECK_STRING, TokFingerprint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LlamaCppBackend:
|
|
24
|
+
name = "llamacpp"
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.llm: Any = None
|
|
28
|
+
self.settings: BackendSettings | None = None
|
|
29
|
+
|
|
30
|
+
def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
|
|
31
|
+
try:
|
|
32
|
+
from llama_cpp import Llama
|
|
33
|
+
except ImportError as e: # pragma: no cover
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
'llama-cpp-python is not installed. Run: pip install "quantgap[llamacpp]"'
|
|
36
|
+
) from e
|
|
37
|
+
if source.path is None or not source.path.is_file():
|
|
38
|
+
raise FileNotFoundError(f"GGUF file not found: {source.path}")
|
|
39
|
+
self.settings = settings
|
|
40
|
+
kwargs: dict[str, Any] = {
|
|
41
|
+
"model_path": str(source.path),
|
|
42
|
+
"n_ctx": settings.ctx,
|
|
43
|
+
"n_gpu_layers": settings.n_gpu_layers,
|
|
44
|
+
"logits_all": True,
|
|
45
|
+
"verbose": False,
|
|
46
|
+
}
|
|
47
|
+
if settings.n_threads:
|
|
48
|
+
kwargs["n_threads"] = settings.n_threads
|
|
49
|
+
self.llm = Llama(**kwargs)
|
|
50
|
+
|
|
51
|
+
def tokenizer_fingerprint(self) -> TokFingerprint:
|
|
52
|
+
llm = self.llm
|
|
53
|
+
ids = llm.tokenize(CHECK_STRING.encode("utf-8"), add_bos=False, special=False)
|
|
54
|
+
return TokFingerprint(
|
|
55
|
+
vocab_size=int(llm.n_vocab()),
|
|
56
|
+
bos_id=int(llm.token_bos()) if llm.token_bos() >= 0 else None,
|
|
57
|
+
eos_id=int(llm.token_eos()) if llm.token_eos() >= 0 else None,
|
|
58
|
+
check_ids=[int(i) for i in ids],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def prompt_logprobs(
|
|
62
|
+
self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
|
|
63
|
+
) -> PromptLogprobs:
|
|
64
|
+
llm = self.llm
|
|
65
|
+
llm.reset()
|
|
66
|
+
llm.eval(token_ids)
|
|
67
|
+
positions = np.asarray(positions)
|
|
68
|
+
rows = np.asarray(llm.scores[positions], dtype=np.float32)
|
|
69
|
+
tgt = np.asarray(token_ids, dtype=np.int64)[positions + 1]
|
|
70
|
+
return gather_from_logits(rows, tgt, gather_ids)
|
|
71
|
+
|
|
72
|
+
def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
|
|
73
|
+
"""Plain greedy decoding: no repetition penalty, no sampling. Stops on any end-of-
|
|
74
|
+
generation token (for Qwen that is both <|im_end|> and <|endoftext|>)."""
|
|
75
|
+
llm = self.llm
|
|
76
|
+
llm.reset()
|
|
77
|
+
is_eog = getattr(llm._model, "token_is_eog", None)
|
|
78
|
+
eos = llm.token_eos()
|
|
79
|
+
out: list[int] = []
|
|
80
|
+
for tok in llm.generate(
|
|
81
|
+
token_ids, top_k=1, top_p=1.0, temp=0.0, repeat_penalty=1.0, reset=True
|
|
82
|
+
):
|
|
83
|
+
if tok == eos or (is_eog is not None and is_eog(tok)) or len(out) >= max_new_tokens:
|
|
84
|
+
break
|
|
85
|
+
out.append(int(tok))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
def decode(self, ids: list[int]) -> str:
|
|
89
|
+
return self.llm.detokenize(ids).decode("utf-8", errors="replace")
|
|
90
|
+
|
|
91
|
+
def version(self) -> str:
|
|
92
|
+
import llama_cpp
|
|
93
|
+
|
|
94
|
+
return f"llama-cpp-python {llama_cpp.__version__}"
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
if self.llm is not None:
|
|
98
|
+
self.llm.close()
|
|
99
|
+
self.llm = None
|
quantgap/backends/mlx.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Apple silicon backend through mlx-lm (needs `quantgap[mlx]`).
|
|
2
|
+
|
|
3
|
+
Written from the mlx-lm API (`mlx_lm.load` returns `(model, tokenizer)`; calling the model on an
|
|
4
|
+
array of token ids with shape (1, T) returns logits of shape (1, T, V)). It has NOT been run on a
|
|
5
|
+
Mac by the author yet: see docs/DECISIONS.md. Run `quantgap doctor` on your Mac and open an issue
|
|
6
|
+
if anything fails.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from quantgap.backends.base import (
|
|
17
|
+
BackendSettings,
|
|
18
|
+
PromptLogprobs,
|
|
19
|
+
ResolvedSource,
|
|
20
|
+
gather_from_logits,
|
|
21
|
+
)
|
|
22
|
+
from quantgap.tokcheck import CHECK_STRING, TokFingerprint
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MLXBackend:
|
|
26
|
+
name = "mlx"
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self.model: Any = None
|
|
30
|
+
self.tok: Any = None
|
|
31
|
+
|
|
32
|
+
def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
|
|
33
|
+
try:
|
|
34
|
+
from mlx_lm import load
|
|
35
|
+
except ImportError as e: # pragma: no cover
|
|
36
|
+
raise RuntimeError('mlx-lm is not installed. Run: pip install "quantgap[mlx]"') from e
|
|
37
|
+
where = str(source.path) if source.path else source.source.removeprefix("hf://")
|
|
38
|
+
self.model, self.tok = load(where)
|
|
39
|
+
|
|
40
|
+
def _hf_tok(self) -> Any:
|
|
41
|
+
return getattr(self.tok, "_tokenizer", self.tok)
|
|
42
|
+
|
|
43
|
+
def tokenizer_fingerprint(self) -> TokFingerprint:
|
|
44
|
+
t = self._hf_tok()
|
|
45
|
+
ids = list(t(CHECK_STRING, add_special_tokens=False)["input_ids"])
|
|
46
|
+
vocab = len(t)
|
|
47
|
+
with contextlib.suppress(Exception): # the real embedding size is what matters for padding
|
|
48
|
+
vocab = int(self.model.model.embed_tokens.weight.shape[0])
|
|
49
|
+
return TokFingerprint(vocab, t.bos_token_id, t.eos_token_id, ids)
|
|
50
|
+
|
|
51
|
+
def prompt_logprobs(
|
|
52
|
+
self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
|
|
53
|
+
) -> PromptLogprobs:
|
|
54
|
+
import mlx.core as mx
|
|
55
|
+
|
|
56
|
+
logits = self.model(mx.array([token_ids]))[0]
|
|
57
|
+
rows = np.array(logits.astype(mx.float32))[np.asarray(positions)]
|
|
58
|
+
tgt = np.asarray(token_ids, dtype=np.int64)[np.asarray(positions) + 1]
|
|
59
|
+
return gather_from_logits(rows, tgt, gather_ids)
|
|
60
|
+
|
|
61
|
+
def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
|
|
62
|
+
import mlx.core as mx
|
|
63
|
+
|
|
64
|
+
ids = list(token_ids)
|
|
65
|
+
out: list[int] = []
|
|
66
|
+
eos = self.tok.eos_token_id
|
|
67
|
+
for _ in range(max_new_tokens): # no KV cache: slow but simple and exact
|
|
68
|
+
logits = self.model(mx.array([ids]))[0, -1]
|
|
69
|
+
nxt = int(mx.argmax(logits).item())
|
|
70
|
+
if nxt == eos:
|
|
71
|
+
break
|
|
72
|
+
out.append(nxt)
|
|
73
|
+
ids.append(nxt)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def decode(self, ids: list[int]) -> str:
|
|
77
|
+
return self.tok.decode(ids)
|
|
78
|
+
|
|
79
|
+
def version(self) -> str:
|
|
80
|
+
import mlx_lm
|
|
81
|
+
|
|
82
|
+
return f"mlx-lm {getattr(mlx_lm, '__version__', 'unknown')}"
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
self.model = None
|
|
86
|
+
self.tok = None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Any OpenAI-compatible server that returns prompt logprobs (vLLM: `prompt_logprobs`).
|
|
2
|
+
|
|
3
|
+
This covers NVFP4, AWQ, GPTQ and FP8 quants served by vLLM.
|
|
4
|
+
|
|
5
|
+
Server facts this adapter relies on (see docs/DECISIONS.md, M0, where they are marked as taken
|
|
6
|
+
from the vLLM docs and checked against a fake server, not against a live GPU):
|
|
7
|
+
- POST {base}/completions accepts `prompt` as a list of token ids and `prompt_logprobs: N`.
|
|
8
|
+
- The reply has `choices[0].prompt_logprobs`: a list with one entry per prompt token. Entry 0 is
|
|
9
|
+
null. Entry i is a dict {"<token id>": {"logprob": float, "rank": int, ...}} holding the actual
|
|
10
|
+
token i plus the server's top-N for that position.
|
|
11
|
+
- N is capped by the server (`--max-logprobs`, default 20). We ask for the configured value and
|
|
12
|
+
step down if the server refuses.
|
|
13
|
+
|
|
14
|
+
A server only tells us its own top-N, not the probability of every reference id. Ids that are
|
|
15
|
+
missing come back as NaN and the scorer moves them into the tail bucket (spec section 7.2, E4).
|
|
16
|
+
Results from this backend are always marked approximate when that happens.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from quantgap.backends.base import BackendSettings, PromptLogprobs, ResolvedSource
|
|
28
|
+
from quantgap.tokcheck import CHECK_STRING, TokFingerprint
|
|
29
|
+
|
|
30
|
+
_FALLBACK_N = (20, 10, 5, 1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenAIBackend:
|
|
34
|
+
name = "openai"
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: httpx.Client | None = None) -> None:
|
|
37
|
+
self._client = client
|
|
38
|
+
self.base_url = ""
|
|
39
|
+
self.model_name = ""
|
|
40
|
+
self.n = 20
|
|
41
|
+
self._headers: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ setup
|
|
44
|
+
def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
|
|
45
|
+
if not settings.base_url:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"The openai backend needs --base-url (for example http://localhost:8000/v1)."
|
|
48
|
+
)
|
|
49
|
+
self.base_url = settings.base_url.rstrip("/")
|
|
50
|
+
self.model_name = settings.model_name or source.extra.get("model_name") or ""
|
|
51
|
+
if not self.model_name:
|
|
52
|
+
raise ValueError("The openai backend needs --model-name (the name the server serves).")
|
|
53
|
+
self.n = settings.max_prompt_logprobs
|
|
54
|
+
key = settings.api_key or os.environ.get("OPENAI_API_KEY")
|
|
55
|
+
self._headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
56
|
+
if self._client is None:
|
|
57
|
+
self._client = httpx.Client(timeout=600)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _root(self) -> str:
|
|
61
|
+
return self.base_url[: -len("/v1")] if self.base_url.endswith("/v1") else self.base_url
|
|
62
|
+
|
|
63
|
+
def _post(self, url: str, payload: dict[str, Any]) -> httpx.Response:
|
|
64
|
+
assert self._client is not None
|
|
65
|
+
return self._client.post(url, json=payload, headers=self._headers)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ interface
|
|
68
|
+
def tokenizer_fingerprint(self) -> TokFingerprint:
|
|
69
|
+
ids: list[int] = []
|
|
70
|
+
try:
|
|
71
|
+
r = self._post(
|
|
72
|
+
f"{self._root}/tokenize",
|
|
73
|
+
{"model": self.model_name, "prompt": CHECK_STRING, "add_special_tokens": False},
|
|
74
|
+
)
|
|
75
|
+
if r.status_code == 200:
|
|
76
|
+
ids = [int(t) for t in r.json()["tokens"]]
|
|
77
|
+
except (httpx.HTTPError, KeyError, ValueError):
|
|
78
|
+
pass
|
|
79
|
+
# A server does not tell us its vocabulary size, so -1 means "unknown, skip that check".
|
|
80
|
+
return TokFingerprint(vocab_size=-1, bos_id=None, eos_id=None, check_ids=ids)
|
|
81
|
+
|
|
82
|
+
def prompt_logprobs(
|
|
83
|
+
self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
|
|
84
|
+
) -> PromptLogprobs:
|
|
85
|
+
entries = self._request_prompt_logprobs(token_ids)
|
|
86
|
+
positions = np.asarray(positions)
|
|
87
|
+
n, k = gather_ids.shape
|
|
88
|
+
gathered = np.full((n, k), np.nan)
|
|
89
|
+
target = np.full(n, np.nan)
|
|
90
|
+
top1 = np.zeros(n, dtype=np.int64)
|
|
91
|
+
for row, p in enumerate(positions):
|
|
92
|
+
entry = entries[p + 1]
|
|
93
|
+
if entry is None:
|
|
94
|
+
raise RuntimeError(f"Server returned no logprobs for token {p + 1}.")
|
|
95
|
+
tid = str(token_ids[p + 1])
|
|
96
|
+
if tid in entry:
|
|
97
|
+
target[row] = entry[tid]["logprob"]
|
|
98
|
+
best = max(entry.items(), key=lambda kv: kv[1]["logprob"])
|
|
99
|
+
top1[row] = int(best[0])
|
|
100
|
+
for j in range(k):
|
|
101
|
+
v = entry.get(str(int(gather_ids[row, j])))
|
|
102
|
+
if v is not None:
|
|
103
|
+
gathered[row, j] = v["logprob"]
|
|
104
|
+
if np.isnan(target).any(): # defensive: server must always include the actual token
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"Server did not return the log probability of the actual next token."
|
|
107
|
+
)
|
|
108
|
+
return PromptLogprobs(gathered, target, top1, approximate=bool(np.isnan(gathered).any()))
|
|
109
|
+
|
|
110
|
+
def _request_prompt_logprobs(self, token_ids: list[int]) -> list[dict | None]:
|
|
111
|
+
last = ""
|
|
112
|
+
for n in dict.fromkeys([self.n, *[x for x in _FALLBACK_N if x < self.n]]):
|
|
113
|
+
r = self._post(
|
|
114
|
+
f"{self.base_url}/completions",
|
|
115
|
+
{
|
|
116
|
+
"model": self.model_name,
|
|
117
|
+
"prompt": token_ids,
|
|
118
|
+
"max_tokens": 1,
|
|
119
|
+
"temperature": 0,
|
|
120
|
+
"prompt_logprobs": n,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
if r.status_code == 200:
|
|
124
|
+
self.n = n
|
|
125
|
+
data = r.json()["choices"][0].get("prompt_logprobs")
|
|
126
|
+
if data is None:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"The server did not return prompt_logprobs. This backend needs vLLM "
|
|
129
|
+
"or a server with the same extension."
|
|
130
|
+
)
|
|
131
|
+
return data
|
|
132
|
+
last = r.text[:300]
|
|
133
|
+
if r.status_code != 400:
|
|
134
|
+
break
|
|
135
|
+
raise RuntimeError(f"Server refused the prompt_logprobs request: {last}")
|
|
136
|
+
|
|
137
|
+
def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
|
|
138
|
+
r = self._post(
|
|
139
|
+
f"{self.base_url}/completions",
|
|
140
|
+
{
|
|
141
|
+
"model": self.model_name,
|
|
142
|
+
"prompt": token_ids,
|
|
143
|
+
"max_tokens": max_new_tokens,
|
|
144
|
+
"temperature": 0,
|
|
145
|
+
"logprobs": 1,
|
|
146
|
+
"return_tokens_as_token_ids": True,
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
r.raise_for_status()
|
|
150
|
+
toks = r.json()["choices"][0]["logprobs"]["tokens"]
|
|
151
|
+
return [int(t.split(":")[1]) for t in toks] # entries look like "token_id:123"
|
|
152
|
+
|
|
153
|
+
def decode(self, ids: list[int]) -> str:
|
|
154
|
+
r = self._post(f"{self._root}/detokenize", {"model": self.model_name, "tokens": ids})
|
|
155
|
+
r.raise_for_status()
|
|
156
|
+
return r.json()["prompt"]
|
|
157
|
+
|
|
158
|
+
def version(self) -> str:
|
|
159
|
+
return f"openai-compatible server at {self.base_url}"
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
if self._client is not None:
|
|
163
|
+
self._client.close()
|
|
164
|
+
self._client = None
|