File size: 6,998 Bytes
a484e22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 4.1 — deployed wall-clock speedup via a faithful external spec-decode
loop with REAL timers (not engine-integrated).

Wiring a retrieval draft into vLLM/sglang's internal speculative-decoding hook
is out of reach in the available time, so — as the directive permits — we
reproduce the accept/reject/re-decode loop end-to-end against the live served
model with real wall-clock timers, and label it plainly as an external harness.

Spec-decode accounting (single retrieval draft per call): the target model runs
ONE verification forward over the drafted tool call, accepts its ``L``-token
correct prefix (token-LCP against the greedy target, exactly the MAT metric),
then autoregressively decodes the remaining ``T-L`` target tokens. So the target
performs ~``(T-L)`` sequential forwards plus one verify, vs. ``T`` for a
no-speculation baseline. We MEASURE the real per-request wall-clock of generating
``T`` tokens (baseline) and ``T-L`` tokens (each arm) from the actual decision-
point prompt on the served gpt-oss-120b — the content is irrelevant, only the
decode-step count and real server timing matter — and add one measured verify
forward. Reports p50/p95 latency per arm and end-to-end speedup vs no-memory.

Run from the repo root:  python -m harness.phase4_wallclock --url http://localhost:30000/v1
"""
from __future__ import annotations

import argparse
import json
import os
import random
import time
from collections import defaultdict
from pathlib import Path

import requests

from . import metrics
from .data import Task
from .memory import Embedder, NoMemory, PersonalMemory, StaticGlobal
from .run_accept import _parse_target
from .simulate import build_users

ROOT = Path(__file__).resolve().parent.parent
RESULTS = ROOT / "results"
# Tokenizer for the token-LCP accept metric: HF hub id by default;
# override with a local snapshot path if running offline.
MODEL_PATH = os.environ.get("SPECMEM_TOKENIZER", "openai/gpt-oss-120b")


def _collect_points(domains):
    """Replay the live tau2 decision points; record, per post-warmup point,
    each arm's token-accept length against the served target."""
    dp = [json.loads(l) for l in
          (RESULTS / "tau2_live_decision_points.jsonl").read_text().splitlines()
          if json.loads(l)["domain"] in domains]
    tools = {d: json.loads((ROOT / "data" / "tau2" /
                            f"tools_{d}.json").read_text()) for d in domains}
    tasks = [Task(id=r["id"], query=r["query"], functions=tools[r["domain"]],
                  origin_id=r["id"]) for r in dp]
    targets = {r["query"]: r["target"] for r in dp}
    emb = Embedder()
    inst = build_users(tasks, n_users=40, tasks_per_user=15, n_sessions=12,
                       queries_per_session=6, seed=0, perturb_prob=0.0)
    inst.sort(key=lambda x: (x.session, x.user_id))
    arms = [NoMemory(), StaticGlobal(), PersonalMemory(capacity=48, eviction="lru")]
    cur, pts = -1, []
    for ins in inst:
        tgt = targets.get(ins.query)
        if tgt is None:
            continue
        if ins.session != cur:
            cur = ins.session
            if cur == 1:
                for a in arms:
                    if hasattr(a, "freeze"):
                        a.freeze()
        if ins.session > 0:
            row = {"query": ins.query, "target": tgt,
                   "T": metrics.accept_length(tgt, tgt)[1]}
            for a in arms:
                row[a.name] = metrics.accept_length(
                    a.draft(ins.query, ins.functions, ins.user_id, emb), tgt)[0]
            pts.append(row)
        cn, ca = _parse_target(tgt)
        for a in arms[1:]:
            a.observe(ins.query, ins.functions, ins.user_id, cn, ca, emb)
            if isinstance(a, PersonalMemory) and ins.session == 0:
                a.seed_shared(ins.query, cn, ca, emb)
    return pts


def _timed_gen(url, prompt, max_tok):
    """Real wall-clock (ms) to generate exactly max_tok tokens; content unused."""
    t0 = time.perf_counter()
    r = requests.post(url, json={"model": "gpt-oss-120b", "temperature": 0.0,
                                 "max_tokens": max_tok, "ignore_eos": True,
                                 "messages": [{"role": "user", "content": prompt}]},
                      timeout=180)
    dt = (time.perf_counter() - t0) * 1000
    r.raise_for_status()
    return dt


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--url", default="http://localhost:30000/v1")
    p.add_argument("--domains", nargs="+",
                   default=["airline", "retail", "telecom"])
    p.add_argument("--sample", type=int, default=120)
    p.add_argument("--seed", type=int, default=0)
    args = p.parse_args()
    chat = args.url.rstrip("/") + "/chat/completions"

    metrics.get_tokenizer(MODEL_PATH)
    pts = _collect_points(args.domains)
    rng = random.Random(args.seed)
    rng.shuffle(pts)
    pts = pts[: args.sample]
    print(f"[wallclock] {len(pts)} sampled decision points", flush=True)

    # one measured verify forward (prefill+1 tok) as spec-decode overhead
    verify_ms = sorted(_timed_gen(chat, pts[i]["query"][:1500], 1)
                       for i in range(min(15, len(pts))))
    verify = verify_ms[len(verify_ms) // 2]

    lat = defaultdict(list)      # arm -> per-request wall-clock ms
    for k, row in enumerate(pts):
        prompt = row["query"][:1500]
        T = max(1, row["T"])
        # cache decode-time per distinct token count to save calls
        need = {T}
        for arm in ("no_memory", "static_global", "personal_memory"):
            need.add(max(1, T - row[arm]))
        tcache = {n: _timed_gen(chat, prompt, n) for n in need}
        lat["baseline_no_spec"].append(tcache[T])
        for arm in ("no_memory", "static_global", "personal_memory"):
            lat[arm].append(verify + tcache[max(1, T - row[arm])])
        if (k + 1) % 20 == 0:
            print(f"  {k+1}/{len(pts)}", flush=True)

    def stats(xs):
        xs = sorted(xs)
        return {"p50_ms": round(xs[len(xs) // 2], 1),
                "p95_ms": round(xs[int(0.95 * len(xs))], 1),
                "mean_ms": round(sum(xs) / len(xs), 1)}

    base = stats(lat["baseline_no_spec"])
    out = {"config": {"domains": args.domains, "n": len(pts),
                      "verify_forward_ms": round(verify, 1),
                      "note": "faithful external spec-decode loop, real timers, "
                              "NOT engine-integrated (see docstring)"},
           "baseline_no_spec": base, "arms": {}}
    for arm in ("no_memory", "static_global", "personal_memory"):
        s = stats(lat[arm])
        s["speedup_vs_no_memory_p50"] = round(
            stats(lat["no_memory"])["p50_ms"] / s["p50_ms"], 3)
        s["speedup_vs_baseline_p50"] = round(base["p50_ms"] / s["p50_ms"], 3)
        out["arms"][arm] = s
    (RESULTS / "phase4_wallclock_deployed.json").write_text(json.dumps(out, indent=2))
    print(json.dumps(out, indent=2))


if __name__ == "__main__":
    main()