File size: 18,162 Bytes
92dcc4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28a1a01
92dcc4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cb6615
 
 
 
 
 
 
 
92dcc4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Python request boundary; all model arithmetic and sampling stay in C++."""

from __future__ import annotations

from dataclasses import dataclass
import math
import threading
from typing import Any, Iterator


@dataclass(frozen=True)
class GenerationChunk:
    text: str
    token_ids: list[int]
    finish_reason: str | None
    prompt_tokens: int
    completion_tokens: int
    spec_proposed: int = 0
    spec_accepted: int = 0


@dataclass(frozen=True)
class GenerationResult:
    text: str
    token_ids: list[int]
    finish_reason: str
    prompt_tokens: int
    completion_tokens: int
    spec_proposed: int = 0
    spec_accepted: int = 0


class Engine:
    """One loaded native model with serialized generation requests.



    ``from_pretrained`` imports safetensors directly, without loading a Python

    model. ``from_weights`` is useful for reference tests and local research.

    """

    def __init__(self, native: Any, tokenizer: Any, config: dict, model_id: str,

                 *, revision: str | None = None) -> None:
        self._native = native
        self.tokenizer = tokenizer
        self.config = dict(config)
        self.model_id = model_id
        self.revision = revision
        self.context_length = config["max_position_embeddings"]
        self._generation_lock = threading.Lock()
        self._keep_warm_stop = threading.Event()
        self._keep_warm_thread: threading.Thread | None = None
        self._keep_warm_error: BaseException | None = None
        eos = config.get("eos_token_id", getattr(tokenizer, "eos_token_id", None))
        self._eos = [] if eos is None else eos if isinstance(eos, list) else [eos]
        for token in self._eos:
            self._validate_token(token)

    @classmethod
    def from_pretrained(cls, model: str, *, precision: str = "fp32",

                        revision: str | None = None, trust_remote_code: bool = False,

                        local_files_only: bool = False, cache_dir: str | None = None,

                        threads: int = 1, act_precision: str = "fp32") -> Engine:
        from .loader import import_model

        loaded = import_model(model, revision=revision, trust_remote_code=trust_remote_code,
                              local_files_only=local_files_only, cache_dir=cache_dir)
        return cls.from_weights(loaded.config, loaded.weights, loaded.tokenizer,
                                model_id=model, precision=precision, revision=loaded.revision,
                                threads=threads, act_precision=act_precision)

    @classmethod
    def from_weights(cls, config: dict, weights: dict, tokenizer: Any, *,

                     model_id: str = "local", precision: str = "fp32",

                     revision: str | None = None, threads: int = 1,

                     act_precision: str = "fp32") -> Engine:
        from .loader import validate_config, validate_fwkv_config, validate_surjo_config
        try:
            from . import _native
        except ImportError as exc:
            raise RuntimeError("CISM native extension is missing. Build with: python -m pip install .") from exc
        if isinstance(config, dict) and config.get("model_type") == "surjo":
            normalized = validate_surjo_config(config)
            return cls(_native.SurjoModel(normalized, weights, precision, threads, act_precision),
                       tokenizer, normalized, model_id, revision=revision)
        if isinstance(config, dict) and config.get("model_type") == "fwkv":
            normalized = validate_fwkv_config(config)
            return cls(_native.FwkvModel(normalized, weights, precision, threads,
                                         act_precision),
                       tokenizer, normalized, model_id, revision=revision)
        normalized = validate_config(config)
        return cls(_native.Model(normalized, weights, precision, threads, act_precision), tokenizer, normalized,
                   model_id, revision=revision)

    @property
    def info(self) -> dict:
        return {**self._native.info, "model": self.model_id, "revision": self.revision,
                "context_length": self.context_length, "architecture": self.config["model_type"],
                "capabilities": ["text", "streaming", "sampling"],
                "keep_warm": self.keep_warm_active}

    def lock_pages(self) -> int:
        """Pin weight pages in physical RAM (no OS paging) until unlock_pages.



        This cannot pin CPU cache lines; hardware decides those. Returns the

        number of bytes the OS accepted; a zero return means locking failed.

        """
        with self._generation_lock:
            return self._native.lock_pages()

    def unlock_pages(self) -> int:
        with self._generation_lock:
            return self._native.unlock_pages()

    def touch(self) -> int:
        """Run one immediate cache-residency sweep over all weight bytes."""
        with self._generation_lock:
            return self._native.touch()

    def scan(self) -> int:
        """Fast full-weight read for bandwidth probes; returns bytes scanned."""
        with self._generation_lock:
            return self._native.scan()

    def start_keep_warm(self, interval: float = 2.0) -> None:
        """Refresh weight cache residency every ``interval`` seconds while idle.



        Stops automatically during generation and resumes afterward. Cache

        lines evict naturally once stop_keep_warm is called; there is no way to

        forbid hardware eviction on x86.

        """
        if type(interval) not in (int, float) or not math.isfinite(interval) or not 0 < interval <= 3600:
            raise ValueError("interval must be a duration in seconds in (0, 3600]")
        if self._keep_warm_thread is not None:
            raise RuntimeError("keep-warm is already running")
        self._keep_warm_error = None
        self._keep_warm_stop.clear()

        def sweep() -> None:
            while not self._keep_warm_stop.wait(interval):
                # Never compete with an active generation for cache or lock.
                if not self._generation_lock.acquire(blocking=False):
                    continue
                try:
                    self._native.touch()
                except BaseException as error:
                    self._keep_warm_error = error
                    return
                finally:
                    self._generation_lock.release()

        self._keep_warm_thread = threading.Thread(target=sweep, name="cism-keep-warm", daemon=True)
        self._keep_warm_thread.start()

    def stop_keep_warm(self) -> None:
        thread = self._keep_warm_thread
        if thread is None:
            return
        self._keep_warm_stop.set()
        thread.join()
        self._keep_warm_thread = None

    @property
    def keep_warm_active(self) -> bool:
        thread = self._keep_warm_thread
        return thread is not None and thread.is_alive()

    @property
    def keep_warm_error(self) -> BaseException | None:
        return self._keep_warm_error

    def _validate_token(self, token: int) -> None:
        if type(token) is not int or not 0 <= token < self.config["vocab_size"]:
            raise ValueError("Token IDs must be integers within the model vocabulary")

    def encode(self, prompt: str | list[int]) -> list[int]:
        if isinstance(prompt, str):
            tokens = list(self.tokenizer.encode(prompt, add_special_tokens=True))
        elif isinstance(prompt, list):
            tokens = prompt.copy()
        else:
            raise TypeError("prompt must be text or a list of token IDs")
        if not tokens:
            bos = self.config.get("bos_token_id", getattr(self.tokenizer, "bos_token_id", None))
            if bos is None:
                raise ValueError("Empty prompt requires a BOS token")
            tokens = [bos]
        for token in tokens:
            self._validate_token(token)
        return tokens

    def format_chat(self, messages: list[dict]) -> list[int]:
        if not getattr(self.tokenizer, "chat_template", None):
            raise ValueError("This tokenizer has no chat template; use /v1/completions")
        rendered = self.tokenizer.apply_chat_template(
            messages, tokenize=True, add_generation_prompt=True)
        # Most tokenizers return a bare list of IDs, but one whose __call__
        # yields a BatchEncoding ({"input_ids", "attention_mask"}) makes
        # apply_chat_template return that dict instead.
        if not isinstance(rendered, list):
            rendered = rendered["input_ids"]
        return self.encode(list(rendered))

    def logits(self, prompt: str | list[int]):
        """Return last-position FP32 logits for numerical evaluation."""
        with self._generation_lock:
            return self._native.logits(self.encode(prompt))

    def nll(self, prompt: str | list[int], window: int = 128) -> dict:
        """Teacher-forced mean NLL and perplexity over text or token IDs.



        Scores in truncated windows of at most ``window`` tokens (each window

        starts with empty context, mirroring HF sliding-window PPL without

        overlap). Windows are scored by the native batched pass, so results

        are deterministic for a given precision.

        """
        if type(window) is not int or window < 2:
            raise ValueError("window must be an integer >= 2")
        tokens = self.encode(prompt)
        if len(tokens) < 2:
            raise ValueError("scoring requires at least 2 tokens")
        limit = min(window, self.context_length)
        if limit < 2:
            raise ValueError("model context is too small for scoring")
        total, count = 0.0, 0
        step = limit - 1
        for start in range(0, len(tokens) - 1, step):
            # +1 tail token so every window ends with a scored target.
            chunk = tokens[start:start + limit]
            scored = len(chunk) - 1
            with self._generation_lock:
                total += self._native.nll(chunk) * scored
            count += scored
        mean = total / count
        try:
            perplexity = math.exp(mean)
        except OverflowError:
            perplexity = float("inf")
        return {"nll": mean, "perplexity": perplexity, "scored_tokens": count}

    def stream(self, prompt: str | list[int], *, max_new_tokens: int = 128,

               temperature: float = 0.0, top_p: float = 1.0, top_k: int = 0,

               seed: int = 0, stop: list[str] | None = None,

               cancel: threading.Event | None = None, chunk_size: int = 16,

               spec_k: int = 0) -> Iterator[GenerationChunk]:
        if type(chunk_size) is not int or not 1 <= chunk_size <= 1024:
            raise ValueError("chunk_size must be an integer in [1, 1024]")
        if type(spec_k) is not int or spec_k != 0 and not 2 <= spec_k <= 16:
            raise ValueError("spec_k must be 0 (off) or an integer in [2, 16]")
        if spec_k >= 2 and temperature != 0:
            # Greedy verification is exact only for temperature=0; sampled
            # speculation would need rejection sampling and is not implemented.
            raise ValueError("spec_k requires temperature=0 (greedy) in this version")
        if type(max_new_tokens) is not int or max_new_tokens < 0:
            raise ValueError("max_new_tokens must be a nonnegative integer")
        if type(seed) is not int or not 0 <= seed < 2**64:
            raise ValueError("seed must be an unsigned 64-bit integer")
        if type(top_k) is not int or top_k < 0:
            raise ValueError("top_k must be a nonnegative integer")
        for name, value in (("temperature", temperature), ("top_p", top_p)):
            if type(value) not in (float, int) or not math.isfinite(value):
                raise ValueError(f"{name} must be finite")
        if temperature < 0 or not 0 < top_p <= 1:
            raise ValueError("temperature must be nonnegative and top_p in (0, 1]")
        if stop is not None and (not isinstance(stop, list) or any(not isinstance(s, str) or not s for s in stop)):
            raise ValueError("stop must be a list of nonempty strings")
        stops = list(stop or [])
        tokens = self.encode(prompt)
        if len(tokens) > self.context_length:
            raise ValueError("Prompt length exceeds the model context length")
        # An over-budget generation fills the remaining context window instead
        # of failing; only a prompt that alone exceeds the context is invalid.
        max_new_tokens = min(max_new_tokens, self.context_length - len(tokens))
        cancel = cancel if cancel is not None else threading.Event()
        while not self._generation_lock.acquire(timeout=0.05):
            if cancel.is_set():
                return
        session = None
        monitor_done = threading.Event()
        monitor = None
        monitor_started = False
        released = False

        def cleanup() -> None:
            nonlocal released
            if released:
                return
            try:
                monitor_done.set()
                if session is not None:
                    session.cancel()
                if monitor_started:
                    monitor.join()
            finally:
                released = True
                self._generation_lock.release()

        try:
            if cancel.is_set():
                return
            session = self._native.create_session(tokens, max_new_tokens=max_new_tokens,
                                                 temperature=temperature, top_p=top_p,
                                                 top_k=top_k, seed=seed, eos_token_ids=self._eos)

            def watch_cancel() -> None:
                # Native prefill releases the GIL, allowing cancellation even
                # before the first token is produced. No per-token callback.
                while not monitor_done.wait(0.01):
                    if cancel.is_set():
                        session.cancel()
                        return

            monitor = threading.Thread(target=watch_cancel, name="cism-cancel", daemon=True)
            monitor.start()
            monitor_started = True
            generated: list[int] = []
            emitted = ""
            first = True
            total_proposed, total_accepted = 0, 0
            while True:
                if cancel.is_set():
                    session.cancel()
                    return
                # Stop strings require token-at-a-time checks, so sampling never
                # advances beyond the stopping token. Otherwise amortize bindings.
                batch = session.next_tokens(1 if first or stops else chunk_size,
                                            spec_k=spec_k if not first and not stops else 0)
                # Duck-typed sessions (tests, third-party backends) predate spec
                # tallies and report zeros; native sessions provide real counts.
                proposed, accepted = getattr(session, "spec_stats", (0, 0))
                chunk_proposed, chunk_accepted = proposed - total_proposed, accepted - total_accepted
                total_proposed, total_accepted = proposed, accepted
                first = False
                generated.extend(batch)
                reason = session.finish_reason or None
                if reason == "cancelled":
                    return
                text = self.tokenizer.decode(generated, skip_special_tokens=True,
                                             clean_up_tokenization_spaces=False)
                if reason is None:
                    text = text.rstrip("\ufffd")
                matches = [text.find(s) for s in stops if s in text]
                if matches:
                    text = text[:min(matches)]
                    reason = "stop"
                    session.cancel()
                elif reason is None:
                    hold = 0
                    for sequence in stops:
                        for size in range(1, min(len(sequence), len(text) + 1)):
                            if text.endswith(sequence[:size]):
                                hold = max(hold, size)
                    if hold:
                        text = text[:-hold]
                if not text.startswith(emitted):
                    raise ValueError("Tokenizer decoding rewrote emitted text; incremental streaming is unsupported")
                delta = text[len(emitted):]
                emitted = text
                # A client may stop iterating as soon as it sees finish_reason.
                # Release resources before publishing that terminal event.
                if reason is not None:
                    cleanup()
                yield GenerationChunk(delta, batch, reason, len(tokens), len(generated),
                                      chunk_proposed, chunk_accepted)
                if reason is not None:
                    break
                if not batch:
                    raise RuntimeError("Native decoder made no progress")
        finally:
            cleanup()

    def generate(self, prompt: str | list[int], **kwargs: Any) -> GenerationResult:
        text: list[str] = []
        tokens: list[int] = []
        proposed, accepted = 0, 0
        last = None
        for chunk in self.stream(prompt, **kwargs):
            text.append(chunk.text)
            tokens.extend(chunk.token_ids)
            proposed += chunk.spec_proposed
            accepted += chunk.spec_accepted
            last = chunk
        if last is None or last.finish_reason is None:
            raise RuntimeError("Generation was cancelled")
        return GenerationResult("".join(text), tokens, last.finish_reason,
                                last.prompt_tokens, last.completion_tokens, proposed, accepted)