patdev/k3-a40-bootstrap
01.3k
1"""Plusieurs sessions Claude Code simultanées : le cache tient-il, et à quel prix ?2 3Deux mécanismes indépendants sont en jeu, et ils tirent en sens contraire.4 5Le cache de préfixe de vLLM est **global**, pas par session : c'est un arbre6partagé entre toutes les requêtes. Deux clients qui envoient le même prompt7système ne le prefillent qu'une fois — le gain croît donc avec le nombre de8sessions.9 10La spéculation, elle, **perd** en concurrence : les brouillons consomment le11budget de batch. Ce script mesure les deux effets ensemble, par session et en12agrégé, pour situer le point de bascule.13 14 python multi_session.py --base https://<pod>-8080.proxy.runpod.net --sessions 1,2,4,815"""16 17from __future__ import annotations18 19import argparse20import json21import re22import threading23import time24import urllib.error25import urllib.request26 27# Prompt systeme identique pour toutes les sessions : c'est la situation reelle28# (Claude Code envoie le meme) et c'est ce qui rend le cache partage observable.29SYSTEM = ("Tu es un agent de codage autonome. Tu lis avant d'ecrire, tu procedes "30 "par etapes verifiables, et tu produis le fichier complet modifie.\n"31 + "Conventions du projet : type hints partout, docstrings sur les "32 "fonctions publiques, pas de dependance non justifiee, tests pytest.\n" * 60)33 34MODULE = """def handler_{i}(request: dict, context: Any) -> dict:35 payload = request.get('payload')36 if payload is None:37 raise ValueError('missing payload in handler_{i}')38 result = context.process(payload, retries=3, timeout={t})39 return {{'status': 'ok', 'handler': {i}, 'result': result}}40"""41 42 43def post(url: str, body: dict, timeout: int = 900) -> dict | None:44 req = urllib.request.Request(45 url, data=json.dumps(body).encode(),46 headers={"Content-Type": "application/json",47 # Cloudflare rejette la signature par defaut d'urllib (403/1010).48 "User-Agent": "Mozilla/5.0 (multi-session)"})49 try:50 with urllib.request.urlopen(req, timeout=timeout) as r:51 return json.loads(r.read())52 except Exception as e:53 return {"_err": f"{type(e).__name__}: {e}"}54 55 56def metrics(base: str) -> dict:57 """Compteurs de blocs de cache : c'est la preuve directe du partage."""58 try:59 req = urllib.request.Request(base.rstrip("/") + "/metrics",60 headers={"User-Agent": "Mozilla/5.0"})61 with urllib.request.urlopen(req, timeout=20) as r:62 txt = r.read().decode("utf-8", "ignore")63 except Exception:64 return {}65 out = {}66 for key in ("vllm:prefix_cache_queries_total", "vllm:prefix_cache_hits_total",67 "vllm:gpu_prefix_cache_queries_total", "vllm:gpu_prefix_cache_hits_total"):68 tot = 0.069 for m in re.finditer(rf"^{re.escape(key)}\{{.*?\}}\s+([\d.e+]+)$", txt, re.M):70 tot += float(m.group(1))71 if tot:72 out[key.split(":")[-1]] = tot73 return out74 75 76def session(base: str, model: str, sid: int, turns: int, out: list) -> None:77 """Une session = plusieurs tours qui reutilisent le meme prompt systeme."""78 url = base.rstrip("/") + "/v1/chat/completions"79 history = [{"role": "system", "content": SYSTEM}]80 got = 081 t0 = time.time()82 for t in range(turns):83 i = sid * 100 + t84 code = "".join(MODULE.format(i=j, t=j + 5) for j in range(i, i + 3))85 history.append({"role": "user", "content":86 f"Ajoute la gestion d'erreur a handler_{i}.\n\n"87 f"```python\n{code}\n```\n\nProduis le fichier complet."})88 r = post(url, {"model": model, "messages": history,89 "max_tokens": 400, "temperature": 0})90 if not r or "_err" in r:91 out.append({"sid": sid, "err": (r or {}).get("_err", "?")})92 return93 msg = (r.get("choices") or [{}])[0].get("message", {}).get("content") or ""94 got += (r.get("usage") or {}).get("completion_tokens", 0)95 history.append({"role": "assistant", "content": msg})96 out.append({"sid": sid, "tokens": got, "sec": time.time() - t0})97 98 99def main() -> None:100 ap = argparse.ArgumentParser()101 ap.add_argument("--base", required=True)102 ap.add_argument("--model", default="qwen")103 ap.add_argument("--sessions", default="1,2,4,8")104 ap.add_argument("--turns", type=int, default=4)105 ap.add_argument("--hourly", type=float, default=0.44)106 a = ap.parse_args()107 108 print(f"{'sessions':>8} {'tokens':>8} {'duree':>7} {'agrege':>10} "109 f"{'/session':>10} {'$/1M':>8} cache partage")110 print("-" * 78)111 112 for n in [int(x) for x in a.sessions.split(",")]:113 before = metrics(a.base)114 out: list = []115 threads = [threading.Thread(target=session,116 args=(a.base, a.model, s, a.turns, out))117 for s in range(n)]118 t0 = time.time()119 for th in threads:120 th.start()121 for th in threads:122 th.join()123 dt = time.time() - t0124 after = metrics(a.base)125 126 errs = [o for o in out if "err" in o]127 toks = sum(o.get("tokens", 0) for o in out)128 agg = toks / dt if dt else 0129 130 # Le taux de reussite du cache sur CE palier : ce que les sessions ont131 # evite de reprefiller grace au prefixe partage.132 # vLLM expose `prefix_cache_*`, pas `gpu_prefix_cache_*` : chercher le133 # mauvais nom rendait "n/d" et laissait croire que le cache ne marchait134 # pas, alors qu'il tournait a ~90 % de reussite.135 dq = after.get("prefix_cache_queries_total", 0) - before.get("prefix_cache_queries_total", 0)136 dh = after.get("prefix_cache_hits_total", 0) - before.get("prefix_cache_hits_total", 0)137 share = f"{100*dh/dq:.0f} % ({dh:.0f}/{dq:.0f} blocs)" if dq else "n/d"138 139 print(f"{n:>8} {toks:>8} {dt:>6.1f}s {agg:>9.1f} {agg/n:>10.1f} "140 f"{a.hourly/(agg*3600)*1e6 if agg else 0:>8.3f} {share}"141 + (f" {len(errs)} ECHECS" if errs else ""))142 for e in errs[:2]:143 print(f" session {e['sid']}: {e['err'][:100]}")144 145 146if __name__ == "__main__":147 main()148 