scugnizz-chat / app.py
Reverendo's picture
Upload app.py with huggingface_hub
28152e4 verified
Raw
History Blame Contribute Delete
24.2 kB
"""Scugnizz Llama-PCS interactive chat (custom PCS decoder, not transformers AutoModel)."""
from __future__ import annotations
import importlib.util
import gc
import json
import os
import shutil
import sys
import time
from pathlib import Path
import gradio as gr
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
HUB_REPO = os.environ.get("HUB_REPO", "ProjectScugnizz/scugnizz-llama-pcs")
HUB_PATH = os.environ.get(
"HUB_PATH",
"training-runs/sft-chat-v2-ground-adhere-8b-20260807-053640",
)
SCRIPT_REPO = os.environ.get("SCRIPT_REPO", "ProjectScugnizz/scugnizz-llama-training")
MODEL_MOUNT = Path(os.environ.get("MODEL_MOUNT", "/models"))
CODE_MOUNT = Path(os.environ.get("CODE_MOUNT", "/code"))
SYSTEM = os.environ.get(
"SYSTEM_PROMPT",
"You are a helpful assistant. Answer clearly and correctly.",
)
_CODE_FALLBACK = Path("/tmp/scugnizz-code")
_MODEL_FALLBACK = Path("/tmp/scugnizz-model")
_WEIGHT_CACHE = Path("/tmp/scugnizz-weights")
_state = {
"model": None,
"tok": None,
"dev": None,
"error": None,
"status": "cold",
"hub_path": HUB_PATH.strip("/"),
"weights_file": "model_final.pt",
}
def _active_hub_path() -> str:
return (_state.get("hub_path") or HUB_PATH).strip("/")
def _active_weights_file() -> str:
return (_state.get("weights_file") or "model_final.pt").strip() or "model_final.pt"
def _resolve_code_dir() -> Path:
# Hub volume mounts truncate some text files (saw `import time` → `import tim`).
# Trainer is tiny — always pull via Hub API.
snapshot_download(
SCRIPT_REPO,
local_dir=str(_CODE_FALLBACK),
allow_patterns=["scugnizz-llama.py", "sft_data.py"],
)
print(f"code from Hub → {_CODE_FALLBACK}", flush=True)
return _CODE_FALLBACK
def _resolve_model_dir(hub_path: str | None = None) -> Path:
hub_path = (hub_path or _active_hub_path()).strip("/")
nested = MODEL_MOUNT / hub_path
# Prefer path-specific mount (volume pointed at a run tree).
if (nested / "args.json").is_file() or (nested / "model_final.pt").is_file():
print(f"model mount nested: {nested}", flush=True)
return nested
# Legacy: Space volume mounts one run at /models root — only if it matches active path.
env_path = os.environ.get("HUB_PATH", "").strip("/")
if hub_path == env_path and (MODEL_MOUNT / "model_final.pt").is_file():
print(f"model mount: {MODEL_MOUNT}", flush=True)
return MODEL_MOUNT
local = _MODEL_FALLBACK / hub_path
# Re-download if missing weights or args
need = not (local / "args.json").is_file()
wname = _active_weights_file()
if not (local / wname).is_file() and not (local / "model_final.pt").is_file():
need = True
if need:
print(f"snapshot_download {HUB_REPO}/{hub_path} (~7GB)…", flush=True)
snapshot_download(
HUB_REPO,
local_dir=str(_MODEL_FALLBACK),
allow_patterns=[f"{hub_path}/*", f"{hub_path}/tokenizer/*"],
)
return local
def _load_train_module():
code = _resolve_code_dir()
path = code / "scugnizz-llama.py"
spec = importlib.util.spec_from_file_location("scugnizz_train", path)
mod = importlib.util.module_from_spec(spec)
sys.path.insert(0, str(code))
sys.modules["scugnizz_train"] = mod
spec.loader.exec_module(mod)
return mod
def _load_tokenizer(model_dir: Path, hub_path: str | None = None):
hub_path = (hub_path or _active_hub_path()).strip("/")
tok_dir = model_dir / "tokenizer"
cfg = tok_dir / "tokenizer_config.json"
try:
if cfg.is_file() and cfg.stat().st_size > 1000:
json.loads(cfg.read_text(encoding="utf-8"))
return AutoTokenizer.from_pretrained(str(tok_dir))
except (OSError, json.JSONDecodeError, ValueError):
pass
print("tokenizer via Hub (mount JSON unreliable)", flush=True)
return AutoTokenizer.from_pretrained(HUB_REPO, subfolder=f"{hub_path}/tokenizer")
def _materialize_weights(src: Path, hub_path: str | None = None, weights_file: str | None = None) -> Path:
"""Copy once to local disk — Hub volume / FUSE is slow for torch.load."""
hub_path = (hub_path or _active_hub_path()).strip("/")
weights_file = weights_file or _active_weights_file()
sz = src.stat().st_size
if sz < 1_000_000_000:
raise RuntimeError(f"weight file too small ({sz} B) at {src} — LFS/mount broken?")
_WEIGHT_CACHE.mkdir(parents=True, exist_ok=True)
safe = hub_path.replace("/", "__") + "__" + weights_file
dest = _WEIGHT_CACHE / safe
if dest.is_file() and dest.stat().st_size == sz:
print(f"weights cache hit {dest} ({sz / 1e9:.2f} GB)", flush=True)
return dest
print(f"copying {sz / 1e9:.2f} GB → {dest} …", flush=True)
t0 = time.perf_counter()
shutil.copyfile(src, dest)
print(f"copy done in {time.perf_counter() - t0:.1f}s", flush=True)
return dest
def _read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _load_run_args(model_dir: Path, hub_path: str | None = None) -> dict:
hub_path = (hub_path or _active_hub_path()).strip("/")
local = model_dir / "args.json"
try:
if local.is_file() and local.stat().st_size > 50:
return _read_json(local)
except (OSError, json.JSONDecodeError, ValueError):
pass
print("args.json via Hub (mount JSON unreliable)", flush=True)
from huggingface_hub import hf_hub_download
p = hf_hub_download(HUB_REPO, f"{hub_path}/args.json")
return _read_json(Path(p))
def _load_weights_into_model(model: torch.nn.Module, weight_path: Path, device: str) -> None:
"""t4-small has 15GB RAM — mmap + per-tensor copy, no full fp32 duplicate."""
t_load = time.perf_counter()
weights = torch.load(weight_path, map_location="cpu", weights_only=True, mmap=True)
print(f"torch.load(mmap) {time.perf_counter() - t_load:.1f}s", flush=True)
state = weights["model"] if isinstance(weights, dict) and "model" in weights else weights
own = model.state_dict()
t_copy = time.perf_counter()
with torch.no_grad():
for k, v in state.items():
dst = own[k]
if torch.is_floating_point(v):
dst.copy_(v.to(device=dst.device, dtype=dst.dtype, non_blocking=True))
else:
dst.copy_(v.to(device=dst.device, non_blocking=True))
if device == "cuda":
torch.cuda.synchronize()
print(f"param copy {time.perf_counter() - t_copy:.1f}s", flush=True)
del weights, state
gc.collect()
def _build_model(mod, cfg, device: str):
# meta init avoids allocating a 7GB fp32 empty model in 15GB RAM
with torch.device("meta"):
model = mod.ScugnizzDecoder(cfg)
model = model.to_empty(device=device)
if device == "cuda":
model = model.half()
# to_empty may split tied weights
model.tok_emb.weight = model.lm_head.weight
return model
def _stop_token_ids(tok):
ids = set()
if getattr(tok, "eos_token_id", None) is not None:
ids.add(int(tok.eos_token_id))
unk = getattr(tok, "unk_token_id", None)
for s in ("<|eot_id|>", "<|eom_id|>"):
tid = tok.convert_tokens_to_ids(s)
if tid is None or tid < 0:
continue
if unk is not None and tid == unk:
continue
ids.add(int(tid))
return ids
def unload_model():
if _state["model"] is not None:
print("unloading model…", flush=True)
_state["model"] = None
_state["tok"] = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
_state["status"] = "cold"
def list_hub_runs():
"""List training-runs/* folders on the weights repo (newest-ish last alphabetically reversed)."""
from huggingface_hub import HfApi
api = HfApi()
runs = []
for item in api.list_repo_tree(
HUB_REPO, path_in_repo="training-runs", recursive=False, repo_type="model"
):
p = getattr(item, "path", "") or ""
if p.startswith("training-runs/"):
runs.append(p)
# Prefer chat SFT near top: sort sft-chat first, then reverse chrono name
def key(p):
name = p.rsplit("/", 1)[-1]
pri = 0 if "sft-chat" in name else 1
return (pri, name)
runs.sort(key=key, reverse=True)
return runs
def list_weight_files(hub_path: str):
"""List *.pt weight files under a run folder."""
from huggingface_hub import HfApi
hub_path = (hub_path or "").strip("/")
if not hub_path:
return ["model_final.pt"]
api = HfApi()
files = []
for item in api.list_repo_tree(
HUB_REPO, path_in_repo=hub_path, recursive=False, repo_type="model"
):
p = getattr(item, "path", "") or ""
name = p.rsplit("/", 1)[-1]
if not name.endswith(".pt"):
continue
size = getattr(item, "size", None) or 0
if size and size < 1_000_000_000:
continue
files.append(name)
if not files:
files = ["model_final.pt"]
# final first, then pulses by step
def wkey(n):
if n == "model_final.pt":
return (0, 0)
if n.startswith("model_pulse_"):
try:
return (1, -int(n.replace("model_pulse_", "").replace(".pt", "")))
except ValueError:
return (1, 0)
return (2, n)
files.sort(key=wkey)
return files
def load_model(force: bool = False):
if _state["model"] is not None and not force:
return _state["model"], _state["tok"], _state["dev"]
if force:
unload_model()
hub_path = _active_hub_path()
weights_file = _active_weights_file()
_state["error"] = None
_state["status"] = f"loading {hub_path}/{weights_file}"
t0 = time.perf_counter()
try:
mod = _load_train_module()
model_dir = _resolve_model_dir(hub_path)
run_args = _load_run_args(model_dir, hub_path)
dev = "cuda" if torch.cuda.is_available() else "cpu"
print(f"device={dev} cuda={torch.cuda.is_available()}", flush=True)
tok = _load_tokenizer(model_dir, hub_path)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
cfg = mod.preset_config(
run_args.get("model_size", "1.7b"),
len(tok),
# Pretrain was 4096; chat SFT now 4096 — match pretrain/Gradio window
int(os.environ.get("BLOCK_SIZE", "4096")),
0.0,
run_args.get("pcs_a", 0.8309193524478643),
run_args.get("pcs_b", 0.0),
)
cfg.gradient_checkpointing = False
model = _build_model(mod, cfg, dev)
wpath = model_dir / weights_file
if not wpath.is_file():
# fall back to final if pulse missing locally but listed
alt = model_dir / "model_final.pt"
if weights_file != "model_final.pt" and alt.is_file():
raise FileNotFoundError(f"{wpath} missing — try Refresh weights or model_final.pt")
raise FileNotFoundError(f"missing weights: {wpath}")
weight_path = _materialize_weights(wpath, hub_path, weights_file)
_load_weights_into_model(model, weight_path, dev)
model.eval()
_state.update(model=model, tok=tok, dev=dev, status="ready")
print(f"model ready in {time.perf_counter() - t0:.1f}s total", flush=True)
return model, tok, dev
except Exception as e:
_state["error"] = str(e)
_state["status"] = f"error: {e}"
print(f"load failed: {e}", flush=True)
raise
def switch_checkpoint(hub_path: str, weights_file: str):
hub_path = (hub_path or "").strip("/")
weights_file = (weights_file or "model_final.pt").strip() or "model_final.pt"
if not hub_path:
return f"**Status:** pick a run · current `{_active_hub_path()}/{_active_weights_file()}`"
same = hub_path == _active_hub_path() and weights_file == _active_weights_file() and _state["model"] is not None
if same:
return f"**Status:** already loaded `{hub_path}/{weights_file}`"
_state["hub_path"] = hub_path
_state["weights_file"] = weights_file
try:
load_model(force=True)
return f"**Status:** ready · `{hub_path}/{weights_file}`"
except Exception as e:
return f"**Status:** error loading `{hub_path}/{weights_file}`: {e}"
def _as_text(content) -> str:
"""Normalize Gradio message content (str | list blocks | ChatMessage-like)."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
parts.append(str(block.get("text") or block.get("content") or ""))
else:
parts.append(str(getattr(block, "text", "") or ""))
return "".join(parts)
return str(content)
def _strip_ctx_footer(text: str) -> str:
marker = "\n\n—\n*ctx "
i = text.rfind(marker)
return text[:i].rstrip() if i >= 0 else text
def history_to_messages(history):
"""Gradio 5 history: dicts, ChatMessage objects, or legacy tuples."""
msgs = [{"role": "system", "content": SYSTEM}]
if not history:
return msgs
for m in history:
if isinstance(m, dict):
role = m.get("role")
content = _as_text(m.get("content"))
elif hasattr(m, "role") and hasattr(m, "content"):
role = getattr(m, "role", None)
content = _as_text(getattr(m, "content", None))
elif isinstance(m, (list, tuple)) and len(m) == 2:
# legacy [user, assistant] pair
u, a = _as_text(m[0]), _as_text(m[1])
if u:
msgs.append({"role": "user", "content": u})
if a:
msgs.append({"role": "assistant", "content": _strip_ctx_footer(a)})
continue
else:
continue
if role in ("user", "assistant") and content:
if role == "assistant":
content = _strip_ctx_footer(content)
msgs.append({"role": role, "content": content})
return msgs
def _prompt_ids(tok, msgs, budget: int):
"""Fit chat into block_size: drop oldest turns first, then left-trim latest user text."""
system, rest = msgs[0], msgs[1:]
if not rest:
prompt = tok.apply_chat_template([system], tokenize=False, add_generation_prompt=True)
ids = tok.encode(prompt, add_special_tokens=False)
return prompt, ids[:budget], False
dropped = 0
for keep_from in range(0, len(rest)):
candidate = [system] + rest[keep_from:]
prompt = tok.apply_chat_template(
candidate, tokenize=False, add_generation_prompt=True
)
ids = tok.encode(prompt, add_special_tokens=False)
if len(ids) <= budget:
if keep_from:
print(f"context: dropped {keep_from} older turns → {len(ids)} tok", flush=True)
return prompt, ids, bool(keep_from)
dropped = keep_from + 1
# Latest user message alone still too long — keep its tail (question usually at end)
last = dict(rest[-1])
content = last.get("content") or ""
lo, hi = 0, len(content)
best_prompt, best_ids = None, None
while lo < hi:
mid = (lo + hi) // 2
last["content"] = content[mid:]
prompt = tok.apply_chat_template(
[system, last], tokenize=False, add_generation_prompt=True
)
ids = tok.encode(prompt, add_special_tokens=False)
if len(ids) <= budget:
best_prompt, best_ids = prompt, ids
hi = mid
else:
lo = mid + 1
if best_ids is None:
last["content"] = content[-500:]
best_prompt = tok.apply_chat_template(
[system, last], tokenize=False, add_generation_prompt=True
)
best_ids = tok.encode(best_prompt, add_special_tokens=False)[-budget:]
print(
f"context: trimmed latest user (+ dropped {dropped} turns) → {len(best_ids)} tok",
flush=True,
)
return best_prompt, best_ids, True
@torch.no_grad()
def _generate(model, tok, ids, max_new_tokens, temperature, top_k, device):
model.eval()
x = torch.tensor([ids], dtype=torch.long, device=device)
greedy = temperature <= 0.0
stop_ids = _stop_token_ids(tok)
out_ids = []
t0 = time.perf_counter()
logits, _, kvs = model(x, use_cache=True)
for _ in range(max_new_tokens):
step_logits = logits[:, -1, :]
if greedy:
next_id = step_logits.argmax(dim=-1, keepdim=True)
else:
step_logits = step_logits / max(temperature, 1e-5)
if top_k > 0:
v, _ = torch.topk(step_logits, min(top_k, step_logits.size(-1)))
step_logits = step_logits.masked_fill(step_logits < v[:, [-1]], float("-inf"))
probs = F.softmax(step_logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1)
tid = int(next_id.item())
out_ids.append(tid)
if tid in stop_ids:
break
if len(ids) + len(out_ids) >= model.cfg.block_size:
break
logits, _, kvs = model(next_id, past_kvs=kvs, use_cache=True)
dt = max(time.perf_counter() - t0, 1e-6)
print(f"gen {len(out_ids)} tok in {dt:.2f}s ({len(out_ids) / dt:.1f} tok/s)", flush=True)
return tok.decode(out_ids, skip_special_tokens=True).strip()
def respond(message, history, max_new_tokens, temperature, top_k):
model, tok, dev = load_model()
msgs = history_to_messages(history)
msgs.append({"role": "user", "content": _as_text(message)})
lengths = [len(m["content"]) for m in msgs]
print(
f"msgs={len(msgs)} roles={[m['role'] for m in msgs]} chars={lengths} "
f"temp={temperature} top_k={top_k} max_new={max_new_tokens}",
flush=True,
)
for i, m in enumerate(msgs):
print(f"--- msg[{i}] {m['role']} ({len(m['content'])} chars) ---", flush=True)
print(m["content"], flush=True)
budget = model.cfg.block_size - 1
prompt, ids, truncated = _prompt_ids(tok, msgs, budget)
print(
f"prompt_tokens={len(ids)}/{budget} truncated={truncated}",
flush=True,
)
print("--- prompt begin ---", flush=True)
print(prompt, flush=True)
print("--- prompt end ---", flush=True)
text = _generate(
model,
tok,
ids,
int(max_new_tokens),
float(temperature),
int(top_k),
dev,
)
print("--- assistant begin ---", flush=True)
print(text, flush=True)
print("--- assistant end ---", flush=True)
note = f"\n\n—\n*ctx {len(ids)}/{budget} tok*" + (" *(trimmed)*" if truncated else "")
return text + note
def build_ui():
try:
run_choices = list_hub_runs()
except Exception as e:
print(f"list_hub_runs failed: {e}", flush=True)
run_choices = [_active_hub_path()]
default_run = _active_hub_path()
if default_run not in run_choices:
run_choices = [default_run] + run_choices
try:
weight_choices = list_weight_files(default_run)
except Exception:
weight_choices = ["model_final.pt"]
with gr.Blocks(title="Scugnizz Llama-PCS chat") as demo:
status = gr.Markdown(
f"**Status:** {_state.get('status', 'cold')} · `{_active_hub_path()}/{_active_weights_file()}`"
)
gr.Markdown(
f"""# Scugnizz Llama-PCS chat
Custom ~1.7B PCS decoder · pick any run under `{HUB_REPO}`
**Context window: 4096 tokens**. Each reply shows `ctx N/4095`.
Switching checkpoints downloads ~7GB the first time — wait for **ready**.
For grounded/open-book answers use **temperature 0–0.3** (default 0.2).
"""
)
with gr.Accordion("Checkpoint", open=True):
run_dd = gr.Dropdown(
choices=run_choices,
value=default_run,
label=f"Hub run ({HUB_REPO})",
allow_custom_value=True,
)
weight_dd = gr.Dropdown(
choices=weight_choices,
value=_active_weights_file()
if _active_weights_file() in weight_choices
else weight_choices[0],
label="Weights file",
allow_custom_value=True,
)
with gr.Row():
refresh_runs = gr.Button("Refresh list", scale=1)
load_btn = gr.Button("Load checkpoint", variant="primary", scale=2)
def on_run_change(hub_path):
try:
ws = list_weight_files(hub_path)
except Exception as e:
return gr.update(choices=["model_final.pt"], value="model_final.pt"), f"**Status:** weight list error: {e}"
val = "model_final.pt" if "model_final.pt" in ws else ws[0]
return gr.update(choices=ws, value=val), f"**Status:** select weights · `{hub_path}`"
def on_refresh():
try:
runs = list_hub_runs()
except Exception as e:
return gr.update(), f"**Status:** refresh error: {e}"
cur = _active_hub_path()
if cur not in runs:
runs = [cur] + runs
return gr.update(choices=runs, value=cur), f"**Status:** listed {len(runs)} runs"
run_dd.change(on_run_change, [run_dd], [weight_dd, status])
refresh_runs.click(on_refresh, None, [run_dd, status])
load_btn.click(switch_checkpoint, [run_dd, weight_dd], status)
chatbot = gr.Chatbot(height=480, type="messages")
msg = gr.Textbox(
placeholder="Ask something… (for open-book: paste passage, then question at the end)",
scale=1,
)
with gr.Accordion("Generation", open=False):
max_new = gr.Slider(16, 512, value=128, step=8, label="max new tokens")
temp = gr.Slider(0.0, 1.5, value=0.2, step=0.05, label="temperature (0 = greedy)")
top_k = gr.Slider(0, 200, value=50, step=1, label="top-k (0 = off)")
clear = gr.Button("Clear")
# Single handler — avoids Gradio queue race where .then() saw empty/stale history
# (logs showed prompt_tokens≈48 while user pasted long context).
def chat(user_msg, history, max_new_tokens, temperature, top_k):
history = list(history or [])
user_msg = _as_text(user_msg)
if not user_msg.strip():
return "", history
prior = history
try:
answer = respond(user_msg, prior, max_new_tokens, temperature, top_k)
except Exception as e:
answer = f"(error loading/generating: {e})"
history = prior + [
{"role": "user", "content": user_msg},
{"role": "assistant", "content": answer},
]
return "", history
def refresh_status():
return (
f"**Status:** {_state.get('status', 'cold')} · "
f"`{_active_hub_path()}/{_active_weights_file()}`"
)
msg.submit(
chat, [msg, chatbot, max_new, temp, top_k], [msg, chatbot]
).then(refresh_status, None, status)
clear.click(lambda: [], None, chatbot, queue=False)
demo.load(refresh_status, None, status)
return demo
if __name__ == "__main__":
print("preloading model before serving…", flush=True)
try:
load_model()
except Exception as e:
print(f"preload failed (will retry on first request): {e}", flush=True)
demo = build_ui()
demo.queue().launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", 7860)),
ssr_mode=False,
)