File size: 16,915 Bytes
5045c2a
e38cb1b
5045c2a
 
e38cb1b
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
 
 
 
 
 
 
 
 
 
 
 
 
5045c2a
e38cb1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5045c2a
e38cb1b
 
 
 
 
 
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
 
e38cb1b
 
 
 
 
 
 
 
5045c2a
 
e38cb1b
 
 
 
 
 
 
 
 
 
 
 
 
 
5045c2a
 
e38cb1b
 
 
 
 
 
 
 
 
 
5045c2a
 
e38cb1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
e38cb1b
 
 
 
 
 
 
 
 
 
5045c2a
e38cb1b
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
 
 
 
e38cb1b
 
5045c2a
 
e38cb1b
 
 
 
 
 
 
 
 
 
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
e38cb1b
 
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
 
e38cb1b
 
 
5045c2a
e38cb1b
 
 
5045c2a
e38cb1b
 
 
5045c2a
 
 
 
 
 
e38cb1b
5045c2a
e38cb1b
5045c2a
e38cb1b
 
 
 
 
 
5045c2a
e38cb1b
 
 
 
 
 
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e38cb1b
5045c2a
 
 
e38cb1b
 
5045c2a
e38cb1b
5045c2a
e38cb1b
5045c2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# Balayage capacite / cache / prefill sur le pod de production.
#
# Socle = la meilleure config connue : 0.93 + --enable-flashinfer-autotune.
# UNE variable change par configuration ; la reference est mesuree d'abord.
#
# TROIS NOUVEAUTES par rapport aux balayages precedents :
#
# 1. --kv-cache-memory-bytes. On est a 12,26 Gio de KV avec 0.93. vLLM annonce
#    19 243 520 512 (17,92 Gio) comme "fully utilize" -- et cette valeur-la a
#    tue le serveur. L'intervalle entre les deux n'a JAMAIS ete essaye.
#    C'est le seul levier qui attaque le goulot demontre : la capacite.
#
# 2. Le prefill FROID APRES EVICTION, pas seulement le froid initial. C'est le
#    seul cas que le dechargement KV peut ameliorer. Les prompts sont SALES en
#    TETE : sans ca le cache de prefixe fausse le "froid" d'un facteur 20.
#
# 3. Une sonde de FUITE CJK. Le 26/08 le modele a ecrit un ideogramme au
#    milieu d'un mot francais -- un glissement de jeton vers le chinois.
#    C'est la signature d'une marge de logit ecrasee. On la mesure a
#    temperature 1,0 (le regime de Claude Code) et a 0,7 (celui des bancs).
#    Un debit ne vaut rien si le texte deraille.
#
# Le script rend TOUJOURS un serveur : la meilleure config qui a demarre, ou
# la reference en repli. Ce pod sert les sessions de l'utilisateur.
import glob
import json
import os
import re
import subprocess
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor

BASE = "http://127.0.0.1:8000"
MODELE = "RadixArk/Qwen3.8-Flash-Next-NVFP4"
LOG = "/travail/vllm.log"
JOURNAL = "/travail/banc.log"
DEBUT = time.time()
PLAFOND = 4200          # 70 min : au-dela on assemble et on rend le serveur.

SOCLE = {
    "--served-model-name": "flashnext",
    "--host": "127.0.0.1",
    "--port": "8000",
    "--max-model-len": "262144",
    "--max-num-seqs": "16",
    "--gpu-memory-utilization": "0.93",
    "--distributed-executor-backend": "mp",
    "--reasoning-parser": "qwen3",
    "--tool-call-parser": "qwen3_coder",
    "--limit-mm-per-prompt": '{"image":0,"video":0}',
}
FANIONS = ["--trust-remote-code", "--enable-prefix-caching",
           "--enable-auto-tool-choice", "--enable-flashinfer-autotune",
           "--enable-prompt-tokens-details"]

GIO = 1024 ** 3


def dire(msg):
    ligne = "[%5.1f min] %s" % ((time.time() - DEBUT) / 60, msg)
    print(ligne, flush=True)
    with open(JOURNAL, "a") as f:
        f.write(ligne + "\n")


