Usando SSE em C# no exemplo da soma de uma lista

Imagem de capa Usando SSE em C# no exemplo da soma de uma lista

Usando SSE em C# no exemplo da soma de uma lista

Em primeiro lugar: O que queremos alcançar? Isso é muito fácil. Queremos aproveitar ao máximo nosso hardware. Se nossa CPU tiver alguns recursos, queremos usá-los com C# e agora a boa notícia: podemos fazer isso!

SSE

SSE são instruções extras em sua CPU que permitem que você execute um novo conjunto de instruções. Se você quiser saber mais em detalhes aqui vai.

Para lhe dar uma visão geral do que significa SIMD, dê uma olhada na imagem a seguir:

Para resumir, basta saber que você tem uma instrução como + (adição) que pode ser executada em vários dados ao mesmo tempo.

C# nos oferece essa API diretamente via System.Runtime.Intrinsics.X86.Sse2.X64 e indiretamente via classes como System.Numerics.Vectors.dll. Claro que a Vector é o exemplo por excelência. Só coisa de somar dois vetores. Cada data é independente, o que é a condição perfeita para o paralelismo.

Antes de mergulharmos no código: A pequena propriedade Vector.IsHardwareAccelerated lhe dirá se o SSE está habilitado em seu processador ou não. Se você estiver usando um Raspberry PI, você está sem sorte, eles não suportam SSE. Assim, o .NET retornará ao modo convencional de fazer as coisas.

Configuração de teste

Antes de mergulharmos no SIMD e como usá-lo, queremos ter algumas linhas de base. O candidato óbvio é que estamos usando for-llop nativo e fazemos a soma por conta própria:

[Benchmark(Baseline = true)]
public  int  GetSumNaive()
{
    var result = 0;
    for (var index = 0; index < Numbers.Length; index++)
    {
         var i = Numbers[index];
         result += i;
    }

    return result;
}     

Eu usei explicitamente um loop for em vez de foreach, pois isso parece mais natural, além de ser mais rápido.

As próximas duas opções são LINQ:

[Benchmark]
public int SumLINQ() => Numbers.Sum();

[Benchmark]
public int SumPLINQ() => Numbers.AsParallel().Sum();

Agora para a versão SIMD de tudo isso. Comentei o código para dar uma ideia do que está acontecendo aqui:

[Benchmark]
public  int  SIMDVectors()
{
    // This depends on your hardware
    // It basically says on how many entries can we performance this single operation
    // aka how big is "multiple"
    var vectorSize = Vector<int>.Count;
    var accVector = Vector<int>.Zero;
    
    // We are tiling the original list by the hardware vector size
    for (var i = 0; i <= Numbers.Length - vectorSize; i += vectorSize)
    {
         var v = new Vector<int>(Numbers, i);
         accVector = Vector.Add(accVector, v);
     }
     
     // Scalar-Product of our vector against the Unit vector is its sum
     var result = Vector.Dot(accVector, Vector<int>.One);
     return result;
}

O link para este exemplo e todos os exemplos em geral podem ser encontrados no final da postagem do blog. Se trabalharmos no estilo SIMD, temos que entender o fato de que temos que dividir e conquistar nossos problemas em subproblemas menores que podem ser resolvidos independentemente.

Agora sem mais delongas vamos mostrar os resultados:

|       Método|   Quer dizer|       Erro |     StdDev |  Razão| RatioSD |
|------------ |------------:|-----------:|-----------:|------:|--------:|
| GetSumNaive |   313.52 ns |   5.928 ns |   5.545 ns |  1.00 |    0.00 |
|     SumLINQ | 2,630.16 ns |  51.568 ns |  95.585 ns |  8.60 |    0.46 |
|    SumPLINQ | 7,854.96 ns | 145.591 ns | 189.309 ns | 25.16 |    0.93 |
| SIMDVectors |    81.13 ns |   1.661 ns |   3.200 ns |  0.26 |    0.01 |
  • Acho que para nossa linha de base não temos que dizer muito.
  • A função Sum é mais lenta que nossa função nativa. Principalmente devido à sobrecarga do foreach e tudo o que vem com ele (callvirt, GetEnumerator, ...)
  • Nosso PLINQ é ainda mais lento? Bem mais para isso em um segundo
  • SIMD está muito à frente. Principalmente porque este seria um exemplo de como usar o SIMD/SSE em geral. Então este é um caso de cereja ??

Por que o PLINQ é tão lento? A resposta é simples: PLINQ tem que detectar por conta própria como particionar a coisa toda. Além disso, ele precisa descobrir como os dados estão relacionados entre si. Qual partição é independente etc. Conclusão: Sem dicas PLINQ cai.

Conclusão

Você pode não usar SSE/SIMD todos os dias, mas se você se deparar com essas coisas, ficará feliz em saber que essas coisas existem.

Recursos

  • Exemplo de código desta postagem de blog no Github
  • Todos os exemplos do meu blog em geral no Github