Spaces:
Sleeping
Sleeping
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
|