Ontem encontrei um artigo de Christoph Nahr intitulado ".NET Struct Performance" que comparou várias linguagens (C ++, C #, Java, JavaScript) para um método que adiciona duas estruturas de pontos ( double
tuplas).
Como se viu, a versão C ++ leva cerca de 1000 ms para executar (iterações 1e9), enquanto C # não pode ficar abaixo de ~ 3000 ms na mesma máquina (e tem desempenho ainda pior em x64).
Para testar eu mesmo, peguei o código C # (e simplifiquei um pouco para chamar apenas o método onde os parâmetros são passados por valor) e o executei em uma máquina i7-3610QM (aumento de 3,1 GHz para núcleo único), 8 GB de RAM, Win8. 1, usando .NET 4.5.2, RELEASE build de 32 bits (x86 WoW64, pois meu sistema operacional é de 64 bits). Esta é a versão simplificada:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}
Com Point
definido como simplesmente:
public struct Point
{
private readonly double _x, _y;
public Point(double x, double y) { _x = x; _y = y; }
public double X { get { return _x; } }
public double Y { get { return _y; } }
}
Executá-lo produz resultados semelhantes aos do artigo:
Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms
Primeira observação estranha
Uma vez que o método deve ser embutido, eu me perguntei como o código funcionaria se eu removesse totalmente as estruturas e simplesmente colocasse tudo embutido juntos:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
public static void Main()
{
// not using structs at all here
double ax = 1, ay = 1, bx = 1, by = 1;
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
{
ax = ax + by;
ay = ay + bx;
}
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
ax, ay, sw.ElapsedMilliseconds);
}
}
E obteve praticamente o mesmo resultado (na verdade, 1% mais lento após várias tentativas), o que significa que o JIT-ter parece estar fazendo um bom trabalho otimizando todas as chamadas de função:
Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms
Também significa que o benchmark não parece medir nenhum struct
desempenho e, na verdade, parece medir apenas a double
aritmética básica (depois que todo o resto for otimizado).
As coisas estranhas
Agora vem a parte estranha. Se eu simplesmente adicionar outro cronômetro fora do loop (sim, reduzi-o a esta etapa maluca após várias tentativas), o código será executado três vezes mais rápido :
public static void Main()
{
var outerSw = Stopwatch.StartNew(); // <-- added
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
outerSw.Stop(); // <-- added
}
Result: x=1000000001 y=1000000001, Time elapsed: 961 ms
Isso é ridículo! E não Stopwatch
é como se estivesse me dando resultados errados, porque posso ver claramente que termina após um único segundo.
Alguém pode me dizer o que pode estar acontecendo aqui?
(Atualizar)
Aqui estão dois métodos no mesmo programa, o que mostra que o motivo não é JITting:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Test1();
Test2();
Console.WriteLine();
Test1();
Test2();
}
private static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
private static void Test2()
{
var swOuter = Stopwatch.StartNew();
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
swOuter.Stop();
}
}
Resultado:
Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms
Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms
Aqui está um pastebin. Você precisa executá-lo como uma versão de 32 bits no .NET 4.x (há algumas verificações no código para garantir isso).
(Atualização 4)
Seguindo os comentários de @usr sobre a resposta de @Hans, verifiquei a desmontagem otimizada para ambos os métodos, e eles são bastante diferentes:
Isso parece mostrar que a diferença pode ser devido ao compilador estar agindo de forma estranha no primeiro caso, ao invés do alinhamento de campo duplo.
Além disso, se eu adicionar duas variáveis (deslocamento total de 8 bytes), ainda obtenho o mesmo aumento de velocidade - e não parece mais que esteja relacionado à menção de alinhamento de campo por Hans Passant:
// this is still fast?
private static void Test3()
{
var magical_speed_booster_1 = "whatever";
var magical_speed_booster_2 = "whatever";
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
GC.KeepAlive(magical_speed_booster_1);
GC.KeepAlive(magical_speed_booster_2);
}
fonte
double
variáveis locais , semstruct
s, então descartei as ineficiências de layout de estrutura / chamada de método.Respostas:
A atualização 4 explica o problema: no primeiro caso, o JIT mantém os valores calculados (
a
,b
) na pilha; no segundo caso, o JIT o mantém nos registros.Na verdade,
Test1
funciona lentamente por causa doStopwatch
. Eu escrevi o seguinte benchmark mínimo baseado no BenchmarkDotNet :Os resultados no meu computador:
Como podemos ver:
WithoutStopwatch
funciona rapidamente (porquea = a + b
usa os registros)WithStopwatch
funciona lentamente (porquea = a + b
usa a pilha)WithTwoStopwatches
funciona rapidamente de novo (porquea = a + b
usa os registros)O comportamento do JIT-x86 depende de uma grande quantidade de condições diferentes. Por alguma razão, o primeiro cronômetro força o JIT-x86 a usar a pilha e o segundo cronômetro permite que ele use os registradores novamente.
fonte
Stopwatch
é executado mais rápido . Mas se você trocar a ordem na qual eles são chamados noMain
método, o outro método será otimizado.Existe uma maneira muito simples de obter sempre a versão "rápida" do seu programa. Projeto> Propriedades> guia Compilar, desmarque a opção "Preferir 32 bits" e certifique-se de que a seleção de destino da plataforma seja AnyCPU.
Você realmente não prefere 32 bits, infelizmente está sempre ativado por padrão para projetos C #. Historicamente, o conjunto de ferramentas do Visual Studio funcionava muito melhor com processos de 32 bits, um problema antigo que a Microsoft vem eliminando. É hora de remover essa opção, o VS2015 em particular abordou os últimos obstáculos reais para o código de 64 bits com um jitter de x64 totalmente novo e suporte universal para Editar + Continuar.
Chega de conversa, o que você descobriu é a importância do alinhamento para as variáveis. O processador se preocupa muito com isso. Se uma variável estiver desalinhada na memória, o processador terá que fazer um trabalho extra para embaralhar os bytes e colocá-los na ordem correta. Existem dois problemas distintos de desalinhamento, um é quando os bytes ainda estão dentro de uma única linha de cache L1, que custa um ciclo extra para colocá-los na posição correta. E o extra ruim, aquele que você encontrou, onde parte dos bytes estão em uma linha de cache e parte em outra. Isso requer dois acessos de memória separados e colá-los. Três vezes mais lento.
Os tipos
double
elong
são os criadores de problemas em um processo de 32 bits. Eles têm 64 bits de tamanho. E pode ficar assim desalinhado em 4, o CLR só pode garantir um alinhamento de 32 bits. Não é um problema em um processo de 64 bits, todas as variáveis têm a garantia de estar alinhadas a 8. Além disso, a razão subjacente pela qual a linguagem C # não pode prometer que sejam atômicas . E por que matrizes de double são alocadas no Large Object Heap quando elas têm mais de 1000 elementos. O LOH fornece uma garantia de alinhamento de 8. E explica porque adicionar uma variável local resolveu o problema, uma referência de objeto tem 4 bytes, então moveu a variável dupla por 4, agora alinhando-a. Por acaso.Um compilador C ou C ++ de 32 bits faz um trabalho extra para garantir que o double não possa ser desalinhado. Não é exatamente um problema simples de resolver, a pilha pode ficar desalinhada quando uma função é inserida, visto que a única garantia é que ela está alinhada a 4. O prólogo de tal função precisa de um trabalho extra para alinhá-la a 8. O mesmo truque não funciona em um programa gerenciado, o coletor de lixo se preocupa muito sobre onde exatamente uma variável local está localizada na memória. Necessário para que possa descobrir que um objeto no heap do GC ainda está referenciado. Ele não pode lidar adequadamente com essa variável sendo movida em 4 porque a pilha estava desalinhada quando o método foi inserido.
Esse também é o problema subjacente com os jitters do .NET não suportando facilmente as instruções SIMD. Eles têm requisitos de alinhamento muito mais rígidos, do tipo que o processador também não consegue resolver sozinho. SSE2 requer um alinhamento de 16, AVX requer um alinhamento de 32. Não é possível obter isso em código gerenciado.
Por último, mas não menos importante, observe também que isso torna o desempenho de um programa C # executado no modo de 32 bits muito imprevisível. Quando você acessa um double ou long que está armazenado como um campo em um objeto, o perf pode mudar drasticamente quando o coletor de lixo compacta o heap. O que move objetos na memória, tal campo pode agora de repente ficar desalinhado. Muito aleatório, é claro, pode ser muito complicado :)
Bem, nenhuma correção simples, mas um, o código de 64 bits é o futuro. Remova a força de jitter, desde que a Microsoft não altere o modelo do projeto. Talvez na próxima versão quando eles se sentirem mais confiantes sobre Ryujit.
fonte
Limitou um pouco (parece afetar apenas o tempo de execução do CLR 4.0 de 32 bits).
Observe que o posicionamento do
var f = Stopwatch.Frequency;
faz toda a diferença.Lento (2700ms):
Rápido (800ms):
fonte
Stopwatch
também altera drasticamente a velocidade. Alterar a assinatura do métodoTest1(bool warmup)
e adicionar uma condicional naConsole
saída:if (!warmup) { Console.WriteLine(...); }
também tem o mesmo efeito (descobri isso ao construir meus testes para reproduzir o problema).Parece haver algum bug no Jitter porque o comportamento é ainda mais estranho. Considere o seguinte código:
Isso será executado em
900
ms, o mesmo que o caso do cronômetro externo. No entanto, se removermos aif (!warmup)
condição, ele será executado em3000
ms. O que é ainda mais estranho é que o seguinte código também será executado em900
ms:Observe que removi
a.X
e asa.Y
referências daConsole
saída.Não tenho ideia do que está acontecendo, mas isso me cheira muito mal e não está relacionado a ter um externo
Stopwatch
ou não, o problema parece um pouco mais generalizado.fonte
a.X
ea.Y
, o compilador provavelmente está livre para otimizar praticamente tudo dentro do loop, porque os resultados da operação não são usados.a.X
ea.Y
não fazendo com que ele vá mais rápido do que quando você inclui aif (!warmup)
condição ou os OPsouterSw
, o que implica em não otimizar nada, é apenas eliminar qualquer bug que esteja fazendo o código rodar em uma velocidade abaixo do ideal (3000
ms em vez de900
ms).warmup
era verdade, mas nesse caso a linha nem é impressa, então o caso em que ela é impressa realmente faz referênciaa
. No entanto, gosto de ter certeza de que estou sempre referenciando resultados de cálculos em algum lugar próximo ao final do método, sempre que estou fazendo benchmarking.