0xDEADBEEF

RSS odkazy english edition
««« »»»

GC a granularita alokací v jazyce D

20. 12. 2020

Paměť je levná a hojná, ale přesto je dobré jí zbytečně neplýtvat, zvlášť na velice malých strojích. Představte si, že chcete provozovat nějaký podstatný program na Raspberry Pi s gigabajtem paměti. Proč zrovna Pi? Roční provoz stojí asi 80 korun v elektřině. Třeba proto.

A tady se dostávám zas k jazyku D. Ten jako správný systémový jazyk nabízí kompletní kontrolu nad každým bajtem paměti a každým cyklem procesoru. Zároveň v sobě ale má i GC, můžete ho použít pro pohodlnější život, ale nemusíte. Je to na programátorovi, co chce a co požaduje. Jaká ale je cena toho GC? Zajímá mě hlavně, jak se projeví na využití paměti.

Články o GC v jazyce D obvykle stráví značné množství času vysvětlováním, jak se mu vyhnout. GC v D je stop-the-word typu, vede k (nechtěným) pauzám programu a tak to nejspíš zůstane i nadále. Walter Bright řekl, že nechtějí investovat do lepšího GC bez pauz, protože to by znamenalo použití bariér a ty něco stojí i v případě, kdy GC neběží, což je neakceptovatelné pro systémový jazyk.

Cena GC má dva aspekty: jednak čas pauzy a dopad na rychlost běhu, to mě v tomto případě nezajímá a druhak vede k vyšší spotřebě paměti. GC přestřelí a alokuje si víc RAM, než striktně vzato potřebuje. Alokace nejsou dealokovány okamžitě, ale sedí na haldě do nejbližšího GC cyklu. Volné místo amortizuje práci GC cyklu.

Když v situaci, kdy mám 1 GB živých dat a 1 GB volného místa, dojde k alokaci 1 GB dat, která nepřežijí, alokace toho 1 GB vyvolá práci úměrnou 1 GB živých dat. (Doba GC typicky závisí na velikosti živých dat). Naproti tomu v opačném extrému, kdy bych měl jen jeden bajt volného místa a 1 GB živých dat, 1 B alokace by vedl k práci úměrné 1 GB, tedy amplifikace 1000000000x. GC potřebuje určité místo extra, aby fungoval efektivně.

D poskytuje přístup k GC. Je možné programově ovlivňovat jeho chování.

import core.memory.GC;

GC.malloc();    // alokuje data na haldě spravovanou GC
GC.free();      // okamžitě dealokuje data
GC.collect();   // vynutí GC cyklus
GC.minimize();  // vrátí nevyužitou paměť operačnímu systému

Hodit se může kombinace collect() + minimize(), když se program dostal do stavu, kdy dokončil velké alokace a bude trávit čas prací s tím, co má. collect() vynutí GC cyklus a minimize() signalizuje, že nevyužitá paměť se může vrátit operačnímu systému.

Další věc ovlivňující, kolik paměti program spolyká, je granularita alokací. Posledně jsem o tom začal psát. D má v současné podobně heap segregovaný podle velikosti objektů. Nejmenší má 16B, pak to jde po mocninách dvou až do 4 kB, větší objekty jsou zarovnané na 4 kB. Alokace probíhají v nejmenší oblasti, která je rovná nebo větší požadované velikosti. Pochopitelně.

Můžeme si to snadno ověřit následujícím programem. capacity udává maximální velikost na jakou může pole vyrůst, než ho je třeba přealokovat. Vždy má velikost o něco málo nižší než mocnina dvou (několik bajtů spolykají nutné metainformace).

foreach (size; 0 .. 4096) {
  auto arr = new byte[size];
  writeln(arr.length, " ", arr.capacity);
}

Toto uspořádání má za následek, že mnoho malých alokací může v nejhorším případě spotřebovat 2x víc paměti než je nutné, typicky 1.5x tolik.

Mám jeden program, který alokuje 3.5 milionů polí. Velikost jejich užitečných dat (myArrays.map!(a => a.length).sum) je 168.5 MB, ale celková kapacita myArrays.map!(a => a.capacity).sum odpovídající skutečně zabranému množství paměti, je 201.2 MB. 20% extra.

Je možné ušetřit místo alokací jednoho gigantického pole, které pak slice operace rozsekají na požadované kousky. Velké alokace mají granularitu 4 kB a vyplýtvané místo tak bude zanedbatelné.

Podle zdrojáku to vypadá, že GC má navíc režii až 5% pro bitová pole ve structu Pool, ve němž si GC uchovává informace, které bloky obsahují pointery, které je třeba finalizovat, které už označil, které jsou volné a tak podobně.


Nakonec ještě jeden mini-poznatek:

Funkce array pro konverzi Range na pole vrátí výsledek, ke kterému není možné nic připojit bez další alokace i když se logicky musí nacházet v části heapu, by místo měl.

auto arr = [1, 2, 3, 4].map!(to!long).array;
writeln(arr.capacity); // 0 - append vždy alokuje

Naproti tomu, když sami alokujeme pole potřebné délky a výsledek do něj zkopírujeme, pole ví kolik má extra místa a append operátor ~ nemusí okamžitě alokovat.

auto arr = new long[4];
[1, 2, 3, 4].map!(to!long).copy(arr);
writeln(arr.capacity); // 7 - append nemusí alokovat

(Navíc ~ operátor není nejrychlejší na světě a v určitých situacích představuje úzké hrdlo.)

píše k47 (@kaja47, k47)