Conteúdo

As novidades do .NET 9: Otimizações que elevam a performance de loops

O .NET 9 traz novidades em otimizações que melhoram a performance de loops, transformando o desempenho das suas aplicações. Entre as melhorias destacam-se a ampliação de variáveis de indução, o endereçamento pós-indexado, a redução de força e, notavelmente, a transformação automática de loops crescentes em decrescentes.

Essas otimizações incluem técnicas avançadas, como ampliação de variáveis de indução e endereçamento pós-indexado, que aproveitam ao máximo os recursos computacionais. Essas melhorias eliminam operações redundantes e aproveitam ao máximo as características de arquiteturas modernas, como x64 e Arm64.

Insights

  • O .NET 9 aprimora o processamento de loops, reduzindo o consumo de recursos e aumentando a velocidade sem comprometer a simplicidade do código.
  • A redução de força elimina cálculos redundantes, introduzindo variáveis de indução adicionais que simplificam loops e otimizam a performance.
  • A transformação automática de loops crescentes em decrescentes reduz o número de instruções e torna o código gerado mais compacto.

Alargamento de variável de indução elimina a sobrecarga nos loops

No contexto de um loop, uma variável de indução (IV) é aquela cujo valor muda de forma sistemática a cada iteração. Em um loop for, por exemplo, o índice i é a variável de indução. Ela começa com um valor inicial, é incrementada ou decrementada em cada iteração e é usada para acessar elementos de um array ou controlar a condição de parada.

No .NET 9, o alargamento de variável de indução (induction-variable widening) resolve um problema relacionado à utilização de registradores em sistemas x64. Em versões anteriores, mesmo quando o IV não era usado no loop, o compilador tratava a variável de forma ineficiente, mantendo comparações e incrementos crescentes desnecessários.

Considere o seguinte código em C#:

public int Incremento()
{
   int count = 0;
   for (int i = 0; i < 1000000; i++)
   {
      count++;
   }
   return count;
}

O compilador, antes do .NET 9, gerava código assembly que ajustava o tamanho de i a cada iteração:

## .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2
; WatchZ.ConsoleApp.Teste.Incremento()
       xor       eax,eax
       xor       ecx,ecx
M00_L00:
       inc       eax                 ; Incrementa o acumulador
       inc       ecx                 ; Incrementa o contador do loop
       cmp       ecx,0F4240          ; Compara o contador com o limite (1000000)
       jl        short M00_L00       ; Salta para o início do loop se a condição for verdadeira
       ret
; Total bytes of code 17

Quantas instruções há no loop?

O loop possui quatro instruções no total:

  1. inc eax
  2. inc ecx
  3. cmp ecx, 0F4240
  4. jl short M00_L00

Quantas instruções são executadas por iteração?

Para cada iteração do loop, as quatro instruções são executadas:

  1. inc eax (incrementa o acumulador).
  2. inc ecx (incrementa o contador).
  3. cmp ecx, 0F4240 (compara o índice com o limite).
  4. jl short M00_L00 (decide se continua no loop).

Portanto, em 1000000 iterações, o total de instruções executadas apenas no loop será: 4 × 1000000 = 4000000 instruções.

Otimizar loops manualmente não é trivial, mas tem ganhos significativos

Os desenvolvedores precisavam transformar manualmente loops crescentes (i++) em loops decrescentes (i--) para uma otimização efetiva. Essa abordagem era necessária para eliminar comparações explícitas, simplificar o controle do loop e aproveitar o comportamento natural das CPUs modernas, que ajustam automaticamente os indicadores (flags) ao decrementar até zero.

Embora eficiente, essa transformação era muitas vezes considerada não trivial pelos desenvolvedores, especialmente em cenários onde a legibilidade do código era prioritária. Vamos analisar o processo em detalhes.

Considere o seguinte código em C#:

public int Decremento()
{
    int count = 0;
    for (int i = 1000000; i >= 0; i--)
    {
        count++;
    }
    return count;
}

Neste caso:

  1. O índice do loop (i) começa em 1000000 e é decrementado até 0.
  2. O controle do loop usa o operador >= para verificar se o índice ainda é válido.

Embora o código seja funcionalmente equivalente ao loop crescente, ele exige que o desenvolvedor reestruture a lógica, o que pode ser confuso em loops mais complexos.

Quando o loop decrescente é compilado no .NET 8, por exemplo, o código assembly gerado é significativamente mais simples do que o do loop crescente:

## .NET 8.0.11 (8.0.1124.51707), X64 RyuJIT AVX2
assembly
; WatchZ.ConsoleApp.Teste.Decremento()
       xor       eax,eax
       mov       ecx,0F4240
M00_L00:
       inc       eax
       dec       ecx
       jns       short M00_L00
       ret
