funkcionálně.cz

Přední český blog o funkcionálním programování, kde se o funkcionálním programování nepíše
««« »»»

Kolik paměti zabírají PHP pole a objekty?

20. 6. 2013, aktualizováno: 29. 9. 2021

(aktualizováno pro PHP 7 a 8, zastaralé pasáže týkající se PHP 5 jsou přeškrtnuté)

V PHP se zdá, že s pamětí je jenom málokdy problém. Pokud nepřekročím limit, který skript může alokovat, je všechno v pořádku. Žádné pauzy garbage collectoru (GC), žádné out of memory error, které zabijí celou aplikaci, žádné memory leaky a žádný tlak na GC, který v marné snaze uvolnit ubývající paměť, spálí všechny cykly CPU .

To všechno jsou důsledky faktu, že PHP je navrženo pro jednoduchý request/response cyklus, kdy skript alokuje paměť a po skončení skriptu se celá najednou uvolní. V mnoha případech PHP ani GC nepotřebuje.

Tento model ovlivnil naprosto všechno v návrhu PHP (když mluvím o PHP mám na mysli výchozí implementaci od Zendu): parser musí být rychlý, protože se skripty parsují pořád dokola, skript se nepřekládá do nativního kódu (JIT), ale vždy se interpretuje, GC je velice jednoduchý refcount s přidanou detekcí cyklů a neprovádí kompakci.

To všechno funguje výborně, když PHP používám tak jak to bůh zamýšlel, ale velice rychle začnu narážet na různé problémy u dlouho běžících skriptů a frameworků jako například React.PHP: skript může běžet celé týdny nebo měsíce a v takovém případě se vyplatí přeložit horké smyčky do nativního kódu a bez GC kompakce, určitý program může fragmentovat paměť, která nikdy nebude uvolněna a nebude ji možné efektivně využít (jako se mi povedlo když jsem testoval tuto zrůdnost)

Ale hlavně člověk začne pozorovat, jak strašlivé množství paměti PHP spotřebuje a že prakticky není možné se z této paměťové smyčky vyvléknout a vytvořit efektivnější datové struktury (ono je to technicky možné, ale za jakou cenu?). O Javě se říká, že je paměťově nenažraná, ale přesto nesahá ani po kolena PHP ve velkolepé neefektivitě.

To je způsobeno dvěma aspekty jazyka/runtime:

  1. všechny hodnoty jsou boxované ve strukturách nazvaných zval (+ každé čtení hodnoty znamená skok na pointer, což s sebou může nést hodně cache miss). (Od PHP 7 došlo ke změně reprezentace a zvaly přímo uchovávají inty, floaty, booly a null)
  2. pole a dynamické objekty jsou implementované jako řazené hashmapy (Myslím, že to byla PHP verze 7, která zavedla takzvanou packed reprezentaci, která v případě, že klíče jsou jen číselné a jdou od nuly bez přestávky jen vzhůru, pole nepředstírá, že je hashmapa.)

O zvalech a polích si můžete přečíst ve výborném článku How big are PHP arrays (and values) really?. (Zastaralé.) Z něho vyplývá, že pole jsou ztracený případ. Je to z části způsobeno tím, že toho umí příliš moc - pole jsou ve skutečnosti hashmapy zvalů, které si uchovávají pořadí na předchozí a následující prvek. Jenom málokdy je potřeba přesně takové chování a to je navíc často matoucí, protože není zřejmé, jak přesně se má v daných případech tato složitá datová struktura používat. Většinou je třeba buď pole, nebo množina nebo mapa, ale jenom málokdy všechno najednou. (V PHP 7 došlo ke změně vnitřní reprezentace polí a ve výsledku jsou menší a rychlejší.)

Udělal jsem proto několik testů, které zjišťují jak efektivně nakládají s pamětí různé datové struktury v PHP (pole, dynamický objekt, třída, SplFixedArray) za různých okolností a v různých verzích PHP (5.3.10, 5.4.9, 5.5-RC2 a 7.4.21). Každý test vždy vytvořil datovou strukturu, která obsahuje 100000 elementů. Výsledky jsou zajímavé a ukazují kdy se vyhnout jakým strukturám, když chci do paměti narvat co nejvíc dat. Je z nich také patrný mírný pokrok mezi PHP 5.3 a 5.4.

Testovací skript můžete najít tady.


Výsledky:

