Articoli approfonditi sulla tecnologia che plasma il futuro.

Perché conta jemalloc: memoria su larga scala

jemalloc gestisce la memoria di alcuni tra i sistemi più grandi al mondo: come riduce la frammentazione e perché Meta ci punta.

Bracci robotici che ordinano blocchi di dati luminosi in contenitori di memoria suddivisi per dimensione in un magazzino

Ogni volta che il tuo programma chiama malloc(), qualcosa deve decidere quale porzione di memoria virtuale restituire. Questa decisione — banale per un programma piccolo — diventa un enorme problema ingegneristico su larga scala. Un allocatore scadente frammenta la memoria, spreca RAM, crea contesa sui lock tra i thread e provoca picchi di latenza quando il sistema operativo deve recuperare le pagine. Un buon allocatore non fa nessuna di queste cose. jemalloc è un buon allocatore, e Meta ha appena rinnovato il proprio impegno su di esso perché, alla loro scala, la differenza tra un allocatore buono e uno mediocre vale miliardi di dollari in costi hardware.

La maggior parte degli sviluppatori non pensa mai all'allocazione della memoria. Chiamano new o malloc e ottengono un puntatore. Ma alla scala di Meta — miliardi di richieste al giorno, milioni di server, petabyte di RAM — il comportamento dell'allocatore ha un impatto diretto e misurabile sull'efficienza dell'hardware, sulla latenza di coda e sui costi operativi.

Cosa fa davvero un allocatore di memoria

L'allocatore di memoria si trova tra il tuo programma e il sistema operativo. Il SO fornisce la memoria in blocchi grandi (pagine, tipicamente da 4KB o 2MB). Il tuo programma, invece, ha bisogno di memoria in porzioni piccole e di dimensione variabile (una stringa da 24 byte qui, un buffer da 4096 byte là). Il compito dell'allocatore è ritagliare le pagine fornite dal SO nei pezzi che il programma richiede e riciclare i blocchi liberati per le allocazioni future.

L'approccio ingenuo — chiedere al SO una nuova pagina per ogni allocazione e restituirla alla liberazione — è catastroficamente lento. Le system call hanno un overhead. Le pagine sono molto più grandi della maggior parte delle allocazioni. Useresti 4KB di memoria per una stringa di 24 byte.

Gli allocatori reali mantengono un pool di memoria e sub-allocano da esso. Le sfide sono: minimizzare la frammentazione (gli spazi tra le allocazioni sono troppo piccoli per essere usati), minimizzare la contesa sui lock (più thread che allocano contemporaneamente) e minimizzare l'overhead (i metadati per allocazione devono essere piccoli rispetto all'allocazione stessa).

Come funziona jemalloc

jemalloc (creato da Jason Evans, da cui il nome «je») è stato sviluppato originariamente per FreeBSD e successivamente adottato da Meta (allora Facebook) come allocatore predefinito per i propri servizi C e C++. Il suo design affronta le tre sfide principali con tecniche specifiche.

Le cache per thread eliminano la contesa. Ogni thread ha una propria cache di piccole allocazioni. Quando un thread chiama malloc() per un oggetto piccolo, l'allocazione viene servita interamente dalla cache locale del thread — niente lock, niente operazioni atomiche, niente contesa. Solo quando la cache del thread si esaurisce si ricorre all'arena condivisa per ricaricarla.

jemalloc allocation flow:
Thread calls malloc(64)
↓
Check thread cache for 64-byte size class
→ Hit: return cached chunk (no lock, no contention)
→ Miss: refill from arena
↓
Arena (shared, but per-thread affinity)
→ Find a partially-full slab for 64-byte objects
→ Carve out a chunk
→ Return to thread cache
↓
Return chunk to caller
Thread caches handle 95%+ of allocations without any locking.

Le classi di dimensione riducono la frammentazione. Invece di allocare esattamente il numero di byte richiesto, jemalloc arrotonda alla classe di dimensione più vicina. Le classi sono scelte con cura: 8, 16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256 e così via, con una spaziatura che cresce al crescere delle dimensioni. Questo significa che un'allocazione di 50 byte riceve un blocco da 64 byte (23% di spreco), che sembra male ma in realtà è un bene — poiché tutti i blocchi da 64 byte sono intercambiabili, non c'è alcuna frammentazione esterna all'interno di una classe di dimensione.

Gli slab organizzano le allocazioni della stessa dimensione. Ogni slab è una regione contigua di memoria suddivisa in blocchi della stessa classe di dimensione. Uno slab per oggetti da 64 byte contiene solo blocchi da 64 byte. Questo elimina il tipo peggiore di frammentazione — quello in cui la memoria liberata non può essere riutilizzata perché è schiacciata tra allocazioni attive di dimensioni diverse.

Il problema della frammentazione

La frammentazione della memoria è il killer silenzioso dei servizi di lunga durata. Un server appena avviato usa la memoria in modo efficiente — le allocazioni sono compatte. Dopo giorni o settimane di allocazioni e liberazioni miste, l'heap diventa un formaggio svizzero: tanti piccoli spazi liberi tra le allocazioni attive. La memoria libera totale potrebbe essere 2GB, ma la regione libera contigua più grande è di 64KB.

