Artigo malsucedido sobre como acelerar a reflexão

Explicarei imediatamente o título do artigo. O plano original era dar conselhos bons e confiáveis ​​sobre como acelerar o uso da reflexão usando um exemplo simples, mas realista, mas durante o benchmarking descobriu-se que a reflexão não é tão lenta quanto eu pensava, o LINQ é mais lento do que nos meus pesadelos. Mas no final descobri que também cometi um erro nas medidas... Os detalhes desta história de vida estão abaixo do recorte e nos comentários. Sendo o exemplo bastante banal e implementado em princípio como normalmente se faz numa empresa, revelou-se uma demonstração de vida bastante interessante, ao que me parece: o impacto na velocidade do tema principal do artigo foi não é perceptível devido à lógica externa: Moq, Autofac, EF Core e outras “correias”.

Comecei a trabalhar com a impressão deste artigo: Por que o reflexo é lento

Como você pode ver, o autor sugere o uso de delegados compilados em vez de chamar diretamente métodos do tipo reflexão como uma ótima maneira de acelerar bastante o aplicativo. É claro que existe emissão de IL, mas gostaria de evitá-la, pois é a forma mais trabalhosa de realizar a tarefa, que está repleta de erros.

Considerando que sempre tive uma opinião semelhante sobre a velocidade da reflexão, não tive a intenção particular de questionar as conclusões do autor.

Muitas vezes encontro o uso ingênuo da reflexão na empresa. O tipo é obtido. Informações sobre a propriedade são obtidas. O método SetValue é chamado e todos ficam felizes. O valor chegou no campo alvo, todos ficam felizes. Pessoas muito inteligentes - idosos e líderes de equipe - escrevem suas extensões para objetos, baseando-se em uma implementação tão ingênua de mapeadores “universais” de um tipo para outro. A essência geralmente é esta: pegamos todos os campos, pegamos todas as propriedades, iteramos sobre elas: se os nomes dos membros do tipo corresponderem, executamos SetValue. De vez em quando pegamos exceções por erros onde não encontramos alguma propriedade em um dos tipos, mas mesmo aqui existe uma saída que melhora o desempenho. Experimente/pegue.

Já vi pessoas reinventarem analisadores e mapeadores sem estarem totalmente munidas de informações sobre como funcionam as máquinas que vieram antes delas. Já vi pessoas esconderem suas implementações ingênuas por trás de estratégias, por trás de interfaces, por trás de injeções, como se isso fosse desculpar a bacanal subsequente. Eu torci o nariz para tais constatações. Na verdade, não medi o vazamento real de desempenho e, se possível, simplesmente mudei a implementação para uma mais “ideal”, se conseguisse colocar as mãos nela. Portanto, as primeiras medições discutidas abaixo me confundiram seriamente.

Acho que muitos de vocês, lendo Richter ou outros ideólogos, se depararam com uma afirmação completamente justa de que a reflexão no código é um fenômeno que tem um impacto extremamente negativo no desempenho do aplicativo.

Chamar a reflexão força o CLR a passar por assemblies para encontrar o que precisa, extrair seus metadados, analisá-los, etc. Além disso, a reflexão ao percorrer sequências leva à alocação de uma grande quantidade de memória. Estamos usando memória, o CLR descobre o GC e os frisos começam. Deve ser visivelmente lento, acredite. As enormes quantidades de memória em servidores de produção modernos ou máquinas em nuvem não evitam grandes atrasos no processamento. Na verdade, quanto mais memória, maior a probabilidade de você OBSERVAR como o GC funciona. A reflexão é, em teoria, um pano vermelho extra para ele.

No entanto, todos nós usamos contêineres IoC e mapeadores de datas, cujo princípio de funcionamento também é baseado na reflexão, mas geralmente não há dúvidas sobre seu desempenho. Não, não porque a introdução de dependências e a abstração de modelos de contexto externos limitados sejam tão necessárias que tenhamos que sacrificar o desempenho em qualquer caso. Tudo é mais simples - isso realmente não afeta muito o desempenho.

