Elemar JR
29 julho, 2010 0 Comentários AUTOR: elemarjr CATEGORIAS: Sem categoria Tags:, , ,

IL 101–Parte 3

Motivação

Nos posts anteriores identifiquei o IL como sendo um dos componentes centrais do .NET Framework. Conhecer seu significado, o que ocorre “sob o capô”, permite que exploremos todas as potencialidades desse fantástico artefato de software. Mesmo não programando diretamente em IL, você pode usar Emiting e, mesmo assim, obter ganhos reais perceptíveis de performance para suas aplicações.

Sobre essa parte

Nessa parte:

  • abordo como o IL trata tipos de dados;
  • destaco como .net trata o tradeoff performance x memória;
  • mostro que implementações idênticas nas em VB.net e C# produzem resultados diferentes;
  • apresento um breve resumo dos principais mnemônicos para carga de valores constantes para a Evaluation Stack.

Trata-se de um post mais teórico, fundamental para nossos próximos posts que vão tratar de questões mais práticas de programação. Desculpe se causar sono.

Common Type System e Common Language Specification (?!)

Uma das forças do .net framework é a forma fácil com que ele fornece interoperabilidade entre linguagens, e dois dos aspectos determinantes disso é um sistema padronizado de tipos (Common Type System – CTS) e uma definição de um conjunto mínimo de características que uma linguagem .net precisa ter (Common Language Specification – CLS).

Dica: Se escreve frameworks para serem usados por várias linguagens, faça isso pensando em CLS-Compliance

Dica: A Common Language Infrastructure está definida pela ECMA-335

Por outro lado, uma das principais fraquezas, a meu ver, do .net é a ausência de uma maneira simples, independente de linguagem, de descrever isso. Vejamos alguns exemplos disso:

  • Um inteiro de 32 bits, sinalizado - que em C# e MC++ é um simples int; em VB.net é um Integer. Vale lembrar que, debaixo do capô (realmente gostei da expressão) tudo se resume a uma instância de System.Int32. Além disso, System.Int32 é um dos tipos definidos na CLS (Common Language Specification), é CLS-compliant - o que significa tem representação em todas as linguagens para o Framework.
  • Um inteiro de 32 bits, não sinalizado – que em C# e MC++ é um unsigned int ; até onde eu sei não tem representação em VB.net. Valores unsigned int são, de fato, System.UInt32, que não é CLS-compliant.
  • Em IL, um inteiro sinalizado é identificado como int32, não sinalizado unsigned int32. Exceto por alguns mnemônicos, onde ganham a forma de .i4 e .u4.

Só eu acho isso confuso?

Tipos em IL

Vamos dar uma boa olhada, agora, em uma lista dos principais tipos disponíveis em Intermediate Language:

Nome em IL

Tipo .net

Significado

CLS-Compliant 

void

 

Apenas como retorno de método

Sim

bool

System.Boolean

true/false

Sim

char

System.Char

Char Unicode de 16 bits

Sim

int8

System.SByte

Inteiro, sinalizado, 1 byte

Não

int16

System.Int16

Inteiro, sinalizado, 2 bytes

Sim

int32

System.Int32

Inteiro, sinalizado, 4 bytes

Sim

int64

System.Int64

Inteiro, sinalizado, 8 bytes

Sim

native int

System.IntPtr

inteiro sinalizado

Sim

unsigned int 8

System.Byte

Inteiro, não-sinalizado, 1 byte

Sim

unsigned int16

System.UInt16

Inteiro, não-sinalizado, 2 bytes

Não

unsigned int32

System.UInt32

Inteiro, não-sinalizado, 4 bytes

Não

unsigned int64

System.UInt64

Inteiro, não-sinalizado, 8 bytes

Não

native unsigned int

System.UIntPtr

Inteiro, não sinalizado

Não

float32

System.Single

Ponto-flutuante, 4 bytes

Sim

float 64

System.Double