# ---------------------------------------------------------------- indexer
def cherche_config():
    """Le seul levier qui reduise le TRAVAIL de prefill au lieu de
    l'ordonnancer : le budget de l'attention eparse QSA. On ne devine pas sa
    valeur, on la lit -- et on note ou elle se trouve dans l'arbre, parce que
    --hf-overrides doit reproduire exactement ce chemin."""
    motifs = [
        "/root/.cache/huggingface/hub/models--RadixArk--*Flash-Next*/snapshots/*/config.json",
        "/travail/**/models--RadixArk--*Flash-Next*/snapshots/*/config.json",
        os.path.expanduser(
            "~/.cache/huggingface/hub/models--RadixArk--*Flash-Next*/snapshots/*/config.json"),
    ]
    for m in motifs:
        for p in glob.glob(m, recursive=True):
            try:
                return json.load(open(p)), p
            except Exception:
                pass
    return None, None


def surcharge_indexer(facteur=0.5):
    cfg, chemin = cherche_config()
    if not cfg:
        return None, "config.json introuvable"
    if "indexer_budget" in cfg:
        v = cfg["indexer_budget"]
        return (json.dumps({"indexer_budget": int(v * facteur)}),
                "racine %s -> %d" % (v, int(v * facteur)))
    tc = cfg.get("text_config") or {}
    if "indexer_budget" in tc:
        v = tc["indexer_budget"]
        return (json.dumps({"text_config": {"indexer_budget": int(v * facteur)}}),
                "text_config %s -> %d (%s)" % (v, int(v * facteur), chemin))
    return None, "pas d'indexer_budget dans %s" % chemin


# ------------------------------------------------------------- lancement
def arreter():
    out = subprocess.run(["ps", "-eo", "pid,rss,comm", "--no-headers"],
                         capture_output=True, text=True).stdout
    for l in out.splitlines():
        p = l.split(None, 2)
        if len(p) != 3:
            continue
        pid, rss, comm = int(p[0]), int(p[1]), p[2].strip()
        # Filtrer sur le NOM, jamais sur la ligne de commande : ce script
        # contient lui-meme la commande de lancement.
        if re.match(r"^(vllm|VLLM|Ple|EngineCore)", comm) or \
           (rss > 10_000_000 and re.match(r"^python3?$", comm)):
            try:
                os.kill(pid, 9)
            except Exception:
                pass
    time.sleep(12)


FATAL = re.compile(r"Engine core initialization failed|EngineDeadError|"
                   r"Traceback \(most recent call last\)")


def raison(txt):
    m = re.findall(r"^.*(?:Error|Exception|not supported|NotImplemented).*$",
                   txt, re.M)
    return " || ".join(x.strip()[:200] for x in m[-3:]) or "cause non identifiee"


def lancer(nom, remplace, ajout, retire):
    if os.path.exists(LOG):
        os.replace(LOG, "/travail/vllm-%s-precedent.log" % nom)
    opts = dict(SOCLE)
    opts.update(remplace)
    cmd = ["vllm", "serve", MODELE]
    for k, v in opts.items():
        cmd += [k, str(v)]
    cmd += [f for f in FANIONS if f not in retire] + list(ajout)
    env = dict(os.environ, VLLM_PLE_CPU_OFFLOAD="1")
    with open(LOG, "w") as sortie:
        subprocess.Popen(cmd, stdout=sortie, stderr=subprocess.STDOUT,
                         stdin=subprocess.DEVNULL, start_new_session=True, env=env)
    for i in range(45):
        time.sleep(20)
        try:
            urllib.request.urlopen(BASE + "/v1/models", timeout=5)
            return True, ""
        except Exception:
            pass
        try:
            txt = open(LOG, errors="replace").read()
        except Exception:
            txt = ""
        if FATAL.search(txt):
            return False, raison(txt)
    return False, "jamais pret en 15 min"


