Download scripts/serve_jev.py from Endikavi/Ines-1: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/Endikavi/Ines-1/resolve/main/scripts/serve_jev.py
- Command line
-
hf download hf://Endikavi/Ines-1/scripts/serve_jev.py
-
curl -L -o serve_jev.py https://huggingface.co/Endikavi/Ines-1/resolve/main/scripts/serve_jev.py
12.1 kB
| """Ines-1 (codename mini-v41) behind the typed-decision `/v1/systemone` HTTP interface, on the fast prefill path. | |
| python scripts/serve_jev.py [--checkpoint .] [--port 30040] [--max-rows 64] [--max-tokens 65536] [--graphs 64] [--graph-tokens 6144] | |
| POST /v1/systemone {state, questions, lang?: "es"|"en"} -> {model, answers, usage} | |
| GET /v1/models, GET /health | |
| Every question is one prompt (decisions.Reader, the same prompt the model was trained and | |
| tested with); the answer is the softmax over the option letters at the last position. | |
| One thread owns the GPU. Whatever arrives while it works goes into the next call, so a lone | |
| request is not delayed and concurrent ones share a forward. Each call: | |
| - groups the waiting prompts by length (the oldest one plus its length neighbours while | |
| padding stays <= 30 %, up to --max-rows rows and --max-tokens padded tokens), as serve.Engine | |
| does for admissions; | |
| - a group whose (batch, length) bucket holds <= --graph-tokens padded tokens replays a | |
| PrefillGraphs graph (launch-bound regime); a larger one runs the exact eager `prefill_batch` | |
| (GPU-bound regime, where padding up to a bucket is waste); | |
| - only the logits at each row's last real position are computed (`_prefill_forward`: the | |
| decoder runs over each row's last R positions). | |
| The HTTP side is asyncio (aiohttp). Prompts are built in worker processes (tokenizer only): | |
| in threads they held the GIL the GPU thread needs to launch ~2,000 kernels per forward. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import json | |
| import queue | |
| import sys | |
| import threading | |
| import time | |
| from concurrent.futures import Future, ProcessPoolExecutor | |
| from pathlib import Path | |
| import torch | |
| sys.path.insert(0, str(Path(__file__).absolute().parent.parent)) # the repository root (not resolve(): HF cache symlinks) | |
| from mini_v41_jev.decisions import Reader, options # noqa: E402,F401 | |
| class Batcher: | |
| def __init__(self, im, reader, max_rows, max_tokens, graph_rows, graph_tokens=4096, max_padding=0.3): | |
| from mini_v41.fast_decode import PrefillGraphs, prefill_batch | |
| self.model, self.reader = im.model, reader | |
| self.prefill_batch = prefill_batch | |
| self.max_rows, self.max_tokens, self.max_padding = max_rows, max_tokens, max_padding | |
| self.graph_tokens = graph_tokens | |
| self.graphs = {} # batch bucket -> PrefillGraphs over the lengths that keep b * n <= graph_tokens | |
| # A forward is launch-bound below a few thousand padded tokens (eager ~20 ms whatever the | |
| # batch, graph 8.6 ms at 8x128) and GPU-bound above (graph == eager at 16x1024): graphs | |
| # only for those small buckets, one set per batch size so no big bucket is ever captured. | |
| for b in (1, 2, 4, 8, 16, 32, 64): | |
| lengths = tuple(n for n in (32, 64, 96, 128, 192, 256, 384, 512, 1024, 2048) if b * n <= graph_tokens) | |
| if b <= graph_rows and lengths: | |
| with torch.autocast("cuda", dtype=torch.bfloat16): # canonical: bf16 weights + bf16 autocast | |
| self.graphs[b] = PrefillGraphs(im.model, lengths=lengths, batches=(b,)) | |
| self.letters = torch.tensor(reader.letter_ids(26), device=im.device) | |
| self.q = queue.Queue() | |
| self.stats = {"calls": 0, "rows": 0, "graph_calls": 0, "busy_s": 0.0, "gpu_s": 0.0, "real_tokens": 0, "padded_tokens": 0} | |
| threading.Thread(target=self._loop, name="gpu", daemon=True).start() | |
| def submit(self, rows): | |
| """rows: [(ids, n_options)] -> Future of [probabilities over the options].""" | |
| fut = Future() | |
| self.q.put((rows, fut)) | |
| return fut | |
| def _groups(self, pending): | |
| """pending: [(ids, n, slot)] in arrival order -> list of groups, oldest first.""" | |
| out = [] | |
| left = sorted(pending, key=lambda r: len(r[0])) | |
| while left: | |
| oldest = min(left, key=lambda r: r[2]) | |
| i = left.index(oldest) | |
| lo = hi = i | |
| width = len(oldest[0]) | |
| def fits(j): | |
| w = max(width, len(left[j][0])) | |
| rows = hi - lo + 2 | |
| real = sum(len(left[k][0]) for k in range(lo, hi + 1)) + len(left[j][0]) | |
| return rows <= self.max_rows and w * rows <= self.max_tokens and real >= (1 - self.max_padding) * w * rows | |
| while True: | |
| cand = [j for j in (lo - 1, hi + 1) if 0 <= j < len(left)] | |
| cand.sort(key=lambda j: abs(len(left[j][0]) - len(oldest[0]))) | |
| j = next((j for j in cand if fits(j)), None) | |
| if j is None: | |
| break | |
| lo, hi = min(lo, j), max(hi, j) | |
| width = max(width, len(left[j][0])) | |
| out.append(left[lo:hi + 1]) | |
| del left[lo:hi + 1] | |
| return out | |
| def _run(self, group): | |
| prompts = [r[0] for r in group] | |
| b = next((x for x in sorted(self.graphs) if x >= len(prompts)), None) | |
| width = max(map(len, prompts)) | |
| n = next((x for x in self.graphs[b].lengths if x >= width), None) if b else None | |
| with torch.autocast("cuda", dtype=torch.bfloat16): # both paths: bf16 weights + bf16 autocast | |
| if n is not None: | |
| logits, _, _ = self.graphs[b](prompts) | |
| self.stats["graph_calls"] += 1 | |
| else: | |
| logits, _, _ = self.prefill_batch(self.model, prompts) | |
| z = logits[:, self.letters].float() # [N, 26] | |
| n = torch.tensor([r[1] for r in group], device=z.device) | |
| z = z.masked_fill(torch.arange(26, device=z.device)[None] >= n[:, None], float("-inf")) | |
| return z.softmax(-1).cpu().tolist() | |
| def _loop(self): | |
| while True: | |
| items = [self.q.get()] | |
| while True: | |
| try: | |
| items.append(self.q.get_nowait()) | |
| except queue.Empty: | |
| break | |
| t0 = time.perf_counter() | |
| pending, slot = [], 0 | |
| for k, (rows, _) in enumerate(items): | |
| for j, (ids, n) in enumerate(rows): | |
| pending.append((ids, n, slot, k, j)) | |
| slot += 1 | |
| results = [[None] * len(rows) for rows, _ in items] | |
| try: | |
| for g in self._groups([(p[0], p[1], p[2]) for p in pending]): | |
| t1 = time.perf_counter() | |
| probs = self._run(g) | |
| self.stats["gpu_s"] += time.perf_counter() - t1 | |
| self.stats["real_tokens"] += sum(len(r[0]) for r in g) | |
| self.stats["padded_tokens"] += len(g) * max(len(r[0]) for r in g) | |
| for (ids, n, s), p in zip(g, probs): | |
| _, _, _, k, j = pending[s] | |
| results[k][j] = p[:n] | |
| self.stats["calls"] += 1 | |
| self.stats["rows"] += len(g) | |
| for (rows, fut), r in zip(items, results): | |
| fut.set_result(r) | |
| except Exception as e: # noqa: BLE001 - one bad batch must not kill the server | |
| for _, fut in items: | |
| if not fut.done(): | |
| fut.set_exception(e) | |
| self.stats["busy_s"] += time.perf_counter() - t0 | |
| _W = {} | |
| def _init_worker(checkpoint, max_len, lang): | |
| from types import SimpleNamespace | |
| from mini_v41.tokenizer import Tokenizer | |
| tok = Tokenizer(Path(checkpoint) / "tokenizer") # the checkpoint's own tokenizer, as InferenceModel | |
| _W["reader"] = Reader(SimpleNamespace(tokenizer=tok, max_sequence_length=max_len, device="cpu")) | |
| _W["lang"] = lang | |
| def _ping(_): | |
| return 0 | |
| def _build(body): | |
| """Request -> ([(ids, n options)], [(qid, question, keys)]), in a worker process.""" | |
| reader = _W["reader"] | |
| if "state" not in body: | |
| raise ValueError("the body needs `state` (text, object or list)") | |
| qs = body.get("questions") or {} | |
| if not isinstance(qs, dict) or not qs: | |
| raise ValueError("`questions` must be a non-empty object") | |
| lang = body.get("lang") or _W["lang"] | |
| rows, meta = [], [] | |
| for qid, q in qs.items(): | |
| if q.get("type") not in ("choice", "score", "noul"): | |
| raise ValueError("question %r: type must be choice, score or noul" % qid) | |
| ids, keys = reader.prompt(body["state"], q, lang) | |
| rows.append((ids, len(keys))) | |
| meta.append((qid, q, keys)) | |
| return rows, meta | |
| def answer(q, keys, p): | |
| probs = dict(zip(keys, p)) | |
| if q["type"] == "noul": | |
| return {"type": "noul", "noul": float(probs["true"])} | |
| r = {"type": q["type"], "probabilities": probs, "confidence": float(max(p))} | |
| if q["type"] == "choice": | |
| r["choice"] = max(probs, key=probs.get) | |
| else: | |
| r["score"] = float(sum(i * x for i, x in enumerate(p))) | |
| r["legend"] = {str(i): t for i, t in enumerate(q["criteria"])} | |
| return r | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--checkpoint", type=Path, default=Path(__file__).absolute().parent.parent, | |
| help="the repository directory (config.json, model.safetensors, tokenizer/)") | |
| ap.add_argument("--name", default="Ines-1") | |
| ap.add_argument("--port", type=int, default=30040) | |
| ap.add_argument("--max-rows", type=int, default=64) | |
| ap.add_argument("--max-tokens", type=int, default=65536, help="padded tokens per forward") | |
| ap.add_argument("--graphs", type=int, default=64, help="groups up to this many rows replay a prefill graph (0: never)") | |
| ap.add_argument("--lang", default="es") | |
| ap.add_argument("--prompt-procs", type=int, default=16, help="processes building prompts (tokenizer only)") | |
| ap.add_argument("--graph-tokens", type=int, default=6144) | |
| a = ap.parse_args() | |
| from aiohttp import web | |
| from mini_v41_jev.decider import load | |
| im = load(a.checkpoint, device="cuda:0") | |
| reader = Reader(im) | |
| t0 = time.time() | |
| batcher = Batcher(im, reader, a.max_rows, a.max_tokens, a.graphs, a.graph_tokens) | |
| print("graphs captured in %.0fs" % (time.time() - t0), flush=True) | |
| import multiprocessing as mp | |
| pool = ProcessPoolExecutor(a.prompt_procs, mp_context=mp.get_context("spawn"), initializer=_init_worker, | |
| initargs=(str(a.checkpoint), im.max_sequence_length, a.lang)) | |
| list(pool.map(_ping, range(a.prompt_procs * 2))) | |
| async def systemone(req): | |
| try: | |
| body = await req.json() | |
| except Exception: # noqa: BLE001 | |
| return web.json_response({"error": "invalid JSON"}, status=400) | |
| loop = asyncio.get_running_loop() | |
| try: | |
| rows, meta = await loop.run_in_executor(pool, _build, body) | |
| except (ValueError, KeyError, TypeError) as e: | |
| return web.json_response({"error": str(e)}, status=422) | |
| probs = await asyncio.wrap_future(batcher.submit(rows)) | |
| answers = {qid: answer(q, keys, p) for (qid, q, keys), p in zip(meta, probs)} | |
| return web.json_response({"model": a.name, "answers": answers, | |
| "usage": {"input_tokens": sum(len(r[0]) for r in rows), "output_tokens": 0}}) | |
| async def models(_): | |
| return web.json_response({"models": [{"name": a.name, "description": "Ines-1: 1.6B MoE typed-decision model, one prompt per question"}]}) | |
| async def health(_): | |
| s = dict(batcher.stats) | |
| s["rows_per_call"] = round(s["rows"] / s["calls"], 2) if s["calls"] else 0 | |
| return web.json_response({"status": "ok", "model": a.name, "checkpoint": str(a.checkpoint), "batching": s}) | |
| # warm-up: one prompt per length bucket, through the batcher | |
| for n in (50, 300, 900, 1900): | |
| batcher.submit([(list(range(10, 10 + n)), 2)]).result() | |
| app = web.Application(client_max_size=8 * 2**20) | |
| app.add_routes([web.post("/v1/systemone", systemone), web.get("/v1/models", models), web.get("/health", health)]) | |
| web.run_app(app, host="0.0.0.0", port=a.port, access_log=None, backlog=4096) | |
| if __name__ == "__main__": | |
| main() | |