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()
|