def kv_annonce():
    try:
        txt = open(LOG, errors="replace").read()
        k = re.findall(r"GPU KV cache size: ([\d,]+) tokens", txt)
        c = re.findall(r"concurrency for [\d,]+ tokens per request: ([\d.]+)x", txt)
        return (int(k[-1].replace(",", "")) if k else None,
                float(c[-1]) if c else None)
    except Exception:
        return None, None


# ------------------------------------------------------------------ banc
def metrique(nom):
    try:
        t = urllib.request.urlopen(BASE + "/metrics", timeout=5).read().decode()
        m = re.search(r"^vllm:%s\S*\s+([0-9.eE+-]+)$" % nom, t, re.M)
        return float(m.group(1)) if m else None
    except Exception:
        return None


def attendre_vide(limite=90):
    """Trois mesures de debit solo ont deja ete polluees par le trafic de
    l'utilisateur. On refuse de mesurer plutot que de publier un faux."""
    t0 = time.time()
    while time.time() - t0 < limite:
        n = metrique("num_requests_running")
        if n is not None and n < 0.5:
            return True
        time.sleep(5)
    return False


def appel(prompt, n, ignore=True, temp=0.7):
    corps = json.dumps({"model": "flashnext", "prompt": prompt, "max_tokens": n,
                        "ignore_eos": ignore, "temperature": temp}).encode()
    t = time.time()
    r = json.loads(urllib.request.urlopen(urllib.request.Request(
        BASE + "/v1/completions", data=corps,
        headers={"content-type": "application/json"}), timeout=600).read().decode())
    u = r["usage"]
    return (u["completion_tokens"], u["prompt_tokens"], time.time() - t,
            r["choices"][0]["text"])


def prompt_sale(graine, mots=24000):
    """Le sel est en TETE : rien ne peut correspondre dans le cache."""
    return ("sel%08d " % graine) + " ".join("mot%d" % (i + graine)
                                            for i in range(mots))


CJK = re.compile(r"[぀-ヿ㐀-䶿一-鿿가-힯]")

Q_PROMPT = ("Redige en francais, en trois paragraphes techniques et precis, "
            "comment on compile un projet Zig avec build.zig, ce que fait "
            "l'etape de verification des types, et pourquoi il faut relancer "
            "la compilation apres avoir modifie un module partage.\n\n")


def div4(texte):
    mots = texte.split()
    if len(mots) < 40:
        return None
    q = [tuple(mots[i:i + 4]) for i in range(len(mots) - 3)]
    return round(len(set(q)) / len(q), 3)


def qualite(r):
    """La sonde du bug du 26/08 : des ideogrammes au milieu du francais.
    Trois tirages, dont deux a temperature 1,0 -- c'est ce que Claude Code
    envoie, et c'est la que le glissement a ete observe."""
    total, echantillons, pire = 0, [], ""
    for temp in (1.0, 1.0, 0.7):
        try:
            _, _, _, txt = appel(Q_PROMPT, 420, ignore=False, temp=temp)
        except Exception:
            continue
        n = len(CJK.findall(txt))
        total += n
        echantillons.append({"temp": temp, "cjk": n, "div4": div4(txt)})
        if n and not pire:
            i = CJK.search(txt).start()
            pire = txt[max(0, i - 45):i + 25].replace("\n", " ")
    r["cjk"] = total
    r["qualite"] = echantillons
    r["div4"] = echantillons[0]["div4"] if echantillons else None
    if pire:
        r["fuite_cjk"] = pire


