00:00:00 Introduzione all’intervento sullo spillare su disco
00:00:34 Elaborazione dei dati del rivenditore e limitazioni della memoria
00:02:13 Soluzione di archiviazione persistente e confronto dei costi
00:04:07 Confronto della velocità di disco e memoria
00:05:10 Limitazioni delle tecniche di partizionamento e streaming
00:06:16 Importanza dei dati ordinati e dimensione di lettura ottimale
00:07:40 Scenario peggiore per la lettura dei dati
00:08:57 Impatto della memoria della macchina sull’esecuzione del programma
00:10:49 Tecniche di spillare su disco e utilizzo della memoria
00:12:59 Spiegazione della sezione del codice e implementazione .NET
00:15:06 Controllo sull’allocazione della memoria e conseguenze
00:16:18 Pagina mappata in memoria e file di mappatura della memoria
00:18:24 Mappe di memoria di lettura-scrittura e strumenti di prestazioni del sistema
00:20:04 Utilizzo della memoria virtuale e pagine mappate in memoria
00:22:08 Gestione di file di grandi dimensioni e puntatori a 64 bit
00:24:00 Utilizzo di span per caricare dalla memoria mappata
00:26:03 Copia dei dati e utilizzo della struttura per leggere gli interi
00:28:06 Creazione di uno span da un puntatore e gestore di memoria
00:30:27 Creazione di un’istanza del gestore di memoria
00:31:05 Implementazione del programma di spillare su disco e mappatura della memoria
00:33:34 Versione mappata in memoria preferibile per le prestazioni
00:35:22 Strategia di buffering del flusso di file e limitazioni
00:37:03 Strategia di mappatura di un file di grandi dimensioni
00:39:30 Divisione della memoria su diversi file di grandi dimensioni
00:40:21 Conclusione e invito a fare domande

Riassunto

Per processare più dati di quelli che possono essere contenuti nella memoria, i programmi possono spillare parte di questi dati su una memoria più lenta ma più grande, come le unità NVMe. Attraverso una combinazione di due funzionalità piuttosto oscure di .NET (file mappati in memoria e gestori di memoria), ciò può essere fatto da C# con poche o nessuna perdita di prestazioni. Questo intervento, presentato durante i Warsaw IT Days 2023, approfondisce i dettagli di come funziona, e discute come il pacchetto open source NuGet Lokad.ScratchSpace nasconde la maggior parte di questi dettagli agli sviluppatori.

Riassunto Esteso

In una lezione completa, Victor Nicolet, il CTO di Lokad, si addentra nelle complessità dello spillare su disco in .NET, una tecnica che permette l’elaborazione di grandi set di dati che superano la capacità di memoria di un computer tipico. Nicolet attinge dalla sua vasta esperienza nel trattare set di dati complessi nel campo dell’ottimizzazione della supply chain quantitativa presso Lokad, fornendo un esempio pratico di un rivenditore con centomila prodotti distribuiti in 100 località. Questo risulta in un set di dati di 10 miliardi di voci considerando i punti dati giornalieri su tre anni, che richiederebbero 37 gigabyte di memoria per memorizzare un valore a virgola mobile per ogni voce, superando di gran lunga la capacità di un computer desktop tipico.

Nicolet suggerisce l’uso di una memoria persistente, come la memoria SSD NVMe, come alternativa economica alla memoria. Confronta il costo della memoria e della memoria SSD, notando che per il costo di 18 gigabyte di memoria, si potrebbe acquistare un terabyte di memoria SSD. Discute anche del compromesso in termini di prestazioni, notando che la lettura dal disco è sei volte più lenta rispetto alla lettura dalla memoria.

Introduce la partizione e lo streaming come tecniche per utilizzare lo spazio su disco come alternativa alla memoria. La partizione consente di elaborare set di dati in pezzi più piccoli che si adattano alla memoria, ma non consente la comunicazione tra le partizioni. Lo streaming, d’altra parte, consente di mantenere uno stato tra l’elaborazione di parti diverse, ma richiede che i dati sul disco siano ordinati o allineati correttamente per ottenere prestazioni ottimali.

Nicolet introduce quindi le tecniche di spill su disco come soluzione ai limiti dell’approccio fits in memory. Queste tecniche distribuiscono i dati tra la memoria e la memoria persistente in modo dinamico, utilizzando più memoria quando disponibile per funzionare più velocemente, e rallentando per utilizzare meno memoria quando ne è disponibile meno. Spiega che le tecniche di spill su disco utilizzano quanta più memoria possibile e iniziano a scaricare i dati sul disco solo quando esauriscono la memoria. Questo le rende migliori nel reagire ad avere più o meno memoria di quanto inizialmente previsto.

Spiega ulteriormente che le tecniche di spill su disco dividono il set di dati in due sezioni: la sezione calda, che è sempre in memoria, e la sezione fredda, che può spillare parti del suo contenuto sulla memoria persistente in qualsiasi momento. Il programma utilizza trasferimenti caldo-freddo, che di solito coinvolgono grandi lotti per massimizzare l’uso della larghezza di banda NVMe. La sezione fredda consente a questi algoritmi di utilizzare quanta più memoria possibile.

Nicolet discute quindi di come implementare questo in .NET. Per la sezione calda, vengono utilizzati oggetti .NET normali, mentre per la sezione fredda, viene utilizzata una classe di riferimento. Questa classe mantiene un riferimento al valore che viene messo in memoria fredda e questo valore può essere impostato su null quando non è più in memoria. Un sistema centrale nel programma tiene traccia di tutti i riferimenti freddi, e ogni volta che viene creato un nuovo riferimento freddo, determina se causa un overflow della memoria e invoca la funzione di spill di uno o più dei riferimenti freddi già presenti nel sistema per rimanere entro il budget di memoria disponibile per la memoria fredda.

Introduce quindi il concetto di memoria virtuale, dove il programma non ha accesso diretto alle pagine di memoria fisica, ma ha accesso alle pagine di memoria virtuale. È possibile creare una pagina mappata in memoria, che è un modo comune per implementare la comunicazione tra programmi e file di mappatura della memoria. Lo scopo principale della mappatura della memoria è prevenire che ogni programma abbia la sua copia della DLL in memoria perché tutte queste copie sono identiche.

