PHP FFI pro kompaktní data a parsování
PHP ve verzi 7.4 přidalo rozšíření FFI – foreign function interface – pro volání nativních funkcí z PHP a pro práci s vrácenými daty. V PHP jsou
všechno dynamicky typované zval
y, na straně C jde o kompaktní reprezentace
structů a polí, které potřebují jen tolik paměti, kolik je nezbytně nutné.
Napadlo mě tedy, jestli by právě FFI nešlo použít pro efektivní reprezentaci
dat ze strany PHP, bez jakéhokoli volání C funkcí.
Pokud potřebuju milion 4B intů, v PHP to zabere buď 32 MB jako vestavěné PHP
pole nebo 16 MB jako \SplFixedArray
. Důvodem je, že každý int musí být
zabalený do zval
u - 8 bajtů pro hodnotu, 8 bajtů pro metadata. V případě PHP
pole je cena dvojnásobná – data jsou interně reprezentovány dvojicí
klíč-hodnota (struct Bucket
)5 i v situacích, kdy to není nezbytně
nutné, když klíče jsou numerické a tvoří nepřerušovanou sekvenci od nuly do
n.
Když ale alokuju přes FFI::new('uint32_t[1000000]')
, pole zabere jen tolik
paměti, kolik by potřebovala céčkovská obdoba: 4 MB + něco pro režii
velikostních tříd malloc
u + něco málo pro instanci FFI\CData
.
To se může vyplatit v případech dlouho běžících skriptů, které potřebují
uchovat netriviální množství dat v paměti.
Ozkoušel jsem několik variant kompaktních dat a FFI není ani příliš pomalé.
(PHP 8.0.8, CPU i5-4570, alokuje se int32_t[1024]
, časy jsou uvedeny v ns/iteraci, kompletní testovací program tady).
no JIT JIT nil 8.1 3.0 // $sink += $i; arr 12.7 5.1 // $sink += $arr[$i]; spl 30.2 23.1 // $sink += $splArr[$i]; ffi 18.5 10.3 // $sink += $ffiArr[$i]; pack 110.3 89.8 // $sink += unpack('V', $str, $i * 4)[1]; pack32 74.6 60.8 // unpack('V32') ord 92.5 39.6 // ord($str[$p]) | (ord($str[$p+1]) << 8) | ...
nil
představuje prázdnou smyčku, která jen sčítá index. Slouží k odhadnutí nezbytně
nutné režie. Když ji odečtu od ostatních řádků, dostanu jejich skutečnou
cenu. S JITem jedno čtení ze standardního pole trvá asi 2 ns. Člověk by čekal,
že půjde o nejrychlejší variantu vzhledem k tomu, že jde o vestavěné PHP pole a JIT
s ním explicitně počítá. Ale podívejte na to: FFI je druhý nejrychlejší způsob,
jak z paměti vyhrabat 4 bajty, lehce přes 7 nanosekund. Pořád je to celá
věčnost, v ideální situaci by ten čas měl být prakticky nulový, ale není to
úplně zlé, zvlášť, když vezmeme v potaz, že se bavíme o PHP.
Podobně kompaktní jsou data ve sloupcích pack
, pack32
a ord
používající funkce
unpack
nebo ord
3 , které čtou data binárně kódovaná v PHP stringu.
V minulosti jsem do PHP poslal pár patchů, které zrychlují unpack
, v určitých případech drasticky a ještě jich mám pár v rukávu.1 Ale asi se
přestanu snažit, když FFI plně dostačuje a pack
mu v určitých situacích z principu nikdy nemůže konkurovat.2
Další možné využití pro FFI je náhrada funkce unpack
pro parsování struktur z binárních dat.
$ffi = FFI::cdef("struct __attribute__ ((packed)) Item { uint32_t id; uint16_t num; uint8_t x; uint8_t len; char rest[20]; };"); $charArrType = FFI::arrayType(FFI::type("char"), [strlen($str)]); $chars = $ffi->new($charArrType); FFI::memcpy($chars, $str, strlen($str)); $items = $ffi->cast('struct Item*', $chars); $item = $items[47];
Funkce cdef
definuje příslušný struct, __attribute__ ((packed))
signalizuje, že mezi proměnnými nebudou žádné mezery a layout structu je
přesně definovaný. Pak se zkonstruuje typ pole $charArrType
, alokuje se pole
nativní paměti (new
), zkopíruje se do něj PHP string (memcpy
) a přetypuje
se na pointer structů (cast
). Do něj pak můžeme indexovat pro vyhrabání dat.
A nejlepší na celé věci je fakt, že to je rychlejší než unpack
.
(testovací program tady.)
no JIT JIT nil 63.0 38.1 pack 479.8 439.0 ffi 153.7 126.5
Bude záležet na konkrétním případu, některé konstrukce jsou rychlejší, jiné
pomalejší. Obecně vše, co vrací nový FFI\CData
není levné, musí se alokovat
64 B pro zend_ffi_cdata
objekt.
Nejzajímavější na FFI je, že s dostatečně inteligentním JITem4 by šlo
efektivně optimalizovat na pouhých pár instrukcí. FFI, stejně jako C,
negarantuje žádnou bezpečnost, FFI::new('int*')[0]
s největší
pravděpodobností segfaultuje, není třeba kontrolovat moc věcí a přímý překlad
je teoreticky možný.
V programu nahoře PHP proměnné $item
a $items
mají asociovaný typ (struct
_zend_ffi_type
), dílčí proměnné jsou v hash tabulce (ale pro
volání se používá inline cache), pokud by kompilátor vydedukoval, že se C
typ nezmění, přenesl by tyto informace z run-time do compile-time, eliminoval
volání do FFI run-timu a místo toho by mohl vyplivnou pár instrukcí.
$items = $ffi->cast('struct Item*', $chars); // noop, nic se neděje, jen se změní typ, po kompilaci eliminováno $item = $items[$i]; // offset = i * sizeof(struct Item) // item = items + offset $sink += $item->id // ptr = item + offsetof(struct Item, id) // sink += *ptr
Je to doslova hrstka instrukcí, ať už se použije jakákoli instrukční sada.
K tématu:
- Případy jako
V*
neboV1024
, můžou jet kolem 2 ns/int a jsou limitované jen tím, jak rychle můžu přidávatzval
y do PHP pole. - Největší problém spočívá v tom, že
unpack
vždy musí vrátit pole a zároveň je napsaný v Céčku. Pole znamená alokaci, ale protože alokace probíhá na straně C, což je z pohledu JITu neprůhledná binárka, nemůže detekovat, že objekt neuniká a skalarizovat ho. Z těchto důvodů pro malá data, jako je právě případ jednoho intu, bude vždy pomalejší.Na druhou stranu by bylo možné optimalizovat klíčový vzor pro extrakci jednoho skaláru
$res = unpack('V', $str)[1]
. Zevnitř funkce můžu zkoumat sekvenci opkódů a jejich argumentů, která předchází jejímu volání a následuje po něm. Za běhu tak můžu zjistit, že výsledné pole je použité jen jednou v[1]
indexaci, jejíž výsledek je přiřazen do proměnné a pole je pak okamžitě zničeno. V téhle situaci nemusím alokovat nové pole, ale použít jedno staticky připravené, které bude sloužit jen k přenosu skaláru. Protože vím, že pole neuniká a je hned zničeno, mám garanci, že tento přístup bude bezpečný.Má to jen dvě nevýhody: jednak se kontrola provádí za běhu při každém volání
unpack
a druhak to vytváří těsnou závislost na konkrétních detailech vnitřností enginu. - Před nějakou dobou jsem do PHP zkusmo přidal intrinsifikaci funkce
ord
, kdy JIT nahradí volání funkce krátkou sekvencí instrukcí a vedlo to ke čtyřnásobnému zrychlení pro kód, který ve smyčce nedělal nic jiného nežord
. Nebylo to dokonalé, PHP je chaos a JIT ještě větší, dělat pattern matching na sekvenci opkódů v C je otravné a ohyzdné a taky, aspoň myslím, pro plné využití síly intrinsifikace, by bylo potřeba upravit alokátor registrů. Ten teď počítá s tím, žeord
je nativní funkce a proto ji tak volá – argumenty nasází do pole zvalů a intrinsická sekvence musí výsledek opět zkopírovat do zvalu připraveného pro výsledek. Protože ale vím, že intrinsifikovaná funkce nebude volána jako běžná interní PHP funkce, některé tyhle kroky, které data přehazují mezi registry a pamětí, by se daly eliminovat. To ale znamená zásah do vnitřností JITu a na to nejsem dost odvážný. Jde o masivní změťstěží dokumentovanéhonedokumentovaného kódu napsaného v jazyce, který byl navržen sektou sadistů, aby maximalizoval utrpení svých uživatelů. - Připadá mi, že současný PHP JIT představuje slepou vývojovou větev a bez de facto přepsání ho nebude možné dále zrychlovat. Operuje na úrovni PHP opkódů s jejich někdy bizarní sémantikou a na binární kód je překládá jeden pro druhém. Vnitřní C rozhraní PHP enginu cílí na interpretr a nikoli na JIT, kde typy jsou často staticky známé. Hlubší optimalizace není dost dobře možná, protože je limitován opkódy navrženými a roky optimalizovanými pro rychlou interpretaci. PHP provede nějakou optimalizaci, to ano, SSA, odvodí typy na úrovni opkódů, ale ty pak přímo překládá do binárky bez mezistupňů se sémantikou na úrovni bližší železu. Peephole optimalizace? Není možná. Navíc je to celé napsané v céčku, absolutně neprůhledné pro nikoho, kdo tomu nevěnoval rok života, používá dynasm a musí mít samostatnou implementaci pro každou architekturu zvlášť, což jsou soubory o cca 15000 řádcích, které je třeba udržovat v synchronním stavu. V současnosti jsou PHP lidé v dobré pozici, ale tipuji, že časem tomu začne docházet dech a zásadní změny budou nutné.
- Od verze 8.2 bude PHP reprezentovat tzv. packed pole jako pole
zval
ů nikoli jako poleBucket
ů. Když se PHP pole chová jako skutečná pole v seriózních jazycích, spotřebuje méně paměti.