Design orientato ai dati - poco pratico con più di 1-2 "membri" della struttura?


23

Il solito esempio di progettazione orientata ai dati è con la struttura a sfera:

struct Ball
{
  float Radius;
  float XYZ[3];
};

e poi creano un algoritmo che esegue l'iterazione di un std::vector<Ball>vettore.

Quindi ti danno la stessa cosa, ma implementati nella progettazione orientata ai dati:

struct Balls
{
  std::vector<float> Radiuses;
  std::vector<XYZ[3]> XYZs;
};

Che è buono e tutto se hai intenzione di iterare prima attraverso tutti i raggi, quindi tutte le posizioni e così via. Tuttavia, come si spostano le palline nel vettore? Nella versione originale, se ne hai uno std::vector<Ball> BallsAll, puoi semplicemente spostarne uno BallsAll[x]su uno qualsiasi BallsAll[y].

Tuttavia, per farlo per la versione orientata ai dati, è necessario fare la stessa cosa per ogni proprietà (2 volte nel caso di Ball - raggio e posizione). Ma peggiora se hai molte più proprietà. Dovrai mantenere un indice per ogni "palla" e quando provi a spostarlo, devi fare la mossa in ogni vettore di proprietà.

Ciò non uccide alcun vantaggio in termini di prestazioni della progettazione orientata ai dati?

Risposte:


23

Un'altra risposta ha fornito un'eccellente panoramica su come incapsuleresti la memoria orientata alle righe e offriresti una visione migliore. Ma dal momento che chiedi anche delle prestazioni, lasciami affrontare questo: il layout di SoA non è un proiettile d'argento . È un valore predefinito piuttosto buono (per l'utilizzo della cache; non tanto per facilità di implementazione nella maggior parte delle lingue), ma non è tutto ciò che esiste, nemmeno nella progettazione orientata ai dati (qualunque cosa significhi esattamente). È possibile che gli autori di alcune presentazioni che hai letto abbiano perso quel punto e presentino solo il layout SoA perché pensano che sia l'intero punto di DOD. Si sbagliano, e per fortuna non tutti cadono in quella trappola .

Come probabilmente hai già capito, non tutti i dati primitivi traggono vantaggio dall'essere estratti nel proprio array. Pertanto, un layout è vantaggioso quando i componenti suddivisi in array separati sono generalmente accessibili separatamente. Ma non si accede a ogni minuscolo pezzo da solo, ad esempio un vettore di posizione viene quasi sempre letto e aggiornato all'ingrosso, quindi naturalmente non lo si divide. In effetti, neanche il tuo esempio lo ha fatto! Allo stesso modo, se di solito accedi a tutte le proprietà di una palla insieme, perché trascorri la maggior parte del tuo tempo a scambiare palle nella tua collezione di palle, non ha senso separarle.

Tuttavia, c'è un secondo aspetto di DOD. Non si ottengono tutti i vantaggi della cache e dell'organizzazione semplicemente ruotando il layout della memoria di 90 ° e facendo il minimo per correggere gli errori di compilazione risultanti. Ci sono altri trucchi comuni insegnati sotto questo banner. Ad esempio "elaborazione basata sull'esistenza": se si disattivano frequentemente le sfere e le si riattivano di nuovo, non aggiungere un flag all'oggetto palla e fare in modo che il ciclo di aggiornamento ignori le palle con il flag impostato su false. Spostare la palla da una raccolta "attiva" a una raccolta "inattiva" e fare in modo che il ciclo di aggiornamento controlli solo la raccolta "attiva".

Ancora più importante e pertinente per il tuo esempio: se passi così tanto tempo a mescolare l'array di palline, forse stai facendo qualcosa di sbagliato. Perché l'ordine conta? Riesci a farlo non importa? In tal caso, otterrai diversi vantaggi:

  • Non è necessario mescolare la raccolta (il codice più veloce è nessun codice).
  • È possibile aggiungere ed eliminare in modo più semplice ed efficiente (scambiare fino alla fine, rilasciare l'ultimo).
  • Il codice rimanente potrebbe diventare idoneo per ulteriori ottimizzazioni (come la modifica del layout su cui ti concentri).

Quindi, invece di buttare ciecamente SoA su tutto, pensa ai tuoi dati e a come li elabori. Se scopri che elabori le posizioni e le velocità in un loop, quindi passa attraverso le mesh e quindi aggiorna gli hitpoint, prova a dividere il layout della memoria in queste tre parti. Se scopri che accedi ai componenti x, y, z della posizione in modo isolato, forse trasforma i tuoi vettori di posizione in un SoA. Se ti trovi a mescolare i dati più che a fare qualcosa di utile, forse smetti di mescolarli.


18

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 Pixelinterfaccia.

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).


5

Quello che hai descritto è un problema di implementazione. La progettazione OO non si occupa espressamente delle implementazioni.

È possibile incapsulare il contenitore Ball orientato alla colonna dietro un'interfaccia che espone una vista orientata a riga o colonna. È possibile implementare un oggetto Ball con metodi come volumee move, che modificano semplicemente i rispettivi valori nella struttura di colonna sottostante. Allo stesso tempo, il contenitore Ball potrebbe esporre un'interfaccia per operazioni efficienti a livello di colonna. Con modelli / tipi appropriati e un compilatore integrato intelligente, è possibile utilizzare queste astrazioni con costi di runtime zero.