Nicolet discute quindi del Performance Tool del sistema, che mostra l’uso attuale della memoria fisica. In verde è la memoria che è stata assegnata direttamente a un processo, in blu è la cache delle pagine, e le pagine modificate al centro sono quelle che dovrebbero essere una copia esatta del disco ma contengono modifiche in memoria.

Discute quindi del secondo tentativo utilizzando la memoria virtuale, dove la sezione fredda sarà composta interamente da pagine mappate in memoria. Se il sistema operativo ha improvvisamente bisogno di memoria, sa quali pagine sono mappate in memoria e possono essere scartate in sicurezza.

Nicolet spiega quindi i passaggi di base per creare un file mappato in memoria in .NET, che sono prima di tutto creare un file mappato in memoria da un file sul disco e poi creare un accessorio di visualizzazione. I due sono tenuti separati perché .NET deve gestire il caso di un processo a 32 bit. Nel caso di un processo a 64 bit, può essere creato un accessorio di visualizzazione che carica l’intero file.

Nicolet discute quindi l’introduzione della memoria e dello span cinque anni fa, che sono tipi utilizzati per rappresentare un intervallo di memoria in un modo più sicuro rispetto ai semplici puntatori. L’idea generale dietro lo span e la memoria è che dato un puntatore e un numero di byte, può essere creato un nuovo span che rappresenta quell’intervallo di memoria. Una volta creato uno span, può essere letto in sicurezza ovunque all’interno dello span sapendo che se si tenta di leggere oltre i limiti, il runtime lo intercetterà e verrà lanciata un’eccezione invece di terminare semplicemente il processo.

Nicolet discute quindi di come utilizzare lo span per caricare dalla memoria mappata in memoria gestita da .NET. Ad esempio, se c’è una stringa che deve essere letta, possono essere utilizzate molte API incentrate sugli span. Nicolet spiega l’uso di API incentrate sugli span, come MemoryMarshal.Read, che può leggere un intero dall’inizio dello span. Menziona anche la funzione Encoding.GetString, che può caricare da uno span di byte in una stringa.

Spiega ulteriormente che queste operazioni vengono eseguite su span, che rappresentano una sezione di dati che potrebbe essere sul disco invece che in memoria. Il sistema operativo gestisce il caricamento dei dati in memoria quando viene acceduto per la prima volta. Nicolet fornisce un esempio di una sequenza di valori a virgola mobile che devono essere caricati in un array di float. Spiega l’uso di MemoryMarshal.Read per leggere la dimensione, l’allocazione di un array di valori a virgola mobile di quella dimensione, e l’uso di MemoryMarshal.Cast per trasformare lo span di byte in uno span di valori a virgola mobile.

Discute anche l’uso della funzione CopyTo degli span, che esegue una copia ad alte prestazioni dei dati dal file mappato in memoria nell’array. Nota che questo processo può essere un po’ spreco in quanto comporta la creazione di una copia completamente nuova. Nicolet suggerisce di creare una struttura che rappresenta l’intestazione con due valori interi al suo interno, che possono essere letti da MemoryMarshal. Discute anche l’uso di una libreria di compressione per decomprimere i dati.

Nicolet discute l’uso di un tipo diverso, Memory, per rappresentare un intervallo di dati di durata più lunga. Menziona la mancanza di documentazione su come creare una memoria da un puntatore e raccomanda un gist su GitHub come la migliore risorsa disponibile. Spiega la necessità di creare un MemoryManager, che viene utilizzato internamente da un Memory ogni volta che ha bisogno di fare qualcosa di più complesso che semplicemente puntare a una sezione di un array.

Nicolet discute l’uso della mappatura della memoria rispetto a FileStream, notando che FileStream è la scelta ovvia quando si accede a dati che sono sul disco e il suo uso è ben documentato. Nota che l’approccio FileStream non è thread safe e richiede un blocco attorno all’operazione, impedendo la lettura da diverse posizioni in parallelo. Nicolet menziona anche che l’approccio FileStream introduce un overhead che non è presente con la versione mappata in memoria.

Spiega che dovrebbe essere utilizzata la versione mappata in memoria, in quanto è in grado di utilizzare quanta più memoria possibile e, quando si esaurisce la memoria, riverserà parti dei set di dati di nuovo sul disco. Nicolet solleva la questione di quanti file allocare, quanto dovrebbero essere grandi, e come ciclare attraverso quei file man mano che la memoria viene allocata e deallocata.

Suggerisce di dividere la memoria su diversi file di grandi dimensioni, senza mai scrivere due volte sulla stessa memoria, e di eliminare i file il più presto possibile. Nicolet conclude condividendo che in produzione a Lokad, utilizzano lo spazio scratch di Lokad con impostazioni specifiche: i file hanno ciascuno 16 gigabyte, ci sono 100 file su ogni disco, e ogni L32VM ha quattro dischi, rappresentando un po’ più di 6 terabyte di spazio di spill per ogni VM.

Trascrizione completa

Slide 1

Victor Nicolet: Ciao e benvenuto a questa presentazione sullo spilling su disco in .NET.

Lo spilling su disco è una tecnica per elaborare set di dati che non si adattano alla memoria mantenendo parti del set di dati che non sono in uso su un supporto di archiviazione persistente.

Questa presentazione si basa sulla mia esperienza lavorativa per Lokad. Facciamo ottimizzazione quantitativa della “supply chain”.

La parte quantitativa significa che lavoriamo con grandi set di dati e la parte della “supply chain”, beh, fa parte del mondo reale quindi sono disordinati, sorprendenti e pieni di casi limite all’interno di casi limite.

Quindi, facciamo un sacco di elaborazioni piuttosto complesse.

Slide 4

Prendiamo un esempio tipico. Un rivenditore avrebbe nell’ordine di centomila prodotti.

Questi prodotti sono presenti in fino a 100 località. Questi possono essere negozi, possono essere magazzini, possono addirittura essere sezioni di magazzini dedicati all’e-commerce.

