Vissza a blogra

A gyorsítótár megváltoztatta a választ, de csak akkor, ha egy másik kérés melegítette be

Kijavítottuk a nemdeterminisztikus top-k kernelt, és ötven feladatból egy még mindig ingadozott. Kiderült, hogy nem a kernel a hibás: az számít, kinek a kérése írta a közös előtagot a gyorsítótárba.

Két hete írtam arról, hogy a Qwen3.8-Flash-Next modellünk egy DGX Sparkon temperature=0 mellett is futásonként más dátumot adott vissza magyar számlákból, és hogy a hibát a vLLM sparse-attention indexerének persistent_topk kerneljében találtuk meg. A javítás azóta éles; a determinisztikus kernelt a vLLM-ben nyitott pull request (#55122) viszi upstream, amelyhez a mérésünket szeptember 3-án hozzászólásként csatoltuk.

Ez a cikk arról szól, ami a javítás után maradt.

Röviden:

  • A minőségi mérésben azóta mind az ötven feladat válasza stabil. A logit-szintű szondában viszont a négy vizsgált feladatból egy nem az: ugyanaz az egy, két mérési körben, minden friss szerverindításon, mindig ugyanazzal a mintázattal.
  • Az előző kör óta azt hittem, a „hideg" út a gyanús. Pontosan fordítva van: a hideg és a részleges találat adja a helyes eredményt, és a teljes gyorsítótár-találat tér el, mégpedig akkor, ha a közös előtag blokkjait egy más hosszúságú kérés írta be.
  • A gyorsítótár kikapcsolása megszünteti. A jelenség nem a már ismert, régóta javított „csupa nulla állapot" hiba: annak a javítása bent van a mért rendszerben.
  • Egy teljesen másik összeállításon (hivatalos kiadás, másik motorverzió, determinisztikus gyorsítókernel) ugyanaz a feladat bukik, ugyanúgy.
  • A hibát előre megjósolva elő tudtuk idézni egy addig nem használt dokumentumon, pusztán a kérések hosszának méretezésével. Egy harmadik dokumentumon viszont nem jött elő; a magyarázat tehát még nem teljes, és ezt ugyanolyan részletesen leírom.
  • Ráadás: a determinisztikus gyorsítókernel a jelenlegi biztonsági megoldásunknál 21–28 %-kal gyorsabban dolgozza fel a bemenetet, változatlan determinizmus mellett.

Frissítés, 2026. szeptember 14.: megvan a mechanizmus, és egyetlen sor megszünteti. A cikk alább leír egy jóslatot, ami egy harmadik dokumentumon nem jött be, és azt írja, hogy a magyarázat ezért nem teljes. Azóta megvan: nem a blokkszám-eltérés a változó, hanem hogy az első kérés ment-e állapotot a közös blokkhatáron. A részletek és a javítás mérése: Frissítés, szeptember 14.

Miért nem elég, hogy a válasz ugyanaz

Az első reflexem az volt, hogy megnézem a szöveget. A szöveg mind a tíz futáson azonos volt. Ha ott megállok, azt írom a jegyzőkönyvbe, hogy minden rendben.

A mérőeszköz ezért nem a szöveget nézi. Minden generált tokennél lekéri a húsz legvalószínűbb jelöltet a logaritmikus valószínűségeikkel, és ezekből képez egy SHA-256 összeget az egész válaszra. Ha a modell ugyanazt a szót választja, de mögötte a második jelölt máshol áll, azt ez látja, a szövegösszehasonlítás nem.

Ez a különbség nem elméleti. Greedy dekódolásnál az argmax akkor is stabil maradhat, ha a logitok elmozdulnak; a következő tokennél viszont már átbillenhet. A legelső körben, még a top-k kernel javítása előtt, egy ugyanilyen logit-szintű elmozdulás ötven feladatból ötben vitte el a kinyert dátumot vagy összeget.

A szövegazonosság nem bizonyíték. Ez a mondat azóta a méréseink első szabálya.

A kísérlet, ami eldöntötte

A makacs feladat (nevezzük B-nek) egy magyar dokumentumból kér ki adatokat. Volt egy másik feladat a készletben (A), amelyik ugyanazt a dokumentumot kapja meg, csak más kérdéssel és más válaszsémával. A mérési sorrendben A futott előbb.

Ez gyanús lett: ha A már beírta a közös dokumentum blokkjait a gyorsítótárba, akkor B „első, hideg" futása valójában nem is hideg. Három kart futtattam, mindegyiket külön, friss szerverindításon:

karszekvenciagyorsítótáreredmény (teljes logprob-hash)
tisztaB × 10befdc948… × 10, stabil
szennyezettA × 1, majd B × 10beB#1 = fdc948…, B#2–10 = dabe44…
kontrollA × 1, majd B × 10kic1dc66… × 10, stabil

Érdemes még egyszer elolvasni a középső sort. A szennyezett kar első futása (az, amelyik részleges találatot kap a másik kérés blokkjaira, de a saját folytatását frissen számolja) bitre ugyanazt adja, mint a teljesen gyorsítótár-mentes tiszta kar. A második és további futások, vagyis a teljes találatok térnek el, már a nulladik tokennél.

A törésvonal tehát nem a „hideg kontra gyorsítótáras" tengelyen van. A tiszta karban a 2–10. futás szintén teljes találat, és ott semmi baj. A különbség az, hogy kinek a kérése írta be a közös előtag blokkjait. Ha ugyanazé, a visszaolvasás helyes. Ha egy másik, eltérő hosszúságúé, a visszaolvasott állapot más logitokat ad.

A harmadik sor zárja a láncot: kikapcsolt gyorsítótár mellett ugyanez a szekvencia tízből tízszer azonos.

Miért pont egy hibrid modellnél

A Qwen3.8-Flash-Next nem klasszikus transzformer: lineáris (Mamba-szerű) rétegek és ritka figyelem váltakozik benne. Egy ilyen modellnél a gyorsítótárazás nehezebb feladat, mint egy sima KV-cache: a lineáris rétegek állapotot visznek magukkal, és ezt az állapotot blokkhatárokon kell menteni, majd visszaolvasni.

A vLLM ezt egy külön üzemmóddal oldja meg, és (ez a lényeges rész) magától bekapcsolja, amint a gyorsítótár be van kapcsolva:

INFO [config.py:605] Mamba cache mode is set to 'align' ... when prefix caching is enabled

A kontroll-futásban, kikapcsolt gyorsítótár mellett ez a sor meg sem jelenik. Ezért nem hasonlítható össze a két konfiguráció abszolút hash-e: nem ugyanaz a numerikus út fut. A mérőszám a stabilitás, nem az érték.

És itt jön a történet legkellemetlenebb fordulata. A vLLM-ben két nyitott javítás céloz pontosan ide: az egyik (#54076) az igazított módú darabolást köti a gyorsítótár-csoport blokkméretéhez, a másik (#53798) a lineáris réteg állapotindexét állítja be helyesen. Megnéztem a receptet, amiből a rendszerünk épül (blazux/qwen3.8-Flash-DGX), és kiderült, hogy ennek a két javításnak a lényege már bent van a mért rendszerben, egy kétsoros folttal, pontosan ugyanabban a két fájlban.

A folt fejléce leírja, mit gyógyít: a motor a blokkméretet a legkisebb gyorsítótár-csoport méretére írta felül, miközben az állapot mentése és visszaolvasása a nagy blokkmérettel számolt volna; az eredmény minden találatnál egy csupa nulla visszaolvasott állapot volt. Ez egy látványos, könnyen észrevehető hiba. Régóta javítva.

Amit én mérek, az ezen túl van: a csupa nullás eset el van hárítva, és marad egy finomabb, csak kérések között jelentkező eltérés. Ez nem teszi feleslegessé a két nyitott javítást: azok lényegesen többet tesznek a kétsoros foltnál, és simán lefedhetik a maradékot is. Csak azt jelenti, hogy a kézenfekvő magyarázat („biztosan az a régi hiba") itt nem áll.

A jóslat, ami bejött, és a másik, ami nem

Egy hiba akkor lesz igazán bizonyított, ha elő tudod idézni ott, ahol addig nem volt.

A napló elárulta a gyorsítótár blokkméretét: 1600 token. Ezután megnéztem a mért feladatpárokat. A bukó pár volt az egyetlen, ahol a két kérés eltérő számú teljes blokkot zár le:

párA hosszaB hosszateljes blokk (1600)eredmény
a bukó pár3 1693 2271 kontra 2eltér
második pár2 1552 1821 kontra 1stabil
harmadik pár24 38424 40415 kontra 15stabil

Innen jött a jóslat: ha egy eddig nem használt dokumentumhoz úgy méretezem a két kérés végét, hogy eltérő számú teljes blokkot zárjanak le, akkor az eltérésnek meg kell jelennie.

Megméreteztem, lefuttattam, és megjelent, pontosan ugyanazzal a mintázattal. Ugyanez a dokumentum, azonos blokkszámmal: stabil. Eddig a jóslat működik.

Aztán kipróbáltam egy harmadik dokumentumon, ugyanazon a szerverindításon: 2 blokk kontra 3 blokk, és stabil maradt. A szintetikus, gépi kitöltésű szöveggel sem jött elő.

Vagyis a blokkhatár-átlépés szükségesnek látszik, de nem elég: a dokumentum hossza vagy tartalma is számít. A legvalószínűbb további tényező a ritka figyelem blokk-választása, ami maga is függ a kontextustól; ennek szétválasztása a következő kör feladata.

Ezt a felemás eredményt szándékosan ugyanolyan részletesen írom le, mint a sikeres jóslatot. Egy hipotézis, amit nem próbáltál megdönteni, nem hipotézis, hanem remény.

Két nappal később kiderült, hogy a hipotézis nem felemás volt, hanem rosszul megfogalmazott. A helyes szabály ugyanezt a három esetet is megmagyarázza, a harmadik dokumentum „megdöntő" eredményét is: lásd a szeptember 14-i frissítést.

A kontroll, ami a legtöbbet érte: egy teljesen másik rendszer

Egy mérés akkor ér valamit, ha a hibát nem csak a saját gépeden, a saját összeállításodban tudod megmutatni. Ezért felépítettem egy második kiszolgálót: ugyanazon a gépen, ugyanazzal a modellfájllal, de minden másban különbözőt:

első összeállításmásodik összeállítás
alapa mi rögzített, előzetes buildünka hivatalos kiadás (v0.29.0)
a modell csomagneve a motorbana régiaz új
a top-k útvonala lassabb, biztonsági megoldása determinisztikus gyorsítókernel

Az eredmény: ugyanaz a feladat bukik, ugyanazzal a mintázattal. És a megjósolt eset (a blokkhatárra méretezett kérés-pár) itt is előjön, miközben az azonos blokkszámú kontroll ugyanazon a kiszolgálón stabil marad.

Vagyis a jelenség nem a mi buildünk sajátja, nem a régi csomagverzióé, és nem a lassabb top-k megoldás mellékhatása.

Egy mellékeredmény, ami rögtön pénzt ér

Mivel a második összeállítás a determinisztikus gyorsítókernelt használja (a #55122 kernelje, a jschmied/qwen38-flash-next-gb10 csomagolásában), ugyanazzal a szondával meg tudtuk mérni, mennyibe kerül ma nekünk a determinizmus. Azonos gép, azonos modell, azonos kapcsolók, hat különböző bemenethossz, mindegyik hideg feldolgozás:

bemenet (token)jelenlegi megoldásdeterminisztikus kernelnyereség
8 0241 736 tok/s2 097+21 %
16 0191 8252 282+25 %
24 0271 8662 293+23 %
32 0221 8772 346+25 %
48 0251 8842 346+25 %
64 0281 8152 315+28 %

A memória mindkét összeállításon sík platón marad, lépcsők nélkül; nincs kiszorítás, nincs összeomlás a hét futás egyik naplójában sem. A determinizmusért tehát már nem kell fizetni; a mi éles rendszerünk viszont még mindig fizet, amíg át nem állunk. (Az átállás feltétele a minőségi mérés megismétlése: a két megoldás tördelése holtversenyben másképp dönt, ezért a pontszám nem öröklődik.)

Amit a módszerből érdemes elvinni

A saját mérőeszközöd is szennyezhet. A makacs feladat azért „bukott", mert a készletben előtte futott egy másik, ugyanazon a dokumentumon. Nem a modell volt instabil: a mérési sorrend hozott létre egy állapotot, amit nem vettem számításba. A javítás egyszerű: minden szondához olyan dokumentumot használok, amit az adott szerverindításon még semmi nem érintett, és a kart külön, friss indításon futtatom.

A gyorsítótár-találat nem egyféle. Az „ugyanaz a prompt, tehát ugyanaz a válasz" feltevés hibrid modellnél elbukik, ha közben egy másik kérés melegítette be a közös előtagot. Aki több feladatot futtat ugyanarra a dokumentumra (márpedig egy dokumentumfeldolgozó rendszer pontosan ezt teszi), annak ez nem elméleti kockázat.

A verzió-régészet is mérés. Menet közben kiderült, hogy a modell csomagja a motorban időközben nevet váltott. Ennek két gyakorlati következménye lett: egy nyitott javítást (a #56500, a ritka figyelem prefill-munkaterületének korlátozása) nem lehet a kiadásra rétegezni (a fájl, amit módosítana, ott nem is létezik), és a régi indítási paraméterekkel az új kiadás csendben elhasal a grafikon-rögzítésnél. Egyik sem derül ki a változásnaplóból; a futó rendszerbe kell benézni. Ezt a negatív eredményt és a kompatibilitási korlátot a pull requesthez hozzászólásként beküldtük, hogy a következő GB10-es tesztelő ne fusson bele ugyanabba.

Frissítés, szeptember 14.: megvan a mechanizmus

A cikk megjelenése után egy külső szakértői elemzés a motor forráskódjában mutatott rá egy helyre, amit érdemes megnézni. Megnéztem, és a feltevés igazolható volt: a mechanizmus a kérésütemezőben van, nem a ritka figyelem blokk-választásában, ahogy a cikk fentebb valószínűsíti.

A hibrid modellnél a gyorsítótár két különböző dolgot tárol a közös előtagról: a figyelem kulcs-érték blokkjait, és a rekurrens réteg belső állapotát egy-egy blokkhatáron. A kettő nem együtt keletkezik. Az ütemező egy előtagot darabokban dolgoz fel, és az állapotot csak ott tudja elmenteni, ahol egy darab pontosan blokkhatáron ér véget. Spekulatív dekódolás mellett viszont az ütemező egy teljes blokkal visszaveszi az utolsó menthető pozíciót, mert a figyelem oldalán az utolsó blokkra még szükség van a tervezethez.

A következménye egy éles küszöb. A blokkméret nálunk 1600 token, tehát:

  • egy 3200 token alatti kérés egyetlen darabban fut le, és nem ment állapotot az 1600-as határon, hiába írja be oda a kulcs-érték blokkjait;
  • egy 3200 token feletti kérés viszont megáll az 1600-nál, és ment.

Ha tehát az A kérés a küszöb alatt van, a B pedig fölötte, akkor B maga állítja elő és menti az 1600-as állapotot, a kulcs-érték blokkokat viszont a saját ismétlésekor A korábbi példányából kapja vissza. Két különböző eredetű tenzor találkozik egy visszaállított előtagban.

Először visszafelé: a szabály megmagyarázza a „megdöntő" esetet is

A függvényt kiemeltem a ténylegesen futó kiszolgáló forrásából, és lefuttattam a korábbi mérések mind a nyolc kérés-párjára, GPU nélkül. A kérdés minden párnál ugyanaz volt: ment-e állapotot az A kérés a közös határon?

párA hosszaA ment állapotot?B hosszaB ment?szabály szerintkorábban mért
a bukó pár3 169nem3 227igeneltéreltért
megjósolt eset3 086nem3 267igeneltéreltért
azonos blokkszám3 307igen3 371igenstabilstabil
a másik összeállításon3 264igen3 346igenstabilstabil
a „megdöntő" pár4 689igen4 870igenstabilstabil
rövid pár2 155nem2 182nemstabilstabil
önmagában futó1 859nem1 859nemstabilstabil
hosszú pár24 384igen24 404igenstabilstabil

Nyolcból nyolc. Beleértve azt a párt, amelyik a cikk fenti jóslatát megdöntötte: ott azért maradt stabil, mert az A kérés (4 689 token) már jóval a küszöb felett van, tehát megírta az 1600-as állapotot, és a B ugyanazt a példányt olvassa vissza. A blokkszám-eltérés tehát csak tünete volt a valódi szabálynak.

Ez fontos tanulság a hipotézisekről: a régi szabályom nem „majdnem jó" volt, hanem rossz változót mért. Ugyanazon a hét adatponton mindkettő illeszkedett, a nyolcadik viszont csak az egyiket engedte tovább.

Aztán előre: kilenc token, és átbillen

Ha a küszöb valódi, akkor néhány token az A kérés végén át kell billentse az eredményt, miközben a közös dokumentum és a B kérés változatlan. Ezt mértem meg friss kiszolgálón, cellánként külön gyorsítótár-maggal, hogy ne szennyezzék egymást:

esetA hosszaB hosszavártmért
A a küszöb alatt, B fölötte3 1963 259eltéreltért
A a küszöb fölött, B fölötte3 2053 259stabilstabil
mindkettő a küszöb alatt3 0973 160stabilstabil
A fölötte, B alatta3 2053 160stabilstabil

Az első két sor a lényeg: a B kérés ugyanaz, a közös dokumentum ugyanaz, az A kérés hossza kilenc tokennel tér el, és ez dönti el, hogy a rendszer determinisztikus-e.

Egy szám, amit eddig nem mértem: a tényleges találat

A korábbi szondáim a válaszból dolgoztak, és nem kérdezték meg a kiszolgálótól, hogy ténylegesen hány tokent hozott a gyorsítótárból. Ezt a külső elemzés jogosan kifogásolta, úgyhogy bekapcsoltam a kiszolgálón a megfelelő adatközlést. A bukó esetben ezt kaptam:

futásgyorsítótárból hozott tokeneredmény
B #10ez a helyes érték
B #21 600innentől tér el
B #3, #41 600ugyanaz az eltérő érték

Ez egy korábbi állításomat is helyreteszi. A cikk fentebb „részleges találatot" ír a B első futására. Valójában nulla a találat: hiába volt bent az A által beírt, szó szerint azonos első 1600 token, a hibrid egyeztetés a hiányzó állapot miatt nem engedi felhasználni. Ezért egyezik a B első futása bitre a teljesen gyorsítótár-mentes karral: mert valóban hideg. A régi mérés jó volt, az értelmezésem nem.

A logitok valóban elmozdulnak

A másik jogos kifogás: a szonda a húsz legvalószínűbb jelöltet a kiszolgáló sorrendjében hasheli, tehát elvileg holtversenyes jelöltek puszta helycseréje is elegendő lenne eltérő összeghez. Ezért most a nyers listákat is elmentettem. Az első három generált tokennél, a bukó esetben:

tokena jelölthalmaza választott szólegnagyobb eltérés a közös jelölteken
#119 közös a 20-bólazonos0,745
#219 közös a 20-bólazonos0,664
#318 közös a 20-bólazonos1,062

A közös jelöltek mindegyike más értéket kap, a jelölthalmaz maga is cserélődik, és a token-azonosító szerint rendezett összeg is két változatot ad. Tehát valódi numerikus elmozdulás, nem a lista sorrendje. A választott szó ebben az esetben stabil maradt, ezért nem látszott a válaszszövegen.

A javítás: egy sor

A motorban van egy nyitott javítás, a #54076, ami többek között pontosan ezt a feltételt írja át: minden átlépett blokkhatár zárjon le egy darabot. A pull request saját indoklása is kimondja a mechanizmust, hogy a több blokkot átívelő darab a köztes állapot-rekeszeket véglegesen üresen hagyja. Ez a javítás a mi receptünk kétsoros foltjából hiányzik.

Ugyanabból az összeállításból, ugyanazokkal a kapcsolókkal indítottam egy második kiszolgálót, és ebben az egyetlen feltételt cseréltem ki. Az eredmény:

eseteredetia határmegállítással
A a küszöb alatt (3 196), B fölötte (3 259)eltérstabil
a másik három esetstabilstabil
az eredeti makacs feladat, tízszereltért10/10 azonos

Az utolsó sor a legfontosabb. Ez ugyanaz a feladatpár, ugyanazzal a szondával, amelyik két különböző összeállításon, három mérési körben bukott. A javított kiszolgálón tízből tíz futás bitre azonos.

Amit ezzel sem tudok

A gyökérok az ütemezőben van, de azt nem mértem meg, hogy a különböző darabolás melyik műveletnél állítja elő az első eltérő számot. Az is árulkodó, hogy a javított kiszolgálón a gyorsítótárból hozott tokenek száma ugyanúgy 0 és 1600 marad, tehát a blokkok eredete továbbra is vegyes, az eredmény mégis azonos. A legvalószínűbb olvasat: nem az eredet keveredése a baj önmagában, hanem hogy az eredeti esetben a közös blokkot egy 3 196 tokenes egyetlen darab írta be, míg az állapot egy 1600 tokenes darabból származott. Ugyanazokra a tokenekre más darabolási alak más számokat ad. Ez következtetés a viselkedésből, nem tenzorszintű bizonyíték.

És egy fontos korlát: ez nálunk kísérleti visszaport, egyetlen sor egy nyitott pull requestből, nem a teljes javítás. Az éles rendszerbe nem ezt tesszük be, hanem megvárjuk, amíg a javítás rendes úton bekerül a motorba. A minden határnál megálló feldolgozás árát (több ütemezési lépés) sem mértük még meg.

Mit csinálunk ezzel

Az éles rendszerünkben a gyorsítótár bekapcsolva marad: a mért hatás a logitokban jelentkezik, a védelem pedig már eddig is a helyén volt: a kinyerésnél háromszori futtatás és egyezés-ellenőrzés, a beszélgetésnél külön ellenőrző lépés. A kikapcsolás ára egy nagyságrend a válaszidőben a hosszú, ismétlődő előtagoknál, ami nálunk a jellemző forgalom.

A mérés viszont megy tovább. A szeptember 14-i kör már megmutatta, hogy melyik nyitott javítás számít nekünk, és hogy egyetlen feltétel átírása megszünteti a jelenséget. A következő lépés ugyanez a mérés egy olyan futtatókörnyezetben, ahol a #54076 a teljes formájában benne van, kiegészítve a feldolgozási sebesség és a minőségi pontszám újramérésével.

És mindezt vissza is adjuk a közösségnek. Mindhárom eredményt hozzászólásként beküldtük a vLLM projekt megfelelő pull requestjéhez, nyers adatokkal és a pontos indítási parancsokkal: a cross-request reprodukciót a #54076-hoz (a #53798 hivatkozásával és ezzel a cikkel), a GB10-es negatív validációt és a kompatibilitási korlátot a #56500-hoz, és a determinisztikus kernel +21–28 %-os prefill-nyereségét a #55122-höz. Egy lokális LLM-stack akkor lesz megbízható, ha a méréseket nem a saját blogunkon tartjuk, hanem oda visszük, ahol a kód készül.


A mérés GB10 / DGX Sparkon készült (driver 580.173.02, 121 GB egyesített memória), a RadixArk/Qwen3.8-Flash-Next-NVFP4 checkpointtal, két külön épített motoron (vLLM 0.1.dev20073+g8e685d198 és a hivatalos v0.29.0), MTP=2 spekulatív dekódolással, PIECEWISE cudagraph módban, hét friss szerverindításon. A szondák a teljes válasz tokenenkénti top-20 logprob-listájára képeznek hash-t; a látható szöveg egyezését sehol nem tekintjük bizonyítéknak. A szeptember 14-i frissítés két további friss szerverindításon készült, ugyanezzel a modellel és kapcsolókkal, a kiszolgáló gyorsítótár-találati adatközlésével együtt. A mechanizmus kernel-szintű feltárása (melyik művelet adja az első eltérő számot) továbbra sem történt meg, és a minőségi suite a javított karon nem futott újra.