Mas esse POO poderia ser uma desvantagem para o software baseado no desempenho, ou seja, com que rapidez o programa é executado?
Muitas vezes sim !!! MAS...
Em outras palavras, muitas referências entre muitos objetos diferentes ou o uso de muitos métodos de várias classes resultam em uma implementação "pesada"?
Não necessariamente. Isso depende do idioma / compilador. Por exemplo, um compilador C ++ otimizado, desde que você não use funções virtuais, geralmente reduz o zero da sobrecarga do objeto. Você pode fazer coisas como escrever um invólucro em um int
local ou um ponteiro inteligente com escopo definido em um ponteiro antigo simples, que executa tão rápido quanto usar esses tipos de dados antigos simples diretamente.
Em outras linguagens como Java, há um pouco de sobrecarga em um objeto (geralmente bastante pequeno em muitos casos, mas astronômico em alguns casos raros com objetos realmente pequenininhos). Por exemplo, Integer
há consideravelmente menos eficiente que int
(leva 16 bytes em vez de 4 em 64 bits). No entanto, isso não é apenas desperdício flagrante ou qualquer coisa desse tipo. Em troca, Java oferece coisas como reflexão sobre cada tipo definido pelo usuário de maneira uniforme, bem como a capacidade de substituir qualquer função não marcada como final
.
No entanto, vamos considerar o melhor cenário: o compilador C ++ otimizado, que pode otimizar as interfaces de objetos até a sobrecarga zero . Mesmo assim, o POO geralmente prejudicará o desempenho e impedirá que ele atinja o pico. Isso pode parecer um paradoxo completo: como poderia ser? O problema está em:
Design de interface e encapsulamento
O problema é que, mesmo quando um compilador pode esmagar a estrutura de um objeto até zero de sobrecarga (o que é pelo menos muitas vezes verdadeiro para otimizar compiladores C ++), o encapsulamento e o design de interface (e dependências acumuladas) de objetos refinados geralmente impedem o representações de dados mais ideais para objetos que devem ser agregados pelas massas (que geralmente é o caso de software crítico para o desempenho).
Veja este exemplo:
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 nosso padrão de acesso à memória seja simplesmente percorrer seqüencialmente essas partículas e movê-las repetidamente em cada quadro, saltando nos cantos da tela e renderizando o resultado.
Já podemos ver uma sobrecarga gritante de 4 bytes necessária para alinhar o birth
membro adequadamente quando as partículas são agregadas de forma contígua. Já ~ 16,7% da memória é desperdiçada com o espaço morto usado para alinhamento.
Isso pode parecer discutível, porque temos gigabytes de DRAM atualmente. No entanto, mesmo as máquinas mais bestiais que temos hoje geralmente têm apenas 8 megabytes quando se trata da região mais lenta e maior do cache da CPU (L3). Quanto menos cabermos lá, mais pagaremos por isso em termos de acesso repetido à DRAM, e as coisas ficarão mais lentas. De repente, desperdiçar 16,7% da memória não parece mais um negócio trivial.
Podemos facilmente eliminar essa sobrecarga sem nenhum impacto no alinhamento do 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)
Agora reduzimos a memória de 24 megas para 20 megas. Com um padrão de acesso seqüencial, a máquina agora consumirá esses dados um pouco mais rápido.
Mas vamos olhar para este birth
campo um pouco mais de perto. Digamos que registre o horário de início em que uma partícula nasce (criada). Imagine que o campo seja acessado apenas quando uma partícula é criada pela primeira vez e a cada 10 segundos para ver se uma partícula deve morrer e renascer em um local aleatório na tela. Nesse caso, birth
é um campo frio. Ele não é acessado em nossos loops de desempenho crítico.
Como resultado, os dados críticos de desempenho reais não são 20 megabytes, mas na verdade um bloco contíguo de 12 megabytes. A memória quente real que estamos acessando com frequência diminuiu para metade do seu tamanho! Espere acelerações significativas em relação à nossa solução original de 24 megabytes (não precisa ser medida - já fiz esse tipo de coisa mil vezes, mas fique à vontade em caso de dúvida).
No entanto, observe o que fizemos aqui. Nós quebramos completamente o encapsulamento desse objeto de partícula. Seu estado agora está dividido entre Particle
os campos privados de um tipo e uma matriz paralela separada. E é aí que o design granular orientado a objetos atrapalha.
Não podemos expressar a representação ideal dos dados quando confinados ao design da interface de um único objeto muito granular, como uma única partícula, um único pixel, até um único vetor de 4 componentes, possivelmente até um único objeto de "criatura" em um jogo. , etc. A velocidade de uma chita será desperdiçada se ela estiver em uma ilha pequenina de 2 metros quadrados, e é isso que o design orientado a objetos muito granular costuma fazer em termos de desempenho. Limita a representação de dados a uma natureza subótima.
Para levar isso adiante, digamos que, como estamos apenas movendo partículas, podemos acessar seus campos x / y / z em três loops separados. Nesse caso, podemos nos beneficiar das intrínsecas SIMD do estilo SoA com registros AVX que podem vetorizar 8 operações SPFP em paralelo. Mas, para fazer isso, precisamos agora usar esta representação:
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)
Agora estamos voando com a simulação de partículas, mas veja o que aconteceu com o nosso design de partículas. Ele foi completamente demolido, e agora estamos olhando para 4 matrizes paralelas e nenhum objeto para agregá-las. Nosso Particle
design orientado a objetos se tornou sayonara.
Isso aconteceu comigo muitas vezes trabalhando em campos críticos de desempenho, em que os usuários exigem velocidade, sendo apenas a correção a única coisa que exigem mais. Esses pequenos projetos orientados a objetos tiveram que ser demolidos, e as quebras em cascata frequentemente exigiam o uso de uma estratégia de depreciação lenta para o design mais rápido.
Solução
O cenário acima apenas apresenta um problema com projetos orientados a objetos granulares . Nesses casos, muitas vezes acabamos tendo que demolir a estrutura para expressar representações mais eficientes como resultado de representantes de SoA, divisão de campo quente / frio, redução de preenchimento para padrões de acesso sequencial (o preenchimento às vezes é útil para desempenho com acesso aleatório padrões em casos de AoS, mas quase sempre um obstáculo para padrões de acesso seqüencial), etc.
No entanto, podemos pegar a representação final em que estabelecemos e ainda modelar uma interface 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)
};
Agora estamos bem. Podemos obter todos os itens orientados a objetos que gostamos. A chita tem um país inteiro para atravessar o mais rápido possível. Nossos designs de interface não nos prendem mais a um ponto de gargalo.
ParticleSystem
potencialmente pode ser abstrato e usar funções virtuais. É discutível agora, estamos pagando a sobrecarga no nível de coleta de partículas em vez de no nível por partícula . A sobrecarga é 1 / 1.000.000º do que seria de outra forma se estivéssemos modelando objetos no nível de partículas individuais.
Portanto, essa é a solução em verdadeiras áreas críticas de desempenho que lidam com uma carga pesada e para todos os tipos de linguagens de programação (essa técnica beneficia C, C ++, Python, Java, JavaScript, Lua, Swift, etc.). E não pode ser rotulado facilmente como "otimização prematura", pois isso se refere ao design e arquitetura da interface . Não podemos escrever uma base de código modelando uma única partícula como um objeto com um monte de dependências do cliente para umParticle's
interface pública e depois mudar de idéia mais tarde. Eu fiz muito isso ao ser chamado para otimizar as bases de código herdadas, e isso pode levar meses a reescrever dezenas de milhares de linhas de código com cuidado para usar o design mais volumoso. Isso afeta idealmente como projetamos as coisas antecipadamente, desde que possamos antecipar uma carga pesada.
Eu continuo ecoando essa resposta de uma forma ou de outra em muitas questões de desempenho, e especialmente aquelas relacionadas ao design orientado a objetos. O design orientado a objetos ainda pode ser compatível com as necessidades de desempenho de maior demanda, mas precisamos mudar um pouco a maneira de pensar sobre isso. Temos que dar a esse guepardo algum espaço para correr o mais rápido possível, e isso geralmente é impossível se projetarmos pequenos objetos que mal armazenam qualquer estado.