E se vogliamo fare qualsiasi tipo di analisi reale su questo, dobbiamo guardare il comportamento passato, cosa succede a quei prodotti e quelle località.

Supponendo che teniamo solo un punto dati per ogni giorno e guardiamo solo a tre anni nel passato, significa circa 1000 giorni. Moltiplica tutto questo insieme e il nostro set di dati avrà 10 miliardi di voci.

Se teniamo solo un valore a virgola mobile per ogni voce, il set di dati occupa già 37 gigabyte di memoria. Questo è superiore a quello che avrebbe un tipico computer desktop.

Slide 10

E un valore a virgola mobile non è quasi sufficiente per fare qualsiasi tipo di analisi.

Un numero migliore sarebbe 20 e anche allora stiamo facendo degli sforzi molto buoni per mantenere piccola l’impronta. Anche allora, stiamo guardando a circa 745 gigabyte di utilizzo della memoria.

Questo si adatta nelle macchine cloud se sono abbastanza grandi, circa settemila dollari al mese. Quindi, è abbastanza conveniente ma è anche un po’ spreco.

Slide 11

Come avrete indovinato dal titolo di questa presentazione, la soluzione è utilizzare invece un supporto di archiviazione persistente, che è più lento ma più economico della memoria.

Oggi, è possibile acquistare storage SSD NVMe per circa 5 centesimi per gigabyte. Un SSD NVMe è circa il tipo di storage persistente più veloce che si può ottenere facilmente oggi.

In confronto, un gigabyte di RAM costa 275 dollari. Questa è una differenza di circa 55 volte.

Slide 14

Un altro modo di guardare a questo è che per il budget che serve per comprare 18 gigabyte di memoria, avresti abbastanza per pagare un terabyte di storage SSD.

Slide 15

E per quanto riguarda le offerte Cloud? Beh, prendendo come esempio il cloud Microsoft, a sinistra c’è il L32s, parte di una serie di macchine virtuali ottimizzate per lo storage.

Per circa duemila dollari al mese, si ottengono quasi 8 terabyte di storage persistente.

A destra c’è il M32ms, parte di una serie ottimizzata per la memoria e per più di due volte e mezzo il costo, si ottengono solo 875 gigabyte di RAM.

Se il mio programma gira sulla macchina a sinistra e impiega il doppio del tempo per completare, sto ancora vincendo sul costo.

Slide 16

E per quanto riguarda le prestazioni? Beh, la lettura dalla memoria funziona a circa 21 gigabyte al secondo. La lettura da un SSD NVMe funziona a circa 3,5 gigabyte al secondo.

Questo non è un benchmark reale. Ho solo creato una macchina virtuale e ho eseguito questi due comandi e ci sono molti modi per aumentare e diminuire questi numeri.

La parte importante qui è solo l’ordine di grandezza della differenza tra i due. Leggere dal disco è sei volte più lento che leggere dalla memoria.

Quindi, il disco è sia deludentemente lento, non si vuole leggere dal disco tutto il tempo con schemi di accesso casuale. Ma d’altra parte, è anche sorprendentemente veloce. Se il tuo processamento è principalmente limitato dalla CPU, potresti nemmeno notare che stai leggendo dal disco invece che dalla memoria.

Slide 19

Una tecnica abbastanza conosciuta per utilizzare lo spazio su disco come alternativa alla memoria è la partizionamento.

L’idea dietro il partizionamento è di selezionare una delle dimensioni del set di dati e di tagliare il set di dati in pezzi più piccoli. Ogni pezzo dovrebbe essere abbastanza piccolo da entrare in memoria.

Il processamento quindi carica ogni pezzo per conto suo, fa il suo processamento, e salva quel pezzo di nuovo sul disco prima di caricare il pezzo successivo.

Nel nostro esempio, se dovessimo tagliare i set di dati lungo le posizioni e processare le posizioni una alla volta, allora ogni posizione richiederebbe solo 7,5 gigabyte di memoria. Questo è ben entro la portata di quello che un computer desktop può fare.

Slide 21

Tuttavia, con il partizionamento, non c’è comunicazione tra le partizioni. Quindi, se abbiamo bisogno di elaborare dati attraverso le posizioni, non possiamo più utilizzare questa tecnica.

Un’altra tecnica è lo streaming. Lo streaming è abbastanza simile al partizionamento in quanto carichiamo solo piccoli pezzi di dati in memoria in qualsiasi momento.

A differenza del partizionamento, ci è permesso mantenere uno stato tra l’elaborazione di diverse parti. Quindi, mentre elaboriamo la prima posizione, impostiamo lo stato iniziale, e poi quando elaboriamo la seconda posizione, ci è permesso utilizzare ciò che era presente nello stato a quel punto per creare un nuovo stato alla fine dell’elaborazione della seconda posizione.

A differenza del partizionamento, lo streaming non si presta all’esecuzione parallela. Ma risolve il problema del calcolo di qualcosa su tutti i dati nel set di dati invece di essere isolato in ogni pezzo separatamente.

Lo streaming ha però le sue limitazioni. Per essere performante, i dati su disco dovrebbero essere ordinati o allineati correttamente.

Slide 26

Per capire queste esigenze, è necessario sapere che NVMe legge e scrive dati in settori di mezzo kilobyte e i valori di prestazione precedenti, come 3,5 gigabyte al secondo, presuppongono che i settori vengano letti e utilizzati nella loro interezza.

Se utilizziamo solo una parte del settore ma l’intero settore deve essere letto, allora stiamo sprecando larghezza di banda e dividendo le nostre prestazioni per un grande fattore.

Slide 28

E quindi, è ottimale quando i dati che leggiamo sono un multiplo di mezzo kilobyte e sono allineati sui confini dei settori.

Non stiamo più utilizzando dischi rotanti ora, quindi saltare avanti e non leggere il settore è fatto senza costi.

Slide 30

Se non è possibile allineare i dati sui confini dei settori, tuttavia, un altro modo è caricarli in ordine sequenziale.

