File size: 20,733 Bytes
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
"""Autoregressive and self-speculative decoding on a LayeredDecoder.

One cycle of self-speculative decoding:

1. Drafting. The last committed token (not yet cached) and then each draft
   token climb the layers; at every head allowed by the policy we ask the
   policy whether to exit. An exit yields a draft token from that head; the
   K/V of the deeper drafting layers are filled from the exit hidden state
   (CALM-style copy) so later draft tokens can attend to it. A token with no
   exit, the draft budget, or a drafted EOS ends the draft.
2. Verification. Every row of the cycle has passed through layer c_min (the
   shallowest exit the policy can use) with exact inputs, so its hidden state
   there is exact. One pass runs layers [c_min, L) over all rows, overwriting
   the approximate K/V, and yields the full-model distribution at each row.
3. Acceptance. Standard speculative sampling (Leviathan et al. 2023): accept
   draft x with prob min(1, p(x)/q(x)), else resample from norm(max(p-q, 0));
   if all drafts are accepted, sample a bonus token from the last row. At T=0
   this is exact argmax matching. The output law (greedy: the output) equals
   the full model's.

Cache invariant between cycles: positions [0, len(seq)-1) are exact in every
layer; the last committed token is processed at the next cycle.
"""

from __future__ import annotations

import bisect
import time
from dataclasses import dataclass, field

import torch
import torch.nn.functional as F

from .heads import ExitHead, entropy, head_draft_distribution
from .layered import LayeredDecoder


# ---------------------------------------------------------------------- policies
class ExitPolicy:
    """Which heads may be tried, in which order, and when to exit."""

    head_order: list[int]          # indices into `heads`, shallow to deep
    max_draft: int

    def exit(self, k: int, logits: torch.Tensor, n_drafted: int) -> bool:
        raise NotImplementedError

    def decide(self, k: int, conf: float, n_drafted: int) -> bool:
        """Exit decision from the head confidence (graph engine)."""
        raise NotImplementedError


class StaticPolicy(ExitPolicy):
    """Always exit at head k; draft exactly `length` tokens (LayerSkip-like)."""

    def __init__(self, k: int, length: int):
        self.head_order = [k]
        self.max_draft = length

    def exit(self, k, logits, n_drafted):
        return True

    def decide(self, k, conf, n_drafted):
        return True


class ThresholdPolicy(ExitPolicy):
    """Exit at the first head (in `head_order`) whose score passes its threshold.

    score: "maxprob" (max over V_k ∪ ⊥, 0 if ⊥ wins), "neg_entropy", or
    "one_minus_reject". With a single head this is the Kangaroo rule; with all
    heads it is DSSD.
    """

    def __init__(self, heads: list[ExitHead], thresholds: dict[int, float],
                 max_draft: int = 32, score: str = "maxprob", cum_gamma: float = 0.0,
                 acc_maps: dict | None = None):
        self.heads = heads
        # Optional per-head calibrated acceptance a_hat_k(conf) (isotonic, piecewise linear
        # knots/values): thresholds and the cumulative product then apply to a_hat.
        self.acc_maps = {int(k): (list(v[0]), list(v[1])) for k, v in (acc_maps or {}).items()}
        self.thresholds = thresholds
        self.head_order = sorted(thresholds)
        self.max_draft = max_draft
        self.score = score
        # Cumulative stop (g): a draft token is only emitted while the product of the
        # exit confidences of the block stays >= cum_gamma (proxy for the probability
        # that the whole block survives verification, cf. SpecDec++).
        self.cum_gamma = cum_gamma
        self._cum = 1.0

    def start_block(self) -> None:
        self._cum = 1.0

    def _cum_ok(self, conf: float, k: int | None = None) -> bool:
        # cum_gamma: a scalar, or a per-head dict {k: gamma_k} (F7 option B: the exit test
        # at head k is cum * a_hat_k >= gamma_k)
        g = self.cum_gamma.get(k, 0.0) if isinstance(self.cum_gamma, dict) else self.cum_gamma
        if g <= 0.0:
            self._cum *= conf
            return True
        if self._cum * conf < g:
            return False
        self._cum *= conf
        return True

    def exit(self, k, logits, n_drafted):
        head = self.heads[k]
        if self.score == "maxprob":
            _, _, s = head_draft_distribution(logits, head.reject, 1.0)
        elif self.score == "neg_entropy":
            s = -entropy(logits)
            if head.reject and int(logits.argmax(-1)) == logits.shape[-1] - 1:
                return False
        elif self.score == "one_minus_reject":
            _, p_rej, s = head_draft_distribution(logits, head.reject, 1.0)
            s = 1.0 - p_rej
        else:
            raise ValueError(self.score)
        return float(s) >= self.thresholds[k] and self._cum_ok(float(s), k)

    def _ahat(self, k, conf):
        if k not in self.acc_maps:
            return conf
        xs, ys = self.acc_maps[k]
        if conf <= xs[0]:
            return ys[0]
        if conf >= xs[-1]:
            return ys[-1]
        j = bisect.bisect_right(xs, conf)
        x0, x1, y0, y1 = xs[j - 1], xs[j], ys[j - 1], ys[j]
        return y0 + (y1 - y0) * (conf - x0) / (x1 - x0 if x1 > x0 else 1.0)

    def decide(self, k, conf, n_drafted):
        if conf <= 0.0 and self.heads[k].reject:  # ⊥ won: never draft (T4: gamma_k > a_hat(⊥))
            return False
        a = self._ahat(k, conf)
        return a >= self.thresholds[k] and self._cum_ok(a, k)


