Newer
Older
navi-1 / scripts / ngram_pp_probe.py
#!/usr/bin/env python3
"""Measure prefill throughput as a function of prompt length.

The 128k plan lives or dies on this curve: at a flat 97 t/s a 128k prompt is
22 minutes, and on a hybrid model only part of the layers see the quadratic
term, so the shape is not obvious in advance.

Usage:
    python3 pp_probe.py --ctx 131072 --sizes 4096,8192,16384,32768 [-- extra flags]
"""
import argparse
import glob
import json
import os
import subprocess
import sys
import time
import http.client

BIN = os.path.expanduser("~/llama/llama-b11960/llama-server")
MODEL = os.path.expanduser("~/models/Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS.gguf")
PORT = 8299

# Dense, repetitive but token-diverse filler: repeated verbatim so the
# tokenizer cost per token stays stable across sizes.
FILLER = (
    "The deployment pipeline builds the image, runs the unit tests, tags the "
    "result and pushes it to the registry. A failing stage stops the run and "
    "notifies the on-call engineer with the log tail attached. Retries are "
    "bounded, idempotent and observable, and every step records its duration. "
)


def post(path, payload, timeout=7200):
    c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=timeout)
    c.request("POST", path, body=json.dumps(payload),
              headers={"Content-Type": "application/json"})
    r = c.getresponse()
    body = r.read()
    c.close()
    if r.status != 200:
        raise RuntimeError(f"{path} {r.status}: {body[:300]!r}")
    return json.loads(body)


def mem_mb():
    vram = None
    for p in glob.glob("/sys/class/drm/card*/device/mem_info_vram_used"):
        try:
            vram = int(open(p).read()) // (2 ** 20)
        except (OSError, ValueError):
            pass
    return vram


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--ctx", type=int, default=131072)
    ap.add_argument("--sizes", default="4096,8192,16384,32768")
    ap.add_argument("--model", default=MODEL)
    ap.add_argument("--threads", type=int, default=4)
    ap.add_argument("--ub", type=int, default=0, help="-ub (0 = default)")
    ap.add_argument("--b", type=int, default=0, help="-b (0 = default)")
    ap.add_argument("--np", type=int, default=1)
    ap.add_argument("--extra", default="")
    args = ap.parse_args()

    flags = ["-c", str(args.ctx), "-ngl", "99", "--n-cpu-moe", "999",
             "-fa", "on", "-ctk", "q8_0", "-ctv", "q8_0",
             "-t", str(args.threads), "-np", str(args.np), "--no-webui",
             "--spec-type", "draft-mtp", "--spec-draft-n-max", "1"]
    if args.ub:
        flags += ["-ub", str(args.ub)]
    if args.b:
        flags += ["-b", str(args.b)]
    if args.extra:
        flags += args.extra.split()

    log_path = os.path.expanduser("~/ngram/smoke/pp_probe.log")
    log = open(log_path, "wb")
    proc = subprocess.Popen([BIN, "-m", args.model, "--port", str(PORT),
                             *flags], stdout=log, stderr=subprocess.STDOUT,
                            stdin=subprocess.DEVNULL)
    print(f"flags: {' '.join(flags)}", file=sys.stderr)
    try:
        t0 = time.time()
        for _ in range(1200):
            if proc.poll() is not None:
                raise SystemExit(f"server died, see {log_path}")
            try:
                c = http.client.HTTPConnection("127.0.0.1", PORT, timeout=5)
                c.request("GET", "/health")
                ok = c.getresponse().status == 200
                c.close()
                if ok:
                    break
            except OSError:
                pass
            time.sleep(1)
        print(f"server ready in {time.time()-t0:.1f}s, vram {mem_mb()} MB",
              file=sys.stderr)

        print(f"{'target':>8} {'actual':>8} {'pp t/s':>8} {'sec':>8} {'vram MB':>8}")
        for size in [int(s) for s in args.sizes.split(",")]:
            # ~3.6 chars per token for this filler; checked against /tokenize.
            text = FILLER * (size * 4 // len(FILLER) + 1)
            ntok = post("/tokenize", {"content": text,
                                      "add_special": False})["tokens"]
            # Trim to the target by words, then trust /tokenize over estimates.
            ids = ntok[:size]
            # llama-server accepts a token list only through /completion's
            # `prompt` when given as array of ids.
            out = post("/completion", {
                "prompt": ids, "n_predict": 1, "temperature": 0,
                "cache_prompt": False}, 7200)
            tm = out["timings"]
            print(f"{size:>8} {tm['prompt_n']:>8} "
                  f"{tm['prompt_n'] / tm['prompt_ms'] * 1000:>8.1f} "
                  f"{tm['prompt_ms']/1000:>8.1f} {mem_mb():>8}")
            sys.stdout.flush()
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=60)
        except subprocess.TimeoutExpired:
            proc.kill()
        log.close()
        # The allocated KV/RS sizes are the other half of the 128k answer.
        with open(log_path, errors="replace") as f:
            for line in f:
                if "KV buffer size" in line or "RS buffer size" in line:
                    print(line.rstrip(), file=sys.stderr)


if __name__ == "__main__":
    main()