Compare commits

..
10 Commits
17 changed files with 2326 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# prain placement: prithivMLmods/gemma-4-26B-A4B-it-Uncensored-MAX on an 8 GB RX 6600 XT
# Model: 30 MoE layers x 128 experts, top-8 routing, 11.34 MiB per expert (BF16).
# VRAM: 8 GiB - 4.45 GiB dense weights - 0.75 GiB KV cache - ~0.9 GiB runtime = ~1.7 GiB experts.
#
# Tiers: vram = pinned in a VRAM slot for the whole run
# ram = pinned host memory, copied into a cache slot when a token needs it
# disk = read from the pack file in pack_dir when a token needs it
# (the kernel page cache still caches it; nothing is pinned)
# [layers."<selector>"] sections apply in file order; a later section wins per expert.
# Layer selectors: "*", "12", "0-9,20". Expert lists: "*", "0-15,32", [1, 2, 3].
# Check what a file costs: prain check <this file> <model config.json>
# Compare variants offline: prain sim <trace> <this file> <model config.json>
# prain tune <trace> <model config.json> --expert-vram-gib 1.7 ...
# Relative paths start at this file's directory; "~" works too.
pack_dir = "../models/packs/gemma4-26b-a4b" # 42.5 GiB, written on the first run
default_tier = "disk" # runs on any RAM size; `prain tune` does better
cache_slots = 5 # 30 x 5 x 11.34 MiB = 1.66 GiB of VRAM
stats_path = "../models/packs/gemma4-26b-a4b/stats.json"
stats_every = 64 # MoE forward passes between stats/trace writes
trace_path = "../models/packs/gemma4-26b-a4b/trace.jsonl.gz" # routing trace for sim/tune
[policy] # which expert a cache slot gives up
eviction = "lfu" # lru | lfu (decayed frequency) | gdsf (frequency x refetch cost)
heat = "weight" # what makes an expert hot: count (tokens) | weight (router mass) |
# recency (router mass, later tokens weigh more: good for warm-ups)
lfu_half_life = 64 # forwards until an LFU score halves
disk_cost = 4.0 # gdsf: a disk expert costs this many RAM experts to refetch
recency_half_life = 32 # heat = "recency": tokens until a token's weight halves
scan_tokens = 64 # a forward with this many tokens (a prompt) is a scan:
scan_keep = 0 # it may not evict the scan_keep most valuable cached experts,
promote = 2 # and only its `promote` hottest experts get full priority
[prefetch] # copy experts before their layer runs, on a side stream
mode = "none" # none | pregate (run layer L+distance's router on layer L's input)
distance = 1 # 1 or 2 layers ahead
budget = 2 # experts per layer and forward
min_prob = 0.05 # skip guesses below this router probability
[miss] # an expert that is not in VRAM
mode = "copy" # copy | cpu (always compute it on the CPU) | auto (CPU if it has
cpu_max_tokens = 1 # at most cpu_max_tokens tokens, else copy)
cpu_threads = 4 # CPU threads of the vLLM worker (sets OMP_NUM_THREADS)
admit = 1 # cpu/auto: copy this many CPU-run experts into VRAM afterwards
[serve] # parallel requests (`prain serve` passes these to vLLM)
max_requests = 2 # requests vLLM runs at once (--max-num-seqs); warm-ups count too
kv_cache_gib = 0.75 # KV cache VRAM shared by them (--kv-cache-memory-bytes). vLLM logs
# "Maximum concurrency for 4096 tokens per request: N.NNx" at start;
# below max_requests, long requests wait or get preempted. Every GiB
# here is 90 expert cache slots less.
[draft] # answers that arrive while others use the GPU (`prain web --config`)
full_requests = 1 # answers that run with all their experts; later ones run at once as
# drafts on the experts already in VRAM (= max_requests: never draft)
min_weight = 0.5 # a draft token still copies its heaviest missing experts until this
# share of its router weight is computed (0 = only what is in VRAM)
refine = "regenerate" # regenerate: once a full slot frees, the draft is cut short and the
# answer generated again with all experts | none: keep the draft
+119
View File
@@ -0,0 +1,119 @@
"""OpenAI-compatible client pieces shared by the TUI and the WebUI. Stdlib only."""
import http.client
import json
import socket
import threading
import urllib.parse
import urllib.request
ABORTED = (OSError, ValueError, http.client.HTTPException) # what a cancelled request raises
def post(base_url, path, body, timeout=600):
request = urllib.request.Request(f"{base_url}{path}", json.dumps(body).encode(),
{"Content-Type": "application/json"})
return urllib.request.urlopen(request, timeout=timeout)
def send_request(base_url, path, body, timeout=600):
"""POST without waiting for the answer. Returns the connection: call getresponse() to
read it, or shutdown its socket from another thread to abort it (vLLM then cancels)."""
url = urllib.parse.urlsplit(base_url)
connection = http.client.HTTPConnection(url.hostname, url.port or 80, timeout=timeout)
connection.request("POST", path, json.dumps(body), {"Content-Type": "application/json"})
return connection
def first_model(base_url):
with urllib.request.urlopen(f"{base_url}/v1/models", timeout=30) as response:
return json.load(response)["data"][0]["id"]
def chat_body(model, messages, **extra):
return {"model": model, "messages": messages, "stream": True, **extra}
def draft_body(model, messages):
"""An answer computed from the experts already in VRAM: prain.drafts marks the request
by its vllm_xargs. Its own cache_salt keeps its KV blocks, computed with skipped experts,
out of the prefix-cache hits of full requests."""
return chat_body(model, messages, vllm_xargs={"prain": "draft"}, cache_salt="prain-draft")
def warm_body(model, messages, draft, priority=0):
"""Prefill the half-typed message and stop after one token. vLLM keeps its KV blocks
(prefix caching) and prain leaves the experts it routed to most in VRAM, so the real
request only prefills what was typed after this. Streamed, so aborting it is cheap.
priority > 0 queues it behind real requests (needs --scheduling-policy priority)."""
body = {"model": model, "messages": [*messages, {"role": "user", "content": draft.strip()}],
"max_tokens": 1, "temperature": 0, "stream": True,
"add_generation_prompt": False, "continue_final_message": True}
if priority:
body["priority"] = priority
return body
def stream_text(response):
"""Yield the content pieces of a streamed chat completion (server-sent events)."""
for raw in response:
line = raw.decode().strip()
if not line.startswith("data: ") or line == "data: [DONE]":
continue
piece = json.loads(line[6:])["choices"][0].get("delta", {}).get("content")
if piece:
yield piece
class Warmer:
"""Warm-up requests of one chat session. At most one is in flight; while it runs, newer
drafts replace each other and only the newest is sent next. cancel() drops the queued
draft and aborts the one in flight, so a sent message never waits behind a warm-up."""
def __init__(self, start):
self.start = start # start(job) -> connection from send_request()
self.lock = threading.Lock()
self.pending = None
self.busy = False
self.connection = None
self.sent = 0
def update(self, job):
with self.lock:
self.pending = job
if self.busy:
return
self.busy = True
threading.Thread(target=self._run, daemon=True).start()
def cancel(self):
with self.lock:
self.pending = None
connection = self.connection
if connection is not None and connection.sock is not None:
try:
connection.sock.shutdown(socket.SHUT_RDWR) # wakes the blocked reader
except OSError:
pass
def _run(self):
while True:
with self.lock:
job, self.pending = self.pending, None
if job is None:
self.busy = False
return
try:
connection = self.start(job)
with self.lock:
self.connection = connection
self.sent += 1
with connection.getresponse() as response:
response.read()
except ABORTED:
pass # best effort: the real request reports server errors
finally:
with self.lock:
connection, self.connection = self.connection, None
if connection is not None:
connection.close()
+198
View File
@@ -0,0 +1,198 @@
"""prain command line: check, plan or tune a placement, simulate it, serve with vLLM, and
chat in a TUI or the browser."""
import argparse
import json
import os
import sys
from dataclasses import asdict
from prain.config import (GIB, Miss, Policy, Prefetch, expert_bytes, format_ids, load_config,
moe_dims, plan, render_toml)
def _model_dims(hf_config_path):
with open(hf_config_path) as f:
num_layers, num_experts, top_k, hidden, inter = moe_dims(json.load(f))
return num_layers, num_experts, top_k, expert_bytes(hidden, inter)
def check(config_path, hf_config_path):
config = load_config(config_path)
num_layers, num_experts, top_k, size = _model_dims(hf_config_path)
print("layer vram ram disk cache_slots")
vram_rows = ram_rows = few_slots = 0
for layer in range(num_layers):
p = config.placement(layer, num_experts)
vram_rows += p.num_slots
ram_rows += len(p.ram)
few_slots += bool((p.ram or p.disk) and p.cache_slots < top_k)
print(f"{layer:5} {len(p.vram):4} {len(p.ram):4} {len(p.disk):4} {p.cache_slots:11}")
if few_slots:
print(f"warning: {few_slots} layers have fewer cache slots than top_k={top_k}; "
"their decode steps run in several rounds", file=sys.stderr)
print(f"expert size {size / 2**20:.2f} MiB")
print(f"VRAM {vram_rows * size / GIB:.2f} GiB (pinned experts + cache slots)")
print(f"KV {config.serve.kv_cache_gib:.2f} GiB (shared by up to "
f"{config.serve.max_requests} parallel requests)")
print(f"RAM {ram_rows * size / GIB:.2f} GiB (pinned host memory)")
print(f"disk {num_layers * num_experts * size / GIB:.2f} GiB (pack in {config.pack_dir})")
def _variant_text(variant):
policy, prefetch, miss = variant
fetch = "none" if prefetch.mode == "none" else f"pregate d{prefetch.distance} b{prefetch.budget}"
cpu = miss.mode if miss.mode == "copy" else f"{miss.mode} admit{miss.admit}"
return (f"{policy.eviction:5} {policy.heat:8} keep{policy.scan_keep} "
f"promote{policy.promote} {fetch:15} {cpu}")
def sim(trace, config_path, hf_config_path, cost_path, expert_mib):
from prain.sim import describe, load_cost, read_forwards, simulate
config = load_config(config_path)
_, num_experts, _, size = _model_dims(hf_config_path)
forwards = read_forwards(trace)
cost = load_cost(cost_path, expert_mib or size / 2**20)
layers = sorted({layer for fwd in forwards for layer in fwd})
placements = {layer: config.placement(layer, num_experts) for layer in layers}
variant = (config.policy, config.prefetch, config.miss)
result = simulate(forwards, placements, *variant, cost)
bound = simulate(forwards, placements, Policy(scan_tokens=config.policy.scan_tokens),
Prefetch(), Miss(), cost, belady=True)
print("\n".join(describe(result, bound, cost)))
def tune_command(args):
from prain.sim import describe, load_cost, read_forwards, tune
_, num_experts, _, size = _model_dims(args.hf_config)
cost = load_cost(args.cost, args.expert_mib or size / 2**20)
forwards = read_forwards(args.trace)
best, ram, per, final, bound, rows = tune(forwards, num_experts, args.expert_vram_gib,
args.ram_gib, cost)
print(" rank total_s decode_ms hit% eviction heat keep promote prefetch miss",
file=sys.stderr)
for rank, (variant, r) in enumerate(rows[: args.top], 1):
print(f"{rank:5} {r.total_ms / 1000:7.2f} {r.ms_per_decode:9.1f} {100 * r.hit_rate:4.1f}"
f" {_variant_text(variant)}", file=sys.stderr)
print("best, with per-layer cache slots:", file=sys.stderr)
print("\n".join(" " + line for line in describe(final, bound, cost)), file=sys.stderr)
policy, prefetch, miss = best
layers = {layer: {k: v for k, v in (("ram", format_ids(ram[layer])),
("cache_slots", per[layer])) if v != ""}
for layer in per}
print(render_toml(
f"generated by `prain tune` from {os.path.basename(args.trace)}",
{"pack_dir": os.path.abspath(args.pack_dir), "default_tier": "disk",
"cache_slots": min(per.values()), "trace_path": os.path.abspath(args.trace)},
{"policy": asdict(policy), "prefetch": asdict(prefetch), "miss": asdict(miss)},
layers,
), end="")
SERVE_OWNED = ("--enforce-eager", "--moe-backend", "--scheduling-policy", "--max-num-seqs",
"--kv-cache-memory-bytes") # set by `prain serve` from the placement file
def serve(args):
config = load_config(args.config) # fail now, not after vLLM spent minutes loading
extra = args.vllm_args[1:] if args.vllm_args[:1] == ["--"] else args.vllm_args
owned = [a for a in extra if a.split("=")[0] in SERVE_OWNED]
if owned:
raise SystemExit(f"prain serve: {' '.join(owned)} comes from the placement file "
"([serve] max_requests, kv_cache_gib); remove it after --")
os.environ["PRAIN_CONFIG"] = os.path.abspath(args.config)
if config.miss.mode != "copy": # vLLM pins torch to 1 CPU thread unless this is set
os.environ.setdefault("OMP_NUM_THREADS", str(config.miss.cpu_threads))
os.execvp("vllm", ["vllm", "serve", args.model, "--enforce-eager", "--moe-backend", "triton",
"--scheduling-policy", "priority",
"--max-num-seqs", str(config.serve.max_requests),
"--kv-cache-memory-bytes", str(int(config.serve.kv_cache_gib * GIB)),
*extra])
def main(argv=None):
parser = argparse.ArgumentParser(prog="prain")
sub = parser.add_subparsers(dest="command", required=True)
c = sub.add_parser("check", help="validate a placement file and print what it costs")
c.add_argument("config")
c.add_argument("hf_config", help="the model's config.json")
p = sub.add_parser("plan", help="print a placement file built from routing stats")
p.add_argument("stats", help="stats_path written by a prain run")
p.add_argument("hf_config", help="the model's config.json")
p.add_argument("--expert-vram-gib", type=float, required=True,
help="VRAM for experts: pinned ones plus cache slots")
p.add_argument("--ram-gib", type=float, required=True, help="pinned host memory for experts")
p.add_argument("--cache-slots", type=int, default=16)
p.add_argument("--pack-dir", required=True)
for name, text in (("sim", "simulate a placement file on a routing trace"),
("tune", "search policies and per-layer slots on a trace; prints a placement file")):
s = sub.add_parser(name, help=text)
s.add_argument("trace", help="trace_path written by a prain run, or `prain synth`")
if name == "sim":
s.add_argument("config")
s.add_argument("hf_config", help="the model's config.json")
s.add_argument("--cost", help="JSON from scripts/measure.py (default: RX 6600 XT guesses)")
s.add_argument("--expert-mib", type=float, help="what-if: expert size, e.g. 2.9 for int4")
if name == "tune":
s.add_argument("--expert-vram-gib", type=float, required=True)
s.add_argument("--ram-gib", type=float, required=True)
s.add_argument("--pack-dir", required=True)
s.add_argument("--top", type=int, default=10, help="variants to list")
y = sub.add_parser("synth", help="write a synthetic routing trace, to try sim and tune")
y.add_argument("out")
for flag, default in (("forwards", 300), ("layers", 30), ("experts", 128), ("top-k", 8),
("seed", 0)):
y.add_argument(f"--{flag}", type=int, default=default)
s = sub.add_parser("serve", help="exec `vllm serve` with the placement active")
s.add_argument("config")
s.add_argument("model")
s.add_argument("vllm_args", nargs=argparse.REMAINDER, help="extra vllm serve args after --")
for name, what in (("tui", "the terminal"), ("web", "the browser")):
ui = sub.add_parser(name, help=f"chat in {what}; experts warm up while you type")
ui.add_argument("--url", default="http://127.0.0.1:8000", help="the vLLM server")
ui.add_argument("--warmup", choices=("idle", "always", "off"), default="idle",
help="idle: not while an answer streams (default)")
ui.add_argument("--warmup-priority", type=int, default=1,
help="vLLM priority of warm-ups; 0 for a server without "
"--scheduling-policy priority")
if name == "web":
ui.add_argument("--port", type=int, default=8080)
ui.add_argument("--config", help="placement file: [serve] max_requests and the "
"[draft] table (default: their defaults)")
args = parser.parse_args(argv)
if args.command == "check":
check(args.config, args.hf_config)
elif args.command == "plan":
_, num_experts, _, size = _model_dims(args.hf_config)
with open(args.stats) as f:
stats = json.load(f)
print(plan(stats, num_experts, size, args.expert_vram_gib, args.ram_gib,
args.cache_slots, os.path.abspath(args.pack_dir), os.path.abspath(args.stats)), end="")
elif args.command == "sim":
sim(args.trace, args.config, args.hf_config, args.cost, args.expert_mib)
elif args.command == "tune":
tune_command(args)
elif args.command == "synth":
from prain.sim import synthetic_trace, write_trace
write_trace(args.out, synthetic_trace(args.layers, args.experts, args.top_k,
args.forwards, seed=args.seed))
elif args.command == "serve":
serve(args)
elif args.command == "tui":
from prain.tui import main as tui # curses only loads when needed
tui(args.url, args.warmup, args.warmup_priority)
else:
from prain.config import Config
from prain.web import serve as web
config = load_config(args.config) if args.config else Config(pack_dir="") # defaults
web(args.url, args.port, args.warmup, args.warmup_priority, config.serve, config.draft)
if __name__ == "__main__":
main()
+245
View File
@@ -57,3 +57,248 @@ def layer_index(name):
"""'language_model.model.layers.12.experts' -> 12, or None unless exactly one number."""
numbers = [int(part) for part in name.split(".") if part.isdigit()]
return numbers[0] if len(numbers) == 1 else None
@dataclass(frozen=True)
class LayerPlacement:
vram: tuple # pinned experts: slot i holds vram[i] for the whole run
ram: tuple # pinned host memory, copied into a cache slot on demand
disk: tuple # read from the pack file on demand
cache_slots: int # VRAM slots for ram/disk experts, managed by [policy]
@property
def num_slots(self):
return len(self.vram) + self.cache_slots
def _opt(default, choices=None, minimum=0, maximum=None):
return field(default=default, metadata={"choices": choices, "min": minimum, "max": maximum})
@dataclass(frozen=True)
class Policy:
"""[policy]: how the VRAM cache slots of every layer are managed."""
eviction: str = _opt("lfu", ("lru", "lfu", "gdsf"))
heat: str = _opt("weight", ("count", "weight", "recency"))
lfu_half_life: float = _opt(64.0, minimum=1) # forwards until an LFU score halves
disk_cost: float = _opt(4.0, minimum=1) # gdsf: refetching a disk expert vs. a RAM one
recency_half_life: float = _opt(32.0, minimum=1) # heat "recency": tokens per halving
scan_tokens: int = _opt(64, minimum=1) # forwards with this many tokens are scans
scan_keep: int = _opt(0) # cache slots a scan may not evict
promote: int = _opt(2) # experts a scan admits at full priority
@dataclass(frozen=True)
class Prefetch:
"""[prefetch]: copy experts before their layer runs."""
mode: str = _opt("none", ("none", "pregate"))
distance: int = _opt(1, minimum=1, maximum=2) # layers ahead
budget: int = _opt(2) # experts per layer and forward
min_prob: float = _opt(0.05, maximum=1) # ignore predictions below this router probability
@dataclass(frozen=True)
class Miss:
"""[miss]: what happens to an expert that is not in VRAM."""
mode: str = _opt("copy", ("copy", "cpu", "auto"))
cpu_max_tokens: int = _opt(1, minimum=1) # auto: CPU runs experts with <= this many tokens
cpu_threads: int = _opt(4, minimum=1) # OMP threads of the vLLM worker
admit: int = _opt(1) # CPU-run experts copied into VRAM afterwards, hottest first
@dataclass(frozen=True)
class Serve:
"""[serve]: the vLLM settings prain and its clients both need to know."""
max_requests: int = _opt(2, minimum=1) # requests vLLM runs at once (--max-num-seqs)
kv_cache_gib: float = _opt(0.75, minimum=0.01) # KV cache, shared by all requests
@dataclass(frozen=True)
class Draft:
"""[draft]: answers that arrive while other answers already use the GPU."""
full_requests: int = _opt(1, minimum=1) # answers that run with all their experts at once
min_weight: float = _opt(0.5, maximum=1) # router weight a draft token must still compute
refine: str = _opt("regenerate", ("regenerate", "none")) # what happens to a draft later
def _section(cls, raw, where):
if not isinstance(raw, dict):
raise ValueError(f"{where} must be a table")
spec = {f.name: f for f in fields(cls)}
_check_keys(raw, set(spec), where)
for name, value in raw.items():
meta, default = spec[name].metadata, spec[name].default
if meta["choices"]:
if value not in meta["choices"]:
raise ValueError(f"{where} {name} must be one of {meta['choices']}, got {value!r}")
continue
number = type(value) is int or (type(default) is float and type(value) is float)
top = meta["max"]
if not number or value < meta["min"] or (top is not None and value > top):
raise ValueError(f"{where} {name} must be a number in "
f"[{meta['min']}, {top if top is not None else 'inf'}], got {value!r}")
return cls(**raw)
@dataclass(frozen=True)
class Config:
pack_dir: str
default_tier: str = "ram"
cache_slots: int = 16
stats_path: str | None = None
stats_every: int = 256
trace_path: str | None = None
policy: Policy = Policy()
prefetch: Prefetch = Prefetch()
miss: Miss = Miss()
serve: Serve = Serve()
draft: Draft = Draft()
layers: tuple = () # ((selector, section), ...) in file order; later sections win
def placement(self, layer, num_experts):
tier = [self.default_tier] * num_experts
cache_slots = self.cache_slots
for selector, section in self.layers:
if selector != "*" and layer not in parse_ids(selector):
continue
seen = {}
for name in TIERS:
for expert in parse_ids(section.get(name, []), num_experts):
if expert in seen:
raise ValueError(
f"[layers.\"{selector}\"] puts expert {expert} in both "
f"{seen[expert]} and {name}"
)
seen[expert] = tier[expert] = name
cache_slots = section.get("cache_slots", cache_slots)
vram, ram, disk = (
tuple(e for e in range(num_experts) if tier[e] == name) for name in TIERS
)
if (ram or disk) and cache_slots < 1:
raise ValueError(
f"layer {layer}: cache_slots must be >= 1 while experts live in ram or disk"
)
if (ram or disk) and self.policy.scan_keep >= cache_slots:
raise ValueError(f"layer {layer}: policy.scan_keep must be < cache_slots ({cache_slots})")
return LayerPlacement(vram, ram, disk, cache_slots)
def _check_keys(table, allowed, where):
unknown = set(table) - allowed
if unknown:
raise ValueError(f"{where}: unknown keys {sorted(unknown)}, allowed: {sorted(allowed)}")
def _check_count(value, minimum, where):
if type(value) is not int or value < minimum:
raise ValueError(f"{where} must be an integer >= {minimum}, got {value!r}")
def _path(config_path, value):
"""A path from the file at config_path: '~' expands, relative paths start at that file's
directory, so a config can point at '../models/...' from anywhere."""
if value is None:
return None
return os.path.join(os.path.dirname(os.path.abspath(config_path)), os.path.expanduser(value))
def load_config(path):
with open(path, "rb") as f:
raw = tomllib.load(f)
_check_keys(raw, _TOP_KEYS, path)
if "pack_dir" not in raw:
raise ValueError(f"{path}: pack_dir is required")
layers = []
for selector, section in raw.get("layers", {}).items():
where = f'{path} [layers."{selector}"]'
if selector != "*":
parse_ids(selector)
if not isinstance(section, dict):
raise ValueError(f"{where} must be a table")
_check_keys(section, _LAYER_KEYS, where)
if "cache_slots" in section:
_check_count(section["cache_slots"], 0, f"{where} cache_slots")
layers.append((selector, section))
config = Config(
pack_dir=_path(path, raw["pack_dir"]),
default_tier=raw.get("default_tier", "ram"),
cache_slots=raw.get("cache_slots", 16),
stats_path=_path(path, raw.get("stats_path")),
stats_every=raw.get("stats_every", 256),
trace_path=_path(path, raw.get("trace_path")),
policy=_section(Policy, raw.get("policy", {}), f"{path} [policy]"),
prefetch=_section(Prefetch, raw.get("prefetch", {}), f"{path} [prefetch]"),
miss=_section(Miss, raw.get("miss", {}), f"{path} [miss]"),
serve=_section(Serve, raw.get("serve", {}), f"{path} [serve]"),
draft=_section(Draft, raw.get("draft", {}), f"{path} [draft]"),
layers=tuple(layers),
)
if config.draft.full_requests > config.serve.max_requests:
raise ValueError(f"{path}: [draft] full_requests must be <= [serve] max_requests "
f"({config.serve.max_requests})")
if config.default_tier not in TIERS:
raise ValueError(f"{path}: default_tier must be one of {TIERS}")
_check_count(config.cache_slots, 0, f"{path}: cache_slots")
_check_count(config.stats_every, 1, f"{path}: stats_every")
return config
def moe_dims(hf_config):
"""(num_layers, num_experts, top_k, hidden, moe_intermediate) of a HF config.json dict."""
c = hf_config.get("text_config") or hf_config
def first(*names):
for name in names:
if c.get(name) is not None:
return c[name]
raise KeyError(f"config.json has none of {names}")
return (
first("num_hidden_layers"),
first("num_experts", "num_local_experts", "n_routed_experts"),
first("top_k_experts", "num_experts_per_tok"),
first("hidden_size"),
first("moe_intermediate_size", "intermediate_size"),
)
def expert_bytes(hidden, moe_intermediate, dtype_bytes=2):
"""One gated expert: gate+up [2I, H] plus down [H, I]."""
return 3 * hidden * moe_intermediate * dtype_bytes
def render_toml(header, top, tables=None, layers=None):
"""A placement file: top-level keys, [tables], then [layers."N"] sections (empty ones skipped)."""
lines = [f"# {header}"] + [f"{k} = {json.dumps(v)}" for k, v in top.items() if v is not None]
for name, values in (tables or {}).items():
lines += ["", f"[{name}]"] + [f"{k} = {json.dumps(v)}" for k, v in values.items()]
for layer, values in sorted((layers or {}).items()):
if values:
lines += ["", f'[layers."{layer}"]'] + [f"{k} = {json.dumps(v)}" for k, v in values.items()]
return "\n".join(lines) + "\n"
def plan(stats, num_experts, expert_size, expert_vram_gib, ram_gib, cache_slots, pack_dir, stats_path):
"""Hottest experts (by steps that routed to them) pinned in VRAM, next in RAM, rest on disk."""
uses = {int(layer): s["uses"] for layer, s in stats["layers"].items()}
vram_rows = int(expert_vram_gib * GIB // expert_size) - cache_slots * len(uses)
if vram_rows < 0:
raise ValueError("expert VRAM budget is smaller than the cache slots alone")
ram_rows = int(ram_gib * GIB // expert_size)
ranked = sorted(
((layer, e) for layer in uses for e in range(num_experts)),
key=lambda le: (-uses[le[0]][le[1]], le),
)
tier = {le: "vram" if i < vram_rows else "ram" if i < vram_rows + ram_rows else "disk"
for i, le in enumerate(ranked)}
layers = {
layer: {name: format_ids(ids) for name in ("vram", "ram")
if (ids := [e for e in range(num_experts) if tier[(layer, e)] == name])}
for layer in uses
}
return render_toml(
"generated by `prain plan`; experts not listed live on disk",
{"pack_dir": pack_dir, "default_tier": "disk", "cache_slots": cache_slots,
"stats_path": stats_path},
layers=layers,
)
+34
View File
@@ -0,0 +1,34 @@
"""Which token rows of the current forward belong to draft requests. Stdlib only.
A request is a draft when its body carries "vllm_xargs": {"prain": "draft"}; vLLM hands
that to the worker as sampling_params.extra_args. Every step, vLLM's model runner (V2, the
default in 0.32) lays the tokens of its requests out back to back in batch order, and
prain.experts wraps its prepare_inputs to call note_step() with that layout. The MoE
layers of the forward that follows read spans().
"""
_DRAFTS = set() # request ids of unfinished drafts
_SPANS = [None] # [(first_row, end_row), ...] of the current forward's draft tokens, or None
def note_step(scheduler_output, req_ids, query_start_loc):
"""scheduler_output: vLLM's SchedulerOutput of this step; req_ids: request id per batch
position; query_start_loc: first token row per batch position, plus the total at the end."""
for new in scheduler_output.scheduled_new_reqs:
args = (new.sampling_params.extra_args or {}) if new.sampling_params else {}
if args.get("prain") == "draft":
_DRAFTS.add(new.req_id)
_DRAFTS.difference_update(scheduler_output.finished_req_ids)
spans = [(int(query_start_loc[i]), int(query_start_loc[i + 1]))
for i, req_id in enumerate(req_ids) if req_id in _DRAFTS]
_SPANS[0] = spans or None
def spans():
return _SPANS[0]
def clear():
"""Called after the last MoE layer: forwards without a note_step() (vLLM's warm-up and
profiling runs) must not see the previous step's layout."""
_SPANS[0] = None
+245
View File
@@ -0,0 +1,245 @@
"""Per-layer VRAM slot cache: which slot holds which expert, what to evict, what to copy
first, how scans and prefetched experts are admitted. Pure Python, no torch."""
import json
import math
import os
from bisect import bisect_right
from prain.config import Policy
class Lru:
"""Evict the expert used longest ago."""
def __init__(self, num_experts, policy, cost):
self.last = [0.0] * num_experts
def score(self, e, now):
return self.last[e]
def hit(self, e, now, heat):
self.last[e] = now
def admit(self, e, now, heat, low):
self.last[e] = -math.inf if low else now
def evicted(self, e):
pass
class Lfu:
"""Evict the expert with the lowest exponentially decayed heat. Scores survive eviction,
so an expert that comes back keeps its history."""
def __init__(self, num_experts, policy, cost):
self.decay = 0.5 ** (1 / policy.lfu_half_life)
self.value = [0.0] * num_experts
self.when = [0] * num_experts
def score(self, e, now):
return self.value[e] * self.decay ** (now - self.when[e])
def hit(self, e, now, heat):
self.value[e] = self.score(e, now) + heat
self.when[e] = now
def admit(self, e, now, heat, low):
if not low: # a low-priority admit keeps its history: never-seen experts score 0
self.hit(e, now, heat)
def evicted(self, e):
pass
class Gdsf:
"""GreedyDual-Size-Frequency: score = floor + frequency x refetch cost. The floor rises
to each victim's score, so experts that stopped being used age out. cost[e] is 1 for a
RAM expert and policy.disk_cost for a disk one, so slow-to-refetch experts stay longer."""
def __init__(self, num_experts, policy, cost):
self.cost = cost
self.floor = 0.0
self.freq = [0.0] * num_experts
self.value = [0.0] * num_experts
def score(self, e, now):
return self.value[e]
def hit(self, e, now, heat):
self.freq[e] += heat
self.value[e] = self.floor + self.freq[e] * self.cost[e]
def admit(self, e, now, heat, low):
self.freq[e] = 0.0 if low else heat
self.value[e] = self.floor + self.freq[e] * self.cost[e]
def evicted(self, e):
self.floor = max(self.floor, self.value[e])
class Belady:
"""The offline optimum, for the simulator only: evict the expert used furthest in the
future. future[e] lists the forward numbers that route to e, ascending."""
def __init__(self, future):
self.future = future
def score(self, e, now):
uses = self.future.get(e, ())
i = bisect_right(uses, now)
return -uses[i] if i < len(uses) else -math.inf
def hit(self, e, now, heat):
pass
def admit(self, e, now, heat, low):
pass
def evicted(self, e):
pass
POLICIES = {"lru": Lru, "lfu": Lfu, "gdsf": Gdsf}
class SlotCache:
"""Slots [0, len(pinned)) hold the pinned experts for the whole run. The cache_slots
slots after them hold experts from RAM or disk, managed by an eviction policy."""
def __init__(self, pinned, num_experts, cache_slots, policy=Policy(), cost=None, evictor=None):
self.num_experts = num_experts
self.cache_slots = cache_slots
self.policy = policy
self.pinned = {e: slot for slot, e in enumerate(pinned)}
self.slots = {} # cached expert -> slot
first = len(self.pinned)
self.free = list(range(first + cache_slots - 1, first - 1, -1)) # pop() -> lowest slot
cost = cost or [1.0] * num_experts
self.evictor = evictor or POLICIES[policy.eviction](num_experts, policy, cost)
self.now = 0 # forwards planned so far
self.recent = set() # experts routed to in the last forward
self.prefetched = set() # prefetched experts that were not used yet
self.uses = [0] * num_experts # forwards that routed to each expert
self.hits = 0 # routed expert already in VRAM
self.misses = 0 # routed expert copied in
self.cpu = 0 # routed expert computed on the CPU instead
self.prefetches = 0 # experts copied in ahead of time (prefetch or admit-behind)
self.prefetch_hits = 0 # ... that were used before being evicted
self.prefetch_wasted = 0 # ... that were evicted unused
def choose_cpu(self, counts, miss):
"""Experts the CPU should compute: counts maps expert -> routed tokens."""
if miss.mode == "copy":
return set()
away = (e for e in counts if e not in self.pinned and e not in self.slots)
if miss.mode == "cpu":
return set(away)
return {e for e in away if counts[e] <= miss.cpu_max_tokens}
def plan(self, heat, tokens=1, cpu=()):
"""Split one forward pass into rounds.
heat: {expert: heat > 0} for every expert routed to in this pass.
tokens: tokens in the pass; a pass with >= policy.scan_tokens tokens is a scan.
cpu: routed experts the CPU computes; they get no slot.
Returns [(slot_of, loads), ...]: slot_of maps each expert computed in that round to
its slot; loads lists the (expert, slot) copies to do before the round. Experts in
VRAM run in round 0. The others follow coldest first, so the hottest are copied last
and are the ones left in VRAM afterwards.
"""
self.now += 1
now, scan = self.now, tokens >= self.policy.scan_tokens
norm = {e: h / tokens for e, h in heat.items()} # per token, so scans don't dominate
for e in heat:
self.uses[e] += 1
self.recent = set(heat)
pinned = [e for e in heat if e in self.pinned]
resident = [e for e in heat if e in self.slots]
missing = sorted((e for e in heat if e not in self.pinned and e not in self.slots
and e not in cpu), key=lambda e: (heat[e], e))
self.hits += len(pinned) + len(resident)
self.misses += len(missing)
self.cpu += len(cpu)
for e in resident:
self.evictor.hit(e, now, norm[e])
if e in self.prefetched:
self.prefetched.discard(e)
self.prefetch_hits += 1
if missing and not self.cache_slots:
raise RuntimeError(f"experts {missing} are not in VRAM and there are no cache slots")
protected = set()
if scan and self.policy.scan_keep:
ranked = sorted(self.slots, key=lambda e: (self.evictor.score(e, now), e))
protected = set(ranked[-self.policy.scan_keep:])
busy = set(resident) # experts computed in the round being built
slot_of = {e: self.pinned[e] for e in pinned} | {e: self.slots[e] for e in resident}
rounds, i = [], 0
while True:
room = len(self.free) + sum(1 for e in self.slots if e not in busy and e not in protected)
group = missing[i:i + room]
if i < len(missing) and not group and not busy:
raise RuntimeError("no evictable cache slot: lower policy.scan_keep")
i += len(group)
loads = []
for e in group:
slot = self._take_slot(now, busy | protected)
self.slots[e] = slot
self.evictor.admit(e, now, norm[e], low=scan)
busy.add(e)
loads.append((e, slot))
slot_of.update(loads)
rounds.append((slot_of, loads))
if i >= len(missing):
break
slot_of, busy = {}, set()
if scan and self.policy.promote:
for e in missing[-self.policy.promote:]:
if e in self.slots:
self.evictor.hit(e, now, norm[e])
return rounds
def prefetch(self, predicted, budget):
"""Copy predicted experts in ahead of time, at low priority: they are evicted first
unless a forward uses them. Never evicts an expert routed to in the last forward.
predicted: [(expert, probability)], most likely first. Returns the (expert, slot) loads."""
loads, keep = [], set(self.recent)
for e, _ in predicted:
if len(loads) >= budget:
break
if e in self.pinned or e in self.slots:
continue
if not self.free and all(r in keep for r in self.slots):
break
slot = self._take_slot(self.now, keep)
self.slots[e] = slot
self.evictor.admit(e, self.now, 0.0, low=True)
self.prefetched.add(e)
keep.add(e)
loads.append((e, slot))
self.prefetches += len(loads)
return loads
def _take_slot(self, now, keep):
if self.free:
return self.free.pop()
victim = min((e for e in self.slots if e not in keep),
key=lambda e: (self.evictor.score(e, now), e))
self.evictor.evicted(victim)
if victim in self.prefetched:
self.prefetched.discard(victim)
self.prefetch_wasted += 1
return self.slots.pop(victim)
COUNTERS = ("hits", "misses", "cpu", "prefetches", "prefetch_hits", "prefetch_wasted")
def write_stats(path, caches):
"""Atomically write {"layers": {"<layer>": {"uses": [...], "hits": n, ...}}}."""
data = {"layers": {str(layer): {"uses": c.uses, **{k: getattr(c, k) for k in COUNTERS}}
for layer, c in sorted(caches.items())}}
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)
+311
View File
@@ -0,0 +1,311 @@
"""Routing traces, a synthetic trace generator, a simulator with a simple cost model, and a
tuner that searches policy variants and per-layer slot counts. Pure Python, no torch.
A trace is gzip JSON lines, one per MoE layer per forward, in execution order:
{"l": layer, "t": tokens, "e": [experts], "c": [tokens per expert], "w": [router weight
mass], "r": [recency-weighted mass], "p": {"1": [[expert, prob], ...], "2": [...]}}.
"p" holds predictions for layers l+1 and l+2 (pregate) and is optional. Routing does not
depend on placement, so one trace can evaluate any placement and policy.
"""
import gzip
import json
import random
from dataclasses import dataclass, field, replace
from prain.config import GIB, LayerPlacement, Miss, Policy, Prefetch
from prain.policy import COUNTERS, Belady, SlotCache
HEAT_FIELD = {"count": "c", "weight": "w", "recency": "r"}
@dataclass(frozen=True)
class Cost:
"""Rough RX 6600 XT numbers. scripts/measure.py writes measured ones as JSON."""
expert_mib: float = 11.34 # one expert
h2d_gbps: float = 12.0 # pinned RAM -> VRAM, PCIe 4.0 x8
disk_gbps: float = 3.0 # pack file -> RAM
round_ms: float = 0.12 # one fused-MoE kernel launch
route_ms: float = 0.0012 # GPU work per (token, expert) route
other_ms: float = 0.6 # attention + dense MLP of one layer per forward
sync_ms: float = 0.05 # host sync per layer
cpu_ms: float = 0.5 # CPU work per (token, expert) route
def copy_ms(self, disk):
mb = self.expert_mib * 1.048576
return mb / self.h2d_gbps + (mb / self.disk_gbps if disk else 0.0)
def load_cost(path, expert_mib):
values = {"expert_mib": expert_mib}
if path:
with open(path) as f:
values |= json.load(f)
values["expert_mib"] = expert_mib
return Cost(**values)
def write_trace(path, records, mode="wt"):
with gzip.open(path, mode) as f:
for r in records:
f.write(json.dumps(r, separators=(",", ":")) + "\n")
def read_forwards(path):
"""[{layer: record}] per forward, in execution order."""
forwards, last = [], None
with gzip.open(path, "rt") as f:
for line in f:
if not line.strip():
continue
r = json.loads(line)
if last is None or r["l"] <= last:
forwards.append({})
forwards[-1][r["l"]] = r
last = r["l"]
return forwards
def synthetic_trace(layers=30, experts=128, top_k=8, forwards=300, prompt_every=100,
prompt_tokens=128, zipf=1.1, stay=0.5, accuracy=0.7, seed=0):
"""Zipf-popular experts per layer, decode tokens that keep some of the previous token's
experts, a prompt every prompt_every forwards, and pregate predictions that are right
with probability accuracy (accuracy**2 two layers ahead)."""
rng = random.Random(seed)
rank = [rng.sample(range(experts), experts) for _ in range(layers)]
weights = [1 / (i + 1) ** zipf for i in range(experts)]
previous = [[] for _ in range(layers)]
records = []
for f in range(forwards):
tokens = prompt_tokens if f % prompt_every == 0 else 1
routed = []
for layer in range(layers):
count, recency = {}, {}
for t in range(tokens):
chosen = {e for e in previous[layer] if rng.random() < stay} if tokens == 1 else set()
while len(chosen) < top_k:
chosen.add(rank[layer][rng.choices(range(experts), weights)[0]])
decay = 0.5 ** ((tokens - 1 - t) / 32)
for e in chosen:
count[e] = count.get(e, 0) + 1
recency[e] = recency.get(e, 0.0) + decay / top_k
if tokens == 1:
previous[layer] = sorted(chosen)
routed.append((count, recency))
for layer, (count, recency) in enumerate(routed):
ids = sorted(count)
record = {"l": layer, "t": tokens, "e": ids, "c": [count[e] for e in ids],
"w": [round(count[e] / top_k, 4) for e in ids],
"r": [round(recency[e], 4) for e in ids]}
predictions = {}
for d in (1, 2):
if layer + d < layers:
nxt = routed[layer + d][0]
guess = []
for e in sorted(nxt, key=lambda e: (-nxt[e], e))[:top_k]:
g = e if rng.random() < accuracy ** d else rng.randrange(experts)
if g not in guess:
guess.append(g)
predictions[str(d)] = [[g, round(1 / top_k, 4)] for g in guess]
if predictions:
record["p"] = predictions
records.append(record)
return records
@dataclass
class Result:
total_ms: float = 0.0
decode_ms: float = 0.0 # summed over forwards with fewer than scan_tokens tokens
decode_forwards: int = 0
prefill_ms: float = 0.0
prefill_tokens: int = 0
counters: dict = field(default_factory=dict)
layer_ms: dict = field(default_factory=dict)
@property
def hit_rate(self):
c = self.counters
return c["hits"] / max(1, c["hits"] + c["misses"] + c["cpu"])
@property
def ms_per_decode(self):
return self.decode_ms / max(1, self.decode_forwards)
def _future(forwards, layer):
future, n = {}, 0
for fwd in forwards:
if layer in fwd:
n += 1
for e in fwd[layer]["e"]:
future.setdefault(e, []).append(n)
return future
def simulate(forwards, placements, policy, prefetch, miss, cost, belady=False):
"""Replay a trace. Model: one PCIe link serves copies in issue order; demand copies of
a round wait for the previous round's kernel; async copies (prefetch, admit-behind) are
issued after the layer's demand copies; the CPU path runs alongside the GPU."""
if prefetch.mode == "pregate" and not any("p" in r for fwd in forwards for r in fwd.values()):
raise ValueError("prefetch.mode = pregate needs a trace with predictions ('p')")
caches, disk = {}, {}
for layer, p in placements.items():
n = len(p.vram) + len(p.ram) + len(p.disk)
disk[layer] = set(p.disk)
cost_of = [policy.disk_cost if e in disk[layer] else 1.0 for e in range(n)]
evictor = Belady(_future(forwards, layer)) if belady else None
caches[layer] = SlotCache(p.vram, n, p.cache_slots, policy, cost_of, evictor)
result = Result(layer_ms={layer: 0.0 for layer in caches})
key, t, link, ready = HEAT_FIELD[policy.heat], 0.0, 0.0, {}
for fwd in forwards:
start, tokens = t, 0
for layer in sorted(fwd):
if layer not in caches:
continue
rec, cache = fwd[layer], caches[layer]
tokens = rec["t"]
counts = dict(zip(rec["e"], rec["c"]))
heat = dict(zip(rec["e"], rec[key]))
cpu = cache.choose_cpu(counts, miss)
gpu = t + cost.sync_ms
for slot_of, loads in cache.plan(heat, tokens, cpu):
if loads:
link = max(link, gpu) + sum(cost.copy_ms(e in disk[layer]) for e, _ in loads)
gpu = link
land = max((ready.pop((layer, e), 0.0) for e in slot_of), default=0.0)
gpu = max(gpu, land) + cost.round_ms + cost.route_ms * sum(counts[e] for e in slot_of)
cpu_end = t + cost.sync_ms + cost.cpu_ms * sum(counts[e] for e in cpu)
later = []
if cpu and miss.admit:
hot = sorted((e for e in cpu if e not in disk[layer]), key=lambda e: (-heat[e], e))
later += [(layer, e) for e, _ in cache.prefetch([(e, 1.0) for e in hot], miss.admit)]
target = layer + prefetch.distance
if prefetch.mode == "pregate" and target in caches:
guesses = [(e, p) for e, p in rec.get("p", {}).get(str(prefetch.distance), [])
if p >= prefetch.min_prob and e not in disk[target]]
later += [(target, e) for e, _ in caches[target].prefetch(guesses, prefetch.budget)]
for where in later:
link = max(link, t + cost.sync_ms) + cost.copy_ms(False)
ready[where] = link
end = max(gpu, cpu_end) + cost.other_ms
result.layer_ms[layer] += end - t
t = end
if tokens < policy.scan_tokens:
result.decode_ms += t - start
result.decode_forwards += 1
else:
result.prefill_ms += t - start
result.prefill_tokens += tokens
result.total_ms = t
result.counters = {k: sum(getattr(c, k) for c in caches.values()) for k in COUNTERS}
return result
def describe(result, bound, cost):
c = result.counters
copied = (c["misses"] + c["prefetches"]) * cost.expert_mib / 1024
rate = 1000 / result.ms_per_decode if result.decode_forwards else 0.0
return [
f"forwards {result.decode_forwards} decode + prefill of {result.prefill_tokens} tokens",
f"VRAM hit rate {100 * result.hit_rate:.1f}% "
f"(eviction alone at best: {100 * bound.hit_rate:.1f}%)",
f"copies {c['misses']} + prefetched {c['prefetches']} (used {c['prefetch_hits']}, "
f"wasted {c['prefetch_wasted']}) = {copied:.1f} GiB, CPU-run {c['cpu']}",
f"decode {result.ms_per_decode:.1f} ms/forward ({rate:.1f} tok/s), "
f"prefill {result.prefill_ms / max(1, result.prefill_tokens):.2f} ms/token",
f"simulated total {result.total_ms / 1000:.2f} s",
]
DIMENSIONS = (
("policy", "eviction", ("lru", "lfu", "gdsf")),
("policy", "heat", ("count", "weight", "recency")),
("policy", "scan_keep", (0, 1, 2)),
("policy", "promote", (0, 2, 4)),
("prefetch", "mode", ("none", "pregate")),
("prefetch", "distance", (1, 2)),
("prefetch", "budget", (1, 2, 4)),
("miss", "mode", ("copy", "auto", "cpu")),
("miss", "admit", (0, 1, 2)),
)
_PART = {"policy": 0, "prefetch": 1, "miss": 2}
def _with(variant, table, name, value):
parts = list(variant)
parts[_PART[table]] = replace(parts[_PART[table]], **{name: value})
return tuple(parts)
def ram_tier(forwards, layers, num_experts, rows):
"""The `rows` most-used (layer, expert) pairs of the trace -> {layer: set of experts}."""
uses = {(layer, e): 0 for layer in layers for e in range(num_experts)}
for fwd in forwards:
for layer, rec in fwd.items():
for e in rec["e"]:
uses[(layer, e)] += 1
ranked = sorted(uses, key=lambda le: (-uses[le], le))[:rows]
ram = {layer: set() for layer in layers}
for layer, e in ranked:
ram[layer].add(e)
return ram
def tune(forwards, num_experts, expert_vram_gib, ram_gib, cost, base=(Policy(), Prefetch(), Miss()),
passes=2, window=3):
"""Coordinate descent over DIMENSIONS with uniform slots, then per-layer slot counts by
greedy marginal gain. Returns (variant, ram, slots_per_layer, result, bound, rows)."""
layers = sorted({layer for fwd in forwards for layer in fwd})
expert_size = cost.expert_mib * 2**20
slots = int(expert_vram_gib * GIB // expert_size) // len(layers)
if slots < 1:
raise ValueError("expert VRAM budget is less than one slot per layer")
ram = ram_tier(forwards, layers, num_experts, int(ram_gib * GIB // expert_size))
def placements(per_layer):
return {layer: LayerPlacement((), tuple(sorted(ram[layer])),
tuple(e for e in range(num_experts) if e not in ram[layer]),
per_layer[layer]) for layer in layers}
uniform = placements({layer: slots for layer in layers})
predicted = any("p" in r for fwd in forwards for r in fwd.values())
results = {}
def run(variant):
if variant not in results:
ok = variant[0].scan_keep < slots
results[variant] = simulate(forwards, uniform, *variant, cost) if ok else None
return results[variant]
best = base
if best[1].mode == "pregate" and not predicted:
best = _with(best, "prefetch", "mode", "none")
for _ in range(passes):
for table, name, values in DIMENSIONS:
if table == "prefetch" and (not predicted or (name != "mode" and best[1].mode == "none")):
continue
if (table, name) == ("miss", "admit") and best[2].mode == "copy":
continue
scored = [(r.total_ms, i, v) for i, v in enumerate(_with(best, table, name, x) for x in values)
if (r := run(v)) is not None]
best = min(scored)[2]
lo, hi = max(1, slots - window, best[0].scan_keep + 1), slots + window
curves = {}
for s in range(lo, hi + 1):
r = simulate(forwards, placements({layer: s for layer in layers}), *best, cost)
curves |= {(layer, s): ms for layer, ms in r.layer_ms.items()}
per = {layer: lo for layer in layers}
for _ in range((slots - lo) * len(layers)):
gains = [(curves[(layer, per[layer])] - curves[(layer, per[layer] + 1)], -layer)
for layer in layers if per[layer] < hi]
if not gains:
break
per[-max(gains)[1]] += 1
final = simulate(forwards, placements(per), *best, cost)
if final.total_ms > results[best].total_ms: # greedy on approximate curves can lose
per, final = {layer: slots for layer in layers}, results[best]
bound = simulate(forwards, placements(per), Policy(scan_tokens=best[0].scan_tokens),
Prefetch(), Miss(), cost, belady=True)
rows = sorted((r.total_ms, str(v), v, r) for v, r in results.items() if r is not None)
return best, ram, per, final, bound, [(v, r) for _, _, v, r in rows]
+115
View File
@@ -0,0 +1,115 @@
"""prain tui: a terminal chat that warms experts up while you type. Stdlib curses."""
import curses
import locale
import os
import queue
import textwrap
import threading
import time
from prain.chat import (ABORTED, Warmer, chat_body, first_model, post, send_request,
stream_text, warm_body)
WARMUP_MODES = ("idle", "always", "off")
class Chat:
"""Conversation state without curses, so tests can drive it.
warmup: "idle" warms up only while no answer streams, "always" also during one, "off" never."""
def __init__(self, base_url, model, warmup="idle", priority=1):
self.base_url, self.model, self.warmup = base_url, model, warmup
self.messages = [] # finished turns
self.draft = ""
self.reply = None # the answer text while it streams, else None
self.status = "type a message; Enter sends, Esc quits"
self.events = queue.Queue()
self.warmer = Warmer(lambda draft: send_request(
base_url, "/v1/chat/completions", warm_body(model, self.messages, draft, priority)))
def type(self, text):
self.draft = text
idle = self.reply is None
if text.strip() and (self.warmup == "always" or (self.warmup == "idle" and idle)):
self.warmer.update(text)
def send(self):
if self.reply is not None or not self.draft.strip():
return
self.warmer.cancel() # a queued draft is stale now; the in-flight one only delays us
self.messages.append({"role": "user", "content": self.draft.strip()})
self.draft, self.reply = "", ""
threading.Thread(target=self._stream, args=(list(self.messages),), daemon=True).start()
def _stream(self, messages):
start, first, pieces = time.monotonic(), None, 0
try:
with post(self.base_url, "/v1/chat/completions", chat_body(self.model, messages)) as r:
for piece in stream_text(r):
first = first or time.monotonic()
pieces += 1
self.events.put(("piece", piece))
rate = (pieces - 1) / (time.monotonic() - first) if pieces > 1 else 0.0
ttft = (first or time.monotonic()) - start
self.events.put(("done", f"first token {ttft:.1f} s, {rate:.1f} tok/s"))
except ABORTED as e:
self.events.put(("done", f"error: {e}"))
def poll(self):
"""Apply streamed pieces; call from the UI thread."""
while True:
try:
kind, value = self.events.get_nowait()
except queue.Empty:
return
if kind == "piece":
self.reply += value
else:
self.messages.append({"role": "assistant", "content": self.reply})
self.reply, self.status = None, value
def draw(screen, chat):
screen.erase()
height, width = screen.getmaxyx()
turns = chat.messages + ([{"role": "assistant", "content": chat.reply}]
if chat.reply is not None else [])
lines = []
for turn in turns:
prefix = "you> " if turn["role"] == "user" else "ai> "
for paragraph in (prefix + turn["content"]).split("\n"):
lines += textwrap.wrap(paragraph, width - 1) or [""]
for y, line in enumerate(lines[-(height - 2):]):
screen.addstr(y, 0, line)
warming = "warming experts... " if chat.warmer.busy else ""
screen.addstr(height - 2, 0, f"[{chat.model}] {warming}{chat.status}"[: width - 1],
curses.A_REVERSE)
screen.addstr(height - 1, 0, ("> " + chat.draft)[-(width - 1):])
screen.refresh()
def run(screen, chat):
screen.timeout(50) # redraw at least every 50 ms while an answer streams
while True:
chat.poll()
draw(screen, chat)
try:
key = screen.get_wch()
except curses.error:
continue # nothing typed
if key in ("\x1b", "\x04"): # Esc or Ctrl-D
return
if key in ("\n", "\r", curses.KEY_ENTER):
chat.send()
elif key in (curses.KEY_BACKSPACE, "\x7f", "\b"):
chat.type(chat.draft[:-1])
elif isinstance(key, str) and key.isprintable():
chat.type(chat.draft + key)
def main(base_url, warmup="idle", priority=1):
locale.setlocale(locale.LC_ALL, "")
os.environ.setdefault("ESCDELAY", "25") # Esc quits at once, not after 1 s
chat = Chat(base_url, first_model(base_url), warmup, priority)
curses.wrapper(run, chat)
+274
View File
@@ -0,0 +1,274 @@
"""prain web: a one-page browser chat that warms experts up while you type.
A small stdlib proxy in front of vLLM. GET / serves the page. POST /warm takes
{"session", "messages", "draft"} and returns at once. POST /chat takes {"session",
"messages"} and streams server-sent events: vLLM's chunks, plus {"prain": "draft" |
"refine" | "error"} events of its own. Binds to 127.0.0.1; use an SSH tunnel from other
machines.
Several browsers at once ([serve] and [draft] tables of the placement file):
- At most draft.full_requests answers run with all their experts. An answer that arrives
while they are taken runs as a draft right away, on the experts already in VRAM (see
prain.drafts), instead of waiting or evicting the other answers' experts.
- With draft.refine = "regenerate", the oldest waiting draft takes the next free full slot:
its draft request is cancelled and the answer is generated again with all experts. The
page shows the draft in grey and the full answer over it.
- Every session has its own Warmer. warmup="idle": no warm-ups while any answer streams;
"always": warm-ups while vLLM has a free request slot (serve.max_requests). Sending cancels
that session's warm-up. A browser that goes away closes the upstream request, and vLLM
cancels it.
"""
import json
import threading
import urllib.error
from collections import OrderedDict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from prain.chat import Warmer, chat_body, draft_body, first_model, post, send_request, warm_body
from prain.config import Draft, Serve
MAX_SESSIONS = 64
PAGE = """<!doctype html>
<meta charset="utf-8">
<title>prain</title>
<style>
body { font: 15px/1.45 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
.user { color: #666; margin-top: 1rem; }
.assistant { white-space: pre-wrap; }
.draft { color: #999; }
textarea { width: 100%; height: 5rem; font: inherit; margin-top: 1rem; }
#status { color: #888; font-size: 13px; }
</style>
<div id="log"></div>
<textarea id="draft" autofocus
placeholder="Experts load while you type. Enter sends, Shift+Enter adds a line."></textarea>
<div id="status"></div>
<script>
const session = Math.random().toString(36).slice(2) + Date.now().toString(36);
const messages = [];
const log = document.getElementById("log");
const draft = document.getElementById("draft");
const status = document.getElementById("status");
let streaming = false;
function show(role, text) {
const div = document.createElement("div");
div.className = role;
div.textContent = text;
log.appendChild(div);
return div;
}
draft.addEventListener("input", () => {
if (!streaming && draft.value.trim())
fetch("/warm", {method: "POST", body: JSON.stringify({session, messages, draft: draft.value})});
});
draft.addEventListener("keydown", async (event) => {
if (event.key !== "Enter" || event.shiftKey || streaming || !draft.value.trim()) return;
event.preventDefault();
const text = draft.value.trim();
messages.push({role: "user", content: text});
show("user", "you> " + text);
draft.value = "";
streaming = true;
const out = show("assistant", "");
const finalSpan = out.appendChild(document.createElement("span"));
const draftSpan = out.appendChild(document.createElement("span"));
draftSpan.className = "draft";
let mode = "full", draftText = "", finalText = "", refined = false;
const render = () => { // the full text, then the grey rest of the draft while they agree
finalSpan.textContent = finalText;
draftSpan.textContent = mode === "draft" ? draftText
: mode === "refine" && draftText.startsWith(finalText) ? draftText.slice(finalText.length) : "";
};
let start = performance.now(), first = 0, pieces = 0, buffer = "";
const response = await fetch("/chat", {method: "POST", body: JSON.stringify({session, messages})});
if (!response.ok) {
out.textContent = "error " + response.status + ": " + await response.text();
messages.pop();
streaming = false;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
const data = JSON.parse(line.slice(6));
if (data.prain === "draft") {
mode = "draft";
status.textContent = "GPU busy: drafting from the experts in VRAM, refined when it is free";
} else if (data.prain === "refine") {
mode = "refine";
refined = true;
start = performance.now(), first = 0, pieces = 0;
status.textContent = "refining with all experts";
} else if (data.prain === "error") {
mode = "draft";
refined = false;
status.textContent = "refining failed (HTTP " + data.status + "), keeping the draft";
} else {
const piece = data.choices[0].delta.content;
if (!piece) continue;
if (pieces++ === 0) first = performance.now();
if (mode === "draft") draftText += piece; else finalText += piece;
}
render();
}
}
messages.push({role: "assistant", content: mode === "draft" ? draftText : finalText});
let same = 0;
while (same < draftText.length && draftText[same] === finalText[same]) same++;
const seconds = (performance.now() - first) / 1000;
status.textContent = (refined ? `draft kept ${same} of ${draftText.length} chars; refined: `
: mode === "draft" ? "draft, not refined: " : "") +
`first token ${((first - start) / 1000).toFixed(1)} s, ` +
`${pieces > 1 ? ((pieces - 1) / seconds).toFixed(1) : "0.0"} tok/s`;
streaming = false;
});
</script>
"""
def make_server(base_url, port, host="127.0.0.1", warmup="idle", priority=1, serve=Serve(),
draft=Draft()):
model = first_model(base_url)
lock = threading.Condition()
sessions = OrderedDict() # session -> Warmer, least recently used first
streams = [0] # answers streaming right now, all sessions, full or draft
full = [0] # answers holding one of the draft.full_requests full slots
waiting = deque() # drafts waiting for a full slot to refine, oldest first
refine = draft.refine == "regenerate"
def warmer(session):
with lock:
if session not in sessions:
sessions[session] = Warmer(lambda job: send_request(
base_url, "/v1/chat/completions", warm_body(model, *job, priority)))
if len(sessions) > MAX_SESSIONS:
sessions.popitem(last=False)[1].cancel()
sessions.move_to_end(session)
return sessions[session]
def my_turn(ticket): # call with the lock held
return full[0] < draft.full_requests and bool(waiting) and waiting[0] is ticket
def turn_now(ticket):
with lock:
return my_turn(ticket)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.reply(200, "text/html; charset=utf-8", PAGE.encode())
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
session = str(body.get("session", ""))
if self.path == "/warm":
with lock:
busy = streams[0]
if body["draft"].strip() and ((warmup == "idle" and not busy) or
(warmup == "always" and busy < serve.max_requests)):
warmer(session).update((body["messages"], body["draft"]))
return self.reply(204, "text/plain", b"")
if self.path != "/chat":
return self.reply(404, "text/plain", b"not found")
warmer(session).cancel()
messages, ticket = body["messages"], object()
with lock:
streams[0] += 1
holds_full = full[0] < draft.full_requests and not waiting
if holds_full:
full[0] += 1
elif refine:
waiting.append(ticket)
try:
if holds_full:
if not self.relay(chat_body(model, messages), opens=True):
return
else:
if not self.relay(draft_body(model, messages), opens=True, event="draft",
stop=(lambda: turn_now(ticket)) if refine else None):
return
if refine:
with lock:
lock.wait_for(lambda: my_turn(ticket))
waiting.popleft()
full[0] += 1
holds_full = True
self.relay(chat_body(model, messages), opens=False, event="refine")
self.send_event("[DONE]")
except (BrokenPipeError, ConnectionResetError):
pass # the browser left; closing upstream makes vLLM cancel the request
finally:
with lock:
streams[0] -= 1
if holds_full:
full[0] -= 1
if ticket in waiting:
waiting.remove(ticket)
lock.notify_all()
def relay(self, body, opens, event=None, stop=None):
"""Stream one vLLM answer to the browser, without its [DONE]. opens: this starts
the response, so a vLLM error becomes the HTTP status (returns False). stop():
checked after every chunk; True closes the upstream request early."""
try:
upstream = post(base_url, "/v1/chat/completions", body)
except urllib.error.HTTPError as e: # vLLM rejected it: pass its answer on
with e:
data = e.read()
if opens:
self.reply(e.code, "application/json", data)
return False
self.send_event({"prain": "error", "status": e.code})
return True
with upstream:
if opens:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
if event:
self.send_event({"prain": event})
for line in upstream:
if line.strip() in (b"", b"data: [DONE]"):
continue
self.wfile.write(line.rstrip(b"\n") + b"\n\n")
self.wfile.flush()
if stop is not None and stop():
break
return True
def send_event(self, data):
text = data if isinstance(data, str) else json.dumps(data)
self.wfile.write(f"data: {text}\n\n".encode())
self.wfile.flush()
def reply(self, code, kind, data):
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
server = ThreadingHTTPServer((host, port), Handler)
server.sessions, server.streams, server.full = sessions, streams, full # for tests
return server
def serve(base_url, port, warmup="idle", priority=1, serve=Serve(), draft=Draft()):
server = make_server(base_url, port, warmup=warmup, priority=priority, serve=serve, draft=draft)
print(f"prain web: http://127.0.0.1:{server.server_port} (vLLM at {base_url}; "
f"{draft.full_requests} full of {serve.max_requests} parallel answers)")
server.serve_forever()
+60
View File
@@ -0,0 +1,60 @@
import threading
import time
import unittest
import prain.chat as chat
from tests.helpers import StubVllm, wait_for
class Chat(unittest.TestCase):
def setUp(self):
self.stub = StubVllm()
self.addCleanup(self.stub.close)
def warmer(self):
return chat.Warmer(lambda draft: chat.send_request(
self.stub.url, "/v1/chat/completions", chat.warm_body("m", [], draft, priority=1)))
def test_warm_body_continues_the_half_typed_message(self):
body = chat.warm_body("m", [{"role": "assistant", "content": "hi"}], " Explain mix ", 1)
self.assertEqual(body["messages"][-1], {"role": "user", "content": "Explain mix"})
self.assertEqual(
(body["max_tokens"], body["add_generation_prompt"], body["continue_final_message"],
body["stream"], body["priority"]), (1, False, True, True, 1))
self.assertNotIn("priority", chat.warm_body("m", [], "x"))
def test_draft_body_marks_the_request_and_salts_its_cache(self):
body = chat.draft_body("m", [{"role": "user", "content": "hi"}])
self.assertEqual(body, {"model": "m", "messages": [{"role": "user", "content": "hi"}],
"stream": True, "vllm_xargs": {"prain": "draft"},
"cache_salt": "prain-draft"})
def test_first_model_and_streamed_text(self):
self.assertEqual(chat.first_model(self.stub.url), "stub-model")
with chat.post(self.stub.url, "/v1/chat/completions",
chat.chat_body("stub-model", [], max_tokens=5)) as response:
self.assertEqual(list(chat.stream_text(response)), ["Hel", "lo", "!"])
self.assertEqual((self.stub.bodies[0]["stream"], self.stub.bodies[0]["max_tokens"]),
(True, 5))
def test_warmer_keeps_one_request_in_flight_and_sends_the_newest_draft(self):
self.stub.delay = 0.3
warmer = self.warmer()
warmer.update("a")
wait_for(lambda: len(self.stub.bodies) == 1)
warmer.update("ab") # "a" is still in flight: these two queue up,
warmer.update("abc") # and only the newest is sent
wait_for(lambda: not warmer.busy)
self.assertEqual([b["messages"][-1]["content"] for b in self.stub.bodies], ["a", "abc"])
def test_cancel_drops_the_queue_and_aborts_the_request_in_flight(self):
self.stub.delay = 3.0
warmer = self.warmer()
warmer.update("slow")
wait_for(lambda: self.stub.bodies)
warmer.update("queued")
start = time.monotonic()
warmer.cancel()
wait_for(lambda: not warmer.busy, timeout=2.0)
self.assertLess(time.monotonic() - start, 1.0) # did not wait out the 3 s answer
self.assertEqual(len(self.stub.bodies), 1) # "queued" was never sent
+78
View File
@@ -0,0 +1,78 @@
import contextlib
import io
import json
import os
import unittest
from unittest import mock
from prain.cli import main
from prain.config import load_config
from tests.helpers import TmpCase
GEMMA4 = {"text_config": {"num_hidden_layers": 30, "num_experts": 128, "top_k_experts": 8,
"hidden_size": 2816, "moe_intermediate_size": 704}}
class Cli(TmpCase):
def run_cli(self, *argv):
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
main(list(argv))
return out.getvalue(), err.getvalue()
def test_check_prints_tier_sizes(self):
hf = self.write("config.json", json.dumps(GEMMA4))
config = self.write("p.toml", 'pack_dir = "/packs"\ncache_slots = 4\n')
out, err = self.run_cli("check", config, hf)
self.assertIn(" 0 0 128 0 4", out)
self.assertTrue(out.endswith(
"expert size 11.34 MiB\n"
"VRAM 1.33 GiB (pinned experts + cache slots)\n"
"KV 0.75 GiB (shared by up to 2 parallel requests)\n"
"RAM 42.54 GiB (pinned host memory)\n"
"disk 42.54 GiB (pack in /packs)\n"), out)
self.assertIn("warning: 30 layers have fewer cache slots than top_k=8", err)
def test_plan_output_is_a_valid_config(self):
hf = self.write("config.json", json.dumps(GEMMA4))
stats = {"layers": {str(layer): {"uses": list(range(128))} for layer in range(30)}}
stats_path = self.write("stats.json", json.dumps(stats))
out, _ = self.run_cli("plan", stats_path, hf, "--expert-vram-gib", "8",
"--ram-gib", "16", "--pack-dir", "/packs")
config = self.write("planned.toml", out)
check_out, _ = self.run_cli("check", config, hf)
self.assertIn("VRAM 8.00 GiB", check_out) # budgets are filled, never exceeded
self.assertIn("RAM 16.00 GiB", check_out)
def test_serve_execs_vllm_with_the_required_flags(self):
config = self.write("p.toml", 'pack_dir = "/packs"\n[miss]\nmode = "auto"\ncpu_threads = 6\n'
'[serve]\nmax_requests = 3\nkv_cache_gib = 1.0\n')
with mock.patch("os.execvp") as execvp, mock.patch.dict(os.environ, clear=False):
os.environ.pop("OMP_NUM_THREADS", None)
main(["serve", config, "/models/m", "--", "--port", "8001"])
self.assertEqual(os.environ["PRAIN_CONFIG"], config)
self.assertEqual(os.environ["OMP_NUM_THREADS"], "6")
execvp.assert_called_once_with("vllm", [
"vllm", "serve", "/models/m", "--enforce-eager", "--moe-backend", "triton",
"--scheduling-policy", "priority", "--max-num-seqs", "3",
"--kv-cache-memory-bytes", "1073741824", "--port", "8001"])
with mock.patch("os.execvp") as execvp, self.assertRaisesRegex(
SystemExit, "--max-num-seqs=4 comes from the placement file"):
main(["serve", config, "/models/m", "--", "--max-num-seqs=4"])
execvp.assert_not_called()
def test_synth_sim_and_tune(self):
hf = self.write("config.json", json.dumps(GEMMA4))
trace = os.path.join(self.dir, "trace.jsonl.gz")
self.run_cli("synth", trace, "--forwards", "40", "--layers", "3")
config = self.write("p.toml", 'pack_dir = "/packs"\ncache_slots = 5\n')
out, _ = self.run_cli("sim", trace, config, hf)
self.assertRegex(out, r"VRAM hit rate \d+\.\d% \(eviction alone at best: \d+\.\d%\)")
self.assertIn("decode ", out)
out, err = self.run_cli("tune", trace, hf, "--expert-vram-gib", "0.2",
"--ram-gib", "4", "--pack-dir", "/packs", "--top", "3")
self.assertIn(" 1 ", err)
tuned = load_config(self.write("tuned.toml", out))
self.assertEqual((tuned.pack_dir, tuned.trace_path), ("/packs", trace))
self.assertLessEqual(sum(tuned.placement(layer, 128).cache_slots for layer in range(3)),
int(0.2 * 1024 / 11.34) // 3 * 3)
+134
View File
@@ -23,3 +23,137 @@ class ParseIds(unittest.TestCase):
def test_layer_index(self):
self.assertEqual(mc.layer_index("language_model.model.layers.12.experts"), 12)
self.assertIsNone(mc.layer_index("model.experts"))
class Placement(TmpCase):
def test_sections_apply_in_file_order(self):
config = mc.load_config(self.write("p.toml", """
pack_dir = "/packs"
default_tier = "ram"
cache_slots = 4
[layers."*"]
vram = "0-1"
[layers."2-3"]
disk = "6-7"
vram = "1-2"
cache_slots = 2
"""))
p0 = config.placement(0, 8)
self.assertEqual((p0.vram, p0.ram, p0.disk, p0.cache_slots),
((0, 1), (2, 3, 4, 5, 6, 7), (), 4))
p3 = config.placement(3, 8)
self.assertEqual((p3.vram, p3.ram, p3.disk, p3.cache_slots),
((0, 1, 2), (3, 4, 5), (6, 7), 2))
self.assertEqual(p3.num_slots, 5)
def test_rejects_typos_conflicts_and_missing_slots(self):
with self.assertRaisesRegex(ValueError, "unknown keys"):
mc.load_config(self.write("a.toml", 'pack_dir = "/p"\ncache_slot = 3\n'))
with self.assertRaisesRegex(ValueError, "default_tier"):
mc.load_config(self.write("b.toml", 'pack_dir = "/p"\ndefault_tier = "gpu"\n'))
both = mc.load_config(
self.write("c.toml", 'pack_dir = "/p"\n[layers."*"]\nvram = "1"\ndisk = "1"\n'))
with self.assertRaisesRegex(ValueError, "both vram and disk"):
both.placement(0, 4)
no_slots = mc.load_config(self.write("d.toml", 'pack_dir = "/p"\ncache_slots = 0\n'))
with self.assertRaisesRegex(ValueError, "cache_slots must be >= 1"):
no_slots.placement(0, 4)
def test_all_in_vram_needs_no_cache_slots(self):
config = mc.load_config(
self.write("v.toml", 'pack_dir = "/p"\ndefault_tier = "vram"\ncache_slots = 0\n'))
self.assertEqual(config.placement(5, 4).num_slots, 4)
def test_policy_prefetch_and_miss_tables(self):
config = mc.load_config(self.write("t.toml", """
pack_dir = "/p"
cache_slots = 3
[policy]
eviction = "gdsf"
lfu_half_life = 16
scan_keep = 1
[prefetch]
mode = "pregate"
min_prob = 0.1
[miss]
mode = "auto"
"""))
self.assertEqual((config.policy.eviction, config.policy.lfu_half_life, config.policy.heat),
("gdsf", 16, "weight"))
self.assertEqual((config.prefetch.mode, config.prefetch.distance, config.miss.mode),
("pregate", 1, "auto"))
self.assertEqual(mc.load_config(self.write("u.toml", 'pack_dir = "/p"\n')).policy, mc.Policy())
def test_rejects_bad_table_values(self):
for body, error in (('[policy]\neviction = "fifo"', "eviction must be one of"),
('[policy]\nscan_keep = -1', "scan_keep must be a number"),
('[prefetch]\ndistance = 3', "distance must be a number in \\[1, 2\\]"),
('[prefetch]\nbudget = 1.5', "budget must be a number"),
('[miss]\nmode = "gpu"', "mode must be one of"),
('[miss]\nthreads = 4', "unknown keys")):
with self.assertRaisesRegex(ValueError, error, msg=body):
mc.load_config(self.write("bad.toml", f'pack_dir = "/p"\n{body}\n'))
keep_all = mc.load_config(self.write("k.toml", 'pack_dir = "/p"\ncache_slots = 2\n'
'[policy]\nscan_keep = 2\n'))
with self.assertRaisesRegex(ValueError, "scan_keep must be < cache_slots"):
keep_all.placement(0, 4)
def test_paths_are_relative_to_the_config_file(self):
config = mc.load_config(self.write("r.toml", 'pack_dir = "packs/x"\nstats_path = "~/s.json"\n'
'trace_path = "/abs/t.gz"\n'))
self.assertEqual(config.pack_dir, os.path.join(self.dir, "packs/x"))
self.assertEqual(config.stats_path, os.path.expanduser("~/s.json"))
self.assertEqual(config.trace_path, "/abs/t.gz")
def test_serve_and_draft_tables(self):
config = mc.load_config(self.write("s.toml", 'pack_dir = "/p"\n[serve]\nmax_requests = 4\n'
'kv_cache_gib = 1.5\n[draft]\nfull_requests = 2\n'
'refine = "none"\n'))
self.assertEqual(config.serve, mc.Serve(max_requests=4, kv_cache_gib=1.5))
self.assertEqual(config.draft, mc.Draft(full_requests=2, min_weight=0.5, refine="none"))
self.assertEqual(mc.load_config(self.write("d.toml", 'pack_dir = "/p"\n')).serve.max_requests, 2)
for body, error in (('[serve]\nmax_requests = 0', "max_requests must be a number"),
('[draft]\nmin_weight = 1.5', "min_weight must be a number in \\[0, 1\\]"),
('[draft]\nfull_requests = 3', "full_requests must be <= \\[serve\\] max_requests \\(2\\)")):
with self.assertRaisesRegex(ValueError, error, msg=body):
mc.load_config(self.write("bad.toml", f'pack_dir = "/p"\n{body}\n'))
class Plan(TmpCase):
def test_hottest_to_vram_then_ram_then_disk(self):
stats = {"layers": {"0": {"uses": [5, 0, 9, 1]}, "1": {"uses": [2, 7, 0, 0]}}}
# 1 GiB experts: 4 GiB VRAM = 2 cache slots + 2 pinned, 3 GiB RAM = 3 experts
text = mc.plan(stats, 4, mc.GIB, 4, 3, 1, "/packs", "/s.json")
self.assertEqual(text, """\
# generated by `prain plan`; experts not listed live on disk
pack_dir = "/packs"
default_tier = "disk"
cache_slots = 1
stats_path = "/s.json"
[layers."0"]
vram = "2"
ram = "0,3"
[layers."1"]
vram = "1"
ram = "0"
""")
p0 = mc.load_config(self.write("plan.toml", text)).placement(0, 4)
self.assertEqual((p0.vram, p0.ram, p0.disk), ((2,), (0, 3), (1,)))
def test_budget_smaller_than_cache_slots(self):
with self.assertRaisesRegex(ValueError, "smaller than the cache slots"):
mc.plan({"layers": {"0": {"uses": [1]}}}, 1, mc.GIB, 1, 0, 2, "/p", "/s")
def test_render_toml_round_trips_tables(self):
text = mc.render_toml("h", {"pack_dir": "/p", "stats_path": None},
{"policy": {"eviction": "lru", "lfu_half_life": 8.0}},
{3: {"cache_slots": 2}, 4: {}})
config = mc.load_config(self.write("r.toml", text))
self.assertEqual((config.policy.eviction, config.policy.lfu_half_life), ("lru", 8.0))
self.assertEqual(config.placement(3, 4).cache_slots, 2)
self.assertNotIn("stats_path", text)
self.assertNotIn('"4"', text)
+42
View File
@@ -0,0 +1,42 @@
import unittest
from types import SimpleNamespace
from prain import drafts
from tests.helpers import TmpCase # noqa: F401 (every test module imports the helpers)
def new_request(req_id, xargs):
return SimpleNamespace(req_id=req_id, sampling_params=SimpleNamespace(extra_args=xargs))
def step(new=(), finished=()):
return SimpleNamespace(scheduled_new_reqs=list(new), finished_req_ids=set(finished))
class Drafts(unittest.TestCase):
def setUp(self):
drafts._DRAFTS.clear()
drafts.clear()
def test_draft_rows_follow_the_batch_layout(self):
# a: 3 prompt tokens (full); b: 2 prompt tokens (draft)
drafts.note_step(step([new_request("a", None), new_request("b", {"prain": "draft"})]),
["a", "b"], [0, 3, 5])
self.assertEqual(drafts.spans(), [(3, 5)])
# next step: both decode one token each, in a new order
drafts.note_step(step(), ["b", "a"], [0, 1, 2])
self.assertEqual(drafts.spans(), [(0, 1)])
drafts.clear()
self.assertIsNone(drafts.spans())
def test_finished_drafts_are_forgotten(self):
drafts.note_step(step([new_request("b", {"prain": "draft"})]), ["b"], [0, 4])
drafts.note_step(step(finished=["b"]), [], [0])
self.assertIsNone(drafts.spans())
self.assertEqual(drafts._DRAFTS, set())
def test_requests_without_the_flag_are_full(self):
drafts.note_step(step([new_request("a", {"other": 1}),
SimpleNamespace(req_id="c", sampling_params=None)]),
["a", "c"], [0, 1, 2])
self.assertIsNone(drafts.spans())
+121
View File
@@ -0,0 +1,121 @@
import json
import math
import os
import random
import unittest
from prain.config import Miss, Policy
from prain.policy import SlotCache, write_stats
from tests.helpers import TmpCase
def lru(**kw):
return Policy(eviction="lru", **kw)
class Eviction(unittest.TestCase):
def test_pinned_experts_stay_and_lru_evicts_the_oldest(self):
cache = SlotCache(pinned=[3, 5], num_experts=8, cache_slots=2, policy=lru())
self.assertEqual(cache.plan({0: 1, 3: 1}), [({0: 2, 3: 0}, [(0, 2)])])
self.assertEqual(cache.plan({0: 1, 1: 1}), [({0: 2, 1: 3}, [(1, 3)])])
self.assertEqual(cache.plan({1: 1, 2: 1}), [({1: 3, 2: 2}, [(2, 2)])]) # 0 was oldest
self.assertEqual((cache.hits, cache.misses), (3, 3))
self.assertEqual(cache.uses, [2, 2, 1, 1, 0, 0, 0, 0])
def test_lfu_keeps_the_frequent_expert_lru_the_recent_one(self):
for policy, survivor in ((lru(), 1), (Policy(eviction="lfu"), 0)):
cache = SlotCache([], 3, 2, policy)
for e in (0, 0, 0, 1, 2):
cache.plan({e: 1})
self.assertIn(survivor, cache.slots, policy.eviction)
def test_gdsf_keeps_the_expensive_disk_expert(self):
cost = [1.0, 4.0, 1.0] # expert 1 lives on disk
for eviction, survivor in (("lfu", 0), ("gdsf", 1)):
cache = SlotCache([], 3, 2, Policy(eviction=eviction), cost)
for e in (0, 0, 1, 2):
cache.plan({e: 1})
self.assertIn(survivor, cache.slots, eviction)
class Rounds(unittest.TestCase):
def test_hottest_experts_are_copied_last_and_stay(self):
cache = SlotCache([], 10, 2, lru())
rounds = cache.plan({0: 1, 1: 9, 2: 3, 3: 8, 4: 2, 5: 1})
self.assertEqual([sorted(slot_of) for slot_of, _ in rounds], [[0, 5], [2, 4], [1, 3]])
self.assertEqual(set(cache.slots), {1, 3})
def test_scan_keeps_the_protected_expert_and_promotes_the_hottest(self):
cache = SlotCache([], 6, 3, lru(scan_tokens=4, scan_keep=1, promote=1))
cache.plan({0: 1})
cache.plan({1: 1})
rounds = cache.plan({2: 1, 3: 5, 4: 2, 5: 3}, tokens=8)
self.assertEqual(rounds, [({2: 2, 4: 0}, [(2, 2), (4, 0)]),
({5: 2, 3: 0}, [(5, 2), (3, 0)])])
self.assertEqual(set(cache.slots), {1, 3, 5}) # 1 was protected
self.assertEqual(cache.evictor.last[3], cache.now) # promoted
self.assertEqual(cache.evictor.last[5], -math.inf) # admitted at low priority
def test_cpu_experts_get_no_slot(self):
cache = SlotCache([0], 6, 2, lru())
cache.plan({1: 1})
counts = {0: 1, 1: 1, 2: 1, 3: 4}
self.assertEqual(cache.choose_cpu(counts, Miss()), set())
self.assertEqual(cache.choose_cpu(counts, Miss(mode="cpu")), {2, 3})
cpu = cache.choose_cpu(counts, Miss(mode="auto", cpu_max_tokens=2))
self.assertEqual(cpu, {2})
self.assertEqual(cache.plan(counts, 4, cpu), [({0: 0, 1: 1, 3: 2}, [(3, 2)])])
self.assertEqual((cache.cpu, cache.misses), (1, 2))
def test_random_plans_always_compute_every_expert_from_the_right_slot(self):
rng = random.Random(1)
for _ in range(300):
n = rng.randint(2, 12)
pinned = rng.sample(range(n), rng.randint(0, n // 2))
slots = rng.randint(1, 4)
policy = Policy(eviction=rng.choice(["lru", "lfu", "gdsf"]),
scan_tokens=rng.randint(1, 6), scan_keep=rng.randint(0, slots - 1),
promote=rng.randint(0, 3))
cache = SlotCache(pinned, n, slots, policy, [rng.choice([1.0, 4.0]) for _ in range(n)])
content = {slot: e for e, slot in cache.pinned.items()} # what each slot really holds
for _ in range(12):
if rng.random() < 0.3:
guesses = [(e, 0.5) for e in rng.sample(range(n), rng.randint(1, n))]
recent = set(cache.recent)
for e, slot in cache.prefetch(guesses, rng.randint(0, 3)):
self.assertNotIn(content.get(slot), recent - {None})
content[slot] = e
routed = rng.sample(range(n), rng.randint(1, n))
heat = {e: rng.randint(1, 9) for e in routed}
cpu = cache.choose_cpu(heat, Miss(mode=rng.choice(["copy", "auto"]), cpu_max_tokens=3))
computed = []
for slot_of, loads in cache.plan(heat, rng.randint(1, 8), cpu):
self.assertEqual(len(set(slot_of.values())), len(slot_of))
for e, slot in loads:
self.assertEqual(slot_of[e], slot)
content[slot] = e
for e, slot in slot_of.items():
self.assertEqual(content[slot], e)
computed += slot_of
self.assertEqual(sorted(computed + sorted(cpu)), sorted(routed))
held = list(cache.slots.values())
self.assertEqual(len(set(held)), len(held))
self.assertEqual(len(held) + len(cache.free), slots)
class Prefetch(TmpCase):
def test_prefetch_is_low_priority_and_never_evicts_the_last_forward(self):
cache = SlotCache([], 4, 2, Policy(eviction="lfu"))
cache.plan({0: 1})
self.assertEqual(cache.prefetch([(1, 0.5), (2, 0.4)], budget=2), [(1, 1)]) # 0 is recent
cache.plan({1: 1}) # the prefetch paid off
self.assertEqual(cache.prefetch([(3, 0.9)], budget=1), [(3, 0)]) # evicts 0, not 1
cache.plan({2: 1}) # 3 was never used: it goes first
self.assertEqual((cache.prefetches, cache.prefetch_hits, cache.prefetch_wasted), (2, 1, 1))
self.assertEqual(set(cache.slots), {1, 2})
path = os.path.join(self.dir, "stats.json")
write_stats(path, {7: cache})
with open(path) as f:
stats = json.load(f)["layers"]["7"]
self.assertEqual((stats["uses"], stats["prefetch_wasted"], stats["misses"]),
([1, 1, 1, 0], 1, 2))
+83
View File
@@ -0,0 +1,83 @@
import os
import unittest
from prain.config import LayerPlacement, Miss, Policy, Prefetch
from prain.sim import (Cost, read_forwards, simulate, synthetic_trace, tune, write_trace)
from tests.helpers import TmpCase
SMALL = dict(layers=4, experts=32, top_k=4, forwards=120, prompt_every=60, prompt_tokens=32)
def forwards_of(records):
forwards, last = [], None
for r in records:
if last is None or r["l"] <= last:
forwards.append({})
forwards[-1][r["l"]] = r
last = r["l"]
return forwards
def all_ram(slots, layers=4, experts=32):
return {layer: LayerPlacement((), tuple(range(experts)), (), slots) for layer in range(layers)}
class Traces(TmpCase):
def test_round_trip_and_determinism(self):
records = synthetic_trace(**SMALL)
self.assertEqual(records, synthetic_trace(**SMALL))
path = os.path.join(self.dir, "t.jsonl.gz")
write_trace(path, records[:8])
write_trace(path, records[8:], mode="at") # runs append to the same trace
forwards = read_forwards(path)
self.assertEqual(len(forwards), 120)
self.assertEqual(forwards[0][0], records[0])
self.assertEqual(forwards[0][0]["t"], 32)
self.assertEqual(sorted(forwards[1][1]["p"]), ["1", "2"])
self.assertNotIn("p", forwards[1][3]) # nothing after the last layer
def test_copy_cost(self):
self.assertAlmostEqual(Cost().copy_ms(False), 0.991, places=3)
self.assertAlmostEqual(Cost().copy_ms(True), 4.955, places=3)
class Simulate(unittest.TestCase):
def setUp(self):
self.forwards = forwards_of(synthetic_trace(**SMALL))
def run_sim(self, policy=Policy(), prefetch=Prefetch(), miss=Miss(), slots=3, **kw):
return simulate(self.forwards, all_ram(slots), policy, prefetch, miss, Cost(), **kw)
def test_belady_bounds_every_policy(self):
bound = self.run_sim(belady=True).hit_rate
for eviction in ("lru", "lfu", "gdsf"):
self.assertLessEqual(self.run_sim(Policy(eviction=eviction)).hit_rate, bound)
def test_cpu_path_beats_copies_when_the_cpu_is_faster_than_pcie(self):
copy = self.run_sim().ms_per_decode
self.assertLess(self.run_sim(miss=Miss(mode="auto")).ms_per_decode, copy)
def test_prefetch_helps_when_predictions_are_right(self):
none = self.run_sim().hit_rate
good = self.run_sim(prefetch=Prefetch(mode="pregate", budget=4))
self.assertGreater(good.hit_rate, none)
self.assertGreater(good.counters["prefetch_hits"], good.counters["prefetch_wasted"])
def test_pregate_needs_predictions(self):
stripped = [{layer: {k: v for k, v in r.items() if k != "p"} for layer, r in f.items()}
for f in self.forwards]
with self.assertRaisesRegex(ValueError, "needs a trace with predictions"):
simulate(stripped, all_ram(3), Policy(), Prefetch(mode="pregate"), Miss(), Cost())
class Tune(unittest.TestCase):
def test_tune_beats_the_defaults_within_the_budget(self):
forwards = forwards_of(synthetic_trace(**SMALL))
cost = Cost(expert_mib=64.0) # 1 GiB = 16 slots = 4 per layer
best, ram, per, final, bound, rows = tune(forwards, 32, 1.0, 1.0, cost)
self.assertLessEqual(sum(per.values()), 16)
self.assertEqual(sum(len(r) for r in ram.values()), 16) # 1 GiB of RAM tier
default = next(r for v, r in rows if v == (Policy(), Prefetch(), Miss()))
self.assertLessEqual(final.total_ms, default.total_ms)
self.assertEqual(rows[0][1].total_ms, min(r.total_ms for _, r in rows))
self.assertGreaterEqual(bound.hit_rate, 0.0)
+70
View File
@@ -0,0 +1,70 @@
import fcntl
import os
import pty
import struct
import subprocess
import sys
import termios
import threading
import unittest
from prain.tui import Chat
from tests.helpers import StubVllm, wait_for
class Tui(unittest.TestCase):
def setUp(self):
self.stub = StubVllm()
self.addCleanup(self.stub.close)
def test_typing_warms_up_and_enter_streams_the_answer(self):
chat = Chat(self.stub.url, "stub-model")
chat.type("Why is the sky")
wait_for(lambda: self.stub.bodies and not chat.warmer.busy)
self.assertEqual(self.stub.bodies[0]["priority"], 1)
chat.send()
chat.type("next question") # an answer is streaming: no warm-up in "idle" mode
wait_for(lambda: chat.poll() or chat.reply is None)
self.assertEqual(chat.messages, [{"role": "user", "content": "Why is the sky"},
{"role": "assistant", "content": "Hello!"}])
self.assertEqual(len(self.stub.bodies), 2)
self.assertIn("tok/s", chat.status)
def test_warmup_off_and_cancel_on_send(self):
off = Chat(self.stub.url, "stub-model", warmup="off")
off.type("hello")
self.assertFalse(off.warmer.busy)
self.stub.delay = 3.0
chat = Chat(self.stub.url, "stub-model")
chat.type("a slow warm-up")
wait_for(lambda: self.stub.bodies)
chat.send() # aborts the warm-up instead of queueing behind it
wait_for(lambda: not chat.warmer.busy, timeout=2.0)
def test_curses_screen_runs_in_a_real_terminal(self):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
tui = subprocess.Popen([sys.executable, "-m", "prain.cli", "tui", "--url", self.stub.url],
stdin=slave, stdout=slave, stderr=slave,
env={**os.environ, "TERM": "xterm"}, start_new_session=True)
os.close(slave)
screen = bytearray()
threading.Thread(target=self._drain, args=(master, screen), daemon=True).start()
wait_for(lambda: b"stub-model" in screen) # status line drawn
os.write(master, b"hi\r")
wait_for(lambda: any(body.get("stream") and "priority" not in body
for body in self.stub.bodies))
wait_for(lambda: b"tok/s" in screen)
os.write(master, b"\x1b")
self.assertEqual(tui.wait(timeout=5), 0)
@staticmethod
def _drain(fd, screen):
while True:
try:
data = os.read(fd, 4096)
except OSError:
return
if not data:
return
screen.extend(data)
+138
View File
@@ -0,0 +1,138 @@
import json
import threading
import unittest
import urllib.error
import urllib.request
import prain.chat as chat
from prain.config import Draft, Serve
from prain.web import make_server
from tests.helpers import StubVllm, wait_for
def events(response):
"""The proxy's stream as a list: content pieces, and "<draft>"-style markers."""
out = []
for raw in response:
line = raw.decode().strip()
if line == "data: [DONE]":
out.append("<done>")
elif line.startswith("data: "):
data = json.loads(line[6:])
out.append(f"<{data['prain']}>" if "prain" in data
else data["choices"][0]["delta"]["content"])
return out
class Web(unittest.TestCase):
def setUp(self):
self.stub = StubVllm()
self.addCleanup(self.stub.close)
self.start()
def start(self, **options):
self.server = make_server(self.stub.url, 0, **options)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
self.url = f"http://127.0.0.1:{self.server.server_port}"
def warm(self, session, draft):
chat.post(self.url, "/warm", {"session": session, "messages": [], "draft": draft}).read()
def ask(self, session, text):
return chat.post(self.url, "/chat", {"session": session,
"messages": [{"role": "user", "content": text}]})
def test_page_warm_up_and_chat(self):
with urllib.request.urlopen(self.url) as response:
self.assertIn(b'<textarea id="draft"', response.read())
self.warm("s1", "Why is ")
wait_for(lambda: self.stub.bodies)
self.assertEqual(self.stub.bodies[0]["messages"], [{"role": "user", "content": "Why is"}])
self.assertEqual(self.stub.bodies[0]["priority"], 1)
with self.ask("s1", "Why is") as r:
self.assertEqual(events(r), ["Hel", "lo", "!", "<done>"])
self.assertEqual(self.stub.bodies[1]["model"], "stub-model")
self.assertNotIn("vllm_xargs", self.stub.bodies[1])
def test_sessions_warm_up_independently(self):
self.stub.delay = 0.3
self.warm("alice", "first")
self.warm("bob", "second") # alice's warm-up is in flight; bob's is not dropped
wait_for(lambda: len(self.stub.bodies) == 2)
self.assertEqual({b["messages"][-1]["content"] for b in self.stub.bodies},
{"first", "second"})
self.assertEqual(set(self.server.sessions), {"alice", "bob"})
def test_no_warm_up_while_another_answer_streams(self):
self.server.streams[0] = 1 # someone else's answer is streaming
self.warm("bob", "typing along")
self.server.streams[0] = 0
self.warm("bob", "now")
wait_for(lambda: self.stub.bodies)
self.assertEqual([b["messages"][-1]["content"] for b in self.stub.bodies], ["now"])
def test_always_mode_warms_up_while_a_request_slot_is_free(self):
self.start(warmup="always", serve=Serve(max_requests=3))
self.server.streams[0] = 3 # every vLLM request slot is busy
self.warm("bob", "no slot")
self.server.streams[0] = 2
self.warm("bob", "one slot")
wait_for(lambda: self.stub.bodies)
self.assertEqual([b["messages"][-1]["content"] for b in self.stub.bodies], ["one slot"])
def test_vllm_errors_reach_the_browser(self):
self.stub.fail = 400
with self.assertRaises(urllib.error.HTTPError) as caught:
chat.post(self.url, "/chat", {"session": "s", "messages": []})
self.assertEqual(caught.exception.code, 400)
with caught.exception as error:
self.assertIn(b"rejected", error.read())
wait_for(lambda: self.server.streams[0] == 0)
def test_second_answer_drafts_now_and_refines_when_the_first_one_ends(self):
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.5 # full answers are slow
alice = []
thread = threading.Thread(target=lambda: alice.extend(events(self.ask("alice", "a"))))
thread.start()
wait_for(lambda: self.server.full[0] == 1)
with self.ask("bob", "b") as r:
bob = events(r)
thread.join()
self.assertEqual(alice, ["Hel", "lo", "!", "<done>"])
self.assertEqual(bob, ["<draft>", "Hel", "lo", "!", "<refine>", "Hel", "lo", "!", "<done>"])
self.assertEqual([(b["messages"][0]["content"], b.get("vllm_xargs"), b.get("cache_salt"))
for b in self.stub.bodies],
[("a", None, None), ("b", {"prain": "draft"}, "prain-draft"),
("b", None, None)])
wait_for(lambda: self.server.full[0] == 0 and self.server.streams[0] == 0)
def test_a_running_draft_is_cut_short_once_a_full_slot_frees(self):
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.3
self.stub.pause = lambda body: 0.6 if "vllm_xargs" in body else 0.0 # drafts trickle
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
thread.start()
wait_for(lambda: self.server.full[0] == 1)
with self.ask("bob", "b") as r: # alice ends at 0.3 s, between bob's "Hel" and "lo"
self.assertEqual(events(r), ["<draft>", "Hel", "lo", "<refine>", "Hel", "lo", "!",
"<done>"])
thread.join()
def test_refine_none_keeps_the_draft_and_more_full_slots_mean_no_draft(self):
self.start(draft=Draft(refine="none"))
self.stub.delay = lambda body: 0.0 if "vllm_xargs" in body else 0.5
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
thread.start()
wait_for(lambda: self.server.full[0] == 1)
with self.ask("bob", "b") as r:
self.assertEqual(events(r), ["<draft>", "Hel", "lo", "!", "<done>"])
thread.join()
self.start(serve=Serve(max_requests=2), draft=Draft(full_requests=2))
thread = threading.Thread(target=lambda: self.ask("alice", "a").read())
thread.start()
wait_for(lambda: self.server.full[0] == 1)
with self.ask("bob", "b") as r:
self.assertEqual(events(r), ["Hel", "lo", "!", "<done>"])
thread.join()
self.assertEqual(sum("vllm_xargs" in b for b in self.stub.bodies), 1)