File size: 9,065 Bytes
28a1a01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cb6615
 
 
 
 
 
 
 
28a1a01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""True batched decode on one weight stream (dense Llama/Qwen3 first).

Wraps a single native ``Model`` with :class:`BatchEngine.generate_batch`,
which runs ``B`` sequences through one shared weight stream via the native
``BatchSession`` (``Model.create_batch_session``):

- per-sequence prompt / position / generated / rng / eos / finish state,
- concatenated ``X:[sumT, hidden]`` per-layer projections via a single
  ``gemm`` per projection (weights stream once for all ``B`` sequences),
- per-sequence attention and sampling loops.

For ``sumT == 1`` the native kernels run in the same order as the
single-sequence ``Session`` path, giving bitwise-identical results.
Surjo hybrid batching is explicitly out of scope (``from_weights`` rejects
``model_type == "surjo"``).
"""

from __future__ import annotations

import math
import threading
from typing import Any

from .engine import GenerationResult


class BatchEngine:
    """One native dense model serving true batched generations.

    Unlike :class:`cism.pool.EnginePool` (``N`` engines, ``N`` weight
    streams), this holds a single native ``Model``: each
    :meth:`generate_batch` call builds one native ``BatchSession`` whose
    decode steps stream each weight matrix once for all ``B`` sequences.
    Calls are serialized with an internal lock; the native session already
    owns its mutex, but the shared ``WorkerPool`` barrier requires one
    active generation at a time for deterministic results.
    """

    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._lock = threading.Lock()
        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_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") -> BatchEngine:
        from .loader import validate_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":
            raise ValueError("batch decode is dense-only in this version (Surjo later)")
        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", "batching"],
                "batching": "single-weight-stream BatchSession (dense only)"}

    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 batch prompts")
        rendered = self.tokenizer.apply_chat_template(
            messages, tokenize=True, add_generation_prompt=True)
        # Bare ID list for most tokenizers; a BatchEncoding
        # ({"input_ids", "attention_mask"}) when the tokenizer's __call__
        # returns one.
        if not isinstance(rendered, list):
            rendered = rendered["input_ids"]
        return self.encode(list(rendered))

    def generate_batch(self, prompts: list[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) -> list[GenerationResult]:
        """Generate ``B`` prompts in one weight-stream batch, preserving order.

        Each prompt is encoded (with BOS fallback for empty prompts), then all
        ``B`` sequences decode together: prefill concatenates every prompt
        into one ``[sumT, hidden]`` activation batch (one ``gemm`` per
        projection), and each decode step concatenates one token per
        unfinished sequence the same way. Sampling and attention stay
        per-sequence, so ``B=1`` is bitwise-identical to serial decoding and
        ``batch[i]`` matches a lone ``B=1`` run of ``prompts[i]`` exactly
        (same ``seed`` seeds sequence ``i`` as ``seed + i``; sequence 0 always
        uses ``seed``).

        ``stop`` is an optional list of nonempty strings applied per sequence
        after decoding (first occurrence wins, ``finish_reason="stop"``,
        token IDs retained), mirroring ``Engine.stream`` truncation for
        completed batch text. ``None`` (default) disables stop handling and
        preserves the exact pre-stop behavior.
        """
        if not isinstance(prompts, (list, tuple)):
            raise TypeError("prompts must be a list or tuple")
        prompt_list = list(prompts)
        if len(prompt_list) == 0:
            return []
        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 [])
        encoded = [self.encode(p) for p in prompt_list]
        for tokens in encoded:
            if len(tokens) > self.context_length:
                raise ValueError("Prompt length exceeds the model context length")
        # Shared native max_new must fit every sequence; clamp to the smallest
        # remaining window (prompts of equal length -- the common benchmark
        # shape -- are unaffected).
        effective = min([max_new_tokens] + [self.context_length - len(t) for t in encoded])
        with self._lock:
            session = self._native.create_batch_session(
                encoded, max_new_tokens=effective,
                temperature=temperature, top_p=top_p, top_k=top_k,
                seed=seed, eos_token_ids=self._eos)
            batched = session.next_tokens(effective)
            reasons = session.finish_reasons
            # generated_tokens property returns per-sequence counts.
            _generated = session.generated_tokens
        results: list[GenerationResult] = []
        for tokens, token_ids, reason in zip(encoded, batched, reasons):
            text = self.tokenizer.decode(list(token_ids), skip_special_tokens=True,
                                         clean_up_tokenization_spaces=False)
            if stops:
                hits = [text.find(s) for s in stops if s in text]
                if hits:
                    text = text[:min(hits)]
                    reason = "stop"
            results.append(GenerationResult(text, list(token_ids), reason,
                                            len(tokens), len(token_ids)))
        return results