La frammentazione esterna (spazi inutilizzabili tra le allocazioni) e quella interna (spreco all'interno delle allocazioni dovuto agli arrotondamenti) contano entrambe, ma quella esterna è peggiore. La frammentazione interna è limitata dalla spaziatura delle classi di dimensione — nel peggiore dei casi si spreca circa il 25% per allocazione. La frammentazione esterna non ha limiti e cresce nel tempo.

L'approccio basato su slab di jemalloc elimina in larga misura la frammentazione esterna per le piccole allocazioni (che sono la stragrande maggioranza). Poiché tutti gli oggetti in uno slab hanno la stessa dimensione, liberarne uno crea un buco esattamente della misura giusta per la successiva allocazione di quella classe. Non ci sono spazi inutilizzabili.

Per le allocazioni grandi (tipicamente oltre 14KB), jemalloc usa una strategia diversa — ricevono pagine dedicate, e le pagine liberate possono essere restituite al SO o riutilizzate per classi di dimensione diverse. È qui che la frammentazione può ancora presentarsi, ma le allocazioni grandi sono relativamente rare.

jemalloc vs. glibc malloc vs. tcmalloc

I tre allocatori principali nell'ecosistema Linux fanno compromessi diversi.

  • glibc malloc (ptmalloc2) è il predefinito sulla maggior parte dei sistemi Linux. Usa le arena per la scalabilità tra thread, ma ha meno classi di dimensione di jemalloc, il che porta a più frammentazione nei servizi di lunga durata. Il suo vantaggio principale è essere quello predefinito — non serve alcuna configurazione.
  • tcmalloc (Google) è un malloc con cache per thread, sviluppato originariamente per i servizi C++ di Google. Ha un'ottima cache locale ai thread e un overhead ridotto. È adatto a carichi di lavoro con molte allocazioni di breve durata e meno adatto a carichi con forte pressione sulla memoria, dove la frammentazione conta.
  • jemalloc ottimizza per bassa frammentazione e comportamento prevedibile sotto carico prolungato. Usa classi di dimensione e gestione degli slab più sofisticate rispetto agli altri. Il compromesso: un overhead per allocazione leggermente superiore (più metadati) in cambio di un'efficienza di memoria migliore nel tempo.

La scelta giusta dipende dal carico di lavoro. Per i processi di breve durata, tutti e tre si comportano in modo simile. Per i server di lunga durata con schemi di allocazione misti (web server, database, cache), la resistenza alla frammentazione di jemalloc di solito vince — si usa dal 10 al 30% di RAM in meno per lo stesso carico rispetto a glibc malloc, il che, alla scala, si traduce direttamente in risparmi hardware.

Perché a Meta interessa

Alla scala di Meta, una riduzione del 10% dell'uso di memoria nei loro servizi C++ fa risparmiare milioni di dollari in hardware. Non all'anno — al mese. Quando gestisci milioni di server, ciascuno con servizi che allocano e liberano memoria miliardi di volte al giorno, l'efficienza dell'allocatore diventa una voce di bilancio.

Il rinnovato investimento di Meta su jemalloc si concentra su diverse aree: un migliore supporto per le huge page (riducendo i TLB miss sui sistemi con molta memoria), una restituzione della memoria al SO più efficace (riducendo la memoria residente quando il carico diminuisce) e strumenti di profiling migliori (per capire dove la memoria viene usata e dove viene sprecata).

L'aspetto del profiling è particolarmente interessante. jemalloc include un profiling dell'heap integrato — puoi chiedergli di campionare le allocazioni e produrre un profilo che mostra dove la memoria è stata allocata, quanta è attiva rispetto a quella liberata e quanto è frammentato l'heap. Questo profiling ha un overhead quasi nullo in produzione, il che rende fattibile eseguirlo in modo continuo sui server di produzione.

Usare jemalloc nei tuoi progetti

Passare a jemalloc è di solito banale per i programmi C/C++. Su Linux puoi collegarlo direttamente oppure usare LD_PRELOAD per iniettarlo a runtime — senza modifiche al codice.

# Install jemalloc
sudo apt install libjemalloc-dev  # Debian/Ubuntu
brew install jemalloc             # macOS
# Use LD_PRELOAD — works with any program, no recompilation
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./my_server
# Or link at compile time
gcc -o my_server my_server.c -ljemalloc
# Enable profiling
export MALLOC_CONF="prof:true,prof_prefix:jeprof"
./my_server
# Then analyze: jeprof --svg ./my_server jeprof.*.heap > heap.svg

Diversi progetti di alto profilo usano jemalloc di default: Redis, Rust (usa jemalloc come allocatore predefinito su alcune piattaforme), Firefox (jemalloc è stato sviluppato originariamente per FreeBSD, che Firefox supportava) e molti motori di gioco.

Per i linguaggi con gestione automatica della memoria (Python, Java, Go), il runtime del linguaggio gestisce l'allocazione e jemalloc non è direttamente applicabile. Ma i concetti — cache locali ai thread, classi di dimensione, allocazione a slab — compaiono in ogni runtime moderno e nel suo garbage collector. L'allocatore del runtime di Go, per esempio, usa un design ispirato a tcmalloc con cache per P (processore) e classi di dimensione.

L'infrastruttura invisibile

L'allocazione della memoria è un'infrastruttura invisibile quando funziona e catastrofica quando non funziona. Un server che perde memoria gradualmente a causa della frammentazione finirà per essere terminato dall'OOM killer, e la causa non apparirà in nessun log applicativo — si trova sotto il livello dell'applicazione.

Per la maggior parte delle applicazioni, l'allocatore predefinito va bene. Ma se gestisci server di lunga durata, osservi una crescita inspiegabile della memoria o operi a una scala in cui l'efficienza hardware conta, capire il tuo allocatore — ed eventualmente passare a uno migliore — è una delle modifiche infrastrutturali con il miglior rapporto impatto/sforzo che puoi fare. jemalloc non è magia. È ingegneria: progettazione accurata delle strutture dati, compromessi ponderati e un'attenzione incessante ai dettagli che contano su larga scala.