class ScriptedPolicy(ExitPolicy):
    """Testing: draft token i exits at head schedule[i % len] (None = no exit)."""

    def __init__(self, n_heads: int, schedule: list, max_draft: int = 15):
        self.head_order = list(range(n_heads))
        self.schedule = schedule
        self.max_draft = max_draft

    def _want(self, n_drafted):
        return self.schedule[n_drafted % len(self.schedule)]

    def exit(self, k, logits, n_drafted):
        return self._want(n_drafted) == k

    def decide(self, k, conf, n_drafted):
        return self._want(n_drafted) == k


class AdaptiveThresholdPolicy(ThresholdPolicy):
    """Single head, threshold adapted online toward a target acceptance rate
    (Draft & Verify / AdaEDL style): after each cycle, raise the threshold if the
    cycle's acceptance fell below `target`, lower it otherwise."""

    def __init__(self, heads, k: int, gamma0: float, target: float, step: float = 0.01,
                 max_draft: int = 15):
        super().__init__(heads, {k: gamma0}, max_draft=max_draft, score="maxprob")
        self.k, self.target, self.step = k, target, step

    def update(self, n_drafted: int, n_accepted: int) -> None:
        if n_drafted == 0:
            return
        g = self.thresholds[self.k] + (self.step if n_accepted / n_drafted < self.target else -self.step)
        self.thresholds[self.k] = min(0.999, max(0.0, g))


