File size: 12,856 Bytes
c83c617
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Convert, gate, compare and package the independent V2 KV-cache experiment."""
import argparse
import json
import shutil
import sys
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))
import numpy as np
import torch
from vimeml.deployment import kv_cache as kv, v2
from vimeml.deployment.bundle import fresh_directory, read_json, write_json
from vimeml.deployment.validation import numeric_error
from vimeml.training.data import file_sha
from vimeml.training.evaluate_ime import common_prefix_length


def align(bundle, directory, reference, output, baseline=None):
    runtime = kv.Runtime(bundle, directory)
    old = v2.CoreMLLM(bundle, baseline) if baseline else None
    errors, invariants, saved = [], [], {}
    info = read_json(reference / "reference.json")
    if info.get("format") != "vimeml_v2_mac_preparation_reference_v1" or info["checkpoint_step"] != runtime.metadata["checkpoint_step"]:
        raise ValueError("Invalid frozen reference identity.")
    if old and any(old.metadata[key] != runtime.metadata[key] for key in ("checkpoint_sha256", "tokenizer_sha256", "model")):
        raise ValueError("Stateless baseline model/tokenizer identity differs.")
    with np.load(reference / "logits.npz", allow_pickle=False) as arrays:
        for name, case in info["cases"].items():
            ids = arrays[name + "_input_ids"][0]
            expected = old.model(torch.tensor([ids.tolist()])).numpy() if old else arrays[name + "_logits"]
            patterns = [[len(ids)], [1] * len(ids)]
            if len(ids) > 3: patterns.append([3, len(ids)-3])
            for chunks in patterns:
                parts, past, state = [], 0, None
                for size in chunks:
                    logits, state = runtime.step(ids[past:past+size], state, past)
                    parts.append(logits); past += size
                actual = np.concatenate(parts, axis=1)
                valid = case["valid_prefix"]
                error = numeric_error(expected[:, :valid], actual[:, :valid], 3e-4, 3e-4)
                errors.append({"case": name, "chunks": chunks, **error})
                if chunks == [len(ids)]: saved[name] = actual
            last, _ = runtime.step(ids, last_only=True)
            errors.append({"case": name, "last_only": True, **numeric_error(expected[:, -1:], last, 3e-4, 3e-4)})
        for name in ("right_pad", "changed_future"):
            invariants.append({"case": name, **numeric_error(saved["short"], saved[name][:, :16], 3e-4, 3e-4)})
        # Immutable shared snapshots, stale suffix overwrite, and A/B/A branch
        # order catch errors that fresh-prefill comparisons cannot detect.
        prefix = arrays["short_input_ids"][0][:7]
        _, state = runtime.step(prefix, last_only=True)
        original = tuple(a.copy() for a in state)
        for suffix in ([9], [47, 81, 102], [9]):
            actual, _ = runtime.step(suffix, state, len(prefix))
            expected = (old.model(torch.tensor([[*prefix.tolist(), *suffix]])).numpy() if old else
                        v2.BundleLM(bundle).model(torch.tensor([[*prefix.tolist(), *suffix]])).detach().numpy())[:, len(prefix):]
            errors.append({"branch": suffix, **numeric_error(expected, actual, 3e-4, 3e-4)})
        immutable = all(np.array_equal(a, b) for a, b in zip(state, original))
        dirty = tuple(a.copy() for a in state)
        for a in dirty: a[:, :, :, len(prefix):, :] = 999
        a, _ = runtime.step([9], state, len(prefix))
        b, _ = runtime.step([9], dirty, len(prefix))
        invariants.append({"case": "unused_tail", **numeric_error(a, b, 0, 0)})
    report = {"format": "vimeml_kv_alignment_v1", "passed": immutable and all(e["outside_tolerance"] == 0 for e in errors+invariants),
        "coreml_manifest_sha256": runtime.metadata["coreml_manifest_sha256"], "model": runtime.metadata,
        "comparison": "same-weight stateless Core ML" if old else "frozen Windows FP32",
        "baseline_manifest_sha256": old.metadata["coreml_manifest_sha256"] if old else None,
        "tolerances": {"atol": 3e-4, "rtol": 3e-4}, "immutable_branch_inputs": immutable,
        "errors": errors, "invariants": invariants}
    fresh_directory(output); write_json(output / "alignment.json", report)
    return report


class CachedForward:
    """Evaluation adapter: share exact batch token prefixes within one call."""
    def __init__(self, runtime):
        self.runtime, self.config = runtime, runtime.config

    def __call__(self, inputs):
        ids = inputs.numpy().astype(np.int32)
        common = common_prefix_length(ids.tolist())
        if common:
            prefix, state = self.runtime.step(ids[0, :common])
        result = []
        for row in ids:
            if common == len(row): logits = prefix
            elif common:
                suffix, _ = self.runtime.step(row[common:], state, common)
                logits = np.concatenate((prefix, suffix), axis=1)
            else: logits, _ = self.runtime.step(row)
            result.append(logits)
        return torch.from_numpy(np.concatenate(result, axis=0))


class CachedLM(v2.BundleLM):
    def __init__(self, bundle, directory):
        super().__init__(bundle)
        self.runtime = kv.Runtime(bundle, directory)
        self.model, self.metadata = CachedForward(self.runtime), self.runtime.metadata
        self._generating = False
        self._ids = self._state = None

    @torch.inference_mode()
    def next_logits(self, ids):
        if self._generating and self._ids and ids[:len(self._ids)] == self._ids and len(ids) > len(self._ids):
            logits, state = self.runtime.step(ids[len(self._ids):], self._state, len(self._ids), last_only=True)
        else:
            logits, state = self.runtime.step(ids, last_only=True)
        if self._generating: self._ids, self._state = list(ids), state
        return torch.from_numpy(logits[0,-1])

    def generate(self, *args, **kwargs):
        self._generating, self._ids, self._state = True, None, None
        try: return super().generate(*args, **kwargs)
        finally: self._generating, self._ids, self._state = False, None, None


def timing(work, repeats=20):
    for _ in range(3): work()
    times = []
    for _ in range(repeats):
        start = time.perf_counter(); work(); times.append((time.perf_counter()-start)*1000)
    return {"n": len(times), "p50_ms": float(np.median(times)), "p95_ms": float(np.percentile(times, 95)), "max_ms": max(times)}


def benchmark(bundle, directory, baseline, output):
    runtime, old = kv.Runtime(bundle, directory), v2.CoreMLLM(bundle, baseline)
    rng = np.random.default_rng(716)
    rows = []
    for length in (8, 32, 64, 120):
        ids = rng.integers(4, 16384, length).tolist(); ids[0] = 2
        _, state = runtime.step(ids[:-1], last_only=True)
        rows.append({"prefix_tokens": length-1,
            "stateless": timing(lambda: old.model(torch.tensor([ids]))),
            "cached_decode": timing(lambda: runtime.step(ids[-1:], state, length-1, last_only=True)),
            "prefill_last_only": timing(lambda: runtime.step(ids, last_only=True))})
    def generated(cached, ids, tokens=8):
        sequence = list(ids)
        if cached: logits, state = runtime.step(sequence, last_only=True)
        else: logits = old.model(torch.tensor([sequence])).numpy()[:, -1:]
        for i in range(tokens):
            token = int(logits[0,-1].argmax()); sequence.append(token)
            if i == tokens-1: break
            if cached: logits, state = runtime.step([token], state, len(sequence)-1, last_only=True)
            else: logits = old.model(torch.tensor([sequence])).numpy()[:, -1:]
        return sequence
    generation = []
    for length in (8, 32, 64):
        ids = [2, *rng.integers(4, 16384, length-1).tolist()]
        generation.append({"prefix_tokens": length, "new_tokens": 8,
            "tokens_equal": generated(True,ids) == generated(False,ids),
            "cached_total": timing(lambda: generated(True,ids), 10),
            "stateless_total": timing(lambda: generated(False,ids), 10)})
    fresh_directory(output)
    write_json(output / "benchmark.json", {"scope": "Mac CPU_ONLY Python/Core ML; includes Python and array-validation overhead, not iPhone or keyboard extension",
        "model": runtime.metadata, "decode": rows, "generation": generation,
        "cache_bytes_per_snapshot": runtime.manifest["cache_bytes_per_snapshot"]})


def package(bundle, source, compiled, comparison, output):
    m = kv.verify_package(source)
    gate = read_json(comparison)
    if m["kind"] != "linear8_fp32_compute" or not (compiled / "coremldata.bin").is_file():
        raise ValueError("Expected compiled INT8 KV resources.")
    if (gate.get("format") != "vimeml_kv_alignment_v1" or not gate.get("passed")
            or gate.get("comparison") != "same-weight stateless Core ML"
            or gate["coreml_manifest_sha256"] != file_sha(source / "manifest.json")):
        raise ValueError("Passed same-weight stateless comparison required before packaging.")
    fresh_directory(output)
    resources = output / "Resources"; resources.mkdir()
    shutil.copytree(compiled, resources / "TinyJapaneseV21KVINT8.mlmodelc")
    # Reuses the exact V2 tokenizer already shipped with the baseline, not a
    # second tokenizer copy. Installer checks this existing identity.
    write_json(resources / "VimeLMManifestV21KV.json", {"format": "vime_ios_lm_v2_kv_v1",
        "architecture": v2.ARCHITECTURE, "model_version": "2.1-extend5-step40000-int8-b32-kv-v1",
        "minimum_ios": 18, "vocab_size": 16384, "context_length": 128, "compute_units": "CPU_ONLY",
        "special_ids": {"pad": 0, "unk": 1, "bos": 2, "eos": 3},
        "tokenizer_sha256": m["bundle"]["tokenizer_sha256"], "checkpoint_sha256": m["bundle"]["checkpoint_sha256"],
        "cache_shape": m["cache_shape"], "cache_dtype": "float32",
        "source_manifest_sha256": file_sha(source / "manifest.json"),
        "stateless_alignment_sha256": file_sha(comparison),
        "compiled_files_sha256": {name: item["sha256"] for name,item in v2.inventory(compiled,hashes=True).items()},
        "policy": "Experimental opt-in; explicit FP32 request-local immutable caches; baseline remains default."})
    write_json(output / "manifest.json", {"format": "vimeml_kv_client_resources_v1", "model": m,
        "files": v2.inventory(output)})


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--threads", type=int, default=4)
    stages = p.add_subparsers(dest="stage", required=True)
    for name in ("convert", "align", "compress", "evaluate", "samples", "benchmark", "package"):
        s = stages.add_parser(name); s.add_argument("--output", type=Path, required=True)
        if name != "compress": s.add_argument("--bundle", type=Path, required=True)
        if name in ("align", "evaluate", "samples", "benchmark"): s.add_argument("--model", type=Path, required=True)
        if name in ("compress", "package"): s.add_argument("--source", type=Path, required=True)
        if name == "compress": s.add_argument("--alignment", type=Path, required=True)
        if name == "align":
            s.add_argument("--reference", type=Path, required=True); s.add_argument("--baseline", type=Path)
        if name in ("evaluate", "samples", "benchmark"): s.add_argument("--baseline", type=Path, required=True)
        if name == "evaluate": s.add_argument("--benchmark", type=Path, required=True)
        if name == "package":
            s.add_argument("--compiled", type=Path, required=True)
            s.add_argument("--comparison", type=Path, required=True)
    args = p.parse_args()
    if args.output.exists() or args.threads < 1: p.error("Fresh output and positive thread count required.")
    torch.set_num_threads(args.threads)
    if args.stage == "convert": kv.convert(args.bundle,args.output)
    elif args.stage == "compress": kv.compress(args.source,args.output,args.alignment)
    elif args.stage == "align":
        report = align(args.bundle,args.model,args.reference,args.output,args.baseline)
        print(json.dumps({"passed": report["passed"], "max_abs": max(e["max_abs"] for e in report["errors"])}, indent=2))
        if not report["passed"]: raise SystemExit("Alignment failed; evidence preserved.")
    elif args.stage == "benchmark": benchmark(args.bundle,args.model,args.baseline,args.output)
    elif args.stage == "package": package(args.bundle,args.source,args.compiled,args.comparison,args.output)
    else:
        lm = CachedLM(args.bundle,args.model)
        if args.stage == "evaluate": v2.evaluate(lm,args.benchmark,args.baseline,args.output)
        else: v2.samples(lm,args.baseline,args.output)
    print(f"Output: {args.output.resolve()}")


if __name__ == "__main__": main()