| |
| """Standalone chat inference for kerzgrr/Monostich-2 (SFT). |
| |
| Downloads model assets from the Hub (cached after first run), auto-installs |
| flash-linear-attention when needed, and streams ChatML assistant replies. |
| |
| Examples: |
| python inference.py --prompt "What is the capital of France?" |
| python inference.py |
| python inference.py --prompt "Write a haiku about GPUs" --temperature 0.7 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import platform |
| import shutil |
| import subprocess |
| import sys |
| import time |
| import warnings |
| from pathlib import Path |
|
|
| import torch |
| from safetensors.torch import load_file |
| from tokenizers import Tokenizer |
|
|
|
|
| def _silence_runtime_warnings() -> None: |
| patterns = ( |
| r"tl\.make_block_ptr is deprecated", |
| r"Memory efficient kernel not used because", |
| r"Memory Efficient attention has been runtime disabled", |
| r"Flash attention kernel not used because", |
| r"Torch was not compiled with flash attention", |
| r"cuDNN attention kernel not used because", |
| r"cuDNN attention has been runtime disabled", |
| ) |
| for pattern in patterns: |
| warnings.filterwarnings("ignore", message=pattern) |
|
|
|
|
| REPO_ID = "kerzgrr/Monostich-2" |
| FLA_COMMIT = "cbb0a72efb55c18ca0ef4f298298317573ad2cb3" |
| FLA_REPO = "https://github.com/fla-org/flash-linear-attention.git" |
| PATCH_FILES = ( |
| "fla/__init__.py", |
| "fla/ops/__init__.py", |
| "fla/layers/__init__.py", |
| "fla/ops/simple_gla/__init__.py", |
| ) |
|
|
|
|
| def _download(filename: str, local_dir: Path | None) -> Path: |
| from huggingface_hub import hf_hub_download |
|
|
| return Path( |
| hf_hub_download( |
| repo_id=REPO_ID, |
| filename=filename, |
| local_dir=str(local_dir) if local_dir else None, |
| ) |
| ) |
|
|
|
|
| def _run(cmd: list[str], *, cwd: Path | None = None, env: dict | None = None) -> None: |
| print("+", " ".join(cmd), flush=True) |
| merged = os.environ.copy() |
| if env: |
| merged.update(env) |
| merged.setdefault("PYTHONUTF8", "1") |
| merged.setdefault("PYTHONIOENCODING", "utf-8") |
| subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=merged) |
|
|
|
|
| def _pip_install(*args: str) -> None: |
| _run([sys.executable, "-m", "pip", "install", *args]) |
|
|
|
|
| def _fla_importable() -> tuple[bool, str]: |
| try: |
| from fla.layers.gdn2 import GatedDeltaNet2 |
| except Exception as error: |
| return False, str(error) |
| return True, "" |
|
|
|
|
| def _cache_root() -> Path: |
| override = os.environ.get("MONOSTICH_CACHE") |
| if override: |
| path = Path(override).expanduser().resolve() |
| else: |
| path = Path.home() / ".cache" / "monostich-2" |
| path.mkdir(parents=True, exist_ok=True) |
| return path |
|
|
|
|
| def _ensure_git() -> None: |
| if shutil.which("git") is None: |
| raise RuntimeError( |
| "git is required to auto-install flash-linear-attention. " |
| "Install Git and ensure it is on PATH." |
| ) |
|
|
|
|
| def _apply_windows_fla_patches(fla_root: Path, local_dir: Path | None) -> None: |
| print("Applying Windows FLA import patches from the Hub …", flush=True) |
| for relative in PATCH_FILES: |
| source = _download(f"windows_fla_patches/{relative}", local_dir) |
| target = fla_root / relative |
| target.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(source, target) |
| print(f" patched {relative}", flush=True) |
|
|
|
|
| def _install_fla(local_dir: Path | None) -> None: |
| print("flash-linear-attention missing/broken — installing automatically …", flush=True) |
| _pip_install("einops", "numpy") |
| if platform.system() != "Windows": |
| _pip_install("--no-deps", f"git+{FLA_REPO}@{FLA_COMMIT}") |
| return |
|
|
| _ensure_git() |
| fla_root = _cache_root() / "flash-linear-attention" |
| if (fla_root / ".git").is_dir(): |
| _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root) |
| _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root) |
| else: |
| if fla_root.exists(): |
| shutil.rmtree(fla_root) |
| _run(["git", "clone", "--filter=blob:none", FLA_REPO, str(fla_root)]) |
| _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root) |
| _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root) |
|
|
| _apply_windows_fla_patches(fla_root, local_dir) |
| _pip_install("--no-build-isolation", "--no-deps", "-e", str(fla_root)) |
|
|
|
|
| def _ensure_fla(local_dir: Path | None) -> None: |
| ok, error = _fla_importable() |
| if ok: |
| return |
| print(f"FLA not ready ({error})", flush=True) |
| try: |
| _install_fla(local_dir) |
| except Exception as install_error: |
| raise RuntimeError( |
| "Automatic flash-linear-attention install failed.\n" |
| f"Original import error: {error}\n" |
| f"Install error: {install_error}" |
| ) from install_error |
|
|
| for name in list(sys.modules): |
| if name == "fla" or name.startswith("fla."): |
| del sys.modules[name] |
|
|
| ok, error = _fla_importable() |
| if not ok: |
| raise RuntimeError( |
| "flash-linear-attention installed but still failed to import " |
| f"GatedDeltaNet2: {error}" |
| ) |
| print("flash-linear-attention ready.", flush=True) |
|
|
|
|
| def _ensure_tiny_gdn(local_dir: Path | None) -> Path: |
| here = Path(__file__).resolve().parent |
| if (here / "tiny_gdn" / "__init__.py").is_file(): |
| return here |
| if local_dir and (local_dir / "tiny_gdn" / "__init__.py").is_file(): |
| return local_dir |
| for name in ("tiny_gdn/__init__.py", "tiny_gdn/config.py", "tiny_gdn/model.py"): |
| _download(name, local_dir) |
| return _download("tiny_gdn/__init__.py", local_dir).parent.parent |
|
|
|
|
| def _sample( |
| logits: torch.Tensor, |
| *, |
| temperature: float, |
| top_p: float, |
| top_k: int, |
| generator: torch.Generator, |
| ) -> int: |
| logits = logits.float() |
| if temperature <= 1e-5: |
| return int(torch.argmax(logits).item()) |
| logits = logits / temperature |
| if 0 < top_k < logits.shape[-1]: |
| threshold = torch.topk(logits, top_k).values[-1] |
| logits = logits.masked_fill(logits < threshold, -torch.inf) |
| if top_p < 1.0: |
| sorted_logits, sorted_indices = torch.sort(logits, descending=True) |
| probs = torch.softmax(sorted_logits, dim=-1) |
| remove = torch.cumsum(probs, dim=-1) > top_p |
| remove[1:] = remove[:-1].clone() |
| remove[0] = False |
| sorted_logits = sorted_logits.masked_fill(remove, -torch.inf) |
| logits = torch.full_like(logits, -torch.inf) |
| logits.scatter_(0, sorted_indices, sorted_logits) |
| probs = torch.softmax(logits, dim=-1) |
| return int(torch.multinomial(probs, 1, generator=generator).item()) |
|
|
|
|
| def _apply_repetition_penalty( |
| logits: torch.Tensor, |
| token_ids: list[int], |
| penalty: float, |
| window: int, |
| ) -> torch.Tensor: |
| if penalty == 1.0 or not token_ids: |
| return logits |
| recent = token_ids[-window:] if window > 0 else token_ids |
| unique = torch.tensor(list(set(recent)), dtype=torch.long, device=logits.device) |
| score = logits[unique] |
| logits[unique] = torch.where(score > 0, score / penalty, score * penalty) |
| return logits |
|
|
|
|
| def encode_chat(tokenizer: Tokenizer, messages: list[dict[str, str]]) -> list[int]: |
| bos = tokenizer.token_to_id("<|begin_of_text|>") |
| im_start = tokenizer.token_to_id("<|im_start|>") |
| im_end = tokenizer.token_to_id("<|im_end|>") |
| if bos is None or im_start is None or im_end is None: |
| raise RuntimeError("Tokenizer missing ChatML specials") |
| ids = [bos] |
| newline = tokenizer.encode("\n", add_special_tokens=False).ids |
| for message in messages: |
| role = message["role"] |
| content = message["content"] |
| ids.append(im_start) |
| ids.extend(tokenizer.encode(f"{role}\n", add_special_tokens=False).ids) |
| ids.extend(tokenizer.encode(content, add_special_tokens=False).ids) |
| ids.append(im_end) |
| ids.extend(newline) |
| ids.append(im_start) |
| ids.extend(tokenizer.encode("assistant\n", add_special_tokens=False).ids) |
| return ids |
|
|
|
|
| @torch.inference_mode() |
| def generate( |
| model, |
| tokenizer: Tokenizer, |
| prompt_ids: list[int], |
| *, |
| max_new_tokens: int, |
| context_length: int, |
| temperature: float, |
| top_p: float, |
| top_k: int, |
| repetition_penalty: float, |
| repetition_window: int, |
| seed: int, |
| stream: bool, |
| device: torch.device, |
| ) -> tuple[str, int, str]: |
| eos_id = int(model.config.eos_token_id) |
| im_end = tokenizer.token_to_id("<|im_end|>") |
| stop = {eos_id} |
| if im_end is not None: |
| stop.add(im_end) |
|
|
| token_ids = list(prompt_ids[-context_length:]) |
| generated: list[int] = [] |
| decoded = "" |
| stop_reason = "max_new_tokens" |
| generator = torch.Generator(device=device) |
| generator.manual_seed(seed) |
| started = time.perf_counter() |
|
|
| for _ in range(max_new_tokens): |
| context = token_ids[-context_length:] |
| input_ids = torch.tensor([context], dtype=torch.long, device=device) |
| output = model(input_ids, return_logits=True, logits_to_keep=1) |
| if output.logits is None: |
| raise RuntimeError("Model returned no logits") |
| next_logits = _apply_repetition_penalty( |
| output.logits[0, -1], |
| token_ids, |
| repetition_penalty, |
| repetition_window, |
| ) |
| next_id = _sample( |
| next_logits, |
| temperature=temperature, |
| top_p=top_p, |
| top_k=top_k, |
| generator=generator, |
| ) |
| if next_id in stop: |
| stop_reason = "stop" |
| break |
| token_ids.append(next_id) |
| generated.append(next_id) |
| current = tokenizer.decode(generated, skip_special_tokens=True) |
| delta = current[len(decoded) :] if current.startswith(decoded) else current |
| decoded = current |
| if stream and delta: |
| print(delta, end="", flush=True) |
|
|
| if stream: |
| print(flush=True) |
| elapsed = time.perf_counter() - started |
| tps = len(generated) / max(elapsed, 1e-9) |
| if stream: |
| print( |
| f"[done] tokens={len(generated)} stop={stop_reason} {tps:.1f} tok/s", |
| file=sys.stderr, |
| flush=True, |
| ) |
| return decoded, len(generated), stop_reason |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="Monostich-2 SFT chat inference") |
| parser.add_argument("--prompt", default=None, help="Single user prompt") |
| parser.add_argument("--system", default="", help="Optional system prompt") |
| parser.add_argument("--max-new-tokens", type=int, default=256) |
| parser.add_argument("--temperature", type=float, default=0.7) |
| parser.add_argument("--top-p", type=float, default=0.9) |
| parser.add_argument("--top-k", type=int, default=50) |
| parser.add_argument("--repetition-penalty", type=float, default=1.08) |
| parser.add_argument("--repetition-window", type=int, default=256) |
| parser.add_argument("--context-length", type=int, default=2048) |
| parser.add_argument("--seed", type=int, default=42) |
| parser.add_argument( |
| "--device", |
| default="cuda" if torch.cuda.is_available() else "cpu", |
| choices=["cuda", "cpu"], |
| ) |
| parser.add_argument("--no-stream", action="store_true") |
| parser.add_argument("--local-dir", default=None) |
| parser.add_argument("--repo-id", default=REPO_ID) |
| return parser.parse_args() |
|
|
|
|
| def main() -> int: |
| _silence_runtime_warnings() |
| args = parse_args() |
| global REPO_ID |
| REPO_ID = args.repo_id |
| local_dir = Path(args.local_dir).resolve() if args.local_dir else None |
|
|
| print(f"Loading Monostich-2 from huggingface.co/{REPO_ID} …", flush=True) |
| try: |
| package_root = _ensure_tiny_gdn(local_dir) |
| except Exception as error: |
| print(f"Failed to resolve tiny_gdn package: {error}", file=sys.stderr) |
| return 1 |
|
|
| if str(package_root) not in sys.path: |
| sys.path.insert(0, str(package_root)) |
|
|
| try: |
| _ensure_fla(local_dir) |
| except Exception as error: |
| print(str(error), file=sys.stderr) |
| return 1 |
|
|
| try: |
| from tiny_gdn import TinyGDNConfig, TinyGDNForCausalLM |
| except ImportError as error: |
| print(f"Could not import tiny_gdn: {error}", file=sys.stderr) |
| return 1 |
|
|
| weights_path = _download("model.safetensors", local_dir) |
| tok_path = _download("tokenizer.json", local_dir) |
| cfg_path = _download("config.json", local_dir) |
|
|
| raw = json.loads(cfg_path.read_text(encoding="utf-8")) |
| from dataclasses import fields |
|
|
| allowed = {item.name for item in fields(TinyGDNConfig)} |
| payload = {key: value for key, value in raw.items() if key in allowed} |
| if "shared_layer_indices" in payload: |
| payload["shared_layer_indices"] = tuple(payload["shared_layer_indices"]) |
| config = TinyGDNConfig(**payload) |
|
|
| device = torch.device(args.device) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| print("CUDA requested but unavailable; falling back to CPU.", flush=True) |
| device = torch.device("cpu") |
| dtype = torch.bfloat16 if device.type == "cuda" else torch.float32 |
|
|
| print( |
| f"Building TinyGDN ({config.num_hidden_layers}L / {config.hidden_size}d) " |
| f"on {device} …", |
| flush=True, |
| ) |
| model = TinyGDNForCausalLM(config) |
| state = load_file(str(weights_path), device="cpu") |
| model.load_state_dict(state, strict=True) |
| del state |
| model = model.to(device=device, dtype=dtype) |
| model.eval() |
| model.requires_grad_(False) |
|
|
| tokenizer = Tokenizer.from_file(str(tok_path)) |
| context_length = min(args.context_length, config.max_position_embeddings) |
| stream = not args.no_stream |
|
|
| def run_chat(messages: list[dict[str, str]]) -> str: |
| prompt_ids = encode_chat(tokenizer, messages) |
| text, _, _ = generate( |
| model, |
| tokenizer, |
| prompt_ids, |
| max_new_tokens=args.max_new_tokens, |
| context_length=context_length, |
| temperature=args.temperature, |
| top_p=args.top_p, |
| top_k=args.top_k, |
| repetition_penalty=args.repetition_penalty, |
| repetition_window=args.repetition_window, |
| seed=args.seed, |
| stream=stream, |
| device=device, |
| ) |
| return text |
|
|
| if args.prompt is not None: |
| messages: list[dict[str, str]] = [] |
| if args.system.strip(): |
| messages.append({"role": "system", "content": args.system.strip()}) |
| messages.append({"role": "user", "content": args.prompt}) |
| if stream: |
| print("assistant> ", end="", flush=True) |
| text = run_chat(messages) |
| if not stream: |
| print(text) |
| return 0 |
|
|
| print( |
| "Interactive chat. Commands: /exit /quit /reset\n" |
| "This is the SFT chat model (ChatML).", |
| flush=True, |
| ) |
| history: list[dict[str, str]] = [] |
| if args.system.strip(): |
| history.append({"role": "system", "content": args.system.strip()}) |
| while True: |
| try: |
| user_input = input("user> ") |
| except (EOFError, KeyboardInterrupt): |
| print() |
| break |
| text = user_input.strip() |
| if not text: |
| continue |
| if text.lower() in {"/exit", "/quit"}: |
| break |
| if text.lower() == "/reset": |
| history = [] |
| if args.system.strip(): |
| history.append({"role": "system", "content": args.system.strip()}) |
| print("(history cleared)", flush=True) |
| continue |
| turn = history + [{"role": "user", "content": text}] |
| if stream: |
| print("assistant> ", end="", flush=True) |
| reply = run_chat(turn) |
| history = turn + [{"role": "assistant", "content": reply}] |
| if not stream: |
| print(f"assistant> {reply}") |
| print(flush=True) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|