0xDEADBEEF

RSS odkazy
««« »»»

PHP FFI pro kompaktní data a parsování

11. 12. 2022 #PHP #benchmark

PHP ve verzi 7.4 přidalo rozšíření FFIforeign 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é zvaly, 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 zvalu - 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 mallocu + 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ž vezmu v potaz, že se bavíme o PHP.

Podobně kompaktní jsou data ve sloupcích pack, pack32ord používající funkce unpack nebo ord3 , 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 bajtů 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$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 PHP run-time do JIT 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:


  1. Případy jako V* nebo V1024, můžou jet kolem 2 ns/int a jsou limitované jen tím, jak rychle můžu přidávat zvaly do PHP pole.
  2. 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ý. (Nebo je možné se naplno opřít do mechanismu refcountování a udělat tohle.)

    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.

  3. 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, že ord 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ého nedokumentovaného kódu napsaného v jazyce, který byl navržen sektou sadistů, aby maximalizoval utrpení svých uživatelů.
  4. 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é.
  5. Od verze 8.2 bude PHP reprezentovat tzv. packed pole jako pole zvalů nikoli jako pole Bucketů. Když se PHP pole chová jako skutečná pole v seriózních jazycích, spotřebuje méně paměti.
píše k47 (@kaja47, k47)