PHP 5.3.10PHP 5.4.9PHP 5.5-RC2PHP 7.4.21
totalB/elemtotalB/elemtotalB/elemtotalB/elem
(MB)(B)(MB)(B)(MB)(B)(MB)(B)
array_fill nulls14.1141.610.4104.910.4104.96.262.9
array_fill constant14.1141.610.4104.910.4104.96.262.9
array_fill value14.1141.610.4104.910.4104.96.262.9
range22.0220.215.2152.015.2152.06.262.9
SplFixedArray empty1.818.41.515.71.515.74.141.9
SplFixedArray nulls9.999.66.262.96.262.94.141.9
SplFixedArray const9.999.66.262.96.262.94.141.9
private fields101.91019.732.7327.732.7327.716.7167.8
public fields default99.6996.232.7327.732.7327.716.7167.8
public fields set123.41234.747.1471.947.1471.916.7167.8
public fields set one107.41074.837.4374.937.4374.916.7167.8
public fields add one120.31203.289.3893.989.3893.954.5545.3
arrays constant keys107.71077.472.8728.872.8728.86.262.9
arrays variable keys107.71077.475.2752.475.2752.444.0440.4

Jak je vidět, PHP 5.4 a 5.5 jsou na tom identicky, ale oproti 5.3 vykazují značné zlepšení. PHP 7 je ale docela jiná liga.

Z prvních třech řádků je vidět, že pokud vytvořím pole přes array_fill s hodnotou null, konstantou nebo $proměnnou, zabírají stejné množství paměti. Když vytvořím stejné pole přes range, zabere přibližně o 50% více místa. Jde o to, že se hodnota nevytváří pořád dokola, ale sdílí se ten samý zval (refcount). Když se pak nějaká takto zalinkovaná hodnota změní, vytvoří se samostatná kopie.

Zajímavé chování ukazuje SplFixedArray, které není interně reprezentováno jako hash tabulka, ale jako ploché pole pointerů na zvaly (jde o dynamické pole). Když SplFixedArray vytvořím aniž bych do něj něco přiřadil, zabírá velice málo místa. Dá se předpokládat, že je plné null pointerů. Ale když do něj cokoli přiřadím, obsahuje pointery na zvaly, včetně případu kdy přiřazuji null. Jak je vidět SplFixedArray oproti poli v tomto konkrétním případě potřebuje jenom cca 42% paměti. PHP pole ukládá data jako struct Bucket, což je klíč + hash + zval, dohromady 32 bajtů, naproti tomu SplFixedArray ukládá přímo pole zvalů, které mají poloviční velikost.

Další testy prověřují objekty a třídy. Instance tříd s privátními i veřejnými atributy zabírají stejné množství paměti. Ale když tyto atributy přepíšu, spotřeba paměti se zvětší a vypadá to, že roste úměrně s počtem přepsaných atributů. Za pozornost stojí, že objekt se třemi intovými atributy spotřebuje přes 300 bajtů paměti včetně nákladů pole ve kterém jsou uloženy (v Javě by to bylo 48 bajtů).

Následují dynamické struktury: pole a objekty bez deklarovaných atributů (public fields add one a arrays variable keys). Jak je vidět, pole potřebují více paměti než instance tříd, přibližně dvakrát. Plně dynamické objekty nebo instance tříd, kterým jsem přiřadil dynamický atribut pak potřebují ještě o něco víc. Důvod je popsán tady. Jde o to, že jména deklarovaných atributů jsou uloženy v definici třídy, ale jména všech dynamických atributů/klíčů musejí být uloženy v každé instanci objektu nebo pole jako klíče hash tabulky (a pochopitelně delší jména zaberou víc paměti). A jak vidět, tak stačí, když do instance třídy zapíšeme jeden dynamický atribut a struktura objektu se automaticky deoptimalizuje na hash-tabulku. Připadá mi, že to jen přidá hashmapu pro extra atributy a ty definované nechá v efektivní podobě. Instance hashmapy má minimálně 8 Bucketů + 16 pozic pro hashe a to dohromady dává 320 B.

Co z toho tedy vyplývá:

  1. Pokud potřebujete lineární strukturu, používejte SplFixedArray i když bude z větší částí plná null hodnot. V budoucnu možná přibude velice podobná třída Vector.
  2. Objekty, které deklarují vlastní proměnné zabírají nejméně místa, pak pole a nakonec plně dynamické objekty
  3. Nepřiřazujte dynamické atributy do instancí tříd, to víc jak zdvojnásobí jejich spotřebu paměti. Navíc existuje RFC, které dynamické properties plánuje zastarat a pak úplně odstranit.
@kaja47, kaja47@k47.cz, deadbeef.k47.cz, starší články