SAI_375M / generate.py
thongbuind's picture
Release dev3.0.2
f81fbed verified
Raw History Blame Contribute Delete
18 kB
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
import torch
VERSION = "dev3.0.0"
TokenConstraint = Callable[[list[int]], Sequence[int] | None]
@dataclass(slots=True)
class GenerationConfig:
strategy: str = "auto"
max_new_tokens: int = 500
beam_size: int = 5
stable_steps: int | None = 8
temperature: float = 0.8
top_k: int = 0
top_p: float = 0.95
repetition_penalty: float = 1.2
no_repeat_ngram_size: int = 3
length_penalty: float = 1.0
lowercase: bool = True
seed: int | None = None
stop_sequences: list[list[int]] = field(default_factory=list)
@dataclass(slots=True)
class GenerationResult:
text: str
token_ids: list[int]
finish_reason: str
prompt_tokens: int
completion_tokens: int
decode_steps: int
completed_beams: int
def _special_ids(tokenizer):
ids = {
"bos": tokenizer.piece_to_id("[BOS]"),
"eos": tokenizer.piece_to_id("[EOS]"),
"im_start": tokenizer.piece_to_id("<|im_start|>"),
"im_end": tokenizer.piece_to_id("<|im_end|>"),
}
if any(token_id < 0 for token_id in ids.values()):
raise ValueError(f"Tokenizer is missing required special tokens: {ids}")
return ids
def _normalise_messages(user_input, system=None, instruction=None, history=None, messages=None):
if system is not None and instruction is not None:
raise ValueError("Use either system or instruction, not both")
system = system if system is not None else instruction
if messages is not None:
if user_input not in (None, "") or system is not None or history:
raise ValueError("Use either messages or user_input/instruction/history, not both")
source = list(messages)
else:
source = []
if system:
source.append({"role": "system", "content": system})
for turn in history or []:
if "role" in turn:
source.append(turn)
continue
if turn.get("user"):
source.append({"role": "user", "content": turn["user"]})
if turn.get("model"):
source.append({"role": "model", "content": turn["model"]})
if user_input is not None:
source.append({"role": "user", "content": user_input})
result = []
role_map = {"assistant": "model", "model": "model", "user": "user", "system": "system"}
for message in source:
role = role_map.get(str(message.get("role", "")).lower())
content = str(message.get("content", "")).strip()
if role is None or not content:
raise ValueError(f"Invalid chat message: {message!r}")
result.append({"role": role, "content": content})
if not result or result[-1]["role"] != "user":
raise ValueError("The final input message must have role='user'")
return result
def _build_input(user_input, tokenizer, *, max_seq_len, config, system=None, instruction=None, history=None, messages=None):
special = _special_ids(tokenizer)
normalised = _normalise_messages(user_input, system, instruction, history, messages)
def encode_message(message):
text = f"{message['role']}\n{message['content']}"
if config.lowercase:
text = text.lower()
return [special["im_start"]] + tokenizer.encode(text, out_type=int) + [special["im_end"]]
segments = [(message["role"], encode_message(message)) for message in normalised]
assistant_prefix = [special["im_start"]] + tokenizer.encode("model\n", out_type=int)
prompt_budget = max_seq_len - config.max_new_tokens
required = 1 + len(assistant_prefix)
if prompt_budget < required:
raise ValueError("max_new_tokens leaves no room for a prompt")
system_segments = [segment for role, segment in segments if role == "system"]
latest = segments[-1][1]
fixed_len = required + len(latest) + sum(map(len, system_segments))
if fixed_len > prompt_budget:
raise ValueError(
f"System + latest user message need {fixed_len} tokens, "
f"but prompt budget is {prompt_budget}"
)
middle = [(role, segment) for role, segment in segments[:-1] if role != "system"]
turn_groups = []
index = 0
while index < len(middle):
role, segment = middle[index]
group = [segment]
if role == "user" and index + 1 < len(middle) and middle[index + 1][0] == "model":
group.append(middle[index + 1][1])
index += 1
turn_groups.append(group)
index += 1
kept_groups = []
used = fixed_len
for group in reversed(turn_groups):
group_len = sum(map(len, group))
if used + group_len <= prompt_budget:
kept_groups.append(group)
used += group_len
kept_groups.reverse()
input_ids = [special["bos"]]
for segment in system_segments:
input_ids.extend(segment)
for group in kept_groups:
for segment in group:
input_ids.extend(segment)
input_ids.extend(latest)
input_ids.extend(assistant_prefix)
return input_ids, special
def validate_generation_config(config):
"""Báo ValueError nếu giá trị của ``config`` nằm ngoài miền hợp lệ; gọi được trước khi nạp model."""
if config.strategy not in {"auto", "beam", "greedy", "sample"}:
raise ValueError("strategy must be auto, beam, greedy, or sample")
if config.max_new_tokens < 1:
raise ValueError("max_new_tokens must be >= 1")
if config.beam_size < 1:
raise ValueError("beam_size must be >= 1")
if config.stable_steps is not None and config.stable_steps < 1:
raise ValueError("stable_steps must be >= 1 or None")
if config.temperature <= 0:
raise ValueError("temperature must be > 0")
if config.top_k < 0:
raise ValueError("top_k must be >= 0")
if not 0 < config.top_p <= 1:
raise ValueError("top_p must be in (0, 1]")
if config.repetition_penalty <= 0:
raise ValueError("repetition_penalty must be > 0")
if config.no_repeat_ngram_size < 0:
raise ValueError("no_repeat_ngram_size must be >= 0")
if config.length_penalty < 0:
raise ValueError("length_penalty must be >= 0")
def _apply_repetition_penalty(logits, generated, penalty):
if penalty == 1.0 or not generated:
return logits
logits = logits.clone()
token_ids = torch.tensor(list(set(generated)), dtype=torch.long, device=logits.device)
selected = logits.index_select(-1, token_ids)
selected = torch.where(selected < 0, selected * penalty, selected / penalty)
logits.scatter_(-1, token_ids, selected)
return logits
def _banned_ngram_tokens(generated, n):
if n <= 0 or len(generated) < n - 1:
return set()
if n == 1:
return set(generated)
prefix = tuple(generated[-(n - 1):])
return {
generated[index + n - 1]
for index in range(len(generated) - n + 1)
if tuple(generated[index:index + n - 1]) == prefix
}
def _is_stopped(generated, stop_ids, stop_sequences):
if generated and generated[-1] in stop_ids:
return True
return any(
sequence and len(generated) >= len(sequence) and generated[-len(sequence):] == sequence
for sequence in stop_sequences
)
def _decode(generated, tokenizer, stop_ids, stop_sequences):
output = list(generated)
for sequence in stop_sequences:
if sequence and len(output) >= len(sequence) and output[-len(sequence):] == sequence:
del output[-len(sequence):]
break
while output and output[-1] in stop_ids:
output.pop()
return tokenizer.decode(output)
def _beam_score(candidate, length_penalty):
length = max(len(candidate["generated"]), 1)
return candidate["log_prob"] / (length ** length_penalty)
def _process_logits(logits, generated, config, constraint=None):
logits = _apply_repetition_penalty(logits, generated, config.repetition_penalty)
banned = _banned_ngram_tokens(generated, config.no_repeat_ngram_size)
if banned:
logits = logits.clone()
logits[list(banned)] = -torch.inf
if constraint is not None:
allowed = constraint(generated)
if allowed is not None:
allowed = list(allowed)
if not allowed:
raise ValueError("Token constraint returned an empty allowed set")
constrained = torch.full_like(logits, -torch.inf)
constrained[allowed] = logits[allowed]
logits = constrained
return logits
def _filter_sampling_logits(logits, config):
logits = logits / config.temperature
if config.top_k > 0:
threshold = torch.topk(logits, min(config.top_k, logits.numel())).values[-1]
logits = logits.masked_fill(logits < threshold, -torch.inf)
if config.top_p < 1:
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
probabilities = torch.softmax(sorted_logits, dim=-1)
remove = probabilities.cumsum(dim=-1) > config.top_p
remove[1:] = remove[:-1].clone()
remove[0] = False
logits = logits.clone()
logits[sorted_indices[remove]] = -torch.inf
return logits
def _single_path_generate(model, input_ids, tokenizer, special, config, strategy, constraint):
device = model.lm_head.weight.device
stop_ids = {special["eos"], special["im_end"]}
stop_sequences = [list(sequence) for sequence in config.stop_sequences]
prompt_len = len(input_ids)
cache = model.init_cache(1, min(model.max_seq_len, prompt_len + config.max_new_tokens), device)
prompt = torch.tensor([input_ids], dtype=torch.long, device=device)
generator = None
if config.seed is not None:
generator = torch.Generator(device=device).manual_seed(config.seed)
with torch.inference_mode():
logits, _ = model.prefill(prompt, kv_cache=cache)
generated = []
finish_reason = "length"
decode_steps = 0
for step in range(config.max_new_tokens):
row = _process_logits(logits[0], generated, config, constraint)
if strategy == "greedy":
token = int(torch.argmax(row).item())
else:
row = _filter_sampling_logits(row, config)
token = int(torch.multinomial(torch.softmax(row, dim=-1), 1, generator=generator).item())
generated.append(token)
if _is_stopped(generated, stop_ids, stop_sequences):
finish_reason = "stop"
break
if step + 1 < config.max_new_tokens:
last = torch.tensor([[token]], dtype=torch.long, device=device)
logits = model.decode_step(last, cache, prompt_len + step)
decode_steps += 1
return GenerationResult(
text=_decode(generated, tokenizer, stop_ids, stop_sequences),
token_ids=generated,
finish_reason=finish_reason,
prompt_tokens=prompt_len,
completion_tokens=len(generated),
decode_steps=decode_steps,
completed_beams=0,
)
def _beam_generate(model, input_ids, tokenizer, special, config):
device = model.lm_head.weight.device
stop_ids = {special["eos"], special["im_end"]}
stop_sequences = [list(sequence) for sequence in config.stop_sequences]
prompt_len = len(input_ids)
cache = model.init_cache(
config.beam_size,
min(model.max_seq_len, prompt_len + config.max_new_tokens),
device,
)
prompt = torch.tensor([input_ids], dtype=torch.long, device=device)
with torch.inference_mode():
first_logits, present = model.prefill(prompt, kv_cache=None)
for layer, (key, value) in enumerate(present):
cache[layer][0][:, :, :prompt_len].copy_(key.expand(config.beam_size, -1, -1, -1))
cache[layer][1][:, :, :prompt_len].copy_(value.expand(config.beam_size, -1, -1, -1))
first_log_probs = torch.log_softmax(first_logits[0], dim=-1)
top_log_probs, top_tokens = torch.topk(
first_log_probs,
min(config.beam_size, first_log_probs.numel()),
)
alive, completed = [], []
for log_prob, token in zip(top_log_probs.tolist(), top_tokens.tolist()):
candidate = {"generated": [int(token)], "log_prob": float(log_prob), "source": 0}
target = completed if _is_stopped(candidate["generated"], stop_ids, stop_sequences) else alive
target.append(candidate)
cache_len = prompt_len
decode_steps = 0
stable_count = 0
last_best_completed = None
finish_reason = "length"
while alive and max(len(item["generated"]) for item in alive) < config.max_new_tokens:
last_tokens = torch.tensor(
[[candidate["generated"][-1]] for candidate in alive],
dtype=torch.long,
device=device,
)
logits = model.decode_step(last_tokens, cache, cache_len)
cache_len += 1
decode_steps += 1
candidates = []
for beam_index, candidate in enumerate(alive):
row = _process_logits(logits[beam_index], candidate["generated"], config)
log_probs = torch.log_softmax(row, dim=-1)
values, tokens = torch.topk(log_probs, min(config.beam_size, log_probs.numel()))
for value, token in zip(values.tolist(), tokens.tolist()):
candidates.append({
"generated": candidate["generated"] + [int(token)],
"log_prob": candidate["log_prob"] + float(value),
"source": beam_index,
})
candidates.sort(
key=lambda item: _beam_score(item, config.length_penalty),
reverse=True,
)
next_alive = []
for candidate in candidates:
if _is_stopped(candidate["generated"], stop_ids, stop_sequences):
completed.append(candidate)
elif len(next_alive) < config.beam_size:
next_alive.append(candidate)
if len(next_alive) >= config.beam_size and len(completed) >= config.beam_size:
break
if completed:
best_completed_score = max(
_beam_score(item, config.length_penalty) for item in completed
)
if last_best_completed is None or best_completed_score > last_best_completed + 1e-12:
last_best_completed = best_completed_score
stable_count = 0
else:
stable_count += 1
if (
config.stable_steps is not None
and len(completed) >= config.beam_size
and stable_count >= config.stable_steps
):
best_alive_score = max(
(_beam_score(item, config.length_penalty) for item in next_alive),
default=float("-inf"),
)
if best_completed_score >= best_alive_score:
finish_reason = "stable_beam"
alive = next_alive
break
if not next_alive:
finish_reason = "all_beams_finished"
break
sources_list = [candidate["source"] for candidate in next_alive]
if sources_list != list(range(len(sources_list))):
sources = torch.tensor(sources_list, dtype=torch.long, device=device)
for key, value in cache:
selected_key = key.index_select(0, sources)[:, :, :cache_len].clone()
selected_value = value.index_select(0, sources)[:, :, :cache_len].clone()
key[:len(next_alive), :, :cache_len].copy_(selected_key)
value[:len(next_alive), :, :cache_len].copy_(selected_value)
alive = next_alive
pool = completed + alive
if not pool:
return GenerationResult("", [], "empty", prompt_len, 0, decode_steps, len(completed))
best = max(pool, key=lambda item: _beam_score(item, config.length_penalty))
return GenerationResult(
text=_decode(best["generated"], tokenizer, stop_ids, stop_sequences),
token_ids=list(best["generated"]),
finish_reason=finish_reason,
prompt_tokens=prompt_len,
completion_tokens=len(best["generated"]),
decode_steps=decode_steps,
completed_beams=len(completed),
)
def generate(model, user_input=None, tokenizer=None, *, messages=None, system=None, instruction=None, history=None, config=None, constraint: TokenConstraint | None = None, return_result=False):
"""Generate a response with beam search, greedy decoding, or sampling."""
if tokenizer is None:
raise ValueError("tokenizer is required")
config = config or GenerationConfig()
validate_generation_config(config)
strategy = config.strategy
if strategy == "auto":
strategy = "greedy" if constraint is not None else "beam"
if strategy == "beam" and constraint is not None:
raise ValueError("Beam strategy does not support token constraints")
input_ids, special = _build_input(
user_input,
tokenizer,
max_seq_len=model.max_seq_len,
config=config,
system=system,
instruction=instruction,
history=history,
messages=messages,
)
if strategy == "beam":
result = _beam_generate(model, input_ids, tokenizer, special, config)
else:
result = _single_path_generate(
model,
input_ids,
tokenizer,
special,
config,
strategy,
constraint,
)
return result if return_result else result.text