File size: 8,113 Bytes
e51b495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""Generate side-by-side example replies with llama-server: stock, and stock plus control vectors.

One llama-server is started per set of vectors (llama.cpp loads control vectors at launch), each prompt is
sent once with a fixed seed, and the reply is saved with llama-server's own timings for prompt processing
(pp) and token generation (tg) and the request's wall-clock time. The first request in a fresh process pays
for Metal/CUDA kernel compilation, so one short warmup request is sent and discarded first.

    python -m ecce_vector.examples --server llama-server --model gemma-4-31B-it-Q4_K_M.gguf \\
        --prompts examples/prompts.json --out examples/runs/musica \\
        --control-vector-scaled vectors/musica.gguf:1.0

A fine-tune released as a LoRA adapter runs on the same base with --lora ADAPTER.gguf (converted with llama.cpp's
convert_lora_to_gguf.py); a full-weight fine-tune runs from its own GGUF as --model, with no vector.

Prompts file: a JSON list of {"id", "system", "turns": [{"role": "user"|"model", "text"}]}, rendered in Gemma 4's
turn format with reasoning off, byte-identical to the model's chat template for enable_thinking=false.
Sampling is Google's recommended setting for Gemma 4 (generation_config.json: temperature 1.0, top-k 64,
top-p 0.95), with llama.cpp's other samplers switched off.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import time
import urllib.request
from pathlib import Path

TURN_OPEN, TURN_CLOSE = "<|turn>", "<turn|>"
THOUGHT_CLOSED = "<|channel>thought\n<channel|>"          # reasoning off: an empty, closed thought channel
SAMPLER = {"temperature": 1.0, "top_k": 64, "top_p": 0.95, "min_p": 0.0, "typical_p": 1.0,
           "repeat_penalty": 1.0, "presence_penalty": 0.0, "frequency_penalty": 0.0}


def render(prompt: dict) -> str:
    parts = [f"{TURN_OPEN}system\n{prompt['system']}{TURN_CLOSE}\n"]
    parts += [f"{TURN_OPEN}{t['role']}\n{t['text']}{TURN_CLOSE}\n" for t in prompt["turns"]]
    parts.append(f"{TURN_OPEN}model\n{THOUGHT_CLOSED}")
    return "".join(parts)