Con quale frequenza accederai ai dati in base alla colonna anziché modificarli in base alla riga? In casi d'uso tipici per l'archiviazione di colonne, l'ordinamento delle righe non ha alcun effetto. È possibile definire una permutazione arbitraria delle righe aggiungendo una colonna di indice separata. La modifica dell'ordine richiede solo lo scambio di valori nella colonna dell'indice.

L'aggiunta / rimozione efficiente di elementi potrebbe essere ottenuta con altre tecniche:

  • Mantieni una bitmap di righe eliminate anziché spostare elementi. Compatta la struttura quando diventa troppo sparsa.
  • Raggruppare le file in blocchi di dimensioni adeguate in una struttura simile a un B-Tree in modo che l'inserimento o la rimozione in posizioni arbitrarie non richieda la modifica dell'intera struttura.

Il codice client vedrebbe una sequenza di oggetti Ball, un contenitore mutabile di oggetti Ball, una sequenza di raggi, una matrice Nx3, ecc .; non deve preoccuparsi dei brutti dettagli di quelle strutture complesse (ma efficienti). Ecco cosa ti compra l'astrazione dell'oggetto.


+1 L'organizzazione AoS è perfettamente modificabile in una bella API orientata all'entità, anche se è certamente più brutto da usare ( ball->do_something();contro ball_table.do_something(ball)) a meno che non si voglia falsificare un'entità coerente tramite uno pseudo-puntatore (&ball_table, index).

1
Farò un ulteriore passo avanti: la conclusione di utilizzare SoA può essere raggiunta esclusivamente dai principi di progettazione OO. Il trucco è che hai bisogno di uno scenario in cui le colonne sono un oggetto più fondamentale delle righe. Le palle non sono un buon esempio qui. Invece, considera un terreno con varie proprietà come altezza, tipo di terreno o pioggia. Ogni proprietà è modellata come un oggetto ScalarField, che ha i suoi metodi come gradiente () o divergenza () che può restituire altri oggetti Field. Puoi incapsulare cose come la risoluzione della mappa e diverse proprietà sul terreno possono funzionare con risoluzioni diverse.
16807,

4

Risposta breve: hai pienamente ragione e articoli come questo mancano completamente di questo punto.

La risposta completa è: l'approccio "Struttura delle matrici" dei tuoi esempi può avere vantaggi in termini di prestazioni per alcuni tipi di operazioni ("operazioni di colonna") e "Matrici di strutture" per altri tipi di operazioni ("operazioni di riga ", come quelli che hai menzionato sopra). Lo stesso principio ha influenzato le architetture di database, ci sono database orientati alle colonne rispetto ai database orientati alle righe classiche

Quindi la seconda cosa da considerare per la scelta di un design è il tipo di operazioni di cui hai più bisogno nel tuo programma e se queste trarranno beneficio dal diverso layout di memoria. Tuttavia, la prima cosa da considerare è se hai davvero bisogno di quella prestazione (penso nella programmazione dei giochi, dove l'articolo sopra è da te spesso ha questo requisito).

La maggior parte dei linguaggi OO attuali utilizza un layout di memoria "Array-Of-Struct" per oggetti e classi. Ottenere i vantaggi di OO (come la creazione di astrazioni per i dati, l'incapsulamento e un ambito più locale delle funzioni di base), è in genere collegato a questo tipo di layout di memoria. Quindi, fintanto che non si esegue il calcolo ad alte prestazioni, non considererei SoA come l'approccio principale.


3
DOD non significa sempre layout di struttura di array (SoA). È comune, perché spesso corrisponde al modello di accesso, ma quando un altro layout funziona meglio , usalo sicuramente . DOD è molto più generale (e più sfocato), più simile a un paradigma di progettazione che a un modo specifico di disporre i dati. Inoltre, mentre l'articolo a cui fai riferimento è lungi dall'essere la migliore risorsa e ha i suoi difetti, non pubblicizza i layout di SoA. La "A" s ed s "B" possono essere completamente descritto Balls altrettanto bene come possono essere singoli floats o vec3s (che sarebbe stessi soggetti a SoA-trasformazione).

2
... e il design orientato alle righe che menzioni è sempre racchiuso in DOD. Si chiama un array di strutture (AoS) e la differenza rispetto a ciò che la maggior parte delle risorse chiama "il modo OOP" (nel migliore dei casi) non sta nel layout riga vs colonna ma semplicemente nel modo in cui questo layout viene mappato nella memoria (molti piccoli oggetti collegato tramite puntatori contro una grande tabella continua di tutti i record). In sintesi, -1 perché anche se sollevi buoni punti contro le idee sbagliate di OP, travisi l'intero jazz DOD piuttosto che correggere la comprensione di OP di DOD.

@delnan: grazie per il tuo commento, probabilmente hai ragione sul fatto che avrei dovuto usare il termine "SoA" anziché "DOD". Ho modificato la mia risposta di conseguenza.
Doc Brown,

Molto meglio, rimosso il voto negativo. Scopri la risposta di user2313838 su come unire SoA con API orientate agli "oggetti" (nel senso di astrazioni, incapsulamento e "più ambito locale delle funzioni di base"). Viene più naturalmente per il layout AoS (poiché l'array può essere un contenitore generico stupido piuttosto che essere sposato con il tipo di elemento) ma è fattibile.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.