Questo perché una volta che un settore è stato caricato in memoria, leggere la seconda parte del settore non richiede un altro caricamento dal disco. Invece, il sistema operativo sarà in grado di darvi i byte rimanenti che non sono ancora stati utilizzati.

E quindi, se i dati vengono caricati consecutivamente, non c’è larghezza di banda sprecata e si ottiene ancora la piena performance.

Slide 31

Il caso peggiore è quando si legge solo un byte o pochi byte da ogni settore. Ad esempio, se si legge un valore a virgola mobile da ogni settore, si divide la propria performance per 128.

Slide 32

Quel che è peggio è che c’è un’altra unità di raggruppamento di dati sopra i settori, che è la pagina del sistema operativo, e il sistema operativo di solito carica intere pagine di circa 4 kilobyte nella loro interezza.

Quindi ora, se si legge un valore a virgola mobile da ogni pagina, si è diviso la propria performance per 1024.

Per questo motivo, è davvero importante assicurarsi che le letture di dati da storage persistente vengano lette in grandi lotti consecutivi.

Slide 33

Utilizzando queste tecniche, è possibile far rientrare il programma in una quantità di memoria più piccola. Ora, queste tecniche tratteranno la memoria e il disco come due spazi di archiviazione separati, indipendenti l’uno dall’altro.

E quindi, la distribuzione del set di dati tra memoria e disco è interamente determinata da quale sia l’algoritmo e quale sia la struttura del set di dati.

Quindi, se eseguiamo il programma su una macchina che ha esattamente la quantità di memoria giusta, il programma si adatterà perfettamente e sarà in grado di funzionare.

Se forniamo una macchina che ha meno della quantità di memoria richiesta, il programma non sarà in grado di adattarsi alla memoria e non sarà in grado di funzionare.

Infine, se forniamo una macchina che ha più della quantità di memoria necessaria, il programma farà ciò che i programmi di solito fanno, non utilizzerà la memoria aggiuntiva e continuerà a funzionare alla stessa velocità.

Slide 38

Se dovessimo tracciare un grafico del tempo di esecuzione in base alla memoria disponibile, sarebbe così. Sotto l’impronta della memoria, non c’è esecuzione, quindi non c’è tempo di elaborazione. Sopra l’impronta, il tempo di elaborazione è costante perché il programma non è in grado di utilizzare la memoria aggiuntiva per funzionare più velocemente.

Slide 39

E inoltre, cosa succede se il set di dati cresce? Beh, a seconda di quale dimensione, se il set di dati cresce in un modo che aumenta il numero di partizioni, allora l’impronta della memoria rimarrà la stessa, ci saranno solo più partizioni.

Slide 41

D’altra parte, se le singole partizioni crescono, allora l’impronta della memoria crescerà anche, il che aumenterà la quantità minima di memoria che il programma ha bisogno per funzionare.

Slide 42

In altre parole, se ho un set di dati più grande che devo elaborare, non solo ci vorrà più tempo, ma avrà anche un’impronta più grande.

Questo crea una situazione spiacevole in cui avrò bisogno di aggiungere più memoria per essere in grado di adattare grandi set di dati quando appaiono, ma aggiungere più memoria non migliora nulla riguardo ai set di dati più piccoli.

Slide 43

Questo è un limite dell’approccio fits in memory dove la distribuzione del set di dati tra memoria e storage persistente è interamente determinata dalla struttura del set di dati e dall’algoritmo stesso.

Non tiene conto della quantità effettiva di memoria disponibile. Quello che le tecniche di spill to disk fanno è che fanno questa distribuzione dinamicamente. Quindi, se c’è più memoria disponibile, useranno più memoria per funzionare più velocemente.

Slide 46

E al contrario, se c’è meno memoria disponibile, allora fino a un certo punto, saranno in grado di rallentare per utilizzare meno memoria. Le curve sembrano molto migliori in quel caso. L’impronta minima è più piccola ed è la stessa per entrambi i set di dati.

Slide 47

Le prestazioni aumentano man mano che si aggiunge più memoria in tutti i casi. Le tecniche di fit to memory scaricheranno preventivamente alcuni dati sul disco per ridurre l’impronta della memoria. Al contrario, le tecniche di spill to disk utilizzeranno quanta più memoria possibile e solo quando esauriranno la memoria inizieranno a scaricare alcuni dati sul disco per fare spazio.

Questo le rende molto più reattive nel caso in cui si abbia più o meno memoria di quella inizialmente prevista. Le tecniche di spill to disk divideranno il set di dati in due sezioni. Si presume che la sezione hot sia sempre in memoria e quindi è sempre sicuro in termini di prestazioni accedervi con schemi di accesso casuale. Avrà ovviamente un budget massimo, forse qualcosa come 8 gigabyte per CPU su una tipica macchina Cloud.

D’altra parte, alla sezione fredda è permesso in qualsiasi momento di riversare parti del suo contenuto nello storage persistente. Non c’è un budget massimo tranne quello disponibile. E ovviamente, non è sicuramente possibile in termini di prestazioni leggere dalla sezione fredda.

Quindi, il programma utilizzerà trasferimenti hot-cold. Questi coinvolgeranno solitamente grandi lotti per massimizzare l’uso della larghezza di banda NVMe. E poiché i lotti sono abbastanza grandi, saranno anche effettuati a una frequenza piuttosto bassa. E quindi, è la sezione fredda che permette a questi algoritmi di utilizzare quanta più memoria possibile.

Slide 50

Perché la sezione fredda riempirà quanta più RAM è disponibile e poi riverserà il resto nello storage persistente. Quindi, come possiamo far funzionare questo in .NET? Dal momento che chiamo questo il primo tentativo, puoi immaginare che non funzionerà. Quindi, cerca di scoprire in anticipo qual sarà il problema.

slide 51

Per la sezione hot, utilizzerò normali oggetti .NET e il problema che esamineremo in un normale programma .NET. Per la sezione fredda, utilizzerò questa chiamata classe di riferimento. Questa classe mantiene un riferimento al valore che viene messo in cold storage e questo valore può essere impostato su null quando non è più in memoria. Ha una funzione di spill che prende il valore dalla memoria e lo scrive nello storage e poi annulla il riferimento che permetterà al garbage collector .NET di recuperare quella memoria quando sente una certa pressione.