# ---------------------------------------------------------------------- stats
@dataclass
class GenStats:
    new_tokens: int = 0
    cycles: int = 0                 # full-model passes after prefill
    drafted: int = 0
    accepted: int = 0
    empty_drafts: int = 0           # cycles whose draft had length 0 (fallback)
    rejected_cycles: int = 0        # cycles ended by a rejected draft
    ar_steps: int = 0               # online control: plain AR steps (lambda > 1, no probe)
    lam_start: float = float("nan") # online control: lambda at the start / end of the request
    lam_end: float = float("nan")
    gamma_start: float = float("nan")  # cost safety (NoSlowdown): gamma at the start / end
    gamma_end: float = float("nan")
    ar_step_median: float = float("nan")  # AR runs: median per-token step time
    lab_n: int = 0                  # online control: labels sent to the controller (and their sum)
    lab_s: float = 0.0
    n_jumps: int = -1               # online control: re-anchoring jumps of the ACI (cumulative)
    pld_drafted: int = 0            # prompt lookup: copied drafts / accepted copied drafts
    pld_accepted: int = 0
    cost_units: float = 0.0         # online control: sum of cycle costs (1 = one AR step)
    h_max: float = float("-inf")    # max over drafting cycles of cost - (1 + xi) * emitted
    excess_end: float = 0.0         # sum over the request of cost - (1 + xi) * emitted
    excess_max: float = float("-inf")  # running maximum of that sum within the request
    exits: dict = field(default_factory=dict)       # head index -> count
    sources: list = field(default_factory=list)     # per output token: copy/head/star/draft/model
    head_evals: int = 0
    layer_rows: int = 0
    kv_fill_rows: int = 0
    vocab_proj_rows: int = 0
    seconds: float = 0.0
    prefill_seconds: float = 0.0

    @property
    def tau(self) -> float:
        """Tokens committed per full-model pass (incl. correction/bonus)."""
        return self.new_tokens / max(self.cycles, 1)

    def as_dict(self) -> dict:
        d = dict(self.__dict__)
        d.pop("sources")  # per-token list: kept out of the bench jsonl
        d["tau"] = self.tau
        d["acceptance"] = self.accepted / max(self.drafted, 1)
        # drafts emitted on the output prefix = accepted + one rejected per rejecting cycle
        d["acceptance_prefix"] = self.accepted / max(self.accepted + self.rejected_cycles, 1)
        d["tokens_per_s"] = self.new_tokens / max(self.seconds, 1e-9)
        return d


def _sync(device) -> None:
    if device.type == "cuda":
        torch.cuda.synchronize(device)


def _sample(probs: torch.Tensor, gen: torch.Generator | None) -> int:
    return int(torch.multinomial(probs, 1, generator=gen))


def _target_token(logits: torch.Tensor, temperature: float, gen) -> int:
    if temperature == 0.0:
        return int(logits.argmax())
    return _sample(F.softmax(logits / temperature, dim=-1), gen)


# ---------------------------------------------------------------------- decoders
@torch.no_grad()
def generate_ar(dec: LayeredDecoder, prompt: list[int], max_new: int,
                temperature: float = 0.0, eos_ids: set[int] | None = None,
                gen: torch.Generator | None = None) -> tuple[list[int], GenStats]:
    eos_ids = eos_ids or set()
    st = GenStats()
    dec.reset_counters()
    ids = torch.tensor(prompt, device=dec.device)
    _sync(dec.device)
    t0 = time.perf_counter()
    logits = dec.prefill(ids)
    _sync(dec.device)
    st.prefill_seconds = time.perf_counter() - t0
    out = [_target_token(logits, temperature, gen)]
    t1 = time.perf_counter()
    while len(out) < max_new and out[-1] not in eos_ids:
        pos = len(prompt) + len(out) - 1
        logits = dec.full_step(torch.tensor([out[-1]], device=dec.device), pos)[0]
        out.append(_target_token(logits, temperature, gen))
        st.cycles += 1
    _sync(dec.device)
    st.seconds = time.perf_counter() - t1 + st.prefill_seconds
    st.new_tokens = len(out)
    st.layer_rows = dec.counters["layer_rows"]
    st.vocab_proj_rows = dec.counters["vocab_proj_rows"]
    return out, st


