A delete obj.prop ötször lassabbá tette a kódomat: a V8 hidden class-ok valódi ára

Lefuttattam a net kedvenc V8-rémtörténetét: a property-sorrend szinte semmit nem számított. Ami tényleg ötszörösére lassította a kódot, egy ártalmatlannak tűnő delete volt – és a V8 forráskódjában egy konkrét szám is előkerült közben.

Valahol egy JS performance cikkben biztosan olvastad már, hogy két, ugyanazokat a mezőket tartalmazó objektum property-elérése akár ötven-hatvanszor lassabb is lehet egymáshoz képest, csak azért, mert máshogy épültek fel. Ez az állítás annyira gyakran felbukkan V8-as blogposztokban, hogy évek óta készpénznek vettem – anélkül, hogy egyszer is lefuttattam volna. Most lefuttattam, Node 22-n, és nem az jött ki, amire számítottam. Ami tényleg ötszörösére lassította a kódot, egy sokkal ártalmatlanabbnak tűnő dolog volt: egy delete.

Diagram: felül négy hatszög egy láncban, egyre több ponttal, nyilakkal összekötve – ez a hidden class transition chain. Alul két doboz: a zöldben egy egyenes nyíl közvetlenül a property értékéhez, a narancssárgában egy összekuszált, szétszórt vonalháló jelzi a lassú dictionary-lookupot.

A hidden class, amiről a legtöbb JS-fejlesztő nem tud

A JavaScript objektum a specifikáció szintjén egy dinamikus property-táska, de a V8 nem így kezeli. Minden objektumhoz tartozik egy belső struktúra, amit V8-nyelven Map-nek hívnak (nem tévesztendő össze a Map típussal) – ez tárolja, milyen property-k vannak az objektumon és melyik milyen offseten. A V8 hivatalos dokumentációja szerint ha két objektum ugyanazt a Mapet használja, a motor egyetlen pointer-összehasonlítással eldönti, hogy „egyforma alakúak”, és ugyanazt a gyors útvonalat használhatja mindkettőhöz.

A csavar ott van, hogy a Map nem a végeredménytől, hanem az odáig vezető úttól függ. Ha egy objektumra sorban rakod fel a property-ket, minden hozzáadás egy új Mapre lép át egy tranzakciós láncon (TransitionArray) keresztül – ugyanaz a sorrend ugyanahhoz a Maphez vezet, egy másik sorrend egy másikhoz, még akkor is, ha a végén pontosan ugyanazok a kulcsok és értékek vannak az objektumon. Ezt nem kell elhinned, ki lehet próbálni:

const a = {};
a.x = 1;
a.y = 2;

const b = {};
b.y = 2;
b.x = 1; // fordított sorrend, ugyanaz a végeredmény

// node --allow-natives-syntax script.js
console.log(%HaveSameMap(a, b)); // false

A %HaveSameMap egy V8-natívfüggvény, amit csak a --allow-natives-syntax V8-flaggel (Node-ban erre külön kapcsoló van, node --v8-options listázza) engedélyezett kódban lehet meghívni – production kódba soha nem kerülhet, de kiváló kísérletezésre. Ha ezt egyszer kipróbálod, onnantól máshogy nézel a { x: 1, y: 2 } literál és a mezőnkénti felépítés közötti különbségre: a kettő – érdekes módon – megint más Mapet kap, még akkor is, ha a sorrend azonos, mert a literálhoz a V8 saját, allokációs helyhez kötött sablont (boilerplate) használ. Ez utóbbi apróság a gyakorlatban ritkán fáj, de jól mutatja, hogy a „hidden class” nem egy elvont fogalom, hanem egy nagyon konkrét, lekérdezhető belső állapot.

Monomorf, polimorf, megamorf – és egy konkrét szám a V8 forráskódjában