Ponto-flutuante, 16 bytes

Sim

object

System.Object

Referência para um objeto no Heap Gerenciado

Sim

&

 

Ponteiro gerenciado

Sim

*

System.IntPtr

Ponteiro não-gerenciado

Não

array

System.Array

Vetor

Sim

string

System.String

Referência para a instância da string no Heap Gerenciado

Sim

 

Os tipos que listei nessa tabela são reconhecidos pela IL, e conseqüentemente pela CLR, como tipos primitivos. Isso significa que os nomes da primeira coluna são palavras-chave em IL. Por exemplo, podemos usar as palavras-chave para identificar os tipos dos parâmetros ou tipos de retorno na assinatura de métodos:

	.method static int32 Soma(int32, int32)

O que é bem mais simples que usar tipos não-primitivos, como pode-se perceber pelo exemplo de chamada do método Intersect da classe Rectangle, que em C# é assim

	rec1.Intersect(rec2);

Mas, em IL é assim:

	call instance void [System.Drawing]System.Drawing.Rectangle::Intersect(
		valuetype [System.Drawing]System.Drawing.Rectangle)

Então? Perceba que especificamos explicitamente o tipo dos objetos na chamada, além de especificar o(s) Assembly onde estes tipos estão. Em IL, todos os detalhes do tipo precisam ser especificadas todas as vezes que o tipo é usado. Logo, vamos ver alguns detalhes repetidos, e em um método que retorne um tipo não-primitivo, veremos esses detalhes na especificação do retorno também.

Performance x Memória

Um debate sempre interessante relacionado a arquitetura de um software é o balanço entre performance (responsividade da aplicação) e consumo de recursos, sobre tudo a memória.

Por que há, afinal tantas representações diferentes para números inteiros, por exemplo? A resposta passa por razões como compatibilidade retroativa, até, é claro consumo de memória.

É importante observar que essas variações em tamanho (byte de 1, 2, 4, 8 bytes) não colaboram para movimentação dos dados e, muito menos, na realização de operações matemáticas. A arquitetura de quase todos os computadores hoje aperfeiçoa operações com números com 4 bytes de largura.

Sim, chegamos a um trade-off, certo? Se optarmos por usar tamanhos menores, temos programas que usam menos memória, mas, em compensação, têm desempenho mais pobre. Se optarmos por usar tipos de 4 bytes, teremos performance melhor (pelo menos na arquitetura atual), mas teremos desperdício de memória.

Aqui, há algo fantástico escondido na forma como o .NET trata essa questão. Vamos aos fatos:

  • Os dados armazenados no heap, na área para objetos estáticos, na tabela de variáveis locais, na tabela de parâmetros e até na área dinâmica ocupam exatamente o tamanho especificado em seus tipos;
  • Os tipos primitivos são “promovidos” a uma versão de 32 bits (no CLR para 32 bits) quando são carregados na Evaluation Stack.

O que isso significa? Em palavras simples, nos lugares onde os dados ficam “salvos” por muito tempo, eles ficam em sua versão mais “enxuta”. Entretanto, quando esses mesmos dados são “chamados” para o processamento, são compensados para o tamanho que apresente melhor desempenho.

OFF: Essas e outras me tornam fã dos “caras” da Microsoft.

 

Se tudo vira IL, por que linguagens diferentes geram programas com diferentes desempenhos?

Todos os compiladores .net traduzem nossos códigos para IL, certo? Exatamente!

O conjunto de instruções que a IL disponibiliza é o mesmo para todas as linguagens, certo? Exatamente!

Então, um mesmo código escrito em duas linguagens vai apresentar o mesmo resultado em performance, consumo de memória. Certo? Bem… Veja bem! Smiley piscando Infelizmente não é bem assim.

Vejamos uma função simples para somar dois inteiros. Primeiro em C#:

	public int Soma(int a, int b)
        {
            return a + b;
        }

