File size: 13,460 Bytes
00855bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The ``Decider``: checkpoint loading, the exact (padded) reference path, and dispatch to the optional fast path."""

from __future__ import annotations

import math
import string
from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, overload

import torch
from safetensors.torch import load_file
from transformers import AutoProcessor
from transformers.masking_utils import create_recurrent_attention_mask
from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5Model

from .protocol import (
    LEGACY_MAX_PIXELS,
    MAX_OPTIONS,
    MIN_PIXELS,
    PROTOCOL,
    Encoded,
    Encoder,
    answer,
    left_pad,
    load_json,
)
from .types import Request, Response

if TYPE_CHECKING:
    from .fast import FastPath

#: Default per-sequence token limit. Longer requests are split into question chunks (each with the full state).
DEFAULT_MAX_LENGTH = 262_144  # backbone limit (max_position_embeddings); training used sequences up to about 32k tokens


@dataclass(frozen=True)
class FastOptions:
    """Settings of the packed fast path (requires CUDA and ``flash-linear-attention``).

    compile:      ``None`` (eager), ``"sub"`` (norms, MLPs and convolution) or ``"layer"`` (whole decoder layers).
    cuda_graphs:  pad every pack to one of ``buckets`` tokens with filler sequences and replay CUDA graphs. Needs
                  ``compile="layer"``. Compiles once per bucket (a few minutes in total, see ``Decider.warmup``).
    buckets:      static pack sizes for ``cuda_graphs``; the largest is also the packing budget.
    token_budget: tokens per packed forward pass without ``cuda_graphs``.
    prune_last:   compute the last layer's attention output and MLP only at the readout markers.
    fp8:          rowwise-scaled FP8 matmuls in the decoder layers. Faster but changes probabilities slightly (see the
                  README). Applies to every path of this ``Decider`` instance.
    prefetch:     tokenize in a background thread while the GPU runs.
    """

    compile: Literal["sub", "layer"] | None = "layer"
    cuda_graphs: bool = True
    buckets: tuple[int, ...] = (2048, 4096, 8192, 16384, 32768)
    token_budget: int = 32768
    prune_last: bool = True
    fp8: bool = False
    prefetch: bool = True

    def budget(self) -> int:
        return max(self.buckets) if self.cuda_graphs else self.token_budget


def enable_noncausal_full_attention(text_model: torch.nn.Module) -> None:
    """Full-attention layers attend to the whole (non-padding) sequence; linear-attention layers stay causal."""

    def mask_inputs(module: torch.nn.Module, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, Any]:
        if args:
            raise ValueError("The noncausal mask hook requires keyword inputs")
        if kwargs.get("past_key_values") is not None or kwargs.get("use_cache"):
            raise ValueError("Noncausal classification does not support a KV cache")
        embeddings = kwargs.get("inputs_embeds")
        if embeddings is None:
            embeddings = module.embed_tokens(kwargs["input_ids"])
        padding = kwargs.get("attention_mask")
        if padding is None:
            padding = torch.ones(embeddings.shape[:2], device=embeddings.device, dtype=torch.bool)
        if isinstance(padding, dict):  # already converted
            return args, kwargs
        kwargs["attention_mask"] = {
            "full_attention": padding[:, None, None, :].bool(),
            "linear_attention": create_recurrent_attention_mask(
                config=module.config, inputs_embeds=embeddings, attention_mask=padding
            ),
        }
        return args, kwargs

    text_model.register_forward_pre_hook(mask_inputs, with_kwargs=True)


def _resolve(path_or_repo: str | Path, revision: str | None, token: str | bool | None) -> Path:
    path = Path(path_or_repo)
    if path.is_dir():
        return path
    from huggingface_hub import snapshot_download

    return Path(snapshot_download(str(path_or_repo), revision=revision, token=token))


class Decider:
    """Answers multi-question decision requests with a protocol-v3 joint checkpoint.

    >>> decider = Decider.from_pretrained("perplexity-ai/<repo>", device="cuda")
    >>> decider.decide({"state": "...", "questions": {"route": {"type": "choice", "criteria": {...}}}})
    """

    def __init__(
        self,
        checkpoint: Path,
        *,
        device: str = "cuda",
        fast: bool | FastOptions = False,
        max_length: int | None = DEFAULT_MAX_LENGTH,
        max_pixels: int | None = None,
        batch_tokens: int = 32768,
        cpu_threads: int = 8,
    ) -> None:
        self.checkpoint = checkpoint
        self.device = torch.device(device)
        protocol = load_json(checkpoint / "joint_config.json") if (checkpoint / "joint_config.json").exists() else {}
        if protocol != PROTOCOL:
            raise ValueError("This checkpoint does not declare the joint inline-readout protocol (version 3)")
        config = load_json(checkpoint / "decision_config.json")
        if config.get("attention_mode") != "noncausal_full_attention" or config.get("pooling", "last") != "last":
            raise ValueError("Expected a noncausal checkpoint with per-position readouts")
        self.temperature = float(config["temperature"])
        if not math.isfinite(self.temperature) or self.temperature <= 0:
            raise ValueError("Saved temperature must be positive and finite")
        self.codes: list[str] = list(config["codes"])
        self.max_length = max_length
        self.batch_tokens = batch_tokens
        self.max_pixels = int(max_pixels or config.get("max_pixels") or LEGACY_MAX_PIXELS)

        torch.set_num_threads(cpu_threads)
        torch.backends.cuda.enable_cudnn_sdp(False)  # the evaluated SDPA kernel choice
        self.processor = AutoProcessor.from_pretrained(str(checkpoint))
        self.processor.tokenizer.padding_side = "left"
        self.processor.image_processor.size = {"shortest_edge": MIN_PIXELS, "longest_edge": self.max_pixels}
        tokenizer = self.processor.tokenizer
        if len(self.codes) != MAX_OPTIONS or any(
            tokenizer.encode(code, add_special_tokens=False) != [token_id]
            for code, token_id in zip(self.codes, config["token_ids"], strict=True)
        ):
            raise ValueError("Checkpoint answer codes differ from its tokenizer")
        if any(not set(code) <= set(string.ascii_uppercase) for code in self.codes):
            raise ValueError("Unexpected answer codes")
        self.encoder = Encoder(self.processor, self.codes)

        dtype = torch.bfloat16 if self.device.type == "cuda" else torch.float32
        self.backbone = Qwen3_5Model.from_pretrained(str(checkpoint), dtype=dtype, attn_implementation="sdpa")
        hidden = self.backbone.config.text_config.hidden_size
        self.readout = torch.nn.Linear(hidden, MAX_OPTIONS, bias=False, dtype=dtype)
        self.readout.load_state_dict(load_file(str(checkpoint / "readout.safetensors")))
        enable_noncausal_full_attention(self.backbone.language_model)
        self.backbone.requires_grad_(False).eval().to(self.device)
        self.readout.requires_grad_(False).eval().to(self.device)

        self.fast: FastPath | None = None
        if fast:
            from .fast import FastPath

            self.fast = FastPath(self, fast if isinstance(fast, FastOptions) else FastOptions())

    @classmethod
    def from_pretrained(
        cls,
        path_or_repo: str | Path,
        *,
        device: str = "cuda",
        fast: bool | FastOptions = False,
        max_length: int | None = DEFAULT_MAX_LENGTH,
        max_pixels: int | None = None,
        revision: str | None = None,
        token: str | bool | None = None,
        **kwargs: Any,
    ) -> Decider:
        """Load a local checkpoint directory or a Hugging Face repository.

        fast:       ``False`` for the reference path (padded batches, exact evaluated computation); ``True`` or a
                    ``FastOptions`` for the packed path (same results up to bf16 noise, several times faster).
        max_length: per-sequence token limit; longer requests are split into question chunks, see the README.
        max_pixels: image pixel cap (default: the checkpoint's ``max_pixels``, else 262,144).
        """
        return cls(_resolve(path_or_repo, revision, token), device=device, fast=fast, max_length=max_length,
                   max_pixels=max_pixels, **kwargs)

    # ------------------------------------------------------------------ public API

    @overload
    def decide(self, requests: Request, *, temperature: float | None = None) -> Response: ...
    @overload
    def decide(self, requests: Sequence[Request], *, temperature: float | None = None) -> list[Response]: ...

    def decide(
        self, requests: Request | Sequence[Request], *, temperature: float | None = None
    ) -> Response | list[Response]:
        """Answer one request (returns a ``Response``) or a list of requests (returns a list, same order).

        Each response holds one answer per question and the token usage. ``temperature`` overrides the calibrated
        temperature saved with the checkpoint (leave it unset for calibrated probabilities).
        """
        single = isinstance(requests, dict)
        batch: list[Request] = [requests] if single else list(requests)  # type: ignore[list-item]
        scale = self.temperature if temperature is None else float(temperature)
        if not math.isfinite(scale) or scale <= 0:
            raise ValueError("Temperature must be positive and finite")
        responses = [self._respond(request, logits, chunks, scale) for request, logits, chunks in self._run(batch)]
        return responses[0] if single else responses

    def logits(self, requests: Sequence[Request]) -> list[dict[str, list[float]]]:
        """Raw readout logits per question (valid options only, before temperature). Useful for ranking."""
        return [
            {key: values for key, values in logits.items()} for _, logits, _ in self._run(list(requests))
        ]

    def warmup(self) -> None:
        """Compile the fast path for every bucket now instead of on first use (no-op for the reference path)."""
        if self.fast is not None:
            self.fast.warmup()

    # ------------------------------------------------------------------ internals

    def _respond(
        self, request: Request, logits: dict[str, list[float]], chunks: list[Encoded], scale: float
    ) -> Response:
        answers = {}
        for key, question in request["questions"].items():
            values = torch.tensor(logits[key], dtype=torch.float32)
            answers[key] = answer(question, (values / scale).softmax(-1).tolist())
        return {
            "answers": answers,
            "usage": {"input_tokens": sum(c.length for c in chunks), "output_tokens": 0, "sequences": len(chunks)},
        }

    def _run(self, requests: list[Request]) -> Iterator[tuple[Request, dict[str, list[float]], list[Encoded]]]:
        if self.fast is not None:
            yield from self.fast.run(requests)
            return
        plans = [self.encoder.plan(request, self.max_length) for request in requests]
        flat = [(index, chunk) for index, plan in enumerate(plans) for chunk in plan]
        results: dict[tuple[int, str], list[float]] = {}
        for group in self._batches(flat):
            for (index, chunk), rows in zip(group, self.reference_logits([c for _, c in group]), strict=True):
                for key, values in zip(chunk.keys, rows, strict=True):
                    results[index, key] = values
        for index, (request, plan) in enumerate(zip(requests, plans, strict=True)):
            yield request, {key: results[index, key] for key in request["questions"]}, plan

    def _batches(self, flat: list[tuple[int, Encoded]]) -> Iterable[list[tuple[int, Encoded]]]:
        """Length-sorted batches whose padded size stays within ``batch_tokens`` (always at least one sequence)."""
        order = sorted(flat, key=lambda item: item[1].length)
        group: list[tuple[int, Encoded]] = []
        for item in order:
            if group and (len(group) + 1) * item[1].length > self.batch_tokens:
                yield group
                group = []
            group.append(item)
        if group:
            yield group

    @torch.inference_mode()
    def reference_logits(self, chunks: Sequence[Encoded]) -> list[list[list[float]]]:
        """Exact evaluated computation: one left-padded batch through the unmodified Transformers backbone."""
        inputs = {key: value.to(self.device) for key, value in left_pad(chunks, self.encoder.pad_id).items()}
        hidden = self.backbone(**inputs, use_cache=False).last_hidden_state
        offsets = [inputs["input_ids"].shape[1] - chunk.length for chunk in chunks]
        rows = torch.cat([torch.full((len(c.keys),), i) for i, c in enumerate(chunks)]).to(self.device)
        cols = torch.cat([c.markers + offsets[i] for i, c in enumerate(chunks)]).to(self.device)
        logits = self.readout(hidden[rows, cols]).float().cpu()
        out, cursor = [], 0
        for chunk in chunks:
            out.append([logits[cursor + j, :count].tolist() for j, count in enumerate(chunk.counts)])
            cursor += len(chunk.keys)
        return out