A hidden class önmagában nem gyorsítana semmit, ha nem lenne mellette az inline cache (IC): amikor a motor lefuttat egy obj.x-hez hasonló property-elérést, feljegyzi a hívási ponton, milyen Mapet látott, és melyik offseten találta az értéket. Legközelebb, ha ugyanazt a Mapet látja, nem kell újra keresnie – egyszerű memóriaolvasás lesz belőle. Ezt a mechanizmust részletesen leírja Mathias Bynens (V8-csapat) shapes-and-ICs összefoglalója: ha egy hívási pont mindig ugyanazt a Mapet látja, monomorf; ha néhány különbözőt, de a cache még bírja, polimorf; ha túl sok félét lát, feladja, és megamorf lesz – ilyenkor a gyors offset-alapú olvasás helyett egy generikusabb, lassabb útvonalra esik vissza.

Na ezt nem tudtam: hogy hány „félét” bír még el a cache, mielőtt feladja, nem egy homályos ökölszabály, hanem konkrét szám a V8 forráskódjában. A src/flags/flag-definitions.h-ban van egy bejegyzés, ami szó szerint ezt mondja:

#define DEFAULT_MAX_POLYMORPHIC_MAP_COUNT 4
DEFINE_INT(max_valid_polymorphic_map_count, DEFAULT_MAX_POLYMORPHIC_MAP_COUNT,
           "maximum number of valid maps to track in POLYMORPHIC state")

Négy különböző Map fér el egy polimorf cache-ben, alapértelmezésben – ez egy futásidejű V8-flag, tehát elméletileg állítható is, csak semmi normális oknál nincs rá szükség. Ha nálad ezt szeretnéd a saját kódodban vizuálisan is látni – melyik hívási pont ragadt meg megamorfban, melyik dedeoptimizálódott –, a deoptigate nevű eszköz pontosan ezt csinálja: lefuttatja a scriptedet V8 tracing-flagekkel, és a forráskódra visszamappelve mutatja meg az IC-állapotokat.

Lefuttattam a rémtörténetet – és nem jött ki

Ezzel a tudással nekiálltam megmérni azt, amit a bevezetőben ígértem: mekkora árat fizetsz, ha egyetlen sum() hívási pontot hat különböző alakú objektummal etetsz felváltva, hogy garantáltan megamorf legyen.

function sum(obj) {
  return obj.x + obj.y;
}

function makeMega(n) {
  const arr = new Array(n);
  for (let i = 0; i < n; i++) {
    let o;
    switch (i % 6) {
      case 0: o = {}; o.x = i; o.y = i + 1; break;
      case 1: o = {}; o.y = i + 1; o.x = i; break;         // fordított sorrend
      case 2: o = {}; o.x = i; o.y = i + 1; o.z = 0; break; // extra mező a végén
      case 3: o = {}; o.a = 0; o.x = i; o.y = i + 1; break; // extra mező elöl
      case 4: o = {}; o.x = i; o.y = i + 1; o.b = 0; o.c = 0; break;
      case 5: o = {}; o.x = i; o.y = i + 1; o.d = "extra"; break;
    }
    arr[i] = o;
  }
  return arr;
}

3 millió elemű tömbön, bemelegítés után mérve (Node v22.22.3, V8 12.4.254.21-node.56, saját gépen, tehát ez illusztráció és nem laborbenchmark), a monomorf tömb kiolvasása kb. 28-30 ms, a hat alakot vegyesen tartalmazó tömbé 27-30 ms volt – futásonként hol az egyik, hol a másik volt gyorsabb. Vagyis a mérési zaj nagyobb volt, mint a „megamorf büntetés”. Ez erősen ellentmond azoknak a cikkeknek, amik egy nagyságrendekkel nagyobb szám mellett teszik le a névjegyüket.

