File size: 10,816 Bytes
1f71c7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""Rigorous benchmark harness for ENSEMBLE.

Measures, across dimensionalities D, on three corpora (facts / qa / prose):

  - build time + throughput (tokens/sec)
  - in-memory footprint (MB)
  - next-token accuracy (lm mode)
  - QA exact-recall accuracy (qa mode, on holdout)
  - query latency
  - .exp compression ratio

Run:
    python bench/run_benchmark.py --Ds 2000 10000 --out bench_results.json
    python bench/run_benchmark.py --Ds 100000 --out bench_results_1b.json

Each result is reproducible (fixed seeds). Output is a JSON the reporter turns
into the RESULTS.md table.
"""
from __future__ import annotations

import argparse
import json
import os
import statistics
import sys
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path

import numpy as np

# allow `python bench/run_benchmark.py` from repo root
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from ensemble import Expert
from ensemble.io import expert_size_bytes
from palimseste.tokenizer import BOS, EOS, PAD

import gen_datasets  # noqa: E402  (same dir)


# ----------------------------------------------------------------- result
@dataclass
class BenchResult:
    name: str
    D: int
    # build
    build_seconds: float
    n_tokens: int
    throughput_tok_per_s: float
    # footprint
    ram_mb: float
    # quality
    next_token_accuracy: float
    # qa
    qa_exact_recall: float = 0.0
    qa_n_eval: int = 0
    # query
    query_latency_ms: float = 0.0
    # storage
    exp_size_bytes: int = 0
    source_size_bytes: int = 0
    compression_ratio: float = 0.0


# ----------------------------------------------------------------- helpers
def _ram_mb(expert: Expert) -> float:
    """Sum of packed-bits storage for all traces (address + value)."""
    total = 0
    for t in expert.model.mem.traces:
        total += t.address.bits.nbytes + t.value.bits.nbytes
    return total / 1e6


def _next_token_accuracy(expert: Expert, text: str, max_eval: int = 400) -> float:
    """Fraction of correctly-predicted next tokens over the text.

    Uses an INCREMENTAL state builder so the whole pass is O(N*D), not
    O(N^2*D): the running sign-sum is updated by one column per token rather
    than rebuilt from scratch each step. This makes accuracy measurement
    feasible even at D=100 000.
    """
    from palimseste.lm import _IncrementalState
    tok = expert.model.tokenizer
    model = expert.model
    if tok is None:
        return 0.0
    ids = tok.encode(text, add_bos=True, add_eos=True)
    if len(ids) < 2:
        return 0.0

    # subsample eval points deterministically
    n_eval_points = min(max_eval, len(ids) - 1)
    step = max(1, (len(ids) - 1) // n_eval_points)

    builder = _IncrementalState(D=model.config.D,
                                window=model.config.context_window,
                                encoder=model.encoder)
    # precompute token bits matrix for vectorized scoring
    V = tok.vocab_size
    packed_len = len(model._self_hv.bits)
    tok_bits = np.empty((V, packed_len), dtype=np.uint8)
    for tid in range(V):
        tok_bits[tid] = tok.token_hv(tid).bits
    _POPCOUNT = np.array([bin(i).count("1") for i in range(256)], dtype=np.uint16)
    D = model.config.D
    from palimseste.hv import bind as _bind

    correct = 0
    total = 0
    for i, tok_id in enumerate(ids):
        s = builder.peek_or_init(tok)
        should_eval = (i > 0 and ((i - 1) % step == 0))
        if should_eval and s is not None:
            q = _bind(model._self_hv, s)
            retrieved = model.phi(model.mem, q)
            if retrieved is not None:
                xored = np.bitwise_xor(tok_bits, retrieved.bits[np.newaxis, :])
                pred = int(np.argmin(_POPCOUNT[xored].sum(axis=1)))
            else:
                pred = PAD
            if pred == ids[i]:
                correct += 1
            total += 1
        builder.push(tok_id, tok)

    return correct / total if total else 0.0


def _qa_exact_recall(expert: Expert, holdout: list[tuple[str, str]]) -> tuple[float, int]:
    """Exact-answer recall: does the expert return the exact answer prefix?

    We score leniently: the answer counts as recalled if its first
    ``min(len, 12)`` chars appear at the start of the response (the char-LM
    often trails off, so we reward the correct *start*).
    """
    n = 0
    hit = 0
    for q, expected in holdout:
        n += 1
        got = expert.answer(q, max_new_tokens=len(expected) + 6, temperature=0.0)
        k = min(len(expected), 12)
        if got.strip().lower().startswith(expected.strip().lower()[:k]):
            hit += 1
    return (hit / n if n else 0.0), n


def _measure_query_latency(expert: Expert, questions: list[str], k: int = 20) -> float:
    """Median query latency in ms over k samples."""
    times = []
    for q in questions[:k]:
        t0 = time.perf_counter()
        expert.answer(q, max_new_tokens=20, temperature=0.0)
        times.append((time.perf_counter() - t0) * 1000)
    return float(statistics.median(times)) if times else 0.0


# ----------------------------------------------------------------- benchmarks
def bench_lm(name: str, text: str, D: int, holdout_qa=None) -> BenchResult:
    print(f"  [{name}] D={D} building lm expert...", flush=True)
    t0 = time.perf_counter()
    expert = Expert.from_text(text, domain=name, D=D, seed=0)
    build_s = time.perf_counter() - t0
    n_tok = expert.n_traces
    throughput = n_tok / build_s if build_s > 0 else 0.0
    print(f"    built {n_tok} traces in {build_s:.1f}s ({throughput:.0f} tok/s)",
          flush=True)

    ram = _ram_mb(expert)
    print(f"    measuring next-token accuracy...", flush=True)
    nta = _next_token_accuracy(expert, text, max_eval=300 if D <= 20000 else 150)

    qa_recall, qa_n = 0.0, 0
    if holdout_qa:
        qa_recall, qa_n = _qa_exact_recall(expert, holdout_qa)

    print(f"    measuring latency...", flush=True)
    qprobes = [q for q, _ in (holdout_qa or [("what is x", "x")][:1])]
    lat = _measure_query_latency(expert, qprobes)

    import tempfile
    with tempfile.TemporaryDirectory() as td:
        res = expert.save(os.path.join(td, f"{name}.exp"))
        exp_bytes = res.expert_size_bytes
        src_bytes = res.source_size_bytes
        ratio = res.compression_ratio

    return BenchResult(
        name=name, D=D, build_seconds=build_s, n_tokens=n_tok,
        throughput_tok_per_s=throughput, ram_mb=ram,
        next_token_accuracy=nta, qa_exact_recall=qa_recall, qa_n_eval=qa_n,
        query_latency_ms=lat, exp_size_bytes=exp_bytes,
        source_size_bytes=src_bytes, compression_ratio=ratio,
    )


def bench_qa(name: str, train: list[tuple[str, str]], holdout: list[tuple[str, str]],
             D: int) -> BenchResult:
    print(f"  [{name}] D={D} building qa expert...", flush=True)
    t0 = time.perf_counter()
    expert = Expert.from_qa_pairs(train, domain=name, D=D, seed=0)
    build_s = time.perf_counter() - t0
    n_tok = expert.n_traces
    throughput = n_tok / build_s if build_s > 0 else 0.0
    print(f"    built {n_tok} traces in {build_s:.1f}s ({throughput:.0f} tok/s)",
          flush=True)

    ram = _ram_mb(expert)
    nta = 0.0  # qa mode: we measure recall, not lm accuracy

    # TWO recall numbers: memorization (train) and generalization (holdout).
    # Use the unique train pairs (dedup) for a fair memorization probe.
    train_unique = list(dict.fromkeys(train))
    # cap memorization probe for speed at large D
    cap = 20 if D <= 30000 else 10
    train_probe = train_unique[:cap]
    recall_train, n_train = _qa_exact_recall(expert, train_probe)
    recall_holdout, n_holdout = _qa_exact_recall(expert, holdout)
    print(f"    qa recall train={recall_train:.1%} (n={n_train})  "
          f"holdout={recall_holdout:.1%} (n={n_holdout})", flush=True)
    # we report the memorization recall as the headline QA number
    qa_recall = recall_train
    qa_n = n_train
    lat = _measure_query_latency(expert, [q for q, _ in holdout])

    import tempfile
    with tempfile.TemporaryDirectory() as td:
        res = expert.save(os.path.join(td, f"{name}.exp"))
        exp_bytes = res.expert_size_bytes
        src_bytes = res.source_size_bytes
        ratio = res.compression_ratio

    # stash the holdout recall in extra fields via a side note
    br = BenchResult(
        name=name, D=D, build_seconds=build_s, n_tokens=n_tok,
        throughput_tok_per_s=throughput, ram_mb=ram,
        next_token_accuracy=nta, qa_exact_recall=qa_recall, qa_n_eval=qa_n,
        query_latency_ms=lat, exp_size_bytes=exp_bytes,
        source_size_bytes=src_bytes, compression_ratio=ratio,
    )
    # carry the holdout number for the report
    br_extra = asdict(br)
    br_extra["qa_holdout_recall"] = recall_holdout
    br_extra["qa_holdout_n"] = n_holdout
    return br  # type: ignore[return-value]


# ----------------------------------------------------------------- driver
def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--Ds", type=int, nargs="+", default=[2000, 10000],
                    help="dimensionalities to benchmark")
    ap.add_argument("--out", default="bench_results.json")
    ap.add_argument("--corpora", nargs="+",
                    default=["facts", "qa", "prose"],
                    choices=["facts", "qa", "prose"])
    ap.add_argument("--data-dir", default="bench_data")
    args = ap.parse_args()

    # ensure datasets exist
    ddir = Path(args.data_dir)
    if not (ddir / "facts.txt").exists():
        print("generating datasets...", flush=True)
        gen_datasets.generate_all(ddir)

    facts_text = (ddir / "facts.txt").read_text(encoding="utf-8")
    prose_text = (ddir / "prose.txt").read_text(encoding="utf-8")
    with open(ddir / "qa_train.json", encoding="utf-8") as f:
        qa_train = [(d["question"], d["answer"]) for d in json.load(f)]
    with open(ddir / "qa_holdout.json", encoding="utf-8") as f:
        qa_holdout = [(d["question"], d["answer"]) for d in json.load(f)]

    results: list[BenchResult] = []
    for D in args.Ds:
        print(f"\n=== D = {D} ===", flush=True)
        if "facts" in args.corpora:
            # also probe QA recall on the facts lm expert
            results.append(bench_lm("facts", facts_text, D, holdout_qa=qa_holdout))
        if "qa" in args.corpora:
            results.append(bench_qa("qa", qa_train, qa_holdout, D))
        if "prose" in args.corpora:
            results.append(bench_lm("prose", prose_text, D, holdout_qa=None))

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump([asdict(r) for r in results], f, indent=2, ensure_ascii=False)
    print(f"\nwrote {len(results)} results -> {args.out}", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())