E infine, ha una proprietà di valore. Questa proprietà quando viene acceduta restituirà il valore dalla memoria se presente e se non lo è, ricaricheremo dal disco in memoria prima di restituirlo. Ora, se imposto un sistema centrale nel mio programma che tiene traccia di tutti i riferimenti freddi, allora ogni volta che viene creato un nuovo riferimento freddo, posso determinare se causa l’overflow della memoria e invocherà la funzione di spill di uno o più dei riferimenti freddi già presenti nel sistema solo per rimanere entro il budget di memoria disponibile per lo storage freddo.

Slide 53

Quindi, qual è il problema? Beh, se guardo il contenuto della memoria di una macchina che esegue il nostro programma, nel caso ideale, sembrerà così. Prima a sinistra c’è la memoria del sistema operativo che usa per i suoi scopi. Poi c’è la memoria interna utilizzata da .NET per cose come assembly caricati o overhead del garbage collector e così via. Poi c’è la memoria della sezione hot e infine, occupando tutto il resto, c’è la memoria assegnata alla sezione fredda.

Con alcuni sforzi, siamo in grado di controllare tutto ciò che è a destra perché è ciò che assegniamo e scegliamo di rilasciare per il garbage collector da raccogliere. Tuttavia, ciò che è a sinistra è fuori dal nostro controllo. E cosa succede se all’improvviso il sistema operativo ha bisogno di un po’ di memoria aggiuntiva e scopre che tutto è occupato da ciò che il processo .NET ha creato?

Slide 56

Beh, la reazione tipica del kernel Linux in quel caso sarà di uccidere il programma che utilizza più memoria e non c’è modo di reagire abbastanza velocemente per rilasciare un po’ di memoria al kernel in modo che non ci uccida. Quindi, qual è la soluzione?

Slide 57

I sistemi operativi moderni hanno il concetto di memoria virtuale. Il programma non ha accesso diretto alle pagine di memoria fisica. Invece, ha accesso alle pagine di memoria virtuale e c’è una mappatura tra queste pagine e le pagine effettive nella memoria fisica. Se un altro programma è in esecuzione sullo stesso computer, non sarà in grado di accedere da solo alle pagine del primo programma. Ci sono modi per condividere, comunque.

È possibile creare una pagina mappata in memoria. In quel caso, tutto ciò che il primo programma scrive sulla pagina condivisa sarà immediatamente visibile dall’altra parte. Questo è un modo comune per implementare la comunicazione tra programmi ma il suo scopo principale è la mappatura di file in memoria. Qui, il sistema operativo saprà che questa pagina è una copia esatta di una pagina su storage persistente, di solito parti di un file di libreria condivisa.

Lo scopo principale qui è prevenire che ogni programma abbia la sua copia della DLL in memoria perché tutte quelle copie sono identiche quindi non c’è motivo di sprecare memoria per memorizzare quelle copie. Qui, ad esempio, abbiamo due programmi che totalizzano quattro pagine di memoria quando la memoria fisica ha spazio solo per tre. Ora, cosa succede se vogliamo allocare una pagina in più nel primo programma? Non c’è spazio disponibile ma il sistema operativo del kernel sa che la pagina mappata in memoria può essere temporaneamente eliminata e quando necessario, sarà in grado di essere ricaricata da storage persistente identicamente.

Slide 63

Quindi, farà proprio così. Le due pagine condivise ora puntano al disco invece che alla memoria. La memoria viene cancellata, impostata a zero dal sistema operativo, e poi data al primo programma per essere utilizzata per la sua terza pagina logica. Ora, la memoria è completamente piena e se uno dei programmi tenta di accedere alla pagina condivisa, non ci sarà spazio per caricarla di nuovo in memoria perché le pagine che vengono date ai programmi non possono essere recuperate dal sistema operativo.

Slide 66

Quindi, quello che succederà qui è un errore di memoria esaurita. Uno dei programmi morirà, la memoria sarà rilasciata, e sarà poi riutilizzata per caricare il file mappato in memoria di nuovo in memoria. Inoltre, mentre la maggior parte delle mappe di memoria sono di sola lettura, è anche possibile crearne alcune che sono di lettura-scrittura.

Slide 70

Un programma apporta una modifica alla memoria nella pagina mappata, poi il sistema operativo in un certo punto nel futuro salverà il contenuto di quella pagina di nuovo sul disco. E naturalmente, è possibile chiedere che ciò avvenga in un momento specifico utilizzando funzioni come flush su Windows. Il Performance Tool del sistema ha questa bella finestra che mostra l’uso attuale della memoria fisica.

In verde è la memoria che è stata assegnata direttamente a un processo. Non può essere recuperata senza uccidere il processo. In blu è la cache delle pagine. Queste sono pagine che si sa essere copie identiche di una pagina sul disco e quindi ogni volta che un processo ha bisogno di leggere dal disco una pagina che è già nella cache, allora non avverrà nessuna lettura del disco e il valore sarà restituito direttamente dalla memoria.

Slide 71

Infine, le pagine modificate nel mezzo sono quelle che dovrebbero essere una copia esatta del disco ma contengono modifiche in memoria. Queste modifiche non sono state ancora applicate di nuovo al disco ma lo saranno in un tempo abbastanza breve. Su Linux, lo strumento h-stop mostra un grafico simile. A sinistra ci sono le pagine che sono state assegnate direttamente ai processi e non possono essere recuperate senza ucciderli e a destra in giallo è la cache delle pagine.

Slide 73

Se sei interessato, c’è un’ottima risorsa di Vyacheslav Biryukov su cosa succede nella cache delle pagine di Linux. Utilizzando la memoria virtuale, facciamo il nostro secondo tentativo. Funzionerà questa volta? Ora, decidiamo che la sezione fredda sarà composta interamente da pagine mappate in memoria. Quindi tutte sono attese per essere presenti sul disco prima.