A gyanúm – amit fontos hangsúlyozni, hogy csak gyanú, nem mért tény –, hogy ez a mai V8 többrétegű JIT-pipeline-jának köszönhető. A Sparkplug nem optimalizáló, de nagyon gyorsan lefordítja a kódot bytecode-ból natív kódba, a Maglev pedig egy köztes szintű optimalizáló compiler, ami olcsóbban hoz „elég jó” kódot, mint a végső TurboFan tier. Lehet, hogy a 2015-ös cikkek nagy számai olyan V8-verzión mértek, ami ma már nem létezik ilyen formában. Ez nem jelenti azt, hogy a hidden class-oknak és az IC-knek ne lenne hatásuk – csak azt, hogy egy egyszerű, két számmezős objektumra a hatás a gyakorlatban kisebb, mint a városi legenda állítja.

Amitől viszont tényleg ötszörösére lassult: a delete

A V8 fast-properties cikke egy mondatban elárulja, hol van a valódi kockázat: „inline caches don’t work with dictionary properties” – az inline cache-ek egyszerűen nem működnek, ha az objektum dictionary módba kerül. Ez egy régi, C++ szótárszerű property-tárolás, ahol nincs Map, nincs fix offset, minden property-elérés egy valódi kikeresés. És ebbe a módba a leggyakoribb belépő pont a delete.

const fast = { x: 1, y: 2, tmp: 0 };
delete fast.tmp;

// node --allow-natives-syntax script.js
console.log(%HasFastProperties(fast)); // false

fast.a = 1; fast.b = 2; fast.c = 3;
console.log(%HasFastProperties(fast)); // még ekkor is false

Ugyanazzal a mérési módszerrel, mint fent, egy dictionary-módba kényszerített (egyetlen fölösleges mező felvétele, majd rögtön törlése) tömb kiolvasása 3-5,6-szor lassabb volt, mint az ugyanolyan mezőkkel, de delete nélkül épített, „fast properties” tömbé – konzisztensen, minden futásnál, nem egyetlen kiugró mérésnél. És – ahogy a fenti snippet is mutatja – ez az állapot tartós: hiába veszel fel utána újra öt mezőt, az objektum nem áll vissza fast módba.

Ez nem elméleti probléma. Bármelyik kódban előfordulhat, ahol egy objektumból „letörölsz” egy mezőt state-kezelés, cache-invalidálás vagy egy régi mező kitakarítása miatt – ORM-ek, state management könyvtárak, saját cache-implementációk tipikus helyei.

Mikor számít ez, és mikor nem

A property-sorrend miatti paranoia szerintem a legtöbb webes kódban felesleges. Ha a konstruktorod néha más sorrendben állítja be a mezőket, vagy egyszer literállal, egyszer mezőnkénti hozzárendeléssel épülnek fel az objektumaid, a modern V8 ezt egészen jól kezeli – legalábbis ekkora, két-három mezős objektumokon, amilyet a legtöbb backend és frontend kód épít. Nem kell külön absztrakciót vagy konvenciót bevezetni csak azért, hogy garantáltan egyforma sorrendben rakd össze a mezőket.

Amit viszont tényleg megfontolnék: delete helyett obj.prop = undefined, ha egy objektum hosszú életű és sok helyen, sokszor olvasod – például egy hot path-en lévő request-context, egy session-objektum vagy egy nagy listát reprezentáló view-model. Ha a property-halmaz eredendően dinamikus (nem tudod előre, milyen kulcsok lesznek rajta, és gyakran adsz hozzá/törölsz), ne is próbálj plain objektumot fast módban tartani: használj Map-et, aminek pont ez a use case a natív erőssége, delete nélküli büntetés nélkül.

Production kódba raknám ezt a tudást, de csak mint kódolási szokást, nem mint performance-optimalizálási projektet: a delete kerülése hot objektumokon egy egysoros, kockázat nélküli döntés, amit egyszer megtanulsz és attól kezdve automatikusan csinálsz. A hidden class transition chain mélyebb megértése viszont inkább arra jó, hogy ne higgy el vakon egy tíz éve körbejáró benchmark-számot – és hogy ha legközelebb valaki azzal jön, hogy „az objektum-sorrend miatt lassú a kódod”, legyen kéznél egy --allow-natives-syntax-os script, amivel tíz másodperc alatt megnézed, igaz-e.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük