Ma questo OOP potrebbe essere uno svantaggio per il software basato sulle prestazioni, ovvero quanto velocemente esegue il programma?
Spesso sì !!! MA...
In altre parole, molti riferimenti tra molti oggetti diversi o l'utilizzo di molti metodi di molte classi potrebbero comportare un'implementazione "pesante"?
Non necessariamente. Questo dipende dalla lingua / dal compilatore. Ad esempio, un compilatore C ++ ottimizzante, a condizione che non si utilizzino funzioni virtuali, spesso riduce a zero il sovraccarico dell'oggetto. Puoi fare cose come scrivere un wrapper su un int
lì o un puntatore intelligente con ambito su un semplice puntatore vecchio che si comporta altrettanto velocemente come usare direttamente questi semplici tipi di dati vecchi.
In altre lingue come Java, c'è un po 'di sovraccarico per un oggetto (spesso abbastanza piccolo in molti casi, ma astronomico in alcuni rari casi con oggetti davvero piccolissimi). Ad esempio, Integer
è notevolmente meno efficiente di int
(richiede 16 byte anziché 4 su 64 bit). Eppure questo non è solo uno spreco palese o qualcosa del genere. In cambio, Java offre elementi come la riflessione su ogni singolo tipo definito dall'utente in modo uniforme, nonché la possibilità di sovrascrivere qualsiasi funzione non contrassegnata come final
.
Eppure prendiamo lo scenario migliore: il compilatore C ++ ottimizzante in grado di ottimizzare le interfacce degli oggetti fino a zero overhead. Anche in questo caso, OOP peggiorerà spesso le prestazioni e impedirà che raggiunga il picco. Potrebbe sembrare un paradosso completo: come potrebbe essere? Il problema sta nel:
Progettazione e incapsulamento dell'interfaccia
Il problema è che anche quando un compilatore può ridurre la struttura di un oggetto a zero overhead (che è almeno molto spesso vero per l'ottimizzazione dei compilatori C ++), l'incapsulamento e il design dell'interfaccia (e le dipendenze accumulate) di oggetti a grana fine spesso impediranno il rappresentazioni dei dati più ottimali per oggetti che sono destinati ad essere aggregati dalle masse (che è spesso il caso di software critico per le prestazioni).
Prendi questo esempio:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Supponiamo che il nostro modello di accesso alla memoria sia semplicemente quello di scorrere ciclicamente queste particelle in sequenza e spostarle ripetutamente attorno a ciascun fotogramma, facendole rimbalzare dagli angoli dello schermo e quindi renderizzando il risultato.
Possiamo già vedere un evidente sovraccarico di padding di 4 byte necessario per allineare birth
correttamente l' elemento quando le particelle vengono aggregate in modo contiguo. Già il ~ 16,7% della memoria viene sprecato con lo spazio morto utilizzato per l'allineamento.
Questo potrebbe sembrare discutibile perché oggi abbiamo gigabyte di DRAM. Eppure anche le macchine più bestiali che abbiamo oggi hanno spesso solo 8 megabyte quando si tratta della regione più lenta e più grande della cache della CPU (L3). Meno riusciamo a inserirci, più paghiamo per questo in termini di accesso ripetuto alla DRAM e più le cose diventano lente. Improvvisamente, sprecare il 16,7% della memoria non sembra più un affare banale.
Possiamo facilmente eliminare questo sovraccarico senza alcun impatto sull'allineamento del campo:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Ora abbiamo ridotto la memoria da 24 a 20 mega. Con un modello di accesso sequenziale, la macchina ora consumerà questi dati un po 'più velocemente.
Ma guardiamo questo birth
campo un po 'più da vicino. Diciamo che registra l'ora di inizio quando una particella nasce (creata). Immagina che il campo sia accessibile solo quando una particella viene creata per la prima volta e ogni 10 secondi per vedere se una particella dovrebbe morire e rinascere in una posizione casuale sullo schermo. In quel caso,birth
è un campo freddo. Non è accessibile nei nostri loop critici per le prestazioni.
Di conseguenza, i dati effettivi critici per le prestazioni non sono 20 megabyte ma in realtà un blocco contiguo da 12 megabyte. La memoria reale effettiva a cui accediamo spesso si è ridotta della metà delle sue dimensioni! Aspettatevi accelerazioni significative rispetto alla nostra soluzione originale da 24 megabyte (non è necessario misurarla: ho già fatto questo tipo di cose mille volte, ma sentitevi liberi in caso di dubbi).
Tuttavia nota cosa abbiamo fatto qui. Abbiamo completamente rotto l'incapsulamento di questo oggetto particellare. Il suo stato è ora suddiviso tra Particle
i campi privati di un tipo e una matrice parallela separata. Ed è qui che si frappongono i design granulari orientati agli oggetti.
Non possiamo esprimere la rappresentazione ottimale dei dati se confinati alla progettazione dell'interfaccia di un singolo oggetto molto granulare come una singola particella, un singolo pixel, persino un singolo vettore a 4 componenti, forse anche un singolo oggetto "creatura" in un gioco , ecc. La velocità di un ghepardo sarà sprecata se si trova su un'isola più piccola di 2 metri quadrati, ed è ciò che spesso fa un design molto granulare orientato agli oggetti in termini di prestazioni. Limita la rappresentazione dei dati a una natura non ottimale.
Per fare ciò, supponiamo che dal momento che stiamo solo spostando le particelle, possiamo effettivamente accedere ai loro campi x / y / z in tre loop separati. In tal caso, possiamo trarre vantaggio dagli intrinseci SIMD in stile SoA con i registri AVX che possono vettorializzare 8 operazioni SPFP in parallelo. Ma per fare questo, ora dobbiamo usare questa rappresentazione:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Ora stiamo volando con la simulazione delle particelle, ma guarda cosa è successo al nostro design delle particelle. È stato completamente demolito e ora stiamo esaminando 4 array paralleli e nessun oggetto per aggregarli. Il nostro Particle
design orientato agli oggetti è diventato sayonara.
Questo mi è successo molte volte lavorando in settori critici per le prestazioni in cui gli utenti richiedono velocità, con la sola correttezza che è l'unica cosa che richiedono di più. Questi piccoli progetti orientati agli oggetti dovevano essere demoliti e le rotture a cascata spesso richiedevano che usassimo una strategia di deprecazione lenta verso un design più veloce.
Soluzione
Lo scenario sopra riportato presenta solo un problema con progetti granulari orientati agli oggetti. In questi casi, spesso finiamo per demolire la struttura per esprimere rappresentazioni più efficienti a seguito di ripetizioni SoA, divisione del campo caldo / freddo, riduzione dell'imbottitura per schemi di accesso sequenziale (l'imbottitura è talvolta utile per le prestazioni con accesso casuale modelli in casi AoS, ma quasi sempre un ostacolo per i modelli ad accesso sequenziale), ecc.
Eppure possiamo prendere quella rappresentazione finale su cui ci siamo basati e modellare ancora un'interfaccia orientata agli oggetti:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Adesso stiamo bene. Possiamo ottenere tutti i gadget orientati agli oggetti che ci piacciono. Il ghepardo ha un intero paese da attraversare il più velocemente possibile. I nostri design di interfaccia non ci intrappolano più in un angolo di collo di bottiglia.
ParticleSystem
può anche essere astratto e utilizzare funzioni virtuali. Adesso è discutibile, stiamo pagando il sovraccarico a livello di raccolta di particelle anziché a livello di particella . Il sovraccarico è 1/1000.000 di quello che sarebbe altrimenti se modellassimo oggetti a livello di singola particella.
Quindi questa è la soluzione in aree critiche per le prestazioni che gestiscono un carico pesante e per tutti i tipi di linguaggi di programmazione (questa tecnica avvantaggia C, C ++, Python, Java, JavaScript, Lua, Swift, ecc.). E non può essere facilmente etichettato come "ottimizzazione prematura", poiché si riferisce al design dell'interfaccia e all'architettura . Non possiamo scrivere una base di codice che modella una singola particella come un oggetto con un carico di dipendenze del client in aParticle's
interfaccia pubblica e poi cambiare idea in seguito. Ho fatto molto quando sono stato chiamato per ottimizzare basi di codice legacy, e questo può finire per richiedere mesi di riscrittura di decine di migliaia di righe di codice con attenzione per utilizzare il design più voluminoso. Ciò influenza idealmente il modo in cui progettiamo le cose in anticipo, a condizione che possiamo prevedere un carico pesante.
Continuo a fare eco a questa risposta in un modo o nell'altro in molte domande relative alle prestazioni, e in particolare quelle relative al design orientato agli oggetti. Il design orientato agli oggetti può ancora essere compatibile con le esigenze di prestazioni più elevate, ma dobbiamo cambiare un po 'il modo di pensarci. Dobbiamo dare a quel ghepardo un po 'di spazio per correre il più velocemente possibile, e questo è spesso impossibile se progettiamo piccoli oggetti per adolescenti che a malapena memorizzano qualsiasi stato.