Struct e readonly: como evitar a degradação do desempenho

Às vezes, o uso do tipo Struct e do modificador readonly pode causar degradação no desempenho. Hoje falaremos sobre como evitar isso usando um analisador de código-fonte aberto - ErrorProne.NET.



Como você provavelmente sabe das minhas publicações anteriores, " O modificador 'in' e as estruturas somente leitura em C # " ("O modificador nas estruturas e somente leitura em C #") e " Interceptações de desempenho de locais de referência e retornos de ref em C # " (" Armadilhas de desempenho ao usar variáveis ​​locais e retornar valores com o modificador ref)), trabalhar com estruturas é mais difícil do que parece. Deixando de lado a questão da mutabilidade, observo que o comportamento de estruturas com modificador somente leitura (somente leitura) e sem ele em contextos somente leitura varia muito.

Supõe-se que as estruturas sejam usadas em scripts de programação que exijam alto desempenho e, para trabalhar efetivamente com elas, você deve saber algo sobre as várias operações ocultas geradas pelo compilador para garantir que a estrutura permaneça inalterada.

Aqui está uma breve lista de cuidados que você deve lembrar:

  • O uso de grandes estruturas que são passadas ou retornadas por valor pode causar problemas de desempenho nos caminhos críticos de execução do programa.
  • xY faz com que uma cópia protetora de x seja criada se:
    • x é um campo somente leitura;
    • o tipo x é uma estrutura sem modificador somente leitura;
    • Y não é um campo.

As mesmas regras funcionam se x for um parâmetro com o modificador in, uma variável local com o modificador readonly ref ou o resultado da chamada de um método que retorne um valor por meio de uma referência readonly.

Aqui estão algumas regras a serem lembradas. E, o mais importante, o código que se baseia nessas regras é muito frágil (ou seja, as alterações feitas no código imediatamente produzem alterações significativas em outras partes do código ou da documentação - aprox. Transl.). Quantas pessoas perceberão que a substituição do public readonly int X ; no public int X { get; } public int X { get; } em uma estrutura frequentemente usada sem modificador somente leitura afeta significativamente o desempenho? Ou quão fácil é ver que a passagem de um parâmetro usando o modificador in em vez de a passagem por valor pode diminuir o desempenho? Isso é realmente possível ao usar a propriedade in de um parâmetro em um loop, quando uma cópia protetora é criada a cada iteração.

Tais propriedades das estruturas apelam literalmente ao desenvolvimento de analisadores. E a ligação foi ouvida. Conheça o ErrorProne.NET - um conjunto de analisadores que informa sobre a possibilidade de alterar o código do programa para melhorar seu design e desempenho ao trabalhar com estruturas.

Análise de código com saída de mensagem "Tornar a estrutura X somente leitura"


A melhor maneira de evitar erros sutis e impactos negativos no desempenho ao usar estruturas é torná-las somente leitura sempre que possível. O modificador somente leitura na declaração da estrutura expressa claramente a intenção do desenvolvedor (enfatizando que a estrutura é imutável) e ajuda o compilador a evitar cópias de segurança em muitos dos contextos mencionados acima.



Declarar uma estrutura somente leitura não viola a integridade do código. Você pode executar com segurança o fixador (o processo de correção do código) no modo em lote e declarar todas as estruturas de toda a solução de software como somente leitura.

Simpatia pelo modificador readonly ref


O próximo passo é avaliar a segurança do uso de novos recursos (no modificador, variáveis ​​locais de leitura, variáveis ​​ref, etc.). Isso significa que o compilador não criará cópias protetoras ocultas que podem reduzir o desempenho.

Três tipos de tipos podem ser considerados:

  • ref estruturas amigáveis ​​somente para leitura, cujo uso nunca leva à criação de cópias protetoras;
  • estruturas que não são fáceis de refazer somente leitura, cujo uso no contexto de somente leitura sempre leva à criação de cópias protetoras;
  • estruturas neutras - estruturas cujo uso pode dar origem a cópias protetoras, dependendo do membro usado no contexto somente leitura.

A primeira categoria inclui estruturas somente leitura e estruturas POCO. O compilador nunca irá gerar uma cópia protetora se a estrutura for somente leitura. Também é seguro usar estruturas POCO no contexto somente leitura: o acesso aos campos é considerado seguro e nenhuma cópia protetora é criada.

A segunda categoria é estruturas sem modificador somente leitura que não contêm campos abertos. Nesse caso, qualquer acesso ao membro público no contexto somente leitura causará a criação de uma cópia protetora.

A última categoria são estruturas com campos públicos ou internos e propriedades ou métodos públicos ou internos. Nesse caso, o compilador cria cópias protetoras, dependendo do membro usado.

Essa separação ajuda a gerar avisos instantaneamente se a estrutura "hostil" for passada com o modificador in, armazenada na variável local ref somente leitura, etc.



O analisador não exibe um aviso se a estrutura "hostil" for usada como um campo somente leitura, pois não há alternativa nesse caso. Os modificadores in e ref readonly foram projetados para serem otimizados especificamente para evitar a criação de cópias redundantes. Se a estrutura for "hostil" com relação a esses modificadores, você terá outras opções: passar um argumento por valor ou salvar uma cópia em uma variável local. Nesse sentido, os campos somente leitura se comportam de maneira diferente: se você deseja tornar o tipo imutável, deve usar esses campos. Lembre-se: o código deve ser claro e elegante, e apenas secundariamente rápido.

Análise Cco


O compilador executa muitas ações ocultas do usuário. Como mostrado em uma postagem anterior, é muito difícil ver quando uma cópia protetora está sendo criada.

O analisador detecta as seguintes cópias ocultas:

  1. Cco do campo somente leitura.
  2. Cco de in.
  3. Cco da variável local somente leitura ref.
  4. Cco retornar ref somente leitura.
  5. Cco ao chamar um método de extensão que aceita um parâmetro com esse modificador por valor para uma instância da estrutura.

 public struct NonReadOnlyStruct { public readonly long PublicField; public long PublicProperty { get; } public void PublicMethod() { } private static readonly NonReadOnlyStruct _ros; public static void Samples(in NonReadOnlyStruct nrs) { // Ok. Public field access causes no hidden copies var x = nrs.PublicField; // Ok. No hidden copies. x = _ros.PublicField; // Hidden copy: Property access on 'in'-parameter x = nrs.PublicProperty; // Hidden copy: Method call on readonly field _ros.PublicMethod(); ref readonly var local = ref nrs; // Hidden copy: method call on ref readonly local local.PublicMethod(); // Hidden copy: method call on ref readonly return Local().PublicMethod(); ref readonly NonReadOnlyStruct Local() => ref _ros; } } 

Observe que os analisadores exibem mensagens de diagnóstico apenas se o tamanho da estrutura for ≥ 16 bytes.

Usando analisadores em projetos reais


A transferência de grandes estruturas por valor e, como resultado, a criação de cópias protetoras pelo compilador afetam significativamente o desempenho. Pelo menos isso é mostrado pelos resultados dos testes de desempenho. Mas como esses fenômenos afetarão aplicações reais em termos de tempo de ponta a ponta?

Para testar os analisadores usando código real, usei-os em dois projetos: o projeto Roslyn e o projeto interno no qual estou trabalhando atualmente na Microsoft (o projeto é um aplicativo de computador autônomo com requisitos rígidos de desempenho); vamos chamá-lo de "Projeto D" para maior clareza.

Aqui estão os resultados:

  1. Projetos com requisitos de alto desempenho geralmente contêm muitas estruturas, e a maioria delas pode ser somente leitura. Por exemplo, no projeto Roslyn, o analisador encontrou cerca de 400 estruturas que podem ser apenas de leitura e, no projeto D, cerca de 300.
  2. Em projetos com requisitos de alto desempenho, cópias ocultas só devem ser criadas em situações excepcionais. Encontrei apenas alguns casos no projeto Roslyn, já que a maioria das estruturas possui campos públicos em vez de propriedades públicas. Isso evita a criação de cópias protetoras em situações em que as estruturas são armazenadas em campos somente leitura. Havia mais cópias ocultas no Projeto D, porque pelo menos metade delas possuía propriedades de obtenção apenas (acesso somente leitura).
  3. A transferência de estruturas razoavelmente grandes usando o modificador in provavelmente terá muito pouco efeito (quase imperceptível) no tempo de execução do programa.

Alterei todas as 300 estruturas no projeto D, tornando-as somente leitura e, em seguida, corrigi centenas de casos de uso, indicando que elas são passadas com o modificador in. Depois, medi o tempo de trânsito de ponta a ponta para vários cenários de desempenho. As diferenças foram estatisticamente insignificantes.

Isso significa que os recursos descritos acima são inúteis? Nem um pouco.

Trabalhar em um projeto com requisitos de alto desempenho (por exemplo, em Roslyn ou "Projeto D") implica que um grande número de pessoas gasta muito tempo em vários tipos de otimização. De fato, em alguns casos, estruturas em nosso código foram passadas com o modificador ref e alguns campos foram declarados sem o modificador somente leitura para excluir a geração de cópias protetoras. A falta de crescimento da produtividade durante a transferência de estruturas com o modificador in pode significar que o código foi bem otimizado e não há cópia excessiva de estruturas nos caminhos críticos de sua passagem.

O que devo fazer com esses recursos?


Acredito que a questão do uso do modificador readonly para estruturas não exija muita reflexão. Se a estrutura for imutável, o modificador somente leitura obriga explicitamente o compilador a tomar uma decisão de design. E a falta de cópias protetoras para essas estruturas é apenas um bônus.

Hoje, minhas recomendações são as seguintes: se a estrutura pode ser apenas de leitura, faça com que seja assim.

O uso das outras opções consideradas possui nuances.

Pré-otimização versus pré-pessimização?


Herb Sutter introduz o conceito de "pessimização preliminar" em seu livro incrível, C ++ Coding Standards: 101 Rule, Recomendations, and Best Practices .

“Ceteris paribus, complexidade e legibilidade do código, alguns padrões de design eficazes e idiomas de codificação devem drenar naturalmente da ponta dos dedos. Esse código não é mais difícil de escrever do que suas alternativas pessimizadas. Você não faz otimização preliminar, mas evita a pessimização voluntária. ”

Do meu ponto de vista, um parâmetro com o modificador in é apenas o caso. Se você souber que a estrutura é relativamente grande (40 bytes ou mais), sempre poderá transmiti-la com o modificador in. O custo do uso do modificador in é relativamente baixo, porque você não precisa ajustar as chamadas e os benefícios podem ser reais.

Por outro lado, para variáveis ​​locais e valores de retorno com o modificador ref somente leitura, este não é o caso. Eu diria que esses recursos devem ser usados ​​ao codificar bibliotecas, e é melhor recusá-los no código do aplicativo (somente se a criação de perfil do código não revelar que a operação de cópia é realmente um problema). O uso desses recursos requer um esforço adicional e fica mais difícil para o leitor de código entender.

Conclusão


  1. Use modificador somente leitura para estruturas sempre que possível.
  2. Considere usar o modificador in para estruturas grandes.
  3. Considere usar variáveis ​​locais e retornar valores com o modificador ref somente leitura para codificar bibliotecas ou nos casos em que os resultados da criação de perfil de código indiquem que isso pode ser útil.
  4. Use o ErrorProne.NET para detectar problemas de código e compartilhar os resultados.

Source: https://habr.com/ru/post/pt423053/


All Articles