Přeskočit na obsah

Správa RAM a předcházení fragmentaci heapu na CircuitPythonu

Toto je obecná poznámka o paměti v CircuitPythonu / MicroPythonu (není specifická pro hry) — platí pro jakýkoli dlouho běžící program, který pracuje s velkými buffery: grafické plochy, síťové odpovědi, souborové/audio streamy, parsování dat atd. Technika na konci (předalokovaná arena) je široce použitelná.

Past: gc.mem_free() lže (není to největší volný blok)

Sekce “Past: gc.mem_free() lže (není to největší volný blok)”

MicroPython/CircuitPython používají nepřesouvající mark-and-sweep GC: uvolňuje nedosažitelné objekty, ale nikdy nepřesouvá živé (objekty jsou odkazovány surovými ukazateli a C stack se prohledává konzervativně — takže je bezpečně přemístit nelze). Sousední volné bloky se při gc.collect() sloučí, ale volné místo rozdělené živými objekty zůstane rozdělené.

Důsledek: poté, co program alokoval a uvolnil mnoho různě velkých bufferů, heap fragmentuje. Můžete mít spoustu celkové volné RAM, ale žádný jediný souvislý blok dostatečně velký pro další velkou alokaci:

gc.mem_free() -> 90000 # 90 KB volných...
bytearray(51200) # ...ale tohle vyhodí MemoryError (žádný souvislý úsek 51 KB)

gc.mem_free() je celkové volné místo; co velká alokace potřebuje, je největší souvislý volný blok, který může být mnohem menší a který se s fragmentací session zmenšuje.

Měření největšího souvislého bloku

Sekce “Měření největšího souvislého bloku”

Žádná vestavěná funkce na to není; najděte ho binárním vyhledáváním (po gc.collect()):

import gc
def largest_block():
gc.collect()
lo, hi = 0, gc.mem_free()
while hi - lo > 256:
m = (lo + hi) // 2
try:
b = bytearray(m); del b; lo = m
except MemoryError:
hi = m
gc.collect()
return lo

import micropython; micropython.mem_info(1) vypíše kompletní mapu heapu (co je živé a kde), pokud potřebujete vidět, proč je fragmentovaný.

Jakýkoli vzor, který opakovaně alokuje a uvolňuje velký buffer během jednoho běhu:

  • Networking / web: čtení HTTP odpovědi, JSON/MQTT payloadu, TLS recordu, stažení obrázku — každý požadavek nabírá (a uvolňuje) čerstvý kilobajtový buffer.
  • Zpracování souborů / streamů: čtení souboru po částech, dekomprese, parsování.
  • Audio: sample buffery pro jednotlivé klipy.
  • Grafika: celoobrazovkové/velké kreslicí plochy (např. displayio/picogame Canvas) vytvářené na obrazovku/úroveň.

Jeden velký buffer alokovaný jednou při bootu a držený navždy je v pořádku (dostane souvislý blok, dokud je heap čerstvý). Problém je v churnu.

Řešení: předalokovaná arena

Sekce “Řešení: předalokovaná arena”

Naberte jeden velký buffer jednou, brzy (když je heap čerstvý a souvislý) a pak rozdávejte jeho výřezy pro velké krátkodobé buffery. Ty buffery pak nikdy nealokují/neuvolňují za běhu, takže nemohou nic fragmentovat. Stejné bajty areny znovu používejte pro práci, která se časově nepřekrývá.

lib/picogame_arena.py je malá obecná implementace (je v picogame knihovně, ale třída Arena není specifická pro hry):

import picogame_arena
AR = picogame_arena.Arena(4096) # 4096 bajtů, nabráno předem (velikost = vaše maximum)
# --- síťový příklad: znovu použij JEDEN response buffer místo churnu ---
buf = AR.alloc(4096) # memoryview výřez, žádná alokace na požadavek
while True:
AR.reset() # znovu použij stejné bajty pro každý požadavek
n = sock.recv_into(buf) # čti přímo do výřezu areny
process(buf[:n]) # parsuj bez alokace dalšího velkého bufferu
# --- grafický příklad (picogame): podlož velké Canvasy pamětí areny ---
AR = picogame_arena.Arena(320 * 80) # pixely (×2 bajty); největší plocha, kterou potřebuješ
AR.reset(); road = AR.canvas(320, 80) # velká plocha jedné obrazovky
# později jiná obrazovka (která není živá současně) znovu použije stejnou arenu:
AR.reset(); shapes = AR.canvas(320, 44); btn = AR.canvas(160, 48)

API: Arena(pixels) (alokuje pixels*2 bajtů), alloc(nbytes) -> memoryview, canvas(w, h, transparent=None) -> Canvas (vyžaduje firmwarový argument Canvas(..., buffer=)), reset() (převine kurzor — volejte na začátku každého nepřekrývajícího se použití), free().

Klíčová pointa: arena způsobí, že se velká alokace stane jednou při startu a výřezy se nikdy nedotknou heapu — takže session může běžet neomezeně dlouho bez selhání „90 KB volných, ale nelze alokovat 51 KB”.

Další techniky (kombinujte podle potřeby)

Sekce “Další techniky (kombinujte podle potřeby)”
  • Alokujte velké/dlouho žijící buffery jako první, při bootu a držte je — neuvolňujte a nevytvářejte je znovu při každé iteraci.
  • Object pooly pro mnoho malých stejně velkých objektů (např. sprity, požadavky) — znovu je používejte místo churnu alloc/free. (picogame: picogame_pool.)
  • recv_into / readinto (a podobná *_into API) čtou do existujícího bufferu místo alokace nového bytes objektu při každém volání.
  • gc.collect() na přirozených hranicích (konec požadavku/úrovně) pro sloučení sousedních volných bloků — nutné, ale ne dostačující (nemůže přesouvat živé objekty).
  • gc.threshold(n) pro spuštění GC dříve a udržení heapu úhlednějšího.
  • CircuitPython už přesouvá „long-lived” objekty z importu na konec heapu při prvním GC, čímž drží spodní heap souvislý pro pracovní alokace — takže import vašich modulů předem (ne líně, uprostřed běhu) pomáhá.

Proč prostě nedefragmentovat?

Sekce “Proč prostě nedefragmentovat?”

Skutečný komprimující/defragmentující GC není proveditelný jako doplněk: objekty MicroPythonu se navzájem odkazují surovými ukazateli (v Pythonu, v C modulech, v bytecodu) a GC prohledává C stack konzervativně — takže nemůže bezpečně přesunout objekt a přepsat každý odkaz na něj. To by vyžadovalo jiný (přesný / handle-based) objektový model v jádru VM. Vzor s arenou je praktická odpověď: nenechte velké buffery churnovat na prvním místě.

Viz také argument Canvas(..., buffer=) enginu (podložte kreslicí plochu pamětí areny) a pomocníka picogame_pool (object pooly). Buildněte a měřte nejprve v desktopovém simulátoru; optimalizujte až poté, co jste změřili, kam vlastně RAM mizí.