Você sente que existe uma troca entre escrever código "bom" orientado a objeto e escrever código de baixa latência muito rápido? Por exemplo, evitando funções virtuais em C ++ / a sobrecarga do polimorfismo etc - reescrevendo código que parece desagradável, mas é muito rápido etc?
Eu trabalho em um campo que é um pouco mais focado na taxa de transferência do que na latência, mas é muito crítico para o desempenho e eu diria "meio que" .
No entanto, um problema é que muitas pessoas entendem completamente suas noções de desempenho. Os iniciantes geralmente entendem tudo errado e todo o seu modelo conceitual de "custo computacional" precisa ser reformulado, com apenas a complexidade algorítmica sendo a única coisa que eles podem acertar. Intermediários entendem muitas coisas erradas. Especialistas entendem algumas coisas erradas.
Medir com ferramentas precisas que podem fornecer métricas como falhas de cache e previsões incorretas de ramificações é o que mantém todas as pessoas com qualquer nível de conhecimento em campo sob controle.
Medir é também o que aponta o que não otimizar . Os especialistas costumam gastar menos tempo otimizando do que os novatos, já que estão otimizando pontos de acesso medidos verdadeiros e não tentando otimizar facadas selvagens no escuro com base em palpites sobre o que poderia ser lento (o que, de forma extrema, poderia tentar micro-otimizar apenas sobre todas as outras linhas da base de código).
Projetando para o desempenho
Com isso de lado, a chave para o design de desempenho vem da parte do design , como no design de interface. Um dos problemas com a inexperiência é que tende a haver uma mudança precoce nas métricas absolutas de implementação, como o custo de uma chamada de função indireta em algum contexto generalizado, como se o custo (que é melhor compreendido em sentido imediato do ponto de vista de um otimizador) ponto de vista de ramificação) é um motivo para evitá-lo por toda a base de código.
Os custos são relativos . Embora exista um custo para uma chamada de função indireta, por exemplo, todos os custos são relativos. Se você está pagando esse custo uma vez para chamar uma função que percorre milhões de elementos, se preocupar com esse custo é como passar horas pechinchando moedas de um centavo para a compra de um produto de um bilhão de dólares, apenas para concluir que não o comprará porque era um centavo muito caro.
Design de interface mais grosseiro
O aspecto do design de interface do desempenho geralmente busca elevar esses custos a um nível mais grosso. Em vez de pagar os custos de abstração de tempo de execução para uma única partícula, por exemplo, podemos elevar esse custo ao nível do sistema / emissor de partículas, tornando efetivamente uma partícula em um detalhe de implementação e / ou simplesmente dados brutos dessa coleção de partículas.
Portanto, o design orientado a objetos não precisa ser incompatível com o design para desempenho (latência ou taxa de transferência), mas pode haver tentações em uma linguagem focada nele para modelar objetos granulares cada vez menores, e o otimizador mais recente não pode Socorro. Ele não pode fazer coisas como unir uma classe que representa um único ponto de uma maneira que produz uma representação SoA eficiente para os padrões de acesso à memória do software. Uma coleção de pontos com o design de interface modelado no nível de grosseria oferece essa oportunidade e permite a iteração em direção a soluções cada vez mais otimizadas, conforme necessário. Esse design foi desenvolvido para memória em massa *.
* Observe o foco na memória aqui e não nos dados , pois trabalhar em áreas críticas de desempenho por muito tempo tenderá a mudar sua visão dos tipos e estruturas de dados e ver como eles se conectam à memória. Uma árvore de pesquisa binária não se torna mais apenas sobre complexidade logarítmica em casos como pedaços de memória possivelmente díspares e pouco amigáveis ao cache para nós de árvore, a menos que auxiliados por um alocador fixo. A exibição não descarta a complexidade algorítmica, mas não a vê mais independentemente dos layouts de memória. Também se começa a ver as iterações de trabalho como sendo mais sobre as iterações de acesso à memória. *
Muitos projetos críticos para o desempenho podem realmente ser muito compatíveis com a noção de design de interface de alto nível que é fácil para os seres humanos entenderem e usarem. A diferença é que "alto nível" nesse contexto seria sobre agregação em massa de memória, uma interface modelada para coleções de dados potencialmente grandes e com uma implementação oculta que pode ser de nível bastante baixo. Uma analogia visual pode ser um carro realmente confortável, fácil de dirigir, manusear e muito seguro, na velocidade do som, mas se você abrir o capô, haverá pequenos demônios que cospem fogo no interior.
Com um design mais grosso, também tende a ser uma maneira mais fácil de fornecer padrões de bloqueio mais eficientes e explorar o paralelismo no código (multithreading é um assunto exaustivo que eu vou pular aqui).
Conjunto de memórias
Um aspecto crítico da programação de baixa latência provavelmente será um controle muito explícito sobre a memória para melhorar a localidade de referência, bem como apenas a velocidade geral de alocar e desalocar memória. Na verdade, uma memória de pool de alocador personalizado ecoa o mesmo tipo de mentalidade de design que descrevemos. Ele foi projetado para granel ; foi projetado em um nível aproximado. Ele pré-aloca a memória em grandes blocos e agrupa a memória já alocada em pequenos blocos.
A idéia é exatamente a mesma de empurrar coisas caras (alocar um pedaço de memória contra um alocador de uso geral, por exemplo) para um nível cada vez mais grosso. Um conjunto de memórias foi projetado para lidar com memória em massa .
Sistemas de tipo segregam memória
Uma das dificuldades do design granular orientado a objetos em qualquer idioma é que ele geralmente quer introduzir muitos tipos e estruturas de dados definidas pelo usuário. Esses tipos podem querer ser alocados em pequenos pedaços pequenos, se forem alocados dinamicamente.
Um exemplo comum em C ++ seria nos casos em que o polimorfismo é necessário, onde a tentação natural é alocar cada instância de uma subclasse contra um alocador de memória de uso geral.
Isso acaba desmembrando layouts de memória possivelmente contíguos em pequenos bits e pedaços espalhados pelo intervalo de endereços, o que se traduz em mais falhas de página e falhas de cache.
Os campos que exigem a resposta determinística de menor latência, sem gagueira são provavelmente o único lugar onde os pontos de acesso nem sempre se resumem a um único gargalo, onde pequenas ineficiências podem realmente realmente se "acumular" (algo que muitas pessoas imaginam acontecendo incorretamente com um criador de perfil para mantê-los sob controle, mas em campos controlados por latência, pode haver alguns casos raros em que pequenas ineficiências se acumulam). E muitas das razões mais comuns para esse acúmulo podem ser as seguintes: a alocação excessiva de pequenos pedaços de memória em todo o lugar.
Em linguagens como Java, pode ser útil usar mais matrizes de tipos de dados antigos simples quando possível para áreas de gargalo (áreas processadas em loops apertados), como uma matriz de int
(mas ainda por trás de uma interface de alto nível volumosa) em vez de, digamos , um ArrayList
dos Integer
objetos definidos pelo usuário . Isso evita a segregação de memória que normalmente acompanha o último. No C ++, não precisamos degradar a estrutura tanto se nossos padrões de alocação de memória forem eficientes, pois os tipos definidos pelo usuário podem ser alocados de forma contígua e mesmo no contexto de um contêiner genérico.
Fundindo a memória novamente
Uma solução aqui é buscar um alocador personalizado para tipos de dados homogêneos e, possivelmente, mesmo entre tipos de dados homogêneos. Quando pequenos tipos de dados e estruturas de dados são achatadas em bits e bytes na memória, elas assumem uma natureza homogênea (embora com alguns requisitos variados de alinhamento). Quando não os olhamos de uma mentalidade centrada na memória, o sistema de tipos de linguagens de programação "deseja" dividir / segregar regiões de memória potencialmente contíguas em pequenos pedaços dispersos.
A pilha utiliza esse foco centralizado na memória para evitar isso e potencialmente armazena qualquer combinação mista possível de instâncias do tipo definido pelo usuário. Utilizar mais a pilha é uma ótima idéia, quando possível, pois quase sempre ela fica em uma linha de cache, mas também podemos projetar alocadores de memória que imitam algumas dessas características sem um padrão LIFO, fundindo a memória entre tipos de dados diferentes em tipos de dados contíguos. até mesmo para padrões mais complexos de alocação e desalocação de memória.
O hardware moderno foi projetado para atingir seu pico ao processar blocos contíguos de memória (acessando repetidamente a mesma linha de cache, a mesma página, por exemplo). A palavra-chave existe contiguidade, pois isso só é benéfico se houver dados de interesse ao redor. Portanto, grande parte da chave (mas também dificuldade) do desempenho é reunir novamente pedaços de memória segregados em blocos contíguos que são acessados na íntegra (todos os dados ao redor são relevantes) antes da remoção. O sistema de tipos avançados de tipos especialmente definidos pelo usuário em linguagens de programação pode ser o maior obstáculo aqui, mas sempre podemos procurar e resolver o problema por meio de um alocador personalizado e / ou projetos mais volumosos, quando apropriado.
Feio
"Feio" é difícil de dizer. É uma métrica subjetiva, e alguém que trabalha em um campo muito crítico de desempenho começará a mudar sua idéia de "beleza" para uma que é muito mais orientada a dados e se concentra nas interfaces que processam as coisas em massa.
Perigoso
"Perigoso" pode ser mais fácil. Em geral, o desempenho tende a alcançar um código de nível inferior. A implementação de um alocador de memória, por exemplo, é impossível sem chegar abaixo dos tipos de dados e trabalhar no nível perigoso de bits e bytes brutos. Como resultado, pode ajudar a aumentar o foco em procedimentos de teste cuidadosos nesses subsistemas críticos para o desempenho, dimensionando a profundidade dos testes com o nível de otimizações aplicado.
Beleza
No entanto, tudo isso estaria no nível de detalhes da implementação. Tanto em uma mentalidade veterana em larga escala quanto em crítica ao desempenho, a "beleza" tende a mudar para designs de interface, em vez de detalhes de implementação. Torna-se uma prioridade exponencialmente mais alta buscar interfaces "bonitas", utilizáveis, seguras e eficientes, em vez de implementações devido a falhas de acoplamento e cascata que podem ocorrer diante de uma alteração no design da interface. As implementações podem ser trocadas a qualquer momento. Normalmente, iteramos para o desempenho conforme necessário e conforme indicado pelas medições. A chave do design da interface é modelar em um nível grosso o suficiente para deixar espaço para essas iterações sem interromper o sistema inteiro.
De fato, eu sugeriria que o foco de um veterano no desenvolvimento crítico de desempenho tende a colocar predominantemente um foco predominante em segurança, testes, manutenibilidade, apenas o discípulo da SE em geral, uma vez que uma base de código em larga escala que possui vários desempenhos Os subsistemas críticos (sistemas de partículas, algoritmos de processamento de imagem, processamento de vídeo, feedback de áudio, rastreadores de raios, mecanismos de malha etc.) precisarão prestar muita atenção à engenharia de software para evitar afogamentos em um pesadelo de manutenção. Não é por mera coincidência que muitas vezes os produtos mais surpreendentemente eficientes do mercado também podem ter o menor número de bugs.
TL; DR
De qualquer forma, essa é minha opinião sobre o assunto, variando de prioridades em campos genuinamente críticos para o desempenho, o que pode reduzir a latência e fazer com que pequenas ineficiências se acumulem, e o que realmente constitui "beleza" (quando se olha para as coisas de maneira mais produtiva).