File size: 5,835 Bytes
c6b6889 | 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 | """
Headless experiment harness for the speculative decoding laboratory.
Runs a matrix of (prompt x speculation length x repeat), records every raw
measurement to JSON, and never aggregates anything here: aggregation lives in
analyze.py so that the raw records stay auditable and any published number can
be traced back to the run that produced it.
Usage:
python experiments/run_experiments.py # full matrix
python experiments/run_experiments.py --quick # small smoke matrix
python experiments/run_experiments.py --max-new-tokens 96 --repeats 5
Run this on the machine whose numbers you intend to publish. The draft/target
cost ratio c is a property of the hardware, not of the model pair, so results
from one machine do not transfer to another.
"""
import argparse
import json
import platform
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import torch # noqa: E402
from backend.main import ( # noqa: E402
DEVICE,
DTYPE,
DRAFT_MODEL_NAME,
TARGET_MODEL_NAME,
load_models,
run_baseline,
run_speculative,
)
RESULTS_DIR = Path(__file__).resolve().parent / "results"
# Prompts deliberately span genres, because acceptance rate tracks how
# predictable the continuation is: boilerplate definitions are easy for a small
# draft model, open-ended reasoning is not.
PROMPTS = {
"definition": "Explain what a hash map is and give a short example.",
"code": "Write a Python function that reverses a linked list.",
"explanation": "Summarise why transformers replaced RNNs for sequence modelling.",
"list": "List five practical uses of binary search trees.",
"reasoning": "A train leaves at 3pm going 60mph and another at 4pm going 80mph. When does the second catch the first? Reason step by step.",
"creative": "Write an opening paragraph for a story about a lighthouse keeper.",
}
K_VALUES = [1, 2, 3, 4, 6, 8]
def build_record(kind, prompt_id, prompt, k, repeat, payload):
"""Strip the generated text down to a hash plus a preview.
Full text is kept only for the equality check between baseline and
speculative; storing every full continuation would bloat the records file
without adding evidence.
"""
record = {
"kind": kind,
"prompt_id": prompt_id,
"k": k,
"repeat": repeat,
"total_time_ms": payload["total_time_ms"],
"tokens_generated": payload["tokens_generated"],
"target_calls": payload["target_calls"],
"text": payload["text"],
"rounds": payload.get("rounds", []),
}
for key in ("alpha_hat", "c_hat", "predicted_speedup", "draft_time_ms", "target_time_ms", "gamma"):
if key in payload:
record[key] = payload[key]
return record
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repeats", type=int, default=3, help="timed repeats per cell")
parser.add_argument("--max-new-tokens", type=int, default=64)
parser.add_argument("--quick", action="store_true", help="2 prompts, K in {1,4}, 1 repeat")
parser.add_argument("--out", default=str(RESULTS_DIR / "records.json"))
args = parser.parse_args()
prompts = dict(PROMPTS)
k_values = list(K_VALUES)
repeats = args.repeats
if args.quick:
prompts = {k: prompts[k] for k in list(prompts)[:2]}
k_values = [1, 4]
repeats = 1
print(f"loading models on {DEVICE} ({DTYPE}) ...", flush=True)
load_models()
# One warm-up generation, discarded. The first call pays for lazy CUDA/MPS
# kernel compilation and allocator warm-up, which would otherwise be
# charged to whichever cell happened to run first.
print("warm-up run (discarded) ...", flush=True)
run_speculative(next(iter(prompts.values())), 4, 16)
run_baseline(next(iter(prompts.values())), 16)
records = []
total_cells = len(prompts) * (1 + len(k_values)) * repeats
done = 0
started = time.time()
for prompt_id, prompt in prompts.items():
for repeat in range(repeats):
payload = run_baseline(prompt, args.max_new_tokens)
records.append(build_record("baseline", prompt_id, prompt, None, repeat, payload))
done += 1
print(f"[{done}/{total_cells}] baseline {prompt_id} r{repeat} "
f"{payload['total_time_ms']:.0f}ms", flush=True)
for k in k_values:
payload = run_speculative(prompt, k, args.max_new_tokens)
records.append(build_record("speculative", prompt_id, prompt, k, repeat, payload))
done += 1
print(f"[{done}/{total_cells}] spec {prompt_id} K={k} r{repeat} "
f"{payload['total_time_ms']:.0f}ms "
f"alpha={payload.get('alpha_hat')} c={payload.get('c_hat')}", flush=True)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
bundle = {
"meta": {
"created_utc": datetime.now(timezone.utc).isoformat(),
"device": DEVICE,
"dtype": str(DTYPE),
"draft_model": DRAFT_MODEL_NAME,
"target_model": TARGET_MODEL_NAME,
"max_new_tokens": args.max_new_tokens,
"repeats": repeats,
"k_values": k_values,
"prompt_ids": list(prompts),
"prompts": prompts,
"torch": torch.__version__,
"platform": platform.platform(),
"wall_clock_s": round(time.time() - started, 1),
},
"records": records,
}
out_path.write_text(json.dumps(bundle, indent=2))
print(f"\nwrote {len(records)} records to {out_path}")
if __name__ == "__main__":
main()
|