O fato é que as estruturas mais comuns baseadas na tecnologia de reflexão usam todos os tipos de truques para trabalhar com ela de maneira mais otimizada. Geralmente isso é um cache. Normalmente são expressões e delegados compilados a partir da árvore de expressões. O mesmo automapper mantém um dicionário competitivo que combina tipos com funções que podem converter um em outro sem chamar reflexão.

Como isso é conseguido? Essencialmente, isso não difere da lógica que a própria plataforma usa para gerar código JIT. Quando um método é chamado pela primeira vez, ele é compilado (e, sim, esse processo não é rápido); nas chamadas subsequentes, o controle é transferido para o método já compilado e não haverá perdas significativas de desempenho.

No nosso caso, você também pode usar a compilação JIT e depois usar o comportamento compilado com o mesmo desempenho de suas contrapartes AOT. As expressões virão em nosso auxílio neste caso.

O princípio em questão pode ser resumidamente formulado da seguinte forma:
Você deve armazenar em cache o resultado final da reflexão como um delegado contendo a função compilada. Também faz sentido armazenar em cache todos os objetos necessários com informações de tipo nos campos do seu tipo, o trabalhador, que são armazenados fora dos objetos.

Há lógica nisso. O bom senso nos diz que se algo pode ser compilado e armazenado em cache, então isso deve ser feito.

Olhando para o futuro, deve-se dizer que o cache no trabalho com reflexão tem suas vantagens, mesmo que não se utilize o método proposto de compilação de expressões. Na verdade, aqui estou simplesmente repetindo as teses do autor do artigo a que me refiro acima.

Agora sobre o código. Vejamos um exemplo que se baseia na minha dor recente que tive que enfrentar numa produção séria de uma instituição de crédito séria. Todas as entidades são fictícias para que ninguém adivinhe.

Existe alguma essência. Que haja contato. Existem letras com corpo padronizado, a partir das quais o analisador e o hidratador criam esses mesmos contatos. Chegou uma carta, lemos, analisamos em pares de valores-chave, criamos um contato e salvamos no banco de dados.

É elementar. Digamos que um contato possua as propriedades Nome Completo, Idade e Telefone de Contato. Esses dados são transmitidos na carta. A empresa também deseja suporte para adicionar rapidamente novas chaves para mapear propriedades de entidades em pares no corpo da carta. Caso alguém tenha cometido um erro de digitação no template ou se antes do lançamento for necessário lançar com urgência o mapeamento de um novo parceiro, adaptando-se ao novo formato. Então podemos adicionar uma nova correlação de mapeamento como um datafix barato. Ou seja, um exemplo de vida.

Implementamos, criamos testes. Funciona.

Não vou fornecer o código: existem muitas fontes e estão disponíveis no GitHub através do link no final do artigo. Você pode carregá-los, torturá-los até ficarem irreconhecíveis e medi-los, como isso afetaria no seu caso. Darei apenas o código de dois métodos de modelo que distinguem o hidratante, que deveria ser rápido, do hidratante, que deveria ser lento.

A lógica é a seguinte: o método template recebe pares gerados pela lógica básica do analisador. A camada LINQ é o analisador e a lógica básica do hidrator, que faz uma solicitação ao contexto do banco de dados e compara as chaves com os pares do analisador (para essas funções existe código sem LINQ para comparação). A seguir, os pares são passados ​​para o método de hidratação principal e os valores dos pares são definidos para as propriedades correspondentes da entidade.

“Rápido” (prefixo Rápido nos benchmarks):

 protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var setterMapItem in _proprtySettersMap)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
                setterMapItem.Value(contact, correlation?.Value);
            }
            return contact;
        }

Como podemos ver, é usada uma coleção estática com propriedades setter - lambdas compilados que chamam a entidade setter. Criado pelo seguinte código:

        static FastContactHydrator()
        {
            var type = typeof(Contact);
            foreach (var property in type.GetProperties())
            {
                _proprtySettersMap[property.Name] = GetSetterAction(property);
            }
        }

        private static Action<Contact, string> GetSetterAction(PropertyInfo property)
        {
            var setterInfo = property.GetSetMethod();
            var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
            var paramEntity = Expression.Parameter(typeof(Contact), "entity");
            var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
            
            var lambda = (Expression<Action<Contact, string>>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);

            return lambda.Compile();
        }

