unify: dashboard now lives in repo, docker-compose moved to root, credentials in .env
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Capacity Test — Fallout Venice of Wasteland
|
||||
Série de tests pour mesurer la capacité de charge LLM.
|
||||
|
||||
Simule N joueurs simultanés et mesure :
|
||||
- Temps de réponse par joueur (P50, P95, max)
|
||||
- Débit total (tokens/s global)
|
||||
- Temps d'attente queue (joueur 1 vs joueur N)
|
||||
- Viabilité pour le mode temps-réel (1h/tick)
|
||||
|
||||
Usage :
|
||||
python3 capacity_test.py
|
||||
python3 capacity_test.py --quick (skip 50-player test)
|
||||
python3 capacity_test.py --output /path/to/results.json
|
||||
"""
|
||||
|
||||
import os, sys, time, json, argparse, threading, statistics
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b")
|
||||
MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b")
|
||||
OUTPUT = os.getenv("CRASH_RESULTS_PATH",
|
||||
"/home/ubuntu/fallout-venice/src/config/crash_results.json")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompts variés pour éviter le cache
|
||||
# ---------------------------------------------------------------------------
|
||||
PNJ_PROMPTS = [
|
||||
"Tu es un garde de La Régie. Réponds en 1 phrase à : 'Laisse-moi passer.'",
|
||||
"Tu es un marchand créole de Pearl River. Réponds en 1 phrase à : 'T'as du carburant ?'",
|
||||
"Tu es une goule du Grand Krewe. Réponds en 1 phrase à : 'Que veut le Baron ?'",
|
||||
"Tu es un Écumeur. Réponds en 1 phrase à : 'On veut juste traverser le fleuve.'",
|
||||
"Tu es Sœur Eulalie de L'Union. Réponds en 1 phrase à : 'J'ai été mordu.'",
|
||||
"Tu es un technicien de la CdA. Réponds en 1 phrase à : 'Répare mon Pip-Boy.'",
|
||||
"Tu es un scavenger neutre. Réponds en 1 phrase à : 'Combien pour cette armure ?'",
|
||||
"Tu es un informateur du Syndicat. Réponds en 1 phrase à : 'Qui contrôle la Route 61 ?'",
|
||||
"Tu es un fermier de Oak Plantation. Réponds en 1 phrase à : 'Vous travaillez pour qui ?'",
|
||||
"Tu es un passeur de Laplace. Réponds en 1 phrase à : 'Combien pour traverser ?'",
|
||||
]
|
||||
|
||||
MJ_PROMPTS = [
|
||||
"En 2 phrases, décris la réaction de la foule quand les joueurs entrent dans le marché de NOLA.",
|
||||
"En 2 phrases, décris l'ambiance de Pearl River à l'aube après une nuit de combat.",
|
||||
"En 2 phrases, que se passe-t-il quand le Grand Krewe envoie un émissaire aux joueurs ?",
|
||||
"En 2 phrases, décris les conséquences d'un vol de convoi de la Régie.",
|
||||
"En 2 phrases, comment réagit L'Union quand elle apprend qu'un de ses membres a trahi ?",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_post(url: str, body: dict, timeout: int = 180) -> dict:
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def call_llm(model: str, prompt: str, player_id: int = 0) -> dict:
|
||||
t0 = time.time()
|
||||
try:
|
||||
res = http_post(f"{OLLAMA_URL}/api/generate", {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 80, "temperature": 0.7, "seed": player_id},
|
||||
})
|
||||
elapsed = time.time() - t0
|
||||
tokens = res.get("eval_count", 0)
|
||||
return {
|
||||
"player_id": player_id,
|
||||
"model": model,
|
||||
"tokens": tokens,
|
||||
"elapsed": round(elapsed, 2),
|
||||
"tok_s": round(tokens / elapsed, 2) if elapsed > 0 else 0,
|
||||
"queued_s": round(res.get("load_duration", 0) / 1e9, 2),
|
||||
"error": None,
|
||||
"enqueued_at": t0,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"player_id": player_id, "model": model,
|
||||
"tokens": 0, "elapsed": round(time.time() - t0, 2),
|
||||
"tok_s": 0, "queued_s": 0,
|
||||
"error": str(e)[:80], "enqueued_at": t0,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scénarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scenario_warmup() -> dict:
|
||||
"""1 appel MJ + 1 appel PNJ pour chauffer les modèles."""
|
||||
print(" [WARMUP] Chargement des modèles en RAM...")
|
||||
r_mj = call_llm(MODEL_MJ, MJ_PROMPTS[0], 0)
|
||||
r_pnj = call_llm(MODEL_PNJ, PNJ_PROMPTS[0], 0)
|
||||
ok = "✓" if not r_mj["error"] and not r_pnj["error"] else "✗"
|
||||
print(f" {ok} MJ: {r_mj['elapsed']}s ({r_mj['tok_s']} t/s) "
|
||||
f"PNJ: {r_pnj['elapsed']}s ({r_pnj['tok_s']} t/s)")
|
||||
return {"mj_warmup": r_mj, "pnj_warmup": r_pnj}
|
||||
|
||||
|
||||
def scenario_n_players(n: int, rounds: int = 3) -> dict:
|
||||
"""
|
||||
Simule N joueurs envoyant une action simultanément.
|
||||
Chaque joueur → 1 appel PNJ 7b (en parallèle via threads).
|
||||
Après tous les PNJ → 1 appel MJ 14b (narrateur du tick).
|
||||
"""
|
||||
print(f"\n [{n} JOUEURS] {rounds} round(s) — {n} PNJ en parallèle + 1 MJ après")
|
||||
all_rounds = []
|
||||
|
||||
for r in range(rounds):
|
||||
round_results = {"pnj": [], "mj": None}
|
||||
t_round_start = time.time()
|
||||
|
||||
# Phase 1 : N joueurs en parallèle
|
||||
lock = threading.Lock()
|
||||
threads = []
|
||||
|
||||
def player_action(pid: int):
|
||||
prompt = PNJ_PROMPTS[pid % len(PNJ_PROMPTS)]
|
||||
res = call_llm(MODEL_PNJ, prompt, pid)
|
||||
with lock:
|
||||
round_results["pnj"].append(res)
|
||||
|
||||
for i in range(n):
|
||||
t = threading.Thread(target=player_action, args=(i,))
|
||||
threads.append(t)
|
||||
|
||||
t_first = time.time()
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
t_all_pnj_done = time.time()
|
||||
pnj_total_wall = round(t_all_pnj_done - t_first, 2)
|
||||
|
||||
# Phase 2 : MJ synthétise
|
||||
mj_prompt = (
|
||||
f"Tick {r+1}, {n} joueurs ont agi simultanément dans NOLA. "
|
||||
+ MJ_PROMPTS[r % len(MJ_PROMPTS)]
|
||||
)
|
||||
mj_res = call_llm(MODEL_MJ, mj_prompt, -1)
|
||||
round_results["mj"] = mj_res
|
||||
|
||||
t_round_total = round(time.time() - t_round_start, 2)
|
||||
|
||||
# Stats PNJ de ce round
|
||||
pnj_ok = [x for x in round_results["pnj"] if not x["error"]]
|
||||
pnj_elapsed = sorted([x["elapsed"] for x in pnj_ok])
|
||||
p50 = statistics.median(pnj_elapsed) if pnj_elapsed else 0
|
||||
p95 = pnj_elapsed[int(len(pnj_elapsed) * 0.95)] if len(pnj_elapsed) > 1 else (pnj_elapsed[0] if pnj_elapsed else 0)
|
||||
|
||||
status = "✓" if not mj_res["error"] and len(pnj_ok) == n else f"⚠ {n - len(pnj_ok)} erreurs"
|
||||
print(f" R{r+1}: PNJ wall={pnj_total_wall}s P50={p50}s P95={p95}s | "
|
||||
f"MJ={mj_res['elapsed']}s | total={t_round_total}s | {status}")
|
||||
|
||||
all_rounds.append({
|
||||
"round": r + 1,
|
||||
"pnj": round_results["pnj"],
|
||||
"mj": mj_res,
|
||||
"pnj_wall_time": pnj_total_wall,
|
||||
"round_total_time": t_round_total,
|
||||
"pnj_p50": p50,
|
||||
"pnj_p95": p95,
|
||||
})
|
||||
|
||||
return {"n_players": n, "rounds": all_rounds}
|
||||
|
||||
|
||||
def compute_stats(scenario_result: dict) -> dict:
|
||||
n = scenario_result["n_players"]
|
||||
rounds = scenario_result["rounds"]
|
||||
|
||||
all_pnj_elapsed = [x["elapsed"] for r in rounds for x in r["pnj"] if not x.get("error")]
|
||||
all_pnj_wall = [r["pnj_wall_time"] for r in rounds]
|
||||
all_mj_elapsed = [r["mj"]["elapsed"] for r in rounds if not r["mj"].get("error")]
|
||||
all_total = [r["round_total_time"] for r in rounds]
|
||||
all_p50 = [r["pnj_p50"] for r in rounds]
|
||||
all_p95 = [r["pnj_p95"] for r in rounds]
|
||||
errors = sum(1 for r in rounds for x in r["pnj"] if x.get("error"))
|
||||
|
||||
def avg(lst): return round(sum(lst) / len(lst), 2) if lst else 0
|
||||
|
||||
tick_duration_s = avg(all_total)
|
||||
viable_realtime = tick_duration_s < 3600
|
||||
viable_5min = tick_duration_s < 300
|
||||
|
||||
return {
|
||||
"n_players": n,
|
||||
"pnj_avg_elapsed_s": avg(all_pnj_elapsed),
|
||||
"pnj_avg_wall_s": avg(all_pnj_wall),
|
||||
"pnj_avg_p50_s": avg(all_p50),
|
||||
"pnj_avg_p95_s": avg(all_p95),
|
||||
"mj_avg_elapsed_s": avg(all_mj_elapsed),
|
||||
"tick_total_avg_s": tick_duration_s,
|
||||
"tick_total_min_s": min(all_total) if all_total else 0,
|
||||
"tick_total_max_s": max(all_total) if all_total else 0,
|
||||
"errors": errors,
|
||||
"viable_realtime_1h": viable_realtime,
|
||||
"viable_5min_tick": viable_5min,
|
||||
"actions_per_hour": int(3600 / tick_duration_s) if tick_duration_s > 0 else 0,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rapport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEP = "=" * 70
|
||||
|
||||
def print_report(warmup: dict, scenarios: list[dict], stats_list: list[dict]):
|
||||
print(f"\n{SEP}")
|
||||
print(f" RAPPORT CAPACITÉ — FALLOUT VENICE — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
|
||||
print(SEP)
|
||||
|
||||
w_mj = warmup.get("mj_warmup", {})
|
||||
w_pnj = warmup.get("pnj_warmup", {})
|
||||
print(f"\n BASELINE (warm)")
|
||||
print(f" MJ 14b : {w_mj.get('elapsed','?')}s | {w_mj.get('tok_s','?')} tok/s")
|
||||
print(f" PNJ 7b : {w_pnj.get('elapsed','?')}s | {w_pnj.get('tok_s','?')} tok/s")
|
||||
|
||||
print(f"\n {'N':>4} {'PNJ wall':>10} {'P50':>7} {'P95':>7} {'MJ':>7} {'TICK total':>11} {'Actions/h':>10} {'1h viable':>10} ERR")
|
||||
print(f" {'-'*4} {'-'*10} {'-'*7} {'-'*7} {'-'*7} {'-'*11} {'-'*10} {'-'*10} {'-'*4}")
|
||||
|
||||
for s in stats_list:
|
||||
v1h = "✓" if s["viable_realtime_1h"] else "✗"
|
||||
err = str(s["errors"]) if s["errors"] else "0"
|
||||
print(f" {s['n_players']:>4} "
|
||||
f"{s['pnj_avg_wall_s']:>9.1f}s "
|
||||
f"{s['pnj_avg_p50_s']:>6.1f}s "
|
||||
f"{s['pnj_avg_p95_s']:>6.1f}s "
|
||||
f"{s['mj_avg_elapsed_s']:>6.1f}s "
|
||||
f"{s['tick_total_avg_s']:>10.1f}s "
|
||||
f"{s['actions_per_hour']:>10} "
|
||||
f"{v1h:>10} "
|
||||
f"{err:>4}")
|
||||
|
||||
print(f"\n LÉGENDE")
|
||||
print(f" PNJ wall : durée réelle pour que TOUS les PNJ répondent (parallèle)")
|
||||
print(f" P50 / P95 : médiane / 95e percentile du temps d'attente par joueur")
|
||||
print(f" TICK total : PNJ wall + MJ (= durée d'un tick complet)")
|
||||
print(f" Actions/h : combien de ticks complets tiennent en 1h réelle")
|
||||
print(f" 1h viable : tick < 1h (compatible mode temps-réel)")
|
||||
|
||||
print(f"\n RECOMMANDATIONS")
|
||||
for s in stats_list:
|
||||
n = s["n_players"]
|
||||
t = s["tick_total_avg_s"]
|
||||
a = s["actions_per_hour"]
|
||||
if a >= 6:
|
||||
msg = f"✓ {n} joueurs → 1 action toutes les {round(t/60,1)} min — jouable"
|
||||
elif a >= 1:
|
||||
msg = f"⚠ {n} joueurs → 1 action toutes les {round(t/60,1)} min — lent mais viable en temps réel"
|
||||
else:
|
||||
msg = f"✗ {n} joueurs → tick de {round(t/60,1)} min — dépasse 1h, non viable en temps réel"
|
||||
print(f" {msg}")
|
||||
|
||||
print(f"\n{SEP}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Capacity test LLM Fallout Venice")
|
||||
parser.add_argument("--quick", action="store_true", help="Skip 50-player test")
|
||||
parser.add_argument("--output", default=OUTPUT)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\n{SEP}")
|
||||
print(f" CAPACITY TEST — FALLOUT VENICE OF WASTELAND")
|
||||
print(f" Ollama : {OLLAMA_URL}")
|
||||
print(f" MJ : {MODEL_MJ} | PNJ : {MODEL_PNJ}")
|
||||
print(f" Début : {datetime.now().strftime('%H:%M:%S')}")
|
||||
print(SEP)
|
||||
|
||||
# Vérifier Ollama
|
||||
try:
|
||||
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r:
|
||||
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
|
||||
print(f"\n Modèles dispos : {', '.join(models)}")
|
||||
except Exception as e:
|
||||
print(f" [ERR] Ollama inaccessible : {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Warmup (toujours)
|
||||
print(f"\n{SEP}")
|
||||
print(" PHASE 0 — WARMUP (chargement modèles en RAM)")
|
||||
print(SEP)
|
||||
warmup = scenario_warmup()
|
||||
|
||||
# 2. Série de tests
|
||||
configs = [
|
||||
{"n": 1, "rounds": 3}, # baseline solo
|
||||
{"n": 5, "rounds": 3}, # petit groupe
|
||||
{"n": 10, "rounds": 2}, # groupe moyen
|
||||
]
|
||||
if not args.quick:
|
||||
configs.append({"n": 50, "rounds": 1}) # stress
|
||||
|
||||
scenarios = []
|
||||
stats_list = []
|
||||
|
||||
for cfg in configs:
|
||||
n, rounds = cfg["n"], cfg["rounds"]
|
||||
print(f"\n{SEP}")
|
||||
print(f" PHASE — {n} JOUEUR{'S' if n > 1 else ''} SIMULTANÉ{'S' if n > 1 else ''} ({rounds} round{'s' if rounds > 1 else ''})")
|
||||
print(SEP)
|
||||
result = scenario_n_players(n, rounds)
|
||||
s = compute_stats(result)
|
||||
scenarios.append(result)
|
||||
stats_list.append(s)
|
||||
|
||||
# 3. Rapport terminal
|
||||
print_report(warmup, scenarios, stats_list)
|
||||
|
||||
# 4. Export JSON
|
||||
output = {
|
||||
"test_type": "capacity",
|
||||
"mode": "capacity_test",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"ollama_url": OLLAMA_URL,
|
||||
"model_mj": MODEL_MJ,
|
||||
"model_pnj": MODEL_PNJ,
|
||||
"warmup": warmup,
|
||||
"scenarios": scenarios,
|
||||
"stats": stats_list,
|
||||
"file_date": datetime.now().strftime("%d/%m/%Y %H:%M:%S"),
|
||||
}
|
||||
try:
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n Résultats écrits → {args.output}")
|
||||
except Exception as e:
|
||||
print(f" [WARN] Impossible d'écrire le fichier : {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user