Pero, ¿podría esta OOP ser una desventaja para el software basado en el rendimiento, es decir, qué tan rápido se ejecuta el programa?
A menudo sí !!! PERO...
En otras palabras, ¿podrían muchas referencias entre muchos objetos diferentes, o el uso de muchos métodos de muchas clases, dar como resultado una implementación "pesada"?
No necesariamente. Esto depende del idioma / compilador. Por ejemplo, un compilador optimizador de C ++, siempre que no use funciones virtuales, a menudo reducirá la sobrecarga de su objeto a cero. Puede hacer cosas como escribir un contenedor sobre un intpuntero inteligente allí o un puntero inteligente sobre un puntero antiguo que funciona tan rápido como el uso directo de estos tipos de datos antiguos.
En otros lenguajes como Java, hay un poco de sobrecarga en un objeto (a menudo bastante pequeño en muchos casos, pero astronómico en algunos casos raros con objetos realmente pequeños). Por ejemplo, Integeres considerablemente menos eficiente que int(toma 16 bytes en lugar de 4 en 64 bits). Sin embargo, esto no es solo un desperdicio descarado o algo por el estilo. A cambio, Java ofrece cosas como la reflexión sobre cada tipo definido por el usuario de manera uniforme, así como la capacidad de anular cualquier función que no esté marcada como final.
Sin embargo, tomemos el mejor de los casos: el compilador optimizador de C ++ que puede optimizar las interfaces de objetos hasta cero sobrecarga. Incluso entonces, la OOP a menudo degradará el rendimiento y evitará que alcance el pico. Eso puede sonar como una paradoja completa: ¿cómo podría ser? El problema radica en:
Diseño de interfaz y encapsulación
El problema es que incluso cuando un compilador puede aplastar la estructura de un objeto a cero sobrecarga (que al menos es muy cierto para optimizar los compiladores de C ++), el diseño de encapsulación e interfaz (y las dependencias acumuladas) de objetos de grano fino a menudo impedirán Representaciones de datos más óptimas para objetos que están destinados a ser agregados por las masas (que a menudo es el caso del software crítico para el rendimiento).
Toma este ejemplo:
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)
Digamos que nuestro patrón de acceso a la memoria es simplemente recorrer estas partículas secuencialmente y moverlas alrededor de cada cuadro repetidamente, rebotando en las esquinas de la pantalla y luego renderizando el resultado.
Ya podemos ver un deslumbrante relleno de 4 bytes por encima requerido para alinear el birthmiembro correctamente cuando las partículas se agregan contiguamente. Ya ~ 16.7% de la memoria se desperdicia con el espacio muerto utilizado para la alineación.
Esto puede parecer discutible porque tenemos gigabytes de DRAM en estos días. Sin embargo, incluso las máquinas más bestiales que tenemos hoy en día solo tienen apenas 8 megabytes cuando se trata de la región más lenta y grande de la caché de la CPU (L3). Cuanto menos podamos encajar allí, más pagaremos en términos de acceso DRAM repetido, y las cosas se volverán más lentas. De repente, perder el 16,7% de la memoria ya no parece un trato trivial.
Podemos eliminar fácilmente esta sobrecarga sin ningún impacto en la alineación 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)
Ahora hemos reducido la memoria de 24 megas a 20 megas. Con un patrón de acceso secuencial, la máquina ahora consumirá estos datos un poco más rápido.
Pero veamos este birthcampo un poco más de cerca. Digamos que registra el tiempo de inicio cuando nace (se crea) una partícula. Imagine que solo se accede al campo cuando se crea una partícula por primera vez, y cada 10 segundos para ver si una partícula debe morir y renacer en una ubicación aleatoria en la pantalla. En ese caso, birthes un campo frío. No se accede a él en nuestros bucles de rendimiento crítico.
Como resultado, los datos críticos de rendimiento real no son 20 megabytes, sino un bloque contiguo de 12 megabytes. ¡La memoria activa real a la que accedemos con frecuencia se ha reducido a la mitad de su tamaño! Espere aceleraciones significativas con respecto a nuestra solución original de 24 megabytes (no es necesario medirla, ya se ha hecho este tipo de cosas miles de veces, pero si tiene dudas, siéntase libre).
Sin embargo, note lo que hicimos aquí. Rompimos por completo la encapsulación de este objeto de partículas. Su estado ahora se divide entre Particlelos campos privados de un tipo y una matriz paralela separada. Y ahí es donde el diseño granular orientado a objetos se interpone en el camino.
No podemos expresar la representación de datos óptima cuando se limita al diseño de la interfaz de un solo objeto muy granular como una sola partícula, un solo píxel, incluso un solo vector de 4 componentes, posiblemente incluso un solo objeto "criatura" en un juego , etc. Se perderá la velocidad de un guepardo si está parado en una pequeña isla de 2 metros cuadrados, y eso es lo que a menudo hace el diseño orientado a objetos muy granular en términos de rendimiento. Limita la representación de datos a una naturaleza subóptima.
Para llevar esto más lejos, digamos que dado que solo estamos moviendo partículas, podemos acceder a sus campos x / y / z en tres bucles separados. En ese caso, podemos beneficiarnos de las intrínsecas SIMD de estilo SoA con registros AVX que pueden vectorizar 8 operaciones SPFP en paralelo. Pero para hacer esto, ahora debemos usar esta representación:
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)
Ahora estamos volando con la simulación de partículas, pero mira lo que sucedió con nuestro diseño de partículas. Se ha demolido por completo, y ahora estamos viendo 4 matrices paralelas y ningún objeto para agregarlas en absoluto. Nuestro Particlediseño orientado a objetos se ha vuelto sayonara.
Esto me sucedió muchas veces trabajando en campos críticos para el rendimiento donde los usuarios exigen velocidad, y solo la corrección es lo único que exigen más. Estos pequeños diseños orientados a objetos pequeños tuvieron que ser demolidos, y las roturas en cascada a menudo requerían que usáramos una estrategia de desaprobación lenta hacia el diseño más rápido.
Solución
El escenario anterior solo presenta un problema con los diseños granulares orientados a objetos. En esos casos, a menudo terminamos teniendo que demoler la estructura para expresar representaciones más eficientes como resultado de repeticiones de SoA, división de campo caliente / frío, reducción de relleno para patrones de acceso secuencial (el relleno a veces es útil para el rendimiento con acceso aleatorio patrones en casos de AoS, pero casi siempre un obstáculo para los patrones de acceso secuencial), etc.
Sin embargo, podemos tomar esa representación final en la que nos decidimos y aún modelar una interfaz orientada a objetos:
// 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)
};
Ahora estamos bien. Podemos obtener todas las golosinas orientadas a objetos que nos gustan. El guepardo tiene un país entero para cruzar tan rápido como pueda. Nuestros diseños de interfaz ya no nos atrapan en una esquina de cuello de botella.
ParticleSystempotencialmente puede incluso ser abstracto y usar funciones virtuales. Ahora es discutible, estamos pagando los gastos generales en el nivel de recolección de partículas en lugar de en un nivel por partícula . La sobrecarga es 1 / 1,000,000 de lo que sería de lo contrario si estuviéramos modelando objetos a nivel de partícula individual.
Así que esa es la solución en áreas verdaderamente críticas para el rendimiento que manejan una carga pesada, y para todo tipo de lenguajes de programación (esta técnica beneficia a C, C ++, Python, Java, JavaScript, Lua, Swift, etc.). Y no se puede etiquetar fácilmente como "optimización prematura", ya que esto se relaciona con el diseño y la arquitectura de la interfaz . No podemos escribir una base de código que modele una sola partícula como un objeto con una gran cantidad de dependencias del cliente en unParticle'sinterfaz pública y luego cambiar de opinión más tarde. Lo he hecho mucho cuando se me llama para optimizar las bases de código heredadas, y eso puede terminar tomando meses de reescribir cuidadosamente decenas de miles de líneas de código para usar el diseño más voluminoso. Esto idealmente afecta cómo diseñamos las cosas por adelantado, siempre que podamos anticipar una carga pesada.
Sigo haciendo eco de esta respuesta de una forma u otra en muchas preguntas de rendimiento, y especialmente en aquellas relacionadas con el diseño orientado a objetos. El diseño orientado a objetos aún puede ser compatible con las necesidades de rendimiento de mayor demanda, pero tenemos que cambiar un poco la forma en que pensamos al respecto. Tenemos que darle a ese guepardo algo de espacio para que corra lo más rápido posible, y eso a menudo es imposible si diseñamos pequeños objetos que apenas almacenan ningún estado.