def banc(nom, eviction):
    r = {"nom": nom}
    r["kv"], r["conc"] = kv_annonce()
    try:
        appel("Bonjour.", 64)                        # rodage des noyaux
        if not attendre_vide():
            r["erreur"] = "serveur non oisif : mesure refusee"
            return r
        n, _, d, _ = appel("Ecris un texte long et detaille sur la mer.", 300)
        r["solo"] = round(n / d, 1)
        r["ms_pas"] = round(d * 1000 / n, 2)

        t = time.time()
        with ThreadPoolExecutor(max_workers=8) as ex:
            res = list(ex.map(lambda i: appel(
                "Ecris un texte long sur la mer numero %d." % i, 200), range(8)))
        r["agg8"] = round(sum(x[0] for x in res) / (time.time() - t), 1)

        A = prompt_sale(1)
        _, pA, d1, _ = appel(A, 1)
        r["prefill_froid"] = round(pA / d1)
        r["jetons"] = pA
        _, _, d2, _ = appel(A, 1)
        r["prefill_chaud"] = round(pA / d2)

        if eviction:
            # Chasser A du cache GPU : le KV tient ~500 k jetons, on pousse
            # 5 x 133 k = 665 k de prompts distincts.
            for i in range(2, 7):
                appel(prompt_sale(i), 1)
            _, _, d3, _ = appel(A, 1)
            r["prefill_evince"] = round(pA / d3)
            # 1,0 = rien recupere, > 1 = le dechargement sert vraiment.
            r["gain_eviction"] = round(d1 / d3, 2)
        r["hit"] = metrique("gpu_prefix_cache_hit_rate")
        qualite(r)
    except Exception as e:
        r["erreur"] = repr(e)[:200]
    return r


# ------------------------------------------------------- configurations
ov_indexer, note_indexer = surcharge_indexer(0.5)

CONFIGS = [
    # --- LE GOULOT DEMONTRE : la capacite. 0.93 rend 12,26 Gio ; le "fully
    # utilize" a 17,92 Gio avait tue le serveur. On sonde l'intervalle.
    ("kvmem15", {"--kv-cache-memory-bytes": 15 * GIO}, [], [], False),
    ("kvmem17", {"--kv-cache-memory-bytes": 17 * GIO}, [], [], False),
    # --- le dechargement : spectaculaire ou nul, rien entre les deux
    ("kvoff8", {"--kv-offloading-size": "8"}, [], [], True),
    # --- 166 blocs Pickle+SHA256 par prompt de 133 k, dans le chemin chaud
    ("xxhash", {"--prefix-caching-hash-algo": "xxhash"}, [], [], False),
    # --- le seul levier qui reduise le TRAVAIL de prefill
    ("indexer", {"--hf-overrides": ov_indexer} if ov_indexer else None,
     [], [], False),
    # --- granularite des succes de prefixe (bloc force a 800 pour Mamba)
    ("matchunit", {"--prefix-match-unit": "64"}, [], [], False),
    # --- partage de KV entre couches : le nom promet exactement notre regime
    ("kvshare", {}, ["--kv-sharing-fast-prefill"], [], False),
]

resultats = []
dire("=== indexer_budget : %s" % note_indexer)
dire("=== REFERENCE (socle 0.93 + autotune) -- relance propre")
arreter()
ok, motif = lancer("base", {}, [], [])
if ok:
    ref = banc("base", eviction=True)
    resultats.append(ref)
    dire("  base : %s" % json.dumps(ref, ensure_ascii=False))
else:
    ref = {}
    dire("  base NE DEMARRE PAS -- %s" % motif)
    resultats.append({"nom": "base", "echec": motif})

for nom, remplace, ajout, retire, evic in CONFIGS:
    if remplace is None:
        dire("=== %s : IGNOREE (%s)" % (nom, note_indexer))
        resultats.append({"nom": nom,
                          "echec": "non applicable : " + str(note_indexer)})
        continue
    if time.time() - DEBUT > PLAFOND:
        dire("!!! plafond de temps -- on assemble et on rend le serveur")
        break
    dire("=== %s : %s %s" % (nom, remplace, ajout))
    arreter()
    ok, motif = lancer(nom, remplace, ajout, retire)
    if not ok:
        dire("  %s : NE DEMARRE PAS -- %s" % (nom, motif))
        resultats.append({"nom": nom, "echec": motif})
        continue
    r = banc(nom, eviction=evic)
    resultats.append(r)
    dire("  %s : %s" % (nom, json.dumps(r, ensure_ascii=False)))

with open("/travail/balayage_cache.json", "w") as f:
    json.dump(resultats, f, indent=1, ensure_ascii=False)


# ------------------------------------------------- assemblage et remise
def trouve(nom):
    for r in resultats:
        if r["nom"] == nom and "echec" not in r and "erreur" not in r:
            return r
    return None


