0xDEADBEEF

RSS odkazy
««« »»»

PHP refcount jako optimalizace

18. 5. 2023, aktualizováno: 9. 7. 2024 #PHP #optimalizace

Refcount mechanismus v PHP se dá použít k optimalizacím.

On se tak už roky používá. Když kód potřebuje změnit jeden ze dvou by-value typů – string nebo pole – nejdřív zkontroluje počet referencí a když je hodnota refcountu rovna jedné, znamená to, že jen aktuální kód drží referenci a změnu nikdo jiný neuvidí. V tom případě může data stringů a polí měnit přímo in-place bez alokace kopie.

Dá se ale využít i přímo.

Třeba taková funkce unpack pro parsování binárních dat musí vrátit pole (z jakéhosi důvodu indexované od jedničky) i když parsuje jen jeden int. V důsledku toho jednoduché unpack('v', $str)[1] trvá 75 ns, což je ostudné. Ve své podstatě jde o tři instrukce: cmp + jb pro kontrolu jestli je ve stringu dost bajtů a jeden mov. To by nemělo trvat 75 ns ale jeden takt.

Nicméně můžu trochu optimalizovat případ, kdy parsuju jeden integer. Když využiju fakt, že pole je by-value typ, můžu to implementovat takhle (pseudokódem, šlo by o nativní funkci):

function unpack($str) {
  static $arr1 = new array [1 => null];

  if (refcount($arr) > 1) {
    $arr1 = new array [1 => null];
  }

  $arr1[1] = chomp2Bytes($str);
  return $arr1;
}

Změněná funkce unpack by interně vždy držela jednu referenci na pole, které opakovaně používá pro výsledek.

Můžou nastat dvě situace:

  1. Volající kód vrácené pole změní. To nám nevadí, protože pole je by-value a program takové objekty může změnit in-place pouze když mají refcount = 1 (viz makro SEPARATE_ARRAY). A protože unpack vždy drží jednu referenci a volající kód druhou, musí dojít ke kopírování pole a změněna bude až kopie. Verze kterou drží unpack zůstane zachována.
  2. Volající kód si vrácené pole uloží do proměnné. To v příštím volání unpack detekujeme přímo, protože najednou refcount je vyšší než 1 a jednoduše alokujeme nové pole. Tohle se stává ale jen velice zřídka, aspoň pro jednoduché případy, kdy extrahuju jen jeden int a nepoužívám syntaxi pro pojmenované výsledky à la unpack('Vid\Vtimestamp\vflags').

S touhle optimalizací unpack('v', $str)[1] trvá 50 ns (časy měřeny v interpreteru, JIT vypnutý). O třetinu lepší, ale stále ostudně pomalé.

Hlavní problém s funkcí unpack je její složitá sémantika, kdy musí parsovat vzorový string (v tomhle případě jen 'v', ale unpack musí být připravený na všechny případy a počítat s komplikovanými vzory jako třeba unpack('Vid/Vdate/Ccount/V*children', $str)) a pak v gigantickém switchi skákat na těch pár instrukcí, které interpretují daný počet bajtů jako int nebo float dané délky, dané znaménkovosti a dané endianovosti. Jen tato logika samotná zabere dobrých 25 ns.

To byl nakonec důvod, proč tohle píšu jako článek a ne jako patch pro PHP. I s vypětím všech sil se stejně nemůžu dostat do čísel, za které bych se nemusel stydět.


Pro představu, jak si funkce unpack stojí v porovnání s nativní alternativou (testovcí kód zde, PHP JIT vypnutý, CPU i5-4570):

*                     ns/iter
empty loop               7.87
noop()                  15.20
unpackInt32LE()         16.26
unpack('V', $str)[1]    72.18

První řádek představuje prázdnou smyčku pro odhadnutí nezbytné režie interpreteru.

noop() volá nativní funkci, co nic nedělá. To testuje režii smyčky + řežii volání nativních funkcí.

unpackInt32LE je nativní funkce, která vezme 4 bajty ze stringu a interpretuje je jako little endian integer.

Jde o následující funkci (v jazyce D), která je exportovaná do PHP přes moji (zatím privátní) knihovnu pro snadnou tvorbu PHP rozšíření.

uint unpackInt32LE(scope const(ubyte)[] str) {
  pragma(inline, true);
  if (str.length < 4) throw new Exception("not enough bytes");
  return *(cast(uint*) str.ptr);
}

Poslední řádek unpack('V', $str)[1] zahrnuje čas iterace, volání nativní funkce a vnitřní machinace unpacku.

Samotná extrakce intu (+ parsování argumentů) zabere v tomto případ jednu nanosekundu, režie smyčky + volání nativní funkce je 15× větší a unpack samotný je ještě 5× pomalejší než tohle. Nemá smysl se snažit o optimalizace neefektivně navržené funkce, když nativní varianta je o tolik rychlejší a už jen použití FFI, bez problémů překoná unpack.

píše k47 (@kaja47, k47)