Yougori's picture
Add files using upload-large-folder tool
e7654c2 verified
Raw History Blame Contribute Delete
14.8 kB
"""Several chats at once on the Ling-mini-2.0 megakernel: a local web app.
python server.py [--port 8790]
Then open http://127.0.0.1:8790. Every pane is its own conversation with its own cache. One thread
owns the GPU and works in rounds: each round, every chat that is reading a prompt or writing an
answer gets its own launch of the megakernel on a share of the SMs (82 / chats working, at most 5
at once; more wait their turn), all launches run together, and their words go out to the panes as
the round ends. One chat alone gets the whole GPU (~850 words/s); three together ~460 each.
Greedy decoding, thinking off, up to 4,096 tokens per conversation. Binds to 127.0.0.1 only.
"""
from __future__ import annotations
import argparse
import json
import queue
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import torch
from ling_kernel import HERE, NQ, LingKernel, State
CHUNK = 32 # answer tokens per launch
PREFILL = 64 # prompt tokens per launch (keeps rounds short while others are writing)
MAX_AT_ONCE = 5 # chats working in the same round (16 SMs each at 5: one attention block per head)
MAX_POS = 4096
MAX_NEW = 1024
PANES = 8 # conversations the page can open
class Chat:
"""One conversation: its GPU state (KV cache and buffers), its history, its listeners."""
def __init__(self, mk: LingKernel, cid: int):
self.mk, self.cid = mk, cid
self.state = State(MAX_POS)
self.out = self.state.out
self.stream = torch.cuda.Stream()
self.history: list[dict] = []
self.cached: list[int] = [] # tokens that have cache entries, in order
self.listeners: list[queue.Queue] = []
self.job: dict | None = None # the answer being written
def launch(self, ins: list[int], pos0: int, n: int, forced: bool, nblocks: int) -> None:
self.mk.launch_async(self.state, ins, pos0, n, forced, nblocks, self.stream)
def send(self, event: dict) -> None:
for q in list(self.listeners):
q.put(event)
class Server:
def __init__(self) -> None:
from transformers import AutoTokenizer
self.tok = AutoTokenizer.from_pretrained(HERE)
print("loading the Ling-mini-2.0 megakernel (4-bit) ...", flush=True)
self.mk = LingKernel(max_pos=MAX_POS, log=lambda s: print(s, flush=True))
self.sms = self.mk.sms
self.eos = {i for i in (self.tok.convert_tokens_to_ids("<|role_end|>"), self.tok.eos_token_id,
self.mk.config.get("eos_token_id")) if isinstance(i, int) and i >= 0}
self.chats = [Chat(self.mk, i) for i in range(PANES)]
self.scratch = Chat(self.mk, -1) # warms the GPU's clocks without touching a conversation
self.lock = threading.Lock()
self.wake = threading.Event()
self.recent: list[tuple[float, int]] = [] # (time, tokens) of finished rounds, for the total rate
self.last_work = 0.0
threading.Thread(target=self.loop, daemon=True).start()
# ---- requests (HTTP threads) ----
def start(self, cid: int, text: str) -> str | None:
with self.lock:
c = self.chats[cid]
if c.job is not None:
return "this chat is still writing"
c.job = {"text": text, "state": "new"}
c.send({"type": "user", "text": text}) # every open page shows it
self.wake.set()
return None
def reset(self, cid: int) -> str | None:
with self.lock:
c = self.chats[cid]
if c.job is not None:
return "this chat is still writing"
c.history, c.cached = [], []
c.send({"type": "cleared"})
return None
def transcript(self, cid: int) -> dict:
with self.lock:
c = self.chats[cid]
job = c.job
msgs = list(c.history)
if job is not None:
msgs.append({"role": "user", "content": job["text"]})
msgs.append({"role": "assistant", "content": job.get("shown", "")})
return {"messages": msgs, "writing": job is not None}
def total_rate(self) -> float:
now = time.perf_counter()
with self.lock:
self.recent = [(t, n) for t, n in self.recent if now - t < 1.0]
n = sum(k for _, k in self.recent)
return n / 1.0
# ---- the GPU loop (one thread) ----
def begin(self, c: Chat) -> None:
"""Turn a new message into prompt tokens; keep the cache's common prefix."""
job = c.job
messages = c.history + [{"role": "user", "content": job["text"]}]
ids = self.tok.apply_chat_template(messages, tokenize=True, add_generation_prompt=True)
if hasattr(ids, "keys"):
ids = ids["input_ids"]
ids = list(ids)
if len(ids) + 2 * CHUNK + 1 > MAX_POS:
c.job = None
c.send({"type": "error", "text": f"this conversation is full ({MAX_POS} tokens): press Clear"})
return
n = 0
while n < min(len(ids) - 1, len(c.cached)) and ids[n] == c.cached[n]:
n += 1
job.update(state="read", messages=messages, ids=ids, pos=n, out=[], shown="", seconds=0.0, steps=0,
max_new=min(MAX_NEW, MAX_POS - len(ids) - CHUNK))
c.send({"type": "start", "prompt_tokens": len(ids) - n})
def finish(self, c: Chat, note: str = "") -> None:
job = c.job
text = job["shown"].strip()
c.history = job["messages"] + [{"role": "assistant", "content": text}]
c.cached = job["ids"] + job["out"][:-1]
rate = job["steps"] / job["seconds"] if job["seconds"] else 0.0
c.send({"type": "done", "tokens": len(job["out"]), "rate": rate, "note": note})
c.job = None
def warm(self) -> None:
"""After the GPU idled, Windows lowers its clocks: run the scratch chat until launches are fast."""
start = time.perf_counter()
best = float("inf")
while time.perf_counter() - start < 2.0:
t = time.perf_counter()
self.scratch.launch([1000], 0, CHUNK, False, self.sms)
torch.cuda.synchronize()
d = time.perf_counter() - t
if d > 0.97 * best and d < 1.2 * best:
break
best = min(best, d)
def loop(self) -> None:
while True:
with self.lock:
for c in self.chats:
if c.job is not None and c.job["state"] == "new":
self.begin(c)
working = [c for c in self.chats if c.job is not None][:MAX_AT_ONCE]
if not working:
self.wake.wait(0.5)
self.wake.clear()
continue
if time.perf_counter() - self.last_work > 1.0:
self.warm()
nb = max(NQ, self.sms // len(working))
plan = []
try:
for c in working:
job = c.job
if job["state"] == "read":
k = min(PREFILL, len(job["ids"]) - job["pos"])
c.launch(job["ids"][job["pos"]:job["pos"] + k], job["pos"], k, True, nb)
else:
k = min(CHUNK, job["max_new"] - len(job["out"]))
c.launch([job["out"][-1]], job["pos"], k, False, nb)
plan.append((c, k))
t = time.perf_counter()
torch.cuda.synchronize()
dt = time.perf_counter() - t
except Exception as e: # report it and drop the round's jobs
torch.cuda.synchronize()
with self.lock:
for c, _ in plan or [(c, 0) for c in working]:
if c.job is not None:
c.send({"type": "error", "text": f"GPU error: {e}"})
c.job = None
continue
self.last_work = time.perf_counter()
made = 0
with self.lock:
for c, k in plan:
job = c.job
outs = c.out[:k].tolist()
if job["state"] == "read":
job["pos"] += k
if job["pos"] == len(job["ids"]): # the prompt is read: its last step's word is the first answer word
job["state"] = "write"
job["out"] = [outs[k - 1]]
made += 1
self.emit(c)
if outs[k - 1] in self.eos:
self.finish(c)
continue
job["seconds"] += dt
job["steps"] += k
job["pos"] += k
for t_ in outs:
job["out"].append(t_)
made += 1
if t_ in self.eos:
break
self.emit(c)
if job["out"][-1] in self.eos:
self.finish(c)
elif len(job["out"]) >= job["max_new"]:
self.finish(c, "stopped at the length limit")
self.recent.append((time.perf_counter(), made))
def emit(self, c: Chat) -> None:
job = c.job
words = [t for t in job["out"] if t not in self.eos]
text = self.tok.decode(words, skip_special_tokens=True)
if text.endswith("�"): # half of a character: wait for the rest
text = text.rstrip("�")
if len(text) > len(job["shown"]) and text.startswith(job["shown"]):
c.send({"type": "delta", "text": text[len(job["shown"]):]})
job["shown"] = text
elif text != job["shown"] and not text.startswith(job["shown"]):
c.send({"type": "replace", "text": text})
job["shown"] = text
def handler(server: Server):
page = (HERE / "multichat.html").read_bytes()
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # quiet
pass
def reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
u = urlparse(self.path)
if u.path == "/":
return self.reply(200, page, "text/html; charset=utf-8")
if u.path == "/history":
cid = int(parse_qs(u.query).get("chat", ["0"])[0])
if not 0 <= cid < PANES:
return self.reply(404, b"{}")
return self.reply(200, json.dumps(server.transcript(cid)).encode())
if u.path == "/status":
with server.lock:
busy = [c.cid for c in server.chats if c.job is not None]
return self.reply(200, json.dumps({"total_rate": server.total_rate(), "busy": busy,
"sms": server.sms, "panes": PANES}).encode())
if u.path == "/events":
cid = int(parse_qs(u.query).get("chat", ["0"])[0])
if not 0 <= cid < PANES:
return self.reply(404, b"{}")
q: queue.Queue = queue.Queue()
c = server.chats[cid]
with server.lock:
c.listeners.append(q)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(b": open\n\n")
self.wfile.flush()
while True:
try:
ev = q.get(timeout=15)
self.wfile.write(f"data: {json.dumps(ev)}\n\n".encode())
except queue.Empty:
self.wfile.write(b": keep-alive\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
pass
finally:
with server.lock:
if q in c.listeners:
c.listeners.remove(q)
return
return self.reply(404, b"{}")
def do_POST(self):
u = urlparse(self.path)
n = int(self.headers.get("Content-Length", "0") or 0)
try:
body = json.loads(self.rfile.read(n) or b"{}")
cid = int(body.get("chat", -1))
except (ValueError, TypeError):
return self.reply(400, b'{"error": "bad request"}')
if not 0 <= cid < PANES:
return self.reply(404, b'{"error": "no such chat"}')
if u.path == "/send":
text = str(body.get("text", "")).strip()
if not text:
return self.reply(400, b'{"error": "empty message"}')
err = server.start(cid, text)
elif u.path == "/reset":
err = server.reset(cid)
else:
return self.reply(404, b"{}")
if err:
return self.reply(409, json.dumps({"error": err}).encode())
return self.reply(200, b'{"ok": true}')
return H
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8790)
args = ap.parse_args()
sys.stdout.reconfigure(encoding="utf-8")
server = Server()
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), handler(server))
httpd.daemon_threads = True
print(f"open http://127.0.0.1:{args.port} ({server.sms} SMs; up to {MAX_AT_ONCE} chats writing at once)", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()