Agora, a mesma função em VB.net

	Public Function Soma(ByVal a As Integer, ByVal b As Integer) As Integer
        	Return a + b
	End Function

Agora, examinemos os ILs gerados pelos compiladores. Primeiro o IL gerado para o C# (Nem tudo que aparece no código já foi demonstrado aqui, mas acho que você vai conseguir acompanhar):

.method public hidebysig instance int32 Soma(int32 a, int32 b) cil managed
{
    .maxstack 2
    .locals init (
        [0] int32 CS$1$0000)
    L_0000: nop
    L_0001: ldarg.1
    L_0002: ldarg.2
    L_0003: add
    L_0004: stloc.0
    L_0005: br.s L_0007
    L_0007: ldloc.0
    L_0008: ret
}

Agora, examinemos o IL gerado pelo compilador do VB.net):

.method public instance int32 Soma(int32 a, int32 b) cil managed
{
    .maxstack 2
    .locals init (
        [0] int32 Soma)
    L_0000: nop
    L_0001: ldarg.1
    L_0002: ldarg.2
    L_0003: add.ovf
    L_0004: stloc.0
    L_0005: br.s L_0007
    L_0007: ldloc.0
    L_0008: ret
}
 

Repare que os códigos são quase iguais. Quase! A linha rotulada como L_0003: apresenta uma pequena, porém significativa diferença!

Antes, uma explicação sobre adições em IL. Há três mnemônicos para operar adições: add, add.ovf e add.ovf.un (o mesmo ocorre com mul.*, sub.*, rem.* e div.*). Todos pegam dois números da evaluation stack e colocam o resultado da operação de soma no lugar. Entretanto:

  • add – adição básica. É o de melhor em performance, mas temos que ter em mente que ele não faz nenhum tipo de tratamento quando ocorre overflow.
  • add.ovf – adição reforçada. Realiza verificação de overflow lançando excessão quando um “estouro” acontece. Obviamente, ocorre algum overhead de processamento
  • add.ovf.un – o mesmo que add.ovf, porém sem considerar o sinal.

Então, o que acontece chamando nossa função Soma, construída em VB.net, passando Int.MaxValue (Integer.MaxValue)? Uma excessão é lançada. E o que acontece em C# (o mesmo para MC++)? Obtem-se como retorno um grande número negativo. E quais são as implicações diretas? A mesma função, escrita em C# é mais rápida, escrita em VB.net é mais “confiável”.

O que você acha disso?

Principais mnemônicos para carga de valores contantes na Evaluation Stack

É chegada a hora de falarmos um pouco sobre a família mnemônicos iniciada com ldc.*. Essas instruções fazem a carga (push) de um valor constante para a Evaluation Stack. Existem (incríveis) 15 instruções para esse propósito:

  • ldc.i4.0, ldc.i4.1, ldc.i4.2, ldc.i4.3, ldc.i4.4, ldc.i4.5, ldc.i4.6, ldc.i4.7, ldc.i4.8 – Cada uma dessas instruções ldc.i4.N servem para carregar o valor constante, inteiro de 4 dígitos, N na pilha;
  • ldc.i4.m1, ldc.i4.M1 – carrega -1 na Evaluation Stack;
  • ldc.i4.s <int8> – carrega o número, inteiro de 8 bits, na Evaluation Stack;
  • ldc.i4 <int32> – carrega o número, inteiro de 32 bits, na Evaluation Stack;
  • ldc.i8 <int64> – carrega o número, inteiro de 64 bits, na Evaluation Stack;
  • ldc.r4 <float32> – carrega o número, ponto-flutuante de 32 bits (single), na Evaluation Stack;
  • ldc.r8 <float64> – carrega o número, ponto-flutuante de 64 bits (double), na Evaluation Stack;
  • ldnull – carrega uma referência nula (do tipo object) na Evaluation Stack.