Il programma non ha più alcun controllo su quali pagine saranno in memoria e quali saranno presenti solo sul disco. Il sistema operativo fa ciò in modo trasparente. Quindi, se il programma cerca di accedere, diciamo, alla terza pagina nella sezione fredda, il sistema operativo rileverà che non è presente in memoria, scaricherà una delle pagine esistenti, diciamo la seconda, e poi caricherà la terza pagina in memoria.

Slide 76

Dal punto di vista del processo stesso, è stato completamente trasparente. L’attesa per la lettura dalla memoria è stata solo un po’ più lunga del solito. E cosa succede se il sistema operativo ha bisogno improvvisamente di un po’ di memoria per fare le sue cose? Beh, sa quali pagine sono mappate in memoria e possono essere scartate in sicurezza. Quindi, lascerà cadere una delle pagine, la userà per i suoi scopi, e poi la rilascerà quando avrà finito.

Slide 77

Tutte queste tecniche si applicano a .NET e sono presenti nel progetto open source Lokad Scratch Space. E la maggior parte del codice che segue si basa su come fa le cose questo pacchetto NuGet.

Slide 78

Prima di tutto, come creeremmo un file mappato in memoria in .NET? La mappatura della memoria esiste da .NET Framework 4, circa 13 anni fa. È abbastanza ben documentata su internet e il codice sorgente è interamente disponibile su GitHub.

Slide 80

I passaggi di base sono prima di tutto creare un file mappato in memoria da un file sul disco e poi creare un accessorio di visualizzazione. Questi due tipi sono tenuti separati perché hanno significati separati. Il file mappato in memoria dice semplicemente al sistema operativo che da questo file, alcune sezioni saranno mappate nella memoria del processo. L’accessorio di visualizzazione rappresenta queste mappature.

I due sono tenuti separati perché .NET deve gestire il caso di un processo a 32 bit. Un file molto grande, uno che è più grande di quattro gigabyte, non può essere mappato nello spazio di memoria di un processo a 32 bit. È troppo grande. Ora, il punto non è abbastanza grande per rappresentarlo. Quindi, invece, è possibile mappare solo piccole sezioni del file una alla volta in modo che si adattino.

Nel nostro caso, lavoreremo con puntatori a 64 bit. Quindi, possiamo semplicemente creare un accessorio di visualizzazione che carica l’intero file. E ora, uso AcquirePointer per ottenere il puntatore ai primi byte di questo intervallo di memoria mappato in memoria. Quando ho finito di lavorare con il puntatore, posso semplicemente rilasciarlo. Lavorare con i puntatori in .NET è insicuro. Richiede l’aggiunta della parola chiave unsafe ovunque e può esplodere se si tenta di accedere alla memoria oltre i limiti dell’intervallo consentito.

Slide 81

Fortunatamente, c’è un modo per aggirare questo problema. Cinque anni fa, .NET ha introdotto memory e span. Questi sono tipi usati per rappresentare un intervallo di memoria in un modo più sicuro dei semplici puntatori. È abbastanza ben documentato e la maggior parte del codice può essere trovato in questa posizione su GitHub.

Slide 83

L’idea generale dietro span e memory è che dato un puntatore e un numero di byte, è possibile creare un nuovo span che rappresenta quell’intervallo di memoria.

Una volta che hai questo span, puoi leggere in sicurezza ovunque all’interno dello span sapendo che se provi a leggere oltre i limiti, il runtime lo catturerà per te e otterrai un’eccezione invece che il processo fatto.

Slide 84

Vediamo come possiamo usare span per caricare dalla memoria mappata in memoria gestita da .NET. Ricorda, non vogliamo accedere direttamente alla sezione fredda per motivi di prestazioni. Invece, vogliamo fare trasferimenti da freddo a caldo che caricano un sacco di dati contemporaneamente.

Ad esempio, diciamo che abbiamo una stringa che vogliamo leggere. Sarà disposta nel file mappato in memoria come una dimensione seguita da un payload di byte codificato in UTF-8, e vogliamo caricare una stringa .NET da quello.

Slide 86

Beh, ci sono molte API che sono incentrate su span che possiamo usare. Ad esempio, MemoryMarshal.Read può leggere un intero dall’inizio dello span. Poi, usando questa dimensione, posso chiedere alla funzione Encoding.GetString di caricare da uno span di byte in una stringa.

Tutti questi operano su span e anche se lo span rappresenta una sezione di dati che è possibilmente presente sul disco invece che in memoria, il sistema operativo si occupa di caricare trasparentemente i dati in memoria quando viene acceduto per la prima volta.

Slide 87

Un altro esempio potrebbe essere una sequenza di valori a virgola mobile che vogliamo caricare in un array di float.

Slide 88

Ancora una volta, usiamo MemoryMarshal.Read per leggere la dimensione. Allochiamo un array di valori a virgola mobile di quella dimensione e poi usiamo MemoryMarshal.Cast per trasformare lo span di byte in uno span di valori a virgola mobile. Questo reinterpretare i dati presenti nello span come valori a virgola mobile invece che come byte.

Infine, usiamo la funzione CopyTo di span che farà una copia ad alte prestazioni dei dati dal file mappato in memoria nell’array stesso. Questo è in un certo senso un po’ spreco, stiamo facendo una copia completamente nuova.

Slide 89

Forse potremmo evitarlo. Beh, di solito quello che memorizzeremo sul disco non saranno i valori a virgola mobile grezzi. Invece, salveremo una versione compressa di essi. Qui memorizziamo la dimensione compressa, che ci dice quanti byte dobbiamo leggere. Memorizziamo la dimensione di destinazione o la dimensione decompressa. Questo ci dice quanti valori a virgola mobile dobbiamo allocare in memoria gestita. E infine, memorizziamo il payload compresso stesso.

Slide 90

Per caricare questo, sarebbe meglio se invece di leggere due interi, creassimo una struttura che rappresenta quell’intestazione con due valori interi al suo interno.

Slide 91