; Total bytes of code 14

Quantas instruções há no loop?

O loop decrescente contém três instruções principais em seu corpo, relacionadas à execução do for. São elas:

  1. inc eax: Incrementa o acumulador (count no código C#).
  2. dec ecx: Decrementa o contador do loop (i no código C#).
  3. jns short M00_L00: Verifica se o contador ainda é não negativo e, se for, salta para o início do loop.

Essas três instruções são repetidas em cada iteração até que a condição do loop seja atendida (quando o índice se torna negativo).

Quantas instruções são executadas por iteração?

Por iteração, todas as três instruções no corpo do loop são executadas:

  1. inc eax: Executada uma vez por iteração para incrementar o acumulador.
  2. dec ecx: Executada uma vez por iteração para decrementar o contador do loop.
  3. jns short M00_L00: Executada uma vez por iteração para verificar se o loop deve continuar.

Logo, o total de três instruções por iteração é executado.

Portanto, em 1000000 iterações, o total de instruções executadas apenas no loop será: 3 × 1000001 = 3000003 instruções.

O loop decrescente é cerca de 25% mais eficiente em termos de instruções executadas. Por quê?

A diferença ocorre porque o loop crescente realiza uma comparação explícita (cmp) a cada iteração, enquanto o loop decrescente elimina essa instrução, utilizando o comportamento natural da CPU ao ajustar os indicadores (flags) durante o decremento (dec).

Essa redução simplifica o código e melhora o desempenho, especialmente em aplicações que executam loops com milhões de iterações.

Resultados do Benchmark: Incremento vs. Decremento

MethodMeanCode Size
Incremento370.9 μs17 B
Decremento287.3 μs14 B

Os resultados obtidos através do BenchmarkDotNet para os métodos de incremento e decremento oferecem insights importantes sobre como diferentes abordagens de loops afetam o desempenho:

Código assembly gerado no .NET 9

Com o .NET 9, o JIT transforma automaticamente o loop crescente em decrescente, gerando o seguinte assembly:

; WatchZ.ConsoleApp.Teste.Incremento()
       xor       eax,eax         ; Inicializa o acumulador em 0
       mov       ecx,0F4240      ; Inicializa o contador do loop (1000000)
M00_L00:
       inc       eax             ; Incrementa o acumulador
       dec       ecx             ; Decrementa o contador do loop
       jne       short M00_L00   ; Salta para o início do loop se o contador não for zero
       ret                      ; Retorna o resultado
MethodMeanCode Size
Incremento (.NET 8)370.9 μs17 B
Incremento (.NET 9)283.2 μs14 B

Redução de força torna loops mais eficientes

A redução de força (strength reduction) é uma técnica clássica de otimização de compiladores que substitui operações mais custosas por alternativas equivalentes mais rápidas. No contexto de loops, ela introduz variáveis de indução adicionais, cujos valores mudam de maneira previsível a cada iteração.

Essa abordagem elimina cálculos redundantes e melhora a eficiência ao reduzir dependências dentro do loop. Uma das vantagens fundamentais é que, ao remover a dependência do índice (i) no corpo do loop, a redução de força possibilita que outras otimizações entrem em ação, como a transformação de loops crescentes para loops decrescentes.

Considere o seguinte código em C#:

private readonly int[] _array = Enumerable.Range(0, 10000).ToArray();

public int SomaArray()
{
    int[] array = _array;
    int sum = 0;

    for (int i = 0; i < array.Length; i++)
    {
        sum += array[i];
    }

    return sum;
}

Assembly gerado no .NET 8

; WatchZ.ConsoleApp.Teste.SomaArray()
       mov       rax,[rcx+8]
       xor       ecx,ecx
       xor       edx,edx
       mov       r8d,[rax+8]
       test      r8d,r8d
       jle       short M00_L01
M00_L00:
       mov       r10d,edx
       add       ecx,[rax+r10*4+10]
       inc       edx
       cmp       r8d,edx
       jg        short M00_L00
M00_L01:
       mov       eax,ecx
       ret

O que está acontecendo aqui?

  1. O índice do loop i é armazenado em edx.
  2. Para acessar o valor atual do array, o endereço é recalculado a cada iteração usando: add ecx, [rax+r10*4+10]
  3. A cada iteração:

Este loop possui dependências internas que aumentam a latência e tornam a execução menos eficiente.

Assembly gerado em .NET 9

; WatchZ.ConsoleApp.Teste.SomaArray()
       mov       rax,[rcx+8]
       xor       ecx,ecx
       mov       edx,[rax+8]
       test      edx,edx
       jle       short M00_L01
       add       rax,10
M00_L00:
       add       ecx,[rax]
       add       rax,4
       dec       edx
       jne       short M00_L00
M00_L01:
       mov       eax,ecx
       ret

Como o .NET 9 otimiza esse loop?

Introdução de uma nova variável de indução:
No .NET 9, o endereço do próximo elemento do array (rax) é armazenado em uma variável separada, que é incrementada diretamente com:

add rax, 4

Eliminação da multiplicação e cálculo repetitivo:
O cálculo de deslocamento (r10*4) foi substituído por uma soma simples. Isso reduz a carga computacional e remove a dependência direta do índice (edx) no corpo do loop.

Transformação para loop decrescente:
A variável edx (antes usada como índice) agora representa o número de iterações restantes. Ela é decrementada em cada ciclo com:

dec edx

Quando edx atinge zero, o loop é encerrado.

Benefícios da Redução de Força no .NET 9

1. Redução do Número de Instruções

No .NET 9, o código do loop é mais enxuto e eficiente.

  • .NET 8: O loop executa 7 instruções por iteração.
  • .NET 9: O loop executa 5 instruções por iteração.

Essa redução no número de instruções diminui o tempo de execução e o tamanho do código gerado.

2. Melhoria na Latência do Loop

Ao eliminar a multiplicação e o cálculo do deslocamento, a latência de cada iteração é reduzida. Variáveis de indução adicionais, como rax, tornam o loop mais independente e eficiente.

3. Tamanho Reduzido do Código

VersãoTamanho do Código
.NET 835 bytes
.NET 930 bytes

A otimização resultou em uma redução de 14% no tamanho do código, facilitando a cacheabilidade e o desempenho.

4. Integração com Outras Otimizações

Com a dependência do índice eliminada, o JIT pôde transformar o loop crescente em decrescente. Isso simplificou ainda mais o controle do loop e eliminou comparações explícitas.

Resultados do Benchmark

MétodoRuntimeTempo Médio (Mean)Razão (Ratio)Tamanho do Código
SomaArray.NET 8.04.561 μs1.0035 bytes
SomaArray.NET 9.03.627 μs0.8030 bytes

Análise dos Resultados

  1. Redução no Tempo de Execução:
    O tempo médio do loop foi reduzido em 20% no .NET 9, passando de 4.561 μs para 3.627 μs. Isso demonstra como a combinação da redução de força e transformação para loop decrescente pode melhorar a eficiência em cenários de alto desempenho.
  2. Melhor Uso dos Recursos da CPU:
    As otimizações no .NET 9 reduzem a carga computacional por iteração, permitindo que a CPU execute mais ciclos em menos tempo.

Conclusão

O .NET 9 apresenta um avanço significativo no desempenho de loops, integrando técnicas de otimização como o alargamento de variáveis de indução, redução de força, endereçamento pós-indexado e transformação de loops crescentes em decrescentes. Essas mudanças demonstram como o compilador JIT pode aproveitar as capacidades do hardware moderno, reduzindo cálculos redundantes, simplificando instruções e otimizando o uso de memória e CPU.

Ao automatizar essas otimizações, o .NET 9 entrega loops mais eficientes sem comprometer a simplicidade e a legibilidade do código-fonte. Para desenvolvedores que lidam com alto volume de processamento ou cenários críticos de desempenho, essas melhorias trazem ganhos reais em velocidade, escalabilidade e economia de recursos.

FAQ: Perguntas Frequentes

1. O que é alargamento de variável de indução no .NET 9?

É uma otimização onde a variável de indução (IV) é ajustada para operar com maior eficiência em registradores de 64 bits, eliminando instruções redundantes. Quando a IV não é utilizada no corpo do loop, o JIT pode transformar loops crescentes em decrescentes para simplificar ainda mais o código gerado.

2. Como a redução de força melhora o desempenho de loops?

A redução de força substitui cálculos custosos, como multiplicações, por operações mais simples, como somas. Isso é feito introduzindo variáveis de indução adicionais, que eliminam dependências internas e tornam o loop mais eficiente.

3. Por que loops decrescentes são mais eficientes do que crescentes?

Loops decrescentes eliminam a necessidade de comparações explícitas, aproveitando o ajuste automático de sinal pela CPU ao decremento. Isso reduz o número de instruções executadas e simplifica o controle do loop.

4. O que é endereçamento pós-indexado e como ele ajuda?

Endereçamento pós-indexado combina o acesso à memória e o incremento do índice em uma única instrução. Essa técnica, usada em arquiteturas como Arm64, reduz o número de instruções necessárias por iteração, melhorando a eficiência.

5. Como essas otimizações impactam o desenvolvimento no dia a dia?

Essas otimizações permitem que desenvolvedores escrevam código intuitivo e legível, enquanto o JIT gera automaticamente loops otimizados em tempo de execução. Isso resulta em aplicações mais rápidas e eficientes sem necessidade de reescrita manual do código.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?