@torch.no_grad()
def generate_spec(dec: LayeredDecoder, heads: list[ExitHead], head_layers: list[int],
                  policy: ExitPolicy, prompt: list[int], max_new: int,
                  temperature: float = 0.0, eos_ids: set[int] | None = None,
                  gen: torch.Generator | None = None,
                  kv_fill: str = "lazy", trace: list | None = None) -> tuple[list[int], GenStats]:
    """Self-speculative decoding; head k reads the hidden state after head_layers[k] layers.

    kv_fill:
      "lazy"      regime A (reference): before a draft row climbs past the depth of
                  earlier rows of the block, those rows are caught up exactly (batched),
                  so every head score depends only on the exact prefix. Verification
                  then runs only layers [c_max, L).
      "copy"      regime B: CALM state copy of the K/V of skipped drafting layers;
                  verification runs [c_min, L).
      "recompute" regime B variant: run the skipped drafting layers for exited tokens.
    trace: if a list, receives (position, head, logits) for every head evaluation.
    """
    eos_ids = eos_ids or set()
    st = GenStats()
    dec.reset_counters()
    L = dec.spec.num_layers
    order = policy.head_order
    c_min = head_layers[order[0]]
    c_max = head_layers[order[-1]]
    ids = torch.tensor(prompt, device=dec.device)

    _sync(dec.device)
    t0 = time.perf_counter()
    logits = dec.prefill(ids)
    _sync(dec.device)
    st.prefill_seconds = time.perf_counter() - t0
    out = [_target_token(logits, temperature, gen)]

    t1 = time.perf_counter()
    while len(out) < max_new and out[-1] not in eos_ids:
        pos0 = len(prompt) + len(out) - 1
        inputs = [out[-1]]
        h_min: list[torch.Tensor] = []    # hidden after c_min layers, per row
        drafts: list[int] = []
        q_rows: list[torch.Tensor | None] = []
        budget = min(policy.max_draft, max_new - len(out))
        if hasattr(policy, "start_block"):
            policy.start_block()

        if kv_fill == "lazy":
            drafts, q_rows, H, verify_from = _draft_lazy(
                dec, heads, head_layers, policy, out[-1], pos0, budget, temperature, gen,
                eos_ids, st, trace, context=(prompt + out[:-1]) if trace is not None else None)
            inputs = [out[-1]] + drafts
            H = dec.run_layers(H, pos0, verify_from, L)
            target = dec.final_logits(H)
            st.cycles += 1
            st.drafted += len(drafts)
            if not drafts:
                st.empty_drafts += 1
            n_acc, committed = _accept(drafts, q_rows, target, temperature, gen)
            st.accepted += n_acc
            for t in committed:
                out.append(t)
                if t in eos_ids or len(out) >= max_new:
                    break
            continue

        # ---------------- drafting (regime B)
        while True:
            j = len(inputs) - 1
            if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids):
                break  # last input is processed by the verification pass
            pos = pos0 + j
            h = dec.run_layers(dec.embed(torch.tensor([inputs[j]], device=dec.device)),
                               pos, 0, c_min)
            h_min.append(h)
            depth = c_min
            exited = None
            for k in order:
                if head_layers[k] > depth:
                    h = dec.run_layers(h, pos, depth, head_layers[k])
                    depth = head_layers[k]
                logits_k = heads[k](h)[0]
                st.head_evals += 1
                if trace is not None:
                    trace.append((pos, k, logits_k.detach().clone()))
                if policy.exit(k, logits_k, len(drafts)):
                    exited = k
                    break
            if exited is None:
                break
            head = heads[exited]
            q_k, _, _ = head_draft_distribution(logits_k, head.reject, temperature)
            if temperature == 0.0:
                tok_local = int(q_k.argmax())
                q_full = None
            else:
                tok_local = _sample(q_k, gen)
                q_full = head.scatter_full(q_k)
            tok = int(head.to_token_ids(torch.tensor(tok_local, device=dec.device)))
            if depth < c_max:
                if kv_fill == "copy":
                    dec.fill_kv_from_hidden(h, pos, depth, c_max)
                elif kv_fill == "recompute":
                    dec.run_layers(h, pos, depth, c_max)
                else:
                    raise ValueError(kv_fill)
            st.exits[exited] = st.exits.get(exited, 0) + 1
            drafts.append(tok)
            q_rows.append(q_full)
            inputs.append(tok)

        # ---------------- verification (one full-model pass)
        if len(h_min) < len(inputs):  # last draft token never entered the stack
            j = len(inputs) - 1
            h_min.append(dec.run_layers(
                dec.embed(torch.tensor([inputs[j]], device=dec.device)), pos0 + j, 0, c_min))
        H = torch.cat(h_min, dim=0)
        H = dec.run_layers(H, pos0, c_min, L)
        target = dec.final_logits(H)               # (len(inputs), V)
        st.cycles += 1
        st.drafted += len(drafts)
        if not drafts:
            st.empty_drafts += 1

        # ---------------- acceptance
        n_acc, committed = _accept(drafts, q_rows, target, temperature, gen)
        st.accepted += n_acc

        for t in committed:
            out.append(t)
            if t in eos_ids or len(out) >= max_new:
                break

    _sync(dec.device)
    st.seconds = time.perf_counter() - t1 + st.prefill_seconds
    st.new_tokens = len(out)
    st.layer_rows = dec.counters["layer_rows"]
    st.kv_fill_rows = dec.counters["kv_fill_rows"]
    st.vocab_proj_rows = dec.counters["vocab_proj_rows"]
    return out, st


