PHP refcount jako 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:
- 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žeunpackvž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žíunpackzůstane zachována. - Volající kód si vrácené pole uloží do proměnné. To v příštím volání
unpackdetekujeme 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 à launpack('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.