MemoryMarshal sarà in grado di leggere un’istanza di quella struttura, caricando i due campi allo stesso tempo. Allochiamo un array di valori a virgola mobile e poi la nostra libreria di compressione ha quasi certamente qualche variante di una funzione di decompressione che prende uno span di byte in sola lettura come input e prende uno span di byte come output. Possiamo usare di nuovo MemoryMarshal.Cast, questa volta trasformando l’array di valori a virgola mobile in uno span di byte da usare come destinazione.

Ora, non è coinvolta nessuna copia. Invece, l’algoritmo di compressione legge direttamente dal disco, di solito attraverso la cache delle pagine, nell’array di destinazione di float.

Slide 92

Span ha però un grande limite, che è quello di non poter essere utilizzato come membro di una classe e, per estensione, non può essere utilizzato nemmeno come variabile locale in un metodo asincrono.

Fortunatamente, esiste un tipo diverso, Memory, che dovrebbe essere utilizzato per rappresentare un intervallo di dati più lungo.

Slide 94

Purtroppo, c’è pochissima documentazione su come fare questo. Creare uno span da un puntatore è facile, creare una memoria da un puntatore non è documentato al punto che la migliore documentazione disponibile è un gist su GitHub, che vi consiglio vivamente di leggere.

Slide 95

In breve, quello che dobbiamo fare è creare un MemoryManager. Il MemoryManager viene utilizzato internamente da un Memory ogni volta che ha bisogno di fare qualcosa di più complesso che semplicemente puntare a una sezione di un array.

Nel nostro caso, dobbiamo fare riferimento all’accessore della vista mappata in memoria in cui stiamo guardando. Dobbiamo conoscere la lunghezza che ci è permesso guardare e infine, avremo bisogno di un offset. Questo perché una Memory di byte può rappresentare non più di due gigabyte per progettazione, e il file stesso sarà probabilmente più lungo di due gigabyte. Quindi l’offset ci dà la posizione dove la memoria inizia all’interno del più ampio accessore della vista.

Slide 97

Il costruttore della classe è abbastanza semplice.

Slide 98

Dobbiamo solo aggiungere un riferimento al safe handle che rappresenta la regione di memoria e questo riferimento sarà rilasciato nella funzione di dispose.

Slide 99

Successivamente, abbiamo una proprietà indirizzo che non è un altro giro, è solo qualcosa che è utile per noi avere. Usiamo DangerousGetHandle per ottenere un puntatore e aggiungiamo l’offset in modo che l’indirizzo punti ai primi byte nella regione che vogliamo che la nostra memoria rappresenti.

Slide 100

Sovrascriviamo la funzione GetSpan che fa tutta la magia. Crea semplicemente uno span utilizzando l’indirizzo e la lunghezza.

Slide 101

Ci sono altri due metodi che devono essere implementati sul MemoryManager. Uno di essi è Pin. Viene utilizzato dal runtime in un caso in cui la memoria deve essere mantenuta nella stessa posizione per una breve durata. Aggiungiamo un riferimento e restituiamo un MemoryHandle che punta alla posizione corretta e fa anche riferimento all’oggetto corrente come pinnable.

Slide 102

Questo farà sapere al runtime che quando la memoria sarà sbloccata, allora chiamerà il metodo Unpin di questo oggetto, che provoca il rilascio del safe handle di nuovo.

Slide 103

Una volta creata questa classe, è sufficiente creare un’istanza di essa e accedere alla sua proprietà Memory che restituirà una Memory di byte che fa riferimento internamente al MemoryManager che abbiamo appena creato. E ci sei, ora hai un pezzo di memoria. Quando ci scrivi sopra, verrà automaticamente scaricato su disco quando lo spazio è necessario e quando vi si accede, verrà caricato trasparentemente da disco ogni volta che ne hai bisogno.

Slide 104

Quindi è sufficiente per implementare il nostro programma di spill to disk. C’è un’altra domanda, perché usare la mappatura della memoria quando potremmo usare FileStream invece? Dopotutto, FileStream è la scelta ovvia quando si accede a dati che sono sul disco e il suo uso è abbastanza ben documentato. Leggendo un array di valori a virgola mobile, ad esempio, hai bisogno di un FileStream e un BinaryReader avvolto attorno al FileStream. Imposti la posizione all’offset dove sono presenti i dati, leggi un Int32 con il lettore, assegni l’array a virgola mobile e poi MemoryMarshal.Cast in uno span di byte.

Slide 106

FileStream.Read ha ora un overload che prende uno span di byte come destinazione. Questo in realtà utilizza anche la cache delle pagine. Invece di mappare quelle pagine nello spazio degli indirizzi del tuo processo, il sistema operativo le tiene semplicemente in giro e per leggere i valori, caricherà semplicemente dal disco alla memoria e poi copierà da quella pagina nello span di destinazione che hai fornito. Quindi questo è equivalente in termini di prestazioni e comportamento a ciò che è successo nella versione mappata in memoria.

Ci sono tuttavia, due grandi differenze. Prima di tutto, questo non è thread safe. Imposti la posizione in una riga e poi in un’altra istruzione, ti affidi al fatto che quella posizione sia ancora la stessa. Questo significa che hai bisogno di un blocco attorno a questa operazione e quindi non puoi leggere da diverse posizioni in parallelo, anche se ciò è possibile con i file mappati in memoria.

Un altro problema è che, a seconda della strategia utilizzata dal FileStream, fai due letture, una per l’Int32 e una per la lettura allo span. Una possibilità è che ognuno di loro farà una chiamata di sistema. Chiamerà il sistema operativo e il sistema operativo copierà alcuni dati dalla sua memoria nella memoria del processo. Questo ha un certo sovraccarico. L’altra possibilità è che lo stream sia bufferizzato. In quel caso, leggere quattro byte inizialmente creerà una copia di una pagina, probabilmente. E questa copia avviene oltre alla copia effettiva che viene fatta dalla funzione di lettura successivamente. Quindi introduce un certo sovraccarico che semplicemente non è presente con la versione mappata in memoria.

