textmachine/eval/local_bench.py

232 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Локальный стенд TextMachine (задача 3 полигона, docs/research/06-local-models.md).
Меряет на установленных ollama-моделях:
- скорость: prompt_eval tok/s и generation tok/s (из метрик /api/generate);
- память: пик VRAM (nvidia-smi) и RSS ollama во время генерации;
- качество: сохраняет переводы ja→ru / zh→ru для субъективного сравнения
(эталон DeepSeek API добавляется тем же скриптом при наличии ключа).
Запуск: eval/.venv/bin/python eval/local_bench.py [--models m1,m2] [--num-ctx 8192]
Выход: eval/data/local_bench/<model>/<fragment>.txt + results.json + таблица в stdout.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
OLLAMA = "http://localhost:11434"
# WSL: в env задан http_proxy, а NO_PROXY=<local> (WinINET-формат) urllib не понимает —
# localhost-запросы уходят на прокси и получают 403. Для ollama прокси отключаем явно.
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def _load_env_file() -> None:
"""Ключи из eval/.env (gitignored, закрыт от Claude deny-правилом) или
legacy ~/.textmachine.env; только в env процесса, не печатаются."""
for cand in (Path(__file__).resolve().parent / ".env",
Path("~/.textmachine.env").expanduser()):
if not cand.exists():
continue
for line in cand.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
if v.strip():
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
return
_load_env_file()
PROMPT_TMPL = (
"Ты профессиональный литературный переводчик с {src_name} на русский. "
"Переведи фрагмент художественной прозы. Требования: точность смысла, живой "
"литературный русский без канцелярита и калек, сохранить все предложения и реплики. "
"Выведи только перевод.\n\n{text}"
)
SRC_NAMES = {"ja": "японского", "zh": "китайского"}
# Фрагменты: (lang, файл, срез символов) — ~1.2-1.5k знаков, самодостаточные сцены
FRAGMENTS = [
("ja", "akutagawa-rashomon.txt", 0, 1400),
("ja", "dazai-hashire-merosu.txt", 0, 1400),
("zh", "luxun-zhufu.txt", 0, 1500),
]
def gpu_mem_mb() -> int:
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5).stdout.strip()
return int(out.splitlines()[0])
except Exception:
return -1
def ram_used_gb() -> float:
with open("/proc/meminfo") as f:
mi = {l.split(":")[0]: int(l.split()[1]) for l in f}
return (mi["MemTotal"] - mi["MemAvailable"]) / 1e6
class PeakSampler(threading.Thread):
def __init__(self):
super().__init__(daemon=True)
self.peak_vram = 0
self.peak_ram = 0.0
self.stop_flag = False
def run(self):
while not self.stop_flag:
self.peak_vram = max(self.peak_vram, gpu_mem_mb())
self.peak_ram = max(self.peak_ram, ram_used_gb())
time.sleep(1)
def ollama_generate(model: str, prompt: str, num_ctx: int, timeout: int = 1800,
think: bool = False) -> dict:
# think=False (дефолт): у моделей 3.5/GLM thinking включён по умолчанию и съедает
# бюджет вывода на рассуждения (перевод обрезается/пустой) — для роли переводчика
# отключаем. think=True: явно включаем + большой num_predict, чтобы модель успела
# и порассуждать, и выдать перевод (замер влияния reasoning на качество).
# num_predict — потолок вывода, чтобы модель не убегала.
req = urllib.request.Request(
f"{OLLAMA}/api/generate",
data=json.dumps({
"model": model, "prompt": prompt, "stream": False, "think": think,
"options": {"num_ctx": num_ctx, "temperature": 0.3,
"num_predict": 6000 if think else 2048},
}).encode(),
headers={"Content-Type": "application/json"})
try:
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
# не все модели принимают think=False (нет thinking-способности) — повтор без него
if e.code == 400:
body = json.loads(req.data)
body.pop("think", None)
req2 = urllib.request.Request(f"{OLLAMA}/api/generate", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp:
return json.loads(resp.read())
raise
def strip_think(text: str) -> str:
"""Qwen3 в дефолте может отдавать <think>…</think> — вырезаем для оценки перевода."""
return re.sub(r"<think>.*?</think>\s*", "", text, flags=re.S).strip()
def deepseek_generate(prompt: str) -> dict | None:
key = os.environ.get("DEEPSEEK_API_KEY")
if not key:
return None
req = urllib.request.Request(
"https://api.deepseek.com/v1/chat/completions",
data=json.dumps({"model": "deepseek-chat", "temperature": 0.3,
"messages": [{"role": "user", "content": prompt}]}).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"})
t0 = time.time()
with urllib.request.urlopen(req, timeout=600) as resp:
data = json.loads(resp.read())
return {"response": data["choices"][0]["message"]["content"],
"elapsed_s": time.time() - t0, "usage": data.get("usage", {})}
def installed_models() -> list[str]:
with _NO_PROXY_OPENER.open(f"{OLLAMA}/api/tags", timeout=10) as resp:
return [m["name"] for m in json.loads(resp.read())["models"]]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--models", help="через запятую; по умолчанию все установленные")
ap.add_argument("--num-ctx", type=int, default=8192)
ap.add_argument("--out", default=str(ROOT / "data" / "local_bench"))
ap.add_argument("--think", action="store_true",
help="включить thinking-режим (reasoning) + большой num_predict — замер влияния на качество")
args = ap.parse_args()
models = args.models.split(",") if args.models else installed_models()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
results = []
for model in models:
print(f"\n=== {model} (num_ctx={args.num_ctx}) ===")
subprocess.run(["ollama", "stop", model.split(":")[0]], capture_output=True)
base_vram, base_ram = gpu_mem_mb(), ram_used_gb()
for lang, fname, a, b in FRAGMENTS:
src_path = ROOT / "data" / "samples" / lang / fname
if not src_path.exists():
print(f" [skip] нет {src_path}")
continue
text = src_path.read_text()[a:b]
prompt = PROMPT_TMPL.format(src_name=SRC_NAMES[lang], text=text)
sampler = PeakSampler()
sampler.start()
t0 = time.time()
try:
r = ollama_generate(model, prompt, args.num_ctx, think=args.think)
except Exception as e:
sampler.stop_flag = True
print(f" {fname}: ОШИБКА {type(e).__name__}: {str(e)[:200]}")
results.append({"model": model, "fragment": fname, "error": str(e)[:500]})
continue
elapsed = time.time() - t0
sampler.stop_flag = True
sampler.join(2)
gen_tps = r.get("eval_count", 0) / max(r.get("eval_duration", 1), 1) * 1e9
pp_tps = r.get("prompt_eval_count", 0) / max(r.get("prompt_eval_duration", 1), 1) * 1e9
translation = strip_think(r.get("response", ""))
mdir = out_dir / model.replace("/", "_").replace(":", "_")
mdir.mkdir(exist_ok=True)
(mdir / f"{fname.removesuffix('.txt')}.ru.txt").write_text(translation)
rec = {"model": model, "fragment": fname, "lang": lang,
"src_chars": len(text),
"prompt_eval_tps": round(pp_tps, 1), "gen_tps": round(gen_tps, 1),
"gen_tokens": r.get("eval_count"), "elapsed_s": round(elapsed, 1),
"peak_vram_mb": sampler.peak_vram, "base_vram_mb": base_vram,
"peak_ram_gb": round(sampler.peak_ram, 1),
"base_ram_gb": round(base_ram, 1),
"out_chars": len(translation)}
results.append(rec)
print(f" {fname}: gen {gen_tps:.1f} tok/s, prompt {pp_tps:.0f} tok/s, "
f"{elapsed:.0f}s, VRAM пик {sampler.peak_vram} MB, RAM пик {sampler.peak_ram:.1f} GB")
# эталон DeepSeek (если есть ключ)
if os.environ.get("DEEPSEEK_API_KEY"):
print("\n=== deepseek-chat (эталон API) ===")
mdir = out_dir / "deepseek-api"
mdir.mkdir(exist_ok=True)
for lang, fname, a, b in FRAGMENTS:
src_path = ROOT / "data" / "samples" / lang / fname
text = src_path.read_text()[a:b]
r = deepseek_generate(PROMPT_TMPL.format(src_name=SRC_NAMES[lang], text=text))
(mdir / f"{fname.removesuffix('.txt')}.ru.txt").write_text(r["response"])
results.append({"model": "deepseek-api", "fragment": fname,
"elapsed_s": round(r["elapsed_s"], 1), "usage": r["usage"]})
print(f" {fname}: {r['elapsed_s']:.0f}s")
else:
print("\n[note] DEEPSEEK_API_KEY нет — эталонные переводы не сняты")
(out_dir / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
print(f"\nJSON: {out_dir}/results.json; переводы: {out_dir}/<model>/*.ru.txt")
if __name__ == "__main__":
main()