final_opts, final_fanions, raisons = {}, [], []
agg_ref = (ref or {}).get("agg8") or 0
kv_ref = (ref or {}).get("kv") or 0

# La capacite d'abord : le plus gros KV qui a demarre ET qui ne perd pas plus
# de 5 % d'agrege. Un cache plus grand ne vaut rien s'il ralentit.
for nom, octets in (("kvmem17", 17 * GIO), ("kvmem15", 15 * GIO)):
    r = trouve(nom)
    if r and r.get("kv") and (r.get("agg8") or 0) >= 0.95 * agg_ref \
            and r["kv"] > kv_ref:
        final_opts["--kv-cache-memory-bytes"] = octets
        raisons.append("%s (kv %d vs %d, agg8 %s vs %s)"
                       % (nom, r["kv"], kv_ref, r["agg8"], agg_ref))
        break

# Puis ce qui rend du debit sans rien couter.
for nom, cle, val in (("xxhash", "--prefix-caching-hash-algo", "xxhash"),
                      ("matchunit", "--prefix-match-unit", "64")):
    r = trouve(nom)
    if r and r.get("agg8") and r["agg8"] > agg_ref * 1.01:
        final_opts[cle] = val
        raisons.append("%s (agg8 %s > %s)" % (nom, r["agg8"], agg_ref))

# Le dechargement : seulement s'il recupere vraiment apres eviction.
r = trouve("kvoff8")
if r and (r.get("gain_eviction") or 0) > 1.15:
    final_opts["--kv-offloading-size"] = "8"
    raisons.append("kvoff8 (gain apres eviction %s)" % r["gain_eviction"])

r = trouve("kvshare")
if r and r.get("prefill_froid") and (ref or {}).get("prefill_froid") \
        and r["prefill_froid"] > 1.05 * ref["prefill_froid"]:
    final_fanions.append("--kv-sharing-fast-prefill")
    raisons.append("kvshare (prefill froid %s > %s)"
                   % (r["prefill_froid"], ref["prefill_froid"]))

dire("=== CONFIG FINALE : %s" % (", ".join(raisons) or "reference telle quelle"))
arreter()
ok, motif = lancer("final", final_opts, final_fanions, [])
if not ok:
    dire("  la config finale ne demarre pas (%s) -- retour a la reference" % motif)
    arreter()
    ok, motif = lancer("repli", {}, [], [])
    dire("  repli : %s %s" % ("OK" if ok else "ECHEC", motif))
    final_opts, final_fanions = {}, []

kv, conc = kv_annonce()
dire("=== SERVEUR RENDU : kv=%s jetons, concurrence %sx, options=%s %s"
     % (kv, conc, final_opts, final_fanions))

print("\n\n########## TABLEAU ##########")
ENT = "%-10s %9s %5s %6s %7s %8s %8s %6s %4s %6s"
print(ENT % ("config", "kv", "conc", "solo", "agg8", "pref_fr", "pref_ev",
             "gain", "cjk", "div4"))
for r in resultats:
    if "echec" in r:
        print("%-10s  NE DEMARRE PAS : %s" % (r["nom"], r["echec"][:95]))
    elif "erreur" in r:
        print("%-10s  ERREUR : %s" % (r["nom"], r["erreur"][:95]))
    else:
        print(ENT % (r["nom"], r.get("kv"), r.get("conc"), r.get("solo"),
                     r.get("agg8"), r.get("prefill_froid"),
                     r.get("prefill_evince", "-"), r.get("gain_eviction", "-"),
                     r.get("cjk"), r.get("div4")))
print("\n  gain = prefill apres eviction / prefill froid. 1,0 = rien recupere.")
print("  cjk  = ideogrammes dans 3 generations FRANCAISES (2 a temperature 1,0).")
print("         Zero est le seul resultat acceptable.")
print("\n########## FUITES CJK OBSERVEES ##########")
vu = False
for r in resultats:
    if r.get("fuite_cjk"):
        vu = True
        print("  %-10s ...%s..." % (r["nom"], r["fuite_cjk"]))
if not vu:
    print("  aucune")