Per questo motivo, l’uso della versione mappata in memoria è preferibile in termini di prestazioni. Dopotutto, FileStream è la scelta ovvia per accedere ai dati che sono presenti sul disco e il suo utilizzo è molto ben documentato. Ad esempio, per leggere un array di valori a virgola mobile, hai bisogno di un FileStream, un BinaryReader. Imposti la posizione del FileStream all’offset dove i dati sono presenti nel file, leggi un Int32 per ottenere la dimensione, assegni l’array a virgola mobile, lo trasformi in uno span di byte usando MemoryMarshal.Cast e lo passi all’overload di FileStream.Read che vuole uno span di byte come destinazione per la lettura. E questo utilizza anche la cache delle pagine. Invece che le pagine siano associate al processo, sono tenute in giro dal sistema operativo stesso e caricherà semplicemente dal disco nella cache delle pagine e copierà dalla cache delle pagine nella memoria del processo, proprio come abbiamo fatto con la versione mappata in memoria.

L’approccio FileStream, tuttavia, ha due principali svantaggi. Il primo è che questo codice non è sicuro per l’uso multi-thread. Dopotutto, la posizione viene impostata in una dichiarazione e poi utilizzata nelle dichiarazioni seguenti. Quindi abbiamo bisogno di un blocco attorno a quelle operazioni di lettura. La versione mappata in memoria non ha bisogno di blocchi e infatti è in grado di caricare da diverse posizioni sul disco in parallelo. Per gli SSD, questo aumenta la profondità della coda che aumenta le prestazioni e quindi è solitamente desiderabile. L’altro problema è che il FileStream deve fare due letture.

A seconda della strategia utilizzata internamente dallo stream, questo può comportare due chiamate di sistema che devono svegliare il sistema operativo. Copierà alcuni dati dalla sua memoria nel processo di memoria e poi dovrà cancellare tutto e restituire il controllo al processo. Questo ha un certo sovraccarico. L’altra possibile strategia è che il FileStream sia bufferizzato. In quel caso, sarebbe fatta solo una chiamata di sistema ma coinvolgerebbe una copia dalla memoria del sistema operativo al buffer interno del FileStream e poi l’istruzione di lettura dovrà copiare di nuovo dal buffer interno del FileStream nell’array a virgola mobile. Quindi ciò crea una copia spregevole che non è presente con la versione mappata in memoria.

Il file stream, sebbene un po’ più facile da usare, ha alcune limitazioni. Dovrebbe essere utilizzata invece la versione mappata in memoria. Quindi ora, abbiamo finito con un sistema che è in grado di utilizzare quanta più memoria possibile e, quando si esaurisce la memoria, riverserà parti dei set di dati di nuovo sul disco. Questo processo è completamente trasparente e coopera con il sistema operativo. Funziona al massimo delle prestazioni perché i pezzi del set di dati che vengono acceduti frequentemente sono sempre mantenuti in memoria.

Tuttavia, c’è una domanda finale che dobbiamo rispondere. Dopotutto, quando mappi le cose in memoria, non mappi il disco, mappi i file sul disco. Ora la domanda è, quanti file stiamo andando ad allocare? Quanto grandi saranno? E come andremo a ciclare attraverso quei file mentre allociamo e deallociamo la memoria?

La scelta ovvia è di mappare semplicemente un file grande, farlo all’avvio del programma, e continuare a girare su di esso. Quando una parte non è più utilizzata, basta sovrascriverla. Questo è ovvio e quindi è sbagliato.

Slide 111

Il primo problema con questo approccio è che sovrascrivere una pagina di memoria richiede un algoritmo discreto.

L’algoritmo è il seguente: prima, carichi la pagina in memoria immediatamente. Poi, cambi il contenuto della pagina in memoria. Il sistema operativo non ha modo di sapere che al passo due, stai per cancellare tutto e sostituirlo, quindi deve ancora caricare la pagina in modo che i pezzi che non cambi rimangano gli stessi. Infine, programmi la pagina per essere riscritta sul disco in un momento futuro.

Ora, la prima volta che scrivi su una data pagina in un file completamente nuovo, non ci sono dati da caricare. Il sistema operativo sa che tutte le pagine sono zero, quindi il carico è gratuito. Prende semplicemente una pagina zero e la usa. Ma quando la pagina è già stata modificata e non è più in memoria, il sistema operativo deve ricaricarla dal disco.

Slide 113

Un secondo problema è che le pagine dalla cache delle pagine vengono espulse su base del meno recentemente utilizzato, e il sistema operativo non è a conoscenza che una sezione morta della tua memoria, che non sarà mai più utilizzata, deve essere eliminata. Quindi, potrebbe finire per mantenere in memoria alcune porzioni del set di dati che non sono necessarie ed espellere alcune porzioni che sono necessarie. Non c’è modo di dire al sistema operativo che dovrebbe semplicemente ignorare le sezioni morte.

Slide 114

Un terzo problema è anche correlato, ovvero che la scrittura dei dati sul disco è sempre in ritardo rispetto alla scrittura dei dati sulla memoria. E se sai che una pagina non è più necessaria e non è ancora stata scritta sul disco, beh, il sistema operativo non lo sa. Quindi, continua a spendere tempo per scrivere quei byte che non saranno mai più utilizzati sul disco, rallentando tutto.

Slide 115

Invece, dovremmo dividere la memoria su diversi file di grandi dimensioni. Non scriviamo mai due volte sulla stessa memoria. Questo garantisce che ogni scrittura colpisca una pagina che il sistema operativo sa essere tutta zero e non comporta il caricamento dal disco. E cancelliamo i file il prima possibile. Questo dice al sistema operativo che non è più necessario, può essere rimosso dalla cache delle pagine, non ha bisogno di essere scritto sul disco se non lo è già.

Slide 116

In produzione presso Lokad, su una tipica VM di produzione, utilizziamo lo spazio scratch di Lokad con le seguenti impostazioni: i file hanno ciascuno 16 gigabyte, ci sono 100 file su ogni disco, e ogni L32VM ha quattro dischi. In totale, questo rappresenta un po’ più di 6 terabyte di spazio di spill per ogni VM.

Slide 117

È tutto per oggi. Non esitate a contattarci se avete domande o commenti, e grazie per aver guardato.