Vamos tratar dessas instruções, agora, em algum detalhe. ldc.i4.0 carrega o número 0 na Evaluation Stack. Esta instrução é tão específica que não precisa de argumentos. O mesmo acontece, por exemplo, com a instrução ldc.i4.m1. A implicação disso é que se você deseja carregar um inteiro entre -1 e 8 na pilha (intervalo mais comum), você pode fazer isso usando uma instrução que ocupa apenas um byte no assembly resultante. Se você precisar armazenar um número fora desse intervalo, você usa um byte para a instrução, mais a quantidade de bytes associada ao tipo do número (para carregar 315, por exemplo, seriam necessários 5 bytes do arquivo, um para a instrução, mais 4 para representar o número).

Mas com todo o exposto, eram realmente necessárias tantas instruções diferentes para carga de números? É realmente ter uma ldc.i4.0, ou ldc.i4.1, etc.. quando há uma ldc.i8 que serve poderia atender a mesma demanda? Bem, a resposta direta é sim!! Essas instruções contribuem para diminuição dos executáveis o que torna a estratégia de distribuição de aplicativos .net mais fácil. Quem disse que a Microsoft não se preocupa com espaço ocupado em disco! Alegre

Nosso exemplo de hoje, uma provocação!

Como foi dito na comparação entre o IL gerado pelo compilador do C# e do compilador do VB.net, o código do C# tende a ser mais rápido. Claro que os métodos usados nesse artigo não são suficientes para demonstrar isso, visto que a diferença de tempo fica perceptível apenas quando executadas algumas milhares de chamadas ao método descrito (e mesmo assim todo processo termina em menos de 1 segundo). Entretanto, como gosto de uma boa briga, o exemplo que proponho agora é de uma dll que rode ainda mais rápido que o C#. Para isso, vamos ao IL: Alegre

// fasteradd.il

.assembly extern mscorlib {}

.assembly fasteradd
{
	.ver 1:0:1:0
}

.module fasteradd.dll

.namespace fasteraddns
{
	.class public auto ansi Matematica extends
		[mscorlib]System.Object
	{
		.method public hidebysig specialname rtspecialname instance void .ctor() cil managed
		{
			.maxstack 1
			ldarg.0
			call instance void [mscorlib]System.Object::.ctor()
			ret
		}

		.method public hidebysig instance int32 Soma(int32 a, int32 b)
		{
			.maxstack 2
			ldarg.1
			ldarg.2
			add
			ret
		}
	}
}

Para compilar esse código, execute:

	ilasm fasteradd.il /dll

Qual foi o resultado? O código em C# foi 1.12x mais rápido do que o código em VB.net. Nosso código em IL foi rodou 2x mais rápido do que nosso código em C#. Smiley piscando 

Sei que o código fonte apresentado apresenta muitos conceitos ainda não tratados. Vamos tratar deles partindo do próximo post.

Leitura recomendada

Está gostando da série? Deseja entender mais sobre a Intermediate Language e toda a infra-estrutura do .net framework. Então aqui vão três livrinhos que podem ajudar:

CLR via C# – Third Edition do Jeffrey Richter

Compiling for .NET Common Language Runtime (CLR)  do John Gough

Accelerated C# 2010 do Trey Nash

Conclusões

Esta parte me permite mais algumas conclusões. Vamos a elas:

  • A CTS, a CLS e a CLR realmente são uma coisa boa, pois, graças a elas, podemos escrever componentes para nossas aplicações facilmente; Entretanto, nem sempre essa fluência é evidente;
  • A máquina virtual IL foi projetada para oferecer economia de memória sem prejuízo para processamento;
  • C# e VB.net produzem executáveis diferentes, com comportamentos diferentes. O que ressalta a importância do TDD;
  • O conjunto de instruções IL foi elaborado de forma a minimizar o tamanho dos arquivos executáveis;
  • Códigos escritos diretamente em IL geralmente têm resultado muito superior!

Bem gente, por hoje, é isso! Até Smiley piscando