Come velocizzare il codice utilizzando le cache della CPU

La cache della CPU riduce la latenza della memoria quando si accede ai dati dalla memoria di sistema principale. Gli sviluppatori possono e devono sfruttare la cache della CPU per migliorare le prestazioni dell'applicazione.

Come funzionano le cache della CPU

Le CPU moderne in genere hanno tre livelli di cache, etichettati L1, L2 e L3, che riflette l'ordine in cui la CPU li controlla. Le CPU hanno spesso una cache dei dati, una cache delle istruzioni (per il codice) e una cache unificata (per qualsiasi cosa). L'accesso a queste cache è molto più veloce dell'accesso alla RAM: in genere, la cache L1 è circa 100 volte più veloce della RAM per l'accesso ai dati e la cache L2 è 25 volte più veloce della RAM per l'accesso ai dati.

Quando il software viene eseguito e ha bisogno di inserire dati o istruzioni, vengono controllate prima le cache della CPU, quindi la RAM di sistema più lenta e infine le unità disco molto più lente. Ecco perché vuoi ottimizzare il tuo codice per cercare prima ciò che è probabile che sia necessario dalla cache della CPU.

Il codice non può specificare dove risiedono le istruzioni e i dati dei dati, lo fa l'hardware del computer, quindi non puoi forzare determinati elementi nella cache della CPU. È tuttavia possibile ottimizzare il codice per recuperare le dimensioni della cache L1, L2 o L3 nel sistema utilizzando Strumentazione gestione Windows (WMI) per ottimizzare il momento in cui l'applicazione accede alla cache e quindi le sue prestazioni.

Le CPU non accedono mai alla cache byte per byte. Invece, leggono la memoria nelle righe della cache, che sono blocchi di memoria generalmente di 32, 64 o 128 byte.

Il seguente elenco di codice illustra come recuperare la dimensione della cache della CPU L2 o L3 nel sistema:

public static uint GetCPUCacheSize (string cacheType) {try {using (ManagementObject managementObject = new ManagementObject ("Win32_Processor.DeviceID = 'CPU0'")) {return (uint) (managementObject [cacheType]); }} catch {return 0; }} static void Main (string [] args) {uint L2CacheSize = GetCPUCacheSize ("L2CacheSize"); uint L3CacheSize = GetCPUCacheSize ("L3CacheSize"); Console.WriteLine ("L2CacheSize:" + L2CacheSize.ToString ()); Console.WriteLine ("L3CacheSize:" + L3CacheSize.ToString ()); Console.Read (); }

Microsoft dispone di documentazione aggiuntiva sulla classe WMI Win32_Processor.

Programmazione per prestazioni: codice di esempio

Quando hai oggetti nello stack, non c'è alcun sovraccarico di garbage collection. Se si utilizzano oggetti basati sull'heap, la raccolta generazionale di dati inutili comporta sempre un costo per la raccolta o lo spostamento di oggetti nell'heap o per la compattazione della memoria dell'heap. Un buon modo per evitare il sovraccarico della garbage collection è usare gli struct invece delle classi.

Le cache funzionano meglio se si utilizza una struttura dati sequenziale, come un array. L'ordinamento sequenziale consente alla CPU di leggere in anticipo e anche di leggere in anticipo in modo speculativo in previsione di ciò che è probabile che venga richiesto in seguito. Pertanto, un algoritmo che accede alla memoria in modo sequenziale è sempre veloce.

Se si accede alla memoria in un ordine casuale, la CPU necessita di nuove linee di cache ogni volta che si accede alla memoria. Ciò riduce le prestazioni.

Il frammento di codice seguente implementa un semplice programma che illustra i vantaggi dell'utilizzo di una struttura su una classe:

 struct RectangleStruct {public int ampiezza; public int height; } class RectangleClass {public int ampiezza; public int height; }

Il codice seguente profila le prestazioni dell'utilizzo di una matrice di strutture rispetto a una matrice di classi. A scopo illustrativo, ho utilizzato un milione di oggetti per entrambi, ma in genere non sono necessari molti oggetti nell'applicazione.

static void Main (string [] args) {const int size = 1000000; var structs = new RectangleStruct [size]; var classes = new RectangleClass [size]; var sw = new Stopwatch (); sw.Start (); for (var i = 0; i <size; ++ i) {structs [i] = new RectangleStruct (); structs [i] .breadth = 0 structs [i] .height = 0; } var structTime = sw.ElapsedMilliseconds; sw.Reset (); sw.Start (); for (var i = 0; i <size; ++ i) {classes [i] = new RectangleClass (); classi [i] .breadth = 0; classi [i] .height = 0; } var classTime = sw.ElapsedMilliseconds; sw.Stop (); Console.WriteLine ("Tempo impiegato dall'array di classi:" + classTime.ToString () + "millisecondi."); Console.WriteLine ("Tempo impiegato da un array di strutture:" + structTime.ToString () + "millisecondi."); Console.Read (); }

Il programma è semplice: crea 1 milione di oggetti di strutture e li memorizza in un array. Crea anche 1 milione di oggetti di una classe e li memorizza in un altro array. Alla larghezza e all'altezza delle proprietà viene assegnato un valore zero su ogni istanza.

Come puoi vedere, l'uso di strutture adatte alla cache fornisce un enorme miglioramento delle prestazioni.

Regole pratiche per un migliore utilizzo della cache della CPU

Quindi, come si scrive il codice che utilizza al meglio la cache della CPU? Purtroppo non esiste una formula magica. Ma ci sono alcune regole pratiche:

  • Evitare di utilizzare algoritmi e strutture di dati che mostrano modelli di accesso alla memoria irregolari; utilizzare invece strutture dati lineari.
  • Usa tipi di dati più piccoli e organizza i dati in modo che non ci siano buchi di allineamento.
  • Considera i modelli di accesso e sfrutta le strutture dati lineari.
  • Migliora la località spaziale, che utilizza ogni riga della cache al massimo una volta che è stata mappata su una cache.