Em geral é claro. Percorremos as propriedades, criamos delegados para eles que chamam setters e os salvamos. Então ligamos quando necessário.

“Lento” (prefixo Lento nos benchmarks):

        protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var property in _properties)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
                if (correlation?.Value == null)
                    continue;

                property.SetValue(contact, correlation.Value);
            }
            return contact;
        }

Aqui ignoramos imediatamente as propriedades e chamamos SetValue diretamente.

Para maior clareza e como referência, implementei um método ingênuo que grava os valores de seus pares de correlação diretamente nos campos da entidade. Prefixo – Manual.

Agora vamos pegar o BenchmarkDotNet e examinar o desempenho. E de repente... (spoiler - este não é o resultado correto, os detalhes estão abaixo)

Artigo malsucedido sobre como acelerar a reflexão

O que vemos aqui? Os métodos que carregam triunfantemente o prefixo Fast acabam sendo mais lentos em quase todas as passagens do que os métodos com o prefixo Slow. Isto é verdade tanto para a alocação quanto para a velocidade do trabalho. Por outro lado, uma implementação bonita e elegante de mapeamento usando métodos LINQ destinados a isso sempre que possível, pelo contrário, reduz bastante a produtividade. A diferença é de ordem. A tendência não muda com diferentes números de passagens. A única diferença está na escala. Com o LINQ é 4 a 200 vezes mais lento, há mais lixo aproximadamente na mesma escala.

ATUALIZADA

Eu não acreditei no que vi, mas o mais importante, nosso colega não acreditou nem nos meus olhos nem no meu código - Dmitry Tikhonov 0x1000000. Depois de verificar novamente minha solução, ele descobriu e apontou de maneira brilhante um erro que não percebi devido a uma série de alterações na implementação, do início ao fim. Depois de corrigir o bug encontrado na configuração do Moq, todos os resultados se encaixaram. De acordo com os resultados do novo teste, a tendência principal não muda - o LINQ ainda afeta mais o desempenho do que a reflexão. Porém, é bom que o trabalho de compilação de Expressões não seja feito em vão, e o resultado seja visível tanto na alocação quanto no tempo de execução. O primeiro lançamento, quando os campos estáticos são inicializados, é naturalmente mais lento para o método “rápido”, mas depois a situação muda.

Aqui está o resultado do reteste:

Artigo malsucedido sobre como acelerar a reflexão

Conclusão: ao usar a reflexão em uma empresa, não há necessidade especial de recorrer a truques - o LINQ consumirá mais produtividade. No entanto, em métodos de alta carga que exigem otimização, você pode salvar a reflexão na forma de inicializadores e compiladores delegados, que fornecerão lógica “rápida”. Desta forma você pode manter a flexibilidade de reflexão e a velocidade da aplicação.

O código de benchmark está disponível aqui. Qualquer um pode verificar minhas palavras:
Testes de reflexão Habra

PS: o código nos testes usa IoC, e nos benchmarks usa uma construção explícita. O fato é que na implementação final cortei todos os fatores que poderiam afetar o desempenho e tornar o resultado barulhento.

PPS: Obrigado ao usuário Dmitry Tikhonov @0x1000000 por descobrir meu erro na configuração do Moq, que afetou as primeiras medições. Se algum dos leitores tiver carma suficiente, por favor, curta. O homem parou, o homem leu, o homem verificou novamente e apontou o erro. Acho que isso é digno de respeito e simpatia.

PPPS: obrigado ao leitor meticuloso que descobriu o estilo e o design. Sou a favor da uniformidade e da conveniência. A diplomacia da apresentação deixa muito a desejar, mas levei em consideração as críticas. Peço o projétil.

Fonte: habr.com

Adicionar um comentário