def _accept(drafts, q_rows, target, temperature, gen):
    """Speculative acceptance (Leviathan et al.): returns (#accepted, committed tokens)."""
    committed: list[int] = []
    for i, d in enumerate(drafts):
        if temperature == 0.0:
            if int(target[i].argmax()) != d:
                committed.append(int(target[i].argmax()))
                return len(committed) - 1, committed
        else:
            p = F.softmax(target[i] / temperature, dim=-1)
            q = q_rows[i]
            ratio = (p[d] / q[d].clamp_min(1e-20)).clamp(max=1.0)
            u = torch.rand((), device=p.device, generator=gen)
            if not bool(u < ratio):
                resid = (p - q).clamp_min(0.0)
                z = resid.sum()
                committed.append(_sample(resid / z if z > 0 else p, gen))
                return len(committed) - 1, committed
        committed.append(d)
    n_acc = len(committed)
    committed.append(_target_token(target[len(drafts)], temperature, gen))
    return n_acc, committed


def _draft_lazy(dec, heads, head_layers, policy, first_token, pos0, budget, temperature, gen,
                eos_ids, st, trace, context=None):
    """Regime-A drafting. Returns (drafts, q_rows, hidden rows at c_max, c_max).

    With `trace`, records (position, head, logits, exact input context) per head evaluation.
    """
    order = policy.head_order
    c_min, c_max = head_layers[order[0]], head_layers[order[-1]]
    rows: list[dict] = []  # {"pos", "h", "depth"} for every input row of the block

    def advance(target: int) -> None:
        while True:
            lag = [r for r in rows if r["depth"] < target]
            if not lag:
                return
            u = min(r["depth"] for r in lag)
            v = min([r["depth"] for r in lag if r["depth"] > u] + [target])
            batch = [r for r in lag if r["depth"] == u]
            out = dec.run_layers(torch.cat([r["h"] for r in batch]), 0, u, v,
                                 positions=[r["pos"] for r in batch])
            for i, r in enumerate(batch):
                r["h"], r["depth"] = out[i:i + 1], v

    def new_row(tok: int, pos: int) -> dict:
        r = {"pos": pos, "h": dec.embed(torch.tensor([tok], device=dec.device)), "depth": 0}
        rows.append(r)
        return r

    drafts: list[int] = []
    q_rows: list = []
    tok = first_token
    while True:
        row = new_row(tok, pos0 + len(drafts))
        if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids):
            break  # trailing row: only verified
        exited = None
        for k in order:
            advance(head_layers[k])
            logits_k = heads[k](row["h"])[0]
            st.head_evals += 1
            if trace is not None:
                ctx = context + [first_token] + drafts
                trace.append((row["pos"], k, logits_k.detach().clone(), ctx))
            if policy.exit(k, logits_k, len(drafts)):
                exited = k
                break
        if exited is None:
            break
        head = heads[exited]
        q_k, _, _ = head_draft_distribution(logits_k, head.reject, temperature)
        if temperature == 0.0:
            tok_local, q_full = int(q_k.argmax()), None
        else:
            tok_local = _sample(q_k, gen)
            q_full = head.scatter_full(q_k)
        tok = int(head.to_token_ids(torch.tensor(tok_local, device=dec.device)))
        st.exits[exited] = st.exits.get(exited, 0) + 1
        drafts.append(tok)
        q_rows.append(q_full)
        if hasattr(policy, "update_draft"):
            policy.update_draft()
    advance(c_max)
    return drafts, q_rows, torch.cat([r["h"] for r in rows]), c_max