Voltline's picture
Release VimeML V4.2 epoch20 FP32 and Core ML INT8 (GPL-2.0)
c83c617 verified
Raw History Blame Contribute Delete
12.9 kB
"""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()