Gravar código gerenciado mais rápido: conheça o custo das coisas
Jan Gray
Equipe de Desempenho do Microsoft CLR
Junho de 2003
Aplica-se a:
Microsoft® .NET Framework
Resumo: Este artigo apresenta um modelo de custo de baixo nível para o tempo de execução do código gerenciado, com base nos tempos de operação medidos, para que os desenvolvedores possam tomar decisões de codificação mais bem informadas e escrever um código mais rápido. (30 páginas impressas)
Baixe o CLR Profiler. (330 KB)
Sumário
Introdução (e Promessa)
Para um modelo de custo para código gerenciado
Qual é o custo das coisas no código gerenciado
Conclusão
Recursos
Introdução (e Promessa)
Há inúmeras maneiras de implementar uma computação, e algumas são muito melhores do que outras: mais simples, mais limpa, mais fácil de manter. Algumas maneiras são extremamente rápidas e outras são surpreendentemente lentas.
Não perpetra código lento e gordo no mundo. Você não despreza esse código? Código executado em ajustes e inícios? Código que bloqueia a interface do usuário por segundos no tempo? Código que prende a CPU ou bate no disco?
Não faça isso. Em vez disso, levante-se e prometa junto comigo:
"Prometo que não enviarei código lento. Velocidade é um recurso com o qual me importo. Todos os dias prestarei atenção ao desempenho do meu código. Medirei regularmente e metodicamente sua velocidade e tamanho. Vou aprender, construir ou comprar as ferramentas que preciso para fazer isso. É minha responsabilidade."
(Realmente.) Então você prometeu? Bom para você.
Então , como você escreve o código mais rápido e apertado dia após dia? É uma questão de escolher conscientemente a maneira frugal em preferência à maneira extravagante e inchada, de novo e de novo, e uma questão de pensar através das consequências. Qualquer determinada página de código captura dezenas de decisões tão pequenas.
Mas você não poderá fazer escolhas inteligentes entre alternativas se não souber quanto custam as coisas: você não poderá escrever um código eficiente se não souber a custo das coisas.
Era mais fácil nos bons velhos tempos. Bons programadores C sabiam. Cada operador e operação em C, seja atribuição, matemática de ponto flutuante ou inteiro, desreferência ou chamada de função, mapeou mais ou menos um para um para uma única operação de computador primitivo. True, às vezes várias instruções de máquina eram necessárias para colocar os operandos certos nos registros certos, e às vezes uma única instrução poderia capturar várias operações C (famosamente *dest++ = *src++;
), mas você geralmente poderia escrever (ou ler) uma linha de código C e saber para onde o tempo estava indo. Para código e dados, o compilador C era WYWIWYG— "o que você escreve é o que você obtém". (A exceção era e é, chamadas de função. Se você não sabe quanto custa a função, não sabe o que é diddly.)
Na anos 1990, para aproveitar os muitos benefícios de engenharia de software e produtividade da abstração de dados, programação orientada a objetos e reutilização de código, o setor de software de computador fez uma transição de C para C++.
O C++ é um superconjunto de C e é "pago conforme o uso" — os novos recursos não custam nada se você não usá-los — portanto, a experiência em programação C, incluindo o modelo de custo internalizado, é diretamente aplicável. Se você pegar algum código C funcional e recompilá-lo para C++, o tempo de execução e a sobrecarga de espaço não deverão mudar muito.
Por outro lado, o C++ apresenta muitos novos recursos de linguagem, incluindo construtores, destruidores, novo, exclusão, herança única, múltipla e virtual, conversões, funções membro, funções virtuais, operadores sobrecarregados, ponteiros para membros, matrizes de objetos, tratamento de exceção e composições do mesmo, que incorrem em custos ocultos não triviais. Por exemplo, as funções virtuais custam duas indireções extras por chamada e adicionam um campo de ponteiro vtable oculto a cada instância. Ou considere que esse código de aparência inócua:
{ complex a, b, c, d; … a = b + c * d; }
compila em aproximadamente treze chamadas de função de membro implícitas (espero que embutidas).
Há nove anos exploramos esse assunto no meu artigo C++: Sob os Bastidores. Eu escrevi:
"É importante entender como sua linguagem de programação é implementada. Tal conhecimento dissipa o medo e a maravilha de "O que o compilador está fazendo aqui?"; fornece confiança para usar os novos recursos; e fornece insights ao depurar e aprender outros recursos de linguagem. Ele também dá uma ideia dos custos relativos de diferentes opções de codificação necessárias para escrever o código mais eficiente no dia a dia."
Agora vamos dar uma olhada semelhante no código gerenciado. Este artigo explora os custos de tempo e espaço de baixo nível da execução gerenciada, para que possamos fazer compensações mais inteligentes em nossa codificação diária.
E mantenha nossas promessas.
Por que código gerenciado?
Para a grande maioria dos desenvolvedores de código nativo, o código gerenciado é uma plataforma melhor e mais produtiva para executar seu software. Ele remove categorias inteiras de bugs, como corrupção de heap e erros de matriz-índice fora do limite que muitas vezes levam a sessões frustrantes de depuração noturna. Ele dá suporte a requisitos modernos, como código móvel seguro (por meio de segurança de acesso de código) e serviços Web XML, e em comparação com o antigo Win32/COM/ATL/MFC/VB, o .NET Framework é uma atualização limpo design de ardósia, onde você pode fazer mais com menos esforço.
Para sua comunidade de usuários, o código gerenciado permite aplicativos mais avançados e robustos, melhor vivendo um software melhor.
Qual é o segredo para escrever um código gerenciado mais rápido?
Só porque você pode fazer mais com menos esforço não é uma licença para abdicar de sua responsabilidade de codificar sabiamente. Primeiro, você deve admitir para si mesmo: "Eu sou um novato." Você é um novato. Eu também sou novato. Somos todos bebês em terras de código gerenciadas. Ainda estamos aprendendo as cordas, incluindo o custo das coisas.
Quando se trata dos .NET Framework ricos e convenientes, é como se fossemos crianças na loja de doces. "Uau, eu não tenho que fazer todas essas coisas tediosas strncpy
, eu posso apenas '+' cadeias de caracteres juntos! Uau, eu posso carregar um megabyte de XML em algumas linhas de código! Whoo-hoo!
É tudo tão fácil. Tão fácil, de fato. Tão fácil de queimar megabytes de conjuntos de informações XML de análise de RAM apenas para extrair alguns elementos deles. Em C ou C++ foi tão doloroso que você pensaria duas vezes, talvez você criasse uma máquina de estado em alguma API semelhante ao SAX. Com o .NET Framework, basta carregar todo o conjunto de informações em um gulp. Talvez você até faça isso uma e outra vez. Então talvez seu aplicativo não pareça mais tão rápido. Talvez tenha um conjunto de trabalho de muitos megabytes. Talvez você devesse ter pensado duas vezes sobre o que esses métodos fáceis custam...
Infelizmente, na minha opinião, a documentação atual do .NET Framework não detalha adequadamente as implicações de desempenho de tipos e métodos do Framework, nem mesmo especifica quais métodos podem criar novos objetos. A modelagem de desempenho não é um assunto fácil de cobrir ou documentar; mas ainda assim, o "não saber" torna muito mais difícil para nós tomar decisões informadas.
Já que somos todos novatos aqui, e como não sabemos o que custa nada, e como os custos não estão claramente documentados, o que devemos fazer?
Meça isso. O segredo é medi-lo e ficar atento. Todos nós vamos ter que ter o hábito de medir o custo das coisas. Se tivermos o problema de medir o custo das coisas, então não seremos os que inadvertidamente chamarão um novo método zunindo que custa dez vezes o que assumimos que custa.
(A propósito, para obter informações mais profundas sobre os fundamentos de desempenho da BCL (biblioteca de classes base) ou do próprio CLR, considere dar uma olhada na CLI de Origem Compartilhada, também conhecida como Rotor. O código rotor compartilha uma linhagem com o .NET Framework e o CLR. Não é o mesmo código durante todo o tempo, mas mesmo assim, prometo que um estudo atencioso de Rotor lhe dará novas informações sobre os andamentos embaixo do CLR. Mas certifique-se de examinar a licença da SSCLI primeiro!)
O Conhecimento
Se você aspira a ser um taxista em Londres, primeiro você deve ganhar o Conhecimento. Os alunos estudam por muitos meses para memorizar as milhares de pequenas ruas de Londres e aprender as melhores rotas de um lugar para outro. E eles saem todos os dias em patinetes para explorar e reforçar seu aprendizado de livros.
Da mesma forma, se você quiser ser um desenvolvedor de código gerenciado de alto desempenho, precisará adquirir o Conhecimento de Código Gerenciado. Você precisa saber quais são os custos de cada operação de baixo nível. Você precisa saber quais recursos, como delegados e custo de segurança de acesso ao código. Você precisa aprender os custos dos tipos e métodos que está usando e os que está escrevendo. E não faz mal descobrir quais métodos podem ser muito caros para seu aplicativo e, portanto, evitá-los.
O Conhecimento não está em nenhum livro, infelizmente. Você tem que sair em sua scooter e explorar , ou seja, aumentar o csc, ildasm, o depurador VS.NET, o CLR Profiler, seu criador de perfil, alguns temporizadores perf e assim por diante, e ver o que seu código custa em tempo e espaço.
Para um modelo de custo para código gerenciado
Preliminares à parte, vamos considerar um modelo de custo para código gerenciado. Dessa forma, você poderá examinar um método folha e dizer rapidamente quais expressões e instruções são mais caras; e você poderá fazer escolhas mais inteligentes ao escrever um novo código.
(Isso não resolverá os custos transitivos de chamar seus métodos ou métodos do .NET Framework. Isso terá que esperar por outro artigo em outro dia.)
Anteriormente, afirmou que a maior parte do modelo de custo C ainda se aplica em cenários C++. Da mesma forma, grande parte do modelo de custo C/C++ ainda se aplica ao código gerenciado.
Como isso pode ser? Você conhece o modelo de execução clr. Você escreve seu código em um dos vários idiomas. Compile-o no formato CIL (Common Intermediate Language), empacotado em assemblies. Você executa o main assembly de aplicativo e ele começa a executar o CIL. Mas isso não é uma ordem de magnitude mais lenta, como os interpretadores de código de bytes antigos?
O compilador Just-In-Time
Não, não é. O CLR usa um compilador JIT (just-in-time) para compilar cada método em CIL no código x86 nativo e, em seguida, executa o código nativo. Embora haja um pequeno atraso para a compilação JIT de cada método como ele é chamado pela primeira vez, cada método chamado executa código nativo puro sem sobrecarga interpretativa.
Ao contrário de um processo de compilação C++ offline tradicional, o tempo gasto no compilador JIT é um atraso de "tempo de relógio de parede", na cara de cada usuário, para que o compilador JIT não tenha o luxo de passagens de otimização exaustivas. Mesmo assim, a lista de otimizações executadas pelo compilador JIT é impressionante:
- Dobra de constante
- Propagação de constante e cópia
- Eliminação de subexpressão comum
- Movimento de código de invariáveis de loop
- Eliminação de armazenamento morto e código morto
- Registrar alocação
- Inlining de método
- Loop unrolling (pequenos loops com corpos pequenos)
O resultado é comparável ao código nativo tradicional , pelo menos no mesmo estádio.
Quanto aos dados, você usará uma combinação de tipos de valor ou tipos de referência. Tipos de valor, incluindo tipos integrais, tipos de ponto flutuante, enumerações e structs, normalmente vivem na pilha. Eles são tão pequenos e rápidos quanto os locais e structs estão em C/C++. Assim como acontece com C/C++, você provavelmente deve evitar passar structs grandes como argumentos de método ou valores retornados, pois a sobrecarga de cópia pode ser proibitivamente cara.
Tipos de referência e tipos de valor em caixa residem no heap. Eles são abordados por referências de objeto, que são simplesmente ponteiros de computador, assim como ponteiros de objeto em C/C++.
Portanto, o código gerenciado jitted pode ser rápido. Com algumas exceções que discutimos abaixo, se você tiver uma ideia do custo de alguma expressão no código C nativo, não vai dar muito errado ao modelar seu custo como equivalente no código gerenciado.
Também devo menção NGEN, uma ferramenta que "antecipadamente" compila o CIL em assemblies de código nativos. Embora o NGEN'ing de seus assemblies atualmente não tenha um impacto substancial (bom ou ruim) no tempo de execução, ele pode reduzir o conjunto de trabalho total para assemblies compartilhados que são carregados em muitos AppDomains e processos. (O sistema operacional pode compartilhar uma cópia do código NGEN'd em todos os clientes; enquanto o código jitted normalmente não é compartilhado entre appDomains ou processos. Mas veja também LoaderOptimizationAttribute.MultiDomain
.)
Gerenciamento automático de memória
A saída mais significativa do código gerenciado (do nativo) é o gerenciamento automático de memória. Você aloca novos objetos, mas o GC (coletor de lixo) CLR os libera automaticamente quando eles se tornam inacessíveis. O GC é executado de vez em quando, muitas vezes imperceptivelmente, geralmente parando seu aplicativo por apenas um ou dois milissegundos, ocasionalmente mais longo.
Vários outros artigos discutem as implicações de desempenho do coletor de lixo e não os recapitularemos aqui. Se o aplicativo seguir as recomendações nestes outros artigos, o custo geral da coleta de lixo poderá ser insignificante, alguns por cento do tempo de execução, competitivo com ou superior ao objeto new
C++ tradicional e delete
. O custo amortizado de criar e, posteriormente, recuperar automaticamente um objeto é suficientemente baixo para que você possa criar muitas dezenas de milhões de objetos pequenos por segundo.
Mas a alocação de objeto ainda não é gratuita. Os objetos ocuparão espaço. A alocação de objetos desenfreada leva a ciclos de coleta de lixo mais frequentes.
Muito pior, manter desnecessariamente referências a grafos de objetos inúteis as mantém vivas. Às vezes vemos programas modestos com lamentáveis mais de 100 MB de conjuntos de trabalho, cujos autores negam sua culpa e, em vez disso, atribuem seu baixo desempenho a algum problema misterioso, não identificado (e, portanto, intratável) com o próprio código gerenciado. É trágico. Mas então uma hora de estudo com o CLR Profiler e mudanças para algumas linhas de código cortam seu uso de heap por um fator de dez ou mais. Se você estiver enfrentando um grande problema de conjunto de trabalho, a primeira etapa é examinar o espelho.
Portanto, não crie objetos desnecessariamente. Só porque o gerenciamento automático de memória dissipa as muitas complexidades, problemas e bugs de alocação e liberação de objetos, porque é tão rápido e tão conveniente, naturalmente tendemos a criar cada vez mais objetos, como se crescessem em árvores. Se você quiser escrever um código gerenciado muito rápido, crie objetos de maneira cuidadosa e apropriada.
Isso também se aplica ao design da API. É possível criar um tipo e seus métodos para que eles exijam que os clientes criem novos objetos com abandono selvagem. Não faça isso.
Qual é o custo das coisas no código gerenciado
Agora vamos considerar o custo de tempo de várias operações de código gerenciado de baixo nível.
A Tabela 1 apresenta o custo aproximado de uma variedade de operações de código gerenciado de baixo nível, em nanossegundos, em um computador Pentium-III quiescente de 1,1 GHz executando o Windows XP e .NET Framework v1.1 ("Everett"), reunidos com um conjunto de loops de tempo simples.
O driver de teste chama cada método de teste, especificando várias iterações a serem executadas, dimensionadas automaticamente para iterar entre 218 e 230 iterações, conforme necessário, para executar cada teste por pelo menos 50 ms. Em termos gerais, isso é longo o suficiente para observar vários ciclos de coleta de lixo de geração 0 em um teste que faz alocação intensa de objetos. A tabela mostra os resultados com média superior a 10 avaliações, bem como a melhor avaliação (tempo mínimo) para cada entidade de teste.
Cada loop de teste é cancelado de 4 a 64 vezes, conforme necessário, para diminuir a sobrecarga do loop de teste. Inspecionei o código nativo gerado para cada teste para garantir que o compilador JIT não estava otimizando o teste — por exemplo, em vários casos modifiquei o teste para manter os resultados intermediários ativos durante e após o loop de teste. Da mesma forma, fiz alterações para impedir a eliminação comum de subexpressão em vários testes.
Tabela 1 Tempos Primitivos (média e mínimo) (ns)
Méd | Min | Primitivo | Méd | Min | Primitivo | Méd | Min | Primitivo |
---|---|---|---|---|---|---|---|---|
0,0 | 0,0 | Control | 2.6 | 2.6 | novo valtype L1 | 0,8 | 0,8 | isinst up 1 |
1.0 | 1.0 | Int add | 4,6 | 4,6 | novo valtype L2 | 0,8 | 0,8 | isinst down 0 |
1.0 | 1.0 | Int sub | 6.4 | 6.4 | novo valtype L3 | 6.3 | 6.3 | isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | novo valtype L4 | 10,7 | 10.6 | isinst (up 2) down 1 |
35,9 | 35,7 | Int div | 23,0 | 22,9 | novo valtype L5 | 6.4 | 6.4 | isinst down 2 |
2.1 | 2.1 | Int shift | 22,0 | 20,3 | novo reftype L1 | 6.1 | 6.1 | isinst down 3 |
2.1 | 2.1 | adição longa | 26,1 | 23,9 | novo reftype L2 | 1.0 | 1.0 | obter campo |
2.1 | 2.1 | long sub | 30,2 | 27,5 | novo reftype L3 | 1.2 | 1.2 | obter prop |
34,2 | 34,1 | mula longa | 34,1 | 30.8 | novo reftype L4 | 1.2 | 1.2 | campo set |
50,1 | 50,0 | div longo | 39.1 | 34.4 | novo reftype L5 | 1.2 | 1.2 | set prop |
5.1 | 5.1 | deslocamento longo | 22,3 | 20,3 | novo reftype vazio ctor L1 | 0,9 | 0,9 | obter este campo |
1,3 | 1,3 | float add | 26,5 | 23,9 | novo reftype vazio ctor L2 | 0,9 | 0,9 | obter este adereço |
1.4 | 1.4 | float sub | 38.1 | 34,7 | novo reftype vazio ctor L3 | 1.2 | 1.2 | definir este campo |
2,0 | 2.0 | float mul | 34,7 | 30,7 | novo reftype ctor vazio L4 | 1.2 | 1.2 | definir este adereço |
27,7 | 27.6 | float div | 38.5 | 34.3 | novo reftype ctor vazio L5 | 6.4 | 6.3 | obter prop virtual |
1.5 | 1.5 | adição dupla | 22,9 | 20,7 | novo reftype ctor L1 | 6.4 | 6.3 | definir prop virtual |
1.5 | 1.5 | double sub | 27.8 | 25.4 | novo reftype ctor L2 | 6.4 | 6.4 | barreira de gravação |
2.1 | 2,0 | mula dupla | 32,7 | 29,9 | novo reftype ctor L3 | 1,9 | 1,9 | load int array elem |
27,7 | 27.6 | double div | 37.7 | 34,1 | novo reftype ctor L4 | 1,9 | 1,9 | store int array elem |
0,2 | 0,2 | chamada estática embutida | 43.2 | 39.1 | novo reftype ctor L5 | 2.5 | 2.5 | load obj array elem |
6.1 | 6.1 | chamada estática | 28,6 | 26,7 | novo reftype ctor no-inl L1 | 16,0 | 16,0 | store obj array elem |
1,1 | 1.0 | chamada de instância embutida | 38.9 | 36,5 | novo reftype ctor no-inl L2 | 29,0 | 21,6 | box int |
6,8 | 6,8 | chamada de instância | 50.6 | 47.7 | novo reftype ctor no-inl L3 | 3,0 | 3,0 | unbox int |
0,2 | 0,2 | inlined this inst call | 61.8 | 58.2 | novo reftype ctor no-inl L4 | 41.1 | 40.9 | delegar invocação |
6.2 | 6.2 | essa chamada de instância | 72.6 | 68.5 | novo reftype ctor no-inl L5 | 2.7 | 2.7 | sum array 1000 |
5.4 | 5.4 | chamada virtual | 0,4 | 0,4 | converter 1 | 2.8 | 2.8 | sum array 10000 |
5.4 | 5.4 | essa chamada virtual | 0.3 | 0.3 | reduzir 0 | 2.9 | 2.8 | sum array 1000000 |
6.6 | 6.5 | chamada de interface | 8,9 | 8.8 | reduzir 1 | 5.6 | 5.6 | sum array 10000000 |
1,1 | 1.0 | inst itf instance call | 9.8 | 9.7 | cast (up 2) down 1 | 3,5 | 3,5 | sum list 1000 |
0,2 | 0,2 | essa chamada de instância itf | 8,9 | 8.8 | reduzir 2 | 6.1 | 6.1 | sum list 10000 |
5.4 | 5.4 | inst itf virtual call | 8.7 | 8,6 | reduzir 3 | 22,0 | 22,0 | sum list 100000 |
5.4 | 5.4 | essa chamada virtual itf | 21,5 | 21,4 | sum list 10000000 |
Um aviso de isenção de responsabilidade: não leve esses dados muito literalmente. O teste de tempo está repleto de riscos de efeitos inesperados de segunda ordem. Uma chance de acontecer pode colocar o código jitted, ou alguns dados cruciais, de modo que ele abrange linhas de cache, interfere em outra coisa ou o que você tem. É um pouco como o Princípio da Incerteza: as diferenças de tempo e tempos de 1 nanossegundo ou assim estão nos limites do observável.
Outro aviso de isenção de responsabilidade: esses dados são pertinentes apenas para pequenos cenários de código e dados que se encaixam inteiramente no cache. Se as partes "quentes" do aplicativo não se ajustarem ao cache no chip, talvez você tenha um conjunto diferente de desafios de desempenho. Temos muito mais a dizer sobre caches perto do fim do papel.
E outro aviso de isenção de responsabilidade: um dos benefícios sublimes de enviar seus componentes e aplicativos como assemblies do CIL é que seu programa pode ser automaticamente mais rápido a cada segundo e ficar mais rápido a cada ano— "mais rápido a cada segundo" porque o runtime pode (em teoria) reajustar o código compilado JIT à medida que seu programa é executado; e "mais rápido sempre" porque a cada nova versão do runtime, algoritmos melhores, mais inteligentes e mais rápidos podem ter uma nova tentativa de otimizar seu código. Portanto, se alguns desses intervalos parecerem menos do que o ideal no .NET 1.1, tenha em mente que eles devem melhorar nas versões subsequentes do produto. Segue-se que qualquer determinada sequência de código nativo relatada neste artigo pode mudar em versões futuras do .NET Framework.
Isenção de responsabilidade à parte, os dados fornecem uma sensação de instinto razoável para o desempenho atual de vários primitivos. Os números fazem sentido e comprovam minha afirmação de que a maioria dos códigos gerenciados com jitted é executada "perto do computador", assim como o código nativo compilado faz. O inteiro primitivo e as operações flutuantes são chamadas rápidas de método de vários tipos menos, mas (confie em mim) ainda comparáveis ao C/C++nativo; e, no entanto, também vemos que algumas operações que geralmente são baratas em código nativo (conversões, repositórios de matrizes e de campo, ponteiros de função (delegados)) agora são mais caras. Por quê? Vamos ver.
Operações Aritméticas
Tempos de operação aritmética da Tabela 2 (ns)
Méd | Min | Primitivo | Méd | Min | Primitivo |
---|---|---|---|---|---|
1.0 | 1.0 | int add | 1,3 | 1,3 | float add |
1.0 | 1.0 | int sub | 1.4 | 1.4 | float sub |
2.7 | 2.7 | int mul | 2,0 | 2.0 | float mul |
35,9 | 35,7 | int div | 27,7 | 27.6 | float div |
2.1 | 2.1 | int shift | |||
2.1 | 2.1 | adição longa | 1.5 | 1.5 | adição dupla |
2.1 | 2.1 | long sub | 1.5 | 1.5 | double sub |
34,2 | 34,1 | mula longa | 2.1 | 2,0 | mula dupla |
50,1 | 50,0 | div longo | 27,7 | 27.6 | double div |
5.1 | 5.1 | deslocamento longo |
Antigamente, a matemática de ponto flutuante era talvez uma ordem de magnitude mais lenta do que a matemática inteira. Como mostra a Tabela 2, com unidades modernas de ponto flutuante em pipeline, parece que há pouca ou nenhuma diferença. É incrível pensar que um computador notebook médio é um computador de classe gigaflop agora (para problemas que se encaixam no cache).
Vamos dar uma olhada em uma linha de código jitted dos testes de adição de ponto inteiro e flutuante:
Desmontagem 1 Int add and float add
int add a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10 mov edx,dword ptr [esp+10h]
00000050 03 54 24 14 add edx,dword ptr [esp+14h]
00000054 03 54 24 18 add edx,dword ptr [esp+18h]
00000058 03 54 24 1C add edx,dword ptr [esp+1Ch]
0000005c 03 54 24 20 add edx,dword ptr [esp+20h]
00000060 03 D5 add edx,ebp
00000062 03 D6 add edx,esi
00000064 03 D3 add edx,ebx
00000066 03 D7 add edx,edi
00000068 89 54 24 10 mov dword ptr [esp+10h],edx
float add i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld dword ptr ds:[003E6138h]
0000001c D8 05 3C 61 3E 00 fadd dword ptr ds:[003E613Ch]
00000022 D8 05 40 61 3E 00 fadd dword ptr ds:[003E6140h]
00000028 D8 05 44 61 3E 00 fadd dword ptr ds:[003E6144h]
0000002e D8 05 48 61 3E 00 fadd dword ptr ds:[003E6148h]
00000034 D8 05 4C 61 3E 00 fadd dword ptr ds:[003E614Ch]
0000003a D8 05 50 61 3E 00 fadd dword ptr ds:[003E6150h]
00000040 D8 05 54 61 3E 00 fadd dword ptr ds:[003E6154h]
00000046 D8 05 58 61 3E 00 fadd dword ptr ds:[003E6158h]
0000004c D9 1D 58 61 3E 00 fstp dword ptr ds:[003E6158h]
Aqui, vemos que o código jitted está próximo do ideal.
int add
No caso, o compilador registrou até mesmo cinco das variáveis locais. No caso de adição float, fui obrigado a criar variáveis a
por meio de h
estáticas de classe para derrotar a eliminação de subexpressão comum.
Chamadas de método
Nesta seção, examinamos os custos e implementações de chamadas de método. A entidade de teste é uma classe T
que implementa a interface I
, com vários tipos de métodos. Consulte Listagem 1.
Listando 1 métodos de teste de chamada de método
interface I { void itf1();… void itf5();… }
public class T : I {
static bool falsePred = false;
static void dummy(int a, int b, int c, …, int p) { }
static void inl_s1() { } … static void s1() { if (falsePred) dummy(1, 2, 3, …, 16); } … void inl_i1() { } … void i1() { if (falsePred) dummy(1, 2, 3, …, 16); } … public virtual void v1() { } … void itf1() { } … virtual void itf5() { } …}
Considere a Tabela 3. Parece que, para uma primeira aproximação, um método é embutido (a abstração não custa nada) ou não (a abstração custa >5X uma operação de inteiro). Não parece haver uma diferença significativa no custo bruto de uma chamada estática, chamada de instância, chamada virtual ou chamada de interface.
Tempos de chamada do método Table 3 (ns)
Méd | Min | Primitivo | Receptor | Méd | Min | Primitivo | Receptor |
---|---|---|---|---|---|---|---|
0,2 | 0,2 | chamada estática embutida | inl_s1 |
5.4 | 5.4 | chamada virtual | v1 |
6.1 | 6.1 | chamada estática | s1 |
5.4 | 5.4 | esta chamada virtual | v1 |
1,1 | 1.0 | chamada de instância embutida | inl_i1 |
6.6 | 6.5 | chamada de interface | itf1 |
6,8 | 6,8 | chamada de instância | i1 |
1,1 | 1.0 | inst itf instance call | itf1 |
0,2 | 0,2 | inlined this inst call | inl_i1 |
0,2 | 0,2 | essa chamada de instância de itf | itf1 |
6.2 | 6.2 | esta chamada de instância | i1 |
5.4 | 5.4 | inst itf virtual call | itf5 |
5.4 | 5.4 | essa chamada virtual itf | itf5 |
No entanto, esses resultados são melhores casos não representativos, o efeito de executar loops de tempo apertados milhões de vezes. Nesses casos de teste, os sites de chamada do método virtual e da interface são monomórficos (por exemplo, por site de chamada, o método de destino não muda ao longo do tempo), portanto, a combinação de armazenar em cache os mecanismos de expedição do método virtual e do método de interface (os ponteiros e entradas do mapa de interface e tabela de método) e, espetacularmente, fornecer previsão de branch permite que o processador faça um trabalho irrealmente eficaz chamando por meio desses métodos difíceis de prever, branches dependentes de dados. Na prática, um cache de dados perde qualquer um dos dados do mecanismo de expedição ou um erro de previsão de branch (seja uma perda de capacidade obrigatória ou um site de chamada polimórfico), pode e reduzirá as chamadas virtuais e de interface em dezenas de ciclos.
Vamos examinar mais detalhadamente cada um desses tempos de chamada de método.
No primeiro caso, chamada estática embutida, chamamos uma série de métodos estáticos vazios s1_inl()
etc. Como o compilador remove completamente todas as chamadas, acabamos atingindo um loop vazio.
Para medir o custo aproximado de uma chamada de método estático, fazemos os métodos estáticos s1()
etc. tão grandes que eles não são lucrativos para embutidos no chamador.
Observe que temos até mesmo que usar uma variável falsePred
de predicado falso explícita . Se escrevemos
static void s1() { if (false) dummy(1, 2, 3, …, 16); }
o compilador JIT eliminaria a chamada morta para dummy
e embutido todo o corpo do método (agora vazio) como antes. A propósito, aqui alguns dos 6,1 ns de tempo de chamada devem ser atribuídos ao teste de predicado (false) e saltar dentro do método s1
estático chamado . (A propósito, uma maneira melhor de desabilitar o sublinhado é o CompilerServices.MethodImpl(MethodImplOptions.NoInlining)
atributo .)
A mesma abordagem foi usada para a chamada de instância embutida e o tempo de chamada de instância regular. No entanto, como a especificação da linguagem C# garante que qualquer chamada em uma referência de objeto nulo gere uma NullReferenceException, cada site de chamada deve garantir que a instância não seja nula. Isso é feito desreferenciando a referência de instância; se for nulo, gerará uma falha que é transformada nessa exceção.
Em Desmontagem 2, usamos uma variável t
estática como a instância , porque quando usamos uma variável local
T t = new T();
o compilador içava a instância nula marcar fora do loop.
Site de chamada de método de instância do Disassembly 2 com a instância nula "marcar"
t.i1();
00000012 8B 0D 30 21 A4 05 mov ecx,dword ptr ds:[05A42130h]
00000018 39 09 cmp dword ptr [ecx],ecx
0000001a E8 C1 DE FF FF call FFFFDEE0
Os casos da chamada embutida dessa instância e essa chamada de instância são os mesmos, exceto que a instância é this
; aqui, o marcar nulo foi ignorado.
Desmontagem 3 Este site de chamada de método de instância
this.i1();
00000012 8B CE mov ecx,esi
00000014 E8 AF FE FF FF call FFFFFEC8
As chamadas de método virtual funcionam da mesma forma que nas implementações tradicionais do C++. O endereço de cada método virtual recém-introduzido é armazenado em um novo slot na tabela de métodos do tipo. A tabela de métodos de cada tipo derivado está em conformidade com e estende a de seu tipo base, e qualquer substituição de método virtual substitui o endereço do método virtual do tipo base pelo endereço do método virtual do tipo derivado no slot correspondente na tabela de métodos do tipo derivado.
No site de chamada, uma chamada de método virtual incorre em duas cargas adicionais em comparação com uma chamada de instância, uma para buscar o endereço da tabela de métodos (sempre encontrado em *(this+0)
) e outra para buscar o endereço de método virtual apropriado da tabela de método e chamá-lo. Consulte Desmontagem 4.
Desmontagem 4 Site de chamada de método virtual
this.v1();
00000012 8B CE mov ecx,esi
00000014 8B 01 mov eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38 call dword ptr [eax+38h] ; fetch/call method address
Por fim, chegamos a chamadas de método de interface (Desmontagem 5). Eles não têm equivalente exato em C++. Qualquer tipo específico pode implementar qualquer número de interfaces, e cada interface requer logicamente sua própria tabela de métodos. Para expedir em um método de interface, pesquisamos a tabela de métodos, seu mapa de interface, a entrada da interface nesse mapa e, em seguida, chamamos indireto por meio da entrada apropriada na seção da interface da tabela de métodos.
Site de chamada do método Deassembly 5 Interface
i.itf1();
00000012 8B 0D 34 21 A4 05 mov ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01 mov eax,dword ptr [ecx] ; method table addr
0000001a 8B 40 0C mov eax,dword ptr [eax+0Ch] ; interface map addr
0000001d 8B 40 7C mov eax,dword ptr [eax+7Ch] ; itf method table addr
00000020 FF 10 call dword ptr [eax] ; fetch/call meth addr
O restante dos intervalos primitivos, inst itf instance call, this itf instance call, inst itf virtual call, this itf virtual call highlight the idea that always a derived type's method implements an interface method, it remains callable via an instance method call site.
Por exemplo, para testar essa chamada de instância itf, uma chamada em uma implementação de método de interface por meio de uma referência de instância (não interface), o método de interface é embutido com êxito e o custo vai para 0 ns. Até mesmo uma implementação de método de interface é potencialmente embutida quando você a chama como um método de instância.
Chamadas para métodos ainda a serem Jitted
Para chamadas de método estático e de instância (mas não chamadas de método virtual e de interface), o compilador JIT gera atualmente sequências de chamadas de método diferentes, dependendo se o método de destino já foi jitted no momento em que seu site de chamada está sendo jitted.
Se o receptor (método de destino) ainda não tiver sido jitted, o compilador emitirá uma chamada indireto por meio de um ponteiro que é inicializado pela primeira vez com um "stub prejit". A primeira chamada sobre o método de destino chega ao stub, que dispara a compilação JIT do método, gerando código nativo e atualizando o ponteiro para abordar o novo código nativo.
Se o receptor já tiver sido jitted, seu endereço de código nativo será conhecido para que o compilador emita uma chamada direta para ele.
Criação de novo objeto
A criação de novo objeto consiste em duas fases: alocação de objeto e inicialização de objeto.
Para tipos de referência, os objetos são alocados no heap coletado pelo lixo. Para tipos de valor, sejam residentes em pilha ou inseridos em outro tipo de referência ou valor, o objeto de tipo de valor é encontrado em algum deslocamento constante da estrutura delimitado — nenhuma alocação é necessária.
Para objetos típicos de tipo de referência pequena, a alocação de heap é muito rápida. Após cada coleta de lixo, exceto na presença de objetos fixados, os objetos dinâmicos do heap de geração 0 são compactados e promovidos para a geração 1 e, portanto, o alocador de memória tem uma grande arena de memória livre contígua para trabalhar. A maioria das alocações de objeto incorre apenas em um incremento de ponteiro e limita marcar, o que é mais barato do que o alocador de lista livre C/C++ típico (malloc/operator new). O coletor de lixo leva em conta até mesmo o tamanho do cache do computador para tentar manter os objetos gen 0 no ponto ideal rápido da hierarquia de cache/memória.
Como o estilo de código gerenciado preferencial é alocar a maioria dos objetos com tempos de vida curtos e recuperá-los rapidamente, também incluímos (no custo de tempo) o custo amortizado da coleta de lixo desses novos objetos.
Observe que o coletor de lixo não gasta tempo lamentando objetos mortos. Se um objeto está morto, o GC não o vê, não o anda, não dá a ele um pensamento de nanossegundos. A GC está preocupada apenas com o bem-estar dos vivos.
(Exceção: objetos mortos finalizáveis são um caso especial. O GC rastreia esses objetos e promove especialmente objetos finalizáveis mortos para a finalização pendente da próxima geração. Isso é caro e, na pior das hipóteses, pode promover transitivamente grandes grafos de objetos mortos. Portanto, não torne os objetos finalizáveis, a menos que seja estritamente necessário; e, se necessário, considere usar o Padrão de Descarte, chamando GC.SuppressFinalizer
quando possível.) A menos que seja exigido pelo método Finalize
, não mantenha referências do objeto finalizável para outros objetos.
É claro que o custo amortizado do GC de um objeto de curta duração grande é maior do que o custo de um objeto de curta duração pequeno. Cada alocação de objeto nos aproxima muito do próximo ciclo de coleta de lixo; objetos maiores fazem isso muito mais cedo que os pequenos. Mais cedo (ou mais tarde), o momento do acerto de contas virá. Os ciclos de GC, particularmente as coleções de geração 0, são muito rápidos, mas não são livres, mesmo que a grande maioria dos novos objetos esteja morta: para localizar (marcar) os objetos dinâmicos, primeiro é necessário pausar threads e, em seguida, percorrer pilhas e outras estruturas de dados para coletar referências de objeto raiz no heap.
(Talvez mais significativamente, menos objetos maiores se ajustem na mesma quantidade de cache que objetos menores. Os efeitos de perda de cache podem dominar facilmente os efeitos de comprimento do caminho do código.)
Depois que o espaço para o objeto é alocado, ele permanece para inicializá-lo (construa-o). O CLR garante que todas as referências de objeto sejam pré-inicializadas como nulas e que todos os tipos escalares primitivos sejam inicializados como 0, 0,0, false etc. (Portanto, é desnecessário fazer isso com redundância em seus construtores definidos pelo usuário. Sinta-se livre, é claro. Mas lembre-se de que o compilador JIT atualmente não otimiza necessariamente seus repositórios redundantes.)
Além de zerado os campos de instância, o CLR inicializa (somente tipos de referência) os campos de implementação interna do objeto: o ponteiro da tabela de métodos e a palavra de cabeçalho do objeto, que precede o ponteiro da tabela de métodos. As matrizes também obtêm um campo Length e as matrizes de objeto obtêm os campos Comprimento e tipo de elemento.
Em seguida, o CLR chama o construtor do objeto, se houver. O construtor de cada tipo, seja definido pelo usuário ou compilador gerado, primeiro chama o construtor do tipo base e, em seguida, executa a inicialização definida pelo usuário, se houver.
Em teoria, isso pode ser caro para cenários de herança profunda. Se E estender D estende C estende B estende A (estende System.Object), inicializar um E sempre incorreria em cinco chamadas de método. Na prática, as coisas não são tão ruins, porque o compilador está embutido (em nada) chama construtores de tipo base vazios.
Referindo-se à primeira coluna da Tabela 4, observe que podemos criar e inicializar um struct D
com quatro campos int em cerca de 8 int-add-times. Desmontagem 6 é o código gerado de três loops de tempo diferentes, criando A's, C e E's. (Em cada loop, modificamos cada nova instância, o que impede que o compilador JIT otimize tudo.)
Valores da Tabela 4 e Tempos de Criação de Objeto de Tipo de Referência (ns)
Méd | Min | Primitivo | Méd | Min | Primitivo | Méd | Min | Primitivo |
---|---|---|---|---|---|---|---|---|
2.6 | 2.6 | novo valtype L1 | 22,0 | 20,3 | novo reftype L1 | 22,9 | 20,7 | novo rt ctor L1 |
4,6 | 4,6 | novo valtype L2 | 26,1 | 23,9 | novo reftype L2 | 27.8 | 25.4 | novo rt ctor L2 |
6.4 | 6.4 | novo valtype L3 | 30,2 | 27,5 | novo reftype L3 | 32,7 | 29,9 | novo rt ctor L3 |
8.0 | 8.0 | novo valtype L4 | 34,1 | 30.8 | novo reftype L4 | 37.7 | 34,1 | novo rt ctor L4 |
23,0 | 22,9 | novo valtype L5 | 39.1 | 34.4 | novo reftype L5 | 43.2 | 39.1 | novo rt ctor L5 |
22,3 | 20,3 | new rt empty ctor L1 | 28,6 | 26,7 | new rt no-inl L1 | |||
26,5 | 23,9 | novo rt vazio ctor L2 | 38.9 | 36,5 | new rt no-inl L2 | |||
38.1 | 34,7 | novo rt vazio ctor L3 | 50.6 | 47.7 | new rt no-inl L3 | |||
34,7 | 30,7 | novo rt vazio ctor L4 | 61.8 | 58.2 | new rt no-inl L4 | |||
38.5 | 34.3 | novo rt vazio ctor L5 | 72.6 | 68.5 | new rt no-inl L5 |
Desmontagem 6 Construção de objeto de tipo de valor
A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov dword ptr [ebp-4],0
00000027 FF 45 FC inc dword ptr [ebp-4]
C c1 = new C(); ++c1.c;
00000024 8D 7D F4 lea edi,[ebp-0Ch]
00000027 33 C0 xor eax,eax
00000029 AB stos dword ptr [edi]
0000002a AB stos dword ptr [edi]
0000002b AB stos dword ptr [edi]
0000002c FF 45 FC inc dword ptr [ebp-4]
E e1 = new E(); ++e1.e;
00000026 8D 7D EC lea edi,[ebp-14h]
00000029 33 C0 xor eax,eax
0000002b 8D 48 05 lea ecx,[eax+5]
0000002e F3 AB rep stos dword ptr [edi]
00000030 FF 45 FC inc dword ptr [ebp-4]
Os próximos cinco intervalos (novo reftipo L1, ... new reftype L5) são para cinco níveis de herança de tipos A
de referência, ..., E
, sans construtores definidos pelo usuário:
public class A { int a; }
public class B : A { int b; }
public class C : B { int c; }
public class D : C { int d; }
public class E : D { int e; }
Comparando os tempos de tipo de referência com os tempos de tipo de valor, vemos que o custo amortizado de alocação e liberação de cada instância é de aproximadamente 20 ns (20X int add time) no computador de teste. Isso é rápido, alocando, inicializando e recuperando cerca de 50 milhões de objetos de curta duração por segundo, sustentados. Para objetos de até cinco campos, a alocação e a coleção são responsáveis por apenas metade do tempo de criação do objeto. Consulte Desmontagem 7.
Desmontagem 7 Construção de objeto de tipo de referência
new A();
0000000f B9 D0 72 3E 00 mov ecx,3E72D0h
00000014 E8 9F CC 6C F9 call F96CCCB8
new C();
0000000f B9 B0 73 3E 00 mov ecx,3E73B0h
00000014 E8 A7 CB 6C F9 call F96CCBC0
new E();
0000000f B9 90 74 3E 00 mov ecx,3E7490h
00000014 E8 AF CA 6C F9 call F96CCAC8
Os últimos três conjuntos de cinco intervalos apresentam variações nesse cenário de construção de classe herdada.
Novo rt vazio ctor L1, ..., novo rt vazio ctor L5: Cada tipo
A
, ...,E
tem um construtor vazio definido pelo usuário. Todos eles são embutidos e o código gerado é o mesmo que o acima.Novo rt ctor L1, ..., novo rt ctor L5: Cada tipo
A
, ...,E
tem um construtor definido pelo usuário que define sua variável de instância como 1:public class A { int a; public A() { a = 1; } } public class B : A { int b; public B() { b = 1; } } public class C : B { int c; public C() { c = 1; } } public class D : C { int d; public D() { d = 1; } } public class E : D { int e; public E() { e = 1; } }
O compilador inlineia cada conjunto de chamadas aninhadas do construtor de classe base para o new
site. (Desmontagem 8).
Desmontagem 8 Construtores herdados profundamente embutidos
new A();
00000012 B9 A0 77 3E 00 mov ecx,3E77A0h
00000017 E8 C4 C7 6C F9 call F96CC7E0
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
new C();
00000012 B9 80 78 3E 00 mov ecx,3E7880h
00000017 E8 14 C6 6C F9 call F96CC630
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1
new E();
00000012 B9 60 79 3E 00 mov ecx,3E7960h
00000017 E8 84 C3 6C F9 call F96CC3A0
0000001c C7 40 04 01 00 00 00 mov dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov dword ptr [eax+0Ch],1
00000031 C7 40 10 01 00 00 00 mov dword ptr [eax+10h],1
00000038 C7 40 14 01 00 00 00 mov dword ptr [eax+14h],1
New rt no-inl L1, ..., new rt no-inl L5: Cada tipo
A
, ...,E
tem um construtor definido pelo usuário que foi gravado intencionalmente para ser muito caro para embutido. Esse cenário simula o custo da criação de objetos complexos com hierarquias de herança profundas e construtores largish.public class A { int a; public A() { a = 1; if (falsePred) dummy(…); } } public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } } public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } } public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } } public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
Os últimos cinco intervalos na Tabela 4 mostram a sobrecarga adicional de chamar os construtores base aninhados.
Interlúdio: Demonstração do CLR Profiler
Agora, para uma demonstração rápida do CLR Profiler. O CLR Profiler, anteriormente conhecido como Criador de Perfil de Alocação, usa as APIs de Criação de Perfil clr para coletar dados de evento, particularmente eventos de chamada, retorno e alocação de objeto e coleta de lixo, conforme o aplicativo é executado. (O CLR Profiler é um criador de perfil "invasivo", o que significa que, infelizmente, retarda substancialmente o aplicativo com perfil.) Depois que os eventos forem coletados, você usará o CLR Profiler para explorar a alocação de memória e o comportamento de GC de seu aplicativo, incluindo a interação entre o grafo de chamada hierárquica e os padrões de alocação de memória.
O CLR Profiler vale a pena aprender porque para muitos aplicativos de código gerenciado "com desafios de desempenho", entender seu perfil de alocação de dados fornece a visão crítica necessária para reduzir seu conjunto de trabalho e, portanto, fornecer componentes e aplicativos rápidos e frugal.
O CLR Profiler também pode revelar quais métodos alocam mais armazenamento do que você esperava e pode descobrir casos em que você mantém inadvertidamente referências a grafos de objetos inúteis que, de outra forma, poderiam ser recuperados pelo GC. (Um padrão de design de problema comum é um cache de software ou uma tabela de pesquisa de itens que não são mais necessários ou são seguros para reconstituição posteriormente. É trágico quando um cache mantém os grafos de objeto vivos além de sua vida útil. Em vez disso, certifique-se de anular referências a objetos que você não precisa mais.)
A Figura 1 é uma exibição linha do tempo do heap durante a execução do driver de teste de tempo. O padrão de serragem indica a alocação de milhares de instâncias C
de objetos (magenta), D
(roxo) e E
(azul). A cada poucos milissegundos, mastigamos mais ~150 KB de RAM no novo heap de objeto (geração 0) e o coletor de lixo é executado brevemente para reciclá-lo e promover quaisquer objetos dinâmicos para a geração 1. É notável que, mesmo sob esse ambiente de criação de perfil invasivo (lento), no intervalo de 100 ms (2,8 s a 2,9s), passamos por ciclos de GC de ~8 geração 0. Em seguida, às 2,977 s, aparando espaço para outra E
instância, o coletor de lixo faz uma coleta de lixo de geração 1, que coleta e compacta o heap gen 1 e, portanto, a autenticação continua, de um endereço inicial inferior.
Figura1 Exibição de Linha de Tempo do Criador de Perfil CLR
Observe que quanto maior o objeto (E maior que D maior que C), mais rápido o heap de geração 0 é preenchido e mais frequente o ciclo de GC.
Conversões e verificações de tipo de instância
A base fundamental do código gerenciado seguro, seguro e verificável é a segurança do tipo. Se fosse possível converter um objeto em um tipo que não é, seria simples comprometer a integridade do CLR e assim tê-lo à mercê de código não confiável.
Tabela 5 Conversão e isinst Times (ns)
Méd | Min | Primitivo | Méd | Min | Primitivo |
---|---|---|---|---|---|
0,4 | 0,4 | converter 1 | 0,8 | 0,8 | isinst up 1 |
0.3 | 0.3 | reduzir 0 | 0,8 | 0,8 | isinst down 0 |
8,9 | 8.8 | reduzir 1 | 6.3 | 6.3 | isinst down 1 |
9.8 | 9.7 | cast (up 2) down 1 | 10,7 | 10.6 | isinst (up 2) down 1 |
8,9 | 8.8 | reduzir 2 | 6.4 | 6.4 | isinst down 2 |
8.7 | 8,6 | reduzir 3 | 6.1 | 6.1 | isinst down 3 |
A Tabela 5 mostra a sobrecarga dessas verificações de tipo obrigatórias. Uma conversão de um tipo derivado para um tipo base é sempre segura e livre; enquanto uma conversão de um tipo base para um tipo derivado deve ser verificada por tipo.
Uma conversão (marcada) converte a referência de objeto para o tipo de destino ou lança InvalidCastException
.
Por outro lado, a isinst
instrução CIL é usada para implementar o palavra-chave C# as
:
bac = ac as B;
Se ac
não B
for ou derivado de B
, o resultado será null
, não uma exceção.
A listagem 2 demonstra um dos loops de tempo de conversão e a Desmontagem 9 mostra o código gerado para um convertido em um tipo derivado. Para executar a conversão, o compilador emite uma chamada direta para uma rotina auxiliar.
Listando loop 2 para testar o tempo de conversão
public static void castUp2Down1(int n) {
A ac = c; B bd = d; C ce = e; D df = f;
B bac = null; C cbd = null; D dce = null; E edf = null;
for (n /= 8; --n >= 0; ) {
bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
}
}
Desmontagem 9 Conversão para baixo
bac = (B)ac;
0000002e 8B D5 mov edx,ebp
00000030 B9 40 73 3E 00 mov ecx,3E7340h
00000035 E8 32 A7 4E 72 call 724EA76C
Propriedades
No código gerenciado, uma propriedade é um par de métodos, um getter de propriedade e um setter de propriedade, que atuam como um campo de um objeto. O método get_ busca a propriedade ; o método set_ atualiza a propriedade para um novo valor.
Fora isso, as propriedades se comportam e custam, assim como os métodos de instância regulares e os métodos virtuais. Se você estiver usando uma propriedade para simplesmente buscar ou armazenar um campo de instância, ela geralmente será embutida, como acontece com qualquer método pequeno.
A Tabela 6 mostra o tempo necessário para buscar (e adicionar) e armazenar um conjunto de propriedades e campos de instância inteiros. O custo de obter ou definir uma propriedade é, de fato, idêntico ao acesso direto ao campo subjacente, a menos que a propriedade seja declarada virtual, nesse caso, o custo é aproximadamente o de uma chamada de método virtual. Não é surpresa.
Campos e tempos de propriedade da Tabela 6 (ns)
Méd | Min | Primitivo |
---|---|---|
1.0 | 1.0 | obter campo |
1.2 | 1.2 | obter prop |
1.2 | 1.2 | campo set |
1.2 | 1.2 | set prop |
6.4 | 6.3 | obter prop virtual |
6.4 | 6.3 | definir prop virtual |
Barreiras de Gravação
O coletor de lixo CLR aproveita bem a "hipótese geracional" — a maioria dos novos objetos morre jovem — para minimizar a sobrecarga de coleta.
O heap é logicamente particionado em gerações. Os objetos mais recentes vivem na geração 0 (geração 0). Esses objetos ainda não sobreviveram a uma coleção. Durante uma coleção gen 0, o GC determina quais objetos gen 0 podem ser acessados no conjunto raiz do GC, que inclui referências de objeto em registros de computador, na pilha, referências de objeto de campo estático de classe etc. Objetos transitivamente acessíveis são "dinâmicos" e promovidos (copiados) para a geração 1.
Como o tamanho total do heap pode ser de centenas de MB, enquanto o tamanho do heap de geração 0 pode ser de apenas 256 KB, limitar a extensão do rastreamento do grafo de objeto do GC para o heap de geração 0 é uma otimização essencial para alcançar os tempos de pausa de coleção muito breves do CLR.
No entanto, é possível armazenar uma referência a um objeto gen 0 em um campo de referência de objeto de um objeto gen 1 ou gen 2. Como não examinamos objetos gen 1 ou gen 2 durante uma coleção gen 0, se essa for a única referência ao objeto gen 0 fornecido, esse objeto poderá ser recuperado erroneamente pelo GC. Não podemos deixar isso acontecer!
Em vez disso, todos os repositórios para todos os campos de referência de objeto no heap incorrem em uma barreira de gravação. Esse é o código de contabilidade que observa com eficiência os repositórios de referências de objeto de nova geração em campos de objetos de geração mais antiga. Esses campos de referência de objeto antigos são adicionados ao conjunto raiz do GC dos GC(s) subsequentes.
A sobrecarga da barreira de gravação por objeto-referência-campo-store é comparável ao custo de uma chamada de método simples (Tabela 7). É uma nova despesa que não está presente no código C/C++ nativo, mas geralmente é um preço pequeno a ser pago pela alocação de objetos super rápida e GC, e os muitos benefícios de produtividade do gerenciamento automático de memória.
Tempo de Barreira de Gravação da Tabela 7 (ns)
Méd | Min | Primitivo |
---|---|---|
6.4 | 6.4 | barreira de gravação |
As barreiras de gravação podem ser dispendiosas em loops internos apertados. Mas, nos anos seguintes, podemos esperar técnicas avançadas de compilação que reduzam o número de barreiras de gravação tomadas e o custo amortizado total.
Você pode achar que as barreiras de gravação só são necessárias em repositórios para campos de referência de objeto de tipos de referência. No entanto, dentro de um método de tipo de valor, os armazenamentos em seus campos de referência de objeto (se houver) também são protegidos por barreiras de gravação. Isso é necessário porque o tipo de valor em si pode, às vezes, ser inserido em um tipo de referência que reside no heap.
Acesso ao elemento Array
Para diagnosticar e impedir erros de matriz fora dos limites e corrompidos de heap e proteger a integridade do próprio CLR, as cargas e repositórios de elementos de matriz são limites verificados, garantindo que o índice esteja dentro do intervalo [0,array. Length-1] inclusive ou lançando IndexOutOfRangeException
.
Nossos testes medem o tempo para carregar ou armazenar elementos de uma int[]
matriz e de uma A[]
matriz. (Tabela 8).
Tempos de acesso de matriz da Tabela 8 (ns)
Méd | Min | Primitivo |
---|---|---|
1,9 | 1,9 | load int array elem |
1,9 | 1,9 | store int array elem |
2.5 | 2.5 | load obj array elem |
16,0 | 16,0 | store obj array elem |
Os limites marcar exigem a comparação do índice de matriz com a matriz implícita. Campo de comprimento. Como mostra o Disassembly 10, em apenas duas instruções, marcar o índice não é menor que 0 nem maior ou igual à matriz. Comprimento – se for, ramificamos para uma sequência fora de linha que gera a exceção. O mesmo vale para cargas de elementos de matriz de objetos e para repositórios em matrizes de ints e outros tipos de valor simples. (Load obj array elem time is (insignificantly) slower devido a uma pequena diferença em seu loop interno.)
Disassembly 10 Load int array element
; i in ecx, a in edx, sum in edi
sum += a[i];
00000024 3B 4A 04 cmp ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19 jae 00000042
00000029 03 7C 8A 08 add edi,dword ptr [edx+ecx*4+8]
… ; throw IndexOutOfRangeException
00000042 33 C9 xor ecx,ecx
00000044 E8 52 78 52 72 call 7252789B
Por meio de suas otimizações de qualidade de código, o compilador JIT geralmente elimina verificações de limites redundantes.
Lembrando as seções anteriores, podemos esperar que os repositórios de elementos de matriz de objetos sejam consideravelmente mais caros. Para armazenar uma referência de objeto em uma matriz de referências de objeto, o runtime deve:
- marcar índice de matriz está em limites;
- marcar objeto é uma instância do tipo de elemento de matriz;
- executar uma barreira de gravação (observando qualquer referência de objeto intergeracional da matriz para o objeto).
Essa sequência de código é bastante longa. Em vez de emitê-lo em cada site de repositório de matriz de objetos, o compilador emite uma chamada para uma função auxiliar compartilhada, conforme mostrado em Desmontagem 11. Essa chamada, além dessas três ações, contabiliza o tempo adicional necessário nesse caso.
Elemento Desassembly 11 Store object array
; objarray in edi
; obj in ebx
objarray[1] = obj;
00000027 53 push ebx
00000028 8B CF mov ecx,edi
0000002a BA 01 00 00 00 mov edx,1
0000002f E8 A3 A0 4A 72 call 724AA0D7 ; store object array element helper
Conversão boxing e unboxing
Uma parceria entre compiladores do .NET e o CLR permite que tipos de valor, incluindo tipos primitivos como int (System.Int32), participem como se fossem tipos de referência, para serem abordados como referências de objeto. Essa acessibilidade — esse açúcar sintactico — permite que os tipos de valor sejam passados para métodos como objetos, armazenados em coleções como objetos etc.
Para "box" um tipo de valor é criar um objeto de tipo de referência que contém uma cópia de seu tipo de valor. Isso é conceitualmente o mesmo que criar uma classe com um campo de instância sem nome do mesmo tipo que o tipo de valor.
Para "unbox" um tipo de valor em caixa é copiar o valor, do objeto, para uma nova instância do tipo de valor.
Como a Tabela 9 mostra (em comparação com a Tabela 4), o tempo amortizado necessário para encaixotar um int e, posteriormente, para recolhê-lo, é comparável ao tempo necessário para instanciar uma classe pequena com um campo int.
Caixa da Tabela 9 e Unbox int Times (ns)
Méd | Min | Primitivo |
---|---|---|
29,0 | 21,6 | box int |
3,0 | 3,0 | unbox int |
Para desem caixa um objeto int em caixa requer uma conversão explícita para int. Isso é compilado em uma comparação do tipo do objeto (representado por seu endereço de tabela de método) e o endereço da tabela do método int em caixa. Se forem iguais, o valor será copiado do objeto . Do contrário, uma exceção será acionada. Consulte Desmontagem 12.
Desmontagem 12 Box e unbox int
box object o = 0;
0000001a B9 08 07 B9 79 mov ecx,79B90708h
0000001f E8 E4 A5 6C F9 call F96CA608
00000024 8B D0 mov edx,eax
00000026 C7 42 04 00 00 00 00 mov dword ptr [edx+4],0
unbox sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C je 00000055
00000049 8B D6 mov edx,esi
0000004b B9 08 07 B9 79 mov ecx,79B90708h
00000050 E8 A9 BB 4E 72 call 724EBBFE ; no, throw exception
00000055 8D 46 04 lea eax,[esi+4]
00000058 3B 08 cmp ecx,dword ptr [eax]
0000005a 03 38 add edi,dword ptr [eax] ; yes, fetch int field
Delegados
Em C, um ponteiro para a função é um tipo de dados primitivo que literalmente armazena o endereço da função.
O C++ adiciona ponteiros a funções membro. Um ponteiro para a função membro (PMF) representa uma invocação de função membro adiada. O endereço de uma função membro não virtual pode ser um endereço de código simples, mas o endereço de uma função membro virtual deve incorporar uma chamada de função membro virtual específica— a desreferência desse PMF é uma chamada de função virtual.
Para desreferenciar um PMF C++, você deve fornecer uma instância:
A* pa = new A;
void (A::*pmf)() = &A::af;
(pa->*pmf)();
Anos atrás, na equipe de desenvolvimento do compilador do Visual C++, costumávamos nos perguntar, que tipo de beastie é a expressão pa->*pmf
naked (operador de chamada de função sans)? Chamamos de ponteiro associado para a função membro , mas a chamada de função membro latente é igual apt.
Retornando à terra do código gerenciado, um objeto delegado é exatamente isso— uma chamada de método latente. Um objeto delegado representa o método a ser chamado e a instância para chamá-lo — ou para um delegado para um método estático, apenas o método estático a ser chamado.
(Como nossa documentação afirma: uma declaração de delegado define um tipo de referência que pode ser usado para encapsular um método com uma assinatura específica. Uma instância delegada encapsula um método estático ou de instância. Os delegados são aproximadamente semelhantes aos ponteiros de função em C++; no entanto, os delegados são type-safe e secure.)
Tipos delegados em C# são tipos derivados de MulticastDelegate. Esse tipo fornece semântica avançada, incluindo a capacidade de criar uma lista de invocação de pares (objeto,método) a serem invocados quando você invoca o delegado.
Os delegados também fornecem uma instalação para invocação de método assíncrono. Depois de definir um tipo delegado e instanciar um, inicializado com uma chamada de método latente, você poderá invocá-lo de forma síncrona (sintaxe de chamada de método) ou de forma assíncrona, por meio de BeginInvoke
. Se BeginInvoke
for chamado, o runtime enfileira a chamada e retorna imediatamente para o chamador. O método de destino é chamado posteriormente, em um thread de pool de threads.
Toda essa semântica rica não é barata. Comparando a Tabela 10 e a Tabela 3, observe que a invocação de delegado é ** aproximadamente oito vezes mais lenta do que uma chamada de método. Espere que isso melhore ao longo do tempo.
Tempo de invocação de representante da Tabela 10 (ns)
Méd | Min | Primitivo |
---|---|---|
41.1 | 40.9 | delegar invocação |
De erros de cache, falhas de página e arquitetura do computador
Nos "bons velhos tempos", por volta de 1983, os processadores eram lentos (aproximadamente,5 milhões de instruções/s) e, relativamente falando, a RAM era rápida o suficiente, mas pequena (aproximadamente 300 ns de tempo de acesso em 256 KB de DRAM) e os discos eram lentos e grandes (aproximadamente 25 ms de tempos de acesso em discos de 10 MB). Os microprocessadores de computador eram CISCs escalares, a maioria dos pontos flutuantes estava no software e não havia caches.
Depois de mais vinte anos da Lei de Moore, por volta de 2003, os processadores são rápidos (emitindo até três operações por ciclo a 3 GHz), a RAM é relativamente muito lenta (aproximadamente 100 ns de tempo de acesso em 512 MB de DRAM) e os discos são glacialmente lentos e enormes (aproximadamente 10 ms tempos de acesso em discos de 100 GB). Os microprocessadores de pc agora estão fora de ordem do fluxo de dados de hiperthreading hiperthreading RISCs (executando instruções CISC decodificadas) e há várias camadas de caches, por exemplo, um determinado microprocessador orientado ao servidor tem cache de dados de nível 1 de 32 KB (talvez 2 ciclos de latência), cache de dados L2 de 512 KB e cache de dados L3 de 2 MB (talvez uma dúzia de ciclos de latência), tudo em chip.
Nos bons velhos tempos, você poderia, e às vezes, contar os bytes de código que escreveu e contar o número de ciclos que o código precisava executar. Uma carga ou repositório levou aproximadamente o mesmo número de ciclos que um suplemento. O processador moderno usa previsão de ramificação, especulação e execução fora de ordem (fluxo de dados) em várias unidades de função para encontrar o paralelismo no nível da instrução e, portanto, fazer progressos em várias frentes ao mesmo tempo.
Agora, nossos computadores mais rápidos podem emitir até ~9.000 operações por microssegundo, mas nesse mesmo microssegundo, carregue ou armazene apenas para o DRAM ~10 linhas de cache. Em círculos de arquitetura do computador, isso é conhecido como atingir a parede de memória. Os caches ocultam a latência de memória, mas apenas até um ponto. Se o código ou os dados não se ajustarem ao cache e/ou apresentarem baixa localidade de referência, nosso jato supersônico de operação por microssegundo 9000 será degenerado para um triciclo de 10 carregamento por microssegundo.
E (não deixe que isso aconteça com você) se o conjunto de trabalho de um programa exceder a RAM física disponível e o programa começar a receber falhas de páginas difíceis, em seguida, em cada serviço de falha de página de 10.000 microssegundos (acesso ao disco), perdemos a oportunidade de aproximar o usuário de até 90 milhões de operações de sua resposta. Isso é tão horrível que eu confio que você vai, a partir deste dia, tomar cuidado para medir seu conjunto de trabalho (vadump) e usar ferramentas como CLR Profiler para eliminar alocações desnecessárias e retenção inadvertida de grafo de objetos.
Mas o que tudo isso tem a ver com saber o custo dos primitivos de código gerenciado?Tudo*.*
Lembrando a Tabela 1, a lista omnibus de tempos primitivos de código gerenciado, medida em um P-III de 1,1 GHz, observa que cada vez, até mesmo o custo amortizado de alocar, inicializar e recuperar um objeto de cinco campos com cinco níveis de chamadas explícitas do construtor, é mais rápido do que um único acesso DRAM. Apenas uma carga que perde todos os níveis de cache no chip pode levar mais tempo para ser atendida do que quase qualquer operação de código gerenciado único.
Portanto, se você é apaixonado pela velocidade do código, é imperativo que você considere e meça a hierarquia de cache/memória ao projetar e implementar seus algoritmos e estruturas de dados.
Hora de uma demonstração simples: é mais rápido somar uma matriz de ints ou somar uma lista vinculada equivalente de ints? O que, quanto, e por quê?
Pense nisso por um minuto. Para itens pequenos, como ints, o volume de memória por elemento de matriz é um quarto do da lista vinculada. (Cada nó de lista vinculado tem duas palavras de sobrecarga de objeto e duas palavras de campos (próximo link e item int).) Isso prejudicará a utilização do cache. Pontuar um para a abordagem da matriz.
Mas a passagem da matriz pode incorrer em um limite de matriz marcar por item. Você acabou de ver que os limites marcar leva um pouco de tempo. Talvez isso dê dicas às escalas em favor da lista vinculada?
Desmontagem 13 Sum int array versus sum int linked list
sum int array: sum += a[i];
00000024 3B 4A 04 cmp ecx,dword ptr [edx+4] ; bounds check
00000027 73 19 jae 00000042
00000029 03 7C 8A 08 add edi,dword ptr [edx+ecx*4+8] ; load array elem
for (int i = 0; i < m; i++)
0000002d 41 inc ecx
0000002e 3B CE cmp ecx,esi
00000030 7C F2 jl 00000024
sum int linked list: sum += l.item; l = l.next;
0000002a 03 70 08 add esi,dword ptr [eax+8]
0000002d 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
00000030 03 70 08 add esi,dword ptr [eax+8]
00000033 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
00000036 03 70 08 add esi,dword ptr [eax+8]
00000039 8B 40 04 mov eax,dword ptr [eax+4]
sum += l.item; l = l.next;
0000003c 03 70 08 add esi,dword ptr [eax+8]
0000003f 8B 40 04 mov eax,dword ptr [eax+4]
for (m /= 4; --m >= 0; ) {
00000042 49 dec ecx
00000043 85 C9 test ecx,ecx
00000045 79 E3 jns 0000002A
Referindo-me à Desmontagem 13, empilguei o baralho em favor da passagem da lista vinculada, cancelando-a quatro vezes, até mesmo removendo o ponteiro nulo usual marcar. Cada item no loop de matriz requer seis instruções, enquanto cada item no loop de lista vinculado precisa apenas de instruções 11/4 = 2,75. Agora, qual você acha que é mais rápido?
Condições de teste: primeiro, crie uma matriz de um milhão de ints e uma lista vinculada simples e tradicional de um milhão de ints (nós de lista de 1 M). Em seguida, quanto tempo, por item, leva para somar os primeiros 1.000, 10.000, 100.000 e 1.000.000 itens. Repita cada loop muitas vezes para medir o comportamento de cache mais lisonjeiro para cada caso.
O que é mais rápido? Depois de adivinhar, consulte as respostas: as últimas oito entradas na Tabela 1.
Interessante! Os tempos ficam substancialmente mais lentos à medida que os dados referenciados aumentam do que os tamanhos sucessivos do cache. A versão da matriz é sempre mais rápida do que a versão da lista vinculada, embora execute o dobro de instruções; para 100.000 itens, a versão da matriz é sete vezes mais rápida!
Por que isso é assim? Primeiro, menos itens de lista vinculados se encaixam em qualquer nível determinado de cache. Todos esses cabeçalhos de objeto e vinculam o espaço desperdiçado. Em segundo lugar, nosso processador de fluxo de dados fora de ordem moderno pode potencialmente ampliar e fazer progressos em vários itens na matriz ao mesmo tempo. Por outro lado, com a lista vinculada, até que o nó de lista atual esteja em cache, o processador não poderá começar a buscar o próximo link para o nó depois disso.
No caso de 100.000 itens, o processador está gastando (em média) aproximadamente (22-3,5)/22 = 84% de seu tempo girando os polegares esperando que a linha de cache de algum nó de lista seja lida do DRAM. Isso parece ruim, mas as coisas podem ser muito piores. Como os itens de lista vinculados são pequenos, muitos deles se encaixam em uma linha de cache. Como atravessamos a lista em ordem de alocação e, como o coletor de lixo preserva a ordem de alocação mesmo que compacte objetos mortos fora do heap, é provável que, depois de buscar um nó em uma linha de cache, que os próximos vários nós agora também estejam em cache. Se os nós fossem maiores ou se os nós da lista estivessem em uma ordem de endereço aleatória, cada nó visitado poderia muito bem ser um erro de cache completo. Adicionar 16 bytes a cada nó de lista dobra o tempo de passagem por item para 43 ns; +32 bytes, 67 ns/item; e adicionar 64 bytes o dobra novamente, para 146 ns/item, provavelmente a latência média do DRAM no computador de teste.
Então, qual é a lição de viagem aqui? Evite listas vinculadas de 100.000 nós? Não. A lição é que os efeitos de cache podem dominar qualquer consideração sobre a eficiência de baixo nível do código gerenciado versus o código nativo. Se você estiver escrevendo um código gerenciado crítico ao desempenho, particularmente o gerenciamento de código de grandes estruturas de dados, tenha em mente os efeitos de cache, pense nos padrões de acesso à estrutura de dados e busque volumes de dados menores e uma boa localidade de referência.
A propósito, a tendência é que a parede de memória, a taxa de tempo de acesso dram dividida pelo tempo de operação da CPU, continue a piorar ao longo do tempo.
Aqui estão algumas regras de "design consciente do cache":
- Experimente e meça seus cenários porque é difícil prever efeitos de segunda ordem e porque as regras de ouro não valem o papel em que são impressas.
- Algumas estruturas de dados, exemplificadas por matrizes, usam a adjacência implícita para representar uma relação entre os dados. Outros, exemplificados por listas vinculadas, usam ponteiros explícitos (referências) para representar a relação. A adjacência implícita geralmente é preferível— "implicitidade" economiza espaço em comparação com ponteiros; e a adjacência fornecem localidade estável de referência e podem permitir que o processador inicie mais trabalho antes de perseguir o próximo ponteiro.
- Alguns padrões de uso favorecem estruturas híbridas— listas de matrizes pequenas, matrizes de matrizes ou árvores B.
- Talvez os algoritmos de agendamento sensíveis ao acesso ao disco, projetados quando os acessos ao disco custam apenas 50.000 instruções de CPU, devem ser reciclados agora que os acessos dram podem levar milhares de operações de CPU.
- Como o coletor de lixo clr mark-and-compact preserva a ordem relativa dos objetos, os objetos alocados juntos no tempo (e no mesmo thread) tendem a permanecer juntos no espaço. Talvez você possa usar esse fenômeno para agrupar cuidadosamente dados cliquish em linhas de cache comuns.
- Talvez você deseje particionar seus dados em hot parts que são frequentemente percorridas e devem caber no cache e partes frias que são usadas com pouca frequência e podem ser "armazenadas em cache".
Experimentos de tempo "Faça você mesmo"
Para as medidas de tempo neste artigo, usei o contador QueryPerformanceCounter
de desempenho de alta resolução win32 (e QueryPerformanceFrequency
).
Eles são facilmente chamados por meio de P/Invoke:
[System.Runtime.InteropServices.DllImport("KERNEL32")]
private static extern bool QueryPerformanceCounter(
ref long lpPerformanceCount);
[System.Runtime.InteropServices.DllImport("KERNEL32")]
private static extern bool QueryPerformanceFrequency(
ref long lpFrequency);
Você chama QueryPerformanceCounter
logo antes e logo após o loop de tempo, as contagens de subtração, multiplicam por 1,0e9, dividem por frequência, dividem por número de iterações e esse é o tempo aproximado por iteração em ns.
Devido a restrições de espaço e tempo, não abordamos o bloqueio, o tratamento de exceções ou o sistema de segurança de acesso de código. Considere-o um exercício para o leitor.
A propósito, produzi as desmontagem neste artigo usando a Janela desmontagem no VS.NET 2003. Há um truque para isso, no entanto. Se você executar seu aplicativo no depurador VS.NET, mesmo como um executável otimizado interno no modo de versão, ele será executado no "modo de depuração" no qual otimizações como inlining são desabilitadas. A única maneira que encontrei para dar uma olhada no código nativo otimizado que o compilador JIT emite foi iniciar meu aplicativo de teste fora do depurador e, em seguida, anexá-lo usando Debug.Processes.Attach.
Um modelo de custo de espaço?
Ironicamente, as considerações espaciais impedem uma discussão completa sobre o espaço. Alguns parágrafos breves, então.
Considerações de baixo nível (várias são C# (TypeAttributes.SequentialLayout padrão) e x86 específicas):
- O tamanho de um tipo de valor geralmente é o tamanho total de seus campos, com campos de 4 bytes ou menores alinhados aos limites naturais.
- É possível usar
[StructLayout(LayoutKind.Explicit)]
atributos e[FieldOffset(n)]
para implementar uniões. - O tamanho de um tipo de referência é de 8 bytes mais o tamanho total de seus campos, arredondado até o próximo limite de 4 bytes e com campos de 4 bytes ou menores alinhados aos limites naturais.
- Em C#, as declarações de enumeração podem especificar um tipo base integral arbitrário (exceto char)— portanto, é possível definir enumerações de 8 bits, 16 bits, 32 bits e 64 bits.
- Como em C/C++, muitas vezes você pode raspar algumas dezenas de por cento de espaço de um objeto maior dimensionando seus campos integrais adequadamente.
- Você pode inspecionar o tamanho de um tipo de referência alocado com o CLR Profiler.
- Objetos grandes (muitas dezenas de KB ou mais) são gerenciados em um heap de objeto grande separado, para impedir a cópia cara.
- Objetos finalizáveis exigem uma geração de GC adicional para recuperar— use-os com moderação e considere usar o Padrão de Descarte.
Considerações importantes:
- Cada AppDomain atualmente incorre em uma sobrecarga de espaço substancial. Muitas estruturas de runtime e framework não são compartilhadas entre AppDomains.
- Em um processo, o código jitted normalmente não é compartilhado entre AppDomains. Se o runtime estiver hospedado especificamente, será possível substituir esse comportamento. Consulte a documentação de
CorBindToRuntimeEx
e oSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
sinalizador. - De qualquer forma, o código jitted não é compartilhado entre processos. Se você tiver um componente que será carregado em muitos processos, considere a pré-compilação com o NGEN para compartilhar o código nativo.
Reflexão
Foi dito que "se você tiver que perguntar quais custos de reflexão, você não pode pagar". Se você leu até aqui, sabe o quanto é importante perguntar a que coisas custam e medir esses custos.
A reflexão é útil e poderosa, mas em comparação com o código nativo jitted, ela não é nem rápida nem pequena. Você foi avisado. Meça isso por si mesmo.
Conclusão
Agora você sabe (mais ou menos) quais custos de código gerenciado no nível mais baixo. Agora você tem a compreensão básica necessária para tornar as compensações de implementação mais inteligentes e escrever um código gerenciado mais rápido.
Vimos que o código gerenciado jitted pode ser tão "pedal para o metal" quanto o código nativo. Seu desafio é codificar com sabedoria e escolher sabiamente entre as muitas instalações ricas e fáceis de usar no Framework
Há configurações em que o desempenho não importa e configurações em que ele é o recurso mais importante de um produto. A otimização prematura é a raiz de todo o mal. Mas também a descuidada desatenção à eficiência. Você é um profissional, um artista, um artesão. Então, certifique-se de saber o custo das coisas. Se você não sabe ou mesmo se acha que faz isso, meça-o regularmente.
Quanto à equipe clr, continuamos a trabalhar para fornecer uma plataforma substancialmente mais produtiva do que o código nativo e ainda é mais rápida do que o código nativo. Espere que as coisas melhorem e melhorem. Fique atento.
Lembre-se de sua promessa.
Recursos
- David Stutz et al, CLI Essentials de origem compartilhada. O'Reilly e Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++: Sob o Capô.
- Gregor Noriskin, escrevendo High-Performance Aplicativos Gerenciados: Um Primer, MSDN.
- Rico Mariani, Noções Básicas e Dicas de Desempenho do Coletor de Lixo, MSDN.
- Emmanuel Schanzer, dicas de desempenho e truques em aplicativos .NET, MSDN.
- Emmanuel Schanzer, Considerações de desempenho para tecnologias de Run-Time no .NET Framework, MSDN.
- vadump (Ferramentas do SDK da Plataforma), MSDN.
- .NET Show, [Gerenciado] Otimização de código, 10 de setembro de 2002, MSDN.