Mindset orientato ai dati
La progettazione orientata ai dati non significa applicare SoA ovunque. Significa semplicemente progettare architetture con un focus predominante sulla rappresentazione dei dati - in particolare con un focus sull'efficienza del layout e dell'accesso alla memoria.
Ciò potrebbe comportare ripetizioni SoA, se del caso in questo modo:
struct BallSoa
{
vector<float> x; // size n
vector<float> y; // size n
vector<float> z; // size n
vector<float> r; // size n
};
... questo è spesso adatto per la logica a ciclo verticale che non elabora contemporaneamente i componenti del vettore di una sfera e il raggio (i quattro campi non sono contemporaneamente caldi), ma invece uno alla volta (un ciclo attraverso il raggio, altri 3 anelli attraverso i singoli componenti dei centri delle sfere).
In altri casi potrebbe essere più appropriato utilizzare un AoS se si accede frequentemente ai campi insieme (se la logica loopy sta iterando attraverso tutti i campi delle sfere anziché individualmente) e / o se è necessario l'accesso casuale di una palla:
struct BallAoS
{
float x;
float y;
float z;
float r;
};
vector<BallAoS> balls; // size n
... in altri casi potrebbe essere opportuno utilizzare un ibrido che bilancia entrambi i vantaggi:
struct BallAoSoA
{
float x[8];
float y[8];
float z[8];
float r[8];
};
vector<BallAoSoA> balls; // size n/8
... potresti persino comprimere la dimensione di una palla a metà usando mezzi galleggianti per adattare più campi a sfera in una linea / pagina cache.
struct BallAoSoA16
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
Float16 r2[16];
};
vector<BallAoSoA16> balls; // size n/16
... forse anche al raggio non si accede con la stessa frequenza del centro della sfera (forse la tua base di codice spesso li tratta come punti e solo raramente come sfere, ad esempio). In tal caso, è possibile applicare ulteriormente una tecnica di divisione del campo caldo / freddo.
struct BallAoSoA16Hot
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
};
vector<BallAoSoA16Hot> balls; // size n/16: hot fields
vector<Float16> ball_radiuses; // size n: cold fields
La chiave per un design orientato ai dati è prendere in considerazione tutti questi tipi di rappresentazioni nelle prime decisioni di progettazione, non intrappolarti in una rappresentazione non ottimale con un'interfaccia pubblica dietro di essa.
Mette in evidenza i modelli di accesso alla memoria e i layout di accompagnamento, rendendoli una preoccupazione significativamente più forte del solito. In un certo senso può anche abbattere in qualche modo le astrazioni. Ho scoperto applicando questa mentalità più che non guardo più std::deque
, ad esempio, in termini di requisiti algoritmici tanto quanto la rappresentazione aggregata di blocchi contigui che ha e come l'accesso casuale di esso funziona a livello di memoria. Si sta in qualche modo concentrando sui dettagli dell'implementazione, ma i dettagli dell'implementazione che tendono ad avere un impatto tanto o più sulle prestazioni della complessità algoritmica che descrive la scalabilità.
Ottimizzazione precoce
Gran parte dell'attenzione predominante nella progettazione orientata ai dati apparirà, almeno a prima vista, pericolosamente vicina all'ottimizzazione prematura. L'esperienza ci insegna spesso che tali micro-ottimizzazioni sono meglio applicate col senno di poi e con un profiler in mano.
Ma forse un messaggio forte da prendere dalla progettazione orientata ai dati è quello di lasciare spazio a tali ottimizzazioni. Questo è ciò che una mentalità orientata ai dati può aiutare a consentire:
Il design orientato ai dati può lasciare spazio per esplorare rappresentazioni più efficaci. Non si tratta necessariamente di raggiungere la perfezione del layout di memoria in una sola volta, ma piuttosto di prendere in anticipo le opportune considerazioni per consentire rappresentazioni sempre più ottimali.
Design orientato agli oggetti granulare
Molte discussioni di progettazione orientate ai dati si metteranno contro nozioni classiche di programmazione orientata agli oggetti. Eppure offrirei un modo di guardare a questo, che non è così hardcore come respingere OOP del tutto.
La difficoltà con il design orientato agli oggetti è che spesso ci tenterà di modellare le interfacce a un livello molto granulare, lasciandoci intrappolati con una mentalità scalare, alla volta invece di una mentalità parallela in blocco.
Come esempio esagerato, immagina una mentalità progettuale orientata agli oggetti applicata a un singolo pixel di un'immagine.
class Pixel
{
public:
// Pixel operations to blend, multiply, add, blur, etc.
private:
Image* image; // back pointer to access adjacent pixels
unsigned char rgba[4];
};
Spero che nessuno lo faccia davvero. Per rendere l'esempio davvero disgustoso, ho memorizzato un puntatore posteriore all'immagine contenente il pixel in modo che possa accedere ai pixel vicini per gli algoritmi di elaborazione delle immagini come la sfocatura.
Il puntatore indietro immagine aggiunge immediatamente un evidente sovraccarico, ma anche se lo escludessimo (facendo sì che solo l'interfaccia pubblica del pixel fornisca operazioni che si applicano a un singolo pixel), finiamo con una classe solo per rappresentare un pixel.
Ora non c'è niente di sbagliato in una classe nell'immediato sovraccarico in un contesto C ++ oltre a questo puntatore posteriore. L'ottimizzazione dei compilatori C ++ è eccezionale nel portare tutta la struttura che abbiamo costruito e nel ridurla in mille pezzi.
La difficoltà qui è che stiamo modellando un'interfaccia incapsulata a un livello di pixel troppo granulare. Ciò ci lascia intrappolati con questo tipo di progettazione granulare e dati, con potenzialmente un gran numero di dipendenze dei client che li accoppiano a questa Pixel
interfaccia.
Soluzione: eliminare la struttura orientata agli oggetti di un pixel granulare e iniziare a modellare le interfacce a un livello più grossolano gestendo un numero elevato di pixel (a livello di immagine).
Modellando a livello di immagine di massa, abbiamo molto più spazio per ottimizzare. Ad esempio, possiamo rappresentare immagini di grandi dimensioni come riquadri coalescenti di 16x16 pixel che si adattano perfettamente a una linea di cache a 64 byte ma consentono un accesso verticale adiacente efficiente dei pixel con un passo tipicamente piccolo (se disponiamo di un numero di algoritmi di elaborazione delle immagini che necessità di accedere ai pixel vicini in modo verticale) come esempio di orientamento orientato ai dati.
Progettare a un livello più grossolano
L'esempio sopra di interfacce di modellazione a livello di immagine è una specie di esempio semplicissimo poiché l'elaborazione delle immagini è un campo molto maturo che è stato studiato e ottimizzato fino alla morte. Eppure meno ovvio potrebbe essere una particella in un emettitore di particelle, uno sprite contro una raccolta di sprite, un bordo in un grafico di bordi, o persino una persona contro una raccolta di persone.
La chiave per consentire l'ottimizzazione orientata ai dati (con lungimiranza o senno di poi) si ridurrà spesso alla progettazione di interfacce a un livello molto più grossolano, alla rinfusa. L'idea di progettare interfacce per singole entità viene sostituita dalla progettazione di raccolte di entità con grandi operazioni che le elaborano in blocco. Questo in particolare e immediatamente si rivolge ai loop di accesso sequenziali che devono accedere a tutto e non possono fare a meno di avere una complessità lineare.
La progettazione orientata ai dati spesso inizia con l'idea di riunire i dati per formare aggregati che modellano i dati in blocco. Una mentalità simile fa eco ai design dell'interfaccia che la accompagnano.
Questa è la lezione più preziosa che ho preso dalla progettazione orientata ai dati, dal momento che non sono abbastanza esperto di architettura informatica da trovare spesso il layout di memoria più ottimale per qualcosa al mio primo tentativo. Diventa qualcosa a cui faccio l'iterazione con un profiler in mano (e talvolta con alcune mancate lungo la strada in cui non sono riuscito ad accelerare le cose). Tuttavia l'aspetto dell'interfaccia di progettazione orientata ai dati è ciò che mi lascia spazio per cercare rappresentazioni di dati sempre più efficienti.
La chiave è progettare interfacce a un livello più grossolano di quanto di solito siamo tentati di fare. Questo ha spesso anche vantaggi collaterali come la mitigazione del sovraccarico dinamico di invio associato a funzioni virtuali, chiamate a puntatori a funzioni, chiamate dylib e incapacità di inserirle. L'idea principale da trarre da tutto ciò è quella di esaminare l'elaborazione alla rinfusa (quando applicabile).
ball->do_something();
controball_table.do_something(ball)
) a meno che non si voglia falsificare un'entità coerente tramite uno pseudo-puntatore(&ball_table, index)
.