def post(url: str, payload: dict, timeout: float = 900) -> dict:
    req = urllib.request.Request(url, json.dumps(payload).encode(), {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())


def wait_healthy(url: str, proc: subprocess.Popen, timeout: float = 600) -> None:
    t0 = time.time()
    while time.time() - t0 < timeout:
        if proc.poll() is not None:
            raise RuntimeError(f"llama-server exited with {proc.returncode}")
        try:
            with urllib.request.urlopen(url + "/health", timeout=5) as r:
                if json.loads(r.read()).get("status") == "ok":
                    return
        except Exception:
            pass
        time.sleep(1)
    raise TimeoutError("llama-server did not become healthy")


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--server", default="llama-server", help="llama-server binary")
    ap.add_argument("--model", required=True, help="the base model GGUF (stock gemma-4-31B-it), or a fine-tune's own GGUF")
    ap.add_argument("--prompts", required=True, type=Path)
    ap.add_argument("--out", required=True, type=Path, help="output directory for this run")
    ap.add_argument("--control-vector-scaled", action="append", default=[], metavar="FILE:SCALE")
    ap.add_argument("--lora", action="append", default=[], metavar="FILE", help="a LoRA adapter GGUF, at scale 1")
    ap.add_argument("--seed", default="24601", help="a seed, or a comma list: the first seed's replies are ID.txt, "
                    "each further seed's ID.seedN.txt (run.json: records, then extra_seeds)")
    ap.add_argument("--max-tokens", type=int, default=320)
    ap.add_argument("--ctx", type=int, default=8192)
    ap.add_argument("--port", type=int, default=8741)
    ap.add_argument("--ik", action="store_true", help="--server is ik_llama.cpp's llama-server (its flag syntax)")
    a = ap.parse_args(argv)

    prompts = json.loads(a.prompts.read_text())
    seeds = [int(s) for s in str(a.seed).split(",")]
    a.out.mkdir(parents=True, exist_ok=True)
    cmd = [a.server, "-m", a.model, "-c", str(a.ctx), "-ngl", "99", "--port", str(a.port), "--parallel", "1"]
    cmd += ["--webui", "none"] if a.ik else ["--no-webui"]
    if a.ik:                        # ik_llama.cpp: the flag repeats, FILE and SCALE as two arguments
        for cv in a.control_vector_scaled:
            f, s = cv.rsplit(":", 1)
            cmd += ["--control-vector-scaled", f, s]
    elif a.control_vector_scaled:   # llama.cpp: one flag, a comma-separated FILE:SCALE list
        cmd += ["--control-vector-scaled", ",".join(a.control_vector_scaled)]
    for f in a.lora:                # the same flag in both engines
        cmd += ["--lora", f]
    url = f"http://127.0.0.1:{a.port}"
    log = open(a.out / "server.log", "w")
    t_launch = time.time()
    proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
    records, extra = [], {}
    try:
        wait_healthy(url, proc)
        load_s = time.time() - t_launch
        post(url + "/completion", {"prompt": render(prompts[0]), "n_predict": 8, "seed": seeds[0], **SAMPLER,
                                   "cache_prompt": False})           # warmup, discarded
        for seed, p in ((s, p) for s in seeds for p in prompts):
            t0 = time.time()
            r = post(url + "/completion", {"prompt": render(p), "n_predict": a.max_tokens, "seed": seed,
                                           "cache_prompt": False, "stop": [TURN_CLOSE, TURN_OPEN], **SAMPLER})
            wall = time.time() - t0
            tm = r.get("timings", {})
            text = r["content"].strip()
            (a.out / (f"{p['id']}.txt" if seed == seeds[0] else f"{p['id']}.seed{seed}.txt")).write_text(text + "\n")
            (records if seed == seeds[0] else extra.setdefault(str(seed), [])).append({
                            "id": p["id"], "seed": seed, "text": text, "wall_s": wall,
                            "pp_tokens": tm.get("prompt_n"), "pp_ms": tm.get("prompt_ms"),
                            "pp_tok_s": tm.get("prompt_per_second"),
                            "tg_tokens": tm.get("predicted_n"), "tg_ms": tm.get("predicted_ms"),
                            "tg_tok_s": tm.get("predicted_per_second"),
                            "stop": r.get("stop_type") or ("limit" if r.get("truncated") else None)})
            print(f"{p['id']:24s} {seed:6d} pp {tm.get('prompt_n')} tok @ {tm.get('prompt_per_second', 0):.1f} tok/s   "
                  f"tg {tm.get('predicted_n')} tok @ {tm.get('predicted_per_second', 0):.2f} tok/s   wall {wall:.1f}s",
                  flush=True)
    finally:
        proc.terminate()
        try:
            proc.wait(30)
        except subprocess.TimeoutExpired:
            proc.kill()
        log.close()
    # record paths without the local machine's layout: binaries and the model by file name, vectors relative
    def portable(x: str) -> str:
        if ":" in x and Path(x.rpartition(":")[0]).suffix == ".gguf":      # FILE:SCALE
            head, sep, scale = x.rpartition(":")
        else:
            head, sep, scale = x, "", ""
        p = Path(head)
        if p.is_absolute():
            p = Path(os.path.relpath(p)) if str(p).startswith(os.getcwd()) else Path(p.name)
        return str(p) + sep + scale
    (a.out / "run.json").write_text(json.dumps({
        "model": Path(a.model).name, "control_vectors": [portable(c) for c in a.control_vector_scaled],
        "lora": [Path(f).name for f in a.lora],
        "seed": seeds[0], "max_tokens": a.max_tokens, "sampler": SAMPLER,
        "server_cmd": [Path(cmd[0]).name] + [",".join(portable(p) for p in c.split(",")) if "/" in c else c
                                             for c in cmd[1:]],
        "server_load_s": load_s, "records": records, **({"extra_seeds": extra} if extra else {})}, indent=1))


if __name__ == "__main__":
    main()