patdev/k3-a40-bootstrap
01.3k
1# Balayage capacite / cache / prefill sur le pod de production.2#3# Socle = la meilleure config connue : 0.93 + --enable-flashinfer-autotune.4# UNE variable change par configuration ; la reference est mesuree d'abord.5#6# TROIS NOUVEAUTES par rapport aux balayages precedents :7#8# 1. --kv-cache-memory-bytes. On est a 12,26 Gio de KV avec 0.93. vLLM annonce9# 19 243 520 512 (17,92 Gio) comme "fully utilize" -- et cette valeur-la a10# tue le serveur. L'intervalle entre les deux n'a JAMAIS ete essaye.11# C'est le seul levier qui attaque le goulot demontre : la capacite.12#13# 2. Le prefill FROID APRES EVICTION, pas seulement le froid initial. C'est le14# seul cas que le dechargement KV peut ameliorer. Les prompts sont SALES en15# TETE : sans ca le cache de prefixe fausse le "froid" d'un facteur 20.16#17# 3. Une sonde de FUITE CJK. Le 26/08 le modele a ecrit un ideogramme au18# milieu d'un mot francais -- un glissement de jeton vers le chinois.19# C'est la signature d'une marge de logit ecrasee. On la mesure a20# temperature 1,0 (le regime de Claude Code) et a 0,7 (celui des bancs).21# Un debit ne vaut rien si le texte deraille.22#23# Le script rend TOUJOURS un serveur : la meilleure config qui a demarre, ou24# la reference en repli. Ce pod sert les sessions de l'utilisateur.25import glob26import json27import os28import re29import subprocess30import time31import urllib.request32from concurrent.futures import ThreadPoolExecutor33 34BASE = "http://127.0.0.1:8000"35MODELE = "RadixArk/Qwen3.8-Flash-Next-NVFP4"36LOG = "/travail/vllm.log"37JOURNAL = "/travail/banc.log"38DEBUT = time.time()39PLAFOND = 4200 # 70 min : au-dela on assemble et on rend le serveur.40 41SOCLE = {42 "--served-model-name": "flashnext",43 "--host": "127.0.0.1",44 "--port": "8000",45 "--max-model-len": "262144",46 "--max-num-seqs": "16",47 "--gpu-memory-utilization": "0.93",48 "--distributed-executor-backend": "mp",49 "--reasoning-parser": "qwen3",50 "--tool-call-parser": "qwen3_coder",51 "--limit-mm-per-prompt": '{"image":0,"video":0}',52}53FANIONS = ["--trust-remote-code", "--enable-prefix-caching",54 "--enable-auto-tool-choice", "--enable-flashinfer-autotune",55 "--enable-prompt-tokens-details"]56 57GIO = 1024 ** 358 59 60def dire(msg):61 ligne = "[%5.1f min] %s" % ((time.time() - DEBUT) / 60, msg)62 print(ligne, flush=True)63 with open(JOURNAL, "a") as f:64 f.write(ligne + "\n")65 66 67# ---------------------------------------------------------------- indexer68def cherche_config():69 """Le seul levier qui reduise le TRAVAIL de prefill au lieu de70 l'ordonnancer : le budget de l'attention eparse QSA. On ne devine pas sa71 valeur, on la lit -- et on note ou elle se trouve dans l'arbre, parce que72 --hf-overrides doit reproduire exactement ce chemin."""73 motifs = [74 "/root/.cache/huggingface/hub/models--RadixArk--*Flash-Next*/snapshots/*/config.json",75 "/travail/**/models--RadixArk--*Flash-Next*/snapshots/*/config.json",76 os.path.expanduser(77 "~/.cache/huggingface/hub/models--RadixArk--*Flash-Next*/snapshots/*/config.json"),78 ]79 for m in motifs:80 for p in glob.glob(m, recursive=True):81 try:82 return json.load(open(p)), p83 except Exception:84 pass85 return None, None86 87 88def surcharge_indexer(facteur=0.5):89 cfg, chemin = cherche_config()90 if not cfg:91 return None, "config.json introuvable"92 if "indexer_budget" in cfg:93 v = cfg["indexer_budget"]94 return (json.dumps({"indexer_budget": int(v * facteur)}),95 "racine %s -> %d" % (v, int(v * facteur)))96 tc = cfg.get("text_config") or {}97 if "indexer_budget" in tc:98 v = tc["indexer_budget"]99 return (json.dumps({"text_config": {"indexer_budget": int(v * facteur)}}),100 "text_config %s -> %d (%s)" % (v, int(v * facteur), chemin))101 return None, "pas d'indexer_budget dans %s" % chemin102 103 104# ------------------------------------------------------------- lancement105def arreter():106 out = subprocess.run(["ps", "-eo", "pid,rss,comm", "--no-headers"],107 capture_output=True, text=True).stdout108 for l in out.splitlines():109 p = l.split(None, 2)110 if len(p) != 3:111 continue112 pid, rss, comm = int(p[0]), int(p[1]), p[2].strip()113 # Filtrer sur le NOM, jamais sur la ligne de commande : ce script114 # contient lui-meme la commande de lancement.115 if re.match(r"^(vllm|VLLM|Ple|EngineCore)", comm) or \116 (rss > 10_000_000 and re.match(r"^python3?$", comm)):117 try:118 os.kill(pid, 9)119 except Exception:120 pass121 time.sleep(12)122 123 124FATAL = re.compile(r"Engine core initialization failed|EngineDeadError|"125 r"Traceback \(most recent call last\)")126 127 128def raison(txt):129 m = re.findall(r"^.*(?:Error|Exception|not supported|NotImplemented).*$",130 txt, re.M)131 return " || ".join(x.strip()[:200] for x in m[-3:]) or "cause non identifiee"132 133 134def lancer(nom, remplace, ajout, retire):135 if os.path.exists(LOG):136 os.replace(LOG, "/travail/vllm-%s-precedent.log" % nom)137 opts = dict(SOCLE)138 opts.update(remplace)139 cmd = ["vllm", "serve", MODELE]140 for k, v in opts.items():141 cmd += [k, str(v)]142 cmd += [f for f in FANIONS if f not in retire] + list(ajout)143 env = dict(os.environ, VLLM_PLE_CPU_OFFLOAD="1")144 with open(LOG, "w") as sortie:145 subprocess.Popen(cmd, stdout=sortie, stderr=subprocess.STDOUT,146 stdin=subprocess.DEVNULL, start_new_session=True, env=env)147 for i in range(45):148 time.sleep(20)149 try:150 urllib.request.urlopen(BASE + "/v1/models", timeout=5)151 return True, ""152 except Exception:153 pass154 try:155 txt = open(LOG, errors="replace").read()156 except Exception:157 txt = ""158 if FATAL.search(txt):159 return False, raison(txt)160 return False, "jamais pret en 15 min"161 162 163def kv_annonce():164 try:165 txt = open(LOG, errors="replace").read()166 k = re.findall(r"GPU KV cache size: ([\d,]+) tokens", txt)167 c = re.findall(r"concurrency for [\d,]+ tokens per request: ([\d.]+)x", txt)168 return (int(k[-1].replace(",", "")) if k else None,169 float(c[-1]) if c else None)170 except Exception:171 return None, None172 173 174# ------------------------------------------------------------------ banc175def metrique(nom):176 try:177 t = urllib.request.urlopen(BASE + "/metrics", timeout=5).read().decode()178 m = re.search(r"^vllm:%s\S*\s+([0-9.eE+-]+)$" % nom, t, re.M)179 return float(m.group(1)) if m else None180 except Exception:181 return None182 183 184def attendre_vide(limite=90):185 """Trois mesures de debit solo ont deja ete polluees par le trafic de186 l'utilisateur. On refuse de mesurer plutot que de publier un faux."""187 t0 = time.time()188 while time.time() - t0 < limite:189 n = metrique("num_requests_running")190 if n is not None and n < 0.5:191 return True192 time.sleep(5)193 return False194 195 196def appel(prompt, n, ignore=True, temp=0.7):197 corps = json.dumps({"model": "flashnext", "prompt": prompt, "max_tokens": n,198 "ignore_eos": ignore, "temperature": temp}).encode()199 t = time.time()200 r = json.loads(urllib.request.urlopen(urllib.request.Request(201 BASE + "/v1/completions", data=corps,202 headers={"content-type": "application/json"}), timeout=600).read().decode())203 u = r["usage"]204 return (u["completion_tokens"], u["prompt_tokens"], time.time() - t,205 r["choices"][0]["text"])206 207 208def prompt_sale(graine, mots=24000):209 """Le sel est en TETE : rien ne peut correspondre dans le cache."""210 return ("sel%08d " % graine) + " ".join("mot%d" % (i + graine)211 for i in range(mots))212 213 214CJK = re.compile(r"[-ヿ㐀-䶿一-鿿가-]")215 216Q_PROMPT = ("Redige en francais, en trois paragraphes techniques et precis, "217 "comment on compile un projet Zig avec build.zig, ce que fait "218 "l'etape de verification des types, et pourquoi il faut relancer "219 "la compilation apres avoir modifie un module partage.\n\n")220 221 222def div4(texte):223 mots = texte.split()224 if len(mots) < 40:225 return None226 q = [tuple(mots[i:i + 4]) for i in range(len(mots) - 3)]227 return round(len(set(q)) / len(q), 3)228 229 230def qualite(r):231 """La sonde du bug du 26/08 : des ideogrammes au milieu du francais.232 Trois tirages, dont deux a temperature 1,0 -- c'est ce que Claude Code233 envoie, et c'est la que le glissement a ete observe."""234 total, echantillons, pire = 0, [], ""235 for temp in (1.0, 1.0, 0.7):236 try:237 _, _, _, txt = appel(Q_PROMPT, 420, ignore=False, temp=temp)238 except Exception:239 continue240 n = len(CJK.findall(txt))241 total += n242 echantillons.append({"temp": temp, "cjk": n, "div4": div4(txt)})243 if n and not pire:244 i = CJK.search(txt).start()245 pire = txt[max(0, i - 45):i + 25].replace("\n", " ")246 r["cjk"] = total247 r["qualite"] = echantillons248 r["div4"] = echantillons[0]["div4"] if echantillons else None249 if pire:250 r["fuite_cjk"] = pire251 252 253def banc(nom, eviction):254 r = {"nom": nom}255 r["kv"], r["conc"] = kv_annonce()256 try:257 appel("Bonjour.", 64) # rodage des noyaux258 if not attendre_vide():259 r["erreur"] = "serveur non oisif : mesure refusee"260 return r261 n, _, d, _ = appel("Ecris un texte long et detaille sur la mer.", 300)262 r["solo"] = round(n / d, 1)263 r["ms_pas"] = round(d * 1000 / n, 2)264 265 t = time.time()266 with ThreadPoolExecutor(max_workers=8) as ex:267 res = list(ex.map(lambda i: appel(268 "Ecris un texte long sur la mer numero %d." % i, 200), range(8)))269 r["agg8"] = round(sum(x[0] for x in res) / (time.time() - t), 1)270 271 A = prompt_sale(1)272 _, pA, d1, _ = appel(A, 1)273 r["prefill_froid"] = round(pA / d1)274 r["jetons"] = pA275 _, _, d2, _ = appel(A, 1)276 r["prefill_chaud"] = round(pA / d2)277 278 if eviction:279 # Chasser A du cache GPU : le KV tient ~500 k jetons, on pousse280 # 5 x 133 k = 665 k de prompts distincts.281 for i in range(2, 7):282 appel(prompt_sale(i), 1)283 _, _, d3, _ = appel(A, 1)284 r["prefill_evince"] = round(pA / d3)285 # 1,0 = rien recupere, > 1 = le dechargement sert vraiment.286 r["gain_eviction"] = round(d1 / d3, 2)287 r["hit"] = metrique("gpu_prefix_cache_hit_rate")288 qualite(r)289 except Exception as e:290 r["erreur"] = repr(e)[:200]291 return r292 293 294# ------------------------------------------------------- configurations295ov_indexer, note_indexer = surcharge_indexer(0.5)296 297CONFIGS = [298 # --- LE GOULOT DEMONTRE : la capacite. 0.93 rend 12,26 Gio ; le "fully299 # utilize" a 17,92 Gio avait tue le serveur. On sonde l'intervalle.300 ("kvmem15", {"--kv-cache-memory-bytes": 15 * GIO}, [], [], False),301 ("kvmem17", {"--kv-cache-memory-bytes": 17 * GIO}, [], [], False),302 # --- le dechargement : spectaculaire ou nul, rien entre les deux303 ("kvoff8", {"--kv-offloading-size": "8"}, [], [], True),304 # --- 166 blocs Pickle+SHA256 par prompt de 133 k, dans le chemin chaud305 ("xxhash", {"--prefix-caching-hash-algo": "xxhash"}, [], [], False),306 # --- le seul levier qui reduise le TRAVAIL de prefill307 ("indexer", {"--hf-overrides": ov_indexer} if ov_indexer else None,308 [], [], False),309 # --- granularite des succes de prefixe (bloc force a 800 pour Mamba)310 ("matchunit", {"--prefix-match-unit": "64"}, [], [], False),311 # --- partage de KV entre couches : le nom promet exactement notre regime312 ("kvshare", {}, ["--kv-sharing-fast-prefill"], [], False),313]314 315resultats = []316dire("=== indexer_budget : %s" % note_indexer)317dire("=== REFERENCE (socle 0.93 + autotune) -- relance propre")318arreter()319ok, motif = lancer("base", {}, [], [])320if ok:321 ref = banc("base", eviction=True)322 resultats.append(ref)323 dire(" base : %s" % json.dumps(ref, ensure_ascii=False))324else:325 ref = {}326 dire(" base NE DEMARRE PAS -- %s" % motif)327 resultats.append({"nom": "base", "echec": motif})328 329for nom, remplace, ajout, retire, evic in CONFIGS:330 if remplace is None:331 dire("=== %s : IGNOREE (%s)" % (nom, note_indexer))332 resultats.append({"nom": nom,333 "echec": "non applicable : " + str(note_indexer)})334 continue335 if time.time() - DEBUT > PLAFOND:336 dire("!!! plafond de temps -- on assemble et on rend le serveur")337 break338 dire("=== %s : %s %s" % (nom, remplace, ajout))339 arreter()340 ok, motif = lancer(nom, remplace, ajout, retire)341 if not ok:342 dire(" %s : NE DEMARRE PAS -- %s" % (nom, motif))343 resultats.append({"nom": nom, "echec": motif})344 continue345 r = banc(nom, eviction=evic)346 resultats.append(r)347 dire(" %s : %s" % (nom, json.dumps(r, ensure_ascii=False)))348 349with open("/travail/balayage_cache.json", "w") as f:350 json.dump(resultats, f, indent=1, ensure_ascii=False)351 352 353# ------------------------------------------------- assemblage et remise354def trouve(nom):355 for r in resultats:356 if r["nom"] == nom and "echec" not in r and "erreur" not in r:357 return r358 return None359 360 361final_opts, final_fanions, raisons = {}, [], []362agg_ref = (ref or {}).get("agg8") or 0363kv_ref = (ref or {}).get("kv") or 0364 365# La capacite d'abord : le plus gros KV qui a demarre ET qui ne perd pas plus366# de 5 % d'agrege. Un cache plus grand ne vaut rien s'il ralentit.367for nom, octets in (("kvmem17", 17 * GIO), ("kvmem15", 15 * GIO)):368 r = trouve(nom)369 if r and r.get("kv") and (r.get("agg8") or 0) >= 0.95 * agg_ref \370 and r["kv"] > kv_ref:371 final_opts["--kv-cache-memory-bytes"] = octets372 raisons.append("%s (kv %d vs %d, agg8 %s vs %s)"373 % (nom, r["kv"], kv_ref, r["agg8"], agg_ref))374 break375 376# Puis ce qui rend du debit sans rien couter.377for nom, cle, val in (("xxhash", "--prefix-caching-hash-algo", "xxhash"),378 ("matchunit", "--prefix-match-unit", "64")):379 r = trouve(nom)380 if r and r.get("agg8") and r["agg8"] > agg_ref * 1.01:381 final_opts[cle] = val382 raisons.append("%s (agg8 %s > %s)" % (nom, r["agg8"], agg_ref))383 384# Le dechargement : seulement s'il recupere vraiment apres eviction.385r = trouve("kvoff8")386if r and (r.get("gain_eviction") or 0) > 1.15:387 final_opts["--kv-offloading-size"] = "8"388 raisons.append("kvoff8 (gain apres eviction %s)" % r["gain_eviction"])389 390r = trouve("kvshare")391if r and r.get("prefill_froid") and (ref or {}).get("prefill_froid") \392 and r["prefill_froid"] > 1.05 * ref["prefill_froid"]:393 final_fanions.append("--kv-sharing-fast-prefill")394 raisons.append("kvshare (prefill froid %s > %s)"395 % (r["prefill_froid"], ref["prefill_froid"]))396 397dire("=== CONFIG FINALE : %s" % (", ".join(raisons) or "reference telle quelle"))398arreter()399ok, motif = lancer("final", final_opts, final_fanions, [])400if not ok:401 dire(" la config finale ne demarre pas (%s) -- retour a la reference" % motif)402 arreter()403 ok, motif = lancer("repli", {}, [], [])404 dire(" repli : %s %s" % ("OK" if ok else "ECHEC", motif))405 final_opts, final_fanions = {}, []406 407kv, conc = kv_annonce()408dire("=== SERVEUR RENDU : kv=%s jetons, concurrence %sx, options=%s %s"409 % (kv, conc, final_opts, final_fanions))410 411print("\n\n########## TABLEAU ##########")412ENT = "%-10s %9s %5s %6s %7s %8s %8s %6s %4s %6s"413print(ENT % ("config", "kv", "conc", "solo", "agg8", "pref_fr", "pref_ev",414 "gain", "cjk", "div4"))415for r in resultats:416 if "echec" in r:417 print("%-10s NE DEMARRE PAS : %s" % (r["nom"], r["echec"][:95]))418 elif "erreur" in r:419 print("%-10s ERREUR : %s" % (r["nom"], r["erreur"][:95]))420 else:421 print(ENT % (r["nom"], r.get("kv"), r.get("conc"), r.get("solo"),422 r.get("agg8"), r.get("prefill_froid"),423 r.get("prefill_evince", "-"), r.get("gain_eviction", "-"),424 r.get("cjk"), r.get("div4")))425print("\n gain = prefill apres eviction / prefill froid. 1,0 = rien recupere.")426print(" cjk = ideogrammes dans 3 generations FRANCAISES (2 a temperature 1,0).")427print(" Zero est le seul resultat acceptable.")428print("\n########## FUITES CJK OBSERVEES ##########")429vu = False430for r in resultats:431 if r.get("fuite_cjk"):432 vu = True433 print(" %-10s ...%s..." % (r["nom"], r["fuite_cjk"